From cc3f148699baed8be98b569aa7ce36c25699ed17 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:21:52 +0000 Subject: [PATCH 01/58] build(deps)!: update Platform to v5.1-dev 063589bf Pin dash-sdk, rs-sdk-trusted-context-provider, platform-wallet and platform-wallet-storage to dashpay/platform v5.1-dev 063589bf (5.0.0-rc.8). The lockfile moves only the Platform packages. The upstream V019 wallet-storage migration adds a nullable asset-lock operation id; V001-V018 are unchanged. The compatibility bridge gets the 033ecae destination snapshot it compares against, and the pin guard records the new revision with an unchanged migration fingerprint. BREAKING CHANGE: the wallet storage upgrade to V019 is forward only. An older build cannot open upgraded storage; going back needs a copy of the data directory taken before the upgrade. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- Cargo.lock | 1052 ++++++++++------- Cargo.toml | 8 +- .../expected-migration-fingerprint.txt | 6 +- .../platform_compatibility/engine.rs | 3 +- .../platform_compatibility/engine/tests.rs | 47 + .../fixtures/033ecae.sql | 413 +++++++ .../platform_compatibility/fixtures/README.md | 17 +- 7 files changed, 1084 insertions(+), 462 deletions(-) create mode 100644 src/wallet_backend/platform_compatibility/fixtures/033ecae.sql diff --git a/Cargo.lock b/Cargo.lock index 00d31d4ed..3095615ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -146,7 +146,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ "crypto-common 0.1.7", - "generic-array 0.14.7", + "generic-array", ] [[package]] @@ -179,6 +179,7 @@ dependencies = [ "cipher 0.5.2", "cpubits", "cpufeatures 0.3.1", + "zeroize", ] [[package]] @@ -233,6 +234,18 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "ambassador" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd3d4da7fddbc6567cbd7b5c30364f91313701b1857ab5b925eb3acf06162eed" +dependencies = [ + "itertools 0.10.5", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "android-activity" version = "0.6.1" @@ -309,7 +322,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -320,7 +333,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -329,6 +342,17 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "app-connect-contract" +version = "5.0.0-rc.8" +source = "git+https://github.com/dashpay/platform?rev=063589bf0adbe1a8823a943cee95385b423b0e3f#063589bf0adbe1a8823a943cee95385b423b0e3f" +dependencies = [ + "platform-value", + "platform-version", + "serde_json", + "thiserror 2.0.20", +] + [[package]] name = "apple-native-keyring-store" version = "1.0.0" @@ -770,11 +794,21 @@ checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" [[package]] name = "base58ck" -version = "0.1.101" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "365c0acd5b2e8dd0111a46c4faea83fb3cfb6e39a49a7c73a06e090db7b2eff0" +checksum = "df540278d807651653f9b6f21e18903d790d7769eadfbc0dc4df0a141260318f" dependencies = [ - "bitcoin_hashes", + "bitcoin-internals 0.6.0", + "bitcoin_hashes 1.2.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "base58ck" +version = "0.5.0" +source = "git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516#7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516" +dependencies = [ + "bitcoin-internals 0.7.0 (git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516)", + "bitcoin_hashes 1.2.0 (git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516)", ] [[package]] @@ -795,15 +829,6 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" -[[package]] -name = "base64-compat" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a8d4d2746f89841e49230dd26917df1876050f95abafafbe34f47cb534b88d7" -dependencies = [ - "byteorder", -] - [[package]] name = "base64ct" version = "1.8.3" @@ -896,7 +921,7 @@ version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ - "bitcoin_hashes", + "bitcoin_hashes 0.14.101", "rand 0.8.8", "rand_core 0.6.4", "serde", @@ -937,27 +962,130 @@ checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" [[package]] name = "bitcoin-consensus-encoding" version = "1.2.0" +source = "git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516#7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516" +dependencies = [ + "bitcoin-internals 0.7.0 (git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516)", + "hex-conservative 1.3.0", + "serde", +] + +[[package]] +name = "bitcoin-consensus-encoding" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6712f9c6fd6785b3b270884e57c441c403dc5d7e19ca45368c97c7a1de3000ec" +checksum = "9daa31138eb443d5751b207f3f64154e2bb09cd59960562ccc7a7112be38147f" dependencies = [ - "bitcoin-internals", + "bitcoin-internals 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)", "hex-conservative 1.3.0", "serde", ] +[[package]] +name = "bitcoin-crypto" +version = "0.3.0" +source = "git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516#7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516" +dependencies = [ + "base58ck 0.5.0 (git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516)", + "bitcoin-internals 0.7.0 (git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516)", + "bitcoin-network-kind", + "bitcoin-primitives 0.103.1", + "bitcoin_hashes 1.2.0 (git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516)", + "hex-conservative 1.3.0", + "secp256k1", + "serde", +] + +[[package]] +name = "bitcoin-internals" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a30a22d1f112dde8e16be7b45c63645dc165cef254f835b3e1e9553e485cfa64" +dependencies = [ + "hex-conservative 0.3.2", +] + [[package]] name = "bitcoin-internals" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" +[[package]] +name = "bitcoin-internals" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8bea3a9f0cfece4564e37cb49a38cc245ca5184719e50d7d0dda3268722c4e2" + +[[package]] +name = "bitcoin-internals" +version = "0.7.0" +source = "git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516#7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516" +dependencies = [ + "serde", +] + [[package]] name = "bitcoin-io" version = "0.1.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" dependencies = [ - "bitcoin-consensus-encoding", + "bitcoin-consensus-encoding 1.3.0", +] + +[[package]] +name = "bitcoin-network-kind" +version = "1.0.0" +source = "git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516#7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516" +dependencies = [ + "bitcoin-internals 0.7.0 (git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516)", + "serde", +] + +[[package]] +name = "bitcoin-primitives" +version = "0.103.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82e12cb1814d037edbd667554c2c0e6b067db72c83ee935b645a6b5829aca908" +dependencies = [ + "bitcoin-consensus-encoding 1.3.0", + "bitcoin-internals 0.6.0", + "bitcoin-units 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)", + "bitcoin_hashes 1.2.0 (registry+https://github.com/rust-lang/crates.io-index)", + "hex-conservative 1.3.0", +] + +[[package]] +name = "bitcoin-primitives" +version = "0.103.1" +source = "git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516#7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516" +dependencies = [ + "bitcoin-consensus-encoding 1.2.0", + "bitcoin-internals 0.7.0 (git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516)", + "bitcoin-units 0.5.0 (git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516)", + "bitcoin_hashes 1.2.0 (git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516)", + "hex-conservative 1.3.0", +] + +[[package]] +name = "bitcoin-units" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae6dcb008158c2bdbe698470280439844be5f640310e4f5d6ef0a0209e066c2" +dependencies = [ + "bitcoin-consensus-encoding 1.3.0", + "bitcoin-internals 0.5.0", + "serde", +] + +[[package]] +name = "bitcoin-units" +version = "0.5.0" +source = "git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516#7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516" +dependencies = [ + "bitcoin-consensus-encoding 1.2.0", + "bitcoin-internals 0.7.0 (git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516)", + "serde", ] [[package]] @@ -968,6 +1096,29 @@ checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" dependencies = [ "bitcoin-io", "hex-conservative 0.2.3", + "serde", +] + +[[package]] +name = "bitcoin_hashes" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5304e53726dbe5f93141535e102ed97b5bf4714fbecefdda8f9fb98d7fdaff0e" +dependencies = [ + "bitcoin-consensus-encoding 1.3.0", + "bitcoin-internals 0.6.0", + "hex-conservative 1.3.0", +] + +[[package]] +name = "bitcoin_hashes" +version = "1.2.0" +source = "git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516#7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516" +dependencies = [ + "bitcoin-consensus-encoding 1.2.0", + "bitcoin-internals 0.7.0 (git+https://github.com/rust-bitcoin/rust-bitcoin?rev=7ba35c7c1dd63892bfb9c9395bbd80e2ab5df516)", + "hex-conservative 1.3.0", + "serde", ] [[package]] @@ -1044,7 +1195,7 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array 0.14.7", + "generic-array", ] [[package]] @@ -1062,7 +1213,7 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" dependencies = [ - "generic-array 0.14.7", + "generic-array", ] [[package]] @@ -1111,41 +1262,16 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7bc6d6292be3a19e6379786dac800f551e5865a5bb51ebbe3064ab80433f403" dependencies = [ - "ff", + "ff 0.13.1", "rand_core 0.6.4", "subtle", ] -[[package]] -name = "blsful" -version = "3.0.0" -source = "git+https://github.com/dashpay/agora-blsful?rev=0c34a7a488a0bd1c9a9a2196e793b303ad35c900#0c34a7a488a0bd1c9a9a2196e793b303ad35c900" -dependencies = [ - "anyhow", - "blstrs_plus", - "hex", - "hkdf", - "merlin", - "pairing", - "rand 0.8.8", - "rand_chacha", - "rand_core 0.6.4", - "serde", - "serde_bare", - "sha2 0.10.9", - "sha3", - "subtle", - "thiserror 2.0.20", - "uint-zigzag", - "vsss-rs", - "zeroize", -] - [[package]] name = "blst" -version = "0.3.12" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62dc83a094a71d43eeadd254b1ec2d24cb6a0bb6cadce00df51f0db594711a32" +checksum = "c20659f9bbee16cbbd2f7393e40ab6309f5a98f76a2eb57a995ec508b72387fe" dependencies = [ "cc", "glob", @@ -1153,24 +1279,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "blstrs_plus" -version = "0.8.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a16dd4b0d6b4538e1fa0388843acb186363082713a8fc8416d802a04d013818" -dependencies = [ - "arrayref", - "blst", - "elliptic-curve", - "ff", - "group", - "pairing", - "rand_core 0.6.4", - "serde", - "subtle", - "zeroize", -] - [[package]] name = "bs58" version = "0.5.1" @@ -1845,26 +1953,13 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" -[[package]] -name = "crypto-bigint" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" -dependencies = [ - "generic-array 0.14.7", - "rand_core 0.6.4", - "serdect", - "subtle", - "zeroize", -] - [[package]] name = "crypto-common" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "generic-array 0.14.7", + "generic-array", "rand_core 0.6.4", "typenum", ] @@ -1931,6 +2026,7 @@ dependencies = [ "curve25519-dalek-derive", "digest 0.11.3", "fiat-crypto 0.3.0", + "rand_core 0.10.1", "rustc_version", "subtle", "zeroize", @@ -1949,14 +2045,15 @@ dependencies = [ [[package]] name = "dapi-grpc" -version = "4.2.0-beta.3" -source = "git+https://github.com/dashpay/platform?rev=9f7ed16935bd540e4c2a542688800d2953f9f67a#9f7ed16935bd540e4c2a542688800d2953f9f67a" +version = "5.0.0-rc.8" +source = "git+https://github.com/dashpay/platform?rev=063589bf0adbe1a8823a943cee95385b423b0e3f#063589bf0adbe1a8823a943cee95385b423b0e3f" dependencies = [ "dash-platform-macros", "futures-core", "getrandom 0.2.17", "platform-version", "prost", + "prost-build", "serde", "serde_bytes", "serde_json", @@ -2054,8 +2151,8 @@ dependencies = [ [[package]] name = "dash-async" -version = "4.2.0-beta.3" -source = "git+https://github.com/dashpay/platform?rev=9f7ed16935bd540e4c2a542688800d2953f9f67a#9f7ed16935bd540e4c2a542688800d2953f9f67a" +version = "5.0.0-rc.8" +source = "git+https://github.com/dashpay/platform?rev=063589bf0adbe1a8823a943cee95385b423b0e3f#063589bf0adbe1a8823a943cee95385b423b0e3f" dependencies = [ "futures", "thiserror 2.0.20", @@ -2066,8 +2163,8 @@ dependencies = [ [[package]] name = "dash-context-provider" -version = "4.2.0-beta.3" -source = "git+https://github.com/dashpay/platform?rev=9f7ed16935bd540e4c2a542688800d2953f9f67a#9f7ed16935bd540e4c2a542688800d2953f9f67a" +version = "5.0.0-rc.8" +source = "git+https://github.com/dashpay/platform?rev=063589bf0adbe1a8823a943cee95385b423b0e3f#063589bf0adbe1a8823a943cee95385b423b0e3f" dependencies = [ "dash-async", "dpp", @@ -2160,8 +2257,8 @@ dependencies = [ [[package]] name = "dash-network" -version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +version = "0.46.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=aca020263990a86a15cc1d97ae2cbf965d488db5#aca020263990a86a15cc1d97ae2cbf965d488db5" dependencies = [ "cbindgen", "grovedb-bincode", @@ -2171,16 +2268,86 @@ dependencies = [ [[package]] name = "dash-network-seeds" -version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +version = "0.46.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=aca020263990a86a15cc1d97ae2cbf965d488db5#aca020263990a86a15cc1d97ae2cbf965d488db5" dependencies = [ "dash-network", ] +[[package]] +name = "dash-num" +version = "0.1.0-beta" +source = "git+https://github.com/dashpay/base-sdk?rev=33fcc59e1868c803f9608e0e78ee8bddf3abcee7#33fcc59e1868c803f9608e0e78ee8bddf3abcee7" +dependencies = [ + "ambassador", + "bitcoin-internals 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)", + "dash-types 0.1.0-beta (git+https://github.com/dashpay/base-sdk?rev=33fcc59e1868c803f9608e0e78ee8bddf3abcee7)", + "delegate", + "hex-conservative 1.3.0", +] + +[[package]] +name = "dash-num" +version = "0.1.0-beta" +source = "git+https://github.com/dashpay/base-sdk?rev=d9e0d241e2e06edff455f88c9cb3b9ce0d8a8ac5#d9e0d241e2e06edff455f88c9cb3b9ce0d8a8ac5" +dependencies = [ + "ambassador", + "bitcoin-internals 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)", + "dash-types 0.1.0-beta (git+https://github.com/dashpay/base-sdk?rev=d9e0d241e2e06edff455f88c9cb3b9ce0d8a8ac5)", + "delegate", + "hex-conservative 1.3.0", + "serde", +] + +[[package]] +name = "dash-pkc" +version = "0.1.0-beta" +source = "git+https://github.com/dashpay/base-sdk?rev=33fcc59e1868c803f9608e0e78ee8bddf3abcee7#33fcc59e1868c803f9608e0e78ee8bddf3abcee7" +dependencies = [ + "aes 0.9.3", + "bitcoin_hashes 1.2.0 (registry+https://github.com/rust-lang/crates.io-index)", + "blst", + "cfg-if", + "dash-num 0.1.0-beta (git+https://github.com/dashpay/base-sdk?rev=33fcc59e1868c803f9608e0e78ee8bddf3abcee7)", + "dash-types 0.1.0-beta (git+https://github.com/dashpay/base-sdk?rev=33fcc59e1868c803f9608e0e78ee8bddf3abcee7)", + "ff 0.14.0", + "group 0.14.0", + "hex-conservative 1.3.0", + "rand_core 0.10.1", + "sha2 0.11.0", + "subtle", + "zeroize", +] + +[[package]] +name = "dash-pkc" +version = "0.1.0-beta" +source = "git+https://github.com/dashpay/base-sdk?rev=d9e0d241e2e06edff455f88c9cb3b9ce0d8a8ac5#d9e0d241e2e06edff455f88c9cb3b9ce0d8a8ac5" +dependencies = [ + "aes 0.9.3", + "base58ck 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)", + "bitcoin_hashes 1.2.0 (registry+https://github.com/rust-lang/crates.io-index)", + "blst", + "cfg-if", + "dash-num 0.1.0-beta (git+https://github.com/dashpay/base-sdk?rev=d9e0d241e2e06edff455f88c9cb3b9ce0d8a8ac5)", + "dash-types 0.1.0-beta (git+https://github.com/dashpay/base-sdk?rev=d9e0d241e2e06edff455f88c9cb3b9ce0d8a8ac5)", + "ed25519-dalek 3.0.0", + "ff 0.14.0", + "group 0.14.0", + "hex-conservative 1.3.0", + "rand_core 0.10.1", + "secp256k1", + "serde", + "sha2 0.11.0", + "subtle", + "thiserror 2.0.20", + "zeroize", +] + [[package]] name = "dash-platform-macros" -version = "4.2.0-beta.3" -source = "git+https://github.com/dashpay/platform?rev=9f7ed16935bd540e4c2a542688800d2953f9f67a#9f7ed16935bd540e4c2a542688800d2953f9f67a" +version = "5.0.0-rc.8" +source = "git+https://github.com/dashpay/platform?rev=063589bf0adbe1a8823a943cee95385b423b0e3f#063589bf0adbe1a8823a943cee95385b423b0e3f" dependencies = [ "heck", "quote", @@ -2189,8 +2356,8 @@ dependencies = [ [[package]] name = "dash-platform-queries" -version = "4.2.0-beta.3" -source = "git+https://github.com/dashpay/platform?rev=9f7ed16935bd540e4c2a542688800d2953f9f67a#9f7ed16935bd540e4c2a542688800d2953f9f67a" +version = "5.0.0-rc.8" +source = "git+https://github.com/dashpay/platform?rev=063589bf0adbe1a8823a943cee95385b423b0e3f#063589bf0adbe1a8823a943cee95385b423b0e3f" dependencies = [ "dapi-grpc", "dash-context-provider", @@ -2207,8 +2374,8 @@ dependencies = [ [[package]] name = "dash-sdk" -version = "4.2.0-beta.3" -source = "git+https://github.com/dashpay/platform?rev=9f7ed16935bd540e4c2a542688800d2953f9f67a#9f7ed16935bd540e4c2a542688800d2953f9f67a" +version = "5.0.0-rc.8" +source = "git+https://github.com/dashpay/platform?rev=063589bf0adbe1a8823a943cee95385b423b0e3f#063589bf0adbe1a8823a943cee95385b423b0e3f" dependencies = [ "arc-swap", "async-trait", @@ -2246,8 +2413,8 @@ dependencies = [ [[package]] name = "dash-spv" -version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +version = "0.46.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=aca020263990a86a15cc1d97ae2cbf965d488db5#aca020263990a86a15cc1d97ae2cbf965d488db5" dependencies = [ "async-trait", "chrono", @@ -2257,10 +2424,10 @@ dependencies = [ "dashcore_hashes", "futures", "git-state", - "hex", + "hex-conservative 1.3.0", "key-wallet", "key-wallet-manager", - "rand 0.8.8", + "rand 0.9.5", "rayon", "serde", "serde_json", @@ -2273,44 +2440,90 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "dash-types" +version = "0.1.0-beta" +source = "git+https://github.com/dashpay/base-sdk?rev=33fcc59e1868c803f9608e0e78ee8bddf3abcee7#33fcc59e1868c803f9608e0e78ee8bddf3abcee7" +dependencies = [ + "cfg-if", + "hex-conservative 1.3.0", + "subtle", + "zeroize", +] + +[[package]] +name = "dash-types" +version = "0.1.0-beta" +source = "git+https://github.com/dashpay/base-sdk?rev=d9e0d241e2e06edff455f88c9cb3b9ce0d8a8ac5#d9e0d241e2e06edff455f88c9cb3b9ce0d8a8ac5" +dependencies = [ + "bitcoin-consensus-encoding 1.3.0", + "bitcoin-primitives 0.103.0", + "cfg-if", + "hex-conservative 1.3.0", + "serde", + "subtle", + "thiserror 2.0.20", + "zeroize", +] + [[package]] name = "dashcore" -version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +version = "0.46.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=aca020263990a86a15cc1d97ae2cbf965d488db5#aca020263990a86a15cc1d97ae2cbf965d488db5" dependencies = [ "anyhow", - "base64-compat", + "base64 0.22.1", "bech32 0.9.1", "bitvec", "blake3", - "blsful", "dash-network", + "dash-num 0.1.0-beta (git+https://github.com/dashpay/base-sdk?rev=d9e0d241e2e06edff455f88c9cb3b9ce0d8a8ac5)", + "dash-types 0.1.0-beta (git+https://github.com/dashpay/base-sdk?rev=d9e0d241e2e06edff455f88c9cb3b9ce0d8a8ac5)", + "dashcore-crypto", "dashcore-private", "dashcore_hashes", - "ed25519-dalek 2.2.0", "grovedb-bincode", "grovedb-bincode-derive", - "hex", - "hex_lit", - "rustversion", - "secp256k1", + "hex-conservative 1.3.0", "serde", "thiserror 2.0.20", "tracing", ] +[[package]] +name = "dashcore-crypto" +version = "0.46.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=aca020263990a86a15cc1d97ae2cbf965d488db5#aca020263990a86a15cc1d97ae2cbf965d488db5" +dependencies = [ + "base58ck 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)", + "bitcoin-crypto", + "dash-network", + "dash-pkc 0.1.0-beta (git+https://github.com/dashpay/base-sdk?rev=d9e0d241e2e06edff455f88c9cb3b9ce0d8a8ac5)", + "dash-types 0.1.0-beta (git+https://github.com/dashpay/base-sdk?rev=d9e0d241e2e06edff455f88c9cb3b9ce0d8a8ac5)", + "dashcore-private", + "dashcore_hashes", + "delegate", + "grovedb-bincode", + "grovedb-bincode-derive", + "hex-conservative 1.3.0", + "serde", + "thiserror 2.0.20", + "tracing", + "zeroize", +] + [[package]] name = "dashcore-private" -version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +version = "0.46.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=aca020263990a86a15cc1d97ae2cbf965d488db5#aca020263990a86a15cc1d97ae2cbf965d488db5" [[package]] name = "dashcore-rpc" -version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +version = "0.46.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=aca020263990a86a15cc1d97ae2cbf965d488db5#aca020263990a86a15cc1d97ae2cbf965d488db5" dependencies = [ "dashcore-rpc-json", - "hex", + "hex-conservative 1.3.0", "jsonrpc", "serde", "serde_json", @@ -2319,12 +2532,12 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" -version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +version = "0.46.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=aca020263990a86a15cc1d97ae2cbf965d488db5#aca020263990a86a15cc1d97ae2cbf965d488db5" dependencies = [ "dashcore", "grovedb-bincode", - "hex", + "hex-conservative 1.3.0", "key-wallet", "serde", "serde_json", @@ -2334,19 +2547,20 @@ dependencies = [ [[package]] name = "dashcore_hashes" -version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +version = "0.46.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=aca020263990a86a15cc1d97ae2cbf965d488db5#aca020263990a86a15cc1d97ae2cbf965d488db5" dependencies = [ - "dashcore-private", + "bitcoin_hashes 0.14.101", "grovedb-bincode", + "hex-conservative 1.3.0", "rs-x11-hash", "serde", ] [[package]] name = "dashpay-contract" -version = "4.2.0-beta.3" -source = "git+https://github.com/dashpay/platform?rev=9f7ed16935bd540e4c2a542688800d2953f9f67a#9f7ed16935bd540e4c2a542688800d2953f9f67a" +version = "5.0.0-rc.8" +source = "git+https://github.com/dashpay/platform?rev=063589bf0adbe1a8823a943cee95385b423b0e3f#063589bf0adbe1a8823a943cee95385b423b0e3f" dependencies = [ "platform-value", "platform-version", @@ -2356,14 +2570,16 @@ dependencies = [ [[package]] name = "data-contracts" -version = "4.2.0-beta.3" -source = "git+https://github.com/dashpay/platform?rev=9f7ed16935bd540e4c2a542688800d2953f9f67a#9f7ed16935bd540e4c2a542688800d2953f9f67a" +version = "5.0.0-rc.8" +source = "git+https://github.com/dashpay/platform?rev=063589bf0adbe1a8823a943cee95385b423b0e3f#063589bf0adbe1a8823a943cee95385b423b0e3f" dependencies = [ + "app-connect-contract", "dashpay-contract", "document-history-contract", "dpns-contract", "keyword-search-contract", "masternode-reward-shares-contract", + "moderation-charters-contract", "platform-value", "platform-version", "serde_json", @@ -2450,6 +2666,17 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "delegate" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "der" version = "0.7.10" @@ -2606,7 +2833,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2658,8 +2885,8 @@ dependencies = [ [[package]] name = "document-history-contract" -version = "4.2.0-beta.3" -source = "git+https://github.com/dashpay/platform?rev=9f7ed16935bd540e4c2a542688800d2953f9f67a#9f7ed16935bd540e4c2a542688800d2953f9f67a" +version = "5.0.0-rc.8" +source = "git+https://github.com/dashpay/platform?rev=063589bf0adbe1a8823a943cee95385b423b0e3f#063589bf0adbe1a8823a943cee95385b423b0e3f" dependencies = [ "platform-value", "platform-version", @@ -2687,8 +2914,8 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "dpns-contract" -version = "4.2.0-beta.3" -source = "git+https://github.com/dashpay/platform?rev=9f7ed16935bd540e4c2a542688800d2953f9f67a#9f7ed16935bd540e4c2a542688800d2953f9f67a" +version = "5.0.0-rc.8" +source = "git+https://github.com/dashpay/platform?rev=063589bf0adbe1a8823a943cee95385b423b0e3f#063589bf0adbe1a8823a943cee95385b423b0e3f" dependencies = [ "platform-value", "platform-version", @@ -2698,26 +2925,31 @@ dependencies = [ [[package]] name = "dpp" -version = "4.2.0-beta.3" -source = "git+https://github.com/dashpay/platform?rev=9f7ed16935bd540e4c2a542688800d2953f9f67a#9f7ed16935bd540e4c2a542688800d2953f9f67a" +version = "5.0.0-rc.8" +source = "git+https://github.com/dashpay/platform?rev=063589bf0adbe1a8823a943cee95385b423b0e3f#063589bf0adbe1a8823a943cee95385b423b0e3f" dependencies = [ "anyhow", "async-trait", "base64 0.22.1", "bech32 0.11.1", + "blst", "bs58", "byteorder", "chrono", "chrono-tz", "ciborium", + "dash-pkc 0.1.0-beta (git+https://github.com/dashpay/base-sdk?rev=33fcc59e1868c803f9608e0e78ee8bddf3abcee7)", "dash-spv", "dashcore", "dashcore-rpc", "data-contracts", "derive_more 1.0.0", "dpp-json-convertible-derive", + "ed25519-dalek 2.2.0", "env_logger", "getrandom 0.2.17", + "getrandom 0.3.4", + "getrandom 0.4.3", "grovedb-bincode", "grovedb-commitment-tree", "hex", @@ -2738,6 +2970,7 @@ dependencies = [ "platform-versioning", "rand 0.8.8", "regex", + "secp256k1", "serde", "serde_json", "serde_repr", @@ -2745,12 +2978,13 @@ dependencies = [ "strum", "thiserror 2.0.20", "tracing", + "zeroize", ] [[package]] name = "dpp-json-convertible-derive" -version = "4.2.0-beta.3" -source = "git+https://github.com/dashpay/platform?rev=9f7ed16935bd540e4c2a542688800d2953f9f67a#9f7ed16935bd540e4c2a542688800d2953f9f67a" +version = "5.0.0-rc.8" +source = "git+https://github.com/dashpay/platform?rev=063589bf0adbe1a8823a943cee95385b423b0e3f#063589bf0adbe1a8823a943cee95385b423b0e3f" dependencies = [ "proc-macro2", "quote", @@ -2759,19 +2993,20 @@ dependencies = [ [[package]] name = "drive" -version = "4.2.0-beta.3" -source = "git+https://github.com/dashpay/platform?rev=9f7ed16935bd540e4c2a542688800d2953f9f67a#9f7ed16935bd540e4c2a542688800d2953f9f67a" +version = "5.0.0-rc.8" +source = "git+https://github.com/dashpay/platform?rev=063589bf0adbe1a8823a943cee95385b423b0e3f#063589bf0adbe1a8823a943cee95385b423b0e3f" dependencies = [ "byteorder", "derive_more 1.0.0", "dpp", - "grovedb 6.0.1", + "grovedb 6.1.1", "grovedb-bincode", - "grovedb-costs 6.0.1", + "grovedb-costs 6.1.1", "grovedb-epoch-based-storage-flags", - "grovedb-path 6.0.1", + "grovedb-merk 6.1.1", + "grovedb-path 6.1.1", "grovedb-query", - "grovedb-version 6.0.1", + "grovedb-version 6.1.1", "hex", "indexmap 2.14.2", "integer-encoding", @@ -2785,8 +3020,8 @@ dependencies = [ [[package]] name = "drive-proof-verifier" -version = "4.2.0-beta.3" -source = "git+https://github.com/dashpay/platform?rev=9f7ed16935bd540e4c2a542688800d2953f9f67a#9f7ed16935bd540e4c2a542688800d2953f9f67a" +version = "5.0.0-rc.8" +source = "git+https://github.com/dashpay/platform?rev=063589bf0adbe1a8823a943cee95385b423b0e3f#063589bf0adbe1a8823a943cee95385b423b0e3f" dependencies = [ "dapi-grpc", "dash-context-provider", @@ -2892,7 +3127,9 @@ checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ "curve25519-dalek 5.0.0", "ed25519 3.0.0", + "rand_core 0.10.1", "sha2 0.11.0", + "signature 3.0.0", "subtle", "zeroize", ] @@ -3072,41 +3309,6 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" -[[package]] -name = "elliptic-curve" -version = "0.13.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" -dependencies = [ - "base16ct", - "crypto-bigint", - "digest 0.10.7", - "ff", - "generic-array 0.14.7", - "group", - "hkdf", - "pkcs8", - "rand_core 0.6.4", - "sec1", - "subtle", - "tap", - "zeroize", -] - -[[package]] -name = "elliptic-curve-tools" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1de2b6fae800f08032a6ea32995b52925b1d451bff9d445c8ab2932323277faf" -dependencies = [ - "elliptic-curve", - "heapless 0.8.0", - "hex", - "multiexp", - "serde", - "zeroize", -] - [[package]] name = "emath" version = "0.36.2" @@ -3305,7 +3507,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3415,6 +3617,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -3733,18 +3945,6 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", - "zeroize", -] - -[[package]] -name = "generic-array" -version = "1.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e" -dependencies = [ - "rustversion", - "serde_core", - "typenum", ] [[package]] @@ -3777,9 +3977,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -3828,8 +4030,8 @@ dependencies = [ [[package]] name = "git-state" -version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +version = "0.46.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=aca020263990a86a15cc1d97ae2cbf965d488db5#aca020263990a86a15cc1d97ae2cbf965d488db5" [[package]] name = "gl_generator" @@ -3974,11 +4176,20 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ - "ff", + "ff 0.13.1", "memuse", - "rand 0.8.8", "rand_core 0.6.4", - "rand_xorshift", + "subtle", +] + +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff 0.14.0", + "rand_core 0.10.1", "subtle", ] @@ -4006,21 +4217,21 @@ dependencies = [ [[package]] name = "grovedb" -version = "6.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=74818ceb95805f56f141d24ca41f58b75c031a7f#74818ceb95805f56f141d24ca41f58b75c031a7f" +version = "6.1.1" +source = "git+https://github.com/dashpay/grovedb?rev=2128ea18f9b1c2f9502f99967b1b1c6485c91f8b#2128ea18f9b1c2f9502f99967b1b1c6485c91f8b" dependencies = [ "blake3", "grovedb-bincode", "grovedb-bincode-derive", "grovedb-bulk-append-tree", - "grovedb-costs 6.0.1", + "grovedb-costs 6.1.1", "grovedb-dense-fixed-sized-merkle-tree", - "grovedb-element 6.0.1", - "grovedb-merk 6.0.1", + "grovedb-element 6.1.1", + "grovedb-merk 6.1.1", "grovedb-merkle-mountain-range", - "grovedb-path 6.0.1", + "grovedb-path 6.1.1", "grovedb-query", - "grovedb-version 6.0.1", + "grovedb-version 6.1.1", "hex", "indexmap 2.14.2", "integer-encoding", @@ -4049,16 +4260,16 @@ dependencies = [ [[package]] name = "grovedb-bulk-append-tree" -version = "6.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=74818ceb95805f56f141d24ca41f58b75c031a7f#74818ceb95805f56f141d24ca41f58b75c031a7f" +version = "6.1.1" +source = "git+https://github.com/dashpay/grovedb?rev=2128ea18f9b1c2f9502f99967b1b1c6485c91f8b#2128ea18f9b1c2f9502f99967b1b1c6485c91f8b" dependencies = [ "blake3", "grovedb-bincode", - "grovedb-costs 6.0.1", + "grovedb-costs 6.1.1", "grovedb-dense-fixed-sized-merkle-tree", "grovedb-merkle-mountain-range", "grovedb-query", - "grovedb-version 6.0.1", + "grovedb-version 6.1.1", "hex", "integer-encoding", "thiserror 2.0.20", @@ -4066,13 +4277,13 @@ dependencies = [ [[package]] name = "grovedb-commitment-tree" -version = "6.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=74818ceb95805f56f141d24ca41f58b75c031a7f#74818ceb95805f56f141d24ca41f58b75c031a7f" +version = "6.1.1" +source = "git+https://github.com/dashpay/grovedb?rev=2128ea18f9b1c2f9502f99967b1b1c6485c91f8b#2128ea18f9b1c2f9502f99967b1b1c6485c91f8b" dependencies = [ "blake3", "grovedb-bulk-append-tree", - "grovedb-costs 6.0.1", - "grovedb-version 6.0.1", + "grovedb-costs 6.1.1", + "grovedb-version 6.1.1", "incrementalmerkletree", "orchard", "rusqlite", @@ -4092,8 +4303,8 @@ dependencies = [ [[package]] name = "grovedb-costs" -version = "6.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=74818ceb95805f56f141d24ca41f58b75c031a7f#74818ceb95805f56f141d24ca41f58b75c031a7f" +version = "6.1.1" +source = "git+https://github.com/dashpay/grovedb?rev=2128ea18f9b1c2f9502f99967b1b1c6485c91f8b#2128ea18f9b1c2f9502f99967b1b1c6485c91f8b" dependencies = [ "integer-encoding", "intmap", @@ -4102,14 +4313,14 @@ dependencies = [ [[package]] name = "grovedb-dense-fixed-sized-merkle-tree" -version = "6.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=74818ceb95805f56f141d24ca41f58b75c031a7f#74818ceb95805f56f141d24ca41f58b75c031a7f" +version = "6.1.1" +source = "git+https://github.com/dashpay/grovedb?rev=2128ea18f9b1c2f9502f99967b1b1c6485c91f8b#2128ea18f9b1c2f9502f99967b1b1c6485c91f8b" dependencies = [ "blake3", "grovedb-bincode", - "grovedb-costs 6.0.1", + "grovedb-costs 6.1.1", "grovedb-query", - "grovedb-version 6.0.1", + "grovedb-version 6.1.1", "thiserror 2.0.20", ] @@ -4129,14 +4340,14 @@ dependencies = [ [[package]] name = "grovedb-element" -version = "6.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=74818ceb95805f56f141d24ca41f58b75c031a7f#74818ceb95805f56f141d24ca41f58b75c031a7f" +version = "6.1.1" +source = "git+https://github.com/dashpay/grovedb?rev=2128ea18f9b1c2f9502f99967b1b1c6485c91f8b#2128ea18f9b1c2f9502f99967b1b1c6485c91f8b" dependencies = [ "grovedb-bincode", "grovedb-bincode-derive", - "grovedb-path 6.0.1", + "grovedb-path 6.1.1", "grovedb-query", - "grovedb-version 6.0.1", + "grovedb-version 6.1.1", "hex", "integer-encoding", "thiserror 2.0.20", @@ -4144,10 +4355,10 @@ dependencies = [ [[package]] name = "grovedb-epoch-based-storage-flags" -version = "6.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=74818ceb95805f56f141d24ca41f58b75c031a7f#74818ceb95805f56f141d24ca41f58b75c031a7f" +version = "6.1.1" +source = "git+https://github.com/dashpay/grovedb?rev=2128ea18f9b1c2f9502f99967b1b1c6485c91f8b#2128ea18f9b1c2f9502f99967b1b1c6485c91f8b" dependencies = [ - "grovedb-costs 6.0.1", + "grovedb-costs 6.1.1", "hex", "integer-encoding", "intmap", @@ -4177,20 +4388,20 @@ dependencies = [ [[package]] name = "grovedb-merk" -version = "6.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=74818ceb95805f56f141d24ca41f58b75c031a7f#74818ceb95805f56f141d24ca41f58b75c031a7f" +version = "6.1.1" +source = "git+https://github.com/dashpay/grovedb?rev=2128ea18f9b1c2f9502f99967b1b1c6485c91f8b#2128ea18f9b1c2f9502f99967b1b1c6485c91f8b" dependencies = [ "blake3", "byteorder", "ed", "grovedb-bincode", "grovedb-bincode-derive", - "grovedb-costs 6.0.1", - "grovedb-element 6.0.1", - "grovedb-path 6.0.1", + "grovedb-costs 6.1.1", + "grovedb-element 6.1.1", + "grovedb-path 6.1.1", "grovedb-query", - "grovedb-version 6.0.1", - "grovedb-visualize 6.0.1", + "grovedb-version 6.1.1", + "grovedb-visualize 6.1.1", "hex", "indexmap 2.14.2", "integer-encoding", @@ -4199,13 +4410,13 @@ dependencies = [ [[package]] name = "grovedb-merkle-mountain-range" -version = "6.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=74818ceb95805f56f141d24ca41f58b75c031a7f#74818ceb95805f56f141d24ca41f58b75c031a7f" +version = "6.1.1" +source = "git+https://github.com/dashpay/grovedb?rev=2128ea18f9b1c2f9502f99967b1b1c6485c91f8b#2128ea18f9b1c2f9502f99967b1b1c6485c91f8b" dependencies = [ "blake3", "grovedb-bincode", - "grovedb-costs 6.0.1", - "grovedb-version 6.0.1", + "grovedb-costs 6.1.1", + "grovedb-version 6.1.1", ] [[package]] @@ -4218,16 +4429,16 @@ dependencies = [ [[package]] name = "grovedb-path" -version = "6.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=74818ceb95805f56f141d24ca41f58b75c031a7f#74818ceb95805f56f141d24ca41f58b75c031a7f" +version = "6.1.1" +source = "git+https://github.com/dashpay/grovedb?rev=2128ea18f9b1c2f9502f99967b1b1c6485c91f8b#2128ea18f9b1c2f9502f99967b1b1c6485c91f8b" dependencies = [ "hex", ] [[package]] name = "grovedb-query" -version = "6.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=74818ceb95805f56f141d24ca41f58b75c031a7f#74818ceb95805f56f141d24ca41f58b75c031a7f" +version = "6.1.1" +source = "git+https://github.com/dashpay/grovedb?rev=2128ea18f9b1c2f9502f99967b1b1c6485c91f8b#2128ea18f9b1c2f9502f99967b1b1c6485c91f8b" dependencies = [ "byteorder", "ed", @@ -4249,8 +4460,8 @@ dependencies = [ [[package]] name = "grovedb-version" -version = "6.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=74818ceb95805f56f141d24ca41f58b75c031a7f#74818ceb95805f56f141d24ca41f58b75c031a7f" +version = "6.1.1" +source = "git+https://github.com/dashpay/grovedb?rev=2128ea18f9b1c2f9502f99967b1b1c6485c91f8b#2128ea18f9b1c2f9502f99967b1b1c6485c91f8b" dependencies = [ "thiserror 2.0.20", "versioned-feature-core", @@ -4267,8 +4478,8 @@ dependencies = [ [[package]] name = "grovedb-visualize" -version = "6.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=74818ceb95805f56f141d24ca41f58b75c031a7f#74818ceb95805f56f141d24ca41f58b75c031a7f" +version = "6.1.1" +source = "git+https://github.com/dashpay/grovedb?rev=2128ea18f9b1c2f9502f99967b1b1c6485c91f8b#2128ea18f9b1c2f9502f99967b1b1c6485c91f8b" dependencies = [ "hex", "itertools 0.14.0", @@ -4359,8 +4570,8 @@ checksum = "fb2a697cad929f706b7987fe804ad57d43622cd37463ba7e4d662a926fdcfea3" dependencies = [ "arrayvec", "bitvec", - "ff", - "group", + "ff 0.13.1", + "group 0.13.0", "halo2_poseidon", "halo2_proofs", "lazy_static", @@ -4384,8 +4595,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa3da60b81f02f9b33ebc6252d766f843291fb4d2247a07ae73d20b791fc56f" dependencies = [ "bitvec", - "ff", - "group", + "ff 0.13.1", + "group 0.13.0", "pasta_curves", ] @@ -4396,8 +4607,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f5aca1c66059a919227dec97444a11a4350d2f9c820ca48690988f0aa0e81cbf" dependencies = [ "blake2b_simd", - "ff", - "group", + "ff 0.13.1", + "group 0.13.0", "halo2_legacy_pdqsort", "indexmap 1.9.3", "maybe-rayon", @@ -4427,15 +4638,6 @@ dependencies = [ "byteorder", ] -[[package]] -name = "hash32" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" -dependencies = [ - "byteorder", -] - [[package]] name = "hashbrown" version = "0.12.3" @@ -4487,20 +4689,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" dependencies = [ "atomic-polyfill", - "hash32 0.2.1", + "hash32", "rustc_version", "serde", - "spin 0.9.9", - "stable_deref_trait", -] - -[[package]] -name = "heapless" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" -dependencies = [ - "hash32 0.3.1", + "spin", "stable_deref_trait", ] @@ -4521,9 +4713,6 @@ name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -dependencies = [ - "serde", -] [[package]] name = "hex-conservative" @@ -4534,6 +4723,15 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "hex-conservative" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830e599c2904b08f0834ee6337d8fe8f0ed4a63b5d9e7a7f49c0ffa06d08d360" +dependencies = [ + "arrayvec", +] + [[package]] name = "hex-conservative" version = "1.3.0" @@ -4549,12 +4747,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" -[[package]] -name = "hex_lit" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" - [[package]] name = "hkdf" version = "0.12.4" @@ -4951,7 +5143,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ "block-padding 0.3.3", - "generic-array 0.14.7", + "generic-array", ] [[package]] @@ -5168,8 +5360,8 @@ checksum = "8499f7a74008aafbecb2a2e608a3e13e4dd3e84df198b604451efe93f2de6e61" dependencies = [ "bitvec", "bls12_381", - "ff", - "group", + "ff 0.13.1", + "group 0.13.0", "rand_core 0.6.4", "subtle", ] @@ -5185,22 +5377,19 @@ dependencies = [ [[package]] name = "key-wallet" -version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +version = "0.46.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=aca020263990a86a15cc1d97ae2cbf965d488db5#aca020263990a86a15cc1d97ae2cbf965d488db5" dependencies = [ "async-trait", - "base58ck", "bip39", "bitflags 2.13.2", "dashcore", "dashcore-private", "dashcore_hashes", - "getrandom 0.2.17", - "hex", - "secp256k1", + "getrandom 0.4.3", + "hex-conservative 1.3.0", "serde", "serde_json", - "sha2 0.10.9", "tracing", "unicode-normalization", "zeroize", @@ -5208,11 +5397,12 @@ dependencies = [ [[package]] name = "key-wallet-manager" -version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e4208c90786a6854bd498315bcb571ef24182c15#e4208c90786a6854bd498315bcb571ef24182c15" +version = "0.46.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=aca020263990a86a15cc1d97ae2cbf965d488db5#aca020263990a86a15cc1d97ae2cbf965d488db5" dependencies = [ "async-trait", "dashcore", + "hex-conservative 1.3.0", "key-wallet", "rayon", "serde", @@ -5232,8 +5422,8 @@ dependencies = [ [[package]] name = "keyword-search-contract" -version = "4.2.0-beta.3" -source = "git+https://github.com/dashpay/platform?rev=9f7ed16935bd540e4c2a542688800d2953f9f67a#9f7ed16935bd540e4c2a542688800d2953f9f67a" +version = "5.0.0-rc.8" +source = "git+https://github.com/dashpay/platform?rev=063589bf0adbe1a8823a943cee95385b423b0e3f#063589bf0adbe1a8823a943cee95385b423b0e3f" dependencies = [ "platform-value", "platform-version", @@ -5286,7 +5476,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin 0.9.9", + "spin", ] [[package]] @@ -5427,8 +5617,8 @@ dependencies = [ [[package]] name = "masternode-reward-shares-contract" -version = "4.2.0-beta.3" -source = "git+https://github.com/dashpay/platform?rev=9f7ed16935bd540e4c2a542688800d2953f9f67a#9f7ed16935bd540e4c2a542688800d2953f9f67a" +version = "5.0.0-rc.8" +source = "git+https://github.com/dashpay/platform?rev=063589bf0adbe1a8823a943cee95385b423b0e3f#063589bf0adbe1a8823a943cee95385b423b0e3f" dependencies = [ "platform-value", "platform-version", @@ -5579,6 +5769,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moderation-charters-contract" +version = "5.0.0-rc.8" +source = "git+https://github.com/dashpay/platform?rev=063589bf0adbe1a8823a943cee95385b423b0e3f#063589bf0adbe1a8823a943cee95385b423b0e3f" +dependencies = [ + "platform-value", + "platform-version", + "serde_json", + "thiserror 2.0.20", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -5589,20 +5790,6 @@ dependencies = [ "pxfm", ] -[[package]] -name = "multiexp" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ec2ce93a6f06ac6cae04c1da3f2a6a24fcfc1f0eb0b4e0f3d302f0df45326cb" -dependencies = [ - "ff", - "group", - "rand_core 0.6.4", - "rustversion", - "std-shims", - "zeroize", -] - [[package]] name = "multimap" version = "0.10.1" @@ -5799,7 +5986,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5824,8 +6011,6 @@ checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", - "rand 0.8.8", - "serde", ] [[package]] @@ -5835,8 +6020,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ "num-traits", - "rand 0.8.8", - "serde", ] [[package]] @@ -5884,7 +6067,6 @@ dependencies = [ "num-bigint", "num-integer", "num-traits", - "serde", ] [[package]] @@ -6363,10 +6545,10 @@ dependencies = [ "bitvec", "blake2b_simd", "corez", - "ff", + "ff 0.13.1", "fpe", "getset", - "group", + "group 0.13.0", "halo2_gadgets", "halo2_poseidon", "halo2_proofs", @@ -6427,15 +6609,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "pairing" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81fec4625e73cf41ef4bb6846cafa6d44736525f442ba45e407c4a000a13996f" -dependencies = [ - "group", -] - [[package]] name = "parking" version = "2.2.1" @@ -6502,8 +6675,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3437083215c505e867eea5478371feba43d7689d6d15ec0a209eb46fb0d4cda6" dependencies = [ "blake2b_simd", - "ff", - "group", + "ff 0.13.1", + "group 0.13.0", "lazy_static", "rand 0.8.8", "static_assertions", @@ -6733,8 +6906,8 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "platform-encryption" -version = "4.2.0-beta.3" -source = "git+https://github.com/dashpay/platform?rev=9f7ed16935bd540e4c2a542688800d2953f9f67a#9f7ed16935bd540e4c2a542688800d2953f9f67a" +version = "5.0.0-rc.8" +source = "git+https://github.com/dashpay/platform?rev=063589bf0adbe1a8823a943cee95385b423b0e3f#063589bf0adbe1a8823a943cee95385b423b0e3f" dependencies = [ "aes 0.8.4", "cbc 0.1.2", @@ -6746,8 +6919,8 @@ dependencies = [ [[package]] name = "platform-serialization" -version = "4.2.0-beta.3" -source = "git+https://github.com/dashpay/platform?rev=9f7ed16935bd540e4c2a542688800d2953f9f67a#9f7ed16935bd540e4c2a542688800d2953f9f67a" +version = "5.0.0-rc.8" +source = "git+https://github.com/dashpay/platform?rev=063589bf0adbe1a8823a943cee95385b423b0e3f#063589bf0adbe1a8823a943cee95385b423b0e3f" dependencies = [ "grovedb-bincode", "platform-version", @@ -6755,8 +6928,8 @@ dependencies = [ [[package]] name = "platform-serialization-derive" -version = "4.2.0-beta.3" -source = "git+https://github.com/dashpay/platform?rev=9f7ed16935bd540e4c2a542688800d2953f9f67a#9f7ed16935bd540e4c2a542688800d2953f9f67a" +version = "5.0.0-rc.8" +source = "git+https://github.com/dashpay/platform?rev=063589bf0adbe1a8823a943cee95385b423b0e3f#063589bf0adbe1a8823a943cee95385b423b0e3f" dependencies = [ "proc-macro2", "quote", @@ -6766,8 +6939,8 @@ dependencies = [ [[package]] name = "platform-value" -version = "4.2.0-beta.3" -source = "git+https://github.com/dashpay/platform?rev=9f7ed16935bd540e4c2a542688800d2953f9f67a#9f7ed16935bd540e4c2a542688800d2953f9f67a" +version = "5.0.0-rc.8" +source = "git+https://github.com/dashpay/platform?rev=063589bf0adbe1a8823a943cee95385b423b0e3f#063589bf0adbe1a8823a943cee95385b423b0e3f" dependencies = [ "base64 0.22.1", "bs58", @@ -6786,19 +6959,19 @@ dependencies = [ [[package]] name = "platform-version" -version = "4.2.0-beta.3" -source = "git+https://github.com/dashpay/platform?rev=9f7ed16935bd540e4c2a542688800d2953f9f67a#9f7ed16935bd540e4c2a542688800d2953f9f67a" +version = "5.0.0-rc.8" +source = "git+https://github.com/dashpay/platform?rev=063589bf0adbe1a8823a943cee95385b423b0e3f#063589bf0adbe1a8823a943cee95385b423b0e3f" dependencies = [ "grovedb-bincode", - "grovedb-version 6.0.1", + "grovedb-version 6.1.1", "thiserror 2.0.20", "versioned-feature-core", ] [[package]] name = "platform-versioning" -version = "4.2.0-beta.3" -source = "git+https://github.com/dashpay/platform?rev=9f7ed16935bd540e4c2a542688800d2953f9f67a#9f7ed16935bd540e4c2a542688800d2953f9f67a" +version = "5.0.0-rc.8" +source = "git+https://github.com/dashpay/platform?rev=063589bf0adbe1a8823a943cee95385b423b0e3f#063589bf0adbe1a8823a943cee95385b423b0e3f" dependencies = [ "proc-macro2", "quote", @@ -6807,8 +6980,8 @@ dependencies = [ [[package]] name = "platform-wallet" -version = "4.2.0-beta.3" -source = "git+https://github.com/dashpay/platform?rev=9f7ed16935bd540e4c2a542688800d2953f9f67a#9f7ed16935bd540e4c2a542688800d2953f9f67a" +version = "5.0.0-rc.8" +source = "git+https://github.com/dashpay/platform?rev=063589bf0adbe1a8823a943cee95385b423b0e3f#063589bf0adbe1a8823a943cee95385b423b0e3f" dependencies = [ "arc-swap", "async-trait", @@ -6841,8 +7014,8 @@ dependencies = [ [[package]] name = "platform-wallet-storage" -version = "4.2.0-beta.3" -source = "git+https://github.com/dashpay/platform?rev=9f7ed16935bd540e4c2a542688800d2953f9f67a#9f7ed16935bd540e4c2a542688800d2953f9f67a" +version = "5.0.0-rc.8" +source = "git+https://github.com/dashpay/platform?rev=063589bf0adbe1a8823a943cee95385b423b0e3f#063589bf0adbe1a8823a943cee95385b423b0e3f" dependencies = [ "apple-native-keyring-store", "argon2 0.5.3", @@ -7002,7 +7175,7 @@ dependencies = [ "cobs", "embedded-io 0.4.0", "embedded-io 0.6.1", - "heapless 0.7.17", + "heapless", "serde", ] @@ -7097,7 +7270,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ "heck", - "itertools 0.14.0", + "itertools 0.10.5", "log", "multimap", "petgraph", @@ -7118,7 +7291,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.119", @@ -7237,7 +7410,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7274,10 +7447,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", - "rand_chacha", + "rand_chacha 0.3.1", "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.2" @@ -7299,6 +7482,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -7308,6 +7501,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_core" version = "0.10.1" @@ -7323,15 +7525,6 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "rand_xorshift" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d25bf25ec5ae4a3f1b92f929810509a2f53d7dca2f50b794ff57e3face536c8f" -dependencies = [ - "rand_core 0.6.4", -] - [[package]] name = "range-alloc" version = "0.1.5" @@ -7405,7 +7598,7 @@ dependencies = [ "blake2b_simd", "byteorder", "frost-rerandomized", - "group", + "group 0.13.0", "hex", "jubjub", "pasta_curves", @@ -7795,8 +7988,8 @@ dependencies = [ [[package]] name = "rs-dapi-client" -version = "4.2.0-beta.3" -source = "git+https://github.com/dashpay/platform?rev=9f7ed16935bd540e4c2a542688800d2953f9f67a#9f7ed16935bd540e4c2a542688800d2953f9f67a" +version = "5.0.0-rc.8" +source = "git+https://github.com/dashpay/platform?rev=063589bf0adbe1a8823a943cee95385b423b0e3f#063589bf0adbe1a8823a943cee95385b423b0e3f" dependencies = [ "backon", "chrono", @@ -7807,6 +8000,7 @@ dependencies = [ "hex", "http", "http-serde", + "hyper-util", "lru", "rand 0.8.8", "serde", @@ -7814,29 +8008,35 @@ dependencies = [ "sha2 0.10.9", "thiserror 2.0.20", "tokio", + "tokio-socks", "tonic-web-wasm-client", + "tower-service", "tracing", "wasm-bindgen-futures", ] [[package]] name = "rs-sdk-trusted-context-provider" -version = "4.2.0-beta.3" -source = "git+https://github.com/dashpay/platform?rev=9f7ed16935bd540e4c2a542688800d2953f9f67a#9f7ed16935bd540e4c2a542688800d2953f9f67a" +version = "5.0.0-rc.8" +source = "git+https://github.com/dashpay/platform?rev=063589bf0adbe1a8823a943cee95385b423b0e3f#063589bf0adbe1a8823a943cee95385b423b0e3f" dependencies = [ "arc-swap", "dash-async", "dash-context-provider", "dpp", "futures", + "gloo-timers", "hex", "lru", "reqwest 0.12.28", "serde", "serde_json", "thiserror 2.0.20", + "tokio", "tracing", "url", + "wasm-bindgen-futures", + "web-time", ] [[package]] @@ -7954,7 +8154,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8013,7 +8213,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8115,37 +8315,22 @@ dependencies = [ "tiny-skia 0.11.4", ] -[[package]] -name = "sec1" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" -dependencies = [ - "base16ct", - "der", - "generic-array 0.14.7", - "pkcs8", - "subtle", - "zeroize", -] - [[package]] name = "secp256k1" -version = "0.30.0" +version = "0.33.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" +checksum = "d7f404a8dab7a7a5a631e741d8699aa9c8e1d689fc26ccf897c7187565490b69" dependencies = [ - "bitcoin_hashes", - "rand 0.8.8", + "rand 0.9.5", "secp256k1-sys", "serde", ] [[package]] name = "secp256k1-sys" -version = "0.10.1" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +checksum = "f6b2992d4a3cd244539a7d5d0966aadbe5ca7fb868a5d7e38c29499b9e709bbd" dependencies = [ "cc", ] @@ -8195,15 +8380,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "serde_bare" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51c55386eed0f1ae957b091dc2ca8122f287b60c79c774cbe3d5f2b69fded660" -dependencies = [ - "serde", -] - [[package]] name = "serde_bytes" version = "0.11.19" @@ -8442,6 +8618,9 @@ name = "signature" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "rand_core 0.10.1", +] [[package]] name = "simd-adler32" @@ -8480,7 +8659,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d268ae0ea06faafe1662e9967cd4f9022014f5eeb798e0c302c876df8b7af9c" dependencies = [ - "group", + "group 0.13.0", "pasta_curves", "subtle", ] @@ -8604,7 +8783,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8616,12 +8795,6 @@ dependencies = [ "lock_api", ] -[[package]] -name = "spin" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" - [[package]] name = "spirv" version = "0.4.0+sdk-1.4.341.0" @@ -8687,17 +8860,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" -[[package]] -name = "std-shims" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "227c4f8561598188d0df96dbe749824576174bba278b5b6bb2eacff1066067d0" -dependencies = [ - "hashbrown 0.16.1", - "rustversion", - "spin 0.10.1", -] - [[package]] name = "strict-num" version = "0.1.1" @@ -8853,10 +9015,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -9096,8 +9258,8 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "token-history-contract" -version = "4.2.0-beta.3" -source = "git+https://github.com/dashpay/platform?rev=9f7ed16935bd540e4c2a542688800d2953f9f67a#9f7ed16935bd540e4c2a542688800d2953f9f67a" +version = "5.0.0-rc.8" +source = "git+https://github.com/dashpay/platform?rev=063589bf0adbe1a8823a943cee95385b423b0e3f#063589bf0adbe1a8823a943cee95385b423b0e3f" dependencies = [ "platform-value", "platform-version", @@ -9175,6 +9337,18 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tokio-socks" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.19" @@ -9577,7 +9751,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -9592,12 +9766,6 @@ dependencies = [ "static_assertions", ] -[[package]] -name = "uint-zigzag" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61faa33dc26b2851a37da5390a1a4cac015887b1e97ecd77ce7b4f987431de9f" - [[package]] name = "unicase" version = "2.9.0" @@ -9888,24 +10056,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "vsss-rs" -version = "5.1.0" -source = "git+https://github.com/dashpay/vsss-rs?branch=main#668f1406bf25a4b9a95cd97c9069f7a1632897c3" -dependencies = [ - "crypto-bigint", - "elliptic-curve", - "elliptic-curve-tools", - "generic-array 1.4.5", - "hex", - "num", - "rand_core 0.6.4", - "serde", - "sha3", - "subtle", - "zeroize", -] - [[package]] name = "walkdir" version = "2.5.0" @@ -9918,8 +10068,8 @@ dependencies = [ [[package]] name = "wallet-utils-contract" -version = "4.2.0-beta.3" -source = "git+https://github.com/dashpay/platform?rev=9f7ed16935bd540e4c2a542688800d2953f9f67a#9f7ed16935bd540e4c2a542688800d2953f9f67a" +version = "5.0.0-rc.8" +source = "git+https://github.com/dashpay/platform?rev=063589bf0adbe1a8823a943cee95385b423b0e3f#063589bf0adbe1a8823a943cee95385b423b0e3f" dependencies = [ "platform-value", "platform-version", @@ -10475,7 +10625,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -10932,7 +11082,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" dependencies = [ "cfg-if", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -11061,8 +11211,8 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "withdrawals-contract" -version = "4.2.0-beta.3" -source = "git+https://github.com/dashpay/platform?rev=9f7ed16935bd540e4c2a542688800d2953f9f67a#9f7ed16935bd540e4c2a542688800d2953f9f67a" +version = "5.0.0-rc.8" +source = "git+https://github.com/dashpay/platform?rev=063589bf0adbe1a8823a943cee95385b423b0e3f#063589bf0adbe1a8823a943cee95385b423b0e3f" dependencies = [ "num_enum 0.5.11", "platform-value", @@ -11350,9 +11500,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "serde", "zeroize_derive", diff --git a/Cargo.toml b/Cargo.toml index bd1648d5e..fe597b3b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ base64 = "0.23.1" # and `DisplayFromStr` adapters — the vulnerable `KeyValueMap` serializer is never invoked in the # dependency chain, so the advisory is not exploitable here (Dependabot alert #41; dismissal as # `not_used` was attempted but requires manual GitHub action — see coordinator note). -dash-sdk = { git = "https://github.com/dashpay/platform", rev = "9f7ed16935bd540e4c2a542688800d2953f9f67a", features = [ +dash-sdk = { git = "https://github.com/dashpay/platform", rev = "063589bf0adbe1a8823a943cee95385b423b0e3f", features = [ "core_key_wallet", "core_key_wallet_manager", "core_bincode", @@ -34,15 +34,15 @@ dash-sdk = { git = "https://github.com/dashpay/platform", rev = "9f7ed16935bd540 "core_spv", "shielded", ] } -rs-sdk-trusted-context-provider = { git = "https://github.com/dashpay/platform", rev = "9f7ed16935bd540e4c2a542688800d2953f9f67a" } -platform-wallet = { git = "https://github.com/dashpay/platform", rev = "9f7ed16935bd540e4c2a542688800d2953f9f67a", features = [ +rs-sdk-trusted-context-provider = { git = "https://github.com/dashpay/platform", rev = "063589bf0adbe1a8823a943cee95385b423b0e3f" } +platform-wallet = { git = "https://github.com/dashpay/platform", rev = "063589bf0adbe1a8823a943cee95385b423b0e3f", features = [ "serde", "shielded", ] } # `serde` backs `model::secret::Secret`'s `Deserialize`: the upstream # visitor copies a borrowed `&str` straight into guarded memory instead of # routing through a transient `String`. Secret schemas are included by `secrets`. -platform-wallet-storage = { git = "https://github.com/dashpay/platform", rev = "9f7ed16935bd540e4c2a542688800d2953f9f67a", features = [ +platform-wallet-storage = { git = "https://github.com/dashpay/platform", rev = "063589bf0adbe1a8823a943cee95385b423b0e3f", features = [ "shielded", "serde", ] } diff --git a/scripts/migration-fixtures/expected-migration-fingerprint.txt b/scripts/migration-fixtures/expected-migration-fingerprint.txt index cdd017629..7f45cb36e 100644 --- a/scripts/migration-fixtures/expected-migration-fingerprint.txt +++ b/scripts/migration-fixtures/expected-migration-fingerprint.txt @@ -5,7 +5,7 @@ # # fingerprint = sha256 of the LC_ALL=C-sorted " " listing of every # file under packages/rs-platform-wallet-storage/migrations at that rev. -pin=9f7ed16935bd540e4c2a542688800d2953f9f67a +pin=063589bf0adbe1a8823a943cee95385b423b0e3f target_branch=v5.1-dev -migration_count=18 -fingerprint=1c6a683d1b781045f4528048b49b1e945f4da6caeb71e72d8de7429904399b99 +migration_count=19 +fingerprint=79465f56868e4dfd6e0ba05e4eefe7e7d4ac8ef478c2579ba019d12f868278e6 diff --git a/src/wallet_backend/platform_compatibility/engine.rs b/src/wallet_backend/platform_compatibility/engine.rs index af1fd4f7e..4397c0fb5 100644 --- a/src/wallet_backend/platform_compatibility/engine.rs +++ b/src/wallet_backend/platform_compatibility/engine.rs @@ -6,7 +6,7 @@ use rusqlite::{Connection, OpenFlags, OptionalExtension, TransactionBehavior}; use super::storage_failure::StorageFailure; const OLD_SCHEMA: &str = include_str!("fixtures/67d4ef3.sql"); -const TARGET_SCHEMA: &str = include_str!("fixtures/e3cd7cf.sql"); +const TARGET_SCHEMA: &str = include_str!("fixtures/033ecae.sql"); const HISTORY: &str = "refinery_schema_history"; const IDENTITY_INDEX_KEY: &str = "det:identity_index:v1"; @@ -740,6 +740,7 @@ fn allowed_columns( "winner_mined_height", ], "core_sync_state" => &["chainlock_height"], + "asset_locks" => &["operation_id"], _ => &[], }; let expected_removed: &[&str] = if table == "identities" { diff --git a/src/wallet_backend/platform_compatibility/engine/tests.rs b/src/wallet_backend/platform_compatibility/engine/tests.rs index a6132f4b1..adbfc6489 100644 --- a/src/wallet_backend/platform_compatibility/engine/tests.rs +++ b/src/wallet_backend/platform_compatibility/engine/tests.rs @@ -1055,3 +1055,50 @@ fn platform_compatibility_lock_survives_concurrent_process_spawns() { outcome.expect("a released lock must be re-acquirable while processes spawn"); }); } + +#[test] +fn platform_compatibility_asset_lock_blobs_survive_with_no_operation_id() { + let (_dir, path, target) = fixture(); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch(include_str!("../fixtures/public-rows.sql")) + .unwrap(); + conn.execute("INSERT INTO asset_locks (wallet_id, outpoint, status, account_index, identity_index, amount_duffs, lifecycle_blob) VALUES (?1, ?2, 'built', 0, 0, 123, ?3)", rusqlite::params![&[0xA1u8;32][..], &[0xB1u8;36][..], &[0xC1u8,0xC2][..]]).unwrap(); + drop(conn); + let backup = upgrade(&path, &target, |_| Ok(())).unwrap().unwrap(); + let conn = Connection::open(&path).unwrap(); + let (blob, operation): (Vec, Option>) = conn + .query_row( + "SELECT lifecycle_blob, operation_id FROM asset_locks", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(blob, [0xC1, 0xC2]); + assert_eq!(operation, None); + assert_eq!( + Connection::open(backup) + .unwrap() + .query_row("SELECT lifecycle_blob FROM asset_locks", [], |row| row + .get::<_, Vec>(0)) + .unwrap(), + blob + ); +} + +#[test] +fn platform_compatibility_refuses_a_different_version_19_without_changes() { + let (dir, path, target) = fixture(); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch("INSERT INTO refinery_schema_history VALUES (19, 'core_transaction_accounting', '2026-10-21T00:00:00Z', '1'); CREATE TABLE core_transaction_inputs (opaque BLOB);").unwrap(); + drop(conn); + let before = snapshot(&path); + assert!( + upgrade(&path, &target, |_| panic!( + "unrecognized history must not reach validation" + )) + .unwrap() + .is_none() + ); + assert_eq!(snapshot(&path), before); + assert!(backup_files(dir.path()).is_empty()); +} diff --git a/src/wallet_backend/platform_compatibility/fixtures/033ecae.sql b/src/wallet_backend/platform_compatibility/fixtures/033ecae.sql new file mode 100644 index 000000000..185a9e2af --- /dev/null +++ b/src/wallet_backend/platform_compatibility/fixtures/033ecae.sql @@ -0,0 +1,413 @@ +-- Empty schema captured from Platform 033ecaee2f3badc8e04ff8be664062f33516e3e4. +-- Generated by SqlitePersister::open; no wallet or identity data. +BEGIN TRANSACTION; +CREATE TABLE "account_registrations" ( + wallet_id BLOB NOT NULL, + account_type TEXT NOT NULL CHECK (account_type IN ('standard', 'standard_bip44', 'standard_bip32', 'coinjoin', 'identity_registration', 'identity_topup', 'identity_topup_unbound', 'identity_invitation', 'asset_lock_address_topup', 'asset_lock_shielded_topup', 'provider_voting', 'provider_owner', 'provider_operator', 'provider_platform', 'dashpay_receiving', 'dashpay_external', 'platform_payment')), + account_index INTEGER NOT NULL, + key_class INTEGER NOT NULL DEFAULT 0, + user_identity_id BLOB NOT NULL DEFAULT (zeroblob(32)), + friend_identity_id BLOB NOT NULL DEFAULT (zeroblob(32)), + account_xpub_bytes BLOB NOT NULL, + PRIMARY KEY (wallet_id, account_type, account_index, key_class, user_identity_id, friend_identity_id), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE +); +CREATE TABLE "asset_locks" ( + wallet_id BLOB NOT NULL, + outpoint BLOB NOT NULL, + status TEXT NOT NULL CHECK (status IN ('built', 'broadcast', 'is_locked', 'chain_locked', 'consumed', 'recovered_from_chain')), + account_index INTEGER NOT NULL, + identity_index INTEGER NOT NULL, + amount_duffs INTEGER NOT NULL, + lifecycle_blob BLOB NOT NULL, operation_id BLOB CHECK (operation_id IS NULL OR (typeof(operation_id) = 'blob' AND length(operation_id) = 16)), + PRIMARY KEY (wallet_id, outpoint), + FOREIGN KEY (wallet_id) REFERENCES "wallets"(wallet_id) ON DELETE CASCADE +); +CREATE TABLE contacts ( + wallet_id BLOB NOT NULL, + owner_id BLOB NOT NULL, + contact_id BLOB NOT NULL, + state TEXT NOT NULL CHECK (state IN ('sent', 'received', 'established')), + outgoing_request BLOB, + incoming_request BLOB, + alias TEXT, + note TEXT, + is_hidden INTEGER, + accepted_accounts BLOB, + -- G1c: set when external-account registration permanently fails for a + -- contact (so the sync sweep stops retrying a poisoned channel); + -- cleared on a superseding rotation. Nullable — readers treat NULL as + -- `false`. + payment_channel_broken INTEGER, + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY (wallet_id, owner_id, contact_id), + FOREIGN KEY (wallet_id) REFERENCES "wallets"(wallet_id) ON DELETE CASCADE +); +CREATE TABLE core_address_pool ( + wallet_id BLOB NOT NULL, + account_type TEXT NOT NULL, + account_index INTEGER NOT NULL, + key_class INTEGER NOT NULL DEFAULT 0, + user_identity_id BLOB NOT NULL DEFAULT (zeroblob(32)), + friend_identity_id BLOB NOT NULL DEFAULT (zeroblob(32)), + pool_type INTEGER NOT NULL CHECK (pool_type IN (0, 1, 2, 3)), + address_index INTEGER NOT NULL, + script BLOB NOT NULL, + used INTEGER NOT NULL DEFAULT 0 CHECK (used IN (0, 1)), public_key BLOB NULL, key_type INTEGER NULL CHECK (key_type IS NULL OR key_type IN (0, 1, 2)), reserved_at INTEGER NULL, + PRIMARY KEY (wallet_id, account_type, account_index, key_class, user_identity_id, friend_identity_id, pool_type, address_index), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE +); +CREATE TABLE core_instant_locks ( + wallet_id BLOB NOT NULL, + txid BLOB NOT NULL, + islock_blob BLOB NOT NULL, + PRIMARY KEY (wallet_id, txid), + FOREIGN KEY (wallet_id) REFERENCES "wallets"(wallet_id) ON DELETE CASCADE +); +CREATE TABLE core_sync_state ( + wallet_id BLOB NOT NULL PRIMARY KEY, + last_processed_height INTEGER, + synced_height INTEGER, chainlock_height INTEGER, last_applied_chain_lock BLOB, + FOREIGN KEY (wallet_id) REFERENCES "wallets"(wallet_id) ON DELETE CASCADE +); +CREATE TABLE "core_transactions" ( + wallet_id BLOB NOT NULL, + txid BLOB NOT NULL, + height INTEGER, + block_hash BLOB, + block_time INTEGER, + finalized INTEGER NOT NULL, + record_blob BLOB, + PRIMARY KEY (wallet_id, txid), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE +); +CREATE TABLE core_utxos ( + wallet_id BLOB NOT NULL, + outpoint BLOB NOT NULL, + value INTEGER NOT NULL, + script BLOB NOT NULL, + spent INTEGER NOT NULL, + spent_in_txid BLOB, winner_mined_height INTEGER, is_sweep_placeholder INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (wallet_id, outpoint), + FOREIGN KEY (wallet_id) REFERENCES "wallets"(wallet_id) ON DELETE CASCADE +); +CREATE TABLE dashpay_payments_overlay ( + identity_id BLOB NOT NULL, + payment_id TEXT NOT NULL, + overlay_blob BLOB NOT NULL, + PRIMARY KEY (identity_id, payment_id), + FOREIGN KEY (identity_id) REFERENCES identities(identity_id) ON DELETE CASCADE +); +CREATE TABLE dashpay_profiles ( + identity_id BLOB NOT NULL PRIMARY KEY, + profile_blob BLOB NOT NULL, + FOREIGN KEY (identity_id) REFERENCES identities(identity_id) ON DELETE CASCADE +); +CREATE TABLE dpns_name_states ( + wallet_id BLOB NOT NULL, + document_id BLOB NOT NULL, + identity_id BLOB NOT NULL, + label TEXT NOT NULL, + normalized_label TEXT NOT NULL, + normalized_parent_domain TEXT NOT NULL, + price INTEGER CHECK (price IS NULL OR price >= 0), + status TEXT NOT NULL CHECK (status IN ('owned', 'sold', 'transferred')), + counterparty_id BLOB, + created_at_ms INTEGER, + updated_at_ms INTEGER, + transferred_at_ms INTEGER, + last_synced_at_ms INTEGER NOT NULL, + PRIMARY KEY (wallet_id, document_id), + CHECK ((status = 'owned') = (counterparty_id IS NULL)), + FOREIGN KEY (wallet_id) REFERENCES "wallets"(wallet_id) ON DELETE CASCADE + ); +CREATE TABLE identities ( + identity_id BLOB NOT NULL PRIMARY KEY, + wallet_id BLOB, + identity_index INTEGER, + entry_blob BLOB NOT NULL, + FOREIGN KEY (wallet_id) REFERENCES "wallets"(wallet_id) ON DELETE CASCADE +); +CREATE TABLE "identity_keys" ( + wallet_id BLOB, + identity_id BLOB NOT NULL, + key_id INTEGER NOT NULL, + public_key_blob BLOB NOT NULL, + public_key_hash BLOB NOT NULL, + -- Reserved for a future typed projection; always NULL today. + -- derivation_indices lives inside public_key_blob (the IdentityKeyWire + -- blob is the single source of truth). + derivation_blob BLOB, + PRIMARY KEY (identity_id, key_id), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE, + FOREIGN KEY (wallet_id, identity_id) + REFERENCES identities(wallet_id, identity_id) ON DELETE CASCADE +); +CREATE TABLE identity_scan_failed_indices ( + wallet_id BLOB NOT NULL, + failed_index INTEGER NOT NULL CHECK (failed_index >= 0), + PRIMARY KEY (wallet_id, failed_index), + FOREIGN KEY (wallet_id) REFERENCES identity_scan_states(wallet_id) ON DELETE CASCADE +); +CREATE TABLE identity_scan_states ( + wallet_id BLOB NOT NULL PRIMARY KEY, + complete INTEGER NOT NULL CHECK (complete IN (0, 1)), + probed_from INTEGER NOT NULL CHECK (probed_from >= 0), + probed_through INTEGER NOT NULL CHECK (probed_through >= probed_from), + unlocated_gap INTEGER NOT NULL CHECK (unlocated_gap IN (0, 1)), + -- A scan cannot both have answered everything and be sitting on a gap + -- nobody could name. + CHECK (complete = 0 OR unlocated_gap = 0), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE +); +CREATE TABLE ignored_senders ( + wallet_id BLOB NOT NULL, + owner_id BLOB NOT NULL, + sender_id BLOB NOT NULL, + ignored_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY (wallet_id, owner_id, sender_id), + FOREIGN KEY (wallet_id) REFERENCES "wallets"(wallet_id) ON DELETE CASCADE +); +CREATE TABLE invitations ( + wallet_id BLOB NOT NULL, + outpoint BLOB NOT NULL, + status TEXT NOT NULL CHECK (status IN ('created', 'claimed', 'reclaimed')), + funding_index INTEGER NOT NULL, + amount_duffs INTEGER NOT NULL, + expiry_unix INTEGER NOT NULL, + created_at_secs INTEGER NOT NULL, + has_inviter INTEGER NOT NULL, + PRIMARY KEY (wallet_id, outpoint), + FOREIGN KEY (wallet_id) REFERENCES "wallets"(wallet_id) ON DELETE CASCADE + ); +CREATE TABLE meta_contact ( + wallet_id BLOB NOT NULL, + owner_id BLOB NOT NULL, + contact_id BLOB NOT NULL, + key TEXT NOT NULL CHECK (length(key) BETWEEN 1 AND 128), + value BLOB NOT NULL, + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY (wallet_id, owner_id, contact_id, key) +); +CREATE TABLE meta_data_versions ( + wallet_id BLOB NOT NULL, + domain TEXT NOT NULL, + seq INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (wallet_id, domain) +); +CREATE TABLE meta_global ( + key TEXT NOT NULL PRIMARY KEY CHECK (length(key) BETWEEN 1 AND 128), + value BLOB NOT NULL, + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +); +CREATE TABLE meta_identity ( + identity_id BLOB NOT NULL, + key TEXT NOT NULL CHECK (length(key) BETWEEN 1 AND 128), + value BLOB NOT NULL, + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY (identity_id, key) +); +CREATE TABLE meta_platform_address ( + wallet_id BLOB NOT NULL, + address BLOB NOT NULL, + key TEXT NOT NULL CHECK (length(key) BETWEEN 1 AND 128), + value BLOB NOT NULL, + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY (wallet_id, address, key) +); +CREATE TABLE meta_store_generation ( + id INTEGER NOT NULL PRIMARY KEY CHECK (id = 0), + generation BLOB NOT NULL +); +INSERT INTO "meta_store_generation" VALUES(0,X'90C76E7005D87413639127DDBBC6C290'); +CREATE TABLE meta_token ( + identity_id BLOB NOT NULL, + token_id BLOB NOT NULL, + key TEXT NOT NULL CHECK (length(key) BETWEEN 1 AND 128), + value BLOB NOT NULL, + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY (identity_id, token_id, key) +); +CREATE TABLE meta_wallet ( + wallet_id BLOB NOT NULL, + key TEXT NOT NULL CHECK (length(key) BETWEEN 1 AND 128), + value BLOB NOT NULL, + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY (wallet_id, key) +); +CREATE TABLE pending_contact_crypto ( + wallet_id BLOB NOT NULL, + owner_identity_id BLOB NOT NULL, + contact_id BLOB NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('register_receiving', 'register_external', 'contact_info_decrypt', 'auto_accept')), + payload BLOB NOT NULL, + enqueued_at_ms INTEGER NOT NULL, + PRIMARY KEY (wallet_id, owner_identity_id, contact_id, kind), + FOREIGN KEY (wallet_id) REFERENCES "wallets"(wallet_id) ON DELETE CASCADE +); +CREATE TABLE platform_address_sync ( + wallet_id BLOB NOT NULL PRIMARY KEY, + sync_height INTEGER NOT NULL, + sync_timestamp INTEGER NOT NULL, + last_known_recent_block INTEGER NOT NULL, + FOREIGN KEY (wallet_id) REFERENCES "wallets"(wallet_id) ON DELETE CASCADE +); +CREATE TABLE platform_addresses ( + wallet_id BLOB NOT NULL, + account_index INTEGER NOT NULL, + address_index INTEGER NOT NULL, + address BLOB NOT NULL, + balance INTEGER NOT NULL, + nonce INTEGER NOT NULL, as_of_height INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (wallet_id, address), + FOREIGN KEY (wallet_id) REFERENCES "wallets"(wallet_id) ON DELETE CASCADE +); +CREATE TABLE refinery_schema_history( + version int4 PRIMARY KEY, + name VARCHAR(255), + applied_on VARCHAR(255), + checksum VARCHAR(255)); +INSERT INTO "refinery_schema_history" VALUES(1,'initial','2026-10-21T00:00:00Z','16458714021387590417'); +INSERT INTO "refinery_schema_history" VALUES(2,'address_height_pin','2026-10-21T00:00:00Z','9304453063389616695'); +INSERT INTO "refinery_schema_history" VALUES(3,'invitations','2026-10-21T00:00:00Z','6768710306020099565'); +INSERT INTO "refinery_schema_history" VALUES(4,'asset_lock_recovered_status','2026-10-21T00:00:00Z','14446754812616312642'); +INSERT INTO "refinery_schema_history" VALUES(5,'dpns_name_states','2026-10-21T00:00:00Z','16834970739141546284'); +INSERT INTO "refinery_schema_history" VALUES(6,'tracked_masternodes','2026-10-21T00:00:00Z','13718514320935649081'); +INSERT INTO "refinery_schema_history" VALUES(7,'utxo_sweep_winner_height','2026-10-21T00:00:00Z','4410976868559573281'); +INSERT INTO "refinery_schema_history" VALUES(8,'rehydration_base_schema','2026-10-21T00:00:00Z','8883371305674850298'); +INSERT INTO "refinery_schema_history" VALUES(9,'unified','2026-10-21T00:00:00Z','8389295906542582065'); +INSERT INTO "refinery_schema_history" VALUES(10,'pool_public_key','2026-10-21T00:00:00Z','15088205568906581463'); +INSERT INTO "refinery_schema_history" VALUES(11,'pool_reserved_at','2026-10-21T00:00:00Z','1184102594786438566'); +INSERT INTO "refinery_schema_history" VALUES(12,'drop_core_utxo_metadata','2026-10-21T00:00:00Z','14281733277552006158'); +INSERT INTO "refinery_schema_history" VALUES(13,'shielded_viewing_keys','2026-10-21T00:00:00Z','2886147678304763509'); +INSERT INTO "refinery_schema_history" VALUES(14,'single_source_core_confirmation_height','2026-10-21T00:00:00Z','13462228277166821110'); +INSERT INTO "refinery_schema_history" VALUES(15,'purge_legacy_empty_script_spent_utxos','2026-10-21T00:00:00Z','723175730717787103'); +INSERT INTO "refinery_schema_history" VALUES(16,'identity_keys_null_scope_requires_existing_identity','2026-10-21T00:00:00Z','5573841766625796742'); +INSERT INTO "refinery_schema_history" VALUES(17,'identity_scan_state','2026-10-21T00:00:00Z','14086773559107496341'); +INSERT INTO "refinery_schema_history" VALUES(18,'identity_hard_delete','2026-10-21T00:00:00Z','14764815300602197850'); +INSERT INTO "refinery_schema_history" VALUES(19,'asset_lock_operation_id','2026-10-21T00:00:00Z','1537039181339941563'); +CREATE TABLE shielded_viewing_keys ( + wallet_id BLOB NOT NULL, + account_index INTEGER NOT NULL CHECK (account_index BETWEEN 0 AND 4294967295), + viewing_key BLOB NOT NULL, + PRIMARY KEY (wallet_id, account_index), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE +); +CREATE TABLE token_balances ( + identity_id BLOB NOT NULL, + token_id BLOB NOT NULL, + balance INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (identity_id, token_id), + FOREIGN KEY (identity_id) REFERENCES identities(identity_id) ON DELETE CASCADE +); +CREATE TABLE tracked_masternodes ( + network TEXT NOT NULL CHECK (network IN ('mainnet', 'testnet', 'devnet', 'regtest')), + pro_tx_hash BLOB NOT NULL CHECK (length(pro_tx_hash) = 32), + label TEXT, + added_at INTEGER NOT NULL, + snapshot_json TEXT NOT NULL, + PRIMARY KEY (network, pro_tx_hash) + ); +CREATE TABLE "wallets" ( + wallet_id BLOB NOT NULL PRIMARY KEY, + network TEXT NOT NULL CHECK (network IN ('mainnet', 'testnet', 'devnet', 'regtest')), + birth_height INTEGER NOT NULL +); +CREATE INDEX idx_core_utxos_spent ON core_utxos(wallet_id, spent); +CREATE INDEX idx_identities_wallet ON identities(wallet_id); +CREATE TRIGGER cascade_meta_on_wallet_delete +AFTER DELETE ON "wallets" +FOR EACH ROW +BEGIN + DELETE FROM meta_wallet WHERE wallet_id = OLD.wallet_id; + DELETE FROM meta_contact WHERE wallet_id = OLD.wallet_id; + DELETE FROM meta_platform_address WHERE wallet_id = OLD.wallet_id; +END; +CREATE TRIGGER cascade_meta_on_identity_delete +AFTER DELETE ON identities +FOR EACH ROW +BEGIN + DELETE FROM meta_identity WHERE identity_id = OLD.identity_id; + DELETE FROM meta_token WHERE identity_id = OLD.identity_id; +END; +CREATE TRIGGER cascade_meta_token_on_token_balance_delete +AFTER DELETE ON token_balances +FOR EACH ROW +BEGIN + DELETE FROM meta_token + WHERE identity_id = OLD.identity_id AND token_id = OLD.token_id; +END; +CREATE TRIGGER cascade_meta_contact_on_contact_delete +AFTER DELETE ON contacts +FOR EACH ROW +BEGIN + DELETE FROM meta_contact + WHERE wallet_id = OLD.wallet_id + AND owner_id = OLD.owner_id + AND contact_id = OLD.contact_id; +END; +CREATE TRIGGER cascade_meta_platform_address_on_address_delete +AFTER DELETE ON platform_addresses +FOR EACH ROW +BEGIN + DELETE FROM meta_platform_address + WHERE wallet_id = OLD.wallet_id AND address = OLD.address; +END; +CREATE UNIQUE INDEX idx_identities_wallet_identity ON identities(wallet_id, identity_id); +CREATE INDEX idx_identity_keys_wallet_identity ON identity_keys(wallet_id, identity_id); +CREATE INDEX idx_core_address_pool_used + ON core_address_pool(wallet_id, used); +CREATE INDEX idx_core_address_pool_script + ON core_address_pool(wallet_id, script); +CREATE TRIGGER cascade_meta_data_versions_on_wallet_delete +AFTER DELETE ON wallets +FOR EACH ROW +BEGIN + DELETE FROM meta_data_versions WHERE wallet_id = OLD.wallet_id; +END; +CREATE INDEX idx_core_transactions_height ON core_transactions(wallet_id, height); +CREATE INDEX idx_core_utxos_unmaterialized ON core_utxos(wallet_id, winner_mined_height) + WHERE is_sweep_placeholder = 1; +CREATE TRIGGER setnull_core_utxos_on_tx_delete AFTER DELETE ON core_transactions +BEGIN + UPDATE core_utxos SET spent_in_txid = NULL + WHERE wallet_id = OLD.wallet_id AND spent_in_txid = OLD.txid; +END; +CREATE TRIGGER identity_keys_null_scope_requires_unowned_identity +BEFORE INSERT ON identity_keys +FOR EACH ROW WHEN NEW.wallet_id IS NULL +BEGIN + SELECT RAISE(ABORT, 'identity_keys.wallet_id is NULL but the named identity is missing or wallet-owned') + WHERE NOT EXISTS ( + SELECT 1 FROM identities i + WHERE i.identity_id = NEW.identity_id AND i.wallet_id IS NULL + ); +END; +CREATE TRIGGER identity_keys_null_scope_requires_unowned_identity_on_update +BEFORE UPDATE ON identity_keys +FOR EACH ROW WHEN NEW.wallet_id IS NULL +BEGIN + SELECT RAISE(ABORT, 'identity_keys.wallet_id is NULL but the named identity is missing or wallet-owned') + WHERE NOT EXISTS ( + SELECT 1 FROM identities i + WHERE i.identity_id = NEW.identity_id AND i.wallet_id IS NULL + ); +END; +CREATE TRIGGER cascade_children_on_identity_delete +AFTER DELETE ON identities +FOR EACH ROW +BEGIN + DELETE FROM identity_keys WHERE identity_id = OLD.identity_id; + DELETE FROM contacts WHERE owner_id = OLD.identity_id; + DELETE FROM ignored_senders WHERE owner_id = OLD.identity_id; + DELETE FROM pending_contact_crypto WHERE owner_identity_id = OLD.identity_id; +END; +CREATE INDEX idx_contacts_owner ON contacts(owner_id); +CREATE INDEX idx_ignored_senders_owner ON ignored_senders(owner_id); +CREATE INDEX idx_pending_contact_crypto_owner ON pending_contact_crypto(owner_identity_id); +CREATE UNIQUE INDEX asset_locks_wallet_operation_id ON asset_locks(wallet_id, operation_id) WHERE operation_id IS NOT NULL; +CREATE TRIGGER asset_locks_operation_id_immutable BEFORE UPDATE OF operation_id ON asset_locks WHEN OLD.operation_id IS NOT NULL AND NEW.operation_id IS NOT OLD.operation_id BEGIN SELECT RAISE(ABORT, 'asset lock operation ID cannot change'); END; +COMMIT; +PRAGMA application_id = 1347180372; +PRAGMA user_version = 0; diff --git a/src/wallet_backend/platform_compatibility/fixtures/README.md b/src/wallet_backend/platform_compatibility/fixtures/README.md index 4582259f9..a0f8ae0d4 100644 --- a/src/wallet_backend/platform_compatibility/fixtures/README.md +++ b/src/wallet_backend/platform_compatibility/fixtures/README.md @@ -18,9 +18,20 @@ The production bridge compares these exact histories and materialized schemas before translating data. The destination itself is created by the current public `SqlitePersister::open`, not by the fixture SQL. Updating either schema revision requires reviewing and regenerating these guards. -The selected dependency pin `63cf57f40d0000bf3b2b26026c8fa1c71162852d` -has identical migration code and schema to `e3cd7cf`, so it uses the same -destination guard. +The current destination guard is `033ecae.sql`, captured from an empty database +opened by `SqlitePersister::open` at Platform revision +`033ecaee2f3badc8e04ff8be664062f33516e3e4`. The V001–V018 migration names and +checksums match `e3cd7cf.sql`. V019 adds only the nullable `asset_locks.operation_id` +column, its unique index and immutability trigger (checksum `1537039181339941563`). +The snapshot contains no domain rows; history timestamps are normalized. The +bridge copies existing asset-lock blobs unchanged and leaves the new column NULL. +A different migration occupying version 19 is not an accepted source history. +Platform revision `a0d7b6ffec6f91e3818530b83d75adcd62480ebe` changes only doc +comments in `V007__utxo_sweep_winner_height.rs`; every migration's SQL and +checksum, and so the snapshot, are unchanged. +Revisions `132ae31a1356d035cd8326f27c1109685090a71d` (5.0.0-rc.8) and +`063589bf0adbe1a8823a943cee95385b423b0e3f` leave the migrations untouched. + `public-rows.sql` contains only public synthetic rows from that old revision's `packages/rs-platform-wallet-storage/tests/fixtures/populated_v001.db`, after From 82eecdd1d93f8acb80221b97dd6f0aa44e9d3591 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:21:52 +0000 Subject: [PATCH 02/58] refactor: adapt to the rust-dashcore cryptography API The new rust-dashcore wraps secp256k1 keys in EcdsaSecretKey and EcdsaPublicKey, returns secret bytes as Zeroizing, and drops the Secp256k1 context argument. Signers, single-key wallets, DashPay derivation, address and QR handling, migrations and their tests follow the new types. Signing stays on the non-grinding dpp::secp256k1 path, and a parity test pins the BLS signature bytes the previous SDK made. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- src/backend_task/dashpay/auto_accept_proof.rs | 39 +++---- src/backend_task/dashpay/encryption.rs | 27 +++-- src/backend_task/dashpay/errors.rs | 2 +- src/backend_task/dashpay/payments.rs | 6 +- .../identity/add_key_to_identity.rs | 20 ++-- src/backend_task/identity/load_identity.rs | 18 ++- src/backend_task/identity/mod.rs | 17 ++- src/backend_task/migration/finish_unwire.rs | 6 +- src/backend_task/migration/legacy_restore.rs | 4 +- .../migration/single_key_restore.rs | 13 +-- src/backend_task/migration/v093_upgrade.rs | 16 ++- .../wallet/derive_identity_key_for_display.rs | 4 +- src/backend_task/wallet/mod.rs | 66 ++++++++--- src/backend_task/wallet/rename_wallet.rs | 20 ++-- .../wallet/sign_message_with_key.rs | 4 +- src/context/wallet_lifecycle/tests.rs | 15 ++- src/database/test_helpers.rs | 8 +- src/database/wallet.rs | 10 +- src/mcp/tools/masternode.rs | 32 +++--- src/model/address.rs | 10 +- src/model/dashpay_derivation/dip14.rs | 33 +++--- src/model/dashpay_derivation/hd.rs | 30 ++--- src/model/dashpay_derivation/mod.rs | 6 +- src/model/key_input.rs | 2 +- src/model/qualified_identity/mod.rs | 62 ++++++---- .../qualified_identity_public_key.rs | 8 +- src/model/single_key.rs | 6 +- src/model/wallet/auth_pubkey_cache.rs | 13 +-- src/model/wallet/mod.rs | 106 ++++++++---------- src/model/wallet/single_key.rs | 12 +- src/ui/components/address_input.rs | 18 +-- src/ui/identity/funding_common.rs | 9 +- src/ui/identity/keys/add_key_screen.rs | 12 +- src/ui/identity/keys/key_info_screen.rs | 42 ++++--- src/ui/state/account_summary.rs | 10 +- src/ui/wallets/send_screen.rs | 11 +- .../wallets/wallets_screen/address_table.rs | 8 +- src/wallet_backend/auth_pubkey_cache.rs | 9 +- src/wallet_backend/contact_profile_cache.rs | 9 +- src/wallet_backend/dashpay.rs | 40 ++----- src/wallet_backend/det_platform_signer.rs | 27 +++-- src/wallet_backend/det_signer.rs | 80 +++++++------ src/wallet_backend/secret_access.rs | 25 +++-- src/wallet_backend/sidecar.rs | 5 +- src/wallet_backend/single_key.rs | 66 +++++------ src/wallet_backend/snapshot.rs | 51 ++++++++- src/wallet_backend/wallet_context.rs | 4 +- tests/backend-e2e/dashpay_tasks.rs | 17 +-- tests/backend-e2e/identity_in_vault_sign.rs | 11 +- tests/backend-e2e/identity_tasks.rs | 9 +- tests/backend-e2e/wallet_reregistration.rs | 6 +- tests/backend-e2e/z_broadcast_st_tasks.rs | 18 ++- tests/kittest/address_input.rs | 7 +- tests/kittest/send_screen.rs | 5 +- tests/kittest/top_up_identity_screen.rs | 5 +- tests/migration-matrix/assertions.rs | 6 +- tests/migration-matrix/public_identities.rs | 14 ++- 57 files changed, 602 insertions(+), 537 deletions(-) diff --git a/src/backend_task/dashpay/auto_accept_proof.rs b/src/backend_task/dashpay/auto_accept_proof.rs index 71bbd9a76..4a3fd5b22 100644 --- a/src/backend_task/dashpay/auto_accept_proof.rs +++ b/src/backend_task/dashpay/auto_accept_proof.rs @@ -1,9 +1,9 @@ use crate::backend_task::error::TaskError; use crate::model::dashpay_derivation::derive_auto_accept_key; use crate::model::qualified_identity::QualifiedIdentity; -use dash_sdk::dpp::dashcore::secp256k1::{Message, Secp256k1, SecretKey}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; +use dash_sdk::dpp::secp256k1::Message; use dash_sdk::platform::Identifier; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -28,8 +28,10 @@ impl AutoAcceptProofData { key_data.extend_from_slice(&self.proof_key); // The actual key // Encode key data in base58 using dashcore's base58 implementation - use dash_sdk::dpp::dashcore::base58; - let key_data_base58 = base58::encode_slice(&key_data); + let key_data_base58 = dash_sdk::dpp::platform_value::string_encoding::encode( + &key_data, + dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, + ); // For QR codes without username (identity-based) format!( @@ -85,8 +87,7 @@ impl AutoAcceptProofData { key_data_base58.ok_or("Missing proof key data in QR code".to_string())?; // Decode the key data from base58 - use dash_sdk::dpp::dashcore::base58; - let key_data = base58::decode(&key_data_base58) + let key_data = dash_sdk::dpp::dashcore::base58::decode(&key_data_base58) .map_err(|e| format!("Invalid base58 key data: {}", e))?; // Parse key data format: key_type (1) + timestamp (4) + key_size (1) + key (32-64) @@ -170,7 +171,7 @@ pub async fn generate_auto_accept_proof( .map_err(|e| TaskError::DashPay(DashPayError::from(e)))?; // Extract the private key bytes (32 bytes) - let proof_key = auto_accept_xprv.private_key.secret_bytes(); + let proof_key = *auto_accept_xprv.private_key.to_bytes(); Ok(AutoAcceptProofData { identity_id: identity.identity.id(), @@ -209,14 +210,13 @@ pub fn create_auto_accept_proof_bytes_with_key( let message_hash = hasher.finalize(); // Create secp256k1 message and sign - let secp = Secp256k1::new(); - let message = Message::from_digest_slice(&message_hash) - .map_err(|e| format!("Failed to create message: {}", e))?; - let secret_key = SecretKey::from_slice(signing_key_bytes) + let message = Message::from_digest(message_hash.into()); + + let secret_key = dash_sdk::dpp::secp256k1::SecretKey::from_secret_bytes(*(signing_key_bytes)) .map_err(|e| format!("Failed to create secret key: {}", e))?; - let signature = secp.sign_ecdsa(&message, &secret_key); + let signature = dash_sdk::dpp::secp256k1::ecdsa::sign(message, &secret_key); let sig_bytes = signature.serialize_compact(); // Build the proof bytes @@ -274,9 +274,8 @@ pub async fn verify_auto_accept_proof( let mut hasher = Sha256::new(); hasher.update(&message_data); let message_hash = hasher.finalize(); - let secp = Secp256k1::new(); - let message = Message::from_digest_slice(&message_hash) - .map_err(|e| format!("Failed to create message: {}", e))?; + + let message = Message::from_digest(message_hash.into()); // Derive expected pubkey from our seed and key index (timestamp) // Use ENCRYPTION key (ECDSA_SECP256K1) for HD derivation as per DIP-15 @@ -299,17 +298,11 @@ pub async fn verify_auto_accept_proof( .ok_or("Private key not found")?; let xprv = derive_auto_accept_key(&wallet_seed[..], our_identity.network, key_index) .map_err(|e| format!("Failed to derive auto-accept key: {}", e))?; - let pubkey = dash_sdk::dpp::dashcore::secp256k1::PublicKey::from_secret_key( - &secp, - &dash_sdk::dpp::dashcore::secp256k1::SecretKey::from_slice( - &xprv.private_key.secret_bytes(), - ) - .map_err(|e| format!("Failed to create secret key: {}", e))?, - ); - let sig = dash_sdk::dpp::dashcore::secp256k1::ecdsa::Signature::from_compact(signature_bytes) + let pubkey = dash_sdk::dpp::secp256k1::PublicKey::from(xprv.private_key.public_key()); + let sig = dash_sdk::dpp::secp256k1::ecdsa::Signature::from_compact(signature_bytes) .map_err(|e| format!("Invalid signature bytes: {}", e))?; - match secp.verify_ecdsa(&message, &sig, &pubkey) { + match dash_sdk::dpp::secp256k1::ecdsa::verify(&sig, message, &pubkey) { Ok(_) => Ok(true), Err(_) => Ok(false), } diff --git a/src/backend_task/dashpay/encryption.rs b/src/backend_task/dashpay/encryption.rs index ca0f060fd..e6f11be17 100644 --- a/src/backend_task/dashpay/encryption.rs +++ b/src/backend_task/dashpay/encryption.rs @@ -1,10 +1,10 @@ use aes_gcm::aes::Aes256; use bip39::rand::{self, RngCore}; use cbc; -use dash_sdk::dpp::dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; use dash_sdk::dpp::identity::IdentityPublicKey; use dash_sdk::dpp::identity::KeyType; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::secp256k1::{PublicKey, SecretKey}; use sha2::{Digest, Sha256}; use zeroize::Zeroizing; @@ -18,11 +18,13 @@ pub fn generate_ecdh_shared_key( private_key: &[u8], public_key: &IdentityPublicKey, ) -> Result, String> { - let _secp = Secp256k1::new(); - // Parse the private key - let secret_key = - SecretKey::from_slice(private_key).map_err(|e| format!("Invalid private key: {}", e))?; + let secret_key = SecretKey::from_secret_bytes( + private_key + .try_into() + .map_err(|_| "Invalid private key length".to_string())?, + ) + .map_err(|e| format!("Invalid private key: {}", e))?; // Get the public key data - only works for full secp256k1 keys match public_key.key_type() { @@ -34,7 +36,7 @@ pub fn generate_ecdh_shared_key( // Perform ECDH to get shared secret. The 64-byte point is secret; // hold it in `Zeroizing` so it is wiped once the key is hashed out. let shared_secret = Zeroizing::new( - dash_sdk::dpp::dashcore::secp256k1::ecdh::shared_secret_point( + dash_sdk::dpp::secp256k1::ecdh::shared_secret_point( &public_key, &secret_key, ), @@ -295,7 +297,7 @@ pub fn decrypt_account_label( mod tests { use super::*; use bip39::rand::{self, RngCore}; - use dash_sdk::dpp::dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; + use dash_sdk::dpp::secp256k1::{PublicKey, SecretKey}; fn generate_test_shared_key() -> [u8; 32] { let mut shared_key = [0u8; 32]; @@ -304,14 +306,13 @@ mod tests { } fn generate_test_key_pair() -> (SecretKey, PublicKey) { - let secp = Secp256k1::new(); - let secret_key = SecretKey::from_slice(&[ + let secret_key = SecretKey::from_secret_bytes([ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20, ]) .unwrap(); - let public_key = PublicKey::from_secret_key(&secp, &secret_key); + let public_key = PublicKey::from_secret_key(&secret_key); (secret_key, public_key) } @@ -380,8 +381,6 @@ mod tests { /// wipes the sha2 digest) cannot silently alter the derived material. #[test] fn ecdh_shared_key_matches_pinned_vector() { - let secp = Secp256k1::new(); - // Our private key: 0x01..=0x20 (same fixed vector the suite uses). let private_key = [ 0x01u8, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, @@ -391,13 +390,13 @@ mod tests { // Counterpart key derived from a different fixed secret so the ECDH // point is non-degenerate. - let counterpart_secret = SecretKey::from_slice(&[ + let counterpart_secret = SecretKey::from_secret_bytes([ 0xA1u8, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD, 0xAE, 0xAF, 0xB0, 0xB1, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xBB, 0xBC, 0xBD, 0xBE, 0xBF, 0xC0, ]) .unwrap(); - let counterpart_pub = PublicKey::from_secret_key(&secp, &counterpart_secret); + let counterpart_pub = PublicKey::from_secret_key(&counterpart_secret); let counterpart_ipk = ecdsa_identity_public_key(&counterpart_pub); let shared_key = generate_ecdh_shared_key(&private_key, &counterpart_ipk) diff --git a/src/backend_task/dashpay/errors.rs b/src/backend_task/dashpay/errors.rs index caa14ffaa..a8561ed0c 100644 --- a/src/backend_task/dashpay/errors.rs +++ b/src/backend_task/dashpay/errors.rs @@ -1,4 +1,4 @@ -use dash_sdk::dpp::dashcore::secp256k1; +use dash_sdk::dpp::secp256k1; use dash_sdk::platform::Identifier; use thiserror::Error; diff --git a/src/backend_task/dashpay/payments.rs b/src/backend_task/dashpay/payments.rs index 31db1e4de..37d27de5e 100644 --- a/src/backend_task/dashpay/payments.rs +++ b/src/backend_task/dashpay/payments.rs @@ -92,7 +92,7 @@ pub async fn derive_contact_payment_address( contact_id: Identifier, ) -> Result<(Address, u32), String> { // Fetch the contact request from the contact to us (they sent us their encrypted xpub) - let dashpay_contract = app_context.dashpay_contract.clone(); + let dashpay_contract = app_context.dashpay_contract(); let mut query = DocumentQuery::new(dashpay_contract.clone(), "contactRequest") .map_err(|e| format!("Failed to create query: {}", e))?; @@ -185,8 +185,8 @@ pub async fn derive_contact_payment_address( // Create extended public key from components // This is simplified - in production you'd properly reconstruct with all fields - use dash_sdk::dpp::dashcore::secp256k1::{PublicKey, Secp256k1}; use dash_sdk::dpp::key_wallet::bip32::{ChainCode, ChildNumber, ExtendedPubKey, Fingerprint}; + use dash_sdk::dpp::secp256k1::{PublicKey, Secp256k1}; let _secp = Secp256k1::new(); let pubkey = @@ -199,7 +199,7 @@ pub async fn derive_contact_payment_address( parent_fingerprint: Fingerprint::default(), child_number: ChildNumber::from_normal_idx(0) .expect("invariant: BIP32 child index 0 is below 2^31"), - public_key: pubkey, + public_key: pubkey.into(), chain_code: ChainCode::from(chain_code), }; diff --git a/src/backend_task/identity/add_key_to_identity.rs b/src/backend_task/identity/add_key_to_identity.rs index fb3954960..91fe6c1fe 100644 --- a/src/backend_task/identity/add_key_to_identity.rs +++ b/src/backend_task/identity/add_key_to_identity.rs @@ -641,7 +641,7 @@ mod tests { async fn derived_key_persists_as_path_and_signs_after_reload() { use crate::context::test_staging::stage_identity_with_vaulted_keys; use crate::model::derived_identity_key::test_support::fixture; - use dash_sdk::dpp::dashcore::secp256k1::{Message, Secp256k1, SecretKey}; + use dash_sdk::dpp::secp256k1::{Message, SecretKey}; let staged = stage_identity_with_vaulted_keys([0xAA; 32], [0xBB; 32]).await; let (mut identity, cache, seed_hash, seed) = fixture(); @@ -703,14 +703,14 @@ mod tests { .get_resolve_with_seed(&placement, &wallets, &seed, Network::Testnet) .unwrap() .unwrap(); - let secp = Secp256k1::new(); - let secret = SecretKey::from_slice(bytes.as_ref()).unwrap(); + + let secret = SecretKey::from_secret_bytes(*bytes).unwrap(); let message = Message::from_digest([1; 32]); - let signature = secp.sign_ecdsa(&message, &secret); - secp.verify_ecdsa( - &message, + let signature = dash_sdk::dpp::secp256k1::ecdsa::sign(message, &secret); + dash_sdk::dpp::secp256k1::ecdsa::verify( &signature, - &cache.get(Network::Testnet, 0, 3).unwrap().inner, + message, + &cache.get(Network::Testnet, 0, 3).unwrap().inner.into(), ) .unwrap(); } @@ -833,14 +833,14 @@ mod tests { /// The stage's identity as published after another device added a key /// (not from this wallet) at key id 1. fn network_record_with_foreign_key(stage: &DerivedStage) -> Identity { - use dash_sdk::dpp::dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; + use dash_sdk::dpp::secp256k1::{PublicKey, SecretKey}; let mut record = stage.identity.identity.clone(); let mut foreign = record.public_keys()[&0].clone(); foreign.set_id(1); foreign.set_security_level(dash_sdk::dpp::identity::SecurityLevel::HIGH); - let secret = SecretKey::from_slice(&[9; 32]).unwrap(); + let secret = SecretKey::from_secret_bytes([9; 32]).unwrap(); foreign.set_data( - PublicKey::from_secret_key(&Secp256k1::new(), &secret) + PublicKey::from_secret_key(&secret) .serialize() .to_vec() .into(), diff --git a/src/backend_task/identity/load_identity.rs b/src/backend_task/identity/load_identity.rs index e6e687210..b7dc03e53 100644 --- a/src/backend_task/identity/load_identity.rs +++ b/src/backend_task/identity/load_identity.rs @@ -19,7 +19,7 @@ use crate::model::wallet::{Wallet, WalletSeedHash}; use crate::ui::identity::add_new_identity_screen::MAX_IDENTITY_INDEX; use dash_sdk::Sdk; use dash_sdk::dashcore_rpc::dashcore::PrivateKey; -use dash_sdk::dashcore_rpc::dashcore::key::Secp256k1; + use dash_sdk::dpp::dashcore::ProTxHash; use dash_sdk::dpp::dashcore::hashes::Hash; use dash_sdk::dpp::identity::KeyType; @@ -268,10 +268,7 @@ impl AppContext { if let Ok(private_key) = PrivateKey::from_byte_array(&voting_private_key_bytes, self.network) { - let voting_address = private_key - .public_key(&Secp256k1::new()) - .pubkey_hash() - .to_byte_array(); + let voting_address = private_key.public_key().pubkey_hash().to_byte_array(); let voter_identifier = voter_identity_id( ProTxHash::from_byte_array(identity_id.to_buffer()), &voting_address, @@ -334,7 +331,6 @@ impl AppContext { }) .collect::, TaskError>>()?; - let secp = Secp256k1::new(); #[allow(clippy::type_complexity)] let (public_key_lookup, public_key_hash_lookup): ( HashMap, [u8; 32]>, @@ -342,12 +338,12 @@ impl AppContext { ) = input_private_keys .into_iter() .map(|private_key| { - let public_key = private_key.public_key(&secp); + let public_key = private_key.public_key(); let public_key_bytes = public_key.to_bytes(); let pub_key_hash = public_key.pubkey_hash().to_byte_array(); ( - (public_key_bytes, private_key.inner.secret_bytes()), - (pub_key_hash, private_key.inner.secret_bytes()), + (public_key_bytes, *private_key.inner.to_bytes()), + (pub_key_hash, *private_key.inner.to_bytes()), ) }) .unzip(); @@ -1042,7 +1038,7 @@ mod tests { .unwrap() .unwrap(); let private_key = PrivateKey::from_byte_array(&rand::random(), Network::Testnet).unwrap(); - let address = private_key.public_key(&Secp256k1::new()).pubkey_hash(); + let address = private_key.public_key().pubkey_hash(); let other_node = Identifier::from([0x75; 32]); let other_voter = Identifier::create_voter_identifier(other_node.as_bytes(), address.as_ref()); @@ -1125,7 +1121,7 @@ mod tests { .unwrap() .unwrap(); let private_key = PrivateKey::from_byte_array(&rand::random(), Network::Testnet).unwrap(); - let address = private_key.public_key(&Secp256k1::new()).pubkey_hash(); + let address = private_key.public_key().pubkey_hash(); let voter = Identifier::create_voter_identifier(staged.id.as_bytes(), address.as_ref()); let mut voter_identity = Identity::create_basic_identity(voter, PlatformVersion::latest()).unwrap(); diff --git a/src/backend_task/identity/mod.rs b/src/backend_task/identity/mod.rs index fb0a12944..54f6f963a 100644 --- a/src/backend_task/identity/mod.rs +++ b/src/backend_task/identity/mod.rs @@ -647,7 +647,7 @@ pub fn build_identity_registration_with_seed( identity_index: u32, funding_amount: Duffs, ) -> Result { - let dashpay_contract_id = app_context.dashpay_contract.id(); + let dashpay_contract_id = app_context.dashpay_contract().id(); let key_specs = default_identity_key_specs(dashpay_contract_id); let network = app_context.network; @@ -1372,10 +1372,7 @@ mod tests { /// rejected or the wallet could not sign for its own identity. #[test] fn identity_key_entry_public_matches_private_derivation() { - use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; - let seed = [0x42u8; 64]; - let secp = Secp256k1::new(); for network in [Network::Testnet, Network::Mainnet] { let wallet = Wallet::new_from_seed(seed, network, None, None).expect("build wallet"); @@ -1413,8 +1410,8 @@ mod tests { .to_priv(); assert_eq!( - entry.public_key.inner.serialize(), - private_key.public_key(&secp).inner.serialize(), + entry.public_key.inner.to_compressed(), + private_key.public_key().inner.to_compressed(), "public/private key drift at ({network:?}, id={identity_index}, key={key_index})" ); @@ -1501,12 +1498,12 @@ mod tests { .expect("resolve master") .expect("master present"); // The resolved private key's public key must match the spec's public key. - let secp = dash_sdk::dpp::dashcore::secp256k1::Secp256k1::new(); - let resolved_pub = dash_sdk::dpp::dashcore::secp256k1::SecretKey::from_slice(&stored.1[..]) + + let resolved_pub = dash_sdk::dpp::secp256k1::SecretKey::from_secret_bytes(*stored.1) .expect("secret") - .public_key(&secp); + .public_key(); assert_eq!( - master.public_key.inner.serialize(), + master.public_key.inner.to_compressed(), resolved_pub.serialize(), "stored path resolves to the spec's public key" ); diff --git a/src/backend_task/migration/finish_unwire.rs b/src/backend_task/migration/finish_unwire.rs index 8f48fbc81..721eda3de 100644 --- a/src/backend_task/migration/finish_unwire.rs +++ b/src/backend_task/migration/finish_unwire.rs @@ -518,7 +518,7 @@ async fn refresh_dapi_nodes_once_with_legacy_check( return; } - if let Err(error) = Arc::clone(app_context).reinit_core_client_and_sdk() { + if let Err(error) = app_context.rebuild_connection().await { tracing::warn!( target = "migration::finish_unwire", ?network, @@ -3955,7 +3955,7 @@ mod tests { use crate::wallet_backend::single_key::{ SingleKeyView, label_for_address, single_key_namespace_id, }; - use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; + use dash_sdk::dpp::dashcore::{Address, Network, PrivateKey, PublicKey}; let dir = tempfile::tempdir().expect("tempdir"); @@ -3970,7 +3970,7 @@ mod tests { let priv_key = PrivateKey::from_byte_array(&raw, Network::Testnet).expect("priv"); let pub_key = PublicKey { compressed: priv_key.compressed, - inner: priv_key.inner.public_key(&Secp256k1::new()), + inner: priv_key.inner.public_key(), }; let address = Address::p2pkh(&pub_key, Network::Testnet).to_string(); let key_hash = crate::model::wallet::single_key::ClosedSingleKey::compute_key_hash(&raw); diff --git a/src/backend_task/migration/legacy_restore.rs b/src/backend_task/migration/legacy_restore.rs index 3dfe0e3b2..888c17b15 100644 --- a/src/backend_task/migration/legacy_restore.rs +++ b/src/backend_task/migration/legacy_restore.rs @@ -365,12 +365,12 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn review_regression_restore_counts_unreadable_protected_keys() { use crate::model::wallet::single_key::ClosedSingleKey; - use dash_sdk::dpp::dashcore::{Address, PrivateKey, PublicKey, secp256k1::Secp256k1}; + use dash_sdk::dpp::dashcore::{Address, PrivateKey, PublicKey}; let dir = tempfile::tempdir().expect("tempdir"); let ctx = app_context(dir.path()); let raw = [0x39; 32]; let private = PrivateKey::from_byte_array(&raw, ctx.network).unwrap(); - let public = PublicKey::new(private.inner.public_key(&Secp256k1::new())); + let public = PublicKey::new(private.inner.public_key()); let address = Address::p2pkh(&public, ctx.network).to_string(); let password = format!("test-password-{}", dir.path().display()); let envelope = ClosedSingleKey::encrypt_private_key(&raw, &password).unwrap(); diff --git a/src/backend_task/migration/single_key_restore.rs b/src/backend_task/migration/single_key_restore.rs index fd0db97cf..dacfc4065 100644 --- a/src/backend_task/migration/single_key_restore.rs +++ b/src/backend_task/migration/single_key_restore.rs @@ -29,7 +29,6 @@ use std::sync::Arc; -use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; use dash_sdk::dpp::dashcore::{Address, Network, PrivateKey, PublicKey}; use rusqlite::{Connection, OpenFlags}; use zeroize::Zeroizing; @@ -333,10 +332,10 @@ pub(super) fn derive_p2pkh_address(wif: &str, network: Network) -> Result &'static TestSecrets { fn random_private_key() -> [u8; 32] { loop { let bytes = rand::random::<[u8; 32]>(); - if dash_sdk::dpp::dashcore::secp256k1::SecretKey::from_slice(&bytes).is_ok() { + if dash_sdk::dpp::secp256k1::SecretKey::from_secret_bytes(bytes).is_ok() { return bytes; } } @@ -132,13 +132,12 @@ const V093_MASTERNODE_BLOB_HEX: &str = "00bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb /// Populate only key payloads, preserving the captured v0.9.3 encoding layout. fn v093_masternode_blob() -> Vec { - use dash_sdk::dpp::dashcore::{ - hashes::{Hash, hash160}, - secp256k1::{PublicKey, Secp256k1, SecretKey}, - }; + use dash_sdk::dpp::dashcore::hashes::{Hash, hash160}; + use dash_sdk::dpp::secp256k1::PublicKey; let public_hash = |key: &[u8; 32]| { - let key = SecretKey::from_slice(key).expect("generated valid key"); - let public = PublicKey::from_secret_key(&Secp256k1::new(), &key); + let key = dash_sdk::dpp::secp256k1::SecretKey::from_secret_bytes(*(key)) + .expect("generated valid key"); + let public = PublicKey::from_secret_key(&key); hex::encode(hash160::Hash::hash(&public.serialize()).to_byte_array()) }; let encoded = V093_MASTERNODE_BLOB_HEX @@ -1642,12 +1641,11 @@ const SKIPPED_PROTECTED_ADDRESS: &str = "XprotectedPaperKeyAddress"; /// The P2PKH address of [`SKIPPED_SINGLE_KEY`] on mainnet. fn skipped_single_key_address() -> String { - use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; use dash_sdk::dpp::dashcore::{Address, PrivateKey, PublicKey}; let key = PrivateKey::from_byte_array(&SKIPPED_SINGLE_KEY, Network::Mainnet).expect("key"); let public = PublicKey { compressed: true, - inner: key.inner.public_key(&Secp256k1::new()), + inner: key.inner.public_key(), }; Address::p2pkh(&public, Network::Mainnet).to_string() } diff --git a/src/backend_task/wallet/derive_identity_key_for_display.rs b/src/backend_task/wallet/derive_identity_key_for_display.rs index 1d7efe1f2..52dd10322 100644 --- a/src/backend_task/wallet/derive_identity_key_for_display.rs +++ b/src/backend_task/wallet/derive_identity_key_for_display.rs @@ -28,7 +28,9 @@ impl AppContext { let network = self.network; let wif = self .with_identity_secret_key(identity_id, target.clone(), key_id, |secret_key| { - let private_key = PrivateKey::new(secret_key, network); + let private_key = + PrivateKey::from_byte_array(secret_key.as_secret_bytes(), network) + .map_err(|_| TaskError::IdentityKeyMalformed)?; Ok(Secret::new(private_key.to_wif())) }) .await?; diff --git a/src/backend_task/wallet/mod.rs b/src/backend_task/wallet/mod.rs index 1d7011244..64afbe9ec 100644 --- a/src/backend_task/wallet/mod.rs +++ b/src/backend_task/wallet/mod.rs @@ -18,14 +18,15 @@ use crate::model::qualified_identity::PrivateKeyTarget; use crate::model::wallet::WalletSeedHash; use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::balances::credits::Credits; +use dash_sdk::dpp::dashcore::ecdsa::EcdsaRecSignature; use dash_sdk::dpp::dashcore::hashes::Hash; -use dash_sdk::dpp::dashcore::secp256k1::{Message, Secp256k1, SecretKey}; -use dash_sdk::dpp::dashcore::sign_message::{MessageSignature, signed_msg_hash}; +use dash_sdk::dpp::dashcore::sign_message::{self, signed_msg_hash}; use dash_sdk::dpp::dashcore::{OutPoint, PrivateKey}; use dash_sdk::dpp::identity::core_script::CoreScript; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::identity::{KeyID, KeyType}; use dash_sdk::dpp::key_wallet::bip32::DerivationPath; +use dash_sdk::dpp::secp256k1::{Message, SecretKey}; use dash_sdk::platform::Identifier; use std::collections::BTreeMap; use std::sync::Arc; @@ -40,11 +41,19 @@ pub(crate) fn dash_signed_message( secret_key: &SecretKey, compressed: bool, ) -> String { - let secp = Secp256k1::new(); let message_hash = signed_msg_hash(message); let digest = Message::from_digest(*message_hash.as_byte_array()); - let recoverable = secp.sign_ecdsa_recoverable(&digest, secret_key); - MessageSignature::new(recoverable, compressed).to_base64() + let recoverable = dash_sdk::dpp::secp256k1::ecdsa::RecoverableSignature::sign_ecdsa_recoverable( + digest, secret_key, + ); + let (recovery_id, _) = recoverable.serialize_compact(); + let envelope = EcdsaRecSignature::from_parts( + recoverable.to_standard().into(), + recovery_id.to_u8(), + compressed, + ) + .expect("a recovery id from signing is in range"); + sign_message::to_base64(envelope) } impl AppContext { @@ -183,7 +192,7 @@ impl AppContext { .ok_or(TaskError::IdentityKeyMissing)?; // Present-but-malformed key bytes are distinct from a // genuinely absent key and from a signing/derivation failure. - let secret_key = SecretKey::from_byte_array(key).map_err(|detail| { + let secret_key = SecretKey::from_secret_bytes(*key).map_err(|detail| { tracing::warn!(error = %detail, "Identity-key secret construction failed"); TaskError::IdentityKeyMalformed })?; @@ -375,13 +384,13 @@ mod tests { use crate::utils::tasks::TaskManager; use crate::wallet_backend::IdentityKeyView; use dash_sdk::dpp::dashcore::Network; - use dash_sdk::dpp::dashcore::secp256k1::PublicKey; - use dash_sdk::dpp::dashcore::sign_message::{MessageSignature, signed_msg_hash}; + use dash_sdk::dpp::dashcore::sign_message::{self, signed_msg_hash}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dash_sdk::dpp::identity::{Identity, Purpose, SecurityLevel}; use dash_sdk::dpp::key_wallet::bip32::ChildNumber; use dash_sdk::dpp::platform_value::BinaryData; + use dash_sdk::dpp::secp256k1::PublicKey; use dash_sdk::dpp::version::PlatformVersion; use dash_sdk::platform::IdentityPublicKey; use std::sync::atomic::{AtomicBool, Ordering}; @@ -506,22 +515,43 @@ mod tests { /// hardcoded recovery header would fail ~50% of the time here. Both the /// wallet-key and identity-key signers call this one helper. fn assert_recovers(compressed: bool) { - let secp = Secp256k1::new(); - let secret_key = SecretKey::from_byte_array(&[0x42u8; 32]).expect("valid secret"); - let expected_pubkey = PublicKey::from_secret_key(&secp, &secret_key); + let secret_key = SecretKey::from_secret_bytes([0x42u8; 32]).expect("valid secret"); + let expected_pubkey = PublicKey::from_secret_key(&secret_key); let message = "Bilby was here"; let base64 = dash_signed_message(message, &secret_key, compressed); - let parsed = MessageSignature::from_base64(&base64).expect("valid envelope"); - assert_eq!(parsed.compressed, compressed); + let parsed = sign_message::from_base64(&base64).expect("valid envelope"); + assert_eq!(parsed.is_compressed().expect("valid header"), compressed); - let recovered = parsed - .recover_pubkey(&secp, signed_msg_hash(message)) + let recovered = sign_message::recover_pubkey(&parsed, signed_msg_hash(message)) .expect("recovers a public key"); - assert_eq!(recovered.inner, expected_pubkey); + assert_eq!(PublicKey::from(recovered.inner), expected_pubkey); assert_eq!(recovered.compressed, compressed); } + /// The envelope keeps the layout the previous encoder produced: a header + /// byte `27 + recovery id` (`+ 4` when compressed), then the compact + /// `r || s` of the same deterministic signature. + #[test] + fn the_envelope_keeps_its_byte_layout() { + use dash_sdk::dpp::secp256k1::Message; + use dash_sdk::dpp::secp256k1::ecdsa::RecoverableSignature; + let message = "Bilby was here"; + let secret_key = SecretKey::from_secret_bytes([0x42u8; 32]).expect("valid secret"); + let digest = Message::from_digest(*signed_msg_hash(message).as_byte_array()); + let (recovery_id, compact) = + RecoverableSignature::sign_ecdsa_recoverable(digest, &secret_key).serialize_compact(); + for compressed in [false, true] { + let mut expected = [0u8; 65]; + expected[0] = 27 + recovery_id.to_u8() + if compressed { 4 } else { 0 }; + expected[1..].copy_from_slice(&compact); + let encoded = + sign_message::from_base64(&dash_signed_message(message, &secret_key, compressed)) + .expect("valid envelope"); + assert_eq!(encoded.as_bytes(), &expected); + } + } + #[test] fn recovers_signer_pubkey_compressed() { assert_recovers(true); @@ -603,7 +633,7 @@ mod tests { let bytes = fixture .ctx - .with_identity_secret_key(identity_id, MAIN, 7, |key| Ok(key.secret_bytes())) + .with_identity_secret_key(identity_id, MAIN, 7, |key| Ok(key.to_secret_bytes())) .await .expect("a key that matches its record must resolve"); @@ -746,7 +776,7 @@ mod tests { let bytes = fixture .ctx - .with_identity_secret_key(identity_id, MAIN, 7, |key| Ok(key.secret_bytes())) + .with_identity_secret_key(identity_id, MAIN, 7, |key| Ok(key.to_secret_bytes())) .await .expect("the sibling placement's live bytes must be served"); diff --git a/src/backend_task/wallet/rename_wallet.rs b/src/backend_task/wallet/rename_wallet.rs index 963687315..b2f86c08c 100644 --- a/src/backend_task/wallet/rename_wallet.rs +++ b/src/backend_task/wallet/rename_wallet.rs @@ -94,8 +94,8 @@ mod tests { use crate::utils::tasks::TaskManager; use crate::wallet_backend::kv_test_support::InMemoryKv; use crate::wallet_backend::{DetKv, DetScope}; - use dash_sdk::dpp::dashcore::secp256k1::SecretKey; use dash_sdk::dpp::dashcore::{Network, PrivateKey}; + use dash_sdk::dpp::secp256k1::SecretKey; use platform_wallet_storage::{KvError, KvStore, ObjectId}; use std::sync::{Condvar, Mutex}; use std::time::Duration; @@ -804,8 +804,10 @@ mod tests { let f = fixture().await; let backend = f.ctx.wallet_backend().expect("backend"); // Mint a deterministic throwaway key rather than committing a WIF. - let sk = SecretKey::from_byte_array(&[0x11u8; 32]).expect("valid scalar"); - let wif = PrivateKey::new(sk, Network::Testnet).to_wif(); + let sk = SecretKey::from_secret_bytes([0x11u8; 32]).expect("valid scalar"); + let wif = PrivateKey::from_byte_array(sk.as_secret_bytes(), Network::Testnet) + .expect("valid secret key") + .to_wif(); let (imported, display_wallet) = f .ctx .import_single_key_wif( @@ -850,8 +852,10 @@ mod tests { use crate::model::wallet::alias::AliasSource; let f = fixture().await; let backend = f.ctx.wallet_backend().expect("backend"); - let key = SecretKey::from_byte_array(&[0x33; 32]).expect("valid scalar"); - let wif = PrivateKey::new(key, Network::Testnet).to_wif(); + let key = SecretKey::from_secret_bytes([0x33; 32]).expect("valid scalar"); + let wif = PrivateKey::from_byte_array(key.as_secret_bytes(), Network::Testnet) + .expect("valid secret key") + .to_wif(); let (first, retained) = f .ctx .import_single_key_wif( @@ -902,8 +906,10 @@ mod tests { async fn rename_single_key_blank_alias_resets_to_default_name() { let f = fixture().await; let backend = f.ctx.wallet_backend().expect("backend"); - let sk = SecretKey::from_byte_array(&[0x22u8; 32]).expect("valid scalar"); - let wif = PrivateKey::new(sk, Network::Testnet).to_wif(); + let sk = SecretKey::from_secret_bytes([0x22u8; 32]).expect("valid scalar"); + let wif = PrivateKey::from_byte_array(sk.as_secret_bytes(), Network::Testnet) + .expect("valid secret key") + .to_wif(); let address = backend .single_key() .import_wif( diff --git a/src/backend_task/wallet/sign_message_with_key.rs b/src/backend_task/wallet/sign_message_with_key.rs index c0f4cf28c..c52dd6a58 100644 --- a/src/backend_task/wallet/sign_message_with_key.rs +++ b/src/backend_task/wallet/sign_message_with_key.rs @@ -6,9 +6,9 @@ use crate::backend_task::error::TaskError; use crate::backend_task::wallet::dash_signed_message; use crate::context::AppContext; use crate::model::wallet::WalletSeedHash; -use dash_sdk::dpp::dashcore::secp256k1::SecretKey; use dash_sdk::dpp::identity::KeyType; use dash_sdk::dpp::key_wallet::bip32::DerivationPath; +use dash_sdk::dpp::secp256k1::SecretKey; use std::sync::Arc; impl AppContext { @@ -40,7 +40,7 @@ impl AppContext { &derivation_path, TaskError::WalletMessageSigningFailed, |private_key| { - let secret_key = SecretKey::from_byte_array(&private_key.inner.secret_bytes()) + let secret_key = SecretKey::from_secret_bytes(*private_key.inner.to_bytes()) .map_err(|detail| { tracing::warn!(error = %detail, "Sign-message secret key construction failed"); TaskError::WalletMessageSigningFailed diff --git a/src/context/wallet_lifecycle/tests.rs b/src/context/wallet_lifecycle/tests.rs index 7880d5609..dece54874 100644 --- a/src/context/wallet_lifecycle/tests.rs +++ b/src/context/wallet_lifecycle/tests.rs @@ -2335,7 +2335,7 @@ async fn clear_network_database_wipes_wallet_meta_and_seed_envelope() { async fn clear_network_database_removes_an_auth_pubkey_cache_rewritten_mid_clear() { use crate::backend_task::system_task::SystemTask; use dash_sdk::dpp::dashcore::PublicKey; - use dash_sdk::dpp::dashcore::secp256k1::{Secp256k1, SecretKey}; + use dash_sdk::dpp::secp256k1::SecretKey; let (ctx, sender, _tmp) = offline_testnet_context(); ctx.ensure_wallet_backend(sender.clone()) @@ -2351,11 +2351,10 @@ async fn clear_network_database_removes_an_auth_pubkey_cache_rewritten_mid_clear let backend = ctx.wallet_backend().expect("backend wired"); let late_writer = Arc::clone(&backend); backend.set_after_forget_all_test_hook(move || { - let secp = Secp256k1::new(); let key = PublicKey::new( - SecretKey::from_slice(&[0x42; 32]) + SecretKey::from_secret_bytes([0x42; 32]) .expect("valid secret key") - .public_key(&secp), + .public_key(), ); late_writer .auth_pubkey_cache() @@ -3596,7 +3595,7 @@ fn seed_legacy_protected_single_key( alias: Option<&str>, ) -> String { use crate::model::wallet::single_key::ClosedSingleKey; - use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; + use dash_sdk::dpp::dashcore::{Address, PrivateKey, PublicKey}; let path = ctx.db.db_file_path().expect("data.db path"); @@ -3608,10 +3607,10 @@ fn seed_legacy_protected_single_key( nonce, } = ClosedSingleKey::encrypt_private_key(raw_key, password).expect("encrypt"); let priv_key = PrivateKey::from_byte_array(raw_key, Network::Testnet).expect("priv"); - let secp = Secp256k1::new(); + let pub_key = PublicKey { compressed: priv_key.compressed, - inner: priv_key.inner.public_key(&secp), + inner: priv_key.inner.public_key(), }; let address = Address::p2pkh(&pub_key, Network::Testnet).to_string(); let key_hash = ClosedSingleKey::compute_key_hash(raw_key); @@ -3625,7 +3624,7 @@ fn seed_legacy_protected_single_key( ciphertext, salt, nonce, - pub_key.inner.serialize().to_vec(), + pub_key.inner.to_compressed().to_vec(), address, alias, Network::Testnet.to_string(), diff --git a/src/database/test_helpers.rs b/src/database/test_helpers.rs index 17dc6123c..01248ae89 100644 --- a/src/database/test_helpers.rs +++ b/src/database/test_helpers.rs @@ -161,21 +161,21 @@ pub fn legacy_master_epk_bytes( network: dash_sdk::dpp::dashcore::Network, ) -> Vec { use dash_sdk::dpp::dashcore::Network; - use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; + use dash_sdk::dpp::key_wallet::bip32::{ ChildNumber, DerivationPath, ExtendedPrivKey, ExtendedPubKey, }; let coin_type = if network == Network::Mainnet { 5 } else { 1 }; - let secp = Secp256k1::new(); + let master = ExtendedPrivKey::new_master(network, seed).expect("master key"); let path = DerivationPath::from(vec![ ChildNumber::Hardened { index: 44 }, ChildNumber::Hardened { index: coin_type }, ChildNumber::Hardened { index: 0 }, ]); - let account = master.derive_priv(&secp, &path).expect("derive account"); - ExtendedPubKey::from_priv(&secp, &account).encode().to_vec() + let account = master.derive_priv(&path).expect("derive account"); + ExtendedPubKey::from_priv(&account).encode().to_vec() } /// Create the legacy `scheduled_votes` table in `data.db`. Fresh installs no diff --git a/src/database/wallet.rs b/src/database/wallet.rs index 5e4a793a4..bbae151d3 100644 --- a/src/database/wallet.rs +++ b/src/database/wallet.rs @@ -554,7 +554,7 @@ impl From for rusqlite::Error { mod tests { use super::*; use crate::database::test_helpers::create_test_database; - use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; + use dash_sdk::dpp::key_wallet::bip32::DerivationPath; use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, ExtendedPrivKey}; @@ -568,17 +568,15 @@ mod tests { fn create_test_master_epk_bytes(network: Network) -> Vec { let seed = [7u8; 64]; - let secp = Secp256k1::new(); + let master = ExtendedPrivKey::new_master(network, &seed).expect("master key"); let path = DerivationPath::from(vec![ ChildNumber::Hardened { index: 44 }, ChildNumber::Hardened { index: 1 }, ChildNumber::Hardened { index: 0 }, ]); - let account = master - .derive_priv(&secp, &path) - .expect("derive bip44 account"); - ExtendedPubKey::from_priv(&secp, &account).encode() + let account = master.derive_priv(&path).expect("derive bip44 account"); + ExtendedPubKey::from_priv(&account).encode() } #[test] diff --git a/src/mcp/tools/masternode.rs b/src/mcp/tools/masternode.rs index eb210dc69..24910a17b 100644 --- a/src/mcp/tools/masternode.rs +++ b/src/mcp/tools/masternode.rs @@ -699,13 +699,13 @@ mod tests { /// Derive a real, checksum-valid testnet P2PKH address from a fixed key. fn sample_core_address() -> String { - use dash_sdk::dashcore_rpc::dashcore::secp256k1::{Secp256k1, SecretKey}; use dash_sdk::dashcore_rpc::dashcore::{Address, Network, PrivateKey, PublicKey}; + use dash_sdk::dpp::secp256k1::SecretKey; - let secp = Secp256k1::new(); - let sk = SecretKey::from_slice(&[7u8; 32]).expect("valid secret key"); - let privkey = PrivateKey::new(sk, Network::Testnet); - let pubkey = PublicKey::from_private_key(&secp, &privkey); + let sk = SecretKey::from_secret_bytes([7u8; 32]).expect("valid secret key"); + let privkey = PrivateKey::from_byte_array(sk.as_secret_bytes(), Network::Testnet) + .expect("valid secret key"); + let pubkey = PublicKey::from_private_key(&privkey); Address::p2pkh(&pubkey, Network::Testnet).to_string() } @@ -855,11 +855,14 @@ mod tests { } fn testnet_core_address() -> String { - use dash_sdk::dashcore_rpc::dashcore::secp256k1::{Secp256k1, SecretKey}; use dash_sdk::dashcore_rpc::dashcore::{Address, Network, PrivateKey, PublicKey}; - let secp = Secp256k1::new(); - let sk = SecretKey::from_slice(&[9u8; 32]).expect("valid secret key"); - let pk = PublicKey::from_private_key(&secp, &PrivateKey::new(sk, Network::Testnet)); + use dash_sdk::dpp::secp256k1::SecretKey; + + let sk = SecretKey::from_secret_bytes([9u8; 32]).expect("valid secret key"); + let pk = PublicKey::from_private_key( + &PrivateKey::from_byte_array(sk.as_secret_bytes(), Network::Testnet) + .expect("valid secret key"), + ); Address::p2pkh(&pk, Network::Testnet).to_string() } @@ -933,11 +936,14 @@ mod tests { #[test] fn transfer_mode_cross_network_address_rejected() { - use dash_sdk::dashcore_rpc::dashcore::secp256k1::{Secp256k1, SecretKey}; use dash_sdk::dashcore_rpc::dashcore::{Address, Network, PrivateKey, PublicKey}; - let secp = Secp256k1::new(); - let sk = SecretKey::from_slice(&[3u8; 32]).expect("valid secret key"); - let pk = PublicKey::from_private_key(&secp, &PrivateKey::new(sk, Network::Mainnet)); + use dash_sdk::dpp::secp256k1::SecretKey; + + let sk = SecretKey::from_secret_bytes([3u8; 32]).expect("valid secret key"); + let pk = PublicKey::from_private_key( + &PrivateKey::from_byte_array(sk.as_secret_bytes(), Network::Mainnet) + .expect("valid secret key"), + ); let mainnet_addr = Address::p2pkh(&pk, Network::Mainnet).to_string(); let qi = masternode_fixture(false, true); diff --git a/src/model/address.rs b/src/model/address.rs index 7f612d8ba..438ca9c23 100644 --- a/src/model/address.rs +++ b/src/model/address.rs @@ -587,13 +587,13 @@ mod tests { #[test] fn detect_core_address() { - use dash_sdk::dashcore_rpc::dashcore::secp256k1::{Secp256k1, SecretKey}; use dash_sdk::dashcore_rpc::dashcore::{PrivateKey, PublicKey}; + use dash_sdk::dpp::secp256k1::SecretKey; - let secp = Secp256k1::new(); - let sk = SecretKey::from_slice(&[1u8; 32]).unwrap(); - let privkey = PrivateKey::new(sk, Network::Testnet); - let pubkey = PublicKey::from_private_key(&secp, &privkey); + let sk = SecretKey::from_secret_bytes([1u8; 32]).unwrap(); + let privkey = PrivateKey::from_byte_array(sk.as_secret_bytes(), Network::Testnet) + .expect("valid secret key"); + let pubkey = PublicKey::from_private_key(&privkey); let addr = Address::p2pkh(&pubkey, Network::Testnet); assert_eq!( AddressKind::detect(&addr.to_string()), diff --git a/src/model/dashpay_derivation/dip14.rs b/src/model/dashpay_derivation/dip14.rs index 923a1fc36..1009eaf40 100644 --- a/src/model/dashpay_derivation/dip14.rs +++ b/src/model/dashpay_derivation/dip14.rs @@ -1,4 +1,5 @@ use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::dashcore::ecdsa::EcdsaPublicKey; use dash_sdk::dpp::dashcore::hashes::hmac::{Hmac, HmacEngine}; use dash_sdk::dpp::dashcore::hashes::sha512; /// DIP-14 compliant 256-bit HD key derivation implementation @@ -6,8 +7,8 @@ use dash_sdk::dpp::dashcore::hashes::sha512; /// This module implements Extended Key Derivation using 256-bit Unsigned Integers /// as specified in DIP-0014 for DashPay contact relationships. use dash_sdk::dpp::dashcore::hashes::{Hash, HashEngine}; -use dash_sdk::dpp::dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; use dash_sdk::dpp::key_wallet::bip32::{ChainCode, ExtendedPrivKey, ExtendedPubKey, Fingerprint}; +use dash_sdk::dpp::secp256k1::SecretKey; use dash_sdk::platform::Identifier; use super::DerivationError; @@ -22,8 +23,6 @@ pub fn ckd_priv_256( index: &[u8; 32], // 256-bit index hardened: bool, ) -> Result { - let secp = Secp256k1::new(); - // Check if this is a compatibility mode derivation (index < 2^32) let is_compatibility_mode = is_index_less_than_2_32(index); @@ -33,7 +32,7 @@ pub fn ckd_priv_256( if hardened { // Hardened derivation: 0x00 || ser_256(k_par) || ser(i) hmac_engine.input(&[0x00]); - hmac_engine.input(&parent_key.private_key.secret_bytes()); + hmac_engine.input(&*parent_key.private_key.to_bytes()); if is_compatibility_mode { // Use ser_32(i) for compatibility @@ -44,8 +43,8 @@ pub fn ckd_priv_256( } } else { // Non-hardened derivation: ser_P(point(k_par)) || ser(i) - let parent_pubkey = parent_key.private_key.public_key(&secp); - hmac_engine.input(&parent_pubkey.serialize()); + let parent_pubkey = parent_key.private_key.public_key(); + hmac_engine.input(&parent_pubkey.to_compressed()); if is_compatibility_mode { // Use ser_32(i) for compatibility @@ -62,11 +61,15 @@ pub fn ckd_priv_256( // Split into I_L (first 32 bytes) and I_R (last 32 bytes) let (i_l, i_r) = hmac_bytes.split_at(32); - // Parse I_L as a private key and add to parent key - let i_l_key = SecretKey::from_slice(i_l)?; + // Parse I_L as a private key (refusing zero and values >= n) and add it + // to the parent key. + let i_l: &[u8; 32] = i_l + .try_into() + .map_err(|_| dash_sdk::dpp::secp256k1::Error::InvalidSecretKey)?; + SecretKey::from_secret_bytes(*i_l)?; // k_i = parse_256(I_L) + k_par (mod n) - let child_key = parent_key.private_key.add_tweak(&i_l_key.into())?; + let child_key = parent_key.private_key.add_tweak(i_l)?; // Chain code is I_R (32 bytes) let mut chain_code_bytes = [0u8; 32]; @@ -74,7 +77,7 @@ pub fn ckd_priv_256( let child_chain_code = ChainCode::from(chain_code_bytes); // Calculate child fingerprint - let parent_pubkey = parent_key.private_key.public_key(&secp); + let parent_pubkey = parent_key.private_key.public_key(); let parent_fingerprint = calculate_fingerprint(&parent_pubkey); // Create the child extended private key @@ -113,8 +116,8 @@ pub fn derive_dashpay_incoming_xpub_dip14( let base_path = DerivationPath::from_str(&format!("m/9'/{coin_type}'/15'/{account}'"))?; // Derive to the account level using standard BIP32 - let secp = Secp256k1::new(); - let account_xprv = master_xprv.derive_priv(&secp, &base_path)?; + + let account_xprv = master_xprv.derive_priv(&base_path)?; // Now use DIP-14 256-bit derivation for the identity levels // Derive: account_key/(sender_id) @@ -126,7 +129,7 @@ pub fn derive_dashpay_incoming_xpub_dip14( let contact_xprv = ckd_priv_256(&sender_level, &recipient_index, false)?; // Convert to extended public key - Ok(ExtendedPubKey::from_priv(&secp, &contact_xprv)) + Ok(ExtendedPubKey::from_priv(&contact_xprv)) } /// Convert an Identifier to a 256-bit index for DIP-14 derivation @@ -175,10 +178,10 @@ fn index_to_child_number( } /// Calculate fingerprint for a public key (first 4 bytes of HASH160) -fn calculate_fingerprint(pubkey: &PublicKey) -> Fingerprint { +fn calculate_fingerprint(pubkey: &EcdsaPublicKey) -> Fingerprint { use dash_sdk::dpp::dashcore::hashes::hash160; - let hash = hash160::Hash::hash(&pubkey.serialize()); + let hash = hash160::Hash::hash(&pubkey.to_compressed()); let mut fingerprint_bytes = [0u8; 4]; fingerprint_bytes.copy_from_slice(&hash.to_byte_array()[0..4]); Fingerprint::from(fingerprint_bytes) diff --git a/src/model/dashpay_derivation/hd.rs b/src/model/dashpay_derivation/hd.rs index 9b38fbd8b..cf4a4862b 100644 --- a/src/model/dashpay_derivation/hd.rs +++ b/src/model/dashpay_derivation/hd.rs @@ -38,10 +38,8 @@ pub fn derive_payment_address( contact_xpub: &ExtendedPubKey, index: u32, ) -> Result { - let secp = dash_sdk::dpp::dashcore::secp256k1::Secp256k1::new(); - // Derive the specific address key - let address_key = contact_xpub.derive_pub(&secp, &[ChildNumber::from_normal_idx(index)?])?; + let address_key = contact_xpub.derive_pub(&[ChildNumber::from_normal_idx(index)?])?; // Convert to Dash address // The ExtendedPubKey's public_key is a secp256k1::PublicKey @@ -72,7 +70,7 @@ pub fn generate_contact_xpub_data( let chain_code = xpub.chain_code.to_bytes(); // Get the public key bytes (33 bytes compressed) - let public_key_bytes = xpub.public_key.serialize(); + let public_key_bytes = xpub.public_key.to_compressed(); Ok((parent_fingerprint, chain_code, public_key_bytes)) } @@ -96,8 +94,7 @@ pub fn derive_auto_accept_key( ))?; // Derive the key - let auto_accept_key = - master_xprv.derive_priv(&dash_sdk::dpp::dashcore::secp256k1::Secp256k1::new(), &path)?; + let auto_accept_key = master_xprv.derive_priv(&path)?; Ok(auto_accept_key) } @@ -151,10 +148,7 @@ mod tests { let master_seed = [2u8; 64]; let master_xprv = ExtendedPrivKey::new_master(network, &master_seed).unwrap(); - let xpub = ExtendedPubKey::from_priv( - &dash_sdk::dpp::dashcore::secp256k1::Secp256k1::new(), - &master_xprv, - ); + let xpub = ExtendedPubKey::from_priv(&master_xprv); let account_ref = calculate_account_reference(&secret_key, &xpub, 0, 0); @@ -255,8 +249,8 @@ mod tests { // Different timestamps should produce different keys assert_ne!( - key1.private_key.secret_bytes(), - key2.private_key.secret_bytes(), + key1.private_key.to_bytes(), + key2.private_key.to_bytes(), "Different timestamps should produce different keys" ); } @@ -300,8 +294,8 @@ mod tests { let master_seed = [2u8; 64]; let master_xprv = ExtendedPrivKey::new_master(network, &master_seed).unwrap(); - let secp = dash_sdk::dpp::dashcore::secp256k1::Secp256k1::new(); - let xpub = ExtendedPubKey::from_priv(&secp, &master_xprv); + + let xpub = ExtendedPubKey::from_priv(&master_xprv); // Test version 0 let ref_v0 = calculate_account_reference(&secret_key, &xpub, 0, 0); @@ -335,8 +329,8 @@ mod tests { .expect("Should derive xpub for account 1"); assert_ne!( - xpub_account0.public_key.serialize(), - xpub_account1.public_key.serialize(), + xpub_account0.public_key.to_compressed(), + xpub_account1.public_key.to_compressed(), "Different accounts should produce different xpubs" ); } @@ -373,8 +367,8 @@ mod tests { assert_eq!(xpub_testnet.network, Network::Testnet); assert_eq!(xpub_mainnet.network, Network::Mainnet); assert_ne!( - xpub_testnet.public_key.serialize(), - xpub_mainnet.public_key.serialize(), + xpub_testnet.public_key.to_compressed(), + xpub_mainnet.public_key.to_compressed(), "different coin types must produce different keys" ); } diff --git a/src/model/dashpay_derivation/mod.rs b/src/model/dashpay_derivation/mod.rs index 4363ffe6a..24a2aaad5 100644 --- a/src/model/dashpay_derivation/mod.rs +++ b/src/model/dashpay_derivation/mod.rs @@ -24,5 +24,9 @@ pub enum DerivationError { /// A secp256k1 operation on derived key material failed. #[error("The wallet key material could not be processed. The wallet data may be invalid.")] - Secp256k1(#[from] dash_sdk::dpp::dashcore::secp256k1::Error), + Secp256k1(#[from] dash_sdk::dpp::secp256k1::Error), + + /// An ECDSA operation on derived key material failed. + #[error("The wallet key material could not be processed. The wallet data may be invalid.")] + Ecdsa(#[from] dash_sdk::dpp::dashcore::ecdsa::EcdsaError), } diff --git a/src/model/key_input.rs b/src/model/key_input.rs index af7008b5b..cf7793f29 100644 --- a/src/model/key_input.rs +++ b/src/model/key_input.rs @@ -60,7 +60,7 @@ pub fn verify_key_input( key_name: key_name.to_owned(), }), 51 | 52 => PrivateKey::from_wif(private_key) - .map(|key| Some(key.inner.secret_bytes())) + .map(|key| Some(*key.inner.to_bytes())) .map_err(|_| KeyInputError::BadWif { key_name: key_name.to_owned(), }), diff --git a/src/model/qualified_identity/mod.rs b/src/model/qualified_identity/mod.rs index e8d48b64d..4166ab369 100644 --- a/src/model/qualified_identity/mod.rs +++ b/src/model/qualified_identity/mod.rs @@ -18,7 +18,6 @@ use crate::model::wallet::{Wallet, WalletSeedHash}; use bincode::{Decode, Encode}; use dash_sdk::dashcore_rpc::dashcore::{PubkeyHash, signer}; use dash_sdk::dpp::async_trait::async_trait; -use dash_sdk::dpp::bls_signatures::{Bls12381G2Impl, SignatureSchemes}; use dash_sdk::dpp::dashcore::address::Payload; use dash_sdk::dpp::dashcore::hashes::Hash; use dash_sdk::dpp::dashcore::{Address, Network, ScriptHash}; @@ -35,7 +34,7 @@ use dash_sdk::dpp::key_wallet::bip32::ChildNumber; use dash_sdk::dpp::platform_value::BinaryData; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::state_transition::errors::InvalidIdentityPublicKeyTypeError; -use dash_sdk::dpp::{ProtocolError, bls_signatures, ed25519_dalek}; +use dash_sdk::dpp::{ProtocolError, bls, ed25519_dalek}; use dash_sdk::platform::IdentityPublicKey; use std::collections::{BTreeMap, HashSet}; use std::fmt::{Display, Formatter}; @@ -445,13 +444,12 @@ impl Signer for QualifiedIdentity { if identity_public_key.key_type() == KeyType::ECDSA_HASH160 { use dash_sdk::dpp::dashcore::PublicKey; use dash_sdk::dpp::dashcore::hashes::{Hash, ripemd160, sha256}; - use dash_sdk::dpp::dashcore::secp256k1::{Secp256k1, SecretKey}; + use dash_sdk::dpp::secp256k1::SecretKey; let platform_key_data = identity_public_key.data().as_slice(); - if let Ok(secret_key) = SecretKey::from_slice(&private_key[..]) { - let secp = Secp256k1::new(); - let derived_pubkey = PublicKey::new(secret_key.public_key(&secp)); + if let Ok(secret_key) = SecretKey::from_secret_bytes(*private_key) { + let derived_pubkey = PublicKey::new(secret_key.public_key()); let pubkey_bytes = derived_pubkey.to_bytes(); let sha256_hash = sha256::Hash::hash(&pubkey_bytes); let hash160 = ripemd160::Hash::hash(sha256_hash.as_byte_array()); @@ -482,17 +480,10 @@ impl Signer for QualifiedIdentity { Ok(signature.to_vec().into()) } KeyType::BLS12_381 => { - let pk = bls_signatures::SecretKey::::from_be_bytes(&private_key) - .into_option() - .ok_or(ProtocolError::Generic( - "bls private key from bytes isn't correct".to_string(), - ))?; - Ok(pk - .sign(SignatureSchemes::Basic, data)? - .as_raw_value() - .to_compressed() - .to_vec() - .into()) + let pk = bls::SecretKey::from_be_bytes(&private_key).ok_or( + ProtocolError::Generic("bls private key from bytes isn't correct".to_string()), + )?; + Ok(pk.sign(data)?.to_bytes().to_vec().into()) } KeyType::EDDSA_25519_HASH160 => { let pk = ed25519_dalek::SigningKey::from(&*private_key); @@ -990,7 +981,7 @@ impl QualifiedIdentity { ) -> Result, ProtocolError> { use dash_sdk::dpp::dashcore::PublicKey; use dash_sdk::dpp::dashcore::hashes::{Hash, ripemd160, sha256}; - use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; + use dash_sdk::dpp::key_wallet::bip32::{DerivationPath as DP, KeyDerivationType}; let (Some(secret_access), Some(seed_hash)) = @@ -1012,7 +1003,7 @@ impl QualifiedIdentity { let Some(seed) = plaintext.expose_hd_seed() else { return Ok(None); }; - let secp = Secp256k1::new(); + for identity_index in 0..10u32 { let correct_path = DP::identity_authentication_path( network, @@ -1025,8 +1016,7 @@ impl QualifiedIdentity { else { continue; }; - let correct_pubkey = - PublicKey::new(extended_key.private_key.public_key(&secp)); + let correct_pubkey = PublicKey::new(extended_key.private_key.public_key()); let correct_hash = ripemd160::Hash::hash( sha256::Hash::hash(&correct_pubkey.to_bytes()).as_byte_array(), ); @@ -1038,7 +1028,7 @@ impl QualifiedIdentity { "Using corrected derivation path for signing (found via scan)" ); let signature = - signer::sign(&data, &extended_key.private_key.secret_bytes()) + signer::sign(&data, extended_key.private_key.to_bytes().as_slice()) .map_err(|_| TaskError::EncryptionError { detail: "Failed to sign with the recovered derivation path." @@ -2022,6 +2012,34 @@ mod key_resolution_tests { } } + #[tokio::test] + async fn bls_signing_preserves_the_previous_sdk_basic_signature_bytes() { + // Generated with the previous pin's blsful Basic signer using this synthetic key. + let secret = [0x11; 32]; + let public = bls::SecretKey::from_be_bytes(&secret).unwrap().public_key(); + let key = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 3, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::BLS12_381, + read_only: false, + data: BinaryData::new(public.to_bytes().to_vec()), + disabled_at: None, + }); + let identity = masternode_with(&key, &[(MAIN, PrivateKeyData::Clear(secret))]); + let signature = identity + .sign(&key, b"DET BLS adapter parity") + .await + .unwrap(); + assert_eq!( + hex::encode(signature.as_slice()), + "94765bd6758b7cd55024a38dc591f3090e9535d97a94ae7674239c559b4733e64dcae72c3d50d96788dfb0475e921bd608410c7a556ab0fab22f9ceb15139c54f8cf8a34e3900f7ab58c6497d3e9b7f6e04445237da30cf635550276eb981c45" + ); + let invalid = masternode_with(&key, &[(MAIN, PrivateKeyData::Clear([0; 32]))]); + assert!(invalid.sign(&key, b"DET BLS adapter parity").await.is_err()); + } + /// T0 — a `Purpose::VOTING` key on the main identity must be signable /// wherever its private half is filed. Held under `Main` is what the /// authoritative loader writes and what the structural target names; held diff --git a/src/model/qualified_identity/qualified_identity_public_key.rs b/src/model/qualified_identity/qualified_identity_public_key.rs index dbfd56424..b5e1e0966 100644 --- a/src/model/qualified_identity/qualified_identity_public_key.rs +++ b/src/model/qualified_identity/qualified_identity_public_key.rs @@ -132,11 +132,11 @@ fn find_wallet_path( mod tests { use super::*; use dash_sdk::dpp::dashcore::hashes::Hash; - use dash_sdk::dpp::dashcore::secp256k1::{Secp256k1, SecretKey}; use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dash_sdk::dpp::identity::{Purpose, SecurityLevel}; use dash_sdk::dpp::key_wallet::bip32::{DerivationPath, KeyDerivationType}; use dash_sdk::dpp::platform_value::BinaryData; + use dash_sdk::dpp::secp256k1::SecretKey; const TEST_SEED: [u8; 64] = [7u8; 64]; @@ -145,9 +145,9 @@ mod tests { fn pubkey(n: u8) -> PublicKey { let mut sk_bytes = [3u8; 32]; sk_bytes[31] = n.max(1); - let secp = Secp256k1::new(); - let sk = SecretKey::from_slice(&sk_bytes).expect("valid secret key"); - PublicKey::new(sk.public_key(&secp)) + + let sk = SecretKey::from_secret_bytes(sk_bytes).expect("valid secret key"); + PublicKey::new(sk.public_key()) } /// An identity public key of `key_type` carrying `data`. `id`/`purpose`/ diff --git a/src/model/single_key.rs b/src/model/single_key.rs index 85b21b8b0..27951c958 100644 --- a/src/model/single_key.rs +++ b/src/model/single_key.rs @@ -10,7 +10,7 @@ //! (spending gated) until single-key moves onto the upstream wallet runtime. use dash_sdk::dpp::dashcore::key::Error as KeyError; -use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; + use dash_sdk::dpp::dashcore::{Address, Network, PrivateKey, PublicKey}; use serde::{Deserialize, Serialize}; @@ -22,10 +22,10 @@ use serde::{Deserialize, Serialize}; /// task is the authoritative enforcement layer. pub fn validate_wif(wif: &str, network: Network) -> Result { let priv_key = PrivateKey::from_wif(wif)?; - let secp = Secp256k1::new(); + let pub_key = PublicKey { compressed: priv_key.compressed, - inner: priv_key.inner.public_key(&secp), + inner: priv_key.inner.public_key(), }; let address = Address::p2pkh(&pub_key, network); Ok(address.to_string()) diff --git a/src/model/wallet/auth_pubkey_cache.rs b/src/model/wallet/auth_pubkey_cache.rs index 374653d82..4dcb8284a 100644 --- a/src/model/wallet/auth_pubkey_cache.rs +++ b/src/model/wallet/auth_pubkey_cache.rs @@ -117,7 +117,7 @@ impl AuthPubkeyCache { public_key: &PublicKey, ) -> bool { let coord = AuthKeyCoord::new(network, identity_index, key_index); - let bytes = public_key.inner.serialize().to_vec(); + let bytes = public_key.inner.to_compressed().to_vec(); match self.entries.entry(coord) { Entry::Occupied(e) if *e.get() == bytes => false, Entry::Occupied(mut e) => { @@ -156,15 +156,12 @@ impl std::fmt::Debug for AuthPubkeyCache { #[cfg(test)] mod tests { use super::*; - use dash_sdk::dpp::dashcore::secp256k1::{ - PublicKey as Secp256k1PublicKey, Secp256k1, SecretKey, - }; + use dash_sdk::dpp::secp256k1::{PublicKey as Secp256k1PublicKey, SecretKey}; /// Deterministic test public key from a one-byte seed. fn pubkey(seed: u8) -> PublicKey { - let secp = Secp256k1::new(); - let sk = SecretKey::from_slice(&[seed.max(1); 32]).expect("valid secret key"); - let inner = Secp256k1PublicKey::from_secret_key(&secp, &sk); + let sk = SecretKey::from_secret_bytes([seed.max(1); 32]).expect("valid secret key"); + let inner = Secp256k1PublicKey::from_secret_key(&sk); PublicKey::new(inner) } @@ -187,7 +184,7 @@ mod tests { assert!(cache.insert(Network::Mainnet, 1, 2, &pk)); let got = cache.get(Network::Mainnet, 1, 2).expect("hit"); assert_eq!(got, pk); - assert_eq!(got.inner.serialize(), pk.inner.serialize()); + assert_eq!(got.inner.to_compressed(), pk.inner.to_compressed()); } /// AUTH-CACHE-003 — coordinates are network-keyed: the same indices diff --git a/src/model/wallet/mod.rs b/src/model/wallet/mod.rs index d84ab6ac2..600c585e9 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -22,7 +22,6 @@ use dash_sdk::dpp::prelude::AddressNonce; use dash_sdk::platform::address_sync::{AddressFunds, AddressIndex, AddressProvider}; use platform_wallet_storage::secrets::{MAX_PASSPHRASE_LEN, MIN_PASSPHRASE_LEN}; -use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; use dash_sdk::dpp::dashcore::{ Address, BlockHash, Network, PrivateKey, PublicKey, Transaction, Txid, }; @@ -486,14 +485,13 @@ impl Wallet { } })?; let bip44_path = Self::bip44_account0_path(network); - let secp = Secp256k1::new(); - let account_priv = master_priv.derive_priv(&secp, &bip44_path).map_err(|e| { + + let account_priv = master_priv.derive_priv(&bip44_path).map_err(|e| { WalletCreationError::KeyDerivation { source: Box::new(e), } })?; - let master_bip44_ecdsa_extended_public_key = - ExtendedPubKey::from_priv(&secp, &account_priv); + let master_bip44_ecdsa_extended_public_key = ExtendedPubKey::from_priv(&account_priv); // Cache the DIP-17 platform-payment account xpub up front (like the // BIP44 one above) so platform addresses stay reverse-derivable — @@ -511,7 +509,7 @@ impl Wallet { // Derive the first receive address (m/44'/coin'/0'/0/0) let (known_addresses, watched_addresses) = - Self::derive_first_address(&master_bip44_ecdsa_extended_public_key, network, &secp) + Self::derive_first_address(&master_bip44_ecdsa_extended_public_key, network) .map_err(|e| WalletCreationError::KeyDerivation { source: e.into() })?; Ok(Wallet { @@ -551,7 +549,6 @@ impl Wallet { fn derive_first_address( master_pub: &ExtendedPubKey, network: Network, - secp: &Secp256k1, ) -> Result< ( BTreeMap, @@ -570,7 +567,7 @@ impl Wallet { .as_slice(), ); - let pk = master_pub.derive_pub(secp, &address_path)?; + let pk = master_pub.derive_pub(&address_path)?; let address = Address::p2pkh(&pk.to_pub(), network); let bip44 = match network { Network::Mainnet => &DASH_BIP44_ACCOUNT_0_PATH_MAINNET, @@ -1036,18 +1033,13 @@ impl Wallet { for wallet in slice { let wallet_ref = wallet.read_recover(); if wallet_ref.seed_hash() == wallet_seed_hash { - // SECURITY: `ExtendedPrivKey` is a `Copy` BIP-32 type from - // key_wallet with no `Drop`, so its inner SecretKey + ChainCode - // cannot be wiped by RAII here. Extract the key straight into a - // `Zeroizing` buffer and never bind the intermediate to a named - // local; the transient copy left on the stack is the - // unavoidable residue of a third-party `Copy` type. - let secret = Zeroizing::new( - derivation_path - .derive_priv_ecdsa_for_master_seed(seed, network)? - .private_key - .secret_bytes(), - ); + // SECURITY: extract the key straight into a `Zeroizing` buffer + // and never bind the intermediate `ExtendedPrivKey` to a named + // local; its secret key wipes itself when the temporary drops. + let secret = derivation_path + .derive_priv_ecdsa_for_master_seed(seed, network)? + .private_key + .to_bytes(); return Ok(Some(secret)); } } @@ -1243,7 +1235,7 @@ impl Wallet { key_index: u32, public_key: &PublicKey, ) -> Result<(), WalletError> { - public_key_result_map.insert(public_key.inner.serialize().to_vec(), key_index); + public_key_result_map.insert(public_key.inner.to_compressed().to_vec(), key_index); public_key_hash_result_map.insert(public_key.pubkey_hash().to_byte_array(), key_index); if register_addresses { let derivation_path = DerivationPath::identity_authentication_path( @@ -1271,8 +1263,7 @@ impl Wallet { path_reference: DerivationPathReference, app_context: &AppContext, ) -> Result<(), WalletError> { - let secp = Secp256k1::new(); - let address = Address::p2pkh(&private_key.public_key(&secp), app_context.network); + let address = Address::p2pkh(&private_key.public_key(), app_context.network); self.register_address( address, derivation_path, @@ -1350,7 +1341,7 @@ impl Wallet { app_context: &AppContext, ) -> Result<(), WalletError> { let coin_type = coin_type_for_network(network); - let secp = Secp256k1::new(); + for (change_flag, max) in [ (false, BOOTSTRAP_BIP44_EXTERNAL_COUNT), (true, BOOTSTRAP_BIP44_CHANGE_COUNT), @@ -1364,8 +1355,8 @@ impl Wallet { ]; let derived = self .master_bip44_ecdsa_extended_public_key - .derive_pub(&secp, &child_path)?; - let dash_public_key = PublicKey::from_slice(&derived.public_key.serialize()) + .derive_pub(&child_path)?; + let dash_public_key = PublicKey::from_slice(&derived.public_key.to_compressed()) .map_err(|e| WalletError::PublicKeyParse(Box::new(e)))?; let derivation_path = DerivationPath::from(vec![ ChildNumber::Hardened { index: 44 }, @@ -1640,8 +1631,8 @@ impl Wallet { let private_key = extended_private_key.to_priv(); // Create a P2PKH address for platform payment - let secp = Secp256k1::new(); - let public_key = private_key.public_key(&secp); + + let public_key = private_key.public_key(); let platform_address = Address::p2pkh(&public_key, network); let canonical = Wallet::canonical_address(&platform_address, network); @@ -1699,7 +1690,6 @@ impl Wallet { seed: &[u8; 64], network: Network, ) -> Result { - let secp = Secp256k1::new(); let account = 0u32; let key_class = 0u32; @@ -1716,7 +1706,7 @@ impl Wallet { let extended_private_key = derivation_path.derive_priv_ecdsa_for_master_seed(seed, network)?; let private_key = extended_private_key.to_priv(); - let public_key = private_key.public_key(&secp); + let public_key = private_key.public_key(); // Create a P2PKH address for platform payment let platform_address = Address::p2pkh(&public_key, network); @@ -1733,7 +1723,6 @@ impl Wallet { change: bool, address_index: u32, ) -> Result { - let secp = Secp256k1::new(); let path_extension = [ ChildNumber::Normal { index: change as u32, @@ -1744,7 +1733,7 @@ impl Wallet { ]; let public_key = self .master_bip44_ecdsa_extended_public_key - .derive_pub(&secp, &path_extension)? + .derive_pub(&path_extension)? .to_pub(); Ok(Address::p2pkh(&public_key, network)) } @@ -1920,9 +1909,8 @@ impl Wallet { .max(DEFAULT_GAP_LIMIT) .saturating_add(PLATFORM_RECONCILE_SCAN_MARGIN); - let secp = Secp256k1::new(); for index in 0..=ceiling { - let child = match account_xpub.derive_pub(&secp, &[ChildNumber::Normal { index }]) { + let child = match account_xpub.derive_pub(&[ChildNumber::Normal { index }]) { Ok(child) => child, Err(e) => { tracing::warn!( @@ -2024,10 +2012,10 @@ pub(crate) fn derive_platform_payment_account_xpub( ChildNumber::Hardened { index: account }, ChildNumber::Hardened { index: key_class }, ]); - let secp = Secp256k1::new(); + let master = ExtendedPrivKey::new_master(network, seed)?; - let account_priv = master.derive_priv(&secp, &account_path)?; - Ok(ExtendedPubKey::from_priv(&secp, &account_priv)) + let account_priv = master.derive_priv(&account_path)?; + Ok(ExtendedPubKey::from_priv(&account_priv)) } /// Provider for wallet Platform addresses that implements AddressProvider for SDK address sync. @@ -2217,10 +2205,9 @@ impl WalletAddressProvider { &self, index: AddressIndex, ) -> Result<(PlatformAddress, Address), WalletError> { - let secp = Secp256k1::new(); let child = self .account_xpub - .derive_pub(&secp, &[ChildNumber::Normal { index }])?; + .derive_pub(&[ChildNumber::Normal { index }])?; let public_key = child.to_pub(); // Create P2PKH address @@ -2346,7 +2333,6 @@ pub(crate) mod test_support { pub(crate) fn open_wallet() -> Wallet { let seed = TEST_SEED; let network = Network::Testnet; - let secp = Secp256k1::new(); // Derive master private key from seed let master_private_key = @@ -2359,10 +2345,10 @@ pub(crate) mod test_support { ChildNumber::Hardened { index: 0 }, ]); let bip44_account_private = master_private_key - .derive_priv(&secp, &bip44_account_path) + .derive_priv(&bip44_account_path) .expect("bip44 derivation"); let master_bip44_ecdsa_extended_public_key = - ExtendedPubKey::from_priv(&secp, &bip44_account_private); + ExtendedPubKey::from_priv(&bip44_account_private); let seed_hash = ClosedKeyItem::compute_seed_hash(&seed); @@ -2409,12 +2395,12 @@ mod tests { /// Helper: create a test address on Testnet fn test_address(index: u8) -> Address { - use dash_sdk::dpp::dashcore::secp256k1::SecretKey; - let secp = Secp256k1::new(); + use dash_sdk::dpp::secp256k1::SecretKey; + let mut sk_bytes = [0u8; 32]; sk_bytes[0] = if index == 0 { 1 } else { index }; - let sk = SecretKey::from_slice(&sk_bytes).expect("valid secret key"); - let inner = dash_sdk::dpp::dashcore::secp256k1::PublicKey::from_secret_key(&secp, &sk); + let sk = SecretKey::from_secret_bytes(sk_bytes).expect("valid secret key"); + let inner = dash_sdk::dpp::secp256k1::PublicKey::from_secret_key(&sk); let pubkey = PublicKey::from_slice(&inner.serialize()).expect("valid pubkey"); Address::p2pkh(&pubkey, Network::Testnet) } @@ -2964,7 +2950,10 @@ mod tests { .expect("cache hit"); assert_eq!(from_cache, from_seed); - assert_eq!(from_cache.inner.serialize(), from_seed.inner.serialize()); + assert_eq!( + from_cache.inner.to_compressed(), + from_seed.inner.to_compressed() + ); assert_eq!(from_cache.pubkey_hash(), from_seed.pubkey_hash()); } } @@ -3122,8 +3111,8 @@ mod tests { .derive_priv_ecdsa_for_master_seed(&seed, network) .expect("reference derive") .to_priv(); - let secp = Secp256k1::new(); - let address = Address::p2pkh(&reference.public_key(&secp), network); + + let address = Address::p2pkh(&reference.public_key(), network); let mut wallet = wallet; wallet.known_addresses.insert(address.clone(), path); @@ -3150,8 +3139,8 @@ mod tests { let reference_xprv = reference_path .derive_priv_ecdsa_for_master_seed(&seed, network) .expect("reference derive"); - let secp = Secp256k1::new(); - let reference = Address::p2pkh(&reference_xprv.to_priv().public_key(&secp), network); + + let reference = Address::p2pkh(&reference_xprv.to_priv().public_key(), network); let param = param_wallet .generate_platform_receive_address_with_seed(&seed, network) @@ -3175,11 +3164,11 @@ mod tests { let slice = vec![Arc::new(RwLock::new(wallet))]; let path = DerivationPath::identity_registration_path(network, 0); - let reference = path + let reference = *path .derive_priv_ecdsa_for_master_seed(&seed, network) .expect("reference derive") .private_key - .secret_bytes(); + .to_bytes(); let param = Wallet::derive_private_key_in_arc_rw_lock_slice_with_seed( &slice, seed_hash, &seed, &path, network, ) @@ -3550,7 +3539,6 @@ mod tests { let wallet = Wallet::new_from_seed(seed, network, None, None).expect("wallet"); let provider = WalletAddressProvider::new(&wallet, network, &seed).expect("provider"); - let secp = Secp256k1::new(); for index in 0u32..DEFAULT_GAP_LIMIT { // Legacy seed-based derivation (what the old provider and the // platform signer used). @@ -3559,7 +3547,7 @@ mod tests { .derive_priv_ecdsa_for_master_seed(&seed, network) .expect("legacy derive") .to_priv(); - let legacy_address = Address::p2pkh(&legacy_priv.public_key(&secp), network); + let legacy_address = Address::p2pkh(&legacy_priv.public_key(), network); // Provider's xpub-based derivation. let (_platform, provider_address) = provider @@ -3795,11 +3783,11 @@ mod tests { /// for. Independent of the reconciliation path under test. fn platform_address_at(index: u32, network: Network) -> Address { let path = DerivationPath::platform_payment_path(network, 0, 0, index); - let secp = Secp256k1::new(); + let xprv = path .derive_priv_ecdsa_for_master_seed(&TEST_SEED, network) .expect("derive platform key"); - Address::p2pkh(&xprv.to_priv().public_key(&secp), network) + Address::p2pkh(&xprv.to_priv().public_key(), network) } /// `new_from_seed` caches the platform-payment account xpub eagerly, and the @@ -4022,11 +4010,11 @@ mod tests { let foreign_seed = [0x11u8; 64]; let path = DerivationPath::platform_payment_path(network, 0, 0, 0); - let secp = Secp256k1::new(); + let xprv = path .derive_priv_ecdsa_for_master_seed(&foreign_seed, network) .expect("derive foreign key"); - let foreign = Address::p2pkh(&xprv.to_priv().public_key(&secp), network); + let foreign = Address::p2pkh(&xprv.to_priv().public_key(), network); assert!(!wallet.reconcile_platform_address(&foreign, network)); assert!(!wallet.known_addresses.contains_key(&foreign)); diff --git a/src/model/wallet/single_key.rs b/src/model/wallet/single_key.rs index eaf01b82a..9cb27bbca 100644 --- a/src/model/wallet/single_key.rs +++ b/src/model/wallet/single_key.rs @@ -10,7 +10,7 @@ use aes_gcm::aead::Aead; use aes_gcm::{Aes256Gcm, KeyInit}; -use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; + use dash_sdk::dpp::dashcore::{Address, Network, OutPoint, PrivateKey, PublicKey, TxOut}; use sha2::{Digest, Sha256}; use std::collections::HashMap; @@ -233,12 +233,10 @@ impl SingleKeyWallet { password: Option<&str>, alias: Option, ) -> Result { - let secp = Secp256k1::new(); - // Create PrivateKey and derive public key and address let private_key = PrivateKey::from_byte_array(&private_key_bytes, network).map_err(|e| e.to_string())?; - let public_key = private_key.public_key(&secp); + let public_key = private_key.public_key(); let address = Address::p2pkh(&public_key, network); let key_hash = ClosedSingleKey::compute_key_hash(&private_key_bytes); @@ -305,7 +303,7 @@ impl SingleKeyWallet { let private_key = PrivateKey::from_wif(wif).map_err(|e| e.to_string())?; let network = private_key.network; let mut key_bytes = [0u8; 32]; - key_bytes.copy_from_slice(&private_key.inner[..]); + key_bytes.copy_from_slice(&*private_key.inner.to_bytes()); Self::new(key_bytes, network, password, alias) } @@ -518,8 +516,8 @@ mod tests { // Through a SingleKeyWallet that holds the closed key. let priv_key = PrivateKey::from_byte_array(&secret, Network::Testnet).expect("valid key bytes"); - let secp = Secp256k1::new(); - let public_key = priv_key.public_key(&secp); + + let public_key = priv_key.public_key(); let address = Address::p2pkh(&public_key, Network::Testnet); let wallet = SingleKeyWallet { private_key_data: data, diff --git a/src/ui/components/address_input.rs b/src/ui/components/address_input.rs index b732e6782..9dba6378d 100644 --- a/src/ui/components/address_input.rs +++ b/src/ui/components/address_input.rs @@ -1483,15 +1483,15 @@ fn row_accessible_label(entry: &AddressEntry, address_display: &str, balance_str #[cfg(test)] mod tests { use super::*; - use dash_sdk::dashcore_rpc::dashcore::secp256k1::{Secp256k1, SecretKey}; use dash_sdk::dashcore_rpc::dashcore::{PrivateKey, PublicKey}; + use dash_sdk::dpp::secp256k1::SecretKey; /// Generate a valid testnet P2PKH address for testing. fn testnet_core_address() -> (String, Address) { - let secp = Secp256k1::new(); - let sk = SecretKey::from_slice(&[1u8; 32]).unwrap(); - let privkey = PrivateKey::new(sk, Network::Testnet); - let pubkey = PublicKey::from_private_key(&secp, &privkey); + let sk = SecretKey::from_secret_bytes([1u8; 32]).unwrap(); + let privkey = PrivateKey::from_byte_array(sk.as_secret_bytes(), Network::Testnet) + .expect("valid secret key"); + let pubkey = PublicKey::from_private_key(&privkey); let addr = Address::p2pkh(&pubkey, Network::Testnet); (addr.to_string(), addr) } @@ -1513,10 +1513,10 @@ mod tests { /// Generate a valid mainnet P2PKH address for testing. fn mainnet_core_address() -> (String, Address) { - let secp = Secp256k1::new(); - let sk = SecretKey::from_slice(&[2u8; 32]).unwrap(); - let privkey = PrivateKey::new(sk, Network::Mainnet); - let pubkey = PublicKey::from_private_key(&secp, &privkey); + let sk = SecretKey::from_secret_bytes([2u8; 32]).unwrap(); + let privkey = PrivateKey::from_byte_array(sk.as_secret_bytes(), Network::Mainnet) + .expect("valid secret key"); + let pubkey = PublicKey::from_private_key(&privkey); let addr = Address::p2pkh(&pubkey, Network::Mainnet); (addr.to_string(), addr) } diff --git a/src/ui/identity/funding_common.rs b/src/ui/identity/funding_common.rs index 30250ff74..946af984a 100644 --- a/src/ui/identity/funding_common.rs +++ b/src/ui/identity/funding_common.rs @@ -373,6 +373,7 @@ pub fn asset_lock_status_label(status: &AssetLockStatus) -> &'static str { AssetLockStatus::InstantSendLocked => "Confirmed and ready to use.", AssetLockStatus::ChainLocked => "Confirmed and ready to use.", AssetLockStatus::Consumed => "Already used to fund an identity.", + AssetLockStatus::DrainCompleted => "Already used to transfer the wallet funds.", // Core-side finality is proven, but whether Platform already spent this // lock is unknown, so it must claim neither "ready" nor "already used". AssetLockStatus::RecoveredFromChain => { @@ -679,16 +680,16 @@ mod tests { } use dash_sdk::dpp::dashcore::PublicKey; - use dash_sdk::dpp::dashcore::secp256k1::{Secp256k1, SecretKey}; + use dash_sdk::dpp::secp256k1::SecretKey; /// A distinct testnet p2pkh address keyed off `n` (derived from a valid /// secret key so the pubkey is a real curve point). fn addr(n: u8) -> Address { let mut sk_bytes = [1u8; 32]; sk_bytes[31] = n.max(1); - let secp = Secp256k1::new(); - let sk = SecretKey::from_slice(&sk_bytes).expect("valid secret key"); - let pubkey = PublicKey::new(sk.public_key(&secp)); + + let sk = SecretKey::from_secret_bytes(sk_bytes).expect("valid secret key"); + let pubkey = PublicKey::new(sk.public_key()); Address::p2pkh(&pubkey, Network::Testnet) } diff --git a/src/ui/identity/keys/add_key_screen.rs b/src/ui/identity/keys/add_key_screen.rs index 18819f00b..ae36c722a 100644 --- a/src/ui/identity/keys/add_key_screen.rs +++ b/src/ui/identity/keys/add_key_screen.rs @@ -193,7 +193,7 @@ impl AddKeyScreen { let derivation = DerivedKeyChooser::new(app_context, &identity, KeyType::ECDSA_SECP256K1); let dashpay_contract_id = app_context - .dashpay_contract + .dashpay_contract() .id() .to_string(Encoding::Base58); @@ -244,7 +244,7 @@ impl AddKeyScreen { let derivation = DerivedKeyChooser::new(app_context, &identity, KeyType::ECDSA_SECP256K1); let dashpay_contract_id = app_context - .dashpay_contract + .dashpay_contract() .id() .to_string(Encoding::Base58); @@ -1382,7 +1382,7 @@ mod derived_key_tests { contract_bounds: None, read_only: false, disabled_at: None, - data: dash_sdk::dpp::dashcore::secp256k1::PublicKey::from_slice(&[ + data: dash_sdk::dpp::secp256k1::PublicKey::from_slice(&[ 0x02, 0x79, 0xBE, 0x66, 0x7E, 0xF9, 0xDC, 0xBB, 0xAC, 0x55, 0xA0, 0x62, 0x95, 0xCE, 0x87, 0x0B, 0x07, 0x02, 0x9B, 0xFC, 0xDB, 0x2D, 0xCE, 0x28, 0xD9, 0x59, 0xF2, 0x81, 0x5B, 0x16, 0xF8, 0x17, 0x98, @@ -1674,9 +1674,9 @@ mod derived_key_tests { /// follow the fresh record. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn derived_key_id_change_refreshes_and_reselects_the_matching_slot() { - use dash_sdk::dpp::dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; use dash_sdk::dpp::identity::accessors::IdentitySettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + use dash_sdk::dpp::secp256k1::{PublicKey, SecretKey}; let (staged, mut identity) = staged_screen_parts(true).await; identity.identity.set_id(staged.id); @@ -1707,9 +1707,9 @@ mod derived_key_tests { let mut refreshed = identity.clone(); let mut foreign = identity.identity.public_keys()[&0].clone(); foreign.set_id(1); - let secret = SecretKey::from_slice(&[9; 32]).unwrap(); + let secret = SecretKey::from_secret_bytes([9; 32]).unwrap(); foreign.set_data( - PublicKey::from_secret_key(&Secp256k1::new(), &secret) + PublicKey::from_secret_key(&secret) .serialize() .to_vec() .into(), diff --git a/src/ui/identity/keys/key_info_screen.rs b/src/ui/identity/keys/key_info_screen.rs index 87c66fb2a..9c0e3d69c 100644 --- a/src/ui/identity/keys/key_info_screen.rs +++ b/src/ui/identity/keys/key_info_screen.rs @@ -34,8 +34,6 @@ use crate::wallet_backend::secret_seam::SecretScheme; use dash_sdk::dashcore_rpc::dashcore::PrivateKey as RPCPrivateKey; use dash_sdk::dpp::dashcore::address::Payload; use dash_sdk::dpp::dashcore::hashes::Hash; -use dash_sdk::dpp::dashcore::secp256k1::{Message, Secp256k1, SecretKey}; -use dash_sdk::dpp::dashcore::sign_message::{MessageSignature, signed_msg_hash}; use dash_sdk::dpp::dashcore::{Address, PrivateKey, PubkeyHash, ScriptHash}; use dash_sdk::dpp::identity::KeyType; use dash_sdk::dpp::identity::KeyType::BIP13_SCRIPT_HASH; @@ -45,6 +43,7 @@ use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicK use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; use dash_sdk::dpp::key_wallet::bip32::DerivationPath; use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::dpp::secp256k1::SecretKey; use dash_sdk::platform::IdentityPublicKey; use eframe::egui::{self}; use egui::{Color32, RichText, ScrollArea}; @@ -543,9 +542,9 @@ impl ScreenLike for KeyInfoScreen { .num_columns(2) .spacing([10.0, 10.0]) .show(ui, |ui| { - if let Ok(secret_key) = SecretKey::from_slice(clear) { - let private_key = - PrivateKey::new(secret_key, self.app_context.network); + if let Ok(private_key) = + PrivateKey::from_byte_array(clear, self.app_context.network) + { ui.label( RichText::new("Private Key (WIF):") .strong() @@ -600,8 +599,8 @@ impl ScreenLike for KeyInfoScreen { && self.view_private_key_even_if_encrypted_or_in_wallet && self.selected_wallet.is_some() { - if let Some(private_key) = self.decrypted_private_key { - Self::render_decrypted_key_grid(ui, &private_key); + if let Some(private_key) = &self.decrypted_private_key { + Self::render_decrypted_key_grid(ui, private_key); } else { Self::queue_key_display( &mut self.pending_key_display_request, @@ -622,8 +621,8 @@ impl ScreenLike for KeyInfoScreen { self.view_private_key_even_if_encrypted_or_in_wallet = true; self.view_wallet_unlock = true; } - if let Some(private_key) = self.decrypted_private_key { - Self::render_decrypted_key_grid(ui, &private_key); + if let Some(private_key) = &self.decrypted_private_key { + Self::render_decrypted_key_grid(ui, private_key); } else { Self::queue_key_display( &mut self.pending_key_display_request, @@ -661,8 +660,8 @@ impl ScreenLike for KeyInfoScreen { .color(text_primary), ); ui.add_space(10.0); - if let Some(private_key) = self.decrypted_private_key { - Self::render_decrypted_key_grid(ui, &private_key); + if let Some(private_key) = &self.decrypted_private_key { + Self::render_decrypted_key_grid(ui, private_key); } else if ui.button("View Private Key").clicked() { self.pending_identity_key_display = true; self.key_display_requested = true; @@ -1109,7 +1108,7 @@ impl KeyInfoScreen { return; } Err(_) => match PrivateKey::from_wif(self.private_key_input.text()) { - Ok(key) => key.inner.secret_bytes(), + Ok(key) => *key.inner.to_bytes(), Err(_) => { MessageBanner::set_global( self.app_context.egui_ctx(), @@ -1294,13 +1293,9 @@ impl KeyInfoScreen { /// are compressed by convention, so a verifier can recover the signer's /// public key and address from the signature alone. fn sign_ecdsa_local(private_key_bytes: &[u8; 32], message: &str) -> String { - let secp = Secp256k1::new(); - let message_hash = signed_msg_hash(message); - let digest = Message::from_digest(*message_hash.as_byte_array()); - let secret_key = SecretKey::from_byte_array(private_key_bytes) + let secret_key = SecretKey::from_secret_bytes(*private_key_bytes) .expect("clear private key is a valid 32-byte secret"); - let recoverable = secp.sign_ecdsa_recoverable(&digest, &secret_key); - MessageSignature::new(recoverable, true).to_base64() + crate::backend_task::wallet::dash_signed_message(message, &secret_key, true) } /// Render the WIF + hex of an already-derived private key. The key is @@ -1325,7 +1320,7 @@ impl KeyInfoScreen { .strong() .color(ui.visuals().text_color()), ); - let private_key_hex = Secret::new(hex::encode(private_key.inner.secret_bytes())); + let private_key_hex = Secret::new(hex::encode(private_key.inner.to_bytes())); ui.label( RichText::new(private_key_hex.expose_secret()).color(ui.visuals().text_color()), ); @@ -2284,10 +2279,11 @@ mod tests { /// passes and the placement check is what decides the outcome. fn keypair(id: KeyID, purpose: Purpose) -> (IdentityPublicKey, [u8; 32]) { let secret_bytes = [0x2A; 32]; - let secp = Secp256k1::new(); - let secret = SecretKey::from_byte_array(&secret_bytes).expect("a valid secret key"); - let data = PrivateKey::new(secret, Network::Testnet) - .public_key(&secp) + + let secret = SecretKey::from_secret_bytes(secret_bytes).expect("a valid secret key"); + let data = PrivateKey::from_byte_array(secret.as_secret_bytes(), Network::Testnet) + .expect("valid secret key") + .public_key() .to_bytes(); ( IdentityPublicKey::V0(IdentityPublicKeyV0 { diff --git a/src/ui/state/account_summary.rs b/src/ui/state/account_summary.rs index 6f004b36e..a38b29c59 100644 --- a/src/ui/state/account_summary.rs +++ b/src/ui/state/account_summary.rs @@ -365,14 +365,12 @@ mod tests { /// valid address to stand in for a funded-but-unindexed chain address. fn addr_from_byte(b: u8) -> Address { use dash_sdk::dpp::dashcore::PublicKey; - use dash_sdk::dpp::dashcore::secp256k1::{ - PublicKey as SecpPublicKey, Secp256k1, SecretKey, - }; - let secp = Secp256k1::new(); + use dash_sdk::dpp::secp256k1::{PublicKey as SecpPublicKey, SecretKey}; + let mut sk_bytes = [0u8; 32]; sk_bytes[0] = if b == 0 { 1 } else { b }; - let sk = SecretKey::from_slice(&sk_bytes).expect("valid secret key"); - let pubkey = PublicKey::from_slice(&SecpPublicKey::from_secret_key(&secp, &sk).serialize()) + let sk = SecretKey::from_secret_bytes(sk_bytes).expect("valid secret key"); + let pubkey = PublicKey::from_slice(&SecpPublicKey::from_secret_key(&sk).serialize()) .expect("valid pubkey"); Address::p2pkh(&pubkey, Network::Testnet) } diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 9c0cad012..754662548 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -5141,10 +5141,10 @@ mod tests { 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 dash_sdk::dpp::secp256k1::SecretKey; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; use std::cell::RefCell; @@ -5158,10 +5158,11 @@ mod tests { } 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"); - let private_key = PrivateKey::new(secret_key, Network::Testnet); - let public_key = PublicKey::from_private_key(&secp, &private_key); + let secret_key = SecretKey::from_secret_bytes([key_byte; 32]).expect("valid secret key"); + let private_key = + PrivateKey::from_byte_array(secret_key.as_secret_bytes(), Network::Testnet) + .expect("valid secret key"); + let public_key = PublicKey::from_private_key(&private_key); Address::p2pkh(&public_key, Network::Testnet) } diff --git a/src/ui/wallets/wallets_screen/address_table.rs b/src/ui/wallets/wallets_screen/address_table.rs index 71f50c5e3..ed000b95c 100644 --- a/src/ui/wallets/wallets_screen/address_table.rs +++ b/src/ui/wallets/wallets_screen/address_table.rs @@ -573,16 +573,16 @@ impl WalletsBalancesScreen { mod tests { use super::*; use dash_sdk::dpp::dashcore::PublicKey; - use dash_sdk::dpp::dashcore::secp256k1::{Secp256k1, SecretKey}; + use dash_sdk::dpp::secp256k1::SecretKey; /// A distinct testnet p2pkh address keyed off `n` (derived from a valid /// secret key so the pubkey is a real curve point). fn addr(n: u8) -> Address { let mut sk_bytes = [1u8; 32]; sk_bytes[31] = n.max(1); - let secp = Secp256k1::new(); - let sk = SecretKey::from_slice(&sk_bytes).unwrap(); - let pubkey = PublicKey::new(sk.public_key(&secp)); + + let sk = SecretKey::from_secret_bytes(sk_bytes).unwrap(); + let pubkey = PublicKey::new(sk.public_key()); Address::p2pkh(&pubkey, Network::Testnet) } diff --git a/src/wallet_backend/auth_pubkey_cache.rs b/src/wallet_backend/auth_pubkey_cache.rs index f88a3ad6f..e53c854bc 100644 --- a/src/wallet_backend/auth_pubkey_cache.rs +++ b/src/wallet_backend/auth_pubkey_cache.rs @@ -214,9 +214,7 @@ mod tests { use dash_sdk::dpp::dashcore::PublicKey; use dash_sdk::dpp::dashcore::base58; - use dash_sdk::dpp::dashcore::secp256k1::{ - PublicKey as Secp256k1PublicKey, Secp256k1, SecretKey, - }; + use dash_sdk::dpp::secp256k1::{PublicKey as Secp256k1PublicKey, SecretKey}; use crate::wallet_backend::kv_test_support::InMemoryKv; @@ -225,9 +223,8 @@ mod tests { } fn pubkey(seed: u8) -> PublicKey { - let secp = Secp256k1::new(); - let sk = SecretKey::from_slice(&[seed.max(1); 32]).expect("valid secret key"); - PublicKey::new(Secp256k1PublicKey::from_secret_key(&secp, &sk)) + let sk = SecretKey::from_secret_bytes([seed.max(1); 32]).expect("valid secret key"); + PublicKey::new(Secp256k1PublicKey::from_secret_key(&sk)) } /// AUTH-CACHE-VIEW-001 — a written cache round-trips through `get` diff --git a/src/wallet_backend/contact_profile_cache.rs b/src/wallet_backend/contact_profile_cache.rs index ec1a47df5..4df5156e3 100644 --- a/src/wallet_backend/contact_profile_cache.rs +++ b/src/wallet_backend/contact_profile_cache.rs @@ -21,7 +21,6 @@ use std::sync::Arc; -use dash_sdk::dpp::dashcore::base58; use dash_sdk::platform::Identifier; use crate::backend_task::error::TaskError; @@ -62,7 +61,13 @@ impl CachedContactProfile { /// Build the canonical k/v key for a contact's cached profile. fn key_for(contact: &Identifier) -> String { - format!("{KEY_PREFIX}{}", base58::encode_slice(&contact.to_buffer())) + format!( + "{KEY_PREFIX}{}", + dash_sdk::dpp::platform_value::string_encoding::encode( + &contact.to_buffer(), + dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58 + ) + ) } /// View borrowing a shared [`DetKv`] handle. Cheap to construct. diff --git a/src/wallet_backend/dashpay.rs b/src/wallet_backend/dashpay.rs index 20bc155ab..35edcc0c7 100644 --- a/src/wallet_backend/dashpay.rs +++ b/src/wallet_backend/dashpay.rs @@ -166,41 +166,23 @@ pub(crate) fn derive_contact_info_encryption_keys( let root_path = DerivationPath::from_str(&format!("m/9'/{coin_type}'/15'/0'")).map_err(derive_err)?; - let secp = dash_sdk::dpp::dashcore::secp256k1::Secp256k1::new(); - let root = master_xprv - .derive_priv(&secp, &root_path) - .map_err(derive_err)?; + let root = master_xprv.derive_priv(&root_path).map_err(derive_err)?; // Key1 (encToUserId): root/(2^16)'/index' let key1 = root - .derive_priv( - &secp, - &[ChildNumber::from_hardened_idx(65536).map_err(derive_err)?], - ) + .derive_priv(&[ChildNumber::from_hardened_idx(65536).map_err(derive_err)?]) .map_err(derive_err)? - .derive_priv( - &secp, - &[ChildNumber::from_hardened_idx(derivation_index).map_err(derive_err)?], - ) + .derive_priv(&[ChildNumber::from_hardened_idx(derivation_index).map_err(derive_err)?]) .map_err(derive_err)?; // Key2 (privateData): root/(2^16 + 1)'/index' let key2 = root - .derive_priv( - &secp, - &[ChildNumber::from_hardened_idx(65537).map_err(derive_err)?], - ) + .derive_priv(&[ChildNumber::from_hardened_idx(65537).map_err(derive_err)?]) .map_err(derive_err)? - .derive_priv( - &secp, - &[ChildNumber::from_hardened_idx(derivation_index).map_err(derive_err)?], - ) + .derive_priv(&[ChildNumber::from_hardened_idx(derivation_index).map_err(derive_err)?]) .map_err(derive_err)?; - Ok(( - Zeroizing::new(key1.private_key.secret_bytes()), - Zeroizing::new(key2.private_key.secret_bytes()), - )) + Ok((key1.private_key.to_bytes(), key2.private_key.to_bytes())) } // --------------------------------------------------------------------------- @@ -3338,7 +3320,7 @@ mod tests { assert_eq!( seam.public_key, - local.public_key.serialize(), + local.public_key.to_compressed(), "published public key must match the receive-side xpub on mainnet" ); assert_eq!( @@ -3379,7 +3361,7 @@ mod tests { assert_eq!( seam.public_key, - local.public_key.serialize(), + local.public_key.to_compressed(), "published public key must match the receive-side xpub on testnet" ); assert_eq!( @@ -3421,7 +3403,7 @@ mod tests { assert_eq!( seam.public_key, - local.public_key.serialize(), + local.public_key.to_compressed(), "public key must match send↔receive on {network:?}" ); assert_eq!( @@ -3567,7 +3549,7 @@ mod tests { .expect("receive-side xpub"); assert_eq!( published.public_key, - scanned.public_key.serialize(), + scanned.public_key.to_compressed(), "published xpub must equal the scanned xpub for the selected wallet" ); @@ -3577,7 +3559,7 @@ mod tests { .expect("other-side xpub"); assert_ne!( published.public_key, - other_scanned.public_key.serialize(), + other_scanned.public_key.to_compressed(), "the unselected wallet's seed must derive a different xpub" ); } diff --git a/src/wallet_backend/det_platform_signer.rs b/src/wallet_backend/det_platform_signer.rs index 54b52695c..5f7c4ec63 100644 --- a/src/wallet_backend/det_platform_signer.rs +++ b/src/wallet_backend/det_platform_signer.rs @@ -154,8 +154,9 @@ impl<'a> DetPlatformSigner<'a> { .map_err(|e| ProtocolError::Generic(e.to_string()))?; let private_key = extended_private_key.to_priv(); - let signature = dash_sdk::dpp::dashcore::signer::sign(data, private_key.inner.as_ref()) - .map_err(|e| ProtocolError::Generic(format!("Failed to sign: {}", e)))?; + let signature = + dash_sdk::dpp::dashcore::signer::sign(data, private_key.inner.to_bytes().as_slice()) + .map_err(|e| ProtocolError::Generic(format!("Failed to sign: {}", e)))?; Ok(signature.to_vec()) } @@ -202,7 +203,7 @@ mod tests { use super::*; use crate::model::wallet::{AddressInfo, DerivationPathReference, DerivationPathType}; use dash_sdk::dpp::dashcore::Address; - use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; + use zeroize::Zeroizing; /// Reference platform-address signing — reproduces exactly what the @@ -223,7 +224,7 @@ mod tests { .derive_priv_ecdsa_for_master_seed(seed, network) .expect("derive") .to_priv(); - dash_sdk::dpp::dashcore::signer::sign(data, private_key.inner.as_ref()) + dash_sdk::dpp::dashcore::signer::sign(data, private_key.inner.to_bytes().as_slice()) .expect("reference sign") .to_vec() } @@ -241,8 +242,8 @@ mod tests { let xprv = path .derive_priv_ecdsa_for_master_seed(&*seed, network) .expect("derive platform key"); - let secp = Secp256k1::new(); - let address = Address::p2pkh(&xprv.to_priv().public_key(&secp), network); + + let address = Address::p2pkh(&xprv.to_priv().public_key(), network); let platform_address = PlatformAddress::try_from(address.clone()).expect("to platform address"); @@ -318,10 +319,12 @@ mod tests { .expect("derive") .to_priv(); let data = b"key-parity-vector"; - let reference_sig = - dash_sdk::dpp::dashcore::signer::sign(data, reference_key.inner.as_ref()) - .expect("reference sign") - .to_vec(); + let reference_sig = dash_sdk::dpp::dashcore::signer::sign( + data, + reference_key.inner.to_bytes().as_slice(), + ) + .expect("reference sign") + .to_vec(); // The signer's view of the same address must yield the same key. let det = DetPlatformSigner::from_held(&seed, network, &index); @@ -349,8 +352,8 @@ mod tests { let other_xprv = other_path .derive_priv_ecdsa_for_master_seed(&*seed, network) .unwrap(); - let secp = Secp256k1::new(); - let other_address = Address::p2pkh(&other_xprv.to_priv().public_key(&secp), network); + + let other_address = Address::p2pkh(&other_xprv.to_priv().public_key(), network); let other_platform = PlatformAddress::try_from(other_address).unwrap(); assert!(!det.can_sign_with(&other_platform)); diff --git a/src/wallet_backend/det_signer.rs b/src/wallet_backend/det_signer.rs index e75ff6c43..d0cba01ba 100644 --- a/src/wallet_backend/det_signer.rs +++ b/src/wallet_backend/det_signer.rs @@ -35,9 +35,10 @@ use async_trait::async_trait; use dash_sdk::dpp::dashcore::Network; -use dash_sdk::dpp::dashcore::secp256k1::{self, Message, PublicKey, Secp256k1, ecdsa}; +use dash_sdk::dpp::dashcore::ecdsa::{EcdsaPublicKey, EcdsaSignature}; use dash_sdk::dpp::key_wallet::bip32::{DerivationPath, ExtendedPrivKey, ExtendedPubKey}; use dash_sdk::dpp::key_wallet::signer::{ExtendedPubKeySigner, Signer, SignerMethod}; +use dash_sdk::dpp::secp256k1::{self, Message, PublicKey, ecdsa}; use zeroize::Zeroizing; use crate::wallet_backend::secret_access::SecretPlaintext; @@ -63,7 +64,7 @@ pub enum DetSignerError { /// upstream digest-length error as a typed signer error rather than /// panicking. fn message_from_digest(digest: [u8; 32]) -> Result { - Message::from_digest_slice(&digest).map_err(DetSignerError::Sign) + Ok(Message::from_digest(digest)) } /// JIT [`Signer`] backed by a **borrowed** held secret. @@ -123,8 +124,13 @@ impl<'a> DetSigner<'a> { /// Derive the secp256k1 secret at `path` from the held HD seed. Errors /// with [`DetSignerError::WrongSecretKind`] if the held secret is a /// single key (no derivation tree). + /// + /// Signing stays on the secp256k1 primitive (plain RFC 6979 nonce), so + /// signatures are byte-identical to earlier builds; the newer + /// `EcdsaSecretKey::sign` grinds for a low R and would change them. fn derive_secret(&self, path: &DerivationPath) -> Result { - Ok(self.derive_xprv(path)?.private_key) + let scalar = self.derive_xprv(path)?.private_key.to_bytes(); + secp256k1::SecretKey::from_secret_bytes(*scalar).map_err(DetSignerError::Sign) } /// Sign `msg` (a 32-byte digest) with the held **single key** directly, @@ -137,10 +143,10 @@ impl<'a> DetSigner<'a> { ) -> Result { match &self.secret { HeldSecret::SingleKey(key) => { - let sk = - secp256k1::SecretKey::from_byte_array(key).map_err(DetSignerError::Sign)?; + let sk = secp256k1::SecretKey::from_secret_bytes(***key) + .map_err(DetSignerError::Sign)?; let message = message_from_digest(*msg)?; - Ok(Secp256k1::signing_only().sign_ecdsa(&message, &sk)) + Ok(dash_sdk::dpp::secp256k1::ecdsa::sign(message, &sk)) } HeldSecret::HdSeed(_) => Err(DetSignerError::WrongSecretKind), } @@ -172,21 +178,18 @@ impl Signer for DetSigner<'_> { &self, path: &DerivationPath, sighash: [u8; 32], - ) -> Result<(ecdsa::Signature, PublicKey), Self::Error> { + ) -> Result<(EcdsaSignature, EcdsaPublicKey), Self::Error> { let secret = self.derive_secret(path)?; - let secp = Secp256k1::signing_only(); + let msg = message_from_digest(sighash)?; - let signature = secp.sign_ecdsa(&msg, &secret); - let public_key = PublicKey::from_secret_key(&secp, &secret); - Ok((signature, public_key)) + let signature = dash_sdk::dpp::secp256k1::ecdsa::sign(msg, &secret); + let public_key = PublicKey::from_secret_key(&secret); + Ok((signature.into(), public_key.into())) } - async fn public_key(&self, path: &DerivationPath) -> Result { + async fn public_key(&self, path: &DerivationPath) -> Result { let secret = self.derive_secret(path)?; - Ok(PublicKey::from_secret_key( - &Secp256k1::signing_only(), - &secret, - )) + Ok(PublicKey::from_secret_key(&secret).into()) } } @@ -204,7 +207,7 @@ impl ExtendedPubKeySigner for DetSigner<'_> { path: &DerivationPath, ) -> Result { let xprv = self.derive_xprv(path)?; - Ok(ExtendedPubKey::from_priv(&Secp256k1::signing_only(), &xprv)) + Ok(ExtendedPubKey::from_priv(&xprv)) } } @@ -311,14 +314,21 @@ mod tests { // Independent reference: derive the key directly from the seed at // the path, then sign + recover the public key with the same // secp256k1 primitive `DetSigner` uses internally. - let secp = Secp256k1::new(); - let reference_sk = path - .derive_priv_ecdsa_for_master_seed(&PARITY_SEED, network) - .expect("reference derive") - .private_key; - let reference_msg = Message::from_digest_slice(&sighash).expect("reference digest"); - let reference_sig = secp.sign_ecdsa(&reference_msg, &reference_sk); - let reference_pk = PublicKey::from_secret_key(&secp, &reference_sk); + + let reference_sk = secp256k1::SecretKey::from_secret_bytes( + *path + .derive_priv_ecdsa_for_master_seed(&PARITY_SEED, network) + .expect("reference derive") + .private_key + .to_bytes(), + ) + .expect("valid scalar"); + let reference_msg = Message::from_digest(sighash); + let reference_sig = EcdsaSignature::from(dash_sdk::dpp::secp256k1::ecdsa::sign( + reference_msg, + &reference_sk, + )); + let reference_pk = EcdsaPublicKey::from(PublicKey::from_secret_key(&reference_sk)); // The JIT signer over the same seed, held open through the // chokepoint, must match byte-for-byte. @@ -359,11 +369,11 @@ mod tests { for network in [Network::Testnet, Network::Mainnet] { // Independent reference: derive the xprv straight from the seed at // the path, then convert to the xpub with the same primitive. - let secp = Secp256k1::new(); + let reference_xprv = path .derive_priv_ecdsa_for_master_seed(&PARITY_SEED, network) .expect("reference derive"); - let reference_xpub = ExtendedPubKey::from_priv(&secp, &reference_xprv); + let reference_xpub = ExtendedPubKey::from_priv(&reference_xprv); let held = Zeroizing::new(PARITY_SEED); let signer = DetSigner::from_held(SecretPlaintext::HdSeed(&held), network); @@ -402,14 +412,14 @@ mod tests { #[test] fn hd_derivation_matches_pinned_vector() { let path: DerivationPath = "m/44'/1'/0'/0/0".parse().unwrap(); - let secp = Secp256k1::new(); - let sk = path + + let pk = path .derive_priv_ecdsa_for_master_seed(&PARITY_SEED, Network::Testnet) .expect("derive") - .private_key; - let pk = PublicKey::from_secret_key(&secp, &sk); + .private_key + .public_key(); assert_eq!( - hex::encode(pk.serialize()), + hex::encode(pk.to_compressed()), PINNED_TESTNET_PUBKEY_HEX, "derived pubkey drifted from the pinned vector — a derivation regression" ); @@ -433,8 +443,10 @@ mod tests { ); let mut key_bytes = [0u8; 32]; key_bytes[31] = 1; - let sk = secp256k1::SecretKey::from_byte_array(&key_bytes).unwrap(); - let wif = PrivateKey::new(sk, Network::Testnet).to_wif(); + let sk = secp256k1::SecretKey::from_secret_bytes(key_bytes).unwrap(); + let wif = PrivateKey::from_byte_array(sk.as_secret_bytes(), Network::Testnet) + .expect("valid secret key") + .to_wif(); let imported = view .import_wif_with_passphrase( &wif, diff --git a/src/wallet_backend/secret_access.rs b/src/wallet_backend/secret_access.rs index 5d676afab..2038b5317 100644 --- a/src/wallet_backend/secret_access.rs +++ b/src/wallet_backend/secret_access.rs @@ -1730,11 +1730,13 @@ mod tests { fn known_testnet_wif() -> String { use dash_sdk::dpp::dashcore::PrivateKey; - use dash_sdk::dpp::dashcore::secp256k1::SecretKey; + use dash_sdk::dpp::secp256k1::SecretKey; let mut bytes = [0u8; 32]; bytes[31] = 1; - let sk = SecretKey::from_byte_array(&bytes).unwrap(); - PrivateKey::new(sk, Network::Testnet).to_wif() + let sk = SecretKey::from_secret_bytes(bytes).unwrap(); + PrivateKey::from_byte_array(sk.as_secret_bytes(), Network::Testnet) + .expect("valid secret key") + .to_wif() } /// Write a legacy DET AES-GCM `SingleKeyEntry` straight to the vault Tier-1 — @@ -1742,19 +1744,17 @@ mod tests { /// import time, so this is how the legacy→Tier-2 lazy migration path stays /// covered. fn write_legacy_protected_key(store: &Arc, passphrase: &str) -> String { - use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; use dash_sdk::dpp::dashcore::{Address, PrivateKey, PublicKey}; let priv_key = PrivateKey::from_wif(&known_testnet_wif()).expect("wif"); - let raw: Zeroizing<[u8; 32]> = - Zeroizing::new(priv_key.inner[..].try_into().expect("32 bytes")); - let secp = Secp256k1::new(); + let raw: Zeroizing<[u8; 32]> = priv_key.inner.to_bytes(); + let pub_key = PublicKey { compressed: priv_key.compressed, - inner: priv_key.inner.public_key(&secp), + inner: priv_key.inner.public_key(), }; let address = Address::p2pkh(&pub_key, Network::Testnet).to_string(); - let pub_bytes = pub_key.inner.serialize().to_vec(); + let pub_bytes = pub_key.inner.to_compressed().to_vec(); let entry = SingleKeyEntry::protected(&raw, passphrase, Some("the usual".into()), pub_bytes) .expect("build legacy protected entry"); @@ -1819,9 +1819,10 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let store = fresh_store(dir.path()); let address = write_legacy_protected_key(&store, SENTINEL_PASSPHRASE); - let expected: [u8; 32] = PrivateKey::from_wif(&known_testnet_wif()).unwrap().inner[..] - .try_into() - .unwrap(); + let expected: [u8; 32] = *PrivateKey::from_wif(&known_testnet_wif()) + .unwrap() + .inner + .to_bytes(); // First resolve: one passphrase, re-wraps to Tier-2. let prompt = Arc::new(TestPrompt::new([ScriptedAnswer::once(SENTINEL_PASSPHRASE)])); diff --git a/src/wallet_backend/sidecar.rs b/src/wallet_backend/sidecar.rs index f399b831d..9f87cb0c3 100644 --- a/src/wallet_backend/sidecar.rs +++ b/src/wallet_backend/sidecar.rs @@ -32,7 +32,10 @@ pub(crate) fn sidecar_key(network: Network, infix: &str, id: &SidecarId) -> Stri format!( "{}{infix}{}", network_prefix(network), - base58::encode_slice(id) + dash_sdk::dpp::platform_value::string_encoding::encode( + id, + dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58 + ) ) } diff --git a/src/wallet_backend/single_key.rs b/src/wallet_backend/single_key.rs index 7c8424fa9..2fb1c85f9 100644 --- a/src/wallet_backend/single_key.rs +++ b/src/wallet_backend/single_key.rs @@ -14,11 +14,11 @@ use std::sync::Arc; #[cfg(test)] -use dash_sdk::dpp::dashcore::secp256k1::Message; -use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; -#[cfg(test)] -use dash_sdk::dpp::dashcore::secp256k1::ecdsa::Signature; +use dash_sdk::dpp::secp256k1::Message; + use dash_sdk::dpp::dashcore::{Address, Network, PrivateKey, PublicKey}; +#[cfg(test)] +use dash_sdk::dpp::secp256k1::ecdsa::Signature; use platform_wallet_storage::secrets::{ SecretBytes, SecretStore, SecretStoreError, SecretString, WalletId as SecretWalletId, }; @@ -256,10 +256,10 @@ impl<'a> SingleKeyView<'a> { if !priv_key.compressed { return Err(TaskError::UncompressedWifUnsupported); } - let secp = Secp256k1::new(); + let pub_key = PublicKey { compressed: priv_key.compressed, - inner: priv_key.inner.public_key(&secp), + inner: priv_key.inner.public_key(), }; let address = Address::p2pkh(&pub_key, self.network); let address_str = address.to_string(); @@ -289,13 +289,9 @@ impl<'a> SingleKeyView<'a> { }; // Extracted WIF bytes wrapped in `Zeroizing` so the stack copy wipes // on drop instead of lingering after the entry is built. - let raw: Zeroizing<[u8; 32]> = Zeroizing::new( - priv_key.inner[..] - .try_into() - .map_err(|_| TaskError::SingleKeyCryptoFailure)?, - ); + let raw: Zeroizing<[u8; 32]> = priv_key.inner.to_bytes(); - let pub_bytes = pub_key.inner.serialize().to_vec(); + let pub_bytes = pub_key.inner.to_compressed().to_vec(); let password = passphrase .passphrase .as_ref() @@ -714,8 +710,8 @@ impl<'a> SingleKeyView<'a> { return Ok(None); } }; - let secp = Secp256k1::new(); - let public_key = priv_key.public_key(&secp); + + let public_key = priv_key.public_key(); let address = Address::p2pkh(&public_key, meta.network); let key_hash = ClosedSingleKey::compute_key_hash(&key_bytes); @@ -869,7 +865,7 @@ fn parse_locked_address_and_pubkey( ); return None; } - let inner = match dash_sdk::dpp::dashcore::secp256k1::PublicKey::from_slice(public_key_bytes) { + let inner = match dash_sdk::dpp::secp256k1::PublicKey::from_slice(public_key_bytes) { Ok(p) => p, Err(e) => { tracing::warn!( @@ -885,7 +881,7 @@ fn parse_locked_address_and_pubkey( address, PublicKey { compressed: true, - inner, + inner: inner.into(), }, )) } @@ -1031,10 +1027,10 @@ impl<'a> SingleKeyView<'a> { /// [`DetSigner`](crate::wallet_backend::det_signer::DetSigner). #[cfg(test)] fn sign_message_with_raw_key(bytes: &[u8; 32], msg: &[u8; 32]) -> Result { - let sk = dash_sdk::dpp::dashcore::secp256k1::SecretKey::from_byte_array(bytes) + let sk = dash_sdk::dpp::secp256k1::SecretKey::from_secret_bytes(*bytes) .map_err(|_| TaskError::ImportedKeyNotFound)?; let message = Message::from_digest(*msg); - Ok(Secp256k1::new().sign_ecdsa(&message, &sk)) + Ok(dash_sdk::dpp::secp256k1::ecdsa::sign(message, &sk)) } #[cfg(test)] @@ -1137,7 +1133,7 @@ mod tests { fn known_wif() -> &'static str { // Testnet WIF with deterministic, all-zero-except-last-byte key - // bytes. Generated locally with `PrivateKey::new(SecretKey::from_byte_array(&[0;31].chain(&[1])).unwrap(), Testnet).to_wif()` + // bytes. Generated locally with `PrivateKey::new(SecretKey::from_secret_bytes([0;31].chain(&[1])).unwrap(), Testnet).to_wif()` // and pinned here to keep tests offline + reproducible. "cMahea7zqjxrtgAbB7LSGbcQUr1uX1ojuat9jZodMN8rFTv2sfUK" } @@ -1251,8 +1247,6 @@ mod tests { /// lookup path. #[test] fn tc_sk_008_sign_uses_secret_store_path_not_bip32() { - use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; - let dir = tempfile::tempdir().expect("tempdir"); let (store, index, network) = fresh_view(dir.path(), Network::Testnet); let view = SingleKeyView { @@ -1274,9 +1268,9 @@ mod tests { // the signer hit the imported-key path (not some other key the // backend might fall back to) without referencing BIP-32 at all. let priv_key = PrivateKey::from_wif(known_wif()).expect("wif"); - let secp = Secp256k1::new(); - let pk = priv_key.inner.public_key(&secp); - secp.verify_ecdsa(&Message::from_digest(msg), &sig, &pk) + + let pk = dash_sdk::dpp::secp256k1::PublicKey::from(priv_key.inner.public_key()); + dash_sdk::dpp::secp256k1::ecdsa::verify(&sig, Message::from_digest(msg), &pk) .expect("signature verifies against WIF-derived pubkey"); // Forgetting the key removes both the index entry and the @@ -1928,9 +1922,9 @@ mod tests { .expect("chokepoint signs after the right passphrase"); let priv_key = PrivateKey::from_wif(known_wif()).unwrap(); - let secp = Secp256k1::new(); - let pk = priv_key.inner.public_key(&secp); - secp.verify_ecdsa(&Message::from_digest(msg), &sig, &pk) + + let pk = dash_sdk::dpp::secp256k1::PublicKey::from(priv_key.inner.public_key()); + dash_sdk::dpp::secp256k1::ecdsa::verify(&sig, Message::from_digest(msg), &pk) .expect("chokepoint signature verifies"); assert_eq!(prompt.ask_count(), 2, "one wrong + one right passphrase"); } @@ -2107,9 +2101,11 @@ mod tests { /// A deterministic throwaway testnet WIF distinct per `byte`. fn wif_for_byte(byte: u8) -> String { - let secret = dash_sdk::dpp::dashcore::secp256k1::SecretKey::from_byte_array(&[byte; 32]) + let secret = dash_sdk::dpp::secp256k1::SecretKey::from_secret_bytes([byte; 32]) .expect("valid scalar"); - PrivateKey::new(secret, Network::Testnet).to_wif() + PrivateKey::from_byte_array(secret.as_secret_bytes(), Network::Testnet) + .expect("valid secret key") + .to_wif() } fn transient_view<'a>( @@ -2553,11 +2549,11 @@ mod tests { let priv_key = PrivateKey::from_wif(known_wif()).unwrap(); let pub_key = PublicKey { compressed: priv_key.compressed, - inner: priv_key.inner.public_key(&Secp256k1::new()), + inner: priv_key.inner.public_key(), }; let address = Address::p2pkh(&pub_key, network).to_string(); let label = label_for_address(&address); - let raw = SecretBytes::from_slice(&priv_key.inner[..]); + let raw = SecretBytes::from_slice(&priv_key.inner.to_bytes()[..]); store .set(&single_key_namespace_id(), &label, &raw) .expect("write legacy bytes"); @@ -2617,7 +2613,7 @@ mod tests { "raw, not a versioned envelope" ); let priv_key = PrivateKey::from_wif(known_wif()).unwrap(); - assert_eq!(raw.expose_secret(), &priv_key.inner[..]); + assert_eq!(raw.expose_secret(), &priv_key.inner.to_bytes()[..]); // Signs with no passphrase. view.sign_with(&imported.address, &[0x42u8; 32]) @@ -2922,7 +2918,7 @@ mod tests { .expect("secret retained"); assert_eq!( raw.expose_secret(), - &PrivateKey::from_wif(known_wif()).unwrap().inner[..] + &PrivateKey::from_wif(known_wif()).unwrap().inner.to_bytes()[..] ); assert_eq!(view.hydrate_wallets().len(), 1); view.forget(&existing.address) @@ -2997,7 +2993,7 @@ mod tests { .map(|key| { let public = PublicKey { compressed: true, - inner: key.inner.public_key(&Secp256k1::new()), + inner: key.inner.public_key(), }; Address::p2pkh(&public, Network::Testnet).to_string() }) @@ -3032,7 +3028,7 @@ mod tests { .map(|key| { let public = PublicKey { compressed: true, - inner: key.inner.public_key(&Secp256k1::new()), + inner: key.inner.public_key(), }; Address::p2pkh(&public, Network::Testnet).to_string() }) diff --git a/src/wallet_backend/snapshot.rs b/src/wallet_backend/snapshot.rs index 65347cbb6..90807d228 100644 --- a/src/wallet_backend/snapshot.rs +++ b/src/wallet_backend/snapshot.rs @@ -766,6 +766,21 @@ impl SnapshotStore { } } + pub(super) fn unconfirm_instant_locked(&self, wallet_id: &WalletId, txids: &[Txid]) { + let Ok(mut log) = self.tx_log.lock() else { + return; + }; + if let Some(transactions) = log.get_mut(wallet_id) { + for txid in txids { + if let Some(tx) = transactions.get_mut(txid) { + tx.status = TransactionStatus::InstantSendLocked; + tx.height = None; + tx.block_hash = None; + } + } + } + } + /// Upgrade a previously-accumulated record's status to /// `InstantSendLocked`. /// @@ -1030,7 +1045,6 @@ impl SnapshotStore { mod tests { use super::*; use dash_sdk::dpp::dashcore::hashes::Hash; - use dash_sdk::dpp::dashcore::secp256k1::{Secp256k1, SecretKey}; use dash_sdk::dpp::dashcore::{BlockHash, Network, PublicKey, Transaction, TxOut}; use dash_sdk::dpp::key_wallet::account::{AccountType, StandardAccountType}; use dash_sdk::dpp::key_wallet::managed_account::transaction_record::{ @@ -1038,6 +1052,7 @@ mod tests { }; use dash_sdk::dpp::key_wallet::transaction_checking::BlockInfo; use dash_sdk::dpp::key_wallet::transaction_checking::transaction_router::TransactionType; + use dash_sdk::dpp::secp256k1::SecretKey; fn seed(n: u8) -> WalletSeedHash { [n; 32] @@ -1962,6 +1977,34 @@ mod tests { ); } + #[test] + fn truncated_chain_clears_block_evidence_but_preserves_instant_lock() { + let store = SnapshotStore::new(); + let mut rec = record(2, 300); + rec.context = TransactionContext::InChainLockedBlock(BlockInfo::new( + 10, + BlockHash::from_byte_array([1; 32]), + 123, + )); + let txid = rec.txid; + store.accumulate_transactions(&wid(5), [&rec]); + store.accumulate_transactions(&wid(6), [&rec]); + store.unconfirm_instant_locked(&wid(5), &[txid]); + publish_tx_only(&store, seed(5), wid(5)); + publish_tx_only(&store, seed(6), wid(6)); + let snap = store.snapshot(&seed(5)); + assert_eq!( + snap.transactions[0].status, + TransactionStatus::InstantSendLocked + ); + assert_eq!(snap.transactions[0].height, None); + assert_eq!(snap.transactions[0].block_hash, None); + assert_eq!( + store.snapshot(&seed(6)).transactions[0].status, + TransactionStatus::ChainLocked + ); + } + /// A block confirmation may race the InstantLock notification. If the /// stronger status already landed first, the InstantLock must not /// regress it back to `InstantSendLocked`. @@ -2244,9 +2287,9 @@ mod tests { fn addr(n: u8) -> Address { let mut sk_bytes = [1u8; 32]; sk_bytes[31] = n.max(1); - let secp = Secp256k1::new(); - let sk = SecretKey::from_slice(&sk_bytes).unwrap(); - let pubkey = PublicKey::new(sk.public_key(&secp)); + + let sk = SecretKey::from_secret_bytes(sk_bytes).unwrap(); + let pubkey = PublicKey::new(sk.public_key()); Address::p2pkh(&pubkey, Network::Testnet) } diff --git a/src/wallet_backend/wallet_context.rs b/src/wallet_backend/wallet_context.rs index 7c0655ac9..ff6ce79a2 100644 --- a/src/wallet_backend/wallet_context.rs +++ b/src/wallet_backend/wallet_context.rs @@ -489,7 +489,7 @@ impl WalletContext { network, has_passphrase: w.uses_password, passphrase_hint: None, - public_key_bytes: w.public_key.inner.serialize().to_vec(), + public_key_bytes: w.public_key.inner.to_compressed().to_vec(), } }; let _writer = self.lock_writer(); @@ -853,7 +853,7 @@ mod tests { network, has_passphrase: false, passphrase_hint: None, - public_key_bytes: open.public_key.inner.serialize().to_vec(), + public_key_bytes: open.public_key.inner.to_compressed().to_vec(), }; (meta, open, protected) } diff --git a/tests/backend-e2e/dashpay_tasks.rs b/tests/backend-e2e/dashpay_tasks.rs index c1ae089f8..98386d3a8 100644 --- a/tests/backend-e2e/dashpay_tasks.rs +++ b/tests/backend-e2e/dashpay_tasks.rs @@ -627,14 +627,16 @@ async fn step_update_contact_info( key_type: KeyType::ECDSA_SECP256K1, read_only: false, data: { - use dash_sdk::dashcore_rpc::dashcore::key::Secp256k1; use dash_sdk::dpp::dashcore::PrivateKey; - let secp = Secp256k1::new(); let secret_key = - dash_sdk::dpp::dashcore::secp256k1::SecretKey::from_slice(&private_key_bytes) + dash_sdk::dpp::secp256k1::SecretKey::from_secret_bytes(private_key_bytes) .expect("Step 5: invalid secret key"); - let pk = PrivateKey::new(secret_key, dash_sdk::dpp::dashcore::Network::Testnet); - pk.public_key(&secp).to_bytes().into() + let pk = PrivateKey::from_byte_array( + secret_key.as_secret_bytes(), + dash_sdk::dpp::dashcore::Network::Testnet, + ) + .expect("valid secret key"); + pk.public_key().to_bytes().into() }, disabled_at: None, }); @@ -850,11 +852,10 @@ async fn tc_045_detect_incoming_contact_payment() { // secret keys so the addresses stay stable across runs while remaining valid // curve points — secp256k1 now rejects raw bytes that are not on the curve, // so a hand-written `[0x02; 33]` is no longer a usable public key. - let secp = dash_sdk::dpp::dashcore::secp256k1::Secp256k1::new(); let derive_pubkey = |seed: [u8; 32]| { - let secret_key = dash_sdk::dpp::dashcore::secp256k1::SecretKey::from_slice(&seed) + let secret_key = dash_sdk::dpp::secp256k1::SecretKey::from_secret_bytes(seed) .expect("fixed test secret key is a valid scalar"); - dash_sdk::dpp::dashcore::PublicKey::new(secret_key.public_key(&secp)) + dash_sdk::dpp::dashcore::PublicKey::new(secret_key.public_key()) }; let pubkey_0 = derive_pubkey([0x01; 32]); let pubkey_1 = derive_pubkey([0x02; 32]); diff --git a/tests/backend-e2e/identity_in_vault_sign.rs b/tests/backend-e2e/identity_in_vault_sign.rs index 3f85cf0dc..142909c22 100644 --- a/tests/backend-e2e/identity_in_vault_sign.rs +++ b/tests/backend-e2e/identity_in_vault_sign.rs @@ -102,14 +102,13 @@ async fn ts_sign_e2e_01_in_vault_identity_signs_and_broadcasts() { // MASTER key via the JIT free-rider path. let new_private_key_bytes: [u8; 32] = rand::random(); let new_public_key_data = { - use dash_sdk::dashcore_rpc::dashcore::key::Secp256k1; use dash_sdk::dpp::dashcore::PrivateKey; - let secp = Secp256k1::new(); let secret_key = - dash_sdk::dpp::dashcore::secp256k1::SecretKey::from_slice(&new_private_key_bytes) + dash_sdk::dpp::secp256k1::SecretKey::from_secret_bytes(new_private_key_bytes) .expect("valid secret"); - PrivateKey::new(secret_key, Network::Testnet) - .public_key(&secp) + PrivateKey::from_byte_array(secret_key.as_secret_bytes(), Network::Testnet) + .expect("valid secret key") + .public_key() .to_bytes() }; let mut new_ipk = IdentityPublicKey::V0(IdentityPublicKeyV0 { @@ -263,7 +262,7 @@ async fn materialize_master_key_as_clear( let xprv = master_path .derive_priv_ecdsa_for_master_seed(seed, network) .expect("derive master private key from seed"); - Ok(xprv.to_priv().inner.secret_bytes()) + Ok(*xprv.to_priv().inner.to_bytes()) }, ) .await diff --git a/tests/backend-e2e/identity_tasks.rs b/tests/backend-e2e/identity_tasks.rs index 60d40dc36..6d476ce42 100644 --- a/tests/backend-e2e/identity_tasks.rs +++ b/tests/backend-e2e/identity_tasks.rs @@ -194,14 +194,13 @@ async fn step_add_key( key_type: KeyType::ECDSA_SECP256K1, read_only: false, data: { - use dash_sdk::dashcore_rpc::dashcore::key::Secp256k1; use dash_sdk::dpp::dashcore::PrivateKey; - let secp = Secp256k1::new(); let secret_key = - dash_sdk::dpp::dashcore::secp256k1::SecretKey::from_slice(&private_key_bytes) + dash_sdk::dpp::secp256k1::SecretKey::from_secret_bytes(private_key_bytes) .expect("invalid secret key"); - let pk = PrivateKey::new(secret_key, Network::Testnet); - pk.public_key(&secp).to_bytes().into() + let pk = PrivateKey::from_byte_array(secret_key.as_secret_bytes(), Network::Testnet) + .expect("valid secret key"); + pk.public_key().to_bytes().into() }, disabled_at: None, }); diff --git a/tests/backend-e2e/wallet_reregistration.rs b/tests/backend-e2e/wallet_reregistration.rs index 32f2a7cd9..d899ddea2 100644 --- a/tests/backend-e2e/wallet_reregistration.rs +++ b/tests/backend-e2e/wallet_reregistration.rs @@ -140,7 +140,6 @@ async fn cold_process_boot_from_migrated_state_registers_and_shows_balance() { use dash_evo_tool::utils::egui_mpsc::EguiMpscAsync; use dash_evo_tool::utils::tasks::TaskManager; use dash_sdk::dpp::dashcore::Network; - use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; use dash_sdk::dpp::key_wallet::bip32::{ ChildNumber, DerivationPath, ExtendedPrivKey, ExtendedPubKey, }; @@ -175,15 +174,14 @@ async fn cold_process_boot_from_migrated_state_registers_and_shows_balance() { w.seed_hash() }; let epk = { - let secp = Secp256k1::new(); let master = ExtendedPrivKey::new_master(Network::Testnet, &seed).expect("master key"); let path = DerivationPath::from(vec![ ChildNumber::Hardened { index: 44 }, ChildNumber::Hardened { index: 1 }, ChildNumber::Hardened { index: 0 }, ]); - let account = master.derive_priv(&secp, &path).expect("derive account"); - ExtendedPubKey::from_priv(&secp, &account).encode().to_vec() + let account = master.derive_priv(&path).expect("derive account"); + ExtendedPubKey::from_priv(&account).encode().to_vec() }; seed_legacy_unprotected_hd_wallet_row( &db, diff --git a/tests/backend-e2e/z_broadcast_st_tasks.rs b/tests/backend-e2e/z_broadcast_st_tasks.rs index e3076e1c3..97f0f6330 100644 --- a/tests/backend-e2e/z_broadcast_st_tasks.rs +++ b/tests/backend-e2e/z_broadcast_st_tasks.rs @@ -51,14 +51,13 @@ async fn step_broadcast_valid( let new_private_key_bytes: [u8; 32] = rand::random(); let new_public_key_data = { - use dash_sdk::dashcore_rpc::dashcore::key::Secp256k1; use dash_sdk::dpp::dashcore::PrivateKey; - let secp = Secp256k1::new(); let secret_key = - dash_sdk::dpp::dashcore::secp256k1::SecretKey::from_slice(&new_private_key_bytes) + dash_sdk::dpp::secp256k1::SecretKey::from_secret_bytes(new_private_key_bytes) .expect("invalid secret key bytes"); - let pk = PrivateKey::new(secret_key, Network::Testnet); - pk.public_key(&secp).to_bytes() + let pk = PrivateKey::from_byte_array(secret_key.as_secret_bytes(), Network::Testnet) + .expect("valid secret key"); + pk.public_key().to_bytes() }; let mut new_ipk = IdentityPublicKey::V0(IdentityPublicKeyV0 { @@ -212,14 +211,13 @@ async fn step_broadcast_invalid( let new_private_key_bytes: [u8; 32] = rand::random(); let new_public_key_data = { - use dash_sdk::dashcore_rpc::dashcore::key::Secp256k1; use dash_sdk::dpp::dashcore::PrivateKey; - let secp = Secp256k1::new(); let secret_key = - dash_sdk::dpp::dashcore::secp256k1::SecretKey::from_slice(&new_private_key_bytes) + dash_sdk::dpp::secp256k1::SecretKey::from_secret_bytes(new_private_key_bytes) .expect("invalid secret key bytes"); - let pk = PrivateKey::new(secret_key, Network::Testnet); - pk.public_key(&secp).to_bytes() + let pk = PrivateKey::from_byte_array(secret_key.as_secret_bytes(), Network::Testnet) + .expect("valid secret key"); + pk.public_key().to_bytes() }; let mut new_ipk = IdentityPublicKey::V0(IdentityPublicKeyV0 { diff --git a/tests/kittest/address_input.rs b/tests/kittest/address_input.rs index 2cca9378f..d4864c470 100644 --- a/tests/kittest/address_input.rs +++ b/tests/kittest/address_input.rs @@ -30,18 +30,17 @@ use std::sync::{Arc, RwLock}; /// and exactly one BIP-44 receive address, distinct per `seed`. The snapshot /// paths — not the wallet's own bookkeeping — are what the component offers. fn wallet(seed: u8, alias: &str) -> WalletWithSnapshot { - use dash_sdk::dpp::dashcore::secp256k1::{Secp256k1, SecretKey}; use dash_sdk::dpp::dashcore::{Address, PublicKey}; use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, DerivationPath}; let wallet = Wallet::new_from_seed([seed; 64], Network::Testnet, Some(alias.to_string()), None) .expect("wallet from seed"); - let secp = Secp256k1::new(); let mut sk_bytes = [1u8; 32]; sk_bytes[31] = seed.max(1); - let sk = SecretKey::from_slice(&sk_bytes).expect("valid secret key"); - let address = Address::p2pkh(&PublicKey::new(sk.public_key(&secp)), Network::Testnet); + let sk = + dash_sdk::dpp::secp256k1::SecretKey::from_secret_bytes(sk_bytes).expect("valid secret key"); + let address = Address::p2pkh(&PublicKey::new(sk.public_key()), Network::Testnet); // m/44'/1'/0'/0/0 — a BIP-44 external receive path. let path = DerivationPath::from( diff --git a/tests/kittest/send_screen.rs b/tests/kittest/send_screen.rs index 678c84a13..caa9feb3d 100644 --- a/tests/kittest/send_screen.rs +++ b/tests/kittest/send_screen.rs @@ -11,7 +11,6 @@ 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; -use dash_sdk::dpp::dashcore::secp256k1::{Secp256k1, SecretKey}; use dash_sdk::dpp::dashcore::{Address, Network, PublicKey}; use dash_sdk::dpp::identity::Identity; use dash_sdk::dpp::identity::core_script::CoreScript; @@ -27,11 +26,11 @@ const ONE_DASH_CREDITS: u64 = CREDITS_PER_DUFF * 100_000_000; fn core_address() -> Address { let secret_key = loop { let bytes: [u8; 32] = rand::random(); - if let Ok(key) = SecretKey::from_slice(&bytes) { + if let Ok(key) = dash_sdk::dpp::secp256k1::SecretKey::from_secret_bytes(bytes) { break key; } }; - let public_key = PublicKey::new(secret_key.public_key(&Secp256k1::new())); + let public_key = PublicKey::new(secret_key.public_key()); Address::p2pkh(&public_key, Network::Testnet) } diff --git a/tests/kittest/top_up_identity_screen.rs b/tests/kittest/top_up_identity_screen.rs index 327dfdfeb..fb125cc6b 100644 --- a/tests/kittest/top_up_identity_screen.rs +++ b/tests/kittest/top_up_identity_screen.rs @@ -25,7 +25,6 @@ use dash_evo_tool::ui::wallets::send_screen::{ use dash_evo_tool::ui::{RootScreenType, Screen, ScreenType}; use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; -use dash_sdk::dpp::dashcore::secp256k1::{Secp256k1, SecretKey}; use dash_sdk::dpp::dashcore::{Address, Network, PublicKey}; use dash_sdk::dpp::identity::Identity; use dash_sdk::dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; @@ -502,9 +501,9 @@ fn confirm_top_up(harness: &mut App, identity: &QualifiedIdentity) -> BackendTas fn prepare_top_up(harness: &mut App, identity: &QualifiedIdentity) -> Result { let core = Address::p2pkh( &PublicKey::new( - SecretKey::from_slice(&[0x31; 32]) + dash_sdk::dpp::secp256k1::SecretKey::from_secret_bytes([0x31; 32]) .expect("secret key") - .public_key(&Secp256k1::new()), + .public_key(), ), identity.network, ); diff --git a/tests/migration-matrix/assertions.rs b/tests/migration-matrix/assertions.rs index cf14e42af..a341cece9 100644 --- a/tests/migration-matrix/assertions.rs +++ b/tests/migration-matrix/assertions.rs @@ -532,7 +532,7 @@ pub fn legacy_wallet_registrations( "SELECT EXISTS (SELECT 1 FROM account_registrations \ WHERE account_type = 'standard_bip44' AND account_index = 0 \ AND instr(account_xpub_bytes, CAST(?1 AS BLOB)) > 0)", - [base58::encode_check(&account_xpub)], + [base58::Base58CkString::encode_unbounded(&account_xpub).to_string()], |row| row.get::<_, bool>(0), ) .map_err(|e| { @@ -1062,7 +1062,7 @@ mod tests { ) .expect("test vector hex"); assert_eq!( - base58::encode_check(&serialized), + base58::Base58CkString::encode_unbounded(&serialized).to_string(), "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8" ); } @@ -1100,7 +1100,7 @@ mod tests { account_xpub_bytes BLOB NOT NULL);", ) .expect("create registrations"); - let text = base58::encode_check(&plain_xpub); + let text = base58::Base58CkString::encode_unbounded(&plain_xpub).to_string(); let mut stored = (text.len() as u32).to_be_bytes().to_vec(); stored.extend_from_slice(text.as_bytes()); store diff --git a/tests/migration-matrix/public_identities.rs b/tests/migration-matrix/public_identities.rs index a4cec78a7..ab8fbbb8d 100644 --- a/tests/migration-matrix/public_identities.rs +++ b/tests/migration-matrix/public_identities.rs @@ -163,7 +163,12 @@ pub fn check_source( for row in rows { let (id, bytes, status, identity_type, alias) = row.map_err(|e| e.to_string())?; let record = snapshot(&bytes, status, &identity_type)?; - if record.id != dash_sdk::dpp::dashcore::base58::encode_slice(&id) || record.alias != alias + if record.id + != dash_sdk::dpp::platform_value::string_encoding::encode( + &id, + dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, + ) + || record.alias != alias { return Err("legacy identity columns disagree with the serialized identity".into()); } @@ -191,7 +196,12 @@ pub fn check_migrated( return Err("unexpected wallet binding in public identity record".into()); } let record = snapshot(&stored.qi_bytes, stored.status, &stored.identity_type)?; - if record.id != dash_sdk::dpp::dashcore::base58::encode_slice(&id) { + if record.id + != dash_sdk::dpp::platform_value::string_encoding::encode( + &id, + dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, + ) + { return Err("identity storage key differs from its serialized identity".into()); } found.push(record); From e18e7f24e03c4fdcbfcd32f8d81a6f680c168c74 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:21:52 +0000 Subject: [PATCH 03/58] fix: adapt to protocol version 14 upstream types Handle the dpp and SDK types the new pin adds, keeping today's behaviour: - Document replace keeps moderated_at and moderated_by; new documents start without them. - The document editor refuses typed-array fields instead of dropping their contents, and keeps referenced key ids as numbers. - Shielded token events show read-only in group actions, and the shielded pool token settings are refused before anything is signed. - Contested document creation states the protocol's base contest fund, so the SDK never auto-quotes a larger one. - Masternode vote reads use ResourceVoteWithCount, the SDK's new vote type; the vote count is not used yet. - New query fields, wallet events, wallet error variants and asset-lock funding modes are handled where the SDK now requires them. - Entropy-only document ids are documented as placeholders from protocol 14, and deferred upstream features link to their issues. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- src/backend_task/contested_names/mod.rs | 46 +++---- .../query_dpns_contested_resources.rs | 19 ++- .../contested_names/refresh_vote_states.rs | 9 +- .../contested_names/vote_on_dpns_name.rs | 8 +- src/backend_task/contract.rs | 3 +- src/backend_task/dashpay.rs | 5 +- src/backend_task/dashpay/contact_info.rs | 15 ++- src/backend_task/dashpay/contact_requests.rs | 14 +- src/backend_task/dashpay/profile.rs | 25 ++-- src/backend_task/document.rs | 14 +- src/backend_task/identity/dpns_usernames.rs | 18 ++- .../refresh_loaded_identities_dpns_names.rs | 5 +- .../identity/register_dpns_name.rs | 42 +++++- src/backend_task/identity/top_up_identity.rs | 3 +- src/backend_task/shielded/mod.rs | 3 + src/backend_task/tokens/mod.rs | 4 +- .../tokens/update_token_config.rs | 36 +++++ src/context/test_support.rs | 14 ++ src/model/asset_lock.rs | 1 + src/model/dashpay.rs | 2 + src/model/fee_estimation.rs | 64 ++++++++- src/model/token.rs | 13 ++ .../document_action_screen.rs | 125 +++++++++++++++++- .../group_actions_screen.rs | 109 +++++++++++++-- src/ui/tokens/tokens_screen/mod.rs | 8 +- src/ui/tokens/update_token_config.rs | 40 ++++++ src/ui/tokens/view_token_claims_screen.rs | 3 +- src/wallet_backend/event_bridge.rs | 11 ++ src/wallet_backend/mod.rs | 22 ++- src/wallet_backend/payments.rs | 18 ++- .../backend-e2e/measure_dpns_registration.rs | 3 + 31 files changed, 602 insertions(+), 100 deletions(-) diff --git a/src/backend_task/contested_names/mod.rs b/src/backend_task/contested_names/mod.rs index 3a59001e4..236476e73 100644 --- a/src/backend_task/contested_names/mod.rs +++ b/src/backend_task/contested_names/mod.rs @@ -25,10 +25,10 @@ use dash_sdk::Sdk; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; -use dash_sdk::dpp::voting::votes::resource_vote::ResourceVote; use dash_sdk::dpp::voting::votes::resource_vote::accessors::v0::ResourceVoteGettersV0; use dash_sdk::drive::query::contested_resource_votes_given_by_identity_query::ContestedResourceVotesGivenByIdentityQuery; use dash_sdk::platform::{FetchMany, Identifier}; +use dash_sdk::query_types::ResourceVoteWithCount; use futures::{StreamExt, stream}; use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; @@ -994,12 +994,12 @@ impl AppContext { start_at: Some((poll_id.to_buffer(), true)), order_ascending: true, }; - match ResourceVote::fetch_many(sdk, query).await { + match ResourceVoteWithCount::fetch_many(sdk, query).await { Ok(votes) => { let observed = votes .get(&poll_id) .and_then(Option::as_ref) - .map(ResourceVoteGettersV0::resource_vote_choice); + .map(|vote| vote.resource_vote.resource_vote_choice()); let status = classify_reconciled_vote(observed, outcome.target.requested_choice) .unwrap_or(DpnsVoteTargetStatus::Unconfirmed); @@ -1669,14 +1669,12 @@ mod tests { context.set_det_kv_override_for_test(kv); store.fail_next_gets_containing("det:dpns_vote_operations:v2:", 2); let mut sdk = Sdk::new_mock(); - let document_type = context - .dpns_contract - .document_type_for_name("domain") - .unwrap(); + let dpns_contract = context.dpns_contract(); + let document_type = dpns_contract.document_type_for_name("domain").unwrap(); sdk.mock() .expect_fetch_many::( VotePollsByDocumentTypeQuery { - contract_id: context.dpns_contract.id(), + contract_id: context.dpns_contract().id(), document_type_name: document_type.name().to_owned(), index_name: document_type.find_contested_index().unwrap().name.clone(), start_at_value: None, @@ -2035,7 +2033,7 @@ mod tests { context.seed_dpns_contest_for_test("alice", Some(now + 600_000), false); let mut sdk = Sdk::new_mock(); sdk.mock() - .expect_fetch_many::( + .expect_fetch_many::( ContestedResourceVotesGivenByIdentityQuery { identity_id: reloaded.identity.id(), offset: None, @@ -2333,14 +2331,12 @@ mod tests { use dash_sdk::query_types::{ContestedResource, ContestedResources}; let (_temp, context) = vote_context(); let mut sdk = Sdk::new_mock(); - let document_type = context - .dpns_contract - .document_type_for_name("domain") - .unwrap(); + let dpns_contract = context.dpns_contract(); + let document_type = dpns_contract.document_type_for_name("domain").unwrap(); sdk.mock() .expect_fetch_many::( VotePollsByDocumentTypeQuery { - contract_id: context.dpns_contract.id(), + contract_id: context.dpns_contract().id(), document_type_name: document_type.name().to_owned(), index_name: document_type.find_contested_index().unwrap().name.clone(), start_at_value: None, @@ -2407,27 +2403,31 @@ mod tests { choice: Option, ) { use dash_sdk::dpp::voting::vote_polls::VotePoll; + use dash_sdk::dpp::voting::votes::resource_vote::ResourceVote; use dash_sdk::dpp::voting::votes::resource_vote::v0::ResourceVoteV0; use dash_sdk::query_types::ResourceVotesByIdentity; let votes: ResourceVotesByIdentity = choice .map(|resource_vote_choice| { let vote_poll = VotePoll::ContestedDocumentResourceVotePoll( - crate::model::dpns_voting::dpns_vote_poll(&context.dpns_contract, name) + crate::model::dpns_voting::dpns_vote_poll(&context.dpns_contract(), name) .unwrap(), ); ( key.vote_poll_id, - Some(ResourceVote::V0(ResourceVoteV0 { - vote_poll, - resource_vote_choice, - })), + Some(ResourceVoteWithCount { + resource_vote: ResourceVote::V0(ResourceVoteV0 { + vote_poll, + resource_vote_choice, + }), + vote_count: 1, + }), ) }) .into_iter() .collect(); sdk.mock() - .expect_fetch_many::( + .expect_fetch_many::( ContestedResourceVotesGivenByIdentityQuery { identity_id: key.voter_id, offset: None, @@ -3009,7 +3009,7 @@ mod tests { .unwrap(); let key = &operation.targets[0].target.key; let mut sdk = Sdk::new_mock(); - sdk.mock().expect_fetch_many::( + sdk.mock().expect_fetch_many::( ContestedResourceVotesGivenByIdentityQuery { identity_id: key.voter_id, offset: None, @@ -3473,7 +3473,7 @@ mod tests { } let mut sdk = Sdk::new_mock(); sdk.mock() - .expect_fetch_many::( + .expect_fetch_many::( ContestedResourceVotesGivenByIdentityQuery { identity_id: voters[0], offset: None, @@ -3537,7 +3537,7 @@ mod tests { context.seed_dpns_contest_for_test("alice", Some(1), false); let mut sdk = Sdk::new_mock(); sdk.mock() - .expect_fetch_many::( + .expect_fetch_many::( ContestedResourceVotesGivenByIdentityQuery { identity_id: voter.identity.id(), offset: None, diff --git a/src/backend_task/contested_names/query_dpns_contested_resources.rs b/src/backend_task/contested_names/query_dpns_contested_resources.rs index bf64b8b16..6a5589f5d 100644 --- a/src/backend_task/contested_names/query_dpns_contested_resources.rs +++ b/src/backend_task/contested_names/query_dpns_contested_resources.rs @@ -153,7 +153,7 @@ impl AppContext { // Held to the end of the pass and taken before any other lock, so a // pass that waits here holds nothing a running pass needs. let _pass = self.dpns_contest_refresh_pass.lock().await; - let data_contract = self.dpns_contract.as_ref(); + let data_contract = self.dpns_contract(); let document_type = data_contract .document_type_for_name("domain") .map_err(|_| TaskError::ContractSchemaMismatch { @@ -456,12 +456,12 @@ mod tests { context.set_det_kv_override_for_test(crate::wallet_backend::DetKv::from_store(Arc::new( crate::wallet_backend::kv_test_support::InMemoryKv::default(), ))); - let document_type = context - .dpns_contract + let dpns_contract = context.dpns_contract(); + let document_type = dpns_contract .document_type_for_name("domain") .expect("domain document type"); let page_from = |start_at_value| VotePollsByDocumentTypeQuery { - contract_id: context.dpns_contract.id(), + contract_id: context.dpns_contract().id(), document_type_name: document_type.name().to_owned(), index_name: document_type .find_contested_index() @@ -716,9 +716,8 @@ mod tests { async fn load_node_without_votes(context: &Arc, sdk: &mut Sdk) -> Identifier { use crate::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedIdentity}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; - use dash_sdk::dpp::voting::votes::resource_vote::ResourceVote; use dash_sdk::drive::query::contested_resource_votes_given_by_identity_query::ContestedResourceVotesGivenByIdentityQuery; - use dash_sdk::query_types::ResourceVotesByIdentity; + use dash_sdk::query_types::{ResourceVoteWithCount, ResourceVotesByIdentity}; let (tx, _rx) = tokio::sync::mpsc::channel::(8); context @@ -754,7 +753,7 @@ mod tests { .expect("load the node"); let node_id = node.identity.id(); sdk.mock() - .expect_fetch_many::( + .expect_fetch_many::( ContestedResourceVotesGivenByIdentityQuery { identity_id: node_id, offset: None, @@ -777,8 +776,8 @@ mod tests { name: &str, winner: Option<(ContestedDocumentVotePollWinnerInfo, BlockInfo)>, ) { - let document_type = context - .dpns_contract + let dpns_contract = context.dpns_contract(); + let document_type = dpns_contract .document_type_for_name("domain") .expect("domain document type"); let query = ContestedDocumentVotePollDriveQuery { @@ -793,7 +792,7 @@ mod tests { .clone(), index_values: vec![Value::from("dash"), Value::Text(name.to_owned())], document_type_name: document_type.name().to_owned(), - contract_id: context.dpns_contract.id(), + contract_id: context.dpns_contract().id(), }, allow_include_locked_and_abstaining_vote_tally: true, result_type: ContestedDocumentVotePollDriveQueryResultType::DocumentsAndVoteTally, diff --git a/src/backend_task/contested_names/refresh_vote_states.rs b/src/backend_task/contested_names/refresh_vote_states.rs index 7892a0416..0cc0eb1d3 100644 --- a/src/backend_task/contested_names/refresh_vote_states.rs +++ b/src/backend_task/contested_names/refresh_vote_states.rs @@ -5,10 +5,10 @@ use crate::context::{AppContext, DpnsVoteRefreshResults, MAX_CONCURRENT_DPNS_VOT use dash_sdk::Sdk; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; -use dash_sdk::dpp::voting::votes::resource_vote::ResourceVote; use dash_sdk::dpp::voting::votes::resource_vote::accessors::v0::ResourceVoteGettersV0; use dash_sdk::drive::query::contested_resource_votes_given_by_identity_query::ContestedResourceVotesGivenByIdentityQuery; use dash_sdk::platform::{FetchMany, Identifier}; +use dash_sdk::query_types::ResourceVoteWithCount; use futures::{StreamExt, stream}; use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; @@ -91,7 +91,7 @@ async fn fetch_votes_for_voter( let mut votes = BTreeMap::new(); let mut start_at = None; loop { - let page = ResourceVote::fetch_many( + let page = ResourceVoteWithCount::fetch_many( sdk, ContestedResourceVotesGivenByIdentityQuery { identity_id: voter_id, @@ -106,7 +106,10 @@ async fn fetch_votes_for_voter( let last_key = page.last().map(|(id, _)| id.to_buffer()); for (poll_id, vote) in page { if let Some(vote) = vote { - votes.insert(poll_id.to_buffer(), vote.resource_vote_choice()); + votes.insert( + poll_id.to_buffer(), + vote.resource_vote.resource_vote_choice(), + ); } } if page_len < usize::from(VOTE_QUERY_PAGE_SIZE) { diff --git a/src/backend_task/contested_names/vote_on_dpns_name.rs b/src/backend_task/contested_names/vote_on_dpns_name.rs index a0e7e8fb4..5911945f2 100644 --- a/src/backend_task/contested_names/vote_on_dpns_name.rs +++ b/src/backend_task/contested_names/vote_on_dpns_name.rs @@ -28,7 +28,7 @@ use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; use dash_sdk::platform::transition::broadcast_request::BroadcastRequestForStateTransition; use dash_sdk::platform::transition::put_settings::PutSettings; use dash_sdk::platform::transition::waitable::Waitable; -use dash_sdk::query_types::ContestedResource; +use dash_sdk::query_types::{ContestedResource, ResourceVoteWithCount}; use std::sync::Arc; #[derive(Debug)] @@ -114,13 +114,13 @@ pub(super) async fn proved_dpns_vote_choice( start_at: Some((key.vote_poll_id.to_buffer(), true)), order_ascending: true, }; - let votes = ResourceVote::fetch_many(sdk, query) + let votes = ResourceVoteWithCount::fetch_many(sdk, query) .await .map_err(TaskError::from)?; Ok(votes .get(&key.vote_poll_id) .and_then(Option::as_ref) - .map(ResourceVoteGettersV0::resource_vote_choice)) + .map(|vote| vote.resource_vote.resource_vote_choice())) } /// Settle a rejection that `broadcast` reported. @@ -169,7 +169,7 @@ impl AppContext { name: &str, sdk: &Sdk, ) -> Result { - let vote_poll = dpns_vote_poll(&self.dpns_contract, name)?; + let vote_poll = dpns_vote_poll(&self.dpns_contract(), name)?; let normalized_label = convert_to_homograph_safe_chars(name); // Pre-flight: confirm Platform has an open poll for this label before diff --git a/src/backend_task/contract.rs b/src/backend_task/contract.rs index a21cd10bf..2b648da42 100644 --- a/src/backend_task/contract.rs +++ b/src/backend_task/contract.rs @@ -76,8 +76,9 @@ impl AppContext { for data_contract in data_contracts { if let Some(contract) = &data_contract.1 { // Fetch the contract description from the Search Contract - let search_contract = &self.keyword_search_contract; + let search_contract = self.keyword_search_contract(); let document_query = DocumentQuery { + integer_range_clauses: vec![], sub_queries: Vec::new(), select: SelectProjection::documents(), data_contract: search_contract.clone(), diff --git a/src/backend_task/dashpay.rs b/src/backend_task/dashpay.rs index ae474f2fe..afe1d2ee9 100644 --- a/src/backend_task/dashpay.rs +++ b/src/backend_task/dashpay.rs @@ -32,7 +32,7 @@ use errors::DashPayError; /// Every `contactRequest` read in this module goes through here, so the /// contract handle and the error attribution are stated once. fn contact_request_query(app_context: &AppContext) -> Result { - DocumentQuery::new(app_context.dashpay_contract.clone(), "contactRequest").map_err(|e| { + DocumentQuery::new(app_context.dashpay_contract(), "contactRequest").map_err(|e| { DashPayError::QueryCreation { query_target: "DashPay contactRequest", source: Box::new(e.into()), @@ -40,6 +40,9 @@ fn contact_request_query(app_context: &AppContext) -> Result Result { - let dashpay_contract = app_context.dashpay_contract.clone(); + let dashpay_contract = app_context.dashpay_contract(); let identity_id = identity.identity.id(); let mut query = DocumentQuery::new(dashpay_contract, "contactInfo").map_err(|e| { DashPayError::QueryCreation { @@ -591,7 +591,7 @@ pub async fn create_or_update_contact_info( contact_user_id: Identifier, update: ContactInfoUpdate, ) -> Result { - let dashpay_contract = app_context.dashpay_contract.clone(); + let dashpay_contract = app_context.dashpay_contract(); let identity_id = identity.identity.id(); let lookup = lookup_contact_info(app_context, sdk, &identity, contact_user_id).await?; let (found_existing_doc, derivation_index, enc_user_id_key, private_data_key) = @@ -714,6 +714,11 @@ pub async fn create_or_update_contact_info( let mut rng = StdRng::from_entropy(); let entropy = Bytes32::random_with_rng(&mut rng); + // Before protocol version 14 this entropy-only id is the document's id, + // which Platform recomputes from the entropy. From protocol version 14 + // (`Document::document_id_depends_on_nonce`) the id also commits to the + // identity contract nonce, so this one is only a placeholder that building + // the create transition replaces. Read the id from the confirmed document. let document_id = Document::generate_document_id_v0( &dashpay_contract.id(), &identity_id, @@ -722,6 +727,8 @@ pub async fn create_or_update_contact_info( ); let document = DppDocument::V0(DocumentV0 { + moderated_at: None, + moderated_by: None, id: document_id, owner_id: identity_id, creator_id: None, @@ -843,6 +850,8 @@ mod tests { properties.insert("privateData".to_string(), private_data); } DppDocument::V0(DocumentV0 { + moderated_at: None, + moderated_by: None, id: id(1), owner_id: id(2), creator_id: None, @@ -878,6 +887,8 @@ mod tests { ), ]); DppDocument::V0(DocumentV0 { + moderated_at: None, + moderated_by: None, id: id(document_id), owner_id: id(254), creator_id: None, diff --git a/src/backend_task/dashpay/contact_requests.rs b/src/backend_task/dashpay/contact_requests.rs index 50873710e..9a0e6f837 100644 --- a/src/backend_task/dashpay/contact_requests.rs +++ b/src/backend_task/dashpay/contact_requests.rs @@ -289,7 +289,7 @@ pub async fn send_contact_request_with_proof( } // Step 3: Check if a contact request already exists - let dashpay_contract = app_context.dashpay_contract.clone(); + let dashpay_contract = app_context.dashpay_contract(); let mut existing_query = contact_request_query(app_context)?; existing_query = existing_query @@ -520,7 +520,11 @@ pub async fn send_contact_request_with_proof( let mut rng = StdRng::from_entropy(); let entropy = Bytes32::random_with_rng(&mut rng); - // Generate deterministic document ID based on entropy + // Before protocol version 14 this entropy-only id is the document's id, + // which Platform recomputes from the entropy. From protocol version 14 + // (`Document::document_id_depends_on_nonce`) the id also commits to the + // identity contract nonce, so this one is only a placeholder that building + // the create transition replaces. Read the id from the confirmed document. let document_id = Document::generate_document_id_v0( &dashpay_contract.id(), &identity.identity.id(), @@ -530,6 +534,8 @@ pub async fn send_contact_request_with_proof( // Create the document let document = DppDocument::V0(DocumentV0 { + moderated_at: None, + moderated_by: None, id: document_id, owner_id: identity.identity.id(), creator_id: None, @@ -1289,6 +1295,8 @@ mod tests { properties.insert("toUserId".to_string(), Value::Identifier(to.to_buffer())); } DppDocument::V0(DocumentV0 { + moderated_at: None, + moderated_by: None, id: id(99), owner_id: owner, creator_id: None, @@ -1319,7 +1327,7 @@ mod tests { operator: WhereOperator::Equal, value: Value::Text(value.to_string()), }; - let mut query = DocumentQuery::new(ctx.dpns_contract.clone(), "domain") + let mut query = DocumentQuery::new(ctx.dpns_contract(), "domain") .expect("domain query") .with_where(equals("normalizedParentDomainName", "dash")) .with_where(equals("normalizedLabel", normalized)); diff --git a/src/backend_task/dashpay/profile.rs b/src/backend_task/dashpay/profile.rs index f67569e0b..eb6605f89 100644 --- a/src/backend_task/dashpay/profile.rs +++ b/src/backend_task/dashpay/profile.rs @@ -21,7 +21,7 @@ pub async fn load_profile( let identity_id = identity.identity.id(); let _operation = app_context.lock_identity_profile(identity_id).await; let name_revision = app_context.begin_identity_profile_load(identity_id); - let dashpay_contract = app_context.dashpay_contract.clone(); + let dashpay_contract = app_context.dashpay_contract(); // Query for profile document owned by this identity let mut profile_query = DocumentQuery::new(dashpay_contract, "profile").map_err(|e| { @@ -193,13 +193,12 @@ pub async fn update_profile( "Failed to cache the avatar fetched for a profile update" ); } - let mut query = - DocumentQuery::new(app_context.dashpay_contract.clone(), "profile").map_err(|e| { - DashPayError::QueryCreation { - query_target: "DashPay profile", - source: Box::new(e.into()), - } - })?; + let mut query = DocumentQuery::new(app_context.dashpay_contract(), "profile").map_err(|e| { + DashPayError::QueryCreation { + query_target: "DashPay profile", + source: Box::new(e.into()), + } + })?; query = query.with_where(WhereClause { field: "$ownerId".to_string(), operator: WhereOperator::Equal, @@ -321,7 +320,7 @@ pub async fn fetch_contact_profile( _identity: QualifiedIdentity, // May be needed for future privacy features contact_id: Identifier, ) -> Result { - let dashpay_contract = app_context.dashpay_contract.clone(); + let dashpay_contract = app_context.dashpay_contract(); // Query for the contact's profile document let mut query = DocumentQuery::new(dashpay_contract, "profile").map_err(|e| { @@ -358,7 +357,7 @@ pub async fn search_profiles( sdk: &Sdk, search_query: String, ) -> Result { - let dashpay_contract = app_context.dashpay_contract.clone(); + let dashpay_contract = app_context.dashpay_contract(); let mut results: Vec<(Identifier, Option, String)> = Vec::new(); let query_trimmed = search_query.trim(); @@ -432,7 +431,7 @@ mod tests { "Alice", Value::Identifier(holder.to_buffer()), ); - let mut prefix_query = DocumentQuery::new(ctx.dpns_contract.clone(), "domain") + let mut prefix_query = DocumentQuery::new(ctx.dpns_contract(), "domain") .expect("domain query") .with_where(WhereClause { field: "normalizedParentDomainName".to_string(), @@ -563,6 +562,8 @@ mod tests { fn profile_removal_is_rejected_instead_of_silently_retaining_fields() { for field in ["displayName", "publicMessage", "avatarUrl"] { let existing = Document::V0(dash_sdk::dpp::document::DocumentV0 { + moderated_at: None, + moderated_by: None, properties: [(field.to_string(), Value::Text("existing value".into()))].into(), ..Default::default() }); @@ -582,6 +583,8 @@ mod tests { #[test] fn profile_replacement_is_allowed_with_unchanged_optional_fields() { let existing = Document::V0(dash_sdk::dpp::document::DocumentV0 { + moderated_at: None, + moderated_by: None, properties: [("displayName".to_string(), Value::Text("Alice".into()))].into(), ..Default::default() }); diff --git a/src/backend_task/document.rs b/src/backend_task/document.rs index 59c8bfef9..fbb493c30 100644 --- a/src/backend_task/document.rs +++ b/src/backend_task/document.rs @@ -102,6 +102,7 @@ impl AppContext { document_id: Identifier, ) -> Result { let document_query = DocumentQuery { + integer_range_clauses: vec![], sub_queries: Vec::new(), select: SelectProjection::documents(), data_contract, @@ -192,6 +193,12 @@ impl AppContext { qualified_identity, identity_key, } => { + let contest_fund = crate::model::fee_estimation::document_create_contest_fund_cap( + document_type.as_ref(), + &document, + sdk.version(), + ) + .map_err(|error| dash_sdk::Error::Protocol(*error))?; let mut builder = DocumentCreateTransitionBuilder::new( data_contract, document_type.name().to_string(), @@ -203,7 +210,12 @@ impl AppContext { builder = builder.with_token_payment_info(token_payment); } - let maybe_options = self.state_transition_options(); + let mut maybe_options = self.state_transition_options(); + if let Some(cap) = contest_fund { + maybe_options + .get_or_insert_with(Default::default) + .contest_fund = Some(cap); + } if let Some(options) = maybe_options { builder = builder.with_state_transition_creation_options(options); } diff --git a/src/backend_task/identity/dpns_usernames.rs b/src/backend_task/identity/dpns_usernames.rs index 3af5c4d0e..72473b787 100644 --- a/src/backend_task/identity/dpns_usernames.rs +++ b/src/backend_task/identity/dpns_usernames.rs @@ -1,4 +1,8 @@ //! Identity-side username ops: availability checks and own-request status. +//! +//! TODO(dpns-marketplace): selling, buying and transferring usernames through +//! the platform-wallet marketplace are not offered yet. See +//! https://github.com/dashpay/dash-evo-tool/issues/1103 use std::collections::BTreeMap; @@ -91,9 +95,10 @@ impl AppContext { /// The lookup for a registered name with this normalized label. pub(super) fn dpns_domain_query(&self, normalized: &str) -> DocumentQuery { DocumentQuery { + integer_range_clauses: vec![], sub_queries: Vec::new(), select: SelectProjection::documents(), - data_contract: self.dpns_contract.clone(), + data_contract: self.dpns_contract(), document_type_name: "domain".to_string(), where_clauses: vec![ WhereClause { @@ -127,7 +132,7 @@ impl AppContext { /// Convert a proved vote state into the pure snapshot the model decides on. fn contest_snapshot(&self, sdk: &Sdk, contenders: &Contenders) -> ContestSnapshot { let documents = self - .dpns_contract + .dpns_contract() .document_type_cloned_for_name("domain") .ok() .and_then(|document_type| { @@ -440,9 +445,10 @@ mod tests { async fn sdk_with_owned_name(ctx: &AppContext, id: Identifier) -> Sdk { let query = DocumentQuery { + integer_range_clauses: vec![], sub_queries: vec![], select: SelectProjection::documents(), - data_contract: ctx.dpns_contract.clone(), + data_contract: ctx.dpns_contract(), document_type_name: "domain".into(), where_clauses: vec![WhereClause { field: "records.identity".into(), @@ -458,6 +464,8 @@ mod tests { start: None, }; let document = Document::V0(dash_sdk::dpp::document::DocumentV0 { + moderated_at: None, + moderated_by: None, id: Identifier::from([88; 32]), owner_id: id, creator_id: None, @@ -604,7 +612,7 @@ mod tests { .expect_fetch_many::( ContestedDocumentVotePollDriveQuery { vote_poll: crate::model::dpns_voting::dpns_vote_poll( - &ctx.dpns_contract, + &ctx.dpns_contract(), &normalize_dpns_label("Alice"), ) .unwrap(), @@ -798,7 +806,7 @@ mod tests { .expect_fetch_many::( ContestedDocumentVotePollDriveQuery { vote_poll: crate::model::dpns_voting::dpns_vote_poll( - &ctx.dpns_contract, + &ctx.dpns_contract(), "d1ssh", ) .unwrap(), diff --git a/src/backend_task/identity/refresh_loaded_identities_dpns_names.rs b/src/backend_task/identity/refresh_loaded_identities_dpns_names.rs index 863e6b4c8..f0cdc4277 100644 --- a/src/backend_task/identity/refresh_loaded_identities_dpns_names.rs +++ b/src/backend_task/identity/refresh_loaded_identities_dpns_names.rs @@ -97,9 +97,10 @@ impl AppContext { /// The lookup for every DPNS name whose records point at `identity_id`. pub(super) fn owned_dpns_names_query(&self, identity_id: Identifier) -> DocumentQuery { DocumentQuery { + integer_range_clauses: vec![], sub_queries: Vec::new(), select: SelectProjection::documents(), - data_contract: self.dpns_contract.clone(), + data_contract: self.dpns_contract(), document_type_name: "domain".to_string(), where_clauses: vec![WhereClause { field: "records.identity".to_string(), @@ -177,6 +178,8 @@ mod tests { /// Mock the owned-names lookup of `owner` to return one name. async fn expect_owned_name(sdk: &mut dash_sdk::Sdk, ctx: &AppContext, owner: Identifier) { let document = Document::V0(dash_sdk::dpp::document::DocumentV0 { + moderated_at: None, + moderated_by: None, id: owner, owner_id: owner, creator_id: None, diff --git a/src/backend_task/identity/register_dpns_name.rs b/src/backend_task/identity/register_dpns_name.rs index 6fb7313a8..f3f80cd6a 100644 --- a/src/backend_task/identity/register_dpns_name.rs +++ b/src/backend_task/identity/register_dpns_name.rs @@ -98,6 +98,7 @@ async fn broadcast_name_registration( identity: &QualifiedIdentity, signing_key: IdentityPublicKey, label: &str, + approved_contest_fee: u64, register: F, ) -> Result where @@ -108,6 +109,7 @@ where let preorder_confirmed = Arc::new(AtomicBool::new(false)); let confirmed = Arc::clone(&preorder_confirmed); let input = SdkRegistrationInput { + contest_fund: Some(approved_contest_fee), label: label.to_owned(), identity: identity.identity.clone(), identity_public_key: signing_key, @@ -151,7 +153,7 @@ impl AppContext { return Err(TaskError::InvalidDpnsName { validation }); } - let dpns_contract = self.dpns_contract.clone(); + let dpns_contract = self.dpns_contract(); let qualified_identity = input.qualified_identity; @@ -189,7 +191,7 @@ impl AppContext { .identity .get_public_key_by_id(key_id) .filter(|key| { - key_can_sign_documents(key, dpns_signing_requirement(&self.dpns_contract)) + key_can_sign_documents(key, dpns_signing_requirement(&self.dpns_contract())) }), None => qualified_identity.document_signing_key(&preorder_document_type), } @@ -214,6 +216,7 @@ impl AppContext { &qualified_identity, public_key.clone(), &input.name_input, + input.approved_contest_fee, register, ) .await @@ -760,7 +763,7 @@ mod tests { let confirmed_preorder = domain_document(identity.identity.id(), "alice-123"); let failure = - broadcast_name_registration(&identity, key, "alice-123", |input| async move { + broadcast_name_registration(&identity, key, "alice-123", 0, |input| async move { // As the SDK does: report the confirmed first document if asked to. if let Some(report_confirmed) = input.preorder_callback { report_confirmed(&confirmed_preorder); @@ -776,6 +779,33 @@ mod tests { ); } + #[tokio::test] + async fn registration_never_auto_quotes_above_the_approved_contest_fund() { + for approved in [0, 50_000_000_000] { + let identity = bare_identity(4); + let key = dash_sdk::platform::IdentityPublicKey::random_key( + 1, + Some(4), + PlatformVersion::latest(), + ); + let _ = broadcast_name_registration( + &identity, + key, + "alice", + approved, + |input| async move { + assert_eq!( + input.contest_fund, + Some(approved), + "The SDK must receive the exact approved cap, including zero." + ); + Err(unproven_broadcast_failure()) + }, + ) + .await; + } + } + /// With no node to confirm the first document, the broadcast stops there. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn broadcast_that_cannot_confirm_the_first_document_reports_that_stage() { @@ -789,7 +819,7 @@ mod tests { PlatformVersion::latest(), ); - let failure = broadcast_name_registration(&identity, key, "alice-123", |input| { + let failure = broadcast_name_registration(&identity, key, "alice-123", 0, |input| { sdk.register_dpns_name(input) }) .await @@ -907,6 +937,8 @@ mod tests { /// A registered-name document as the network returns it for `label`. fn domain_document(owner: Identifier, label: &str) -> Document { Document::V0(DocumentV0 { + moderated_at: None, + moderated_by: None, id: Identifier::from([0x5D; 32]), owner_id: owner, creator_id: None, @@ -950,7 +982,7 @@ mod tests { .expect_fetch_many::( ContestedDocumentVotePollDriveQuery { vote_poll: crate::model::dpns_voting::dpns_vote_poll( - &ctx.dpns_contract, + &ctx.dpns_contract(), &normalize_dpns_label(label), ) .expect("vote poll"), diff --git a/src/backend_task/identity/top_up_identity.rs b/src/backend_task/identity/top_up_identity.rs index 8a6915f62..8303e2afb 100644 --- a/src/backend_task/identity/top_up_identity.rs +++ b/src/backend_task/identity/top_up_identity.rs @@ -259,7 +259,8 @@ impl AppContext { // Unreachable from here — the caller builds only the two modes // above — but this is a funds path, so an upstream funding mode // this path cannot build fails closed instead of panicking. - AssetLockFunding::DrainAccountBalance { .. } => { + AssetLockFunding::DrainAccountBalance { .. } + | AssetLockFunding::DrainWalletBalance { .. } => { return Err(TaskError::TopUpFundingMethodUnsupported); } }; diff --git a/src/backend_task/shielded/mod.rs b/src/backend_task/shielded/mod.rs index c988bfd56..be0c365db 100644 --- a/src/backend_task/shielded/mod.rs +++ b/src/backend_task/shielded/mod.rs @@ -11,6 +11,9 @@ use dash_sdk::dpp::address_funds::{OrchardAddress, PlatformAddress}; use dash_sdk::dpp::dashcore::Address; use std::sync::Arc; +// TODO(shielded-identity-transfers): moving credits between identities and the +// shielded pool (protocol version 14), and shielding to another wallet, are not +// offered yet. See https://github.com/dashpay/dash-evo-tool/issues/1105 /// The shielded-pool operations DET dispatches into the upstream /// `platform-wallet` coordinator. /// diff --git a/src/backend_task/tokens/mod.rs b/src/backend_task/tokens/mod.rs index 9d3b3304c..e7f66aee8 100644 --- a/src/backend_task/tokens/mod.rs +++ b/src/backend_task/tokens/mod.rs @@ -761,7 +761,9 @@ impl AppContext { token_config_v0.distribution_rules = distribution_rules; token_config_v0.description = token_description; - // All tokens are created NotTradeable; a future SDK will add more modes. + // Tokens are created NotTradeable. Tradeable needs protocol version 15 + // (TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION) and a transferable token, + // and DET has no way to list a token or make an offer for it yet. token_config_v0.marketplace_rules = TokenMarketplaceRules::V0(TokenMarketplaceRulesV0 { trade_mode: TokenTradeMode::NotTradeable, trade_mode_change_rules: marketplace_rules, diff --git a/src/backend_task/tokens/update_token_config.rs b/src/backend_task/tokens/update_token_config.rs index c81668bc2..9d16d7a95 100644 --- a/src/backend_task/tokens/update_token_config.rs +++ b/src/backend_task/tokens/update_token_config.rs @@ -27,6 +27,7 @@ impl AppContext { group_info: Option, sdk: &Sdk, ) -> Result { + validate_supported_change(&change_item)?; tracing::trace!( ?group_info, ?identity_token_info, @@ -126,3 +127,38 @@ impl AppContext { )) } } + +fn validate_supported_change(item: &TokenConfigurationChangeItem) -> Result<(), TaskError> { + if crate::model::token::token_change_item_is_supported(item) { + Ok(()) + } else { + Err(TaskError::TokenShieldedConfigurationUnsupported) + } +} + +#[cfg(test)] +mod sdk_adapter_tests { + use super::*; + use dash_sdk::dpp::data_contract::change_control_rules::authorized_action_takers::AuthorizedActionTakers; + + #[test] + fn backend_rejects_shielded_configuration_before_submission() { + for item in [ + TokenConfigurationChangeItem::MinimumPoolNotesForOutgoing(17), + TokenConfigurationChangeItem::MinimumPoolNotesForOutgoingControlGroup( + AuthorizedActionTakers::NoOne, + ), + TokenConfigurationChangeItem::MinimumPoolNotesForOutgoingAdminGroup( + AuthorizedActionTakers::NoOne, + ), + ] { + assert!(matches!( + validate_supported_change(&item), + Err(TaskError::TokenShieldedConfigurationUnsupported) + )); + } + assert!( + validate_supported_change(&TokenConfigurationChangeItem::MaxSupply(Some(17))).is_ok() + ); + } +} diff --git a/src/context/test_support.rs b/src/context/test_support.rs index dda777c98..b75e9bd1d 100644 --- a/src/context/test_support.rs +++ b/src/context/test_support.rs @@ -174,6 +174,18 @@ fn test_app_context_with_kv_and_network( .expect("AppContext") } +/// Put `ctx` at `protocol_version`, as its SDK stands once a proven response +/// ratcheted it there. The replacement SDK is a mock: nothing reaches a network. +pub(crate) fn set_sdk_protocol_version(ctx: &AppContext, protocol_version: u32) { + let version = dash_sdk::dpp::version::PlatformVersion::get(protocol_version) + .expect("a protocol version this build knows"); + let sdk = dash_sdk::SdkBuilder::new_mock() + .with_initial_version(version) + .build() + .expect("mock sdk"); + ctx.sdk.store(Arc::new(sdk)); +} + /// A mock SDK that reads data contracts through `ctx`, as the app's own SDK does. pub(crate) fn mock_sdk_for(ctx: &AppContext) -> Sdk { let sdk = Sdk::new_mock(); @@ -191,6 +203,8 @@ pub(crate) fn mock_sdk_for(ctx: &AppContext) -> Sdk { pub(crate) fn dpns_name_document(owner: Identifier, label: &str, identity: Value) -> Document { let text = |value: &str| Value::Text(value.to_string()); Document::V0(DocumentV0 { + moderated_at: None, + moderated_by: None, id: Identifier::from([0x5D; 32]), owner_id: owner, creator_id: None, diff --git a/src/model/asset_lock.rs b/src/model/asset_lock.rs index 0ed4c3904..aabbac967 100644 --- a/src/model/asset_lock.rs +++ b/src/model/asset_lock.rs @@ -84,6 +84,7 @@ pub(crate) fn confirmed_funding_for_test( use platform_wallet::wallet::asset_lock::tracked::{AssetLockStatus, TrackedAssetLock}; let out_point = OutPoint::new(Txid::from_byte_array([txid_byte; 32]), 0); TrackedAssetLock { + operation_id: None, out_point, transaction: Transaction { version: 3, diff --git a/src/model/dashpay.rs b/src/model/dashpay.rs index 56de2a557..3965a56d9 100644 --- a/src/model/dashpay.rs +++ b/src/model/dashpay.rs @@ -443,6 +443,8 @@ mod tests { properties.insert("toUserId".to_string(), Value::Identifier(to.to_buffer())); } DppDocument::V0(DocumentV0 { + moderated_at: None, + moderated_by: None, id: id(99), owner_id: id(1), creator_id: None, diff --git a/src/model/fee_estimation.rs b/src/model/fee_estimation.rs index 9919d97d6..53deabbfb 100644 --- a/src/model/fee_estimation.rs +++ b/src/model/fee_estimation.rs @@ -33,6 +33,23 @@ use dash_sdk::dpp::state_transition::identity_topup_transition::IdentityTopUpTra use dash_sdk::dpp::state_transition::identity_topup_transition::v0::IdentityTopUpTransitionV0; use dash_sdk::dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; +/// Keep contested document creation within the protocol's base fund, without auto-quoting a larger fund. +pub fn document_create_contest_fund_cap( + document_type: dash_sdk::dpp::data_contract::document_type::DocumentTypeRef<'_>, + document: &dash_sdk::dpp::document::Document, + platform_version: &PlatformVersion, +) -> Result, Box> { + use dash_sdk::dpp::data_contract::document_type::methods::{ + DocumentTypeBasicMethods, DocumentTypeV0Methods, + }; + use dash_sdk::dpp::document::DocumentV0Getters; + let mut document = document.clone(); + document_type.regenerate_generated_properties(document.properties_mut(), platform_version)?; + Ok(document_type + .contested_vote_poll_for_document(&document, platform_version)? + .map(|_| contest_fee_credits(platform_version))) +} + /// Subtract an estimated fee from a credit balance without floating-point conversion. pub const fn max_spendable_credits(balance: u64, estimated_fee: u64) -> u64 { balance.saturating_sub(estimated_fee) @@ -1337,6 +1354,43 @@ pub fn shield_from_balance_fee_headroom( mod tests { use super::*; + #[test] + fn document_contest_cap_preserves_ordinary_creates_and_caps_contested_names() { + use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; + use dash_sdk::dpp::platform_value::Value; + use dash_sdk::dpp::system_data_contracts::{SystemDataContract, load_system_data_contract}; + use dash_sdk::platform::Identifier; + for version in [13, 14] { + let pv = PlatformVersion::get(version).unwrap(); + let contract = load_system_data_contract(SystemDataContract::DPNS, pv).unwrap(); + let owner = Identifier::from([1; 32]); + let document = crate::context::test_support::dpns_name_document( + owner, + "alice", + Value::Identifier(owner.to_buffer()), + ); + let domain = contract.document_type_for_name("domain").unwrap(); + assert_eq!( + document_create_contest_fund_cap(domain, &document, pv).unwrap(), + Some(contest_fee_credits(pv)) + ); + let preorder = contract.document_type_for_name("preorder").unwrap(); + assert_eq!( + document_create_contest_fund_cap(preorder, &document, pv).unwrap(), + None + ); + let ordinary = crate::context::test_support::dpns_name_document( + owner, + "alice-123", + Value::Identifier(owner.to_buffer()), + ); + assert_eq!( + document_create_contest_fund_cap(domain, &ordinary, pv).unwrap(), + None + ); + } + } + #[test] fn contest_fee_follows_protocol_version() { // USR-TC-005 @@ -2080,12 +2134,10 @@ mod tests { fn any_core_address() -> Address { use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::dashcore::PublicKey; - use dash_sdk::dpp::dashcore::secp256k1::{ - PublicKey as SecpPublicKey, Secp256k1, SecretKey, - }; - let secp = Secp256k1::new(); - let sk = SecretKey::from_slice(&[1u8; 32]).expect("valid secret key"); - let pubkey = PublicKey::from_slice(&SecpPublicKey::from_secret_key(&secp, &sk).serialize()) + use dash_sdk::dpp::secp256k1::{PublicKey as SecpPublicKey, SecretKey}; + + let sk = SecretKey::from_secret_bytes([1u8; 32]).expect("valid secret key"); + let pubkey = PublicKey::from_slice(&SecpPublicKey::from_secret_key(&sk).serialize()) .expect("valid pubkey"); Address::p2pkh(&pubkey, Network::Testnet) } diff --git a/src/model/token.rs b/src/model/token.rs index de3669c7d..3989dc219 100644 --- a/src/model/token.rs +++ b/src/model/token.rs @@ -14,6 +14,19 @@ pub fn validate_contract_keyword(keyword: &str) -> Result<(), TextLengthError> { ) } +/// Whether this application supports editing the requested token setting. +pub(crate) fn token_change_item_is_supported( + item: &dash_sdk::dpp::data_contract::associated_token::token_configuration_item::TokenConfigurationChangeItem, +) -> bool { + use dash_sdk::dpp::data_contract::associated_token::token_configuration_item::TokenConfigurationChangeItem; + !matches!( + item, + TokenConfigurationChangeItem::MinimumPoolNotesForOutgoing(_) + | TokenConfigurationChangeItem::MinimumPoolNotesForOutgoingControlGroup(_) + | TokenConfigurationChangeItem::MinimumPoolNotesForOutgoingAdminGroup(_) + ) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/ui/contracts_documents/document_action_screen.rs b/src/ui/contracts_documents/document_action_screen.rs index 744c22a91..56db4e99a 100644 --- a/src/ui/contracts_documents/document_action_screen.rs +++ b/src/ui/contracts_documents/document_action_screen.rs @@ -732,6 +732,7 @@ impl DocumentActionScreen { | DocumentPropertyType::U64 | DocumentPropertyType::I64 | DocumentPropertyType::U32 + | DocumentPropertyType::KeyIdWithReference(_) | DocumentPropertyType::I32 | DocumentPropertyType::U16 | DocumentPropertyType::I16 @@ -804,6 +805,11 @@ impl DocumentActionScreen { .background_color(DashColors::input_background(dark_mode)), ); } + DocumentPropertyType::TypedArray(_) => { + ui.label( + "This field cannot be edited here. Use another document editor.", + ); + } DocumentPropertyType::Object(_) | DocumentPropertyType::Array(_) | DocumentPropertyType::VariableTypeArray(_) => { @@ -1255,6 +1261,10 @@ impl DocumentActionScreen { .as_ref() .ok_or("No identity selected")?; + if !Self::supports_field_editor(doc_type) { + return Err("This document contains fields that cannot be edited here. Use another document editor.".to_string()); + } + for (prop, schema) in doc_type.properties() { let entry = self.field_inputs.get(prop).map(|s| s.trim()); if schema.required && entry.unwrap_or("").is_empty() { @@ -1300,7 +1310,7 @@ impl DocumentActionScreen { .map_err(|_| format!("{} must be a signed 64-bit integer", name))?; Value::I64(n) } - DocumentPropertyType::U32 => { + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_) => { let n = input_str .parse::() .map_err(|_| format!("{} must be an unsigned 32-bit integer", name))?; @@ -1372,6 +1382,11 @@ impl DocumentActionScreen { .map_err(|_| format!("{} (Date) expects unix-ms integer", name))?; Value::U64(ts) } + DocumentPropertyType::TypedArray(_) => { + return Err(format!( + "Field {name} cannot be edited here. Use another document editor." + )); + } DocumentPropertyType::Object(_) => { return Err(format!( "Object field {} must be supplied via JSON textarea", @@ -1393,6 +1408,11 @@ impl DocumentActionScreen { let entropy: [u8; 32] = rng.r#gen(); let owner_id = qi.identity.id(); + // Before protocol version 14 this entropy-only id is the document's id, + // which Platform recomputes from the entropy. From protocol version 14 + // (`Document::document_id_depends_on_nonce`) the id also commits to the + // identity contract nonce, so this one is only a placeholder that building + // the create transition replaces. Read the id from the confirmed document. let id = Document::generate_document_id_v0( &contract.contract.id(), &owner_id, @@ -1411,6 +1431,8 @@ impl DocumentActionScreen { properties, owner_id, creator_id: None, + moderated_at: None, + moderated_by: None, revision, created_at: None, updated_at: None, @@ -1434,6 +1456,10 @@ impl DocumentActionScreen { .as_ref() .ok_or("No document-type selected")?; + if !Self::supports_field_editor(doc_type) { + return Err("This document contains fields that cannot be edited here. Use another document editor.".to_string()); + } + for (prop, schema) in doc_type.properties() { let entry = self.field_inputs.get(prop).map(|s| s.trim()); if schema.required && entry.unwrap_or("").is_empty() { @@ -1479,7 +1505,7 @@ impl DocumentActionScreen { .map_err(|_| format!("{} must be a signed 64-bit integer", name))?; Value::I64(n) } - DocumentPropertyType::U32 => { + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_) => { let n = input_str .parse::() .map_err(|_| format!("{} must be an unsigned 32-bit integer", name))?; @@ -1551,6 +1577,11 @@ impl DocumentActionScreen { .map_err(|_| format!("{} (Date) expects unix-ms integer", name))?; Value::U64(ts) } + DocumentPropertyType::TypedArray(_) => { + return Err(format!( + "Field {name} cannot be edited here. Use another document editor." + )); + } DocumentPropertyType::Object(_) => { return Err(format!( "Object field {} must be supplied via JSON textarea", @@ -1582,6 +1613,8 @@ impl DocumentActionScreen { properties, owner_id: original_doc.owner_id(), creator_id: original_doc.creator_id(), + moderated_at: original_doc.moderated_at(), + moderated_by: original_doc.moderated_by(), revision: new_revision, created_at: None, updated_at: None, @@ -1598,7 +1631,26 @@ impl DocumentActionScreen { Ok((updated_doc.into(), entropy)) } + fn supports_field_editor( + doc_type: &dash_sdk::dpp::data_contract::document_type::DocumentType, + ) -> bool { + !doc_type + .properties() + .values() + .any(|schema| matches!(schema.property_type, DocumentPropertyType::TypedArray(_))) + } + fn can_broadcast(&self) -> bool { + if matches!( + self.action_type, + DocumentActionType::Create | DocumentActionType::Replace + ) && self + .selected_document_type + .as_ref() + .is_some_and(|doc_type| !Self::supports_field_editor(doc_type)) + { + return false; + } match self.action_type { DocumentActionType::Create => !self.field_inputs.is_empty(), DocumentActionType::Delete => !self.document_id_input.is_empty(), @@ -1895,3 +1947,72 @@ impl DocumentActionScreen { .inner } } + +#[cfg(test)] +mod tests { + use super::*; + fn editor_document_type(array: bool) -> DocumentType { + use dash_sdk::dpp::data_contract::config::DataContractConfig; + use dash_sdk::dpp::platform_value::platform_value; + use dash_sdk::dpp::version::PlatformVersion; + + let version = PlatformVersion::get(14).expect("protocol 14"); + let config = DataContractConfig::default_for_version(version).unwrap(); + let property = if array { + platform_value!({"type": "array", "items": {"type": "string", "maxLength": 20_u32}, "maxItems": 4_u32, "position": 0_u32}) + } else { + platform_value!({"type": "string", "maxLength": 20_u32, "position": 0_u32}) + }; + DocumentType::try_from_schema( + Identifier::new([1; 32]), 1, config.version(), "entry", + platform_value!({"type": "object", "properties": {"value": property}, "additionalProperties": false}), + None, &BTreeMap::new(), &config, false, &mut Vec::new(), version, + ).unwrap() + } + + #[test] + fn replacing_an_optional_typed_array_does_not_drop_its_contents() { + let dir = tempfile::tempdir().unwrap(); + let context = crate::context::test_support::test_app_context(dir.path()); + let mut screen = DocumentActionScreen::new(context, None, DocumentActionType::Replace); + screen.selected_document_type = Some(editor_document_type(true)); + screen.original_doc = Some( + DocumentV0 { + properties: BTreeMap::from([( + "value".to_string(), + Value::Array(vec![Value::Text("keep".into())]), + )]), + ..Default::default() + } + .into(), + ); + screen.broadcast_status = BroadcastStatus::Fetched; + assert!(!screen.can_broadcast()); + assert!(screen.try_build_document_from_original().is_err()); + assert!(matches!(screen.create_replace_task(), BackendTask::None)); + } + + #[test] + fn replacing_document_fields_preserves_moderation_metadata() { + let dir = tempfile::tempdir().unwrap(); + let context = crate::context::test_support::test_app_context(dir.path()); + let mut screen = DocumentActionScreen::new(context, None, DocumentActionType::Replace); + screen.selected_document_type = Some(editor_document_type(false)); + screen.original_doc = Some( + DocumentV0 { + moderated_at: Some(1234), + moderated_by: Some(Identifier::new([7; 32])), + ..Default::default() + } + .into(), + ); + screen.field_inputs.insert("value".into(), "changed".into()); + let (document, _) = screen.try_build_document_from_original().unwrap(); + assert_eq!(document.moderated_at(), Some(1234)); + assert_eq!(document.moderated_by(), Some(Identifier::new([7; 32]))); + assert_eq!( + document.properties().get("value"), + Some(&Value::Text("changed".into())) + ); + } +} diff --git a/src/ui/contracts_documents/group_actions_screen.rs b/src/ui/contracts_documents/group_actions_screen.rs index f8ccf6ee9..58cb3b087 100644 --- a/src/ui/contracts_documents/group_actions_screen.rs +++ b/src/ui/contracts_documents/group_actions_screen.rs @@ -217,23 +217,28 @@ impl GroupActionsScreen { }); row.col(|ui| { ui.horizontal(|ui| { - ui.label(self.get_token_event_type(token_event)); + ui.label(Self::get_token_event_type(token_event)); ui.add_space(30.0); }); }); row.col(|ui| { ui.horizontal(|ui| { - ui.label(self.get_token_event_info(token_event)); + ui.label(Self::get_token_event_info(token_event)); ui.add_space(30.0); }); }); row.col(|ui| { ui.horizontal(|ui| { - ui.label(self.get_token_event_note(token_event)); + ui.label(Self::get_token_event_note(token_event)); ui.add_space(30.0); }); }); row.col(|ui| { + if !Self::supports_token_event_action(token_event) { + ui.add_enabled(false, egui::Button::new("Take Action")) + .on_hover_text("This action cannot be signed here. Use another token client."); + return; + } if ComponentStyles::add_primary_button( ui, "Take Action", @@ -291,7 +296,7 @@ impl GroupActionsScreen { action } - fn get_token_event_type(&self, token_event: &TokenEvent) -> &'static str { + fn get_token_event_type(token_event: &TokenEvent) -> &'static str { match token_event { TokenEvent::Mint(..) => "Mint", TokenEvent::Burn(..) => "Burn", @@ -304,10 +309,17 @@ impl GroupActionsScreen { TokenEvent::ConfigUpdate(..) => "ConfigUpdate", TokenEvent::ChangePriceForDirectPurchase(..) => "ChangePrice", TokenEvent::DirectPurchase(..) => "DirectPurchase", + TokenEvent::Shield(..) => "Shield", + TokenEvent::Unshield(..) => "Unshield", + TokenEvent::ShieldedTransfer => "ShieldedTransfer", + TokenEvent::MintToPool(..) => "MintToPool", + TokenEvent::BurnFromPool(..) => "BurnFromPool", + TokenEvent::ClaimToPool(..) => "ClaimToPool", + TokenEvent::DirectPurchaseToPool(..) => "DirectPurchaseToPool", } } - fn get_token_event_info(&self, token_event: &TokenEvent) -> String { + fn get_token_event_info(token_event: &TokenEvent) -> String { match token_event { TokenEvent::Mint(amount, identifier, _) => format!("{} to {}", amount, identifier), TokenEvent::Burn(amount, burn_from, _) => format!("{} from {}", amount, burn_from), @@ -323,13 +335,25 @@ impl GroupActionsScreen { TokenEvent::EmergencyAction(action, _) => format!("{:?}", action), TokenEvent::ConfigUpdate(change_item, _) => format!("{:?}", change_item), TokenEvent::ChangePriceForDirectPurchase(schedule, _) => format!("{:?}", schedule), - TokenEvent::DirectPurchase(amount, credits) => { + TokenEvent::DirectPurchase(amount, credits) + | TokenEvent::DirectPurchaseToPool(amount, credits) => { format!("{} for {} credits", amount, credits) } + TokenEvent::Shield(amount) | TokenEvent::ClaimToPool(amount) => { + format!("{amount} to the shielded pool") + } + TokenEvent::Unshield(identifier, amount) => format!("{amount} to {identifier}"), + TokenEvent::ShieldedTransfer => "The amount and participants are private.".to_string(), + TokenEvent::MintToPool(amount, digest, _) => { + format!("{amount} to the shielded pool; action digest {digest}") + } + TokenEvent::BurnFromPool(amount, digest, _) => { + format!("{amount} from the shielded pool; action digest {digest}") + } } } - fn get_token_event_note(&self, token_event: &TokenEvent) -> String { + fn get_token_event_note(token_event: &TokenEvent) -> String { match token_event { TokenEvent::Mint(_, _, note_opt) | TokenEvent::Burn(_, _, note_opt) @@ -339,16 +363,36 @@ impl GroupActionsScreen { | TokenEvent::Claim(_, _, note_opt) | TokenEvent::EmergencyAction(_, note_opt) | TokenEvent::ConfigUpdate(_, note_opt) - | TokenEvent::ChangePriceForDirectPurchase(_, note_opt) => { + | TokenEvent::ChangePriceForDirectPurchase(_, note_opt) + | TokenEvent::MintToPool(_, _, note_opt) + | TokenEvent::BurnFromPool(_, _, note_opt) => { note_opt.clone().unwrap_or_default().to_string() } TokenEvent::Transfer(_, public_note, _, _, _) => { public_note.clone().unwrap_or_default().to_string() } - TokenEvent::DirectPurchase(_, _) => String::new(), + TokenEvent::DirectPurchase(_, _) + | TokenEvent::Shield(_) + | TokenEvent::Unshield(_, _) + | TokenEvent::ShieldedTransfer + | TokenEvent::ClaimToPool(_) + | TokenEvent::DirectPurchaseToPool(_, _) => String::new(), } } + fn supports_token_event_action(token_event: &TokenEvent) -> bool { + !matches!( + token_event, + TokenEvent::Shield(_) + | TokenEvent::Unshield(_, _) + | TokenEvent::ShieldedTransfer + | TokenEvent::MintToPool(_, _, _) + | TokenEvent::BurnFromPool(_, _, _) + | TokenEvent::ClaimToPool(_) + | TokenEvent::DirectPurchaseToPool(_, _) + ) + } + /// On click of the "Take Action" button fn handle_token_event_action( &mut self, @@ -614,3 +658,50 @@ impl ScreenLike for GroupActionsScreen { action } } + +#[cfg(test)] +mod sdk_adapter_tests { + use super::*; + + #[test] + fn shielded_events_are_displayed_without_offering_transparent_actions() { + let identity = Identifier::from([7; 32]); + for (event, label, detail, note) in [ + (TokenEvent::Shield(12), "Shield", "12", ""), + (TokenEvent::Unshield(identity, 13), "Unshield", "13", ""), + ( + TokenEvent::ShieldedTransfer, + "ShieldedTransfer", + "private", + "", + ), + ( + TokenEvent::MintToPool(14, identity, Some("mint note".into())), + "MintToPool", + "action digest", + "mint note", + ), + ( + TokenEvent::BurnFromPool(15, identity, Some("burn note".into())), + "BurnFromPool", + "action digest", + "burn note", + ), + (TokenEvent::ClaimToPool(16), "ClaimToPool", "16", ""), + ( + TokenEvent::DirectPurchaseToPool(17, 18), + "DirectPurchaseToPool", + "17 for 18", + "", + ), + ] { + assert_eq!(GroupActionsScreen::get_token_event_type(&event), label); + assert!(GroupActionsScreen::get_token_event_info(&event).contains(detail)); + assert_eq!(GroupActionsScreen::get_token_event_note(&event), note); + assert!(!GroupActionsScreen::supports_token_event_action(&event)); + } + assert!(GroupActionsScreen::supports_token_event_action( + &TokenEvent::Mint(12, identity, None) + )); + } +} diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index 1beb76bcf..b8cfcfdc6 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -3444,7 +3444,9 @@ mod tests { let (token_pos, token_config) = contract_v1.tokens.iter().next().unwrap(); assert_eq!({ *token_pos }, 0, "Should be at position 0 by default"); - let TokenConfiguration::V0(token_v0) = token_config; + let TokenConfiguration::V0(token_v0) = token_config else { + panic!("the existing token builder must retain V0 configuration"); + }; let TokenConfigurationConvention::V0(conv_v0) = &token_v0.conventions; assert_eq!(conv_v0.decimals, 8, "Decimals from UI not matched"); @@ -3647,7 +3649,9 @@ mod tests { .expect("Should build successfully"); let contract_v1 = data_contract.as_v1().expect("Expected DataContract::V1"); - let TokenConfiguration::V0(ref token_v0) = contract_v1.tokens[&0u16]; + let TokenConfiguration::V0(ref token_v0) = contract_v1.tokens[&0u16] else { + panic!("the existing token builder must retain V0 configuration"); + }; let TokenDistributionRules::V0(dist_rules_v0) = &token_v0.distribution_rules else { panic!("rules without a once-per-identity distribution stay V0"); }; diff --git a/src/ui/tokens/update_token_config.rs b/src/ui/tokens/update_token_config.rs index 3e127a965..54b2b6a7b 100644 --- a/src/ui/tokens/update_token_config.rs +++ b/src/ui/tokens/update_token_config.rs @@ -5,6 +5,7 @@ use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::token::token_change_item_is_supported; use crate::model::user_role::UserRole; use crate::model::wallet::Wallet; use crate::ui::components::left_panel::add_left_panel; @@ -604,6 +605,11 @@ impl UpdateTokenConfigScreen { TokenConfigurationChangeItem::MarketplaceTradeMode(_) => { ui.label("Marketplace settings are not yet supported."); } + TokenConfigurationChangeItem::MinimumPoolNotesForOutgoing(_) + | TokenConfigurationChangeItem::MinimumPoolNotesForOutgoingControlGroup(_) + | TokenConfigurationChangeItem::MinimumPoolNotesForOutgoingAdminGroup(_) => { + ui.label("This setting cannot be changed here. Use another token configuration editor."); + } } }); }); @@ -668,6 +674,7 @@ impl UpdateTokenConfigScreen { ); if (self.app_context.user_role().at_least(UserRole::Power) || !button_text.contains("Test")) + && token_change_item_is_supported(&self.change_item) && self.change_item != TokenConfigurationChangeItem::TokenConfigurationNoChange { ui.add_space(20.0); @@ -1091,5 +1098,38 @@ fn token_change_item_label(item: &TokenConfigurationChangeItem) -> &'static str "Marketplace Trade Mode Admin Group" } TokenConfigurationChangeItem::MainControlGroup(_) => "Main Control Group", + TokenConfigurationChangeItem::MinimumPoolNotesForOutgoing(_) => { + "Minimum Pool Notes for Outgoing Transfers" + } + TokenConfigurationChangeItem::MinimumPoolNotesForOutgoingControlGroup(_) => { + "Minimum Pool Notes Control Group" + } + TokenConfigurationChangeItem::MinimumPoolNotesForOutgoingAdminGroup(_) => { + "Minimum Pool Notes Admin Group" + } + } +} + +#[cfg(test)] +mod sdk_adapter_tests { + use super::*; + + #[test] + fn shielded_pool_configuration_cannot_be_submitted_from_this_editor() { + for item in [ + TokenConfigurationChangeItem::MinimumPoolNotesForOutgoing(17), + TokenConfigurationChangeItem::MinimumPoolNotesForOutgoingControlGroup( + AuthorizedActionTakers::NoOne, + ), + TokenConfigurationChangeItem::MinimumPoolNotesForOutgoingAdminGroup( + AuthorizedActionTakers::NoOne, + ), + ] { + assert!(!token_change_item_is_supported(&item)); + assert!(token_change_item_label(&item).contains("Pool")); + } + assert!(token_change_item_is_supported( + &TokenConfigurationChangeItem::MaxSupply(Some(17)) + )); } } diff --git a/src/ui/tokens/view_token_claims_screen.rs b/src/ui/tokens/view_token_claims_screen.rs index 6264a8720..9a23c0243 100644 --- a/src/ui/tokens/view_token_claims_screen.rs +++ b/src/ui/tokens/view_token_claims_screen.rs @@ -59,9 +59,10 @@ impl ViewTokenClaimsScreen { Self { identity_token_basic_info: identity_token_basic_info.clone(), new_claims_query: DocumentQuery { + integer_range_clauses: vec![], sub_queries: Vec::new(), select: SelectProjection::documents(), - data_contract: app_context.token_history_contract.clone(), + data_contract: app_context.token_history_contract(), document_type_name: "claim".to_string(), where_clauses: vec![ WhereClause { diff --git a/src/wallet_backend/event_bridge.rs b/src/wallet_backend/event_bridge.rs index 3df519f1d..f1ccb4e0e 100644 --- a/src/wallet_backend/event_bridge.rs +++ b/src/wallet_backend/event_bridge.rs @@ -286,6 +286,17 @@ impl EventHandler for EventBridge { *wallet_id } WalletEvent::SyncHeightAdvanced { wallet_id, .. } => *wallet_id, + WalletEvent::ChainTruncated { + wallet_id, + txids, + unconfirmed_txids, + .. + } => { + self.snapshots.remove_transactions(wallet_id, txids); + self.snapshots + .unconfirm_instant_locked(wallet_id, unconfirmed_txids); + *wallet_id + } WalletEvent::TransactionsSwept { wallet_id, txids, .. } => { diff --git a/src/wallet_backend/mod.rs b/src/wallet_backend/mod.rs index f1aeb5fc8..253ee1a0e 100644 --- a/src/wallet_backend/mod.rs +++ b/src/wallet_backend/mod.rs @@ -2326,7 +2326,7 @@ impl WalletBackend { &self, address: &str, msg: &[u8; 32], - ) -> Result { + ) -> Result { let scope = SecretScope::SingleKey { address: address.to_string(), }; @@ -3378,6 +3378,12 @@ fn map_shielded_op_error(e: platform_wallet::error::PlatformWalletError) -> Task } } + // A token operation keeps its SDK cause, so it is classified like any + // SDK error; an unclassified one still names the operation. + P::TokenOperationFailed { operation, source } => { + TaskError::from_token_operation_failure(operation, source) + } + // Every remaining variant → generic WalletBackend wrapper. other @ (P::WalletCreation(_) | P::StaleReservation @@ -3427,8 +3433,6 @@ fn map_shielded_op_error(e: platform_wallet::error::PlatformWalletError) -> Task | P::NoWalletsConfigured | P::SpvError(_) | P::TokenError(_) - // Upstream's typed successor of `TokenError`; same message. - | P::TokenOperationFailed { .. } | P::ShieldedNoUnspentNotes | P::ShieldedInsufficientBalance { .. } | P::ShieldedBuildError(_) @@ -3453,6 +3457,12 @@ fn map_shielded_op_error(e: platform_wallet::error::PlatformWalletError) -> Task | P::MessageSigningMessageInvalid { .. } | P::MessageSigningKeyUnavailable { .. } | P::MessageSigningFailed { .. } + | P::PersisterFlush(_) + | P::IdentityBalanceUnavailable(_) + | P::InvitationNetworkMismatch { .. } + | P::AssetLockTooManyInputs { .. } + | P::QuorumNotVerified { .. } + | P::SpvProcessRestartRequired(_) | P::ShutdownIncomplete(_)) => TaskError::WalletBackend { source: Arc::new(other), }, @@ -3783,6 +3793,12 @@ fn identity_op_error_kind(e: &platform_wallet::error::PlatformWalletError) -> Id // know", so it is not `NotManaged` (which asserts the identity is // absent and must be reloaded); upstream's contract is to retry. | P::IdentityDiscoveryIncomplete { .. } + | P::PersisterFlush(_) + | P::IdentityBalanceUnavailable(_) + | P::InvitationNetworkMismatch { .. } + | P::AssetLockTooManyInputs { .. } + | P::QuorumNotVerified { .. } + | P::SpvProcessRestartRequired(_) // Background sync failed to quiesce — a shutdown fault, unrelated to // whether this op reached Platform. | P::ShutdownIncomplete(_) => IdentityOpErrorKind::Other, diff --git a/src/wallet_backend/payments.rs b/src/wallet_backend/payments.rs index 7c573f995..71a14cfdb 100644 --- a/src/wallet_backend/payments.rs +++ b/src/wallet_backend/payments.rs @@ -1111,6 +1111,14 @@ mod tests { } } + /// A credit output script as long as the P2PKH one a real asset lock pays + /// to. The builder sizes the asset-lock payload, and so its fee, from the + /// actual credit outputs, so a shorter script would make the fee cheaper + /// than in production and let a larger amount build. + fn credit_output_script() -> ScriptBuf { + ScriptBuf::from_bytes(vec![0; super::P2PKH_CREDIT_OUTPUT_SCRIPT_LEN]) + } + #[test] fn asset_lock_max_excludes_unconfirmed_funds_counted_by_snapshot() { const CONFIRMED_DUFFS: u64 = 1_000_000; @@ -1175,7 +1183,7 @@ mod tests { .set_special_payload(TransactionPayload::AssetLockPayloadType( AssetLockPayload::new(vec![TxOut { value: max_amount, - script_pubkey: ScriptBuf::new(), + script_pubkey: credit_output_script(), }]), )) .add_funding(&mut dry_run_account, account) @@ -1193,7 +1201,7 @@ mod tests { .set_special_payload(TransactionPayload::AssetLockPayloadType( AssetLockPayload::new(vec![TxOut { value: max_amount + 1, - script_pubkey: ScriptBuf::new(), + script_pubkey: credit_output_script(), }]), )) .add_funding(&mut one_over_account, account) @@ -1211,7 +1219,7 @@ mod tests { .set_special_payload(TransactionPayload::AssetLockPayloadType( AssetLockPayload::new(vec![TxOut { value: snapshot_balance.spendable(), - script_pubkey: ScriptBuf::new(), + script_pubkey: credit_output_script(), }]), )) .add_funding(&mut overshoot_account, account) @@ -1294,7 +1302,7 @@ mod tests { .set_special_payload(TransactionPayload::AssetLockPayloadType( AssetLockPayload::new(vec![TxOut { value: max_amount, - script_pubkey: ScriptBuf::new(), + script_pubkey: credit_output_script(), }]), )) .add_funding(&mut dry_run_account, account) @@ -1316,7 +1324,7 @@ mod tests { .set_special_payload(TransactionPayload::AssetLockPayloadType( AssetLockPayload::new(vec![TxOut { value: max_amount + 1, - script_pubkey: ScriptBuf::new(), + script_pubkey: credit_output_script(), }]), )) .add_funding(&mut one_over_account, account) diff --git a/tests/backend-e2e/measure_dpns_registration.rs b/tests/backend-e2e/measure_dpns_registration.rs index 29f0c65d9..6a944e268 100644 --- a/tests/backend-e2e/measure_dpns_registration.rs +++ b/tests/backend-e2e/measure_dpns_registration.rs @@ -172,6 +172,9 @@ async fn register_and_log( let (confirmed_tx, confirmed_rx) = tokio::sync::oneshot::channel::<()>(); let (resume_tx, resume_rx) = std::sync::mpsc::channel::<()>(); let input = SdkRegistrationInput { + // States the fund the app approves, as the app's task does: the vote + // fee for a contested name, nothing otherwise. + contest_fund: Some(vote_fee), label: label.clone(), identity: identity.identity.clone(), identity_public_key: signing_key, From 2bb9fede8bd244fd965fe011d8067fa814859e9a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:21:52 +0000 Subject: [PATCH 04/58] fix(sdk): follow the network's protocol version The SDK was pinned to the protocol 13 table, so it could not build any protocol 14 transition or follow the versioned identity lookup. It now starts at upstream's per-network minimum and ratchets to what proven responses report. - System contracts are cached per protocol version and served through accessors. The context provider serves them at the version the proof verifier asks for and fails rather than serve another version's. - A proved epoch sets the fee multiplier and raises the protocol version. An older epoch than the adopted one is ignored. - Rebuilding the connection resets the protocol version, fee multiplier and adopted epoch under one lock before the new SDK is published, and drops reads begun on the old connection. - Capabilities read the network's version through a helper that stays closed for a version this build does not define. - Contest timing reads the live version; retired vote schedules keep the protocol 13 notice window they were created under. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- .../query_dpns_vote_contenders.rs | 2 +- src/backend_task/dashpay/contacts.rs | 2 +- src/backend_task/mod.rs | 30 +- src/backend_task/platform_info.rs | 422 +++++++++--------- src/backend_task/tokens/query_tokens.rs | 17 +- src/context/contested_names_db.rs | 56 ++- src/context/contract_token_db.rs | 10 +- src/context/dpns_vote_state.rs | 2 +- src/context/feature_gate.rs | 50 ++- src/context/legacy_scheduled_votes.rs | 6 +- src/context/mod.rs | 391 +++++++++++++--- src/context/system_contracts.rs | 156 +++++++ src/context_provider.rs | 133 +++++- src/mcp/resolve.rs | 6 +- src/sdk_wrapper.rs | 11 +- .../contracts_documents_screen.rs | 6 +- .../identity/add_new_identity_screen/mod.rs | 4 +- src/ui/identity/mod.rs | 2 +- src/ui/identity/register_dpns_name_screen.rs | 6 +- src/ui/identity/usernames_card.rs | 2 +- src/ui/tokens/tokens_screen/my_tokens.rs | 2 +- 21 files changed, 963 insertions(+), 353 deletions(-) create mode 100644 src/context/system_contracts.rs diff --git a/src/backend_task/contested_names/query_dpns_vote_contenders.rs b/src/backend_task/contested_names/query_dpns_vote_contenders.rs index 52760f99b..8eab0311a 100644 --- a/src/backend_task/contested_names/query_dpns_vote_contenders.rs +++ b/src/backend_task/contested_names/query_dpns_vote_contenders.rs @@ -20,7 +20,7 @@ impl AppContext { sdk: &Sdk, _sender: crate::utils::egui_mpsc::SenderAsync, ) -> Result<(), TaskError> { - let data_contract = self.dpns_contract.as_ref(); + let data_contract = self.dpns_contract(); let document_type = data_contract .document_type_for_name("domain") .map_err(|_| TaskError::DataContractNotFound)?; diff --git a/src/backend_task/dashpay/contacts.rs b/src/backend_task/dashpay/contacts.rs index 6de7aa6d1..642f1a18b 100644 --- a/src/backend_task/dashpay/contacts.rs +++ b/src/backend_task/dashpay/contacts.rs @@ -145,7 +145,7 @@ pub async fn load_contacts( identity: QualifiedIdentity, ) -> Result { let identity_id = identity.identity.id(); - let dashpay_contract = app_context.dashpay_contract.clone(); + let dashpay_contract = app_context.dashpay_contract(); // Query for contact requests where we are the sender (ownerId) let mut outgoing_query = contact_request_query(app_context)?; diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index b0a3878e7..09811cb07 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -1,36 +1,36 @@ use crate::app::TaskResult; -use crate::backend_task::error::{DapiAddressAvailability, TaskError}; use crate::backend_task::contested_names::ContestedResourceTask; use crate::backend_task::contract::ContractTask; use crate::backend_task::core::{CoreItem, CoreTask}; -use crate::backend_task::dashpay::{DashPayTask, ContactData}; +use crate::backend_task::dashpay::{ContactData, DashPayTask}; use crate::backend_task::document::DocumentTask; +use crate::backend_task::error::{DapiAddressAvailability, TaskError}; use crate::backend_task::identity::IdentityTask; use crate::backend_task::platform_info::{PlatformInfoTaskRequestType, PlatformInfoTaskResult}; use crate::backend_task::system_task::SystemTask; use crate::backend_task::wallet::WalletTask; use crate::context::AppContext; use crate::context::identity_load_registry::IdentityLoadToken; -use crate::model::masternode_input::decode_identity_id; use crate::model::dpns_voting::{ DpnsScheduledVoteClearOutcome, DpnsScheduledVoteKey, DpnsVoteOperationId, DpnsVoteTargetKey, }; -use dash_sdk::dpp::address_funds::PlatformAddress; -use dash_sdk::dpp::dashcore::Network; -use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::key_wallet::bip32::DerivationPath; +use crate::model::grovestark_prover::ProofDataOutput; +use crate::model::masternode_input::decode_identity_id; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::{PlatformAddressUpdates, WalletSeedHash}; -use crate::model::grovestark_prover::ProofDataOutput; use crate::ui::tokens::tokens_screen::{ ContractDescriptionInfo, IdentityTokenIdentifier, TokenInfo, }; use crate::utils::egui_mpsc::SenderAsync; use crate::utils::tasks::TaskManager; use contested_names::ScheduledDPNSVote; +use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::balances::credits::TokenAmount; -use dash_sdk::dpp::data_contract::associated_token::token_perpetual_distribution::distribution_function::evaluate_interval::IntervalEvaluationExplanation; +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::data_contract::associated_token::token_perpetual_distribution::distribution_function; use dash_sdk::dpp::group::group_action::GroupAction; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::key_wallet::bip32::DerivationPath; use dash_sdk::dpp::prelude::DataContract; use dash_sdk::dpp::state_transition::StateTransition; use dash_sdk::dpp::tokens::token_pricing_schedule::TokenPricingSchedule; @@ -38,15 +38,15 @@ use dash_sdk::platform::proto::get_documents_request::get_documents_request_v0:: use dash_sdk::platform::{Document, DocumentQuery, Identifier}; use dash_sdk::query_types::{Documents, IndexMap}; use futures::future::join_all; +use grovestark::GroveSTARKTask; +use migration::MigrationTask; +use shielded::ShieldedTask; use std::collections::BTreeMap; use std::future::Future; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; -use migration::MigrationTask; -use shielded::ShieldedTask; use tokens::TokenTask; -use grovestark::GroveSTARKTask; pub mod broadcast_state_transition; pub mod contested_names; @@ -60,6 +60,8 @@ pub mod grovestark; pub mod identity; pub mod migration; pub mod platform_info; +#[cfg(test)] +mod protocol_13_transitions; pub mod register_contract; pub mod shielded; pub mod system_task; @@ -810,7 +812,7 @@ pub enum BackendTaskSuccessResult { TokenEstimatedNonClaimedPerpetualDistributionAmountWithExplanation( IdentityTokenIdentifier, TokenAmount, - IntervalEvaluationExplanation, + distribution_function::evaluate_interval::IntervalEvaluationExplanation, ), ContractsWithDescriptions( BTreeMap, Vec)>, @@ -1430,7 +1432,7 @@ impl AppContext { Ok(self.run_migration_task(migration_task).await?) } BackendTask::ReinitCoreClientAndSdk => { - Arc::clone(self).reinit_core_client_and_sdk()?; + self.rebuild_connection().await?; Ok(BackendTaskSuccessResult::CoreClientReinitialized) } BackendTask::SwitchNetwork { network, start_spv } => { diff --git a/src/backend_task/platform_info.rs b/src/backend_task/platform_info.rs index fff1f014f..16ad8cc3a 100644 --- a/src/backend_task/platform_info.rs +++ b/src/backend_task/platform_info.rs @@ -1,11 +1,13 @@ use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; -use crate::context::{AppContext, DET_PLATFORM_VERSION}; +use crate::context::AppContext; use crate::model::datetime; -use crate::model::fee_estimation::PlatformFeeEstimator; +use chrono::{LocalResult, prelude::*}; +use chrono_humanize::{Accuracy, HumanTime, Tense}; use dash_sdk::Error as SdkError; use dash_sdk::Sdk; use dash_sdk::dashcore_rpc::RpcApi; +use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::block::extended_epoch_info::{ ExtendedEpochInfo, v0::ExtendedEpochInfoV0Getters, }; @@ -13,22 +15,19 @@ use dash_sdk::dpp::core_types::validator_set::v0::ValidatorSetV0Getters; use dash_sdk::dpp::dashcore::hashes::Hash; use dash_sdk::dpp::dashcore::{Address, Network, ScriptBuf}; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; -use dash_sdk::dpp::data_contracts::SystemDataContract; -use dash_sdk::dpp::data_contracts::withdrawals_contract::WithdrawalStatus; -use dash_sdk::dpp::data_contracts::withdrawals_contract::v1::document_types::withdrawal::properties::{ - AMOUNT, STATUS, TRANSACTION_INDEX, +use dash_sdk::dpp::data_contracts::withdrawals_contract::{ + WithdrawalStatus, + v1::document_types::withdrawal::properties::{AMOUNT, STATUS, TRANSACTION_INDEX}, }; use dash_sdk::dpp::document::{Document, DocumentV0Getters}; use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::platform_value::Value; use dash_sdk::dpp::platform_value::btreemap_extensions::BTreeValueMapHelper; use dash_sdk::dpp::state_transition::identity_credit_withdrawal_transition::fields::OUTPUT_SCRIPT; -use dash_sdk::dpp::system_data_contracts::load_system_data_contract; use dash_sdk::dpp::version::PlatformVersion; use dash_sdk::dpp::withdrawal::daily_withdrawal_limit::daily_withdrawal_limit; use dash_sdk::dpp::{dash_to_credits, version::ProtocolVersionVoteCount}; -use dash_sdk::drive::query::{SelectProjection, OrderClause, WhereClause, WhereOperator}; -use dash_sdk::dpp::address_funds::PlatformAddress; +use dash_sdk::drive::query::{OrderClause, SelectProjection, WhereClause, WhereOperator}; use dash_sdk::platform::fetch_current_no_parameters::FetchCurrent; use dash_sdk::platform::proto::get_documents_request::get_documents_request_v0::Start; use dash_sdk::platform::{ @@ -38,8 +37,6 @@ use dash_sdk::query_types::{ AddressInfo, CurrentQuorumsInfo, NoParamQuery, ProtocolVersionUpgrades, TotalCreditsInPlatform, }; use std::sync::Arc; -use chrono::{LocalResult, prelude::*}; -use chrono_humanize::{Accuracy, HumanTime, Tense}; #[derive(Debug, Clone, PartialEq)] pub enum PlatformInfoTaskRequestType { @@ -252,15 +249,11 @@ fn format_extended_epoch_info( ) } -/// The degraded rendering used when the live epoch fetch fails. -/// +/// The epoch summary shown when the current epoch cannot be read. /// `protocol_version` is `None` while the connected network has not confirmed -/// one. The fee multiplier here is the app's own default, and the copy says so -/// rather than claiming anything about what networks charge: with the fetch -/// failed, this build cannot know that. A user comparing the figure against a -/// network that raised its fees has to be able to tell which of the two the app -/// is charging by. -fn format_hardcoded_current_epoch_info( +/// one; `fee_multiplier_permille` is the last known multiplier, not a fresh +/// network reading. +fn format_unavailable_current_epoch_info( protocol_version: Option, fee_multiplier_permille: u64, ) -> String { @@ -269,16 +262,16 @@ fn format_hardcoded_current_epoch_info( Some(protocol_version) => format!( "Current Epoch Information:\n\ • Protocol Version: {protocol_version}\n\ - • Fee Multiplier: {fee_multiplier}x (a fixed value, not read from the network)\n\n\ - Epoch details could not be read from the network just now. The multiplier shown \ - is this app's default, not a value read from the network. Try again in a moment." + • Fee Multiplier: {fee_multiplier}x (the last known value, not a fresh reading)\n\n\ + The current epoch could not be read from the network. Fee estimates use the last \ + known fee multiplier. Refresh again in a moment to read the current one." ), None => format!( "Current Epoch Information:\n\ • Protocol Version: the connected network has not confirmed one yet.\n\ - • Fee Multiplier: {fee_multiplier}x (a fixed value, not read from the network)\n\n\ - Epoch details could not be read from the network just now. The multiplier shown \ - is this app's default, not a value read from the network. Try again in a moment." + • Fee Multiplier: {fee_multiplier}x (the last known value, not a fresh reading)\n\n\ + The current epoch could not be read from the network. Fee estimates use the last \ + known fee multiplier. Refresh again in a moment to read the current one." ), } } @@ -321,51 +314,26 @@ fn format_current_quorums_info(current_quorums_info: &CurrentQuorumsInfo) -> Str result } -/// The daily withdrawal limit at [`DET_PLATFORM_VERSION`], never at the -/// newest version the upstream crates know. +/// The daily withdrawal limit the network enforces, at `platform_version` — +/// never at whatever version the upstream crates happen to know best. /// /// Protocol 13 uses `daily_withdrawal_limit` v1, a flat 2000 Dash that ignores /// the total. Protocol 14 (v2) derives it from the total credits Platform held a /// day ago, which the SDK cannot query; passing today's total is an accepted -/// gap for this display-only figure once DET moves to protocol 14. -fn local_daily_withdrawal_limit( +/// gap for this display-only figure. +fn network_daily_withdrawal_limit( total_credits_on_platform: Credits, + platform_version: &PlatformVersion, ) -> Result { - daily_withdrawal_limit(Some(total_credits_on_platform), DET_PLATFORM_VERSION) + daily_withdrawal_limit(Some(total_credits_on_platform), platform_version) .map_err(|e| WithdrawalParseError::DailyLimit(Box::new(e))) } -/// The Withdrawals system contract at [`DET_PLATFORM_VERSION`]. -/// The `status` values a finished withdrawal can carry, as the -/// `RecentlyCompletedWithdrawals` filter sends them. -/// -/// `FAILED` (5) exists only in the v2 withdrawals schema (protocol 14); the v1 -/// schema this build loads stops at `EXPIRED` (4). Sending it at protocol 13 is -/// still correct and deliberate: a JSON-schema `enum` constrains documents being -/// created, not query values. Nothing in the query path checks membership — the -/// `status` property is typed as a plain integer, and -/// `DocumentPropertyType::encode_value_for_tree_keys` encodes any value that -/// converts to it. So the out-of-enum member encodes, matches nothing on a -/// protocol-13 network (which cannot produce a failed withdrawal), and leaves -/// `COMPLETE` and `EXPIRED` matching as usual — while keeping the query complete -/// once the network, and DET's version pin, reach protocol 14. -fn completed_withdrawal_statuses() -> Vec { - vec![ - Value::U8(WithdrawalStatus::COMPLETE as u8), - Value::U8(WithdrawalStatus::EXPIRED as u8), - Value::U8(WithdrawalStatus::FAILED as u8), - ] -} - -fn withdrawals_contract() -> Result { - load_system_data_contract(SystemDataContract::Withdrawals, DET_PLATFORM_VERSION) - .map_err(|e| TaskError::from(SdkError::Protocol(e))) -} - fn format_withdrawal_documents_with_daily_limit( withdrawal_documents: &[Document], total_credits_on_platform: Credits, network: Network, + platform_version: &PlatformVersion, ) -> Result { let total_amount: Credits = withdrawal_documents .iter() @@ -384,7 +352,8 @@ fn format_withdrawal_documents_with_daily_limit( .map(|document| format_withdrawal_line(document, network)) .collect::, WithdrawalParseError>>()?; - let daily_withdrawal_limit = local_daily_withdrawal_limit(total_credits_on_platform)?; + let daily_withdrawal_limit = + network_daily_withdrawal_limit(total_credits_on_platform, platform_version)?; Ok(format!( "Withdrawal Information:\n\n\ @@ -594,6 +563,37 @@ fn build_withdrawals_result( } impl AppContext { + /// Fetches the current epoch — authenticated by proof — and adopts its + /// fee multiplier and protocol version. The only path by which the fee + /// multiplier cache takes a network value, and fee estimates follow it, + /// so it must never come from an unproven source. + pub(crate) async fn refresh_current_epoch( + &self, + sdk: &Sdk, + ) -> Result> { + // Captured together before the read: a read on an SDK a rebuild has + // replaced, or begun before it, is never adopted. + let (generation, on_current_sdk) = self.connection_snapshot_for(sdk); + let epoch_info = ExtendedEpochInfo::fetch_current(sdk) + .await + .map_err(Box::new)?; + if on_current_sdk { + self.adopt_epoch_info(generation, &epoch_info); + } + Ok(epoch_info) + } + + /// Adopts the fee multiplier and protocol version of a proved epoch read + /// on connection `generation` (see `AppContext::adopt_epoch`). + fn adopt_epoch_info(&self, generation: u64, epoch_info: &ExtendedEpochInfo) { + self.adopt_epoch( + generation, + epoch_info.index(), + epoch_info.fee_multiplier_permille(), + epoch_info.protocol_version(), + ); + } + pub async fn run_platform_info_task( self: &Arc, request: PlatformInfoTaskRequestType, @@ -624,15 +624,9 @@ impl AppContext { )) } PlatformInfoTaskRequestType::CurrentEpochInfo => { - // The live fetch is proved: `ExtendedEpochInfo::fetch_current` resolves the - // current epoch with two explicit-start queries the proof verifier accepts - // (dashpay/platform#4231, in this repo's pin). Its protocol version is a - // network observation, so it feeds the ratchet directly. - match ExtendedEpochInfo::fetch_current(sdk).await { + match self.refresh_current_epoch(sdk).await { Ok(epoch_info) => { let fee_multiplier = epoch_info.fee_multiplier_permille(); - self.set_fee_multiplier_permille(fee_multiplier); - self.set_platform_protocol_version(epoch_info.protocol_version()); let mut formatted = format_extended_epoch_info(epoch_info, self.network, true); @@ -647,38 +641,36 @@ impl AppContext { Err(error) => { tracing::warn!( %error, - "Current-epoch fetch failed; falling back to the cached fee \ - multiplier and the protocol-version ratchet" + "Current epoch fetch failed; keeping the last known fee multiplier" ); - - // Without the epoch, the network's version is learned from the - // ratchet a proved DPNS fetch drives. Only a successful fetch proves - // it came from the network, not the local seed. - match DataContract::fetch(sdk, self.dpns_contract.id()).await { - Ok(_) => { - self.set_platform_protocol_version(sdk.protocol_version_number()) + // Without the epoch, the network's version is learned from + // the ratchet a proved DPNS fetch drives. Only a successful + // fetch proves it came from the network, not the local seed. + let (generation, on_current_sdk) = self.connection_snapshot_for(sdk); + match DataContract::fetch(sdk, self.dpns_contract().id()).await { + Ok(_) if on_current_sdk => { + self.raise_platform_protocol_version_on( + generation, + sdk.protocol_version_number(), + ); } + Ok(_) => {} Err(error) => tracing::warn!( %error, "Protocol-version ratchet trigger (DPNS contract fetch) failed; \ the network's protocol version stays unconfirmed" ), } - - // 1000 permille (1.0x) is what the network actually stores, not a - // placeholder: an epoch's multiplier is written from - // `platform_version.fee_version.uses_version_fee_multiplier_permille`, - // and both fee schedules in the pinned crate declare `Some(1000)`. - let fee_multiplier = PlatformFeeEstimator::DEFAULT_FEE_MULTIPLIER_PERMILLE; - self.set_fee_multiplier_permille(fee_multiplier); - let confirmed = match self.platform_protocol_version() { 0 => None, version => Some(version), }; Ok(BackendTaskSuccessResult::PlatformInfo( PlatformInfoTaskResult::TextResult( - format_hardcoded_current_epoch_info(confirmed, fee_multiplier), + format_unavailable_current_epoch_info( + confirmed, + self.fee_multiplier_permille(), + ), ), )) } @@ -741,12 +733,11 @@ impl AppContext { } } PlatformInfoTaskRequestType::CurrentWithdrawalsInQueue => { - let withdrawal_contract = withdrawals_contract()?; - let queued_document_query = DocumentQuery { + integer_range_clauses: vec![], sub_queries: Vec::new(), select: SelectProjection::documents(), - data_contract: Arc::new(withdrawal_contract), + data_contract: self.withdraws_contract(), document_type_name: "withdrawal".to_string(), where_clauses: vec![], time_range_clauses: Vec::new(), @@ -771,6 +762,7 @@ impl AppContext { &withdrawal_docs, total_credits.0, self.network, + self.connected_platform_version(), )?; Ok(BackendTaskSuccessResult::PlatformInfo( PlatformInfoTaskResult::TextResult(formatted), @@ -788,17 +780,20 @@ impl AppContext { } } PlatformInfoTaskRequestType::RecentlyCompletedWithdrawals => { - let withdrawal_contract = withdrawals_contract()?; - let completed_document_query = DocumentQuery { + integer_range_clauses: vec![], sub_queries: Vec::new(), select: SelectProjection::documents(), - data_contract: Arc::new(withdrawal_contract), + data_contract: self.withdraws_contract(), document_type_name: "withdrawal".to_string(), where_clauses: vec![WhereClause { field: "status".to_string(), operator: WhereOperator::In, - value: Value::Array(completed_withdrawal_statuses()), + value: Value::Array(vec![ + Value::U8(WithdrawalStatus::COMPLETE as u8), + Value::U8(WithdrawalStatus::EXPIRED as u8), + Value::U8(WithdrawalStatus::FAILED as u8), + ]), }], time_range_clauses: Vec::new(), group_by: Vec::new(), @@ -877,15 +872,17 @@ impl AppContext { limit, start_after, } => { - let withdrawal_contract = withdrawals_contract()?; - // `0` is the upstream sentinel for "default limit"; clamp the // requested page so the cursor heuristic has a known bound. let page_limit = limit.unwrap_or(50).clamp(1, 100); let start = start_after.map(|id| Start::StartAfter(id.to_buffer().to_vec())); let statuses = if completed { - completed_withdrawal_statuses() + vec![ + Value::U8(WithdrawalStatus::COMPLETE as u8), + Value::U8(WithdrawalStatus::EXPIRED as u8), + Value::U8(WithdrawalStatus::FAILED as u8), + ] } else { vec![ Value::U8(WithdrawalStatus::QUEUED as u8), @@ -910,9 +907,10 @@ impl AppContext { ]; let query = DocumentQuery { + integer_range_clauses: vec![], sub_queries: Vec::new(), select: SelectProjection::documents(), - data_contract: Arc::new(withdrawal_contract), + data_contract: self.withdraws_contract(), document_type_name: "withdrawal".to_string(), where_clauses, time_range_clauses: Vec::new(), @@ -995,145 +993,161 @@ impl AppContext { mod tests { use super::*; - /// The withdrawal limit follows DET's protocol version, not the newest one - /// upstream knows: protocol 13 keeps the flat 2000 Dash, where protocol 14 - /// reports 15% of the total, capped at 4000 Dash. - /// - /// `dash_to_credits!` stringifies its argument and parses the text, so a - /// Rust numeric separator is not a digit to it: `dash_to_credits!(1_000_000)` - /// silently evaluates to 0 credits. Never write a separator inside this - /// macro. The total is asserted below, and the protocol-14 expectation is - /// pinned to the 4000 Dash cap — a value only a genuinely large total - /// produces, so a zeroed total fails the test instead of passing it for the - /// wrong reason (at 0 credits, protocol 14 returns its 500 Dash floor). + /// The limit is whatever the network's own protocol version says it is, + /// never a version fixed in this build: protocol 13 reports a flat 2000 + /// Dash whatever the total, protocol 14 derives it from that total (15% of + /// it, `SYSTEM_LIMITS_V4.daily_withdrawal_limit_percent`, never below the + /// 500 Dash maximum withdrawal). #[test] - fn daily_withdrawal_limit_uses_det_platform_version_not_latest() { + fn daily_withdrawal_limit_follows_the_given_platform_version() { + // `dash_to_credits!` stringifies its argument and parses the digits, so + // a `1_000_000` written with separators silently reads as no credits. let total = dash_to_credits!(1000000); - assert_eq!( - total, - 1_000_000 * dash_to_credits!(1), - "the total must really be a million Dash" - ); + let v13 = PlatformVersion::get(13).expect("protocol 13"); + let v14 = PlatformVersion::get(14).expect("protocol 14"); - let det_limit = local_daily_withdrawal_limit(total).expect("limit"); - assert_eq!(det_limit, dash_to_credits!(2000)); assert_eq!( - det_limit, - daily_withdrawal_limit(Some(total), DET_PLATFORM_VERSION).expect("limit") + network_daily_withdrawal_limit(total, v13).expect("limit"), + dash_to_credits!(2000) ); - - let latest_limit = - daily_withdrawal_limit(Some(total), PlatformVersion::latest()).expect("limit"); assert_eq!( - latest_limit, - dash_to_credits!(4000), - "protocol 14 caps a million-Dash total at 4000 Dash" + network_daily_withdrawal_limit(total, v14).expect("limit"), + dash_to_credits!(150000) ); - assert_ne!( - det_limit, latest_limit, - "the limit must not follow PlatformVersion::latest()" + // A small total falls back to the maximum single withdrawal. + assert_eq!( + network_daily_withdrawal_limit(dash_to_credits!(1000), v14).expect("limit"), + dash_to_credits!(500) ); } - /// The Withdrawals contract used for queries is loaded at DET's protocol + /// A proved epoch sets both the fee multiplier cache and the protocol /// version. #[test] - fn withdrawals_contract_uses_det_platform_version() { - let expected = - load_system_data_contract(SystemDataContract::Withdrawals, DET_PLATFORM_VERSION) - .expect("contract"); - assert_eq!(withdrawals_contract().expect("contract"), expected); - assert_eq!(DET_PLATFORM_VERSION.protocol_version, 13); + fn a_proved_epoch_updates_the_fee_multiplier_and_protocol_version() { + use dash_sdk::dpp::block::extended_epoch_info::v0::ExtendedEpochInfoV0; + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = crate::context::test_support::test_app_context(temp_dir.path()); + ctx.set_fee_multiplier_permille(1000); + + ctx.adopt_epoch_info( + ctx.connection_generation(), + &ExtendedEpochInfo::V0(ExtendedEpochInfoV0 { + index: 7, + first_block_time: 0, + first_block_height: 0, + first_core_block_height: 0, + fee_multiplier_permille: 1_500, + protocol_version: 14, + }), + ); + + assert_eq!(ctx.fee_multiplier_permille(), 1_500); + assert_eq!(ctx.platform_protocol_version(), 14); } - /// The completed-withdrawal filter keeps `FAILED` even though the schema - /// this build loads stops at `EXPIRED`: a schema `enum` constrains - /// documents, not query values, and every status encodes into the `status` - /// property's key representation at both schema versions. + /// Epoch reads can finish out of order: an older epoch arriving after a + /// newer one must not put back its fee multiplier or protocol version. #[test] - fn completed_withdrawal_filter_encodes_at_both_schema_versions() { - use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; - use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; + fn an_older_epoch_does_not_overwrite_a_newer_one() { + use dash_sdk::dpp::block::extended_epoch_info::v0::ExtendedEpochInfoV0; + let epoch = |index, fee_multiplier_permille, protocol_version| { + ExtendedEpochInfo::V0(ExtendedEpochInfoV0 { + index, + first_block_time: 0, + first_block_height: 0, + first_core_block_height: 0, + fee_multiplier_permille, + protocol_version, + }) + }; + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = crate::context::test_support::test_app_context(temp_dir.path()); + ctx.adopt_epoch_info(ctx.connection_generation(), &epoch(8, 1_500, 14)); + ctx.adopt_epoch_info(ctx.connection_generation(), &epoch(7, 1_000, 13)); + assert_eq!(ctx.fee_multiplier_permille(), 1_500); + assert_eq!(ctx.platform_protocol_version(), 14); + + // The same or a newer epoch is adopted; the protocol version never + // goes back. + ctx.adopt_epoch_info(ctx.connection_generation(), &epoch(8, 1_600, 14)); + assert_eq!(ctx.fee_multiplier_permille(), 1_600); + ctx.adopt_epoch_info(ctx.connection_generation(), &epoch(9, 1_700, 13)); + assert_eq!(ctx.fee_multiplier_permille(), 1_700); + assert_eq!(ctx.platform_protocol_version(), 14); + } + + /// Rebuilding the connection resets everything learned from the old one: + /// the protocol version goes back to unknown, the fee multiplier to the + /// default, and the next proved epoch is adopted whatever its index. + #[test] + fn a_rebuilt_connection_starts_from_unknown_protocol_and_default_fees() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = crate::context::test_support::test_app_context(temp_dir.path()); + ctx.adopt_epoch(ctx.connection_generation(), 8, 1_500, 14); + + ctx.replace_connection(Arc::new(Sdk::new_mock())); + + assert_eq!(ctx.platform_protocol_version(), 0); assert_eq!( - completed_withdrawal_statuses(), - vec![Value::U8(3), Value::U8(4), Value::U8(5)], - "COMPLETE, EXPIRED and FAILED, in that order" + ctx.fee_multiplier_permille(), + crate::model::fee_estimation::PlatformFeeEstimator::DEFAULT_FEE_MULTIPLIER_PERMILLE ); + assert!(ctx.adopt_epoch(ctx.connection_generation(), 2, 1_100, 13)); + assert_eq!(ctx.fee_multiplier_permille(), 1_100); + assert_eq!(ctx.platform_protocol_version(), 13); + } - let status_property = |version| { - let contract = load_system_data_contract(SystemDataContract::Withdrawals, version) - .expect("withdrawals contract"); - contract - .document_type_cloned_for_name("withdrawal") - .expect("withdrawal document type") - .properties() - .get("status") - .expect("status property") - .clone() - }; + /// An epoch read that started on the old connection and finishes after + /// the rebuild is dropped, so it cannot be the new connection's first + /// adoption. + #[test] + fn an_epoch_read_from_before_a_rebuild_is_dropped() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = crate::context::test_support::test_app_context(temp_dir.path()); + let before_rebuild = ctx.connection_generation(); - // v1 (protocol 13, what DET loads) and v2 (protocol 14) differ only in - // the enum's last member, so the property type is identical. - let det = status_property(DET_PLATFORM_VERSION); - let latest = status_property(PlatformVersion::latest()); + ctx.replace_connection(Arc::new(Sdk::new_mock())); + + assert!(!ctx.adopt_epoch(before_rebuild, 9, 1_500, 14)); + assert_eq!(ctx.platform_protocol_version(), 0); assert_eq!( - det.property_type, latest.property_type, - "the status property type must not differ across schema versions" + ctx.fee_multiplier_permille(), + crate::model::fee_estimation::PlatformFeeEstimator::DEFAULT_FEE_MULTIPLIER_PERMILLE ); - - // FAILED is outside the v1 enum, yet encodes for the query exactly like - // the statuses that are inside it. - for status in completed_withdrawal_statuses() { - assert!( - det.property_type - .encode_value_for_tree_keys(&status) - .is_ok(), - "status {status:?} must encode against the protocol-13 schema" - ); - } } #[test] - fn epoch_fallback_reports_protocol_version_and_the_default_fee_multiplier() { + fn unavailable_epoch_reports_protocol_version_and_the_last_known_fee_multiplier() { assert_eq!( - format_hardcoded_current_epoch_info(Some(12), 1000), + format_unavailable_current_epoch_info(Some(14), 1000), "Current Epoch Information:\n\ - • Protocol Version: 12\n\ - • Fee Multiplier: 1x (a fixed value, not read from the network)\n\n\ - Epoch details could not be read from the network just now. The multiplier shown \ - is this app's default, not a value read from the network. Try again in a moment." + • Protocol Version: 14\n\ + • Fee Multiplier: 1x (the last known value, not a fresh reading)\n\n\ + The current epoch could not be read from the network. Fee estimates use the last \ + known fee multiplier. Refresh again in a moment to read the current one." ); } - /// The multiplier is presented as this app's default, never as a live - /// reading and never as a claim about what networks charge — with the fetch - /// failed, the app cannot know that. A user comparing the figure against a - /// network that raised its fees must be able to see which of the two the app - /// is charging by. + /// The multiplier is presented as stale, never as a live reading: a user + /// comparing it against a network that raised its fees must be able to see + /// which of the two the app is charging by. #[test] - fn epoch_fallback_never_presents_the_fee_multiplier_as_a_network_reading() { + fn unavailable_epoch_never_presents_the_fee_multiplier_as_a_network_reading() { for protocol_version in [None, Some(12)] { - let formatted = format_hardcoded_current_epoch_info(protocol_version, 1500); + let formatted = format_unavailable_current_epoch_info(protocol_version, 1500); assert!( formatted - .contains("• Fee Multiplier: 1.5x (a fixed value, not read from the network)"), - "the multiplier must be shown as fixed, got: {formatted}" - ); - assert!( - formatted.contains("is this app's default, not a value read from the network"), - "the degraded copy must name the default as the app's own, got: {formatted}" - ); - assert!( - !formatted.contains("every network charges"), - "a failed fetch cannot support a claim about what networks charge: {formatted}" + .contains("• Fee Multiplier: 1.5x (the last known value, not a fresh reading)"), + "the multiplier must be shown as stale, got: {formatted}" ); } } #[test] - fn epoch_fallback_never_reports_an_unconfirmed_protocol_version_as_a_number() { - let formatted = format_hardcoded_current_epoch_info(None, 1000); + fn unavailable_epoch_never_reports_an_unconfirmed_protocol_version_as_a_number() { + let formatted = format_unavailable_current_epoch_info(None, 1000); assert!( formatted .contains("• Protocol Version: the connected network has not confirmed one yet."), @@ -1141,33 +1155,29 @@ mod tests { ); } - /// Fee estimates must not keep running on whatever multiplier a previous - /// refresh happened to leave behind: when the live epoch fetch fails (here, - /// against a mock SDK that answers nothing), the task republishes the - /// default one. + /// A failed epoch fetch must not reset a multiplier a previous live read + /// published: the last known value is closer to the network's than any + /// fixed default, and the report names the value it keeps. #[tokio::test] - async fn epoch_fallback_republishes_the_default_fee_multiplier() { + async fn failed_epoch_fetch_keeps_the_last_known_fee_multiplier() { let temp_dir = tempfile::tempdir().expect("tempdir"); let ctx = crate::context::test_support::test_app_context(temp_dir.path()); let sdk = dash_sdk::Sdk::new_mock(); - ctx.set_fee_multiplier_permille(7_777); + ctx.set_fee_multiplier_permille(1_500); let result = ctx .run_platform_info_task(PlatformInfoTaskRequestType::CurrentEpochInfo, &sdk) .await .expect("a failed epoch fetch degrades to a text result"); - assert_eq!( - ctx.fee_multiplier_permille(), - PlatformFeeEstimator::DEFAULT_FEE_MULTIPLIER_PERMILLE - ); + assert_eq!(ctx.fee_multiplier_permille(), 1_500); let BackendTaskSuccessResult::PlatformInfo(PlatformInfoTaskResult::TextResult(text)) = result else { - panic!("the degraded epoch path returns a text result"); + panic!("a failed epoch fetch returns a text result"); }; assert!( - text.contains("• Fee Multiplier: 1x"), + text.contains("• Fee Multiplier: 1.5x (the last known value"), "the reported multiplier must match the cached one, got: {text}" ); } diff --git a/src/backend_task/tokens/query_tokens.rs b/src/backend_task/tokens/query_tokens.rs index a481fefcd..0767c1c63 100644 --- a/src/backend_task/tokens/query_tokens.rs +++ b/src/backend_task/tokens/query_tokens.rs @@ -26,12 +26,10 @@ impl AppContext { sdk: &Sdk, ) -> Result { // -- 1. fetch keyword -> contractId docs - let mut kw_query = - DocumentQuery::new(self.keyword_search_contract.clone(), "contractKeywords").map_err( - |e| TaskError::TokenQueryError { - detail: format!("Failed to create document query: {}", e), - }, - )?; + let mut kw_query = DocumentQuery::new(self.keyword_search_contract(), "contractKeywords") + .map_err(|e| TaskError::TokenQueryError { + detail: format!("Failed to create document query: {}", e), + })?; kw_query.limit = 100; kw_query.start = cursor.clone(); kw_query = kw_query.with_where(WhereClause { @@ -71,10 +69,11 @@ impl AppContext { for cid in &contract_ids { let mut desc_query = - DocumentQuery::new(self.keyword_search_contract.clone(), "shortDescription") - .map_err(|e| TaskError::TokenQueryError { + DocumentQuery::new(self.keyword_search_contract(), "shortDescription").map_err( + |e| TaskError::TokenQueryError { detail: format!("Failed to create document query: {}", e), - })?; + }, + )?; desc_query.limit = 1; desc_query = desc_query.with_where(WhereClause { field: "contractId".into(), diff --git a/src/context/contested_names_db.rs b/src/context/contested_names_db.rs index aabd34e81..3a219f3e7 100644 --- a/src/context/contested_names_db.rs +++ b/src/context/contested_names_db.rs @@ -21,6 +21,7 @@ use crate::model::dpns_voting::{ use crate::model::qualified_identity::QualifiedIdentity; use crate::utils::time::now_ms; use crate::wallet_backend::{DetScope, KvAdapterError}; +#[cfg(test)] use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::data_contract::document_type::DocumentTypeRef; use dash_sdk::dpp::document::DocumentV0Getters; @@ -120,12 +121,7 @@ impl StoredContestedName { self.locked || self.awarded_to.is_some() } - fn to_contested_name(&self, network: Network) -> ContestedName { - let join_window = crate::model::dpns::contest_durations( - network, - crate::context::default_platform_version(&network), - ) - .join; + fn to_contested_name(&self, join_window: Duration) -> ContestedName { let awarded_to_id = self.awarded_to.map(Identifier::from); // Match pre-C6 semantics: state is computed from the latest @@ -234,7 +230,15 @@ impl AppContext { let mut out = Vec::with_capacity(keys.len()); for key in keys { match kv.get::(DetScope::Global, &key) { - Ok(Some(stored)) => out.push(stored.to_contested_name(self.network)), + Ok(Some(stored)) => out.push( + stored.to_contested_name( + crate::model::dpns::contest_durations( + self.network, + self.platform_version(), + ) + .join, + ), + ), Ok(None) => {} Err(e) => tracing::warn!( key = %key, @@ -262,7 +266,13 @@ impl AppContext { Ok(Some(stored)) => match stored.end_time { Some(t) if t <= current_timestamp => {} _ => { - let contest = stored.to_contested_name(self.network); + let contest = stored.to_contested_name( + crate::model::dpns::contest_durations( + self.network, + self.platform_version(), + ) + .join, + ); if !contest.is_unread() { out.push(contest); } @@ -1420,6 +1430,22 @@ mod tests { // Decode: contest state branches the cache resolves at read time. // ---------------------------------------------------------------- + #[test] + fn to_contested_name_uses_the_supplied_protocol_join_window() { + let stored = StoredContestedName { + contestants: vec![contestant(1, Some(now_ms() - 60_000))], + ..Default::default() + }; + assert_eq!( + stored.to_contested_name(Duration::from_secs(120)).state, + ContestState::Joinable + ); + assert_eq!( + stored.to_contested_name(Duration::from_secs(30)).state, + ContestState::Ongoing + ); + } + #[test] fn to_contested_name_reports_won_by_when_awarded() { let stored = StoredContestedName { @@ -1427,7 +1453,7 @@ mod tests { awarded_to: Some([7u8; 32]), ..Default::default() }; - let cn = stored.to_contested_name(Network::Testnet); + let cn = stored.to_contested_name(Duration::from_secs(45 * 60)); assert_eq!(cn.state, ContestState::WonBy(Identifier::from([7u8; 32]))); assert_eq!(cn.awarded_to, Some(Identifier::from([7u8; 32]))); } @@ -1439,7 +1465,7 @@ mod tests { locked: true, ..Default::default() }; - let cn = stored.to_contested_name(Network::Testnet); + let cn = stored.to_contested_name(Duration::from_secs(45 * 60)); assert_eq!(cn.state, ContestState::Locked); assert!(cn.awarded_to.is_none()); } @@ -1455,7 +1481,7 @@ mod tests { ..Default::default() }; assert_eq!( - stored.to_contested_name(Network::Testnet).state, + stored.to_contested_name(Duration::from_secs(45 * 60)).state, ContestState::Locked ); } @@ -1469,7 +1495,7 @@ mod tests { contestants: vec![contestant(1, None), contestant(2, None)], ..Default::default() }; - let cn = stored.to_contested_name(Network::Testnet); + let cn = stored.to_contested_name(Duration::from_secs(45 * 60)); assert_eq!(cn.state, ContestState::Unknown); assert_eq!(cn.contestants.as_ref().map(Vec::len), Some(2)); } @@ -1482,7 +1508,7 @@ mod tests { contestants: vec![contestant(3, Some(100))], ..Default::default() }; - let cn = stored.to_contested_name(Network::Testnet); + let cn = stored.to_contested_name(Duration::from_secs(45 * 60)); let mapped = &cn.contestants.unwrap()[0]; assert_eq!(mapped.id, Identifier::from([3u8; 32])); assert_eq!(mapped.name, "name-3"); @@ -1685,8 +1711,8 @@ mod tests { use dash_sdk::dpp::block::block_info::BlockInfo; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; let (_temp_dir, context, _history) = cold_history(3); - let document_type = context - .dpns_contract + let dpns_contract = context.dpns_contract(); + let document_type = dpns_contract .document_type_for_name("domain") .expect("domain document type"); let answer = |winner| Contenders { diff --git a/src/context/contract_token_db.rs b/src/context/contract_token_db.rs index 2bb044e63..b700d67bf 100644 --- a/src/context/contract_token_db.rs +++ b/src/context/contract_token_db.rs @@ -130,11 +130,11 @@ impl AppContext { // Pin the system contracts at the head of the list, in display order. let system_contracts = [ - (&self.dpns_contract, "dpns"), - (&self.token_history_contract, "token_history"), - (&self.withdraws_contract, "withdrawals"), - (&self.keyword_search_contract, "keyword_search"), - (&self.dashpay_contract, "dashpay"), + (self.dpns_contract(), "dpns"), + (self.token_history_contract(), "token_history"), + (self.withdraws_contract(), "withdrawals"), + (self.keyword_search_contract(), "keyword_search"), + (self.dashpay_contract(), "dashpay"), ]; for (index, (contract, alias)) in system_contracts.into_iter().enumerate() { contracts.insert( diff --git a/src/context/dpns_vote_state.rs b/src/context/dpns_vote_state.rs index 5fe5f8dbc..2da1d94da 100644 --- a/src/context/dpns_vote_state.rs +++ b/src/context/dpns_vote_state.rs @@ -333,7 +333,7 @@ impl AppContext { /// Build the exact Platform vote-poll ID for one DPNS label. pub fn dpns_vote_poll_id(&self, name: &str) -> Result { - dpns_vote_poll(&self.dpns_contract, name)? + dpns_vote_poll(&self.dpns_contract(), name)? .unique_id() .map_err(|source| TaskError::SdkError { source_error: Box::new(dash_sdk::Error::Protocol(source)), diff --git a/src/context/feature_gate.rs b/src/context/feature_gate.rs index 51d0e8eea..014d31dcf 100644 --- a/src/context/feature_gate.rs +++ b/src/context/feature_gate.rs @@ -1,5 +1,6 @@ use crate::context::AppContext; use crate::model::user_role::UserRole; +use dash_sdk::dpp::version::PlatformVersion; use dash_sdk::dpp::version::feature_initial_protocol_versions::SHIELDED_POOL_INITIAL_PROTOCOL_VERSION; /// Shielded state transitions activate at upstream rs-platform-version's @@ -28,6 +29,22 @@ pub enum Capability { ShieldedProtocol, } +/// The connected network's protocol version, when this build defines it. +/// +/// `None` before the version is fetched (0) and for a version newer than any +/// this build knows, which a proved epoch can report after a network upgrade. +/// Every capability reads the version through this, so both fail closed: this +/// build cannot tell what an unknown version's rules allow. +fn known_protocol_version(ctx: &AppContext) -> Option<&'static PlatformVersion> { + PlatformVersion::get_optional(ctx.platform_protocol_version()) +} + +/// Whether the connected protocol version is one this build defines and is +/// at least `activation`. +fn known_protocol_version_at_least(ctx: &AppContext, activation: u32) -> bool { + known_protocol_version(ctx).is_some_and(|version| version.protocol_version >= activation) +} + impl Capability { fn is_met(self, ctx: &AppContext) -> bool { match self { @@ -35,10 +52,8 @@ impl Capability { // A closed gate makes the capability unavailable on every network. None => false, // The version fetched from the connected network, not the hardcoded - // default — the capability is per-network. The boot value (0, "not - // fetched yet") is below any activation version, so a network whose - // version is not known yet reads as unmet rather than optimistic. - Some(activation) => ctx.platform_protocol_version() >= activation, + // default — the capability is per-network. + Some(activation) => known_protocol_version_at_least(ctx, activation), }, } } @@ -419,4 +434,31 @@ mod tests { ); } } + + /// A protocol version this build does not define closes every protocol + /// capability: nothing here knows that version's rules. The newest + /// version this build defines keeps them open. + #[test] + fn an_unknown_future_protocol_version_closes_every_protocol_capability() { + use dash_sdk::dpp::version::LATEST_VERSION; + let (_tmp, ctx) = ctx_with_role(UserRole::Developer); + let capabilities = [Capability::ShieldedProtocol]; + + ctx.set_platform_protocol_version(LATEST_VERSION); + for capability in capabilities { + assert!( + capability.is_met(&ctx), + "{capability:?} on protocol {LATEST_VERSION}" + ); + } + + ctx.set_platform_protocol_version(LATEST_VERSION + 1); + for capability in capabilities { + assert!( + !capability.is_met(&ctx), + "{capability:?} on unknown protocol {}", + LATEST_VERSION + 1 + ); + } + } } diff --git a/src/context/legacy_scheduled_votes.rs b/src/context/legacy_scheduled_votes.rs index f1ef5a2e1..208da3bd3 100644 --- a/src/context/legacy_scheduled_votes.rs +++ b/src/context/legacy_scheduled_votes.rs @@ -107,9 +107,11 @@ impl AppContext { now } }; + // Retired schedules were created under protocol 13. Keep their notice + // lifetime stable across SDK ratchets and future protocol upgrades. let contest_duration = crate::model::dpns::contest_durations( self.network, - super::default_platform_version(&self.network), + &dash_sdk::dpp::version::v13::PLATFORM_V13, ) .total; Ok(legacy_notice_is_current( @@ -268,7 +270,7 @@ mod tests { let contest_duration = crate::model::dpns::contest_durations( Network::Testnet, - crate::context::default_platform_version(&Network::Testnet), + &dash_sdk::dpp::version::v13::PLATFORM_V13, ) .total; let long_ago = now_ms() - u64::try_from(contest_duration.as_millis()).unwrap() - 1; diff --git a/src/context/mod.rs b/src/context/mod.rs index d5a2a5139..f2c62f63c 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -17,7 +17,10 @@ pub(crate) mod lock_probe; pub(crate) use identity_db::test_staging; pub(crate) mod identity_load_registry; pub mod migration_status; +#[cfg(test)] +mod platform_version_guard; mod settings_db; +mod system_contracts; #[cfg(test)] pub(crate) mod test_support; mod wallet_balances; @@ -50,15 +53,14 @@ use connection_status::ConnectionStatus; use dash_sdk::Sdk; use dash_sdk::dapi_client::AddressList; use dash_sdk::dashcore_rpc::{Auth, Client}; +use dash_sdk::dpp::block::epoch::EpochIndex; use dash_sdk::dpp::dashcore::Network; #[cfg(any(test, feature = "testing"))] use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::state_transition::StateTransitionSigningOptions; use dash_sdk::dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; -use dash_sdk::dpp::system_data_contracts::{SystemDataContract, load_system_data_contract}; use dash_sdk::dpp::version::PlatformVersion; -use dash_sdk::dpp::version::v13::PLATFORM_V13; use dash_sdk::platform::DataContract; use dash_sdk::platform::Identifier; use egui::Context; @@ -69,6 +71,7 @@ use std::path::PathBuf; use std::str::FromStr as _; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock, RwLockWriteGuard}; +use system_contracts::{SystemContracts, SystemContractsCache}; use crate::model::settings::AppSettings; use crate::model::user_role::{UserRole, UserRoleCell}; @@ -161,6 +164,15 @@ impl DpnsVoteDispatchCoordinator { } } +/// What epoch adoption is keyed on. See [`AppContext::adopt_epoch`]. +#[derive(Debug, Default)] +struct EpochAdoption { + /// The connection generation, bumped by every rebuild. + generation: u64, + /// The index of the last adopted proved epoch on this generation. + epoch_index: Option, +} + #[derive(Debug)] pub struct AppContext { pub(crate) data_dir: PathBuf, @@ -178,11 +190,9 @@ pub struct AppContext { // owned by upstream platform-wallet. spv_context_provider: RwLock, pub(crate) config: Arc>, - pub(crate) dpns_contract: Arc, - pub(crate) withdraws_contract: Arc, - pub(crate) dashpay_contract: Arc, - pub(crate) token_history_contract: Arc, - pub(crate) keyword_search_contract: Arc, + /// The system contracts at the SDK's protocol version; read through + /// [`Self::dpns_contract`] and its siblings. + system_contracts: SystemContractsCache, pub(crate) core_client: RwLock, pub(crate) has_wallet: AtomicBool, /// One-shot-per-session latch for the automatic all-wallets identity sweep. @@ -317,6 +327,9 @@ pub struct AppContext { /// Updated alongside fee_multiplier when epoch info is fetched. /// 0 means not yet fetched from the network. platform_protocol_version: AtomicU32, + /// The connection generation and the last adopted proved epoch. See + /// [`Self::adopt_epoch`] and [`Self::replace_connection`]. + epoch_adoption: Mutex, /// Frame-safe shielded balance snapshot (credits, summed across all Orchard accounts). /// /// Written by the Phase-E `on_shielded_sync_completed` event handler from the @@ -543,22 +556,14 @@ impl AppContext { return None; } }; - let platform_version = sdk.version(); - - let load_contract = |contract: SystemDataContract, label: &str| { - load_system_data_contract(contract, platform_version) - .inspect_err(|e| tracing::error!(?network, "Failed to load {label} contract: {e}")) - .ok() + let system_contracts = match SystemContracts::load(sdk.version()) { + Ok(contracts) => SystemContractsCache::new(contracts), + Err(e) => { + tracing::error!(?network, "Failed to load the system contracts: {e}"); + return None; + } }; - let dpns_contract = load_contract(SystemDataContract::DPNS, "DPNS")?; - let withdrawal_contract = load_contract(SystemDataContract::Withdrawals, "Withdrawals")?; - let token_history_contract = - load_contract(SystemDataContract::TokenHistory, "TokenHistory")?; - let keyword_search_contract = - load_contract(SystemDataContract::KeywordSearch, "KeywordSearch")?; - let dashpay_contract = load_contract(SystemDataContract::Dashpay, "Dashpay")?; - let addr = format!( "http://{}:{}", network_config.rpc_host(), @@ -600,11 +605,7 @@ impl AppContext { sdk: ArcSwap::from_pointee(sdk), spv_context_provider: spv_provider.into(), config: config_lock, - dpns_contract: Arc::new(dpns_contract), - withdraws_contract: Arc::new(withdrawal_contract), - dashpay_contract: Arc::new(dashpay_contract), - token_history_contract: Arc::new(token_history_contract), - keyword_search_contract: Arc::new(keyword_search_contract), + system_contracts, core_client: core_client.into(), has_wallet: false.into(), identity_autodiscovery_fired: AtomicBool::new(false), @@ -654,6 +655,7 @@ impl AppContext { PlatformFeeEstimator::DEFAULT_FEE_MULTIPLIER_PERMILLE, ), platform_protocol_version: AtomicU32::new(0), + epoch_adoption: Mutex::new(EpochAdoption::default()), shielded_balances: Arc::new(Mutex::new(std::collections::HashMap::new())), shielded_addresses: Arc::new(Mutex::new(std::collections::HashMap::new())), platform_balances: Arc::new(Mutex::new(std::collections::HashMap::new())), @@ -874,6 +876,13 @@ impl AppContext { .store(version, Ordering::Relaxed); } + /// Raise the cached platform protocol version to `version`; a lower one + /// is ignored. What the network proves never moves it back. + pub(crate) fn raise_platform_protocol_version(&self, version: u32) { + self.platform_protocol_version + .fetch_max(version, Ordering::Relaxed); + } + /// Synchronous read of the frame-safe shielded balance for `seed_hash`. /// /// Returns the total shielded balance in duffs, summed across all Orchard accounts. @@ -1101,8 +1110,63 @@ impl AppContext { } } + /// The platform version the SDK builds, validates and signs with: the + /// network's minimum until a proven response ratchets it to the version + /// the network runs. pub fn platform_version(&self) -> &'static PlatformVersion { - default_platform_version(&self.network) + self.sdk.load().version() + } + + /// The DPNS contract at [`Self::platform_version`]. + pub(crate) fn dpns_contract(&self) -> Arc { + Arc::clone(&self.system_contracts().dpns) + } + + /// The withdrawals contract at [`Self::platform_version`]. + pub(crate) fn withdraws_contract(&self) -> Arc { + Arc::clone(&self.system_contracts().withdrawals) + } + + /// The DashPay contract at [`Self::platform_version`]. + pub(crate) fn dashpay_contract(&self) -> Arc { + Arc::clone(&self.system_contracts().dashpay) + } + + /// The token history contract at [`Self::platform_version`]. + pub(crate) fn token_history_contract(&self) -> Arc { + Arc::clone(&self.system_contracts().token_history) + } + + /// The keyword search contract at [`Self::platform_version`]. + pub(crate) fn keyword_search_contract(&self) -> Arc { + Arc::clone(&self.system_contracts().keyword_search) + } + + fn system_contracts(&self) -> Arc { + self.system_contracts.get(self.platform_version()) + } + + /// The system contracts exactly as `platform_version` defines them, for a + /// caller that verifies at a version other than [`Self::platform_version`]. + /// Fails rather than serving another version's contracts. + pub(crate) fn system_contracts_at( + &self, + platform_version: &PlatformVersion, + ) -> Result, Box> { + self.system_contracts.try_get(platform_version) + } + + /// The platform version of the connected network, once its protocol + /// version has been fetched; [`Self::platform_version`] until then, or for + /// a protocol version this build does not know. + /// + /// For values that differ between protocol versions and must match the + /// network, such as fees introduced by a newer protocol. + pub fn connected_platform_version(&self) -> &'static PlatformVersion { + match self.platform_protocol_version() { + 0 => self.platform_version(), + version => PlatformVersion::get_optional(version).unwrap_or(self.platform_version()), + } } pub fn state_transition_options(&self) -> Option { @@ -1110,6 +1174,7 @@ impl AppContext { // and purpose checks when signing a state transition. if self.user_role().at_least(UserRole::Developer) { Some(StateTransitionCreationOptions { + contest_fund: None, signing_options: StateTransitionSigningOptions { allow_signing_with_any_security_level: true, allow_signing_with_any_purpose: true, @@ -1117,8 +1182,7 @@ impl AppContext { batch_feature_version: None, method_feature_version: None, base_feature_version: None, - // Action fee agreements are a protocol-14 field; DET builds - // protocol-13 transitions only (`DET_PLATFORM_VERSION`). + // Signing overrides only; DET agrees to no document action fee. action_fee_agreement: None, }) } else { @@ -1126,8 +1190,133 @@ impl AppContext { } } + /// The generation of the connection to the network: bumped each time the + /// connection is rebuilt. Production reads take it together with the SDK + /// check, via [`Self::connection_snapshot_for`]. + #[cfg(test)] + pub(crate) fn connection_generation(&self) -> u64 { + self.epoch_adoption + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .generation + } + + /// Adopt the fee multiplier and protocol version of the proved epoch + /// `epoch_index`, read on connection `generation`. Returns whether it was + /// adopted. + /// + /// Dropped when the connection was rebuilt since the read was sent, or + /// when an epoch newer than it was adopted already: epoch reads run + /// concurrently (platform info, fee refusals) and can finish out of + /// order. The protocol version only moves up. The lock covers the checks + /// and both stores, so an adoption and a rebuild cannot interleave; + /// nothing is awaited under it. + pub(crate) fn adopt_epoch( + &self, + generation: u64, + epoch_index: EpochIndex, + fee_multiplier_permille: u64, + protocol_version: u32, + ) -> bool { + let mut adoption = self + .epoch_adoption + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if generation != adoption.generation + || adoption + .epoch_index + .is_some_and(|adopted| epoch_index < adopted) + { + return false; + } + adoption.epoch_index = Some(epoch_index); + self.set_fee_multiplier_permille(fee_multiplier_permille); + self.raise_platform_protocol_version(protocol_version); + true + } + + /// Raise the protocol version to `protocol_version`, proved on connection + /// `generation`; dropped if the connection was rebuilt since. Returns + /// whether it was applied. + pub(crate) fn raise_platform_protocol_version_on( + &self, + generation: u64, + protocol_version: u32, + ) -> bool { + let adoption = self + .epoch_adoption + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if generation != adoption.generation { + return false; + } + self.raise_platform_protocol_version(protocol_version); + true + } + + /// Replace the connection's SDK with `new_sdk` and forget everything + /// learned from the old connection: bump the generation, so reads still in + /// flight on it are dropped, forget the adopted epoch, and put the + /// protocol version back to unknown and the fee multiplier to the default. + /// The new endpoints may serve another protocol version or a reset devnet; + /// capabilities stay closed until the new connection proves its epoch. + /// + /// The reset is written before the new SDK is published, under the + /// adoption lock: whoever loads the new SDK sees the reset state, and a + /// [`Self::connection_snapshot_for`] sees either the old generation and + /// SDK or the new ones. A task still holding the old SDK may see the reset + /// state, which closes capabilities and uses default fees: fail closed. + pub(crate) fn replace_connection(&self, new_sdk: Arc) { + self.replace_connection_observed(new_sdk, |_| {}); + } + + /// [`Self::replace_connection`]; `observe` runs where the two halves meet (test seam). + fn replace_connection_observed(&self, new_sdk: Arc, observe: impl Fn(&Self)) { + let mut adoption = self + .epoch_adoption + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + adoption.generation += 1; + adoption.epoch_index = None; + self.set_fee_multiplier_permille(PlatformFeeEstimator::DEFAULT_FEE_MULTIPLIER_PERMILLE); + self.set_platform_protocol_version(0); + observe(self); + self.sdk.store(new_sdk); + } + + /// The connection generation and whether `sdk` is the current SDK (not + /// one a rebuild replaced, nor a copy), read together under the adoption + /// lock. An epoch read takes this before it is sent and passes the + /// generation to [`Self::adopt_epoch`]. + pub(crate) fn connection_snapshot_for(&self, sdk: &Sdk) -> (u64, bool) { + let adoption = self + .epoch_adoption + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let current = std::ptr::eq(sdk, Arc::as_ptr(&self.sdk.load_full())); + (adoption.generation, current) + } + + /// Rebuild the connection ([`Self::reinit_core_client_and_sdk`]) and read + /// the new connection's epoch, so its protocol version and fee multiplier + /// are known again. A failed epoch read only leaves them unknown until + /// the next one; it does not fail the rebuild. + pub(crate) async fn rebuild_connection(self: &Arc) -> Result<(), TaskError> { + Arc::clone(self).reinit_core_client_and_sdk()?; + let sdk = self.sdk.load_full(); + if let Err(error) = self.refresh_current_epoch(&sdk).await { + tracing::warn!( + %error, + "Could not read the current epoch after rebuilding the connection; \ + protocol features stay closed until the next read" + ); + } + Ok(()) + } + /// Rebuild both the Dash RPC `core_client` and the `Sdk` using the - /// updated `NetworkConfig` from `self.config`. + /// updated `NetworkConfig` from `self.config`, and forget the state + /// learned from the old connection ([`Self::replace_connection`]). pub fn reinit_core_client_and_sdk(self: Arc) -> Result<(), TaskError> { // 1. Grab a fresh snapshot of your NetworkConfig let cfg = { @@ -1160,6 +1349,9 @@ impl AppContext { }; let provider = self.spv_context_provider.read()?.clone(); + // Like a cold start: the network minimum, raised by the new + // connection's first proven response. Nothing learned from the old + // endpoints is carried over. let new_sdk = initialize_sdk(address_list, self.network, provider) .map_err(|e| TaskError::SdkInitializationFailed { detail: e })?; @@ -1168,7 +1360,7 @@ impl AppContext { let mut client_lock = self.core_client.write()?; *client_lock = new_client; } - self.sdk.store(Arc::new(new_sdk)); + self.replace_connection(Arc::new(new_sdk)); // Rebind the provider to hold the new AppContext reference. // bind_app_context also registers the provider with the SDK. @@ -1770,34 +1962,10 @@ impl AppContext { /// Returns the DashPay contract identifier. pub fn dashpay_contract_id(&self) -> Identifier { - self.dashpay_contract.id() + self.dashpay_contract().id() } } -/// The platform version DET uses locally: validation, building and signing -/// state transitions, loading system contracts, and the SDK's initial -/// protocol-version seed. -/// -/// Protocol 13 is what mainnet and testnet run. The pinned upstream crates -/// already know protocol 14 (`PlatformVersion::latest()`), but DET must not -/// build or validate protocol-14 structures until it supports them, so every -/// local use goes through this constant instead of `latest()`. -// TODO(pv14): raise to PLATFORM_V14 together with protocol-14 support. -pub(crate) const DET_PLATFORM_VERSION: &PlatformVersion = &PLATFORM_V13; - -/// Returns the default platform version for the given network. -// Seeded (`with_initial_version`), not pinned, so `Sdk`'s protocol-version -// ratchet stays active and follows the network upward. -// TODO(platform#4231): the fix is merged upstream and included in the current -// platform pin; restore the live `ExtendedEpochInfo::fetch_current` in -// `PlatformInfoTaskRequestType::CurrentEpochInfo` and reconsider this seeding. -// TODO(pv14): devnet should seed at PV14 per upstream rs-sdk `min_protocol_version` -// (devnets run the development line); DET seeds every network at -// `DET_PLATFORM_VERSION` until it supports protocol 14. -pub(crate) const fn default_platform_version(_network: &Network) -> &'static PlatformVersion { - DET_PLATFORM_VERSION -} - #[cfg(test)] mod tests { use super::*; @@ -1835,33 +2003,124 @@ mod tests { } } + /// Before any proven response, a context builds, validates and signs at + /// the lowest protocol version its network still runs (upstream's + /// per-network minimum): 13 on mainnet, not an older table. #[test] - fn platform_version_is_v13_for_every_network() { + fn platform_version_starts_at_the_network_minimum() { + use crate::context::test_support::test_app_context_for_network; + use dash_sdk::sdk::min_protocol_version; + + assert_eq!(min_protocol_version(Network::Mainnet), 13); for network in [ Network::Mainnet, Network::Testnet, Network::Devnet, Network::Regtest, ] { + let tmp = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context_for_network(tmp.path(), network); assert_eq!( - default_platform_version(&network).protocol_version, - PLATFORM_V13.protocol_version + ctx.platform_version().protocol_version, + min_protocol_version(network), + "{network:?}" ); } } - /// Contest timing is read from this pin on the voting side and from the live - /// SDK version on the identity side. The SDK only ratchets upward from the - /// pin, so both sides describe one contest as long as every newer version - /// this build knows keeps the pinned windows. + /// Once the SDK runs at protocol 14, the context builds with 14 and serves + /// the system contracts protocol 14 defines — not the ones loaded at boot. + #[test] + fn platform_version_and_system_contracts_follow_the_sdk() { + use crate::context::test_support::test_app_context_for_network; + use dash_sdk::dpp::system_data_contracts::{SystemDataContract, load_system_data_contract}; + + let tmp = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context_for_network(tmp.path(), Network::Mainnet); + let v14 = PlatformVersion::get(14).expect("protocol 14"); + let boot_dashpay = ctx.dashpay_contract(); + assert_ne!( + *boot_dashpay, + load_system_data_contract(SystemDataContract::Dashpay, v14).unwrap(), + "precondition: protocol 14 redefines the DashPay contract" + ); + + crate::context::test_support::set_sdk_protocol_version(&ctx, 14); + + assert_eq!(ctx.platform_version().protocol_version, 14); + for (served, contract) in [ + (ctx.dashpay_contract(), SystemDataContract::Dashpay), + (ctx.withdraws_contract(), SystemDataContract::Withdrawals), + ( + ctx.token_history_contract(), + SystemDataContract::TokenHistory, + ), + (ctx.dpns_contract(), SystemDataContract::DPNS), + ( + ctx.keyword_search_contract(), + SystemDataContract::KeywordSearch, + ), + ] { + assert_eq!(*served, load_system_data_contract(contract, v14).unwrap()); + } + assert_eq!(ctx.dashpay_contract().id(), boot_dashpay.id()); + } + + /// A task that loads the new SDK always sees the reset connection state: + /// the new SDK never pairs with the old connection's protocol version or + /// fee multiplier. #[test] - fn contest_durations_match_the_pin_for_every_version_the_sdk_can_report() { + fn the_new_sdk_is_published_only_after_the_connection_state_is_reset() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = crate::context::test_support::test_app_context(temp_dir.path()); + ctx.adopt_epoch(ctx.connection_generation(), 8, 1_500, 14); + let new_sdk = Arc::new(dash_sdk::Sdk::new_mock()); + let new_sdk_ptr = Arc::as_ptr(&new_sdk); + + ctx.replace_connection_observed(new_sdk, |ctx| { + if Arc::as_ptr(&ctx.sdk.load_full()) == new_sdk_ptr { + assert_eq!(ctx.platform_protocol_version(), 0, "new SDK, old protocol"); + assert_eq!( + ctx.fee_multiplier_permille(), + crate::model::fee_estimation::PlatformFeeEstimator::DEFAULT_FEE_MULTIPLIER_PERMILLE, + "new SDK, old fee multiplier" + ); + } + }); + + assert_eq!(Arc::as_ptr(&ctx.sdk.load_full()), new_sdk_ptr); + assert_eq!(ctx.platform_protocol_version(), 0); + } + + /// Until the network's version is fetched, the connected version is the + /// one the SDK builds with. + #[test] + fn connected_platform_version_falls_back_to_the_sdk_version() { + use crate::context::test_support::test_app_context_for_network; + + let tmp = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context_for_network(tmp.path(), Network::Mainnet); + assert_eq!(ctx.platform_protocol_version(), 0); + assert_eq!( + ctx.connected_platform_version().protocol_version, + ctx.sdk.load().protocol_version_number() + ); + } + + /// Contest windows stay consistent as the SDK ratchets upward from each + /// network minimum to a newer supported protocol. + #[test] + fn contest_durations_match_the_network_minimum_for_every_supported_version() { use crate::model::dpns::contest_durations; let newest = PlatformVersion::latest().protocol_version; for network in [Network::Mainnet, Network::Testnet] { - let pinned = contest_durations(network, default_platform_version(&network)); - for protocol_version in DET_PLATFORM_VERSION.protocol_version..=newest { + let minimum = dash_sdk::sdk::min_protocol_version(network); + let pinned = contest_durations( + network, + PlatformVersion::get(minimum).expect("network minimum"), + ); + for protocol_version in minimum..=newest { let live = PlatformVersion::get(protocol_version).expect("known version"); assert_eq!( contest_durations(network, live), diff --git a/src/context/system_contracts.rs b/src/context/system_contracts.rs new file mode 100644 index 000000000..7a6db8a37 --- /dev/null +++ b/src/context/system_contracts.rs @@ -0,0 +1,156 @@ +//! The system data contracts, as defined at the protocol version in use. + +use std::sync::Arc; +use std::sync::atomic::{AtomicU32, Ordering}; + +use arc_swap::ArcSwap; +use dash_sdk::dpp::ProtocolError; +use dash_sdk::dpp::system_data_contracts::{SystemDataContract, load_system_data_contract}; +use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::platform::DataContract; + +/// The system contracts DET uses, all loaded at one protocol version. +#[derive(Debug)] +pub(crate) struct SystemContracts { + protocol_version: u32, + pub(crate) dpns: Arc, + pub(crate) withdrawals: Arc, + pub(crate) dashpay: Arc, + pub(crate) token_history: Arc, + pub(crate) keyword_search: Arc, +} + +impl SystemContracts { + /// Load every system contract as `platform_version` defines it. + pub(crate) fn load(platform_version: &PlatformVersion) -> Result> { + let load = |contract| { + load_system_data_contract(contract, platform_version) + .map(Arc::new) + .map_err(Box::new) + }; + Ok(Self { + protocol_version: platform_version.protocol_version, + dpns: load(SystemDataContract::DPNS)?, + withdrawals: load(SystemDataContract::Withdrawals)?, + dashpay: load(SystemDataContract::Dashpay)?, + token_history: load(SystemDataContract::TokenHistory)?, + keyword_search: load(SystemDataContract::KeywordSearch)?, + }) + } +} + +/// [`SystemContracts`] that follow the protocol version in use. +/// +/// Lock-free: the set lives in an [`ArcSwap`]. Two callers reloading at once +/// store equivalent sets; a set stored late for an older version is replaced +/// on the next access, so the cache converges on the current version. +#[derive(Debug)] +pub(crate) struct SystemContractsCache { + current: ArcSwap, + /// The last version whose reload failed, so the failure is logged once. + failed_version: AtomicU32, +} + +impl SystemContractsCache { + pub(crate) fn new(contracts: SystemContracts) -> Self { + Self { + current: ArcSwap::from_pointee(contracts), + failed_version: AtomicU32::new(0), + } + } + + /// The set for exactly `platform_version`, reloaded if the cached one was + /// built for another version. A failed reload is an error, never the set + /// of another version: what verifies a proof must not mix schemas. + pub(crate) fn try_get( + &self, + platform_version: &PlatformVersion, + ) -> Result, Box> { + let cached = self.current.load_full(); + if cached.protocol_version == platform_version.protocol_version { + return Ok(cached); + } + let fresh = Arc::new(SystemContracts::load(platform_version)?); + self.current.store(Arc::clone(&fresh)); + Ok(fresh) + } + + /// The set for `platform_version`, for the app's own reads (names, + /// documents, forms). A failed reload keeps the cached set, so the app + /// keeps working on its last loaded contracts; proof verification uses + /// [`Self::try_get`] instead. + pub(crate) fn get(&self, platform_version: &PlatformVersion) -> Arc { + match self.try_get(platform_version) { + Ok(contracts) => contracts, + Err(error) => { + let cached = self.current.load_full(); + let version = platform_version.protocol_version; + if self.failed_version.swap(version, Ordering::Relaxed) != version { + tracing::error!( + protocol_version = version, + kept_protocol_version = cached.protocol_version, + ?error, + "Could not load the system contracts for the network's protocol version; keeping the loaded ones" + ); + } + cached + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; + + fn version(protocol_version: u32) -> &'static PlatformVersion { + PlatformVersion::get(protocol_version).expect("known protocol version") + } + + /// Protocol 14 redefines some system contracts, so a set loaded at 13 + /// must not be served once the network runs 14. + #[test] + fn a_new_protocol_version_reloads_the_contracts() { + let v13 = version(13); + let v14 = version(14); + let dashpay_v13 = load_system_data_contract(SystemDataContract::Dashpay, v13).unwrap(); + let dashpay_v14 = load_system_data_contract(SystemDataContract::Dashpay, v14).unwrap(); + assert_ne!( + dashpay_v13, dashpay_v14, + "precondition: protocol 14 redefines the DashPay contract" + ); + + let cache = SystemContractsCache::new(SystemContracts::load(v13).unwrap()); + assert_eq!(*cache.get(v13).dashpay, dashpay_v13); + let reloaded = cache.get(v14); + assert_eq!(*reloaded.dashpay, dashpay_v14); + assert_eq!(reloaded.dpns.id(), cache.get(v13).dpns.id()); + assert_eq!( + *cache.get(v14).withdrawals, + load_system_data_contract(SystemDataContract::Withdrawals, v14).unwrap() + ); + } + + /// A version whose contracts cannot be loaded is an error for the exact + /// lookup; the app's lookup keeps the set it has. + #[test] + fn an_unloadable_version_fails_the_exact_lookup() { + let v13 = version(13); + let cache = SystemContractsCache::new(SystemContracts::load(v13).unwrap()); + let mut unloadable = v13.clone(); + unloadable.protocol_version = u32::MAX; + + assert!(cache.try_get(&unloadable).is_err()); + assert_eq!(cache.get(&unloadable).protocol_version, 13); + assert!(Arc::ptr_eq(&cache.get(v13), &cache.try_get(v13).unwrap())); + } + + /// The same version serves the cached set itself, not a reload. + #[test] + fn the_same_protocol_version_keeps_the_cached_set() { + let v13 = version(13); + let cache = SystemContractsCache::new(SystemContracts::load(v13).unwrap()); + assert!(Arc::ptr_eq(&cache.get(v13), &cache.get(v13))); + } +} diff --git a/src/context_provider.rs b/src/context_provider.rs index 6e83a488b..8ba42fa9c 100644 --- a/src/context_provider.rs +++ b/src/context_provider.rs @@ -21,25 +21,41 @@ pub(crate) const MASTERNODE_LIST_NOT_READY_DETAIL: &str = /// Resolve a data contract by ID: check cached system contracts first, then DB. /// +/// System contracts are served as `platform_version` defines them: the proof +/// verifier passes the version it verifies with, which during an upgrade can +/// be newer than the SDK's stored version. +/// /// All system contracts are listed in `cached` — adding a new one is a single /// array edit. The array size is tied to [`SYSTEM_CONTRACT_COUNT`] so the /// compiler enforces completeness. fn resolve_data_contract( app_ctx: &AppContext, data_contract_id: &Identifier, + platform_version: &PlatformVersion, ) -> Result>, ContextProviderError> { + // Never another version's contracts: the verifier would check the proof + // against the wrong schema. + let system_contracts = app_ctx + .system_contracts_at(platform_version) + .map_err(|error| { + ContextProviderError::DataContractFailure(format!( + "system contracts for protocol version {} could not be loaded: {error}", + platform_version.protocol_version + )) + })?; let cached: [&Arc; SYSTEM_CONTRACT_COUNT] = [ - &app_ctx.dpns_contract, - &app_ctx.dashpay_contract, - &app_ctx.token_history_contract, - &app_ctx.withdraws_contract, - &app_ctx.keyword_search_contract, + &system_contracts.dpns, + &system_contracts.dashpay, + &system_contracts.token_history, + &system_contracts.withdrawals, + &system_contracts.keyword_search, ]; - for contract in &cached { - if data_contract_id == &contract.id() { - return Ok(Some(Arc::clone(contract))); - } + if let Some(contract) = cached + .into_iter() + .find(|contract| data_contract_id == &contract.id()) + { + return Ok(Some(Arc::clone(contract))); } // K/V fallback for user-added / non-system contracts @@ -107,10 +123,10 @@ impl ContextProvider for SpvProvider { fn get_data_contract( &self, data_contract_id: &Identifier, - _platform_version: &PlatformVersion, + platform_version: &PlatformVersion, ) -> Result>, ContextProviderError> { let app_ctx = self.app_context()?; - resolve_data_contract(&app_ctx, data_contract_id) + resolve_data_contract(&app_ctx, data_contract_id, platform_version) } fn get_token_configuration( @@ -171,7 +187,7 @@ impl ContextProvider for SpvProvider { core_chain_locked_height, )) }) - .map_err(|e| ContextProviderError::Generic(e.to_string())) + .map_err(quorum_lookup_error) } fn get_platform_activation_height( @@ -187,3 +203,96 @@ impl ContextProvider for SpvProvider { }) } } + +fn quorum_lookup_error(error: crate::backend_task::error::TaskError) -> ContextProviderError { + match error { + crate::backend_task::error::TaskError::WalletBackend { source } => { + source.quorum_lookup_context_error() + } + other => ContextProviderError::Generic(other.to_string()), + } +} + +#[cfg(test)] +mod quorum_error_tests { + use super::*; + use crate::backend_task::error::TaskError; + use dash_sdk::dpp::dashcore::sml::llmq_entry_verification::{ + LLMQEntryVerificationSkipStatus, LLMQEntryVerificationStatus, + }; + + #[test] + fn unverified_local_quorum_does_not_blame_the_dapi_endpoint() { + let error = TaskError::WalletBackend { + source: Arc::new( + platform_wallet::error::PlatformWalletError::QuorumNotVerified { + quorum_type: 6, + quorum_hash: [0xab; 32], + status: LLMQEntryVerificationStatus::Skipped( + LLMQEntryVerificationSkipStatus::NotMarkedForVerification, + ), + }, + ), + }; + assert!(matches!( + quorum_lookup_error(error), + ContextProviderError::QuorumSourceUnavailable(_) + )); + } + + #[test] + fn unrelated_quorum_lookup_errors_are_not_reclassified_as_readiness() { + assert!(matches!( + quorum_lookup_error(TaskError::NoIdentitiesFound), + ContextProviderError::Generic(_) + )); + } +} + +#[cfg(test)] +mod data_contract_tests { + use super::*; + use dash_sdk::dpp::system_data_contracts::{SystemDataContract, load_system_data_contract}; + + /// The proof verifier asks for a contract at the version it verifies + /// with, which during an upgrade can be newer than the SDK's stored + /// version. A system contract is served as that version defines it. + #[test] + fn system_contracts_resolve_at_the_requested_platform_version() { + let dir = tempfile::tempdir().expect("tempdir"); + let ctx = crate::context::test_support::test_app_context(dir.path()); + for protocol_version in [13, 14] { + let platform_version = PlatformVersion::get(protocol_version).expect("known version"); + let expected = + load_system_data_contract(SystemDataContract::Dashpay, platform_version).unwrap(); + let resolved = resolve_data_contract(&ctx, &expected.id(), platform_version) + .expect("lookup") + .expect("a cached system contract"); + assert_eq!(*resolved, expected, "protocol {protocol_version}"); + } + } + + /// A version whose system contracts cannot be loaded is refused, not + /// answered with the set loaded for another version. + #[test] + fn system_contracts_that_cannot_load_at_the_requested_version_are_refused() { + let dir = tempfile::tempdir().expect("tempdir"); + let ctx = crate::context::test_support::test_app_context(dir.path()); + let dpns_id = SystemDataContract::DPNS.id(); + // Load a set first, so a fallback would have something to serve. + let latest = PlatformVersion::latest(); + assert!(matches!( + resolve_data_contract(&ctx, &dpns_id, latest), + Ok(Some(_)) + )); + + let mut unloadable = latest.clone(); + unloadable.protocol_version = u32::MAX; + let resolved = resolve_data_contract(&ctx, &dpns_id, &unloadable); + + assert!( + matches!(resolved, Err(ContextProviderError::DataContractFailure(_))), + "got {resolved:?}" + ); + } +} diff --git a/src/mcp/resolve.rs b/src/mcp/resolve.rs index 23c6abd12..9dfef54aa 100644 --- a/src/mcp/resolve.rs +++ b/src/mcp/resolve.rs @@ -448,12 +448,12 @@ mod tests { use dash_sdk::platform::DataContract; /// Mocks the proved DPNS-contract fetch that drives the protocol-version - /// ratchet — the only network read the epoch task still performs while - /// dashpay/platform#4231 is unresolved. The mock reports `LATEST_VERSION` in + /// ratchet — the epoch task's fallback when the current epoch cannot be + /// read (the mock has no epoch response). The mock reports `LATEST_VERSION` in /// the response metadata, so a successful fetch ratchets the SDK exactly as a /// live network would; a failed one leaves the version unconfirmed. async fn mock_sdk_with_dpns_contract(ctx: &Arc) -> Sdk { - let contract = DataContract::clone(&ctx.dpns_contract); + let contract = DataContract::clone(&ctx.dpns_contract()); let mut sdk = Sdk::new_mock(); sdk.mock() .expect_fetch(contract.id(), Some(contract)) diff --git a/src/sdk_wrapper.rs b/src/sdk_wrapper.rs index 1f6f53879..82b0a6ea8 100644 --- a/src/sdk_wrapper.rs +++ b/src/sdk_wrapper.rs @@ -1,4 +1,3 @@ -use crate::context::default_platform_version; use dash_sdk::dapi_client::AddressList; use dash_sdk::dpp::dashcore::Network; use dash_sdk::platform::ContextProvider; @@ -17,16 +16,20 @@ fn request_settings() -> RequestSettings { } } +/// Build an SDK for `network` that tracks the network's protocol version. +/// +/// The SDK is never pinned: it starts at upstream's per-network minimum (the +/// lowest version the network still runs) and ratchets upward to whatever the +/// network reports in proven responses. A rebuilt connection starts there +/// too: nothing learned from the endpoints it replaces is carried over. pub fn initialize_sdk( address_list: AddressList, network: Network, context_provider: P, ) -> Result { let request_settings = request_settings(); - let platform_version = default_platform_version(&network); let sdk = SdkBuilder::new(address_list) - .with_initial_version(platform_version) .with_network(network) .with_context_provider(context_provider) .with_settings(request_settings) @@ -35,7 +38,7 @@ pub fn initialize_sdk( info!( ?network, - protocol_version = platform_version.protocol_version, + protocol_version = sdk.protocol_version_number(), "SDK initialized successfully" ); diff --git a/src/ui/contracts_documents/contracts_documents_screen.rs b/src/ui/contracts_documents/contracts_documents_screen.rs index 5550c5a30..04f07943d 100644 --- a/src/ui/contracts_documents/contracts_documents_screen.rs +++ b/src/ui/contracts_documents/contracts_documents_screen.rs @@ -120,7 +120,7 @@ pub enum DocumentDisplayMode { impl DocumentQueryScreen { pub fn new(app_context: &Arc) -> Self { let dpns_contract = QualifiedContract { - contract: Arc::clone(&app_context.dpns_contract).as_ref().clone(), + contract: Arc::clone(&app_context.dpns_contract()).as_ref().clone(), alias: Some("dpns".to_string()), }; @@ -614,7 +614,9 @@ impl ScreenLike for DocumentQueryScreen { // Reset the selected contract and document type let dpns_contract = QualifiedContract { - contract: Arc::clone(&self.app_context.dpns_contract).as_ref().clone(), + contract: Arc::clone(&self.app_context.dpns_contract()) + .as_ref() + .clone(), alias: Some("dpns".to_string()), }; self.selected_data_contract = dpns_contract.clone(); diff --git a/src/ui/identity/add_new_identity_screen/mod.rs b/src/ui/identity/add_new_identity_screen/mod.rs index 4d2e6cecb..2b6c6f194 100644 --- a/src/ui/identity/add_new_identity_screen/mod.rs +++ b/src/ui/identity/add_new_identity_screen/mod.rs @@ -221,7 +221,7 @@ impl AddNewIdentityScreen { /// Default number of keys (master + additional) the chooser warms and reads /// from the auth-pubkey cache. fn default_key_count(&self) -> u32 { - let dashpay_contract_id = self.app_context.dashpay_contract.id(); + let dashpay_contract_id = self.app_context.dashpay_contract().id(); // master (index 0) + the default additional keys. default_identity_key_specs(dashpay_contract_id).len() as u32 + 1 } @@ -255,7 +255,7 @@ impl AddNewIdentityScreen { let network = self.app_context.network; let identity_index = self.identity_id_number; - let dashpay_contract_id = self.app_context.dashpay_contract.id(); + let dashpay_contract_id = self.app_context.dashpay_contract().id(); let default_keys = default_identity_key_specs(dashpay_contract_id); let Ok(backend) = self.app_context.wallet_backend() else { diff --git a/src/ui/identity/mod.rs b/src/ui/identity/mod.rs index c31274ebb..c0c2dbd4f 100644 --- a/src/ui/identity/mod.rs +++ b/src/ui/identity/mod.rs @@ -204,7 +204,7 @@ pub fn get_selected_wallet( ) -> Result>>, String> { // If `app_context` is provided, use the DPNS-based approach. let public_key = if let Some(context) = app_context { - let dpns_contract = &context.dpns_contract; + let dpns_contract = &context.dpns_contract(); // Attempt to fetch the `preorder` document type from the DPNS contract. let preorder_document_type = dpns_contract diff --git a/src/ui/identity/register_dpns_name_screen.rs b/src/ui/identity/register_dpns_name_screen.rs index fe2015201..437f21b60 100644 --- a/src/ui/identity/register_dpns_name_screen.rs +++ b/src/ui/identity/register_dpns_name_screen.rs @@ -197,7 +197,7 @@ impl RegisterDpnsNameScreen { let Some(identity) = &self.selected_qualified_identity else { return; }; - let required = dpns_signing_requirement(&self.app_context.dpns_contract); + let required = dpns_signing_requirement(&self.app_context.dpns_contract()); let wallets = self.app_context.wallet_context(); let arrived = missing_registration_wallets(identity, required) .any(|seed_hash| wallets.contains_hd(seed_hash)); @@ -590,7 +590,7 @@ impl RegisterDpnsNameScreen { .show(ui, |ui| { let document_type = self .app_context - .dpns_contract + .dpns_contract() .document_type_cloned_for_name("domain") .ok(); if self.selected_key.is_none() { @@ -913,7 +913,7 @@ impl ScreenLike for RegisterDpnsNameScreen { // and Keys screen; this page only says what helps. let key_status = registration_key_status( identity, - dpns_signing_requirement(&self.app_context.dpns_contract), + dpns_signing_requirement(&self.app_context.dpns_contract()), ); let updated_install = key_status.key_is_unusable_here() && self.app_context.has_legacy_identities(); diff --git a/src/ui/identity/usernames_card.rs b/src/ui/identity/usernames_card.rs index 9c3c2d5d6..5e163557d 100644 --- a/src/ui/identity/usernames_card.rs +++ b/src/ui/identity/usernames_card.rs @@ -403,7 +403,7 @@ impl UsernamesCard { app_context: &Arc, identity: &QualifiedIdentity, ) -> Option { - let required = dpns_signing_requirement(&app_context.dpns_contract); + let required = dpns_signing_requirement(&app_context.dpns_contract()); let status = registration_key_status(identity, required); let identity_id = identity.identity.id(); let key_missing = status.key_is_unusable_here(); diff --git a/src/ui/tokens/tokens_screen/my_tokens.rs b/src/ui/tokens/tokens_screen/my_tokens.rs index 7cf834325..b67b54872 100644 --- a/src/ui/tokens/tokens_screen/my_tokens.rs +++ b/src/ui/tokens/tokens_screen/my_tokens.rs @@ -1116,7 +1116,7 @@ mod tests { let mut screen = TokensScreen::new(&context, TokensSubscreen::MyTokens); let token_id = Identifier::from([42; 32]); let config = TokenConfigurationV0::default_most_restrictive().into(); - let contract = context.dpns_contract.as_ref().clone(); + let contract = context.dpns_contract().as_ref().clone(); screen.all_known_tokens.insert( token_id, TokenInfoWithDataContract { From 173a135ab4662c09b1422f6e0ff4a0dea3644de2 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:21:52 +0000 Subject: [PATCH 05/58] test(platform): guard the protocol 13 tables and transitions Compare the protocol 13 client tables with a recorded snapshot, so an upstream change to them is reviewed, and check that keys and documents DET builds on a protocol 13 network use encodings protocol 13 decodes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- src/backend_task/protocol_13_transitions.rs | 236 ++++++ .../fixtures/platform_v13_client_tables.txt | 798 ++++++++++++++++++ src/context/platform_version_guard.rs | 59 ++ 3 files changed, 1093 insertions(+) create mode 100644 src/backend_task/protocol_13_transitions.rs create mode 100644 src/context/fixtures/platform_v13_client_tables.txt create mode 100644 src/context/platform_version_guard.rs diff --git a/src/backend_task/protocol_13_transitions.rs b/src/backend_task/protocol_13_transitions.rs new file mode 100644 index 000000000..5811df3f0 --- /dev/null +++ b/src/backend_task/protocol_13_transitions.rs @@ -0,0 +1,236 @@ +//! State transitions built the way DET builds them on a protocol 13 network +//! (mainnet and testnet today): only encodings protocol 13 decodes. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use async_trait::async_trait; +use dash_sdk::dpp::ProtocolError; +use dash_sdk::dpp::address_funds::AddressWitness; +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; +use dash_sdk::dpp::data_contract::config::DataContractConfig; +use dash_sdk::dpp::data_contract::document_type::DocumentType; +use dash_sdk::dpp::document::{Document, DocumentV0}; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; +use dash_sdk::dpp::identity::signer::Signer; +use dash_sdk::dpp::identity::{Identity, IdentityPublicKey, KeyType, Purpose, SecurityLevel}; +use dash_sdk::dpp::platform_value::{BinaryData, Identifier, platform_value}; +use dash_sdk::dpp::prelude::UserFeeIncrease; +use dash_sdk::dpp::state_transition::StateTransition; +use dash_sdk::dpp::state_transition::batch_transition::BatchTransition; +use dash_sdk::dpp::state_transition::batch_transition::batched_transition::BatchedTransition; +use dash_sdk::dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransitionV0Methods; +use dash_sdk::dpp::state_transition::batch_transition::document_base_transition::DocumentBaseTransition; +use dash_sdk::dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; +use dash_sdk::dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; +use dash_sdk::dpp::state_transition::identity_update_transition::IdentityUpdateTransition; +use dash_sdk::dpp::state_transition::identity_update_transition::methods::IdentityUpdateTransitionMethodsV0; +use dash_sdk::dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; +use dash_sdk::dpp::version::PlatformVersion; + +use crate::context::AppContext; +use crate::context::test_support::test_app_context_for_network; + +/// Signs everything with placeholder bytes: these tests check what is +/// encoded, not signature validity. +#[derive(Debug)] +struct PlaceholderSigner; + +#[async_trait] +impl Signer for PlaceholderSigner { + async fn sign( + &self, + _key: &IdentityPublicKey, + _data: &[u8], + ) -> Result { + Ok(BinaryData::new(vec![0; 65])) + } + + async fn sign_create_witness( + &self, + _key: &IdentityPublicKey, + _data: &[u8], + ) -> Result { + Err(ProtocolError::Generic( + "no address witnesses here".to_string(), + )) + } + + fn can_sign_with(&self, _key: &IdentityPublicKey) -> bool { + true + } +} + +/// A mainnet context, which builds at protocol 13 until the network says +/// otherwise. +fn mainnet_context() -> (tempfile::TempDir, Arc, &'static PlatformVersion) { + let tmp = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context_for_network(tmp.path(), Network::Mainnet); + let platform_version = ctx.platform_version(); + assert_eq!(platform_version.protocol_version, 13); + (tmp, ctx, platform_version) +} + +fn plain_key(id: u32, security_level: SecurityLevel) -> IdentityPublicKey { + IdentityPublicKey::from(IdentityPublicKeyV0 { + id, + purpose: Purpose::AUTHENTICATION, + security_level, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: vec![2; 33].into(), + disabled_at: None, + }) +} + +/// A key without limits, added on a protocol 13 network, goes out as the +/// version 0 key encoding protocol 13 decodes. +#[tokio::test] +async fn a_plain_key_is_added_in_the_protocol_13_encoding() { + let (_tmp, _ctx, platform_version) = mainnet_context(); + let mut identity = Identity::create_basic_identity(Identifier::from([4; 32]), platform_version) + .expect("identity"); + identity.add_public_key(plain_key(0, SecurityLevel::MASTER)); + + let transition = IdentityUpdateTransition::try_from_identity_with_signer( + &identity, + &0, + vec![plain_key(1, SecurityLevel::HIGH)], + vec![], + 1, + UserFeeIncrease::default(), + &PlaceholderSigner, + platform_version, + None, + ) + .await + .expect("identity update"); + + let StateTransition::IdentityUpdate(IdentityUpdateTransition::V0(update)) = transition else { + panic!("expected a version 0 identity update, got {transition:?}"); + }; + assert_eq!(update.add_public_keys.len(), 1); + assert!(matches!( + update.add_public_keys[0], + IdentityPublicKeyInCreation::V0(_) + )); +} + +fn note_contract(platform_version: &PlatformVersion) -> dash_sdk::platform::DataContract { + use dash_sdk::dpp::data_contract::v1::DataContractV1; + + let id = Identifier::from([8; 32]); + let config = DataContractConfig::default_for_version(platform_version).expect("config"); + let schema = platform_value!({ + "type": "object", + "properties": {"name": {"type": "string", "position": 0, "maxLength": 63_u32}}, + "required": ["name"], + "additionalProperties": false, + }); + let note = DocumentType::try_from_schema( + id, + 1, + config.version(), + "note", + schema, + None, + &BTreeMap::new(), + &config, + true, + &mut Vec::new(), + platform_version, + ) + .expect("document type"); + dash_sdk::platform::DataContract::V1(DataContractV1 { + id, + version: 1, + owner_id: Identifier::from([7; 32]), + document_types: BTreeMap::from([("note".to_string(), note)]), + config, + schema_defs: None, + groups: BTreeMap::new(), + tokens: BTreeMap::new(), + keywords: vec![], + created_at: None, + updated_at: None, + created_at_block_height: None, + updated_at_block_height: None, + created_at_epoch: None, + updated_at_epoch: None, + description: None, + }) +} + +fn note(owner_id: Identifier) -> Document { + Document::V0(DocumentV0 { + moderated_at: None, + moderated_by: None, + id: Identifier::from([9; 32]), + owner_id, + creator_id: None, + properties: BTreeMap::from([("name".to_string(), "hello".into())]), + revision: Some(1), + created_at: None, + updated_at: None, + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + contract_version: None, + }) +} + +async fn create_note( + platform_version: &PlatformVersion, + options: Option, +) -> Result> { + let contract = note_contract(platform_version); + let document_type = contract.document_type_for_name("note").expect("note type"); + let owner_id = Identifier::from([4; 32]); + BatchTransition::new_document_creation_transition_from_document( + note(owner_id), + document_type, + [3; 32], + &plain_key(1, SecurityLevel::HIGH), + 1, + UserFeeIncrease::default(), + None, + &PlaceholderSigner, + platform_version, + options, + ) + .await + .map_err(Box::new) +} + +/// A document created on a protocol 13 network uses a document base that +/// protocol 13 decodes: not the protocol 14 one that carries a fee agreement. +#[tokio::test] +async fn a_document_is_created_in_the_protocol_13_encoding() { + let (_tmp, _ctx, platform_version) = mainnet_context(); + + let transition = create_note(platform_version, None) + .await + .expect("document create"); + + let StateTransition::Batch(batch) = transition else { + panic!("expected a batch, got {transition:?}"); + }; + let base = match &batch { + BatchTransition::V0(v0) => v0.transitions[0].base().clone(), + BatchTransition::V1(v1) => match &v1.transitions[0] { + BatchedTransition::Document(document) => document.base().clone(), + other => panic!("expected a document transition, got {other:?}"), + }, + }; + assert!( + !matches!(base, DocumentBaseTransition::V2(_)), + "protocol 13 cannot decode {base:?}" + ); +} diff --git a/src/context/fixtures/platform_v13_client_tables.txt b/src/context/fixtures/platform_v13_client_tables.txt new file mode 100644 index 000000000..219368253 --- /dev/null +++ b/src/context/fixtures/platform_v13_client_tables.txt @@ -0,0 +1,798 @@ +protocol_version: 13 +dpp: DPPVersion { + costs: DPPCostsVersions { + signature_verify: 0, + }, + validation: DPPValidationVersions { + json_schema_validator: JsonSchemaValidatorVersions { + new: 0, + validate: 0, + compile: 0, + compile_and_validate: 0, + }, + data_contract: DataContractValidationVersions { + validate: 0, + validate_config_update: 1, + validate_token_config_update: 0, + validate_index_definitions: 0, + validate_index_naming_duplicates: 0, + validate_not_defined_properties: 0, + validate_property_definition: 0, + validate_token_config_groups_exist: 1, + validate_localizations: 0, + validate_once_per_identity_distribution: None, + }, + document_type: DocumentTypeValidationVersions { + validate_update: 0, + unique_index_limit: 10, + contested_index_limit: 1, + }, + voting: VotingValidationVersions { + allow_other_contenders_time_mainnet_ms: 604800000, + allow_other_contenders_time_testing_ms: 2700000, + votes_allowed_per_masternode: 5, + }, + validation_result: ValidationResultMethodVersions { + flatten: 1, + merge_many: 1, + }, + }, + state_transition_serialization_versions: DPPStateTransitionSerializationVersions { + identity_public_key_in_creation: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + identity_create_from_addresses_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + identity_create_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + identity_update_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + identity_key_limits_update_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + identity_top_up_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + identity_top_up_from_addresses_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + identity_credit_withdrawal_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + identity_credit_transfer_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + identity_credit_transfer_to_addresses_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + masternode_vote_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + contract_create_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + contract_update_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + contract_user_moderation_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + contract_fee_claim_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + batch_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 1, + default_current_version: 1, + }, + document_base_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 1, + }, + document_create_state_transition: DocumentFeatureVersionBounds { + bounds: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + }, + document_replace_state_transition: DocumentFeatureVersionBounds { + bounds: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + }, + document_delete_state_transition: DocumentFeatureVersionBounds { + bounds: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + }, + document_index_only_delete_state_transition: None, + document_transfer_state_transition: DocumentFeatureVersionBounds { + bounds: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + }, + document_update_price_state_transition: DocumentFeatureVersionBounds { + bounds: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + }, + document_purchase_state_transition: DocumentFeatureVersionBounds { + bounds: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + }, + address_funds_transfer_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + address_funding_from_asset_lock_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + address_credit_withdrawal_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + shield_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + shielded_transfer_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + unshield_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + shield_from_asset_lock_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + shielded_withdrawal_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + identity_create_from_shielded_pool_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + shield_from_identity_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + identity_top_up_from_shielded_pool_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + token_shielded_transfer_with_shielded_fee_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + token_unshield_with_shielded_fee_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + token_purchase_from_shielded_pool_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + }, + state_transition_conversion_versions: DPPStateTransitionConversionVersions { + identity_to_identity_create_transition: 0, + identity_to_identity_top_up_transition: 0, + identity_to_identity_transfer_transition: 0, + identity_to_identity_withdrawal_transition: 1, + identity_to_identity_create_transition_with_signer: 0, + inputs_to_identity_create_from_addresses_transition_with_signer: 0, + address_funds_to_address_funds_transfer_transition: 0, + identity_to_identity_top_up_from_addresses_transition: 0, + address_funding_from_asset_lock_transition: 0, + }, + state_transition_method_versions: DPPStateTransitionMethodVersions { + public_key_in_creation_methods: PublicKeyInCreationMethodVersions { + from_public_key_signed_with_private_key: 0, + from_public_key_signed_external: 0, + hash: 0, + duplicated_key_ids_witness: 0, + duplicated_keys_witness: 0, + validate_identity_public_keys_structure: 0, + }, + verify_identity_signed_signature: 0, + }, + state_transitions: DPPStateTransitionVersions { + documents: DocumentTransitionVersions { + documents_batch_transition: DocumentsBatchTransitionVersions { + validation: DocumentsBatchTransitionValidationVersions { + find_duplicates_by_id: 0, + validate_base_structure: 0, + }, + }, + }, + identities: IdentityTransitionVersions { + max_public_keys_in_creation: 6, + asset_locks: IdentityTransitionAssetLockVersions { + required_asset_lock_duff_balance_for_processing_start_for_identity_create: 200000, + required_asset_lock_duff_balance_for_processing_start_for_identity_top_up: 50000, + required_asset_lock_duff_balance_for_processing_start_for_address_funding: 50000, + validate_asset_lock_transaction_structure: 0, + validate_instant_asset_lock_proof_structure: 0, + max_asset_lock_transaction_inputs: 100, + }, + credit_withdrawal: IdentityCreditWithdrawalTransitionVersions { + default_constructor: 1, + }, + calculate_min_required_fee_on_identity_create_transition: 1, + calculate_min_required_fee_on_identity_top_up_transition: 1, + }, + contract: ContractTransitionVersions { + contract_create_transition_default_version: 0, + contract_update_transition_default_version: 0, + }, + address_funds: AddressFundsTransitionVersions { + address_funds_transition_default_version: 0, + credit_withdrawal: 0, + validate_credit_withdrawal_structure: 0, + min_output_amount: 500000, + min_input_amount: 100000, + min_identity_funding_amount: 200000, + }, + shielded: ShieldedTransitionVersions { + validate_withdrawal_structure: 0, + }, + max_address_inputs: 16, + max_address_outputs: 128, + max_address_fee_strategies: 4, + }, + contract_versions: DPPContractVersions { + max_serialized_size: 65000, + contract_serialization_version: FeatureVersionBounds { + min_version: 0, + max_version: 1, + default_current_version: 1, + }, + contract_structure_version: 1, + created_data_contract_structure: 0, + config: FeatureVersionBounds { + min_version: 1, + max_version: 1, + default_current_version: 1, + }, + methods: DataContractMethodVersions { + validate_document: 0, + validate_update: 0, + schema: 0, + validate_groups: 0, + validate_moderation_config: 0, + equal_ignoring_time_fields: 0, + registration_cost: 1, + }, + document_type_versions: DocumentTypeVersions { + index_versions: DocumentTypeIndexVersions { + index_levels_from_indices: 0, + }, + class_method_versions: DocumentTypeClassMethodVersions { + try_from_schema: 2, + create_document_types_from_document_schemas: 1, + }, + structure_version: 0, + schema: DocumentTypeSchemaVersions { + document_type_schema: 2, + should_add_creator_id: 1, + enrich_with_base_schema: 1, + find_identifier_and_binary_paths: 0, + apply_property_reference: None, + apply_required_since: None, + apply_distinct_from: None, + apply_encrypted_for: None, + apply_max_bytes: None, + parse_typed_array: None, + parse_property_constraints: None, + apply_generated_from: None, + expand_property_type_shorthands: None, + validate_max_depth: 0, + max_depth: 256, + recursive_schema_validator_versions: RecursiveSchemaValidatorVersions { + traversal_validator: 0, + }, + validate_schema_compatibility: 0, + }, + methods: DocumentTypeMethodVersions { + create_document_from_data: 0, + create_document_with_prevalidated_properties: 0, + prefunded_voting_balance_for_document: 0, + contested_vote_poll_for_document: 0, + estimated_size: 0, + index_for_types: 0, + max_size: 0, + serialize_value_for_key: 0, + deserialize_value_for_key: 0, + validate_distinct_from: None, + validate_encrypted_property_shapes: None, + validate_max_bytes: None, + validate_property_constraints: None, + canonical_contested_index_values: None, + fill_generated_properties: None, + validate_generated_from: None, + }, + }, + token_versions: TokenVersions { + validate_structure_interval: 0, + validate_pre_programmed_distribution_amounts: 0, + token_configuration_format: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + }, + }, + document_versions: DPPDocumentVersions { + document_structure_version: 0, + document_serialization_version: FeatureVersionBounds { + min_version: 0, + max_version: 2, + default_current_version: 2, + }, + document_cbor_serialization_version: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + extended_document_structure_version: 0, + extended_document_serialization_version: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + document_method_versions: DocumentMethodVersions { + is_equal_ignoring_timestamps: 0, + hash: 0, + get_raw_for_contract: 0, + get_raw_for_document_type: 0, + try_into_asset_unlock_base_transaction_info: 0, + generate_document_id: 0, + get_raw_array_elements_for_document_type: None, + }, + }, + identity_versions: DPPIdentityVersions { + identity_structure_version: 0, + identity_key_structure_version: 0, + identity_key_type_method_versions: IdentityKeyTypeMethodVersions { + random_public_key_data: 0, + random_public_and_private_key_data: 0, + }, + }, + voting_versions: DPPVotingVersions { + default_vote_poll_time_duration_mainnet_ms: 1209600000, + default_vote_poll_time_duration_test_network_ms: 5400000, + contested_document_vote_poll_stored_info_version: 0, + }, + token_versions: DPPTokenVersions { + identity_token_info_default_structure_version: 0, + identity_token_status_default_structure_version: 0, + token_contract_info_default_structure_version: 0, + token_config_update_action_id_version: 1, + token_set_price_action_id_version: 1, + distribution_function_evaluate_version: 0, + reward_distribution_max_cycle_moment_version: 0, + distribution_function_cycle_epochs_version: 0, + }, + asset_lock_versions: DPPAssetLockVersions { + reduced_asset_lock_value: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + }, + methods: DPPMethodVersions { + epoch_core_reward_credits_for_distribution: 0, + daily_withdrawal_limit: 1, + deduct_fee_from_outputs_or_remaining_balance_of_inputs: 0, + compute_minimum_shielded_fee: 0, + shielded_extra_sighash_data: 0, + core_credit_pool_unlock_limit: None, + credit_pool_bundle_binding: None, + }, + factory_versions: DPPFactoryVersions { + data_contract_factory_structure_version: 0, + document_factory_structure_version: 0, + }, +} +drive.methods.verify: DriveVerifyMethodVersions { + contract: DriveVerifyContractMethodVersions { + verify_contract: 0, + verify_contract_history: 0, + verify_contract_return_serialization: 0, + verify_contracts_by_range: 0, + verify_contracts_versions: 0, + }, + document: DriveVerifyDocumentMethodVersions { + verify_proof: 0, + verify_proof_keep_serialized: 0, + verify_document_history: 0, + verify_start_at_document_in_proof: 0, + }, + chained_document: DriveVerifyChainedDocumentMethodVersions { + verify_chained_documents_proof: 0, + }, + composite_document: DriveVerifyCompositeDocumentMethodVersions { + verify_composite_documents_proof: 0, + }, + document_count: DriveVerifyDocumentCountMethodVersions { + verify_aggregate_count_proof: 0, + verify_carrier_aggregate_count_proof: 0, + verify_distinct_count_proof: 0, + verify_point_lookup_count_proof: 0, + verify_primary_key_count_tree_proof: 0, + }, + document_sum: DriveVerifyDocumentSumMethodVersions { + verify_aggregate_sum_proof: 0, + verify_carrier_aggregate_sum_proof: 0, + verify_carrier_aggregate_count_and_sum_proof: 0, + verify_aggregate_count_and_sum_proof: 0, + verify_primary_key_sum_tree_proof: 0, + verify_primary_key_count_sum_tree_proof: 0, + verify_point_lookup_sum_proof: 0, + verify_distinct_sum_proof: 0, + verify_distinct_count_and_sum_proof: 0, + verify_point_lookup_count_and_sum_proof: 0, + }, + document_ranked: DriveVerifyDocumentRankedMethodVersions { + verify_ranked_top_k_proof: 0, + verify_having_range_proof: 0, + }, + identity: DriveVerifyIdentityMethodVersions { + verify_full_identities_by_public_key_hashes: 0, + verify_full_identity_by_identity_id: 0, + verify_full_identity_by_public_key_hash: 0, + verify_identity_balance_for_identity_id: 0, + verify_identity_balances_for_identity_ids: 0, + verify_identity_balance_revision_and_addresses_from_inputs: 0, + verify_identity_id_by_unique_public_key_hash: 0, + verify_identity_ids_by_unique_public_key_hashes: 0, + verify_identity_keys_by_identity_id: 0, + verify_identity_nonce: 0, + verify_identity_contract_nonce: 0, + verify_identities_contract_keys: 0, + verify_identity_revision_for_identity_id: 0, + verify_full_identity_by_non_unique_public_key_hash: 0, + verify_full_identity_by_non_unique_public_key_hash_two_proofs: 0, + verify_identity_id_by_non_unique_public_key_hash: 0, + verify_identity_keys_remaining_budgets: 0, + }, + group: DriveVerifyGroupMethodVersions { + verify_group_info: 0, + verify_group_infos_in_contract: 0, + verify_action_infos: 0, + verify_action_signers: 0, + verify_action_signers_total_power: 0, + }, + contract_group: DriveVerifyContractGroupMethodVersions { + verify_contract_group_info: 0, + verify_contract_group_members: 0, + verify_contract_group_memberships_for_contract: 0, + }, + contract_moderation: DriveVerifyContractModerationMethodVersions { + verify_contract_moderation_status: 0, + verify_contract_moderation_entries: 0, + verify_contract_fee_pots: 0, + verify_contract_document_removals: 0, + verify_contract_team_actions: 0, + verify_contract_team_action_signers: 0, + verify_contract_team_action_signature: 0, + verify_contract_moderation_action_counts: 0, + }, + token: DriveVerifyTokenMethodVersions { + verify_token_balances_for_identity_ids: 0, + verify_token_balances_for_identity_id: 0, + verify_token_infos_for_identity_ids: 0, + verify_token_infos_for_identity_id: 0, + verify_token_statuses: 0, + verify_token_total_supply_and_aggregated_identity_balance: 0, + verify_token_balance_for_identity_id: 0, + verify_token_info_for_identity_id: 0, + verify_token_status: 0, + verify_token_pre_programmed_distributions: 0, + verify_token_direct_selling_price: 0, + verify_token_direct_selling_prices: 0, + verify_token_perpetual_distribution_last_paid_time: 0, + verify_token_contract_info: 0, + }, + single_document: DriveVerifySingleDocumentMethodVersions { + verify_proof: 0, + verify_proof_keep_serialized: 0, + }, + system: DriveVerifySystemMethodVersions { + verify_epoch_infos: 0, + verify_epoch_proposers: 0, + verify_elements: 0, + verify_total_credits_in_system: 0, + verify_upgrade_state: 0, + verify_upgrade_vote_status: 0, + verify_finalized_epoch_infos: 0, + }, + voting: DriveVerifyVoteMethodVersions { + verify_masternode_vote: 0, + verify_vote_poll_votes_proof: 0, + verify_identity_votes_given_proof: 0, + verify_vote_poll_vote_state_proof: 0, + verify_contests_proof: 0, + verify_vote_polls_by_end_date_proof: 0, + verify_specialized_balance: 0, + }, + address_funds: DriveVerifyAddressFundsMethodVersions { + verify_address_info: 0, + verify_addresses_infos: 0, + verify_address_funds_trunk_query: 0, + verify_address_funds_branch_query: 0, + verify_recent_address_balance_changes: 0, + verify_compacted_address_balance_changes: 1, + }, + state_transition: DriveVerifyStateTransitionMethodVersions { + verify_state_transition_was_executed_with_proof: 0, + }, + shielded: DriveVerifyShieldedMethodVersions { + verify_shielded_pool_state: 0, + verify_shielded_anchors: 0, + verify_most_recent_shielded_anchor: 0, + verify_shielded_encrypted_notes: 0, + verify_shielded_notes_count: 0, + verify_shielded_nullifiers: 0, + }, +} +fee_version: FeeVersion { + fee_version_number: 1, + uses_version_fee_multiplier_permille: Some( + 1000, + ), + storage: FeeStorageVersion { + storage_disk_usage_credit_per_byte: 27000, + storage_processing_credit_per_byte: 400, + storage_load_credit_per_byte: 20, + non_storage_load_credit_per_byte: 10, + storage_seek_cost: 2000, + ttl_ephemeral_disk_usage_credit_per_byte: 270, + }, + signature: FeeSignatureVersion { + verify_signature_ecdsa_secp256k1: 15000, + verify_signature_bls12_381: 300000, + verify_signature_ecdsa_hash160: 15500, + verify_signature_bip13_script_hash: 300000, + verify_signature_eddsa25519_hash160: 3500, + }, + hashing: FeeHashingVersion { + blake3_base: 100, + blake3_per_block: 300, + sha256_per_block: 5000, + sha256_ripe_md160_base: 6000, + single_sha256_base: 100, + ripemd160_per_block: 5000, + sinsemilla_base: 40000, + }, + processing: FeeProcessingVersion { + fetch_identity_balance_processing_cost: 10000, + fetch_identity_revision_processing_cost: 9000, + fetch_identity_balance_and_revision_processing_cost: 15000, + fetch_identity_cost_per_look_up_key_by_id: 9000, + fetch_identity_token_balance_processing_cost: 10000, + fetch_prefunded_specialized_balance_processing_cost: 10000, + fetch_key_with_type_nonce_and_balance_cost: 12000, + fetch_single_identity_key_processing_cost: 10000, + validate_key_structure: 50, + perform_network_threshold_signing: 100000000, + }, + data_contract_validation: FeeDataContractValidationVersion { + document_type_base_fee: 500, + document_type_schema_size_fee: 10, + document_type_per_property_fee: 40, + document_type_base_non_unique_index_fee: 50, + document_type_non_unique_index_per_property_fee: 30, + document_type_base_unique_index_fee: 100, + document_type_unique_index_per_property_fee: 60, + }, + data_contract_registration: FeeDataContractRegistrationVersion { + base_contract_registration_fee: 10000000000, + document_type_registration_fee: 2000000000, + document_type_base_non_unique_index_registration_fee: 1000000000, + document_type_base_unique_index_registration_fee: 1000000000, + document_type_base_contested_index_registration_fee: 100000000000, + token_registration_fee: 10000000000, + token_uses_perpetual_distribution_fee: 10000000000, + token_uses_pre_programmed_distribution_fee: 10000000000, + token_uses_once_per_identity_distribution_fee: 0, + search_keyword_fee: 10000000000, + }, + state_transition_min_fees: StateTransitionMinFees { + credit_transfer: 100000, + credit_transfer_to_addresses: 500000, + credit_withdrawal: 400000000, + identity_update: 100000, + document_batch_sub_transition: 100000, + contract_create: 100000, + contract_update: 100000, + masternode_vote: 100000, + address_credit_withdrawal: 400000000, + address_funds_transfer_input_cost: 500000, + address_funds_transfer_output_cost: 6000000, + identity_create_base_cost: 2000000, + identity_topup_base_cost: 500000, + identity_key_in_creation_cost: 6500000, + }, + vote_resolution_fund_fees: VoteResolutionFundFees { + contested_document_vote_resolution_fund_required_amount: 20000000000, + contested_document_vote_resolution_unlock_fund_required_amount: 400000000000, + contested_document_single_vote_cost: 10000000, + moderation_vote_resolution_fund_required_amount: 20000000000, + contested_document_contenders_before_fund_doubling: 0, + contested_document_contenders_per_fund_doubling: 0, + }, + document_ttl: FeeDocumentTtlVersion { + tiers: [ + DocumentTtlFeeTier { + max_ttl_seconds: 3600, + credit_per_byte: 1, + }, + DocumentTtlFeeTier { + max_ttl_seconds: 86400, + credit_per_byte: 4, + }, + DocumentTtlFeeTier { + max_ttl_seconds: 172800, + credit_per_byte: 8, + }, + DocumentTtlFeeTier { + max_ttl_seconds: 345600, + credit_per_byte: 15, + }, + DocumentTtlFeeTier { + max_ttl_seconds: 604800, + credit_per_byte: 26, + }, + ], + credit_per_byte_per_period: 34, + pricing_period_seconds: 788400, + cleanup_base_processing_cost: 1200000, + cleanup_processing_cost_per_index_level: 400000, + cleanup_processing_cost_per_document_byte: 420, + }, + document_reference: FeeDocumentReferenceVersion { + choose_first_credits_per_candidate: 100000, + where_list_credits_per_element: 100, + }, +} +system_data_contracts: SystemDataContractVersions { + withdrawals: 1, + dpns: 2, + dashpay: 1, + masternode_reward_shares: 1, + wallet: 1, + token_history: 1, + keyword_search: 1, + document_history: 1, + app_connect: 0, + moderation_charters: 0, +} +system_limits: SystemLimits { + credit_pool_nullifier_backfill_page_size: 0, + credit_pool_nullifier_backfill_batch_size: 0, + estimated_contract_max_serialized_size: 16384, + max_field_value_size: 5120, + max_document_value_depth: Some( + 256, + ), + max_typed_array_items: 1024, + max_references_per_document: 256, + max_reference_operands: 4, + max_reference_expression_depth: 4, + max_property_constraints: 16, + max_property_constraint_nodes: 32, + max_property_constraint_aggregates: 4, + max_state_transition_size: 20480, + max_transitions_in_documents_batch: 1, + withdrawal_transactions_per_block_limit: 4, + retry_signing_expired_withdrawal_documents_per_block_limit: 1, + max_withdrawal_amount: 50000000000000, + daily_withdrawal_limit_percent: None, + core_credit_pool_unlock_limit_percent: None, + core_credit_pool_unlock_limit_floor: None, + core_credit_pool_window_blocks: None, + regtest_core_credit_pool_window_blocks: None, + min_withdrawal_amount: 1000000, + core_dust_relay_fee_per_kb: None, + max_core_fee_per_byte: None, + max_group_member_count: 256, + max_contract_group_memberships_per_contract: 16, + max_contract_group_admins: 16, + max_contract_group_name_length: 64, + max_contract_group_description_length: 256, + max_contract_moderators: 16, + max_contract_suspension_until: 9007199254740991, + max_contract_moderation_reason_length: 1024, + max_contract_warnings_per_identity: 16, + max_contract_moderation_reason_documents: 16, + min_mainnet_contract_moderation_election_window_seconds: 86400, + max_contract_moderation_election_window_seconds: 2419200, + min_contract_moderation_challenge_cool_down_seconds: 1209600, + max_contract_moderation_challenge_cool_down_seconds: 94608000, + contract_document_restore_window_ms: 604800000, + max_contract_moderation_added_moderators: 15, + max_moderation_charter_elected_members: 15, + max_contenders_per_contest: 1000, + max_token_redemption_cycles: 128, + max_evonode_reward_claim_epochs: 100, + max_shielded_transition_actions: 16, + max_token_pool_notes_for_outgoing: 250, + max_time_range_overlap_factor: None, + max_time_range_ttl_seconds: None, + min_time_range_ttl_drop_operations_per_write: None, + min_document_ttl_seconds: None, + max_document_ttl_seconds: None, + max_document_expirations_per_block: 0, + max_document_expiration_weight_per_block: 0, + max_contested_summed_value_magnitude: None, + max_expiring_signed_summed_value_magnitude: None, + max_indexed_array_items: None, + max_windowed_indexed_array_items: None, + max_index_entries_per_document: None, + max_choose_first_candidates: 1000, + max_rekey_batch_moves: None, +} diff --git a/src/context/platform_version_guard.rs b/src/context/platform_version_guard.rs new file mode 100644 index 000000000..d93d8af00 --- /dev/null +++ b/src/context/platform_version_guard.rs @@ -0,0 +1,59 @@ +//! Guards the protocol version 13 tables DET builds, validates and verifies +//! with on mainnet and testnet against drift across platform pin bumps. +//! +//! Protocol 13 is live, so table changes require compatibility review. The snapshot +//! covers what the client side reads: serialization and validation (`dpp`), +//! proof verification (`drive.methods.verify`), fees, system contract versions +//! and system limits. On a mismatch, diff the fixture, confirm the change is +//! additive (new fields at 0/None) or otherwise safe for a protocol 13 network, +//! then regenerate with `DET_BLESS_PLATFORM_VERSION_SNAPSHOT=1`. +//! +//! Platform #5294 enforces GroveDB proof envelope V1 outside these tables, even +//! for protocol 13. The snapshot does not establish wire compatibility: #5361 +//! removed the legacy discovery response field 2, still served by sampled +//! mainnet nodes, and #5515 brings it back as the answer to a version 0 lookup +//! request, checked by `verify_full_identity_by_non_unique_public_key_hash_two_proofs`. +//! Only a live check against those nodes shows the SDK accepts their proofs. + +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::sdk::min_protocol_version; + +const SNAPSHOT_PATH: &str = "src/context/fixtures/platform_v13_client_tables.txt"; +const SNAPSHOT: &str = include_str!("fixtures/platform_v13_client_tables.txt"); + +fn client_tables(platform_version: &PlatformVersion) -> String { + format!( + "protocol_version: {}\ndpp: {:#?}\ndrive.methods.verify: {:#?}\nfee_version: {:#?}\nsystem_data_contracts: {:#?}\nsystem_limits: {:#?}\n", + platform_version.protocol_version, + platform_version.dpp, + platform_version.drive.methods.verify, + platform_version.fee_version, + platform_version.system_data_contracts, + platform_version.system_limits, + ) +} + +#[test] +fn protocol_13_client_tables_match_the_snapshot() { + let actual = client_tables(PlatformVersion::get(13).expect("protocol 13")); + if std::env::var_os("DET_BLESS_PLATFORM_VERSION_SNAPSHOT").is_some() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(SNAPSHOT_PATH); + std::fs::write(&path, &actual).expect("write snapshot"); + return; + } + assert!( + actual == SNAPSHOT, + "The protocol 13 tables changed with the platform pin. Diff {SNAPSHOT_PATH} (rerun with \ + DET_BLESS_PLATFORM_VERSION_SNAPSHOT=1 to rewrite it), check the change is safe for \ + mainnet, and commit the new snapshot." + ); +} + +/// Mainnet and testnet run protocol 13, and DET seeds its SDK from upstream's +/// per-network minimum: the snapshot above is the table DET starts with there. +#[test] +fn mainnet_and_testnet_start_at_protocol_13() { + assert_eq!(min_protocol_version(Network::Mainnet), 13); + assert_eq!(min_protocol_version(Network::Testnet), 13); +} From 6a4e6e75d43cde8a35438285322e5900f2dc67c1 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:21:52 +0000 Subject: [PATCH 06/58] fix(errors): explain protocol 14 refusals that existing actions can hit Map the refusals the new protocol adds to flows DET already offers to dedicated TaskError variants: a full username contest, a vote choice the contest does not allow, a name someone else asked for first, a token that cannot be moved, a changed immutable document field, a reference to a document type that cannot be deleted, and a moderators' fee on a contract without moderation. Names supplied by the node stay in the typed source only, never in the message. Token operations that fail inside the wallet runtime keep their SDK cause, so they are classified like any SDK error; an unclassified one names the operation that did not complete. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- src/backend_task/error.rs | 590 +++++++++++++++++++++++++++++++++++++- 1 file changed, 586 insertions(+), 4 deletions(-) diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index fb6cb61a6..42006aefc 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -43,6 +43,108 @@ impl DapiAddressAvailability { } } +/// The token operation a wallet-runtime token failure belongs to. +/// +/// Upstream `PlatformWalletError::TokenOperationFailed` names the operation with +/// a fixed `&'static str` label (a discriminator, not a message); this is its +/// typed form, so each failure gets a complete, translatable sentence. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TokenOperationKind { + Claim, + Mint, + Burn, + Transfer, + Freeze, + Unfreeze, + DestroyFrozenFunds, + Pause, + Resume, + SetPrice, + Purchase, + ConfigUpdate, + /// A label this build does not know (a newer upstream operation). The + /// raw label is kept for `Debug` and logs, never shown to the user. + Unrecognized { + label: &'static str, + }, +} + +impl TokenOperationKind { + /// The kind for the operation label upstream puts in + /// `PlatformWalletError::TokenOperationFailed::operation`. + /// + /// Upstream exposes the operation only as this `&'static str` (no typed + /// enum), so every label it uses is matched here; an unknown one is + /// logged at warn and kept as [`Self::Unrecognized`], never silently + /// folded into another operation. + pub fn from_upstream_label(label: &'static str) -> Self { + match label { + "claim" => Self::Claim, + "mint" => Self::Mint, + "burn" => Self::Burn, + "transfer" => Self::Transfer, + "freeze" => Self::Freeze, + "unfreeze" => Self::Unfreeze, + "destroy frozen funds" => Self::DestroyFrozenFunds, + "pause" => Self::Pause, + "resume" => Self::Resume, + "set price" => Self::SetPrice, + "purchase" => Self::Purchase, + "config update" => Self::ConfigUpdate, + label => { + tracing::warn!( + label, + "Unrecognized upstream token operation label; showing a generic message" + ); + Self::Unrecognized { label } + } + } + } + + /// The user-facing sentence for a failure of this operation. + fn failure_message(self) -> &'static str { + match self { + Self::Claim => "The token claim did not complete. Check your connection and try again.", + Self::Mint => { + "Minting the tokens did not complete. Check your connection and try again." + } + Self::Burn => { + "Burning the tokens did not complete. Check your connection and try again." + } + Self::Transfer => { + "The token transfer did not complete. Check your connection and try again." + } + Self::Freeze => { + "Freezing the token account did not complete. Check your connection and try again." + } + Self::Unfreeze => { + "Unfreezing the token account did not complete. Check your connection and try again." + } + Self::DestroyFrozenFunds => { + "Destroying the frozen tokens did not complete. Check your connection and try again." + } + Self::Pause => { + "Pausing the token did not complete. Check your connection and try again." + } + Self::Resume => { + "Resuming the token did not complete. Check your connection and try again." + } + Self::SetPrice => { + "Setting the token price did not complete. Check your connection and try again." + } + Self::Purchase => { + "The token purchase did not complete. Check your connection and try again." + } + Self::ConfigUpdate => { + "Updating the token settings did not complete. Check your connection and try again." + } + Self::Unrecognized { .. } => { + "The token operation did not complete. Check your connection and try again." + } + } + } +} + /// Why an existing DashPay `contactInfo` payload could not be preserved. #[derive(Debug, Error)] pub enum ContactInfoReadError { @@ -2396,13 +2498,113 @@ pub enum TaskError { source_error: Box, }, + // ────────────────────────────────────────────────────────────────────────── + // Username, vote and token transfer refusals (protocol versions 14 and 15) + // ────────────────────────────────────────────────────────────────────────── + /// The contest for the requested name already holds the most contenders it + /// accepts. + #[error( + "Too many people have already asked for this username, so no one else can join. Choose a different username." + )] + DocumentContestContendersLimitReached { + max_contenders: u16, + #[source] + source_error: Box, + }, + + /// The token cannot be moved to another identity, so a transfer, or a + /// payment that moves it, is refused. + #[error( + "Token {} cannot be moved to another identity, so this action is not possible. Use a different token.", + token_id.to_string(Encoding::Base58) + )] + TokenNotTransferable { + token_id: Identifier, + #[source] + source_error: Box, + }, + + /// The vote choice is not allowed for this contest: a lock on a name that + /// cannot be locked, or a vote towards an identity that is not competing. + #[error( + "This vote is not allowed for this username. Vote for one of the identities that asked for it, or choose another option, then vote again." + )] + VoteChoiceNotAllowed { + #[source] + source_error: Box, + }, + + /// Another identity committed to the same name earlier, so its request + /// comes first. + #[error("Someone else asked for this username first. Choose a different username.")] + ReferencedDocumentPreceded { + document_id: Identifier, + preceding_document_id: Identifier, + #[source] + source_error: Box, + }, + + // ────────────────────────────────────────────────────────────────────────── + // Contract authoring (protocol version 14) + // ────────────────────────────────────────────────────────────────────────── + /// A replace changed a property the document type declares immutable. + #[error( + "One of the fields you changed cannot be changed after the document is created. Keep the original values of its fixed fields, then try again." + )] + DocumentImmutablePropertyChanged { + // The node's names for the document type and the property stay in + // `source_error` only: unproven text, never copied into the variant. + document_id: Identifier, + #[source] + source_error: Box, + }, + + /// A `deletableDocument` reference points at a document type whose + /// documents cannot be deleted. + #[error( + "This contract refers to documents that can never be deleted as if they could be. Make that reference a permanent document reference, then try again." + )] + ReferencedDocumentTypeNotDeletable { + // The node's names for the document type and the reference path stay + // in `source_error` only: unproven text, never copied into the variant. + contract_id: Identifier, + #[source] + source_error: Box, + }, + + /// A document type charges moderators a fee the contract has no + /// moderation for. + #[error( + "A document type in this contract charges a fee for moderators, but the contract has no moderation. Remove the moderators' part of the fee, then try again." + )] + DocumentActionFeesWithoutModeration { + // The node's name for the document type stays in `source_error` only: + // unproven text, never copied into the variant. + #[source] + source_error: Box, + }, + // ────────────────────────────────────────────────────────────────────────── // Token query errors // ────────────────────────────────────────────────────────────────────────── + #[error("Shielded token settings cannot be changed here yet. Use a supported token setting instead.")] + TokenShieldedConfigurationUnsupported, + /// Querying token data from the platform failed. #[error("Could not retrieve token information from the platform. Please retry.")] TokenQueryError { detail: String }, + /// A token operation run by the wallet runtime failed for a reason the + /// SDK error classification has no specific variant for. Built by + /// [`TaskError::from_token_operation_failure`], which prefers the specific + /// variant whenever there is one. + #[error("{}", operation.failure_message())] + TokenOperationFailed { + operation: TokenOperationKind, + #[source] + source_error: Box, + }, + /// The token does not have a perpetual distribution configured — no rewards to claim. #[error("This token does not have perpetual distribution, so there are no rewards to claim.")] TokenNoPerpetualDistribution, @@ -3828,6 +4030,26 @@ impl From for TaskError { } } +impl TaskError { + /// The error for a token operation the wallet runtime reported as failed + /// (`PlatformWalletError::TokenOperationFailed`). + /// + /// The SDK source is classified exactly like any other SDK error, so a + /// rejection with a specific meaning (insufficient balance, a claim already + /// taken, …) keeps its specific message. Only an otherwise unclassified + /// failure becomes [`TaskError::TokenOperationFailed`], which at least names + /// the operation. + pub fn from_token_operation_failure(operation: &'static str, source: SdkError) -> Self { + match TaskError::from(source) { + TaskError::SdkError { source_error } => TaskError::TokenOperationFailed { + operation: TokenOperationKind::from_upstream_label(operation), + source_error, + }, + classified => classified, + } + } +} + /// Why the DET identity signer could not resolve a signing key. /// /// The upstream `Signer` trait can only return `ProtocolError`, which has no @@ -4138,6 +4360,69 @@ impl From for TaskError { source_error, })) } + ConsensusError::StateError( + StateError::DocumentContestMaximumContendersReachedError(e), + ) => { + let max_contenders = e.max_contenders(); + Some(Box::new(move |source_error| { + TaskError::DocumentContestContendersLimitReached { + max_contenders, + source_error, + } + })) + } + ConsensusError::StateError(StateError::TokenNotTransferableError(e)) => { + let token_id = *e.token_id(); + Some(Box::new(move |source_error| { + TaskError::TokenNotTransferable { + token_id, + source_error, + } + })) + } + ConsensusError::StateError( + StateError::VoteChoiceNotAllowedForVotePollError(_), + ) => Some(Box::new(|source_error| TaskError::VoteChoiceNotAllowed { + source_error, + })), + ConsensusError::StateError(StateError::ReferencedDocumentPrecededError(e)) => { + let (document_id, preceding_document_id) = + (*e.document_id(), *e.preceding_document_id()); + Some(Box::new(move |source_error| { + TaskError::ReferencedDocumentPreceded { + document_id, + preceding_document_id, + source_error, + } + })) + } + ConsensusError::StateError( + StateError::DocumentImmutablePropertyChangedError(e), + ) => { + let document_id = e.document_id(); + Some(Box::new(move |source_error| { + TaskError::DocumentImmutablePropertyChanged { + document_id, + source_error, + } + })) + } + ConsensusError::StateError( + StateError::ReferencedDocumentTypeNotDeletableError(e), + ) => { + let contract_id = *e.contract_id(); + Some(Box::new(move |source_error| { + TaskError::ReferencedDocumentTypeNotDeletable { + contract_id, + source_error, + } + })) + } + ConsensusError::BasicError( + BasicError::DocumentActionFeesWithoutModerationError(_), + ) => Some(Box::new(|source_error| { + TaskError::DocumentActionFeesWithoutModeration { source_error } + })), _ => None, } }); @@ -4301,10 +4586,12 @@ mod tests { InvalidTokenNameCharacterError, InvalidTokenNameLengthError, }; use dash_sdk::dpp::consensus::basic::identity::InvalidInstantAssetLockProofSignatureError; - use dash_sdk::dpp::consensus::state::identity::duplicated_identity_public_key_id_state_error::DuplicatedIdentityPublicKeyIdStateError; - use dash_sdk::dpp::consensus::state::identity::duplicated_identity_public_key_state_error::DuplicatedIdentityPublicKeyStateError; - use dash_sdk::dpp::consensus::state::identity::IdentityInsufficientBalanceError; - use dash_sdk::dpp::consensus::state::identity::identity_public_key_already_exists_for_unique_contract_bounds_error::IdentityPublicKeyAlreadyExistsForUniqueContractBoundsError; + use dash_sdk::dpp::consensus::state::identity::{ + IdentityInsufficientBalanceError, + duplicated_identity_public_key_id_state_error::DuplicatedIdentityPublicKeyIdStateError, + duplicated_identity_public_key_state_error::DuplicatedIdentityPublicKeyStateError, + identity_public_key_already_exists_for_unique_contract_bounds_error::IdentityPublicKeyAlreadyExistsForUniqueContractBoundsError, + }; use dash_sdk::dpp::consensus::state::voting::masternode_voted_too_many_times::MasternodeVotedTooManyTimesError; use dash_sdk::dpp::identity::Purpose; use dash_sdk::platform::Identifier; @@ -6547,6 +6834,301 @@ mod tests { ); } + fn broadcast_rejection(cause: ConsensusError) -> SdkError { + SdkError::StateTransitionBroadcastError(dash_sdk::error::StateTransitionBroadcastError { + code: 1, + message: "rejected".to_string(), + cause: Some(cause), + }) + } + + /// The protocol-version-14 refusals of contract and document authoring map + /// to their own variants, keep the SDK error as their source, and read as + /// complete sentences free of protocol jargon. + #[test] + fn protocol_v14_consensus_errors_map_to_their_variants() { + use dash_sdk::dpp::consensus::basic::contract_moderation::DocumentActionFeesWithoutModerationError; + use dash_sdk::dpp::consensus::state::document::{ + document_immutable_property_changed_error::DocumentImmutablePropertyChangedError, + referenced_document_type_not_deletable_error::ReferencedDocumentTypeNotDeletableError, + }; + + let contract_id = Identifier::random(); + type Expectation = fn(&TaskError) -> bool; + let cases: Vec<(ConsensusError, Expectation)> = vec![ + ( + DocumentImmutablePropertyChangedError::new( + Identifier::random(), + "note".to_string(), + "title".to_string(), + ) + .into(), + |e| matches!(e, TaskError::DocumentImmutablePropertyChanged { .. }), + ), + ( + ReferencedDocumentTypeNotDeletableError::new( + contract_id, + "note".to_string(), + "properties.ref".to_string(), + ) + .into(), + |e| matches!(e, TaskError::ReferencedDocumentTypeNotDeletable { .. }), + ), + ( + DocumentActionFeesWithoutModerationError::new("note".to_string()).into(), + |e| matches!(e, TaskError::DocumentActionFeesWithoutModeration { .. }), + ), + ]; + + for (cause, expected) in cases { + let debug = format!("{cause:?}"); + let error = TaskError::from(broadcast_rejection(cause)); + assert!(expected(&error), "{debug} mapped to {error:?}"); + assert!( + std::error::Error::source(&error).is_some(), + "{error:?} must keep the SDK error as its source" + ); + let message = error.to_string(); + assert!(message.ends_with('.'), "not a sentence: {message}"); + for jargon in ["consensus", "state transition", "nonce", "SDK", "permille"] { + assert!( + !message.contains(jargon), + "{message} contains jargon {jargon}" + ); + } + } + } + + /// The refusals protocol versions 14 and 15 add on username, vote and + /// token flows map to their own variants, keep the SDK error as their + /// source and tell the user what to do instead. + #[test] + fn contest_vote_and_token_transfer_refusals_map_to_their_variants() { + use dash_sdk::dpp::consensus::state::document::document_contest_maximum_contenders_reached_error::DocumentContestMaximumContendersReachedError; + use dash_sdk::dpp::consensus::state::document::referenced_document_preceded_error::ReferencedDocumentPrecededError; + use dash_sdk::dpp::consensus::state::token::TokenNotTransferableError; + use dash_sdk::dpp::consensus::state::voting::vote_choice_not_allowed_for_vote_poll_error::VoteChoiceNotAllowedForVotePollError; + use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; + use dash_sdk::dpp::voting::vote_polls::VotePoll; + use dash_sdk::dpp::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; + + type Expectation = fn(&TaskError) -> bool; + let token_id = Identifier::random(); + let poll = ContestedDocumentResourceVotePoll { + contract_id: Identifier::random(), + document_type_name: "domain".to_string(), + index_name: "parentNameAndLabel".to_string(), + index_values: vec![ + dash_sdk::dpp::platform_value::Value::from("dash"), + dash_sdk::dpp::platform_value::Value::from("alice"), + ], + }; + let cases: Vec<(ConsensusError, Expectation, &str)> = vec![ + ( + DocumentContestMaximumContendersReachedError::new(poll.clone(), 1000).into(), + |e| { + matches!( + e, + TaskError::DocumentContestContendersLimitReached { + max_contenders: 1000, + .. + } + ) + }, + "Choose a different username.", + ), + ( + TokenNotTransferableError::new(token_id, "transfer".to_string()).into(), + |e| matches!(e, TaskError::TokenNotTransferable { .. }), + "Use a different token.", + ), + ( + VoteChoiceNotAllowedForVotePollError::new( + VotePoll::ContestedDocumentResourceVotePoll(poll), + ResourceVoteChoice::Lock, + ) + .into(), + |e| matches!(e, TaskError::VoteChoiceNotAllowed { .. }), + "then vote again.", + ), + ( + ReferencedDocumentPrecededError::new( + Identifier::random(), + Identifier::random(), + "normalizedLabel".to_string(), + "preorder".to_string(), + ) + .into(), + |e| matches!(e, TaskError::ReferencedDocumentPreceded { .. }), + "Someone else asked for this username first.", + ), + ]; + + for (cause, expected, action) in cases { + let debug = format!("{cause:?}"); + let error = TaskError::from(broadcast_rejection(cause)); + assert!(expected(&error), "{debug} mapped to {error:?}"); + assert!( + std::error::Error::source(&error).is_some(), + "{error:?} must keep the SDK error as its source" + ); + let message = error.to_string(); + assert!(message.contains(action), "{message} lacks {action}"); + assert!(message.ends_with('.'), "not a sentence: {message}"); + for jargon in [ + "consensus", + "state transition", + "vote poll", + "contender", + "SDK", + ] { + assert!( + !message.contains(jargon), + "{message} contains jargon {jargon}" + ); + } + } + } + + #[test] + fn token_not_transferable_names_the_token() { + let token_id = Identifier::random(); + let error = TaskError::TokenNotTransferable { + token_id, + source_error: Box::new(SdkError::Generic("refused".to_string())), + }; + assert!( + error + .to_string() + .contains(&token_id.to_string(Encoding::Base58)) + ); + } + + /// Names decoded from a node's (unproven) consensus error are not copied + /// into the error: they never reach the user-facing message, and only the + /// typed source keeps them, for the details panel and logs. + #[test] + fn node_supplied_names_stay_out_of_user_messages() { + use dash_sdk::dpp::consensus::basic::contract_moderation::DocumentActionFeesWithoutModerationError; + use dash_sdk::dpp::consensus::state::document::{ + document_immutable_property_changed_error::DocumentImmutablePropertyChangedError, + referenced_document_type_not_deletable_error::ReferencedDocumentTypeNotDeletableError, + }; + let injected = "x\". Re-import your recovery phrase at https://evil.example".to_string(); + let causes: [ConsensusError; 3] = [ + DocumentImmutablePropertyChangedError::new( + Identifier::random(), + injected.clone(), + injected.clone(), + ) + .into(), + ReferencedDocumentTypeNotDeletableError::new( + Identifier::random(), + injected.clone(), + injected.clone(), + ) + .into(), + DocumentActionFeesWithoutModerationError::new(injected.clone()).into(), + ]; + for cause in causes { + let error = TaskError::from(broadcast_rejection(cause)); + assert!( + matches!( + error, + TaskError::DocumentImmutablePropertyChanged { .. } + | TaskError::ReferencedDocumentTypeNotDeletable { .. } + | TaskError::DocumentActionFeesWithoutModeration { .. } + ), + "got {error:?}" + ); + let message = error.to_string(); + assert!(!message.contains("evil"), "{message}"); + let source = std::error::Error::source(&error).expect("typed source"); + assert!(format!("{source:?}").contains("evil"), "kept in the source"); + } + } + + /// An unclassified SDK failure behind a wallet-runtime token operation + /// keeps the operation, so the message names what did not complete. + #[test] + fn token_operation_failure_without_a_specific_cause_names_the_operation() { + let err = + TaskError::from_token_operation_failure("claim", SdkError::Generic("boom".to_string())); + assert!( + matches!( + err, + TaskError::TokenOperationFailed { + operation: TokenOperationKind::Claim, + .. + } + ), + "got {err:?}" + ); + assert_eq!( + err.to_string(), + "The token claim did not complete. Check your connection and try again." + ); + } + + /// A token operation rejected with a specific consensus cause keeps the + /// specific variant: the operation label must not mask a better message. + #[test] + fn token_operation_failure_with_a_specific_cause_keeps_it() { + use dash_sdk::dpp::consensus::state::token::IdentityTokenAccountNotFrozenError; + let consensus = ConsensusError::from(IdentityTokenAccountNotFrozenError::new( + Identifier::random(), + Identifier::random(), + "Unfreeze".to_string(), + )); + let sdk_err = SdkError::StateTransitionBroadcastError( + dash_sdk::error::StateTransitionBroadcastError { + code: 40703, + message: "identity token account is not frozen".to_string(), + cause: Some(consensus), + }, + ); + let err = TaskError::from_token_operation_failure("unfreeze", sdk_err); + assert!( + matches!(err, TaskError::TokenAccountNotFrozen { .. }), + "got {err:?}" + ); + } + + /// Every label upstream emits today maps to its own kind; an unknown one + /// degrades to the generic kind instead of failing. + #[test] + fn token_operation_labels_map_to_kinds() { + for (label, kind) in [ + ("claim", TokenOperationKind::Claim), + ("mint", TokenOperationKind::Mint), + ("burn", TokenOperationKind::Burn), + ("transfer", TokenOperationKind::Transfer), + ("freeze", TokenOperationKind::Freeze), + ("unfreeze", TokenOperationKind::Unfreeze), + ( + "destroy frozen funds", + TokenOperationKind::DestroyFrozenFunds, + ), + ("pause", TokenOperationKind::Pause), + ("resume", TokenOperationKind::Resume), + ("set price", TokenOperationKind::SetPrice), + ("purchase", TokenOperationKind::Purchase), + ("config update", TokenOperationKind::ConfigUpdate), + ( + "something new", + TokenOperationKind::Unrecognized { + label: "something new", + }, + ), + ] { + assert_eq!( + TokenOperationKind::from_upstream_label(label), + kind, + "{label}" + ); + } + } + #[test] fn test_identity_token_account_not_frozen_from_consensus_error() { use dash_sdk::dpp::consensus::state::token::IdentityTokenAccountNotFrozenError; From d75a1591ac8af704e9ea2d9ae3a43d8b0d215ba2 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:21:52 +0000 Subject: [PATCH 07/58] docs: record the Platform v5.1-dev bump and protocol tracking Add a dated design note for the pin, the protocol version sources, the connection state rules and the protocol 13 guards, and point AGENTS.md at From for TaskError as the SDK error reference. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- AGENTS.md | 2 +- .../README.md | 67 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 docs/ai-design/2026-10-24-platform-v5.1-dev-bump/README.md diff --git a/AGENTS.md b/AGENTS.md index 07f0babe7..207af94d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,7 +128,7 @@ User-facing error messages (shown in `MessageBanner` via `Display`) must follow 2. **Structure**: *What happened* + *what to do*. Every message must include a concrete action the user can take themselves: retry, wait, try a different approach. Never redirect to "contact support" — users must be able to self-resolve. 3. **Tone**: Calm, direct, brief. Not apologetic ("Sorry!"), not alarming ("Something went wrong!"), not vague ("An error occurred"). 4. **Technical details**: Never in the message itself — no raw error strings, stack traces, SDK internals, or error codes. Attach via `BannerHandle::with_details(e)` — the `Debug` repr goes to the collapsible details panel and logs. Never refer users to "details" or "details panel" — these are not visible in basic mode. Exception: Base58 identifiers (see rule 6) are not technical details — they are user-meaningful handles. -5. **Reference implementation**: `sdk_error_user_message()` in `src/backend_task/error.rs` demonstrates the pattern for SDK errors. New `TaskError` variants should follow the same style. +5. **Reference implementation**: `impl From for TaskError` in `src/backend_task/error.rs` demonstrates the pattern for SDK errors. New `TaskError` variants should follow the same style. 6. **Base58 IDs are allowed in messages**: Contract IDs, identity IDs, document IDs, and similar Base58-encoded identifiers may appear in user-facing messages when they help the user identify which object is involved (e.g., *"This key conflicts with an existing key bound to contract `Abc123…`."*). They are not jargon — they are opaque-but-copyable handles the user can look up. 7. **Use dedicated `TaskError` variants**: Every error should get a dedicated `TaskError` variant with a `#[source]` field that preserves the error chain, enables structural matching, and keeps `Display` / `Debug` separation explicit. For `#[source]` fields in SDK-originated error variants, use `Box` — convert upstream types (e.g. `ProtocolError`) via `SdkError::Protocol(e)`. Use the concrete domain type directly for non-SDK errors (e.g. `rusqlite::Error`). Omit `#[source]` entirely when the upstream error carries no useful diagnostic information (e.g. a channel `SendError`). **Never store user-facing strings in error variants** — error variants must not contain `String` fields that hold messages for the user. The `#[error("...")]` attribute on the variant provides the user-facing message; `String` fields (regardless of name) break this separation. Instead, create a dedicated variant with typed `#[source]` fields, or a fieldless variant if no upstream error exists. When encountering existing variants that store stringified errors, replace them with properly typed variants as part of the change. diff --git a/docs/ai-design/2026-10-24-platform-v5.1-dev-bump/README.md b/docs/ai-design/2026-10-24-platform-v5.1-dev-bump/README.md new file mode 100644 index 000000000..fe5b8bb3a --- /dev/null +++ b/docs/ai-design/2026-10-24-platform-v5.1-dev-bump/README.md @@ -0,0 +1,67 @@ +# Platform v5.1-dev pin and protocol version tracking + +A point-in-time record of the update to Platform `v5.1-dev` +(`063589bf0adbe1a8823a943cee95385b423b0e3f`, 5.0.0-rc.8). The update keeps +today's behaviour on protocol 13 and protocol 14 networks and adds no new +user-facing feature. Protocol 14 features build on it in separate changes. +It supersedes the open "Devnet protocol seed" item of +[`2026-09-14-platform-4.2-dev-bump`](../2026-09-14-platform-4.2-dev-bump/upgrade-notes.md). + +## Which protocol version DET builds for + +There are two version sources, and they can disagree while a network moves +from 13 to 14: + +- **The SDK's version** (`AppContext::platform_version()`). It starts at + upstream's per-network `dash_sdk::sdk::min_protocol_version` (13 on mainnet, + testnet and regtest, 14 on devnet) and ratchets to what verified responses + report. Transitions are encoded at this version. The SDK is no longer pinned + to a fixed table: a pinned SDK cannot build any protocol 14 transition, and + cannot follow the versioned identity lookup below. +- **The network's confirmed version** (`AppContext::platform_protocol_version()`). + It comes from a proved epoch read (or, when that fails, a proved DPNS fetch). + It is `0` until confirmed. Capabilities (`context/feature_gate.rs`) read only + this one, through `known_protocol_version`, so an unconfirmed network, or a + version this build does not define, never unlocks a protocol feature. + `connected_platform_version()` selects that version's tables for estimates, + such as the daily withdrawal limit. + +## System contracts + +`SystemContractsCache` serves the system contracts (DPNS, DashPay, withdrawals, +token history, keyword search) at the SDK's version and reloads them when it +changes. The context provider serves them at the version the proof verifier +asks for (`verifier_version`), which can be newer than the SDK's. `try_get` +fails rather than serving another version's contracts, so a proof is never +checked against the wrong schema. + +## Connection state + +- **Epoch adoption.** A proved epoch sets the fee multiplier and raises the + protocol version (`AppContext::adopt_epoch`). Reads can finish out of order, + so an epoch older than the adopted one is ignored, and the protocol version + only moves up. +- **Rebuilding the connection** (`replace_connection`, `rebuild_connection`) + bumps a generation counter, forgets the adopted epoch, and puts the protocol + version back to unknown and the fee multiplier to the default, under one + lock, before the new SDK is published. A read begun on the old connection is + dropped. Capabilities stay closed until the new connection proves its epoch. + +## Protocol 13 guards + +- `context/platform_version_guard.rs` compares the protocol 13 client tables + with a recorded snapshot, so an upstream change to them is reviewed. +- `backend_task/protocol_13_transitions.rs` checks that transitions DET builds + on a protocol 13 network use encodings protocol 13 decodes. + +## Mainnet compatibility + +dashpay/platform#5515 versions the non-unique identity lookup: protocol 13 +nodes answer the V0 request with the legacy two-proof response, and the SDK +verifies it. Proof verification stays strict; nothing skips or weakens it. + +## Existing data + +The upstream V019 wallet-storage migration adds an asset-lock operation id. +V001–V018 are unchanged. The upgrade is forward only: going back to an older +build needs a copy of the data directory taken before the upgrade. From 7ff2beb95541738b4dcd6894fd749e70089349ec Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:57:16 +0000 Subject: [PATCH 08/58] fix(identity): choose signing keys Platform accepts on protocol 14 Automatic key choice keeps only keys that are live (not disabled, not expired), held on this device, in scope for the action's contract or document type, and at a security level the action accepts. Among those it prefers a key without limits, and a document type takes a stronger key than the one it requires when that is all the identity holds. Keys are judged as the identity holds them now: a key whose limits were raised on the network is still the same key, so its stored private half stays matched to it, while a key that gains or loses a limit is not. An explicitly chosen DPNS key may have expired; the chooser warns and Platform decides by network time. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- src/context/test_support.rs | 90 +++ src/model/dpns_usernames.rs | 70 ++- src/model/identity_key_usability.rs | 576 ++++++++++++++++++ src/model/mod.rs | 1 + .../encrypted_key_storage.rs | 221 ++++--- src/model/qualified_identity/mod.rs | 409 ++++++++++++- 6 files changed, 1271 insertions(+), 96 deletions(-) create mode 100644 src/model/identity_key_usability.rs diff --git a/src/context/test_support.rs b/src/context/test_support.rs index b75e9bd1d..61a66b68c 100644 --- a/src/context/test_support.rs +++ b/src/context/test_support.rs @@ -234,3 +234,93 @@ pub(crate) fn dpns_name_document(owner: Identifier, label: &str, identity: Value contract_version: None, }) } + +/// An ECDSA AUTHENTICATION key `id` at `level`, unbound and unlimited. +pub(crate) fn auth_key( + id: u32, + level: dash_sdk::dpp::identity::SecurityLevel, +) -> dash_sdk::platform::IdentityPublicKey { + dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0 { + id, + purpose: dash_sdk::dpp::identity::Purpose::AUTHENTICATION, + security_level: level, + contract_bounds: None, + key_type: dash_sdk::dpp::identity::KeyType::ECDSA_SECP256K1, + read_only: false, + data: vec![2; 33].into(), + disabled_at: None, + } + .into() +} + +/// A testnet user identity publishing `keys` and holding the private half of +/// each. +pub(crate) fn identity_holding( + keys: &[dash_sdk::platform::IdentityPublicKey], +) -> QualifiedIdentity { + use crate::model::qualified_identity::PrivateKeyTarget; + use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; + use dash_sdk::dpp::identity::accessors::IdentitySettersV0; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + + let mut qi = bare_user_identity(Identifier::new([0xD0; 32]), Network::Testnet); + qi.identity + .set_public_keys(keys.iter().map(|key| (key.id(), key.clone())).collect()); + for key in keys { + qi.private_keys + .insert_non_encrypted( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, key.id()), + ( + QualifiedIdentityPublicKey { + identity_public_key: key.clone(), + in_wallet_at_derivation_path: None, + }, + [key.id() as u8; 32], + ), + ) + .expect("a free slot"); + } + qi +} + +/// The DashPay system contract, whose document types require a HIGH key. +pub(crate) fn dashpay_contract() -> dash_sdk::platform::DataContract { + use dash_sdk::dpp::system_data_contracts::{SystemDataContract, load_system_data_contract}; + load_system_data_contract( + SystemDataContract::Dashpay, + dash_sdk::dpp::version::PlatformVersion::latest(), + ) + .expect("the DashPay contract") +} + +/// A user identity whose only key, key 1 (an ECDSA HIGH AUTHENTICATION key +/// bound to `bounds`), is held as a reference into an open test wallet, and +/// that wallet. +pub(crate) fn identity_with_bound_wallet_key( + bounds: dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds, +) -> ( + QualifiedIdentity, + Arc>, +) { + let wallet = crate::model::wallet::test_support::open_wallet(); + let seed_hash = wallet.seed_hash(); + let mut key = auth_key(1, dash_sdk::dpp::identity::SecurityLevel::HIGH); + { + let dash_sdk::platform::IdentityPublicKey::V0(v0) = &mut key else { + unreachable!("auth_key builds a version 0 key"); + }; + v0.contract_bounds = Some(bounds); + } + use dash_sdk::dpp::identity::accessors::IdentitySettersV0; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + let mut identity = bare_user_identity(Identifier::new([0xD1; 32]), Network::Testnet); + identity + .identity + .set_public_keys(std::collections::BTreeMap::from([(key.id(), key)])); + hold_as_wallet_reference(&mut identity, 1, seed_hash); + let wallet = Arc::new(std::sync::RwLock::new(wallet)); + identity + .associated_wallets + .insert(seed_hash, Arc::clone(&wallet)); + (identity, wallet) +} diff --git a/src/model/dpns_usernames.rs b/src/model/dpns_usernames.rs index 9ad25f5de..8505dbff5 100644 --- a/src/model/dpns_usernames.rs +++ b/src/model/dpns_usernames.rs @@ -116,20 +116,49 @@ impl SeenMarkAttempts { } } -/// Whether `key` may sign documents that require `required` security: an enabled -/// authentication key at least as strong as required, and never a master key. +/// Whether `key` may sign documents that require `required` security: an enabled, +/// unexpired authentication key at least as strong as required, and never a master +/// key. The rule for choosing a key automatically. pub fn key_can_sign_documents( key: &dash_sdk::platform::IdentityPublicKey, required: dash_sdk::dpp::identity::SecurityLevel, +) -> bool { + chosen_key_can_sign_documents(key, required) + && super::identity_key_usability::is_auto_selectable( + key, + dpns_signing_scope(), + super::identity_key_usability::now_ms(), + ) +} + +/// Whether the user may sign documents that require `required` security with +/// `key` they chose: an enabled authentication key at least as strong as +/// required, never a master key, and not bound to another contract. +/// +/// Unlike [`key_can_sign_documents`], an expired key is allowed: the key chooser +/// offers it with a warning, and whether it has expired is Platform's call by +/// network time, not this device's clock. +pub fn chosen_key_can_sign_documents( + key: &dash_sdk::platform::IdentityPublicKey, + required: dash_sdk::dpp::identity::SecurityLevel, ) -> bool { use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::identity::{Purpose, SecurityLevel}; key.purpose() == Purpose::AUTHENTICATION && !key.is_disabled() + && super::identity_key_usability::bounds_permit(key.contract_bounds(), dpns_signing_scope()) + == Some(true) && key.security_level() != SecurityLevel::MASTER && key.security_level() <= required } +/// The scope a username registration signs in: the DPNS contract. +fn dpns_signing_scope() -> super::identity_key_usability::SigningScope<'static> { + super::identity_key_usability::SigningScope::ContractWide { + contract_id: dash_sdk::dpp::system_data_contracts::SystemDataContract::DPNS.id(), + } +} + /// The weakest key security level that may sign both DPNS documents a registration /// creates (preorder and domain); falls back to `HIGH` if the contract lacks them. pub fn dpns_signing_requirement( @@ -1176,6 +1205,43 @@ mod tests { )); } + /// A key the user picked stays usable after it expires: the chooser offers + /// it with a warning, and Platform decides by network time. Automatic + /// choice still skips it. The other rules hold for both. + #[test] + fn a_chosen_key_is_not_refused_for_its_expiry() { + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + use dash_sdk::dpp::identity::{Purpose, SecurityLevel}; + let key = |purpose, level| { + let mut key = dash_sdk::platform::IdentityPublicKey::random_key( + 1, + Some(1), + dash_sdk::dpp::version::PlatformVersion::latest(), + ); + key.set_purpose(purpose); + key.set_security_level(level); + key.with_limits(None, Some(1)) + }; + let high = SecurityLevel::HIGH; + let expired = key(Purpose::AUTHENTICATION, high); + assert!(!key_can_sign_documents(&expired, high)); + assert!(chosen_key_can_sign_documents(&expired, high)); + + for refused in [ + key(Purpose::AUTHENTICATION, SecurityLevel::MEDIUM), + key(Purpose::AUTHENTICATION, SecurityLevel::MASTER), + key(Purpose::TRANSFER, SecurityLevel::CRITICAL), + ] { + assert!( + !chosen_key_can_sign_documents(&refused, high), + "{refused:?}" + ); + } + let mut disabled = key(Purpose::AUTHENTICATION, high); + disabled.set_disabled_at(1); + assert!(!chosen_key_can_sign_documents(&disabled, high)); + } + /// A testnet identity publishing `published` keys and holding `held` of them. fn identity_with_keys( published: &[(u32, dash_sdk::dpp::identity::SecurityLevel)], diff --git a/src/model/identity_key_usability.rs b/src/model/identity_key_usability.rs new file mode 100644 index 000000000..cb0a62e8c --- /dev/null +++ b/src/model/identity_key_usability.rs @@ -0,0 +1,576 @@ +//! Which identity keys can sign a given state transition right now. +//! +//! From protocol version 14 an identity key may carry usage limits (a lifetime +//! `total_budget` and an `expires_at` instant, V1 keys only) and an +//! AUTHENTICATION key may be bound to one contract, one document type or a +//! contract group. Picking a signing key by purpose, security level and key type +//! alone can therefore choose a key Platform refuses: an expired key, a key +//! bound elsewhere, or a contract-bound key on a transition outside a batch. +//! +//! These are pure rules — the caller passes the wall clock — mirroring upstream +//! `platform-wallet`'s `key_selection`: an unlimited key is preferred over a +//! limited one, a contract-group bound key is never chosen automatically (group +//! membership is Platform state, not known offline), and what is left of a +//! budget is not known offline either, so a spent key is refused by Platform. + +use dash_sdk::dpp::data_contract::document_type::DocumentTypeRef; +use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::identity::identity_public_key::accessors::v1::IdentityPublicKeyGettersV1; +use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; +use dash_sdk::dpp::identity::{ + Identity, IdentityPublicKey, KeyType, Purpose, SecurityLevel, TimestampMillis, +}; +use dash_sdk::platform::Identifier; + +/// What a signature authorizes, as far as contract bounds are concerned. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SigningScope<'a> { + /// A state transition outside a batch: identity update, credit transfer or + /// withdrawal, data contract create or update, masternode vote. Platform + /// refuses a contract-bound key on any of them. + NonBatch, + /// A document transition on `document_type_name` of `contract_id`. + Document { + contract_id: Identifier, + document_type_name: &'a str, + }, + /// Transitions spanning `contract_id` as a whole: a token transition (token + /// operations are contract-wide), or document transitions on several + /// document types of one contract (DPNS preorder + domain). A document-type + /// bound never covers them. + ContractWide { contract_id: Identifier }, +} + +/// Why a key cannot sign in a [`SigningScope`], or what the user should know +/// before choosing it. Ordered by severity: the first applicable one is shown. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum KeyCaveat { + /// The key is disabled; Platform refuses it. + Disabled, + /// The key's expiry has passed; Platform refuses it. + Expired { expired_at: TimestampMillis }, + /// The key is bound to a different contract or document type. + OutOfBounds, + /// The key is bound to a contract group; whether the group covers this + /// contract is only known to Platform. + ContractGroupBound { group_id: Identifier }, + /// The key has a lifetime budget; Platform refuses it once the budget is + /// spent. + Budgeted { total_budget: u64 }, + /// The key expires at `expires_at` (in the future). + Expiring { expires_at: TimestampMillis }, +} + +impl KeyCaveat { + /// Whether Platform is certain to refuse a signature from the key. + pub fn blocks_signing(&self) -> bool { + match self { + KeyCaveat::Disabled | KeyCaveat::Expired { .. } | KeyCaveat::OutOfBounds => true, + KeyCaveat::ContractGroupBound { .. } + | KeyCaveat::Budgeted { .. } + | KeyCaveat::Expiring { .. } => false, + } + } +} + +/// Whether a key carrying `bounds` may sign in `scope`, when that can be known +/// offline. `None` means only Platform can answer (a contract-group bound on a +/// batch transition). +pub fn bounds_permit(bounds: Option<&ContractBounds>, scope: SigningScope<'_>) -> Option { + match (bounds, scope) { + (None, _) => Some(true), + (Some(_), SigningScope::NonBatch) => Some(false), + ( + Some(ContractBounds::SingleContract { id }), + SigningScope::Document { contract_id, .. } | SigningScope::ContractWide { contract_id }, + ) => Some(*id == contract_id), + ( + Some(ContractBounds::SingleContractDocumentType { + id, + document_type_name: bound_type, + }), + SigningScope::Document { + contract_id, + document_type_name, + }, + ) => Some(*id == contract_id && bound_type == document_type_name), + ( + Some(ContractBounds::SingleContractDocumentType { .. }), + SigningScope::ContractWide { .. }, + ) => Some(false), + ( + Some(ContractBounds::ContractGroup { .. }), + SigningScope::Document { .. } | SigningScope::ContractWide { .. }, + ) => None, + } +} + +/// Everything the user should know about signing with `key` in `scope` at +/// `now_ms`, most severe first. Empty for an unlimited, unbound, live key. +pub fn key_caveats( + key: &IdentityPublicKey, + scope: SigningScope<'_>, + now_ms: TimestampMillis, +) -> Vec { + let mut caveats = Vec::new(); + if key.is_disabled() { + caveats.push(KeyCaveat::Disabled); + } + if let Some(expires_at) = key.expires_at() + && key.is_expired_at(now_ms) + { + caveats.push(KeyCaveat::Expired { + expired_at: expires_at, + }); + } + match bounds_permit(key.contract_bounds(), scope) { + Some(true) => {} + Some(false) => caveats.push(KeyCaveat::OutOfBounds), + None => { + if let Some(group_id) = key.contract_bounds().and_then(|b| b.contract_group_id()) { + caveats.push(KeyCaveat::ContractGroupBound { + group_id: *group_id, + }); + } + } + } + if let Some(total_budget) = key.total_budget() { + caveats.push(KeyCaveat::Budgeted { total_budget }); + } + if let Some(expires_at) = key.expires_at() + && !key.is_expired_at(now_ms) + { + caveats.push(KeyCaveat::Expiring { expires_at }); + } + caveats +} + +/// Whether `key` may be picked automatically for `scope` at `now_ms`: not +/// disabled, not expired, and bounds known to permit the scope. +pub fn is_auto_selectable( + key: &IdentityPublicKey, + scope: SigningScope<'_>, + now_ms: TimestampMillis, +) -> bool { + !key.is_disabled() + && !key.is_expired_at(now_ms) + && bounds_permit(key.contract_bounds(), scope) == Some(true) +} + +/// The security levels Platform accepts for a document action on +/// `document_type`: CRITICAL up to and including the level the type requires, +/// since a stronger key satisfies a weaker requirement. Never MASTER. +pub fn document_security_levels(document_type: DocumentTypeRef<'_>) -> Vec { + let required = document_type.security_level_requirement() as u8; + [ + SecurityLevel::CRITICAL, + SecurityLevel::HIGH, + SecurityLevel::MEDIUM, + ] + .into_iter() + .filter(|level| (SecurityLevel::CRITICAL as u8..=required).contains(&(*level as u8))) + .collect() +} + +/// The signing key requirements of a transition. +#[derive(Debug, Clone, Copy)] +pub struct KeyRequirements<'a> { + pub purpose: Purpose, + pub security_levels: &'a [SecurityLevel], + /// `None` accepts every key type. + pub key_types: Option<&'a [KeyType]>, + pub scope: SigningScope<'a>, +} + +impl KeyRequirements<'_> { + fn matches(&self, key: &IdentityPublicKey) -> bool { + key.purpose() == self.purpose + && self.security_levels.contains(&key.security_level()) + && self + .key_types + .is_none_or(|types| types.contains(&key.key_type())) + } +} + +/// The key of `keys` to pick automatically for `requirements` at `now_ms` +/// among those `can_sign` accepts, or `None` when none qualifies. +/// +/// Mirrors upstream platform-wallet's signer-aware `usable_authentication_key`: +/// a key qualifies when it matches purpose, security level and key type and +/// [`is_auto_selectable`]; qualifying unlimited keys come first, then limited +/// ones (a budgeted or expiring key is an application key), each in `keys` +/// order (key id order for an identity's key map); the first of that sequence +/// this device can sign with wins. So a limited key held here beats an +/// unlimited one whose private half lives elsewhere — the delegated-key case +/// protocol version 14 limits exist for. +pub fn select_signing_key<'k>( + keys: impl IntoIterator, + requirements: KeyRequirements<'_>, + now_ms: TimestampMillis, + can_sign: impl Fn(&IdentityPublicKey) -> bool, +) -> Option<&'k IdentityPublicKey> { + let qualifying: Vec<&IdentityPublicKey> = keys + .into_iter() + .filter(|key| { + requirements.matches(key) && is_auto_selectable(key, requirements.scope, now_ms) + }) + .collect(); + let unlimited = qualifying.iter().filter(|key| !key.has_limits()); + let limited = qualifying.iter().filter(|key| key.has_limits()); + unlimited.chain(limited).find(|key| can_sign(key)).copied() +} + +impl<'a> KeyRequirements<'a> { + /// Requirements accepting every key type. + pub const fn new( + purpose: Purpose, + security_levels: &'a [SecurityLevel], + scope: SigningScope<'a>, + ) -> Self { + Self { + purpose, + security_levels, + key_types: None, + scope, + } + } + + /// Restricts the accepted key types. + pub const fn with_key_types(mut self, key_types: &'a [KeyType]) -> Self { + self.key_types = Some(key_types); + self + } +} + +/// [`select_signing_key`] over `identity`'s keys. +pub fn select_identity_signing_key<'i>( + identity: &'i Identity, + requirements: KeyRequirements<'_>, + now_ms: TimestampMillis, + can_sign: impl Fn(&IdentityPublicKey) -> bool, +) -> Option<&'i IdentityPublicKey> { + select_signing_key( + identity.public_keys().values(), + requirements, + now_ms, + can_sign, + ) +} + +/// The current wall-clock time in milliseconds since the Unix epoch, the clock +/// key expiry is compared against. Platform compares against block time, which +/// trails the wall clock by seconds at most. +pub fn now_ms() -> TimestampMillis { + chrono::Utc::now().timestamp_millis().max(0) as TimestampMillis +} + +#[cfg(test)] +mod tests { + use super::*; + use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dash_sdk::dpp::platform_value::BinaryData; + + /// A HIGH document type accepts CRITICAL and HIGH keys, never MEDIUM. + #[test] + fn a_high_document_type_accepts_critical_and_high_keys() { + use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; + let dashpay = crate::context::test_support::dashpay_contract(); + for name in ["contactRequest", "contactInfo"] { + let document_type = dashpay.document_type_for_name(name).expect("document type"); + assert_eq!( + document_type.security_level_requirement(), + SecurityLevel::HIGH + ); + assert_eq!( + document_security_levels(document_type), + vec![SecurityLevel::CRITICAL, SecurityLevel::HIGH], + "{name}" + ); + } + } + + const CONTRACT: Identifier = Identifier::new([0xDA; 32]); + const OTHER: Identifier = Identifier::new([0x0E; 32]); + const NOW: TimestampMillis = 1_800_000_000_000; + + fn key(id: u32, bounds: Option) -> IdentityPublicKey { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: bounds, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![2; 33]), + disabled_at: None, + }) + } + + fn doc_scope() -> SigningScope<'static> { + SigningScope::Document { + contract_id: CONTRACT, + document_type_name: "note", + } + } + + fn requirements(scope: SigningScope<'_>) -> KeyRequirements<'_> { + KeyRequirements { + purpose: Purpose::AUTHENTICATION, + security_levels: &[SecurityLevel::HIGH, SecurityLevel::CRITICAL], + key_types: None, + scope, + } + } + + #[test] + fn an_unbound_key_covers_every_scope() { + for scope in [ + SigningScope::NonBatch, + doc_scope(), + SigningScope::ContractWide { + contract_id: CONTRACT, + }, + ] { + assert_eq!(bounds_permit(None, scope), Some(true), "{scope:?}"); + } + } + + #[test] + fn a_contract_bound_key_never_signs_outside_a_batch() { + let bounds = ContractBounds::SingleContract { id: CONTRACT }; + assert_eq!( + bounds_permit(Some(&bounds), SigningScope::NonBatch), + Some(false) + ); + } + + #[test] + fn a_single_contract_bound_covers_its_documents_and_tokens_only() { + let bounds = ContractBounds::SingleContract { id: CONTRACT }; + assert_eq!(bounds_permit(Some(&bounds), doc_scope()), Some(true)); + assert_eq!( + bounds_permit( + Some(&bounds), + SigningScope::ContractWide { + contract_id: CONTRACT + } + ), + Some(true) + ); + assert_eq!( + bounds_permit( + Some(&bounds), + SigningScope::Document { + contract_id: OTHER, + document_type_name: "note" + } + ), + Some(false) + ); + } + + #[test] + fn a_document_type_bound_covers_that_type_and_no_token() { + let bounds = ContractBounds::SingleContractDocumentType { + id: CONTRACT, + document_type_name: "note".to_string(), + }; + assert_eq!(bounds_permit(Some(&bounds), doc_scope()), Some(true)); + assert_eq!( + bounds_permit( + Some(&bounds), + SigningScope::Document { + contract_id: CONTRACT, + document_type_name: "other" + } + ), + Some(false) + ); + assert_eq!( + bounds_permit( + Some(&bounds), + SigningScope::ContractWide { + contract_id: CONTRACT + } + ), + Some(false) + ); + } + + #[test] + fn a_contract_group_bound_is_left_to_platform_inside_a_batch() { + let bounds = ContractBounds::ContractGroup { id: OTHER }; + assert_eq!(bounds_permit(Some(&bounds), doc_scope()), None); + assert_eq!( + bounds_permit(Some(&bounds), SigningScope::NonBatch), + Some(false) + ); + } + + #[test] + fn expiry_is_reached_at_the_instant_itself() { + let k = key(1, None).with_limits(None, Some(NOW)); + assert!(!k.is_expired_at(NOW - 1)); + assert!(k.is_expired_at(NOW)); + assert!(!is_auto_selectable(&k, doc_scope(), NOW)); + assert!(is_auto_selectable(&k, doc_scope(), NOW - 1)); + } + + #[test] + fn selection_prefers_an_unlimited_key_over_a_limited_one() { + let limited = key(0, None).with_limits(Some(1_000), None); + let unlimited = key(1, None); + let picked = select_signing_key( + [&limited, &unlimited], + requirements(doc_scope()), + NOW, + |_| true, + ); + assert_eq!(picked.map(|k| k.id()), Some(1)); + } + + #[test] + fn selection_falls_back_to_a_limited_key() { + let limited = key(0, None).with_limits(Some(1_000), Some(NOW + 1)); + let picked = select_signing_key([&limited], requirements(doc_scope()), NOW, |_| true); + assert_eq!(picked.map(|k| k.id()), Some(0)); + } + + #[test] + fn selection_skips_expired_disabled_out_of_bounds_and_group_keys() { + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + + let expired = key(0, None).with_limits(None, Some(NOW)); + let mut disabled = key(1, None); + disabled.set_disabled_at(1); + let elsewhere = key(2, Some(ContractBounds::SingleContract { id: OTHER })); + let grouped = key(3, Some(ContractBounds::ContractGroup { id: OTHER })); + let keys = [&expired, &disabled, &elsewhere, &grouped]; + + assert_eq!( + select_signing_key(keys, requirements(doc_scope()), NOW, |_| true), + None + ); + } + + #[test] + fn selection_takes_a_key_bound_to_the_target_contract() { + let bound = key(0, Some(ContractBounds::SingleContract { id: CONTRACT })); + assert_eq!( + select_signing_key([&bound], requirements(doc_scope()), NOW, |_| true).map(|k| k.id()), + Some(0) + ); + assert_eq!( + select_signing_key([&bound], requirements(SigningScope::NonBatch), NOW, |_| { + true + }), + None, + "but not for a transition outside a batch" + ); + } + + #[test] + fn selection_honours_purpose_security_level_and_key_type() { + let k = key(0, None); + let wrong_level = KeyRequirements { + security_levels: &[SecurityLevel::CRITICAL], + ..requirements(doc_scope()) + }; + let wrong_type = KeyRequirements { + key_types: Some(&[KeyType::BLS12_381]), + ..requirements(doc_scope()) + }; + let wrong_purpose = KeyRequirements { + purpose: Purpose::TRANSFER, + ..requirements(doc_scope()) + }; + for r in [wrong_level, wrong_type, wrong_purpose] { + assert_eq!(select_signing_key([&k], r, NOW, |_| true), None); + } + } + + #[test] + fn caveats_list_blocking_ones_first() { + let k = key(0, Some(ContractBounds::SingleContract { id: OTHER })) + .with_limits(Some(500), Some(NOW - 1)); + let caveats = key_caveats(&k, doc_scope(), NOW); + assert_eq!( + caveats, + vec![ + KeyCaveat::Expired { + expired_at: NOW - 1 + }, + KeyCaveat::OutOfBounds, + KeyCaveat::Budgeted { total_budget: 500 }, + ] + ); + assert!(caveats[0].blocks_signing()); + } + + #[test] + fn a_live_limited_key_has_only_informational_caveats() { + let k = key(0, None).with_limits(Some(500), Some(NOW + 10)); + let caveats = key_caveats(&k, doc_scope(), NOW); + assert_eq!( + caveats, + vec![ + KeyCaveat::Budgeted { total_budget: 500 }, + KeyCaveat::Expiring { + expires_at: NOW + 10 + }, + ] + ); + assert!(caveats.iter().all(|c| !c.blocks_signing())); + } + + #[test] + fn a_plain_key_has_no_caveats() { + assert!(key_caveats(&key(0, None), doc_scope(), NOW).is_empty()); + } + + /// A limited key this device can sign with beats an unlimited key whose + /// private half lives elsewhere (upstream `usable_authentication_key`). + #[test] + fn a_held_limited_key_beats_an_unlimited_key_held_elsewhere() { + let limited = key(4, None).with_limits(Some(100_000), None); + let unlimited = key(1, None); + let picked = select_signing_key( + [&unlimited, &limited], + requirements(doc_scope()), + NOW, + |k| k.id() == 4, + ); + assert_eq!(picked.map(|k| k.id()), Some(4)); + } + + /// An unlimited key still wins when both can sign. + #[test] + fn an_unlimited_key_wins_when_both_can_sign() { + let limited = key(4, None).with_limits(Some(100_000), None); + let unlimited = key(1, None); + let picked = select_signing_key( + [&limited, &unlimited], + requirements(doc_scope()), + NOW, + |_| true, + ); + assert_eq!(picked.map(|k| k.id()), Some(1)); + } + + /// Availability never widens eligibility: a signable but ineligible key + /// is not picked when the eligible one cannot sign. + #[test] + fn availability_does_not_relax_eligibility() { + let bound_elsewhere = key(2, Some(ContractBounds::SingleContract { id: OTHER })); + let eligible = key(1, None); + let picked = select_signing_key( + [&eligible, &bound_elsewhere], + requirements(doc_scope()), + NOW, + |k| k.id() == 2, + ); + assert_eq!(picked, None); + } +} diff --git a/src/model/mod.rs b/src/model/mod.rs index 7648d0387..fd23a0014 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -16,6 +16,7 @@ pub mod fee_estimation; pub mod grovestark_prover; pub mod identity_discovery; pub mod identity_key_protection; +pub mod identity_key_usability; pub mod identity_name; pub mod key_input; pub mod legacy_recovery; diff --git a/src/model/qualified_identity/encrypted_key_storage.rs b/src/model/qualified_identity/encrypted_key_storage.rs index 954901e26..d89160c7b 100644 --- a/src/model/qualified_identity/encrypted_key_storage.rs +++ b/src/model/qualified_identity/encrypted_key_storage.rs @@ -23,90 +23,110 @@ use zeroize::Zeroizing; pub type ResolvedPrivateKey = (QualifiedIdentityPublicKey, Zeroizing<[u8; 32]>); /// Whether a `stored` public half is the same key as the `live` one, ignoring -/// only `disabled_at`. +/// only the fields Platform lets move after the key is added. /// -/// Every field of an `IdentityPublicKey` is immutable once the key is added, with -/// that single exception: disabling a key rewrites it. The stored copy is a -/// snapshot taken when the private half was saved, so plain `==` stops matching -/// as soon as a key is disabled or rotated, and a key this device demonstrably +/// Every identifying field of an `IdentityPublicKey` is immutable once the key +/// is added. The exceptions are state, not identity: disabling a key rewrites +/// `disabled_at`, and an `IdentityKeyLimitsUpdate` (protocol version 14) raises +/// a V1 key's `total_budget` and moves its `expires_at` later. The stored copy +/// is a snapshot taken when the private half was saved, so plain `==` stops +/// matching as soon as any of those move, and a key this device demonstrably /// holds gets reported as missing. /// /// Comparing only the id and the key material would fix that and open a worse /// hole the other way. A main identity's voting key and a linked voter identity's /// key can carry identical `data` under the same `id`, leaving `purpose` as the /// only thing telling them apart — and a lookup that conflates them can hand out, -/// or delete, material the requested key does not own. So this excludes the one -/// field that legitimately moves and nothing else. +/// or delete, material the requested key does not own. So this excludes the +/// fields that legitimately move and nothing else. /// -/// V1 keys (budget/expiry limits) exist only from protocol 14; DET runs protocol -/// 13, so they are compared strictly apart from `disabled_at`. Upstream writes a -/// key without limits as V0, so a V1 key is never the same key as a V0 one. -// TODO(pv14): `IdentityKeyLimitsUpdate` raises `total_budget` and moves -// `expires_at`; decide whether those amounts identify a key once DET supports -// protocol 14. +/// Whether a key has a budget and whether it has an expiry is identity too: a +/// limit can only be raised, never added or removed, so that shape is fixed for +/// the key's life. The key version is not — upstream writes a V1 key without +/// limits as V0 — so a V0 key and a limit-less V1 key with the same fields are +/// the same key. pub(crate) fn same_key(stored: &IdentityPublicKey, live: &IdentityPublicKey) -> bool { + key_identity(stored) == key_identity(live) +} + +/// The fields of `key` that identify it: everything except the state Platform +/// lets move (see [`same_key`]). +/// +/// Destructured exhaustively, and without `..`, on purpose: a field added +/// upstream must break this build rather than be silently ignored. A new field +/// that distinguishes two keys would otherwise leave [`same_key`] reporting a +/// match where there is none — which is how one key's private material ends up +/// attributed to another. Whoever adds it decides here whether it identifies a +/// key or, like `disabled_at`, only describes its state. +fn key_identity(key: &IdentityPublicKey) -> KeyIdentity<'_> { use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dash_sdk::dpp::identity::identity_public_key::v1::IdentityPublicKeyV1; - // Destructured exhaustively, and without `..`, on purpose: a field added - // upstream must break this build rather than be silently ignored. A new field - // that distinguishes two keys would otherwise leave this reporting a match - // where there is none — which is how one key's private material ends up - // attributed to another. Whoever adds it decides here whether it identifies a - // key or, like `disabled_at`, only describes its state. - match (stored, live) { - (IdentityPublicKey::V0(stored), IdentityPublicKey::V0(live)) => { - let IdentityPublicKeyV0 { - id, - purpose, - security_level, - contract_bounds, - key_type, - read_only, - data, - // The one field Platform lets move after a key is added: disabling - // a key rewrites it, and the stored snapshot predates that. - disabled_at: _, - } = stored; - - *id == live.id - && *purpose == live.purpose - && *security_level == live.security_level - && *contract_bounds == live.contract_bounds - && *key_type == live.key_type - && *read_only == live.read_only - && *data == live.data - } - (IdentityPublicKey::V1(stored), IdentityPublicKey::V1(live)) => { - let IdentityPublicKeyV1 { - id, - purpose, - security_level, - contract_bounds, - key_type, - read_only, - data, - // Moves when the key is disabled, as for V0. - disabled_at: _, - total_budget, - expires_at, - } = stored; - - *id == live.id - && *purpose == live.purpose - && *security_level == live.security_level - && *contract_bounds == live.contract_bounds - && *key_type == live.key_type - && *read_only == live.read_only - && *data == live.data - && *total_budget == live.total_budget - && *expires_at == live.expires_at - } - (IdentityPublicKey::V0(_), IdentityPublicKey::V1(_)) - | (IdentityPublicKey::V1(_), IdentityPublicKey::V0(_)) => false, + match key { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + purpose, + security_level, + contract_bounds, + key_type, + read_only, + data, + // Moves when the key is disabled; the stored snapshot predates that. + disabled_at: _, + }) => KeyIdentity { + id: *id, + purpose: *purpose, + security_level: *security_level, + contract_bounds: contract_bounds.as_ref(), + key_type: *key_type, + read_only: *read_only, + data: data.as_slice(), + has_budget: false, + has_expiry: false, + }, + IdentityPublicKey::V1(IdentityPublicKeyV1 { + id, + purpose, + security_level, + contract_bounds, + key_type, + read_only, + data, + // Moves when the key is disabled. + disabled_at: _, + // The amounts move with `IdentityKeyLimitsUpdate`; only whether the + // key has each limit is fixed. + total_budget, + expires_at, + }) => KeyIdentity { + id: *id, + purpose: *purpose, + security_level: *security_level, + contract_bounds: contract_bounds.as_ref(), + key_type: *key_type, + read_only: *read_only, + data: data.as_slice(), + has_budget: total_budget.is_some(), + has_expiry: expires_at.is_some(), + }, } } +/// The identifying fields of an identity public key, whatever its version. +#[derive(PartialEq, Eq)] +struct KeyIdentity<'a> { + id: KeyID, + purpose: Purpose, + security_level: SecurityLevel, + contract_bounds: + Option<&'a dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds>, + key_type: dash_sdk::dpp::identity::KeyType, + read_only: bool, + data: &'a [u8], + has_budget: bool, + has_expiry: bool, +} + /// A `(target, key_id)` map key paired with the raw 32-byte private key the /// migration must store in the vault — see /// [`KeyStorage::take_plaintext_for_vault`]. Bytes are [`Zeroizing`]. @@ -1409,9 +1429,22 @@ mod tests { )); } - /// A V1 key disabled since its snapshot was taken is still the same key. + /// A V1 key's budget and expiry are raised by `IdentityKeyLimitsUpdate`, so + /// a snapshot taken before the raise still names the key. + #[test] + fn same_key_ignores_raised_limits() { + let base = IdentityPublicKey::random_key(0, Some(1), PlatformVersion::latest()); + let stored = base.clone().with_limits(Some(1_000), Some(1_800_000)); + let raised = base.with_limits(Some(5_000), Some(9_000_000)); + + assert!(same_key(&stored, &raised), "raised limits keep the key"); + assert!(same_key(&raised, &stored), "in either direction"); + } + + /// A limited key is disabled the same way any other key is, and the state + /// it carries is no more identifying for having limits beside it. #[test] - fn same_key_ignores_a_v1_key_being_disabled() { + fn same_key_ignores_a_limited_key_being_disabled() { use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; let stored = IdentityPublicKey::random_key(0, Some(1), PlatformVersion::latest()) @@ -1423,31 +1456,41 @@ mod tests { assert!(same_key(&disabled_since, &stored)); } - /// A V1 key carries limits a V0 key cannot, so the two are never the same - /// key, and V1 limits are compared strictly — `total_budget` and - /// `expires_at` each on their own, so dropping either conjunct fails here. + /// Whether a key has a budget or an expiry is fixed when it is added: a + /// limit can be raised but never added or removed. A disagreement on the + /// presence of a limit is a different key. #[test] - fn same_key_rejects_a_v1_key_against_v0_or_other_limits() { + fn same_key_rejects_a_different_limit_shape() { let base = IdentityPublicKey::random_key(0, Some(1), PlatformVersion::latest()); - let limited = base.clone().with_limits(Some(1_000), None); - let raised = base.clone().with_limits(Some(5_000), None); - // Same budget as `limited`, differing only in when the key expires. - let expiring = base.clone().with_limits(Some(1_000), Some(1_800_000)); - let expiring_later = base.clone().with_limits(Some(1_000), Some(2_400_000)); - - assert!(!same_key(&base, &limited)); - assert!(!same_key(&limited, &base)); - assert!(!same_key(&limited, &raised)); + let budget_only = base.clone().with_limits(Some(1_000), None); + let expiry_only = base.clone().with_limits(None, Some(1_800_000)); + let limits_none = base.clone().with_limits(None, None); + assert!(!same_key(&budget_only, &expiry_only)); + assert!( + !same_key(&budget_only, &limits_none), + "a budget alone tells two keys apart" + ); assert!( - !same_key(&limited, &expiring), - "an expiry added since the snapshot is a different key" + !same_key(&expiry_only, &limits_none), + "and so does an expiry alone" ); - assert!(!same_key(&expiring, &limited)); assert!( - !same_key(&expiring, &expiring_later), - "a moved expiry is a different key, at an identical budget" + !same_key(&base, &budget_only), + "a V0 key never gains a budget" ); + assert!(!same_key(&expiry_only, &base), "nor an expiry"); + } + + /// Upstream writes a V1 key without limits as V0, so the two encodings of + /// one key must still compare equal. + #[test] + fn same_key_treats_a_limitless_v1_key_as_its_v0_form() { + let base = IdentityPublicKey::random_key(0, Some(1), PlatformVersion::latest()); + let limits_none = base.clone().with_limits(None, None); + + assert!(same_key(&base, &limits_none)); + assert!(same_key(&limits_none, &base)); } /// A storage filing one key under several placements, each with the given diff --git a/src/model/qualified_identity/mod.rs b/src/model/qualified_identity/mod.rs index 4166ab369..e4d8620f6 100644 --- a/src/model/qualified_identity/mod.rs +++ b/src/model/qualified_identity/mod.rs @@ -8,6 +8,9 @@ pub mod qualified_identity_public_key; // requires making that secret-seam chokepoint generic over the closure error // type — a wallet_backend change out of scope here. use crate::backend_task::error::{SigningKeyUnavailableCause, TaskError}; +use crate::model::identity_key_usability::{ + KeyRequirements, SigningScope, document_security_levels, now_ms, select_identity_signing_key, +}; use crate::model::qualified_identity::encrypted_key_storage::{ KeyStorage, ResolvedPrivateKey, same_key, }; @@ -21,8 +24,9 @@ use dash_sdk::dpp::async_trait::async_trait; use dash_sdk::dpp::dashcore::address::Payload; use dash_sdk::dpp::dashcore::hashes::Hash; use dash_sdk::dpp::dashcore::{Address, Network, ScriptHash}; +use dash_sdk::dpp::data_contract::DataContract; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::document_type::DocumentTypeRef; -use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dash_sdk::dpp::ed25519_dalek::Signer as EDDSASigner; use dash_sdk::dpp::identity::KeyType::{BIP13_SCRIPT_HASH, ECDSA_HASH160}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; @@ -36,7 +40,7 @@ use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::state_transition::errors::InvalidIdentityPublicKeyTypeError; use dash_sdk::dpp::{ProtocolError, bls, ed25519_dalek}; use dash_sdk::platform::IdentityPublicKey; -use std::collections::{BTreeMap, HashSet}; +use std::collections::BTreeMap; use std::fmt::{Display, Formatter}; use std::sync::{Arc, RwLock}; use zeroize::Zeroizing; @@ -1153,18 +1157,100 @@ impl QualifiedIdentity { None } + /// The key to sign document transitions on `document_type` with, within + /// `scope`: a live AUTHENTICATION key at a level the type accepts + /// ([`document_security_levels`]) whose contract bounds permit `scope`, + /// unlimited keys first (see [`crate::model::identity_key_usability`]). + /// The level the type requires is preferred; a stronger key signs only + /// when no key at that level qualifies. pub fn document_signing_key( &self, + scope: SigningScope<'_>, document_type: &DocumentTypeRef, ) -> Option<&IdentityPublicKey> { - self.identity.get_first_public_key_matching( + document_security_levels(*document_type) + .iter() + .rev() + .find_map(|level| { + self.signing_key_now(KeyRequirements::new( + Purpose::AUTHENTICATION, + std::slice::from_ref(level), + scope, + )) + }) + } + + /// The key to sign an action on document type `document_type_name` of + /// `contract` with: a live AUTHENTICATION key at a level the type accepts + /// ([`document_security_levels`]), of one of `key_types` when given, whose + /// bounds permit that document type; unlimited keys first. `None` when the + /// contract has no such type or no key qualifies. + pub fn document_action_signing_key( + &self, + contract: &DataContract, + document_type_name: &str, + key_types: Option<&[KeyType]>, + ) -> Option<&IdentityPublicKey> { + let document_type = contract.document_type_for_name(document_type_name).ok()?; + let levels = document_security_levels(document_type); + let mut requirements = KeyRequirements::new( Purpose::AUTHENTICATION, - HashSet::from([document_type.security_level_requirement()]), - HashSet::from(KeyType::all_key_types()), + &levels, + SigningScope::Document { + contract_id: contract.id(), + document_type_name, + }, + ); + if let Some(key_types) = key_types { + requirements = requirements.with_key_types(key_types); + } + self.signing_key_now(requirements) + } + + /// The ENCRYPTION key whose wallet seeds DashPay auto-accept proofs + /// (DIP-15): the first MEDIUM ECDSA_SECP256K1 one. + pub fn auto_accept_proof_key(&self) -> Option<&IdentityPublicKey> { + self.identity.get_first_public_key_matching( + Purpose::ENCRYPTION, + std::collections::HashSet::from([SecurityLevel::MEDIUM]), + std::collections::HashSet::from([KeyType::ECDSA_SECP256K1]), false, ) } + /// The key as the identity holds it now for `snapshot`, a copy taken + /// earlier (e.g. stored next to its private half): budget and expiry move + /// with key limits updates, so the snapshot may be stale. Falls back to + /// the snapshot when the identity no longer holds the same key. + pub fn live_public_key<'a>(&'a self, snapshot: &'a IdentityPublicKey) -> &'a IdentityPublicKey { + self.identity + .public_keys() + .get(&snapshot.id()) + .filter(|live| encrypted_key_storage::same_key(snapshot, live)) + .unwrap_or(snapshot) + } + + /// The key to sign with for `requirements` now: the automatic choice of + /// [`select_identity_signing_key`] among the keys this device holds the + /// private half of. + pub fn signing_key_now(&self, requirements: KeyRequirements<'_>) -> Option<&IdentityPublicKey> { + select_identity_signing_key(&self.identity, requirements, now_ms(), |key| { + self.can_sign_with(key) + }) + } + + /// The key [`select_identity_signing_key`] would pick for `requirements` + /// now, whether or not this device holds its private half. For + /// informational lookups only (e.g. pointing the user at a key whose + /// private half they should load) — never for signing; use + /// [`Self::signing_key_now`] for that. + pub fn matching_key_now( + &self, + requirements: KeyRequirements<'_>, + ) -> Option<&IdentityPublicKey> { + select_identity_signing_key(&self.identity, requirements, now_ms(), |_| true) + } + pub fn available_withdrawal_keys(&self) -> Vec<&QualifiedIdentityPublicKey> { let mut keys = vec![]; @@ -2638,6 +2724,30 @@ pub(crate) mod withdrawal_key_tests { assert!(qi.default_withdrawal_key().is_none()); } + /// Regression: the withdraw screen's "Check Owner Key" / "Check Payout + /// Address Key" buttons render exactly when no signable key is held, so + /// their lookup must still find a public-only key. + #[test] + fn a_public_only_owner_key_is_found_for_information_but_not_for_signing() { + let owner = key(2, Purpose::OWNER); + let transfer = key(3, Purpose::TRANSFER); + let qi = build_identity(IdentityType::Masternode, vec![owner, transfer], vec![]); + let levels = SecurityLevel::full_range(); + let requirements = |purpose| KeyRequirements::new(purpose, &levels, SigningScope::NonBatch); + + assert!(qi.signing_key_now(requirements(Purpose::OWNER)).is_none()); + assert_eq!( + qi.matching_key_now(requirements(Purpose::OWNER)) + .map(|k| k.id()), + Some(2) + ); + assert_eq!( + qi.matching_key_now(requirements(Purpose::TRANSFER)) + .map(|k| k.id()), + Some(3) + ); + } + #[test] fn private_backed_transfer_key_is_selected() { let transfer = key(1, Purpose::TRANSFER); @@ -3254,6 +3364,295 @@ mod decode_limit_tests { } } +/// Protocol version 14 added `IdentityPublicKey::V1` (key limits) and +/// `ContractBounds::ContractGroup`. Stored identity blobs carry both enums, so +/// blobs written before the bump must decode unchanged and the new variants +/// must round-trip. +#[cfg(test)] +mod protocol_v14_compat_tests { + use super::*; + use crate::model::identity_key_usability::SigningScope; + use dash_sdk::dpp::data_contract::document_type::DocumentTypeRef; + use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; + use dash_sdk::dpp::identity::accessors::IdentitySettersV0; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + use dash_sdk::dpp::identity::identity_public_key::accessors::v1::IdentityPublicKeyGettersV1; + use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::Identifier; + + /// The bundled v0.9.3 profile: real identity rows written by DET v0.9.3. + const V0_9_3_DATA_SQL: &str = + include_str!("../../../tests/migration-fixtures/v0.9.3-public-identities/data.sql"); + + /// The stored blob of the v0.9.3 user identity `public-dpns-user`. + fn v0_9_3_user_blob() -> Vec { + const ROW: &str = "INSERT INTO \"identity\" VALUES(X'3B9DA97EBC06C07C0C0C48695EA5D36CAECE9E19E4944AAE5548691D9A80FAE5',X'"; + let start = V0_9_3_DATA_SQL.find(ROW).expect("the user identity row") + ROW.len(); + let len = V0_9_3_DATA_SQL[start..] + .find('\'') + .expect("the end of the blob literal"); + hex::decode(&V0_9_3_DATA_SQL[start..start + len]).expect("a hex blob") + } + + #[test] + fn a_blob_written_before_protocol_v14_still_decodes_with_v0_keys() { + let qi = QualifiedIdentity::from_bytes(&v0_9_3_user_blob()).expect("decodes"); + + let keys = qi.identity.public_keys(); + assert_eq!(keys.len(), 6, "as the v0.9.3 fixture expectations list"); + assert!( + keys.values().all(|k| matches!(k, IdentityPublicKey::V0(_))), + "every pre-v14 key decodes as V0" + ); + assert!(keys.values().all(|k| !k.has_limits())); + let bounds: Vec<_> = keys.values().filter_map(|k| k.contract_bounds()).collect(); + assert_eq!( + bounds.len(), + 2, + "the DashPay encryption and decryption keys" + ); + assert!(bounds.iter().all(|b| matches!( + b, + ContractBounds::SingleContractDocumentType { document_type_name, .. } + if document_type_name == "contactRequest" + ))); + + let reencoded = QualifiedIdentity::from_bytes(&qi.to_bytes()).expect("re-decodes"); + assert_eq!(reencoded.identity, qi.identity, "a re-save keeps every key"); + } + + #[test] + fn keys_with_limits_and_group_bounds_round_trip_through_the_blob() { + let mut qi = QualifiedIdentity::from_bytes(&v0_9_3_user_blob()).expect("decodes"); + let pv = PlatformVersion::latest(); + let limited = IdentityPublicKey::random_key(10, Some(1), pv) + .with_limits(Some(5_000_000), Some(1_900_000_000_000)); + let grouped = match IdentityPublicKey::random_key(11, Some(2), pv) { + IdentityPublicKey::V0(mut v0) => { + v0.contract_bounds = Some(ContractBounds::ContractGroup { + id: Identifier::from([7u8; 32]), + }); + IdentityPublicKey::V0(v0) + } + other => other, + }; + let mut keys = qi.identity.public_keys().clone(); + keys.insert(10, limited.clone()); + keys.insert(11, grouped.clone()); + qi.identity.set_public_keys(keys); + + let decoded = QualifiedIdentity::from_bytes(&qi.to_bytes()).expect("decodes"); + let keys = decoded.identity.public_keys(); + assert_eq!(keys.get(&10), Some(&limited)); + assert_eq!(keys[&10].total_budget(), Some(5_000_000)); + assert_eq!(keys[&10].expires_at(), Some(1_900_000_000_000)); + assert_eq!(keys.get(&11), Some(&grouped)); + } + + /// A key extended on the network but stored here as its expired copy is + /// judged by the live key: no "expired" caveat (the key chooser's view). + #[test] + fn an_extended_key_is_not_reported_expired_from_its_stale_copy() { + use crate::model::identity_key_usability::{KeyCaveat, key_caveats}; + let pv = PlatformVersion::latest(); + let stale = IdentityPublicKey::random_key(7, Some(7), pv).with_limits(None, Some(1)); + let extended = stale.clone().with_limits(None, Some(u64::MAX)); + let mut qi = QualifiedIdentity::from_bytes(&v0_9_3_user_blob()).expect("decodes"); + qi.identity + .set_public_keys(BTreeMap::from([(7, extended.clone())])); + + let live = qi.live_public_key(&stale); + assert_eq!(live, &extended); + let caveats = key_caveats(live, SigningScope::NonBatch, 1_000); + assert!( + !caveats + .iter() + .any(|caveat| matches!(caveat, KeyCaveat::Expired { .. })), + "{caveats:?}" + ); + assert!( + key_caveats(&stale, SigningScope::NonBatch, 1_000) + .iter() + .any(|caveat| matches!(caveat, KeyCaveat::Expired { .. })), + "the stale copy alone would say expired" + ); + } + + /// Platform accepts a stronger key than a document type requires, so a + /// CRITICAL key signs a HIGH document type when no HIGH key is held; a + /// MASTER key never does. + #[test] + fn document_signing_key_accepts_a_stronger_key_than_required() { + use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; + use dash_sdk::dpp::identity::{KeyType, Purpose}; + + let pv = PlatformVersion::latest(); + let dpns = dash_sdk::dpp::system_data_contracts::load_system_data_contract( + dash_sdk::dpp::system_data_contracts::SystemDataContract::DPNS, + pv, + ) + .expect("the DPNS contract"); + let preorder: DocumentTypeRef = dpns.document_type_for_name("preorder").expect("preorder"); + assert_eq!(preorder.security_level_requirement(), SecurityLevel::HIGH); + + let auth_key = |id: KeyID, level: SecurityLevel| { + let mut key = IdentityPublicKey::random_key(id, Some(u64::from(id)), pv); + key.set_purpose(Purpose::AUTHENTICATION); + key.set_security_level(level); + key.set_key_type(KeyType::ECDSA_SECP256K1); + key + }; + let all = [ + auth_key(1, SecurityLevel::MASTER), + auth_key(2, SecurityLevel::CRITICAL), + ]; + let mut qi = QualifiedIdentity::from_bytes(&v0_9_3_user_blob()).expect("decodes"); + qi.private_keys = Default::default(); + for key in &all { + qi.private_keys + .insert_non_encrypted( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, key.id()), + ( + QualifiedIdentityPublicKey { + identity_public_key: key.clone(), + in_wallet_at_derivation_path: None, + }, + [key.id() as u8; 32], + ), + ) + .expect("a free slot"); + } + qi.identity + .set_public_keys(all.iter().map(|key| (key.id(), key.clone())).collect()); + let scope = SigningScope::ContractWide { + contract_id: dpns.id(), + }; + assert_eq!( + qi.document_signing_key(scope, &preorder).map(|k| k.id()), + Some(2), + "the CRITICAL key, never the MASTER key" + ); + + // A key at the required level is still preferred over a stronger one. + let high = auth_key(3, SecurityLevel::HIGH); + qi.private_keys + .insert_non_encrypted( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, high.id()), + ( + QualifiedIdentityPublicKey { + identity_public_key: high.clone(), + in_wallet_at_derivation_path: None, + }, + [high.id() as u8; 32], + ), + ) + .expect("a free slot"); + let mut keys = qi.identity.public_keys().clone(); + keys.insert(high.id(), high); + qi.identity.set_public_keys(keys); + assert_eq!( + qi.document_signing_key(scope, &preorder).map(|k| k.id()), + Some(3), + "the HIGH key the type asks for" + ); + } + + /// The document signing key skips a key Platform would refuse (expired, + /// bound elsewhere) and prefers an unlimited key over a limited one. + #[test] + fn document_signing_key_picks_a_usable_unlimited_key() { + use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; + use dash_sdk::dpp::identity::{KeyType, Purpose}; + + let pv = PlatformVersion::latest(); + let dpns = dash_sdk::dpp::system_data_contracts::load_system_data_contract( + dash_sdk::dpp::system_data_contracts::SystemDataContract::DPNS, + pv, + ) + .expect("the DPNS contract"); + let preorder: DocumentTypeRef = dpns.document_type_for_name("preorder").expect("preorder"); + let level = preorder.security_level_requirement(); + + let auth_key = |id: KeyID| { + let mut key = IdentityPublicKey::random_key(id, Some(u64::from(id)), pv); + key.set_purpose(Purpose::AUTHENTICATION); + key.set_security_level(level); + key.set_key_type(KeyType::ECDSA_SECP256K1); + key + }; + let expired = auth_key(1).with_limits(None, Some(1)); + let elsewhere = match auth_key(2) { + IdentityPublicKey::V0(mut v0) => { + v0.contract_bounds = Some(ContractBounds::SingleContract { + id: Identifier::from([9u8; 32]), + }); + IdentityPublicKey::V0(v0) + } + other => other, + }; + let limited = auth_key(3).with_limits(Some(1_000), None); + let plain = auth_key(4); + + let mut qi = QualifiedIdentity::from_bytes(&v0_9_3_user_blob()).expect("decodes"); + let all = [expired, elsewhere, limited, plain]; + qi.private_keys = Default::default(); + for key in &all { + qi.private_keys + .insert_non_encrypted( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, key.id()), + ( + QualifiedIdentityPublicKey { + identity_public_key: key.clone(), + in_wallet_at_derivation_path: None, + }, + [key.id() as u8; 32], + ), + ) + .expect("a free slot"); + } + qi.identity + .set_public_keys(all.iter().map(|key| (key.id(), key.clone())).collect()); + let scope = SigningScope::ContractWide { + contract_id: dpns.id(), + }; + assert_eq!( + qi.document_signing_key(scope, &preorder).map(|k| k.id()), + Some(4), + "the unlimited, unbound, live key" + ); + + let mut keys = qi.identity.public_keys().clone(); + keys.remove(&4); + qi.identity.set_public_keys(keys); + assert_eq!( + qi.document_signing_key(scope, &preorder).map(|k| k.id()), + Some(3), + "a limited key when it is the only usable one" + ); + + // The stored snapshot of key 3 is judged by the live key. + let mut keys = qi.identity.public_keys().clone(); + let raised = keys[&3].clone().with_limits(Some(9_000), None); + let snapshot = keys[&3].clone(); + keys.insert(3, raised.clone()); + qi.identity.set_public_keys(keys); + assert_eq!(qi.live_public_key(&snapshot), &raised); + let unknown = auth_key(42); + assert_eq!(qi.live_public_key(&unknown), &unknown); + + // A held limited key beats an unlimited key whose private half is + // not on this device. + let mut keys = qi.identity.public_keys().clone(); + keys.insert(5, auth_key(5)); + qi.identity.set_public_keys(keys); + assert_eq!( + qi.document_signing_key(scope, &preorder).map(|k| k.id()), + Some(3), + "the unlimited key 5 cannot be signed with here" + ); + } +} #[cfg(test)] mod identity_display_name_tests { use super::*; From da6210064f15b30ab8531b4dbfad152157f5a46f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:57:16 +0000 Subject: [PATCH 09/58] fix(errors): explain signing key refusals Map the refusals Platform gives for a signing key that expired, used up its spending limit, cannot pay for the action, or is bound to another contract or to batch actions only, to dedicated TaskError variants with a message that says what to do. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- src/backend_task/error.rs | 183 +++++++++++++++++++++++++++++++++++++- 1 file changed, 179 insertions(+), 4 deletions(-) diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 42006aefc..9dc11d9d1 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -14,9 +14,12 @@ use dash_sdk::dashcore_rpc; use dash_sdk::dpp::ProtocolError; use dash_sdk::dpp::consensus::ConsensusError; use dash_sdk::dpp::consensus::basic::basic_error::BasicError; +use dash_sdk::dpp::consensus::signature::SignatureError; use dash_sdk::dpp::consensus::state::state_error::StateError; use dash_sdk::dpp::dashcore; use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::fee::Credits; +use dash_sdk::dpp::identity::KeyID; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::Identifier; use platform_wallet::wallet::asset_lock::tracked::TrackedAssetLock; @@ -2498,6 +2501,66 @@ pub enum TaskError { source_error: Box, }, + // ────────────────────────────────────────────────────────────────────────── + // Signing key refusals (protocol version 14) + // ────────────────────────────────────────────────────────────────────────── + /// The signing key's expiry time has passed. + #[error( + "The key used to sign this action expired and can no longer sign. Choose a different key, or add a new key to this identity, then try again." + )] + SigningKeyExpired { + key_id: KeyID, + /// When the key expired, in milliseconds since the Unix epoch. + expired_at_ms: u64, + #[source] + source_error: Box, + }, + + /// The signing key has spent its whole budget. + #[error( + "The key used to sign this action has used up its spending limit and can no longer sign. Raise the key's spending limit or choose a different key, then try again." + )] + SigningKeyBudgetExhausted { + key_id: KeyID, + #[source] + source_error: Box, + }, + + /// The action costs more than the signing key's remaining budget. + #[error( + "This action costs {required_dash}, but the key used to sign it has only {remaining_dash} of its spending limit left. Raise the key's spending limit or choose a different key, then try again.", + required_dash = format_credits_as_dash(*.required_budget), + remaining_dash = format_credits_as_dash(*.remaining_budget) + )] + SigningKeyBudgetExceeded { + key_id: KeyID, + remaining_budget: Credits, + required_budget: Credits, + #[source] + source_error: Box, + }, + + /// A contract-bound key signed something outside its contract bounds. + #[error( + "The key used to sign this action is limited to other contracts or documents, so it cannot sign this one. Choose a different key, then try again." + )] + SigningKeyOutOfContractBounds { + key_id: KeyID, + #[source] + source_error: Box, + }, + + /// A contract-bound key signed an action that is not a document or token + /// operation. + #[error( + "The key used to sign this action is limited to one contract, so it cannot sign this kind of action. Choose a key that is not limited to a contract, then try again." + )] + SigningKeyContractBoundForNonBatch { + key_id: KeyID, + #[source] + source_error: Box, + }, + // ────────────────────────────────────────────────────────────────────────── // Username, vote and token transfer refusals (protocol versions 14 and 15) // ────────────────────────────────────────────────────────────────────────── @@ -4360,6 +4423,61 @@ impl From for TaskError { source_error, })) } + ConsensusError::SignatureError(SignatureError::PublicKeyExpiredError(e)) => { + let (key_id, expired_at_ms) = (e.public_key_id(), e.expires_at()); + Some(Box::new(move |source_error| TaskError::SigningKeyExpired { + key_id, + expired_at_ms, + source_error, + })) + } + ConsensusError::SignatureError( + SignatureError::PublicKeyBudgetExhaustedError(e), + ) => { + let key_id = e.public_key_id(); + Some(Box::new(move |source_error| { + TaskError::SigningKeyBudgetExhausted { + key_id, + source_error, + } + })) + } + ConsensusError::StateError( + StateError::IdentityPublicKeyBudgetExceededError(e), + ) => { + let (key_id, remaining_budget, required_budget) = + (e.public_key_id(), e.remaining_budget(), e.required_budget()); + Some(Box::new(move |source_error| { + TaskError::SigningKeyBudgetExceeded { + key_id, + remaining_budget, + required_budget, + source_error, + } + })) + } + ConsensusError::SignatureError( + SignatureError::ContractBoundedKeyOutOfBoundsError(e), + ) => { + let key_id = *e.public_key_id(); + Some(Box::new(move |source_error| { + TaskError::SigningKeyOutOfContractBounds { + key_id, + source_error, + } + })) + } + ConsensusError::SignatureError( + SignatureError::ContractBoundedKeyNonBatchError(e), + ) => { + let key_id = *e.public_key_id(); + Some(Box::new(move |source_error| { + TaskError::SigningKeyContractBoundForNonBatch { + key_id, + source_error, + } + })) + } ConsensusError::StateError( StateError::DocumentContestMaximumContendersReachedError(e), ) => { @@ -6842,20 +6960,64 @@ mod tests { }) } - /// The protocol-version-14 refusals of contract and document authoring map - /// to their own variants, keep the SDK error as their source, and read as - /// complete sentences free of protocol jargon. + /// The protocol-version-14 refusals of signing keys and of contract and + /// document authoring map to their own variants, keep the SDK error as + /// their source, and read as complete sentences free of protocol jargon. #[test] fn protocol_v14_consensus_errors_map_to_their_variants() { use dash_sdk::dpp::consensus::basic::contract_moderation::DocumentActionFeesWithoutModerationError; + use dash_sdk::dpp::consensus::signature::{ + ContractBoundedKeyNonBatchError, ContractBoundedKeyOutOfBoundsError, + PublicKeyBudgetExhaustedError, PublicKeyExpiredError, + }; use dash_sdk::dpp::consensus::state::document::{ document_immutable_property_changed_error::DocumentImmutablePropertyChangedError, referenced_document_type_not_deletable_error::ReferencedDocumentTypeNotDeletableError, }; + use dash_sdk::dpp::consensus::state::identity::identity_public_key_budget_exceeded_error::IdentityPublicKeyBudgetExceededError; - let contract_id = Identifier::random(); + let (contract_id, identity_id) = (Identifier::random(), Identifier::random()); type Expectation = fn(&TaskError) -> bool; let cases: Vec<(ConsensusError, Expectation)> = vec![ + (PublicKeyExpiredError::new(3, 1_000, 2_000).into(), |e| { + matches!( + e, + TaskError::SigningKeyExpired { + key_id: 3, + expired_at_ms: 1_000, + .. + } + ) + }), + (PublicKeyBudgetExhaustedError::new(4).into(), |e| { + matches!(e, TaskError::SigningKeyBudgetExhausted { key_id: 4, .. }) + }), + ( + IdentityPublicKeyBudgetExceededError::new(identity_id, 5, 10, 20).into(), + |e| { + matches!( + e, + TaskError::SigningKeyBudgetExceeded { + key_id: 5, + remaining_budget: 10, + required_budget: 20, + .. + } + ) + }, + ), + (ContractBoundedKeyOutOfBoundsError::new(6).into(), |e| { + matches!( + e, + TaskError::SigningKeyOutOfContractBounds { key_id: 6, .. } + ) + }), + (ContractBoundedKeyNonBatchError::new(7).into(), |e| { + matches!( + e, + TaskError::SigningKeyContractBoundForNonBatch { key_id: 7, .. } + ) + }), ( DocumentImmutablePropertyChangedError::new( Identifier::random(), @@ -6990,6 +7152,19 @@ mod tests { } } + #[test] + fn budget_exceeded_names_both_amounts_in_dash() { + let error = TaskError::SigningKeyBudgetExceeded { + key_id: 1, + remaining_budget: 50_000_000_000, + required_budget: 100_000_000_000, + source_error: Box::new(SdkError::Generic("budget".to_string())), + }; + let message = error.to_string(); + assert!(message.contains("costs 1 DASH"), "got: {message}"); + assert!(message.contains("only 0.5 DASH"), "got: {message}"); + } + #[test] fn token_not_transferable_names_the_token() { let token_id = Identifier::random(); From 2f4ae190c9834e47020839a8031f1cafd4a5d1ae Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:57:16 +0000 Subject: [PATCH 10/58] docs(user-stories): add IDN-024 for signing with keys that can sign Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- docs/user-stories.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/user-stories.md b/docs/user-stories.md index ddeba35f3..156721e72 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -806,6 +806,15 @@ As a user, I want to remove an identity from this device from the identity's own --- +### IDN-024: Signing avoids keys that cannot sign [Implemented] +**Persona:** Alex, Priya + +As a user, I want the app to sign with a key that can actually sign the action, so that my actions are not refused because of an expired, used-up or out-of-scope key. + +- Automatic key choice skips disabled and expired keys, keys limited to other contracts or document types, and keys bound to a contract group. +- Only keys this device holds the private key of are chosen; among those, one without limits is preferred over a limited one. +- Where the user picks the key, limited and expired keys stay selectable, and the key list and selection show a warning explaining the limit. Keys bound to another contract or document type are not offered. + ## DPNS (DPN) ### DPN-001: Register a username [Implemented] From 82d3ef2e3b560c1e8676607672218aff623e26b5 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:57:16 +0000 Subject: [PATCH 11/58] fix(ui): sign each action with a key that can sign it, from the wallet that holds it Every screen and backend task that picks a signing key now asks for the action's scope: the contract or document type for document and token actions, none for identity and contract transitions. DashPay documents use the levels each document type accepts. The manual key chooser leaves out keys bound to another contract or document type, keeps expired and limited keys with a warning, and judges the key the identity holds now. A document action with no usable key says why the form stops. The wallet to unlock, and the owner of an identity stored without a wallet link, follow the key that is actually used. Lookups that only point the user at a key, such as Check Owner Key, do not require the private half on this device. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- src/backend_task/dashpay/auto_accept_proof.rs | 18 +- src/backend_task/dashpay/contact_info.rs | 42 ++- src/backend_task/dashpay/contact_requests.rs | 10 +- .../identity/register_dpns_name.rs | 17 +- src/context/mod.rs | 33 +- .../document_action_screen.rs | 333 ++++++++++++++++-- .../register_contract_screen.rs | 46 +-- .../update_contract_screen.rs | 42 +-- src/ui/dashpay/add_contact_screen.rs | 36 +- src/ui/dashpay/contact_requests.rs | 23 +- src/ui/dashpay/mod.rs | 39 ++ src/ui/dashpay/profile_screen.rs | 12 +- src/ui/dashpay/qr_code_generator.rs | 18 +- src/ui/dashpay/qr_scanner.rs | 62 +++- src/ui/helpers.rs | 137 ++++++- src/ui/identity/keys/add_key_screen.rs | 29 +- src/ui/identity/mod.rs | 19 +- src/ui/identity/register_dpns_name_screen.rs | 13 +- src/ui/identity/transfer_screen.rs | 24 +- src/ui/identity/withdraw_screen.rs | 22 +- src/ui/tokens/claim_tokens_screen.rs | 29 +- src/ui/tokens/direct_token_purchase_screen.rs | 17 +- src/ui/tokens/set_token_price_screen.rs | 16 +- src/ui/tokens/token_action_screen.rs | 17 +- src/ui/tokens/tokens_screen/token_creator.rs | 2 + src/ui/tokens/transfer_tokens_screen.rs | 20 +- src/ui/tokens/update_token_config.rs | 17 +- .../backend-e2e/measure_dpns_registration.rs | 8 +- 28 files changed, 801 insertions(+), 300 deletions(-) diff --git a/src/backend_task/dashpay/auto_accept_proof.rs b/src/backend_task/dashpay/auto_accept_proof.rs index 4a3fd5b22..0ecf1b6fa 100644 --- a/src/backend_task/dashpay/auto_accept_proof.rs +++ b/src/backend_task/dashpay/auto_accept_proof.rs @@ -2,12 +2,10 @@ use crate::backend_task::error::TaskError; use crate::model::dashpay_derivation::derive_auto_accept_key; use crate::model::qualified_identity::QualifiedIdentity; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::dpp::secp256k1::Message; use dash_sdk::platform::Identifier; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use std::collections::HashSet; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct AutoAcceptProofData { @@ -141,13 +139,7 @@ pub async fn generate_auto_accept_proof( // Get wallet seed for HD derivation - use ENCRYPTION key (ECDSA_SECP256K1) as per DIP-15 // The auto-accept proof uses HD derivation from the wallet, and ENCRYPTION keys are ECDSA_SECP256K1 let signing_key = identity - .identity - .get_first_public_key_matching( - Purpose::ENCRYPTION, - HashSet::from([SecurityLevel::MEDIUM]), - HashSet::from([KeyType::ECDSA_SECP256K1]), - false, - ) + .auto_accept_proof_key() .ok_or(DashPayError::MissingEncryptionKey)?; // Resolve the ENCRYPTION private key through the JIT chokepoint — no @@ -280,13 +272,7 @@ pub async fn verify_auto_accept_proof( // Derive expected pubkey from our seed and key index (timestamp) // Use ENCRYPTION key (ECDSA_SECP256K1) for HD derivation as per DIP-15 let signing_key = our_identity - .identity - .get_first_public_key_matching( - Purpose::ENCRYPTION, - HashSet::from([SecurityLevel::MEDIUM]), - HashSet::from([KeyType::ECDSA_SECP256K1]), - false, - ) + .auto_accept_proof_key() .ok_or("No suitable key found. This operation requires a MEDIUM security level ECDSA_SECP256K1 ENCRYPTION key.")?; // Resolve the ENCRYPTION private key through the JIT chokepoint — no // parked-seed read. diff --git a/src/backend_task/dashpay/contact_info.rs b/src/backend_task/dashpay/contact_info.rs index 91d881c7a..c22474831 100644 --- a/src/backend_task/dashpay/contact_info.rs +++ b/src/backend_task/dashpay/contact_info.rs @@ -17,14 +17,13 @@ use dash_sdk::dpp::document::{ }; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; -use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::dpp::platform_value::{Bytes32, Value}; use dash_sdk::drive::query::{OrderClause, WhereClause, WhereOperator}; use dash_sdk::platform::documents::transitions::DocumentCreateTransitionBuilder; use dash_sdk::platform::proto::get_documents_request::get_documents_request_v0::Start; use dash_sdk::platform::{Document, DocumentQuery, FetchMany, Identifier}; use dash_sdk::query_types::Documents; -use std::collections::{BTreeMap, HashSet}; +use std::collections::BTreeMap; use std::future::Future; use std::sync::Arc; use zeroize::Zeroizing; @@ -634,20 +633,7 @@ pub async fn create_or_update_contact_info( )); } - // Get signing key — accept any key type (BLS, ECDSA, EDDSA) since - // Platform accepts all for document state transitions. - let signing_key = identity - .identity - .get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([ - SecurityLevel::CRITICAL, - SecurityLevel::HIGH, - SecurityLevel::MEDIUM, - ]), - KeyType::all_key_types().into(), - false, - ) + let signing_key = contact_info_signing_key(&identity, &dashpay_contract) .ok_or_else(|| TaskError::DashPay(DashPayError::MissingAuthenticationKey))?; // Create document properties @@ -782,6 +768,15 @@ pub async fn create_or_update_contact_info( }) } +/// The key to sign a contactInfo document with, of any key type: Platform +/// accepts all of them for document transitions. +fn contact_info_signing_key<'i>( + identity: &'i QualifiedIdentity, + dashpay_contract: &dash_sdk::platform::DataContract, +) -> Option<&'i dash_sdk::platform::IdentityPublicKey> { + identity.document_action_signing_key(dashpay_contract, "contactInfo", None) +} + #[cfg(test)] mod tests { use super::*; @@ -1283,4 +1278,19 @@ mod tests { }) )); } + + /// Regression: the contactInfo key choice admitted MEDIUM keys, which the + /// document type refuses, and preferred unlimited keys, so an unlimited + /// MEDIUM key beat a held limited HIGH key and the save was refused. + #[test] + fn a_contact_info_is_signed_with_a_key_its_document_type_accepts() { + use crate::context::test_support::{auth_key, dashpay_contract, identity_holding}; + use dash_sdk::dpp::identity::SecurityLevel; + + let limited_high = auth_key(1, SecurityLevel::HIGH).with_limits(Some(5_000), None); + let identity = identity_holding(&[limited_high, auth_key(2, SecurityLevel::MEDIUM)]); + + let chosen = contact_info_signing_key(&identity, &dashpay_contract()); + assert_eq!(chosen.map(|key| key.id()), Some(1)); + } } diff --git a/src/backend_task/dashpay/contact_requests.rs b/src/backend_task/dashpay/contact_requests.rs index 9a0e6f837..4a4b02db0 100644 --- a/src/backend_task/dashpay/contact_requests.rs +++ b/src/backend_task/dashpay/contact_requests.rs @@ -715,16 +715,8 @@ pub async fn accept_contact_request( }); } - // Get an AUTHENTICATION key for signing the state transition - // Platform requires CRITICAL or HIGH security level for document creation let signing_key = identity - .identity - .get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL, SecurityLevel::HIGH]), - KeyType::all_key_types().into(), - false, - ) + .document_action_signing_key(&app_context.dashpay_contract(), "contactRequest", None) .ok_or_else(|| TaskError::DashPay(DashPayError::MissingAuthenticationKey))? .clone(); diff --git a/src/backend_task/identity/register_dpns_name.rs b/src/backend_task/identity/register_dpns_name.rs index f3f80cd6a..ca130a762 100644 --- a/src/backend_task/identity/register_dpns_name.rs +++ b/src/backend_task/identity/register_dpns_name.rs @@ -1,3 +1,4 @@ +use crate::model::identity_key_usability::SigningScope; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -30,7 +31,7 @@ use dash_sdk::{ use super::refresh_loaded_identities_dpns_names::name_acquired_at; use super::{BackendTaskSuccessResult, RegisterDpnsNameInput}; -use crate::model::dpns_usernames::{dpns_signing_requirement, key_can_sign_documents}; +use crate::model::dpns_usernames::{chosen_key_can_sign_documents, dpns_signing_requirement}; fn rebrand_dpns_domain_conflict(error: TaskError) -> TaskError { match error { @@ -190,10 +191,20 @@ impl AppContext { Some(key_id) => qualified_identity .identity .get_public_key_by_id(key_id) + // A key the user chose may have expired: the chooser offers + // it with a warning, and Platform decides by network time. .filter(|key| { - key_can_sign_documents(key, dpns_signing_requirement(&self.dpns_contract())) + chosen_key_can_sign_documents( + key, + dpns_signing_requirement(&self.dpns_contract()), + ) }), - None => qualified_identity.document_signing_key(&preorder_document_type), + None => qualified_identity.document_signing_key( + SigningScope::ContractWide { + contract_id: dpns_contract.id(), + }, + &preorder_document_type, + ), } .ok_or(TaskError::NoDocumentSigningKey)?; diff --git a/src/context/mod.rs b/src/context/mod.rs index f2c62f63c..421fc3c91 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -1691,14 +1691,19 @@ impl AppContext { self.selected_single_key_hash.lock().ok().and_then(|g| *g) } - /// Stored owner, falling back to the signing-key wallet for legacy records. + /// Stored owner, falling back for legacy records to the loaded wallet that + /// derives one of the identity's keys, whatever the key is bound to. fn owning_wallet_hash(&self, id: Identifier) -> Option { if let Ok(Some((hash, _))) = self.stored_identity_wallet_link(&id) { return Some(hash); } let identities = self.load_local_qualified_identities().ok()?; let qi = identities.into_iter().find(|qi| qi.identity.id() == id)?; - let wallet = crate::ui::identity::get_selected_wallet(&qi, Some(self), None).ok()??; + let wallet = qi + .identity + .public_keys() + .values() + .find_map(|key| crate::ui::identity::wallet_for_signing_key(&qi, Some(key)))?; let hash = wallet.read().ok()?.seed_hash(); Some(hash) } @@ -2612,4 +2617,28 @@ mod tests { assert_eq!(maximum.load(Ordering::SeqCst), MAX_CONCURRENT_DPNS_VOTERS); } + + /// Regression: the owner of an identity stored without a wallet link was + /// read from its DPNS signing key, so an identity whose keys are all bound + /// to another contract had no owner. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn an_unlinked_identity_is_owned_by_the_wallet_deriving_its_keys() { + use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; + let dir = tempfile::tempdir().expect("tempdir"); + let ctx = crate::context::test_support::wired_test_app_context(dir.path()).await; + let (identity, wallet) = crate::context::test_support::identity_with_bound_wallet_key( + ContractBounds::SingleContract { + id: ctx.dashpay_contract().id(), + }, + ); + let seed_hash = wallet.read().expect("wallet").seed_hash(); + ctx.wallet_context().insert_test_wallet(seed_hash, wallet); + ctx.insert_local_qualified_identity(&identity, &None) + .expect("store identity"); + + assert_eq!( + ctx.owning_wallet_hash(identity.identity.id()), + Some(seed_hash) + ); + } } diff --git a/src/ui/contracts_documents/document_action_screen.rs b/src/ui/contracts_documents/document_action_screen.rs index 56db4e99a..81715eec5 100644 --- a/src/ui/contracts_documents/document_action_screen.rs +++ b/src/ui/contracts_documents/document_action_screen.rs @@ -4,6 +4,7 @@ use crate::backend_task::FeeResult; use crate::backend_task::{BackendTask, document::DocumentTask}; use crate::context::AppContext; use crate::model::fee_estimation::format_credits_as_dash; +use crate::model::identity_key_usability::{KeyRequirements, SigningScope}; use crate::model::qualified_contract::QualifiedContract; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; @@ -15,12 +16,12 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; -use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt, ResultBannerExt}; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::helpers::{ TransactionType, add_contract_doc_type_chooser_with_filtering, add_key_chooser_with_doc_type, show_success_screen_with_info, }; -use crate::ui::identity::get_selected_wallet; +use crate::ui::identity::wallet_for_signing_key; use crate::ui::theme::{ComponentStyles, DashColors}; use base64::Engine; use base64::engine::general_purpose::STANDARD; @@ -133,6 +134,10 @@ pub struct DocumentActionScreen { refresh_banner: Option, } +/// Shown when the selected identity holds no key this device can sign the +/// chosen document action with. +const NO_SIGNING_KEY_NOTICE: &str = "This identity has no key on this device that can sign this action. Add or load a suitable key from the identity's keys, or select another identity."; + impl DocumentActionScreen { pub fn new( app_context: Arc, @@ -247,13 +252,34 @@ impl DocumentActionScreen { .as_ref() .map(|d| d.name().to_owned()); if contract_changed || doc_type_changed { - self.no_documents_found = false; - self.fetched_documents.clear(); + self.signing_scope_changed(); } ui.add_space(10.0); } + /// The contract or document type changed: what was fetched for the old + /// one no longer applies, and the key is picked again for the new scope + /// (a key bound to the old contract or type cannot sign in the new one). + /// The wallet holding the key the action is signed with, for the unlock + /// prompt. + fn signing_wallet(&self) -> Option>> { + let identity = self.selected_identity.as_ref()?; + wallet_for_signing_key(identity, self.selected_key.as_ref()) + } + + fn signing_scope_changed(&mut self) { + self.no_documents_found = false; + self.fetched_documents.clear(); + if let Some(identity) = &self.selected_identity { + self.selected_key = auto_selected_document_key( + identity, + self.selected_contract.as_ref(), + self.selected_document_type.as_ref(), + ); + } + } + fn render_identity_and_key_selection(&mut self, ui: &mut Ui) { ui.horizontal(|ui| { ui.heading("2. Select an identity:"); @@ -285,29 +311,13 @@ impl DocumentActionScreen { self.no_documents_found = false; self.fetched_documents.clear(); if let Some(identity) = &self.selected_identity { - // Auto-select a suitable key for document actions - // Note: MASTER keys cannot be used for document operations, - // only MEDIUM, HIGH, or CRITICAL security levels are allowed - use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; - self.selected_key = identity - .identity - .get_first_public_key_matching( - Purpose::AUTHENTICATION, - [ - SecurityLevel::CRITICAL, - SecurityLevel::HIGH, - SecurityLevel::MEDIUM, - ] - .into(), - KeyType::all_key_types().into(), - false, - ) - .cloned(); + self.selected_key = auto_selected_document_key( + identity, + self.selected_contract.as_ref(), + self.selected_document_type.as_ref(), + ); - // Update wallet - self.wallet = get_selected_wallet(identity, Some(&self.app_context), None) - .or_show_error(self.app_context.egui_ctx()) - .unwrap_or(None); + self.wallet = self.signing_wallet(); self.wallet_open_attempted = false; } else { self.selected_key = None; @@ -327,6 +337,10 @@ impl DocumentActionScreen { &mut self.selected_key, TransactionType::DocumentAction, self.selected_document_type.as_ref(), + signing_scope( + self.selected_contract.as_ref(), + self.selected_document_type.as_ref(), + ), ); } } @@ -1886,7 +1900,15 @@ impl DocumentActionScreen { // Step 2: Identity and Key Selection self.render_identity_and_key_selection(ui); - if self.selected_identity.is_none() || self.selected_key.is_none() { + if self.selected_identity.is_none() { + return action; + } + if self.selected_key.is_none() { + let dark_mode = ui.style().visuals.dark_mode; + ui.label( + RichText::new(NO_SIGNING_KEY_NOTICE) + .color(DashColors::warning_color(dark_mode)), + ); return action; } @@ -1894,11 +1916,8 @@ impl DocumentActionScreen { ui.add_space(10.0); // Wallet unlock - if let Some(selected_identity) = &self.selected_identity { - let new_wallet = - get_selected_wallet(selected_identity, Some(&self.app_context), None) - .or_show_error(self.app_context.egui_ctx()) - .unwrap_or(None); + if self.selected_identity.is_some() { + let new_wallet = self.signing_wallet(); let wallet_changed = match (&self.wallet, &new_wallet) { (Some(a), Some(b)) => !Arc::ptr_eq(a, b), (None, None) => false, @@ -1948,6 +1967,48 @@ impl DocumentActionScreen { } } +/// The contract-bounds scope of a document action on `document_type` of +/// `contract`. Before a contract is chosen nothing is known about the target, +/// so only keys that can sign anywhere (unbound keys) qualify. +/// The key to sign a document action with when the user has not picked one: +/// a live AUTHENTICATION key this device can sign with, at a security level +/// the document type accepts, within the contract and document type's scope. +fn auto_selected_document_key( + identity: &QualifiedIdentity, + contract: Option<&QualifiedContract>, + document_type: Option<&DocumentType>, +) -> Option { + // The levels the document type accepts (never MASTER), so the preference + // for unlimited keys only ever chooses among keys Platform accepts. + let levels = crate::ui::helpers::compute_allowed_security_levels( + TransactionType::DocumentAction, + document_type, + ); + identity + .signing_key_now(KeyRequirements::new( + dash_sdk::dpp::identity::Purpose::AUTHENTICATION, + &levels, + signing_scope(contract, document_type), + )) + .cloned() +} + +fn signing_scope<'a>( + contract: Option<&QualifiedContract>, + document_type: Option<&'a DocumentType>, +) -> SigningScope<'a> { + match (contract, document_type) { + (Some(contract), Some(document_type)) => SigningScope::Document { + contract_id: contract.contract.id(), + document_type_name: document_type.name().as_str(), + }, + (Some(contract), None) => SigningScope::ContractWide { + contract_id: contract.contract.id(), + }, + (None, _) => SigningScope::NonBatch, + } +} + #[cfg(test)] mod tests { use super::*; @@ -1970,6 +2031,183 @@ mod tests { ).unwrap() } + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + + /// An AUTHENTICATION key at `level`, optionally bound to `bounds`. + fn auth_key( + id: u32, + level: dash_sdk::dpp::identity::SecurityLevel, + bounds: Option< + dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds, + >, + ) -> IdentityPublicKey { + dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0 { + id, + purpose: dash_sdk::dpp::identity::Purpose::AUTHENTICATION, + security_level: level, + contract_bounds: bounds, + key_type: dash_sdk::dpp::identity::KeyType::ECDSA_SECP256K1, + read_only: false, + data: vec![2; 33].into(), + disabled_at: None, + } + .into() + } + + /// A user identity holding the private half of each of `keys`. + fn identity_holding(keys: &[IdentityPublicKey]) -> QualifiedIdentity { + use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; + use crate::model::qualified_identity::{IdentityStatus, IdentityType, PrivateKeyTarget}; + use dash_sdk::dpp::identity::Identity; + use dash_sdk::dpp::identity::accessors::IdentitySettersV0; + use dash_sdk::dpp::version::PlatformVersion; + + let mut identity = + Identity::create_basic_identity(Identifier::new([0xD0; 32]), PlatformVersion::latest()) + .expect("basic identity"); + identity.set_public_keys(keys.iter().map(|key| (key.id(), key.clone())).collect()); + let mut qi = QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: None, + private_keys: Default::default(), + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network: dash_sdk::dpp::dashcore::Network::Testnet, + }; + for key in keys { + qi.private_keys + .insert_non_encrypted( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, key.id()), + ( + QualifiedIdentityPublicKey { + identity_public_key: key.clone(), + in_wallet_at_derivation_path: None, + }, + [key.id() as u8; 32], + ), + ) + .expect("a free slot"); + } + qi + } + + /// The DPNS contract, whose document types require a HIGH key. + fn dpns() -> QualifiedContract { + use dash_sdk::dpp::system_data_contracts::{SystemDataContract, load_system_data_contract}; + use dash_sdk::dpp::version::PlatformVersion; + QualifiedContract { + contract: load_system_data_contract( + SystemDataContract::DPNS, + PlatformVersion::latest(), + ) + .expect("the DPNS contract"), + alias: None, + } + } + + fn owned_document_type(contract: &QualifiedContract, name: &str) -> DocumentType { + contract + .contract + .document_type_for_name(name) + .expect("document type") + .to_owned_document_type() + } + + /// Regression: automatic selection admitted every non-master level and + /// preferred unlimited keys, so an unlimited MEDIUM key beat a limited key + /// at the level the document type requires, and Platform refused it. + #[test] + fn auto_selection_honours_the_document_types_security_level() { + use dash_sdk::dpp::identity::SecurityLevel; + let contract = dpns(); + let domain = owned_document_type(&contract, "domain"); + let required = domain.security_level_requirement(); + assert_ne!( + required, + SecurityLevel::MEDIUM, + "the test needs a stricter type" + ); + + let limited = auth_key(1, required, None).with_limits(Some(5_000), None); + let weaker_unlimited = auth_key(2, SecurityLevel::MEDIUM, None); + let identity = identity_holding(&[limited, weaker_unlimited]); + + let chosen = auto_selected_document_key(&identity, Some(&contract), Some(&domain)); + assert_eq!(chosen.map(|key| key.id()), Some(1)); + } + + /// Regression: the unlock prompt looked for a DPNS signing key, so an + /// identity whose only key is bound to the action's contract got no + /// wallet to unlock although that key signs the action. + #[test] + fn the_unlock_wallet_follows_the_key_that_signs() { + use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; + let dir = tempfile::tempdir().unwrap(); + let context = crate::context::test_support::test_app_context(dir.path()); + let contract = QualifiedContract { + contract: crate::context::test_support::dashpay_contract(), + alias: None, + }; + let (identity, wallet) = crate::context::test_support::identity_with_bound_wallet_key( + ContractBounds::SingleContract { + id: contract.contract.id(), + }, + ); + + let mut screen = DocumentActionScreen::new(context, None, DocumentActionType::Create); + screen.selected_document_type = Some(owned_document_type(&contract, "contactInfo")); + screen.selected_contract = Some(contract); + screen.selected_identity = Some(identity); + screen.signing_scope_changed(); + assert_eq!(screen.selected_key.as_ref().map(|key| key.id()), Some(1)); + + let resolved = screen.signing_wallet(); + assert!(resolved.is_some_and(|resolved| Arc::ptr_eq(&resolved, &wallet))); + } + + /// Regression: a key picked for one document type survived a switch to a + /// type it is not bound to, and the action was signed with it anyway. + #[test] + fn changing_the_document_type_picks_the_key_again() { + use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; + let dir = tempfile::tempdir().unwrap(); + let context = crate::context::test_support::test_app_context(dir.path()); + let contract = dpns(); + let domain = owned_document_type(&contract, "domain"); + let preorder = owned_document_type(&contract, "preorder"); + let domain_only = auth_key( + 1, + domain.security_level_requirement(), + Some(ContractBounds::SingleContractDocumentType { + id: contract.contract.id(), + document_type_name: "domain".to_string(), + }), + ); + let identity = identity_holding(&[domain_only]); + + let mut screen = DocumentActionScreen::new(context, None, DocumentActionType::Create); + screen.selected_contract = Some(contract.clone()); + screen.selected_document_type = Some(domain.clone()); + screen.selected_key = auto_selected_document_key(&identity, Some(&contract), Some(&domain)); + screen.selected_identity = Some(identity); + assert_eq!(screen.selected_key.as_ref().map(|key| key.id()), Some(1)); + + screen.selected_document_type = Some(preorder); + screen.signing_scope_changed(); + assert_eq!( + screen.selected_key, None, + "a key bound to domain cannot sign a preorder" + ); + } + #[test] fn replacing_an_optional_typed_array_does_not_drop_its_contents() { let dir = tempfile::tempdir().unwrap(); @@ -2015,4 +2253,35 @@ mod tests { Some(&Value::Text("changed".into())) ); } + + /// Regression: with an identity selected but no key that can sign the + /// action, the screen stopped below the identity selector with no word + /// of why (the key chooser is behind Advanced Options). + #[test] + fn an_identity_without_a_usable_key_is_told_why_nothing_follows() { + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + + let dir = tempfile::tempdir().unwrap(); + let context = crate::context::test_support::test_app_context(dir.path()); + let contract = dpns(); + let domain = owned_document_type(&contract, "domain"); + let mut screen = DocumentActionScreen::new(context, None, DocumentActionType::Delete); + screen.selected_contract = Some(contract); + screen.selected_document_type = Some(domain); + screen.selected_identity = Some(identity_holding(&[])); + assert!(screen.selected_key.is_none()); + + let mut harness = Harness::builder().build_ui_state( + |ui, screen: &mut DocumentActionScreen| { + screen.render_main_content(ui); + }, + screen, + ); + harness.run(); + assert!( + harness.query_by_label(NO_SIGNING_KEY_NOTICE).is_some(), + "the missing key is explained" + ); + } } diff --git a/src/ui/contracts_documents/register_contract_screen.rs b/src/ui/contracts_documents/register_contract_screen.rs index 14c43790d..061ac58d4 100644 --- a/src/ui/contracts_documents/register_contract_screen.rs +++ b/src/ui/contracts_documents/register_contract_screen.rs @@ -4,6 +4,7 @@ use crate::backend_task::FeeResult; use crate::backend_task::contract::ContractTask; use crate::context::AppContext; use crate::model::fee_estimation::format_credits_as_dash; +use crate::model::identity_key_usability::{KeyRequirements, SigningScope}; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::components::identity_selector::IdentitySelector; @@ -15,7 +16,7 @@ use crate::ui::components::wallet_unlock_popup::{ }; use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt, ResultBannerExt}; use crate::ui::helpers::{TransactionType, add_key_chooser}; -use crate::ui::identity::get_selected_wallet; +use crate::ui::identity::wallet_for_signing_key; use crate::ui::theme::{ComponentStyles, DashColors}; use crate::ui::{BackendTaskSuccessResult, MessageType, ScreenLike}; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Setters; @@ -74,26 +75,21 @@ impl RegisterDataContractScreen { }) .or_else(|| qualified_identities.first().cloned()); - let selected_wallet = if let Some(ref identity) = selected_qualified_identity { - get_selected_wallet(identity, Some(app_context), None).unwrap_or(None) - } else { - None - }; - // Auto-select a suitable key for contract registration - use dash_sdk::dpp::identity::KeyType; let selected_key = selected_qualified_identity.as_ref().and_then(|identity| { identity - .identity - .get_first_public_key_matching( + .signing_key_now(KeyRequirements::new( Purpose::AUTHENTICATION, - [SecurityLevel::HIGH, SecurityLevel::CRITICAL].into(), - KeyType::all_key_types().into(), - false, - ) + &[SecurityLevel::HIGH, SecurityLevel::CRITICAL], + SigningScope::NonBatch, + )) .cloned() }); + let selected_wallet = selected_qualified_identity + .as_ref() + .and_then(|identity| wallet_for_signing_key(identity, selected_key.as_ref())); + let selected_identity_string = selected_qualified_identity .as_ref() .map(|qi| { @@ -463,25 +459,16 @@ impl ScreenLike for RegisterDataContractScreen { if response.changed() { if let Some(identity) = &self.selected_qualified_identity { // Auto-select a suitable key for contract registration - use dash_sdk::dpp::identity::KeyType; self.selected_key = identity - .identity - .get_first_public_key_matching( + .signing_key_now(KeyRequirements::new( Purpose::AUTHENTICATION, - [SecurityLevel::HIGH, SecurityLevel::CRITICAL].into(), - KeyType::all_key_types().into(), - false, - ) + &[SecurityLevel::HIGH, SecurityLevel::CRITICAL], + SigningScope::NonBatch, + )) .cloned(); - // Update wallet - self.selected_wallet = get_selected_wallet( - identity, - Some(&self.app_context), - None, - ) - .or_show_error(self.app_context.egui_ctx()) - .unwrap_or(None); + self.selected_wallet = + wallet_for_signing_key(identity, self.selected_key.as_ref()); self.wallet_open_attempted = false; // Re-parse contract with new owner ID @@ -503,6 +490,7 @@ impl ScreenLike for RegisterDataContractScreen { identity, &mut self.selected_key, TransactionType::RegisterContract, + SigningScope::NonBatch, ); } } diff --git a/src/ui/contracts_documents/update_contract_screen.rs b/src/ui/contracts_documents/update_contract_screen.rs index 03da6e2b2..1f7dde2f3 100644 --- a/src/ui/contracts_documents/update_contract_screen.rs +++ b/src/ui/contracts_documents/update_contract_screen.rs @@ -4,6 +4,7 @@ use crate::backend_task::FeeResult; use crate::backend_task::contract::ContractTask; use crate::context::AppContext; use crate::model::fee_estimation::format_credits_as_dash; +use crate::model::identity_key_usability::{KeyRequirements, SigningScope}; use crate::model::qualified_contract::QualifiedContract; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; @@ -16,7 +17,7 @@ use crate::ui::components::wallet_unlock_popup::{ }; use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt, ResultBannerExt}; use crate::ui::helpers::{TransactionType, add_key_chooser}; -use crate::ui::identity::get_selected_wallet; +use crate::ui::identity::wallet_for_signing_key; use crate::ui::theme::{ComponentStyles, DashColors}; use crate::ui::{BackendTaskSuccessResult, MessageType, ScreenLike}; use dash_sdk::dpp::data_contract::accessors::v0::{DataContractV0Getters, DataContractV0Setters}; @@ -24,13 +25,12 @@ use dash_sdk::dpp::data_contract::conversion::json::DataContractJsonConversionMe use dash_sdk::dpp::data_contract::serialized_version::DataContractInSerializationFormat; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; -use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; +use dash_sdk::dpp::identity::{Purpose, SecurityLevel}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::version::TryFromPlatformVersioned; use dash_sdk::platform::{DataContract, IdentityPublicKey}; use eframe::egui::{self, Color32, Frame, Margin, TextEdit}; use egui::{RichText, ScrollArea, Ui}; -use std::collections::HashSet; use std::sync::{Arc, RwLock}; #[derive(PartialEq)] @@ -83,12 +83,6 @@ impl UpdateDataContractScreen { }) .or_else(|| qualified_identities.first().cloned()); - let selected_wallet = if let Some(ref identity) = selected_qualified_identity { - get_selected_wallet(identity, Some(app_context), None).unwrap_or(None) - } else { - None - }; - let excluded_aliases = ["dpns", "keyword_search", "token_history", "withdrawals"]; let contracts = app_context.get_contracts().unwrap_or_else(|error| { MessageBanner::set_global( @@ -109,16 +103,18 @@ impl UpdateDataContractScreen { let selected_key = selected_qualified_identity.as_ref().and_then(|identity| { identity - .identity - .get_first_public_key_matching( + .signing_key_now(KeyRequirements::new( Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ) + &[SecurityLevel::CRITICAL], + SigningScope::NonBatch, + )) .cloned() }); + let selected_wallet = selected_qualified_identity + .as_ref() + .and_then(|identity| wallet_for_signing_key(identity, selected_key.as_ref())); + let selected_identity_string = selected_qualified_identity .as_ref() .map(|qi| qi.identity.id().to_string(Encoding::Base58)) @@ -493,20 +489,15 @@ impl ScreenLike for UpdateDataContractScreen { if let Some(identity) = &self.selected_qualified_identity { // Auto-select a suitable key for contract updates self.selected_key = identity - .identity - .get_first_public_key_matching( + .signing_key_now(KeyRequirements::new( Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ) + &[SecurityLevel::CRITICAL], + SigningScope::NonBatch, + )) .cloned(); - // Update wallet self.selected_wallet = - get_selected_wallet(identity, Some(&self.app_context), None) - .or_show_error(self.app_context.egui_ctx()) - .unwrap_or(None); + wallet_for_signing_key(identity, self.selected_key.as_ref()); self.wallet_open_attempted = false; // Re-parse contract with new owner ID @@ -528,6 +519,7 @@ impl ScreenLike for UpdateDataContractScreen { identity, &mut self.selected_key, TransactionType::UpdateContract, + SigningScope::NonBatch, ); } } diff --git a/src/ui/dashpay/add_contact_screen.rs b/src/ui/dashpay/add_contact_screen.rs index ec75b1a14..986d01b4b 100644 --- a/src/ui/dashpay/add_contact_screen.rs +++ b/src/ui/dashpay/add_contact_screen.rs @@ -5,9 +5,9 @@ use crate::backend_task::error::TaskError; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; use crate::model::dashpay::validate_account_label; +use crate::model::identity_key_usability::SigningScope; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; -use crate::ui::components::ResultBannerExt; use crate::ui::components::dashpay_subscreen_chooser_panel::add_dashpay_subscreen_chooser_panel; use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::info_popup::InfoPopup; @@ -19,10 +19,11 @@ use crate::ui::components::wallet_unlock_popup::{ }; use crate::ui::dashpay::DashPaySubscreen; use crate::ui::helpers::{TransactionType, add_key_chooser}; -use crate::ui::identity::get_selected_wallet; use crate::ui::identity::keys::add_key_screen::AddKeyScreen; +use crate::ui::identity::wallet_for_signing_key; use crate::ui::theme::{DashColors, Typography}; use crate::ui::{MessageType, RootScreenType, Screen, ScreenLike}; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::platform::IdentityPublicKey; use egui::{RichText, ScrollArea, TextEdit, Ui}; @@ -308,29 +309,18 @@ impl ScreenLike for AddContactScreen { if should_auto_select { if let Some(identity) = &self.selected_identity { - // Auto-select a suitable AUTHENTICATION key for signing contact requests - // Platform requires CRITICAL or HIGH security level for contact request signing - use dash_sdk::dpp::identity::accessors::IdentityGettersV0; - use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; - use std::collections::HashSet; self.selected_key = identity - .identity - .get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL, SecurityLevel::HIGH]), - KeyType::all_key_types().into(), - false, + .document_action_signing_key( + &self.app_context.dashpay_contract(), + "contactRequest", + None, ) .cloned(); - // Update wallet if not already set - if self.selected_wallet.is_none() { - self.selected_wallet = - get_selected_wallet(identity, Some(&self.app_context), None) - .or_show_error(self.app_context.egui_ctx()) - .unwrap_or(None); - self.wallet_open_attempted = false; - } + // The wallet holding the key that signs the request. + self.selected_wallet = + wallet_for_signing_key(identity, self.selected_key.as_ref()); + self.wallet_open_attempted = false; } else { self.selected_key = None; self.selected_wallet = None; @@ -348,6 +338,10 @@ impl ScreenLike for AddContactScreen { identity, &mut self.selected_key, TransactionType::ContactRequest, + SigningScope::Document { + contract_id: self.app_context.dashpay_contract().id(), + document_type_name: "contactRequest", + }, ); if !matches!(key_action, AppAction::None) { inner_action = key_action; diff --git a/src/ui/dashpay/contact_requests.rs b/src/ui/dashpay/contact_requests.rs index eaeb72e54..f2f80f0bf 100644 --- a/src/ui/dashpay/contact_requests.rs +++ b/src/ui/dashpay/contact_requests.rs @@ -7,14 +7,14 @@ use crate::context::AppContext; use crate::model::dashpay::{UnreadableContactInfoPolicy, contact_request_recipient}; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; +use crate::ui::components::MessageBanner; use crate::ui::components::component_trait::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; -use crate::ui::components::{MessageBanner, ResultBannerExt}; -use crate::ui::identity::get_selected_wallet; +use crate::ui::dashpay::dashpay_action_wallet; use crate::ui::identity::keys::add_key_screen::AddKeyScreen; use crate::ui::theme::{ComponentStyles, DashColors}; use crate::ui::{MessageType, Screen, ScreenLike, ScreenType}; @@ -116,9 +116,8 @@ impl ContactRequests { .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58); // Get wallet for the selected identity - new_self.selected_wallet = get_selected_wallet(&preferred, Some(&app_context), None) - .or_show_error(app_context.egui_ctx()) - .unwrap_or(None); + new_self.selected_wallet = + dashpay_action_wallet(&preferred, &app_context, "contactRequest"); } new_self @@ -141,9 +140,8 @@ impl ContactRequests { .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58); // Update wallet for the newly selected identity - self.selected_wallet = get_selected_wallet(id, Some(&self.app_context), None) - .or_show_error(self.app_context.egui_ctx()) - .unwrap_or(None); + self.selected_wallet = + dashpay_action_wallet(id, &self.app_context, "contactRequest"); self.wallet_open_attempted = false; } else { self.selected_identity_string.clear(); @@ -462,10 +460,11 @@ impl ContactRequests { // Update wallet for the newly selected identity if let Some(identity) = &self.selected_identity { - self.selected_wallet = - get_selected_wallet(identity, Some(&self.app_context), None) - .or_show_error(self.app_context.egui_ctx()) - .unwrap_or(None); + self.selected_wallet = dashpay_action_wallet( + identity, + &self.app_context, + "contactRequest", + ); } else { self.selected_wallet = None; } diff --git a/src/ui/dashpay/mod.rs b/src/ui/dashpay/mod.rs index 88d0b1cbc..db90e868e 100644 --- a/src/ui/dashpay/mod.rs +++ b/src/ui/dashpay/mod.rs @@ -24,6 +24,22 @@ use chrono::{LocalResult, TimeZone, Utc}; use chrono_humanize::HumanTime; use dash_sdk::platform::Identifier; +/// The wallet holding the key `identity` signs a `document_type_name` DashPay +/// document with, for the unlock prompt. `None` when no key qualifies or no +/// wallet derives it. +pub(crate) fn dashpay_action_wallet( + identity: &crate::model::qualified_identity::QualifiedIdentity, + app_context: &AppContext, + document_type_name: &str, +) -> Option>> { + let key = identity.document_action_signing_key( + &app_context.dashpay_contract(), + document_type_name, + None, + ); + crate::ui::identity::wallet_for_signing_key(identity, key) +} + /// Persist a contact's DET-local private memo (nickname / notes / hidden flag) /// to the WalletBackend k/v sidecar — the single save path shared by the /// contact list, contact details, and profile-viewer inline editors. @@ -141,3 +157,26 @@ pub fn render_no_identities_card(ui: &mut Ui, app_context: &Arc) -> }) .inner } + +#[cfg(test)] +mod tests { + use super::*; + + /// Regression: the unlock prompt looked for a DPNS signing key, so an + /// identity whose only key is bound to DashPay got no wallet to unlock. + #[test] + fn the_unlock_wallet_follows_a_key_bound_to_dashpay() { + use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; + use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; + let dir = tempfile::tempdir().expect("tempdir"); + let app_context = crate::context::test_support::test_app_context(dir.path()); + let (identity, wallet) = crate::context::test_support::identity_with_bound_wallet_key( + ContractBounds::SingleContract { + id: app_context.dashpay_contract().id(), + }, + ); + + let resolved = dashpay_action_wallet(&identity, &app_context, "contactRequest"); + assert!(resolved.is_some_and(|resolved| std::sync::Arc::ptr_eq(&resolved, &wallet))); + } +} diff --git a/src/ui/dashpay/profile_screen.rs b/src/ui/dashpay/profile_screen.rs index 5b3cff47d..d503327f3 100644 --- a/src/ui/dashpay/profile_screen.rs +++ b/src/ui/dashpay/profile_screen.rs @@ -7,6 +7,7 @@ use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::MessageType; +use crate::ui::components::MessageBanner; use crate::ui::components::avatar::Avatar; use crate::ui::components::component_trait::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; @@ -15,9 +16,8 @@ use crate::ui::components::info_popup::InfoPopup; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; -use crate::ui::components::{MessageBanner, ResultBannerExt}; +use crate::ui::dashpay::dashpay_action_wallet; use crate::ui::helpers::{ModalOpeningGuard, clicked_outside_window_after_open}; -use crate::ui::identity::get_selected_wallet; use crate::ui::state::{AvatarCache, PendingRequest}; use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; @@ -138,9 +138,7 @@ impl ProfileScreen { new_self.selected_identity_string ); - new_self.selected_wallet = get_selected_wallet(&preferred, Some(&app_context), None) - .or_show_error(app_context.egui_ctx()) - .unwrap_or(None); + new_self.selected_wallet = dashpay_action_wallet(&preferred, &app_context, "profile"); } new_self @@ -404,9 +402,7 @@ impl ProfileScreen { // Update wallet for the newly selected identity if let Some(identity) = &self.selected_identity { self.selected_wallet = - get_selected_wallet(identity, Some(&self.app_context), None) - .or_show_error(self.app_context.egui_ctx()) - .unwrap_or(None); + dashpay_action_wallet(identity, &self.app_context, "profile"); } else { self.selected_wallet = None; } diff --git a/src/ui/dashpay/qr_code_generator.rs b/src/ui/dashpay/qr_code_generator.rs index 2f2a22ede..34c24c43e 100644 --- a/src/ui/dashpay/qr_code_generator.rs +++ b/src/ui/dashpay/qr_code_generator.rs @@ -4,6 +4,7 @@ use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; +use crate::ui::components::MessageBanner; use crate::ui::components::dashpay_subscreen_chooser_panel::add_dashpay_subscreen_chooser_panel; use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::info_popup::InfoPopup; @@ -14,9 +15,8 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; -use crate::ui::components::{MessageBanner, ResultBannerExt}; use crate::ui::dashpay::dashpay_screen::DashPaySubscreen; -use crate::ui::identity::get_selected_wallet; +use crate::ui::identity::wallet_for_signing_key; use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, ScreenLike}; use egui::{RichText, ScrollArea, TextEdit, Ui}; @@ -84,9 +84,8 @@ impl QRCodeGeneratorScreen { new_self.selected_identity_string = preferred.identity.id().to_string(Encoding::Base58); // Get wallet for the selected identity - new_self.selected_wallet = get_selected_wallet(&preferred, Some(&app_context), None) - .or_show_error(app_context.egui_ctx()) - .unwrap_or(None); + new_self.selected_wallet = + wallet_for_signing_key(&preferred, preferred.auto_accept_proof_key()); } new_self @@ -205,13 +204,10 @@ impl QRCodeGeneratorScreen { if response.changed() { // Update wallet for the newly selected identity if let Some(identity) = &self.selected_identity { - self.selected_wallet = get_selected_wallet( + self.selected_wallet = wallet_for_signing_key( identity, - Some(&self.app_context), - None, - ) - .or_show_error(self.app_context.egui_ctx()) - .unwrap_or(None); + identity.auto_accept_proof_key(), + ); } else { self.selected_wallet = None; } diff --git a/src/ui/dashpay/qr_scanner.rs b/src/ui/dashpay/qr_scanner.rs index 6730a1e3c..925a592af 100644 --- a/src/ui/dashpay/qr_scanner.rs +++ b/src/ui/dashpay/qr_scanner.rs @@ -5,6 +5,7 @@ use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; +use crate::ui::components::MessageBanner; use crate::ui::components::dashpay_subscreen_chooser_panel::add_dashpay_subscreen_chooser_panel; use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::left_panel::add_left_panel; @@ -13,14 +14,12 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; -use crate::ui::components::{MessageBanner, ResultBannerExt}; use crate::ui::dashpay::dashpay_screen::DashPaySubscreen; -use crate::ui::identity::get_selected_wallet; +use crate::ui::identity::wallet_for_signing_key; use crate::ui::{MessageType, RootScreenType, ScreenLike}; +use dash_sdk::dpp::identity::KeyType; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use egui::{RichText, ScrollArea, TextEdit, Ui}; -use std::collections::HashSet; use std::sync::{Arc, RwLock}; pub struct QRScannerScreen { @@ -105,15 +104,9 @@ impl QRScannerScreen { if let Some(identity) = &self.selected_identity { if let Some(qr_data) = &self.parsed_qr_data { // Get signing key - let signing_key = match identity.identity.get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([ - SecurityLevel::CRITICAL, - SecurityLevel::HIGH, - SecurityLevel::MEDIUM, - ]), - HashSet::from([KeyType::ECDSA_SECP256K1]), - false, + let signing_key = match contact_request_signing_key( + identity, + &self.app_context.dashpay_contract(), ) { Some(key) => key, None => { @@ -209,13 +202,11 @@ impl QRScannerScreen { let new_identity_id = self.selected_identity.as_ref().map(|i| i.identity.id()); if prev_identity_id != new_identity_id { if let Some(identity) = &self.selected_identity { - self.selected_wallet = get_selected_wallet( + let key = contact_request_signing_key( identity, - Some(&self.app_context), - None, - ) - .or_show_error(self.app_context.egui_ctx()) - .unwrap_or(None); + &self.app_context.dashpay_contract(), + ); + self.selected_wallet = wallet_for_signing_key(identity, key); } else { self.selected_wallet = None; } @@ -354,6 +345,18 @@ impl QRScannerScreen { } } +/// The ECDSA key to sign a contactRequest document with. +fn contact_request_signing_key<'i>( + identity: &'i QualifiedIdentity, + dashpay_contract: &dash_sdk::platform::DataContract, +) -> Option<&'i dash_sdk::platform::IdentityPublicKey> { + identity.document_action_signing_key( + dashpay_contract, + "contactRequest", + Some(&[KeyType::ECDSA_SECP256K1]), + ) +} + impl ScreenLike for QRScannerScreen { fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { let ctx = ui.ctx().clone(); @@ -404,3 +407,24 @@ impl ScreenLike for QRScannerScreen { self.display_task_result(result); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::context::test_support::{auth_key, dashpay_contract, identity_holding}; + use dash_sdk::dpp::identity::SecurityLevel; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + + /// Regression: the contact-request key choice from a scanned code admitted + /// MEDIUM keys, which the document type refuses, and preferred unlimited + /// keys, so an unlimited MEDIUM key beat a held limited HIGH key and the + /// request was refused. + #[test] + fn a_scanned_contact_request_is_signed_with_a_key_its_document_type_accepts() { + let limited_high = auth_key(1, SecurityLevel::HIGH).with_limits(Some(5_000), None); + let identity = identity_holding(&[limited_high, auth_key(2, SecurityLevel::MEDIUM)]); + + let chosen = contact_request_signing_key(&identity, &dashpay_contract()); + assert_eq!(chosen.map(|key| key.id()), Some(1)); + } +} diff --git a/src/ui/helpers.rs b/src/ui/helpers.rs index 79d32ea63..89229d925 100644 --- a/src/ui/helpers.rs +++ b/src/ui/helpers.rs @@ -1,3 +1,5 @@ +use crate::model::fee_estimation::format_credits_as_dash; +use crate::model::identity_key_usability::{KeyCaveat, SigningScope, key_caveats, now_ms}; use crate::ui::theme::ResponseExt; use std::sync::Arc; @@ -149,16 +151,7 @@ pub fn compute_allowed_security_levels( ) -> Vec { match (transaction_type, document_type) { (TransactionType::DocumentAction, Some(doc_type)) => { - let required_level = doc_type.security_level_requirement(); - let allowed_range = SecurityLevel::CRITICAL as u8..=required_level as u8; - [ - SecurityLevel::CRITICAL, - SecurityLevel::HIGH, - SecurityLevel::MEDIUM, - ] - .into_iter() - .filter(|level| allowed_range.contains(&(*level as u8))) - .collect() + crate::model::identity_key_usability::document_security_levels(doc_type.as_ref()) } _ => transaction_type.allowed_security_levels(), } @@ -485,7 +478,9 @@ fn render_key_combo( allowed_purposes: &[Purpose], allowed_security_levels: &[SecurityLevel], is_dev_mode: bool, + scope: SigningScope<'_>, ) { + let now = now_ms(); ComboBox::from_id_salt(combo_id) .width(width) .selected_text( @@ -500,7 +495,9 @@ fn render_key_combo( return; }; for key_ref in qi.private_keys.identity_public_keys() { - let key = &key_ref.1.identity_public_key; + // The stored copy may predate a limits raise: judge, label and + // hand on the key as the identity holds it now. + let key = qi.live_public_key(&key_ref.1.identity_public_key); // Platform rejects signing with a disabled key, so never offer one // — not even in dev mode, where the override only relaxes purpose and @@ -524,6 +521,17 @@ fn render_key_combo( } else { format_key_label(key) }; + let caveats = key_caveats(key, scope, now); + if !offered_for_manual_choice(&caveats) { + continue; + } + // A key Platform may refuse stays selectable (the user may know + // better, e.g. a contract group that covers this contract), but + // its entry says why it may not work. + let label = match caveats.first() { + Some(caveat) => format!("{label} ({tag})", tag = key_caveat_tag(caveat)), + None => label, + }; if kui .selectable_label(selected_key.as_ref() == Some(key), label) @@ -533,6 +541,75 @@ fn render_key_combo( } } }); + + if let (Some(qi), Some(selected)) = (identity, selected_key.as_ref()) { + let live = qi.live_public_key(selected); + if live != selected { + *selected_key = Some(live.clone()); + } + } + if let Some(key) = selected_key.as_ref() { + render_key_caveats(ui, &key_caveats(key, scope, now)); + } +} + +/// Whether a key with `caveats` is offered for the user to pick: every key +/// but one whose bounds tie it to another contract or document type, which +/// Platform is certain to refuse here. Expired and limited keys stay on +/// offer with their warning; the user may know better. +fn offered_for_manual_choice(caveats: &[KeyCaveat]) -> bool { + !caveats + .iter() + .any(|caveat| matches!(caveat, KeyCaveat::OutOfBounds)) +} + +/// A one- or two-word tag for a key list entry, naming the most severe caveat. +fn key_caveat_tag(caveat: &KeyCaveat) -> &'static str { + match caveat { + KeyCaveat::Disabled => "disabled", + KeyCaveat::Expired { .. } => "expired", + KeyCaveat::OutOfBounds => "other contract", + KeyCaveat::ContractGroupBound { .. } => "contract group", + KeyCaveat::Budgeted { .. } => "spending limit", + KeyCaveat::Expiring { .. } => "expires", + } +} + +/// The user-facing explanation of a key caveat: what it means and what to do. +pub(crate) fn key_caveat_message(caveat: &KeyCaveat) -> String { + match caveat { + KeyCaveat::Disabled => "This key is disabled, so the network will reject anything signed with it. Choose another key.".to_string(), + KeyCaveat::Expired { expired_at } => format!( + "This key expired on {date}, so the network will reject anything signed with it. Choose another key.", + date = crate::model::datetime::local_date_time_from_unix_millis(*expired_at) + ), + KeyCaveat::OutOfBounds => "This key can only sign for a different contract or document type, so the network will reject this action. Choose another key.".to_string(), + KeyCaveat::ContractGroupBound { group_id } => format!( + "This key can only sign for the contracts in contract group {group_id}. If this contract is not in that group, the network rejects the action. Choose another key if you are not sure.", + group_id = group_id.to_string(Encoding::Base58) + ), + KeyCaveat::Budgeted { total_budget } => format!( + "This key has a lifetime spending limit of {amount}. Once the limit is used up, the network rejects actions signed with it. Its details page shows how much is left.", + amount = format_credits_as_dash(*total_budget) + ), + KeyCaveat::Expiring { expires_at } => format!( + "This key stops working on {date}.", + date = crate::model::datetime::local_date_time_from_unix_millis(*expires_at) + ), + } +} + +/// Renders the caveats of the selected signing key under a key chooser. +fn render_key_caveats(ui: &mut Ui, caveats: &[KeyCaveat]) { + let dark_mode = ui.style().visuals.dark_mode; + for caveat in caveats { + let color = if caveat.blocks_signing() { + DashColors::error_color(dark_mode) + } else { + DashColors::warning_color(dark_mode) + }; + ui.label(egui::RichText::new(key_caveat_message(caveat)).color(color)); + } } /// Renders a centered title + description block (used above success-screen buttons). @@ -560,12 +637,15 @@ fn render_info_section(ui: &mut Ui, title: &str, description: &str) { ); } +/// `scope` is what the chosen key will sign, for the contract-bounds warning +/// under the chooser. pub fn add_key_chooser( ui: &mut Ui, app_context: &Arc, identity: &QualifiedIdentity, selected_key: &mut Option, transaction_type: TransactionType, + scope: SigningScope<'_>, ) -> AppAction { add_key_chooser_with_doc_type( ui, @@ -574,6 +654,7 @@ pub fn add_key_chooser( selected_key, transaction_type, None, + scope, ) } @@ -586,6 +667,7 @@ pub fn add_key_chooser_with_doc_type( selected_key: &mut Option, transaction_type: TransactionType, document_type: Option<&DocumentType>, + scope: SigningScope<'_>, ) -> AppAction { let is_dev_mode = app_context.user_role().at_least(UserRole::Developer); let mut action = AppAction::None; @@ -619,6 +701,7 @@ pub fn add_key_chooser_with_doc_type( &allowed_purposes, &allowed_security_levels, is_dev_mode, + scope, ); }); } @@ -634,6 +717,7 @@ pub fn add_identity_key_chooser<'a, T>( selected_identity: &mut Option, selected_key: &mut Option, transaction_type: TransactionType, + scope: SigningScope<'_>, ) -> AppAction where T: Iterator, @@ -646,10 +730,12 @@ where selected_key, transaction_type, None, + scope, ) } /// Identity key chooser that filters keys based on transaction type, document type and dev mode +#[allow(clippy::too_many_arguments)] pub fn add_identity_key_chooser_with_doc_type<'a, T>( ui: &mut Ui, app_context: &Arc, @@ -658,6 +744,7 @@ pub fn add_identity_key_chooser_with_doc_type<'a, T>( selected_key: &mut Option, transaction_type: TransactionType, document_type: Option<&DocumentType>, + scope: SigningScope<'_>, ) -> AppAction where T: Iterator, @@ -731,6 +818,7 @@ where &allowed_purposes, &allowed_security_levels, is_dev_mode, + scope, ); } }); @@ -1106,6 +1194,33 @@ pub fn show_group_token_success_screen_with_fee( #[cfg(test)] mod tests { + /// Regression: a key bound to another contract or document type was + /// offered, though its bounds prove Platform refuses it here. Expired and + /// limited keys stay on offer (IDN-024), with their warning. + #[test] + fn the_manual_chooser_leaves_out_keys_bound_elsewhere() { + use super::{KeyCaveat, offered_for_manual_choice}; + assert!(!offered_for_manual_choice(&[KeyCaveat::OutOfBounds])); + assert!(!offered_for_manual_choice(&[ + KeyCaveat::Expired { expired_at: 1 }, + KeyCaveat::OutOfBounds, + ])); + for kept in [ + KeyCaveat::Expired { expired_at: 1 }, + KeyCaveat::Budgeted { total_budget: 5 }, + KeyCaveat::Expiring { expires_at: 9 }, + KeyCaveat::ContractGroupBound { + group_id: dash_sdk::platform::Identifier::new([3; 32]), + }, + ] { + assert!( + offered_for_manual_choice(std::slice::from_ref(&kept)), + "{kept:?}" + ); + } + assert!(offered_for_manual_choice(&[])); + } + use super::*; fn outside_press() -> egui::RawInput { diff --git a/src/ui/identity/keys/add_key_screen.rs b/src/ui/identity/keys/add_key_screen.rs index ae36c722a..14801159c 100644 --- a/src/ui/identity/keys/add_key_screen.rs +++ b/src/ui/identity/keys/add_key_screen.rs @@ -4,6 +4,7 @@ use crate::backend_task::identity::IdentityTask; use crate::backend_task::{BackendTask, BackendTaskContext, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::fee_estimation::format_credits_as_dash; +use crate::model::identity_key_usability::{KeyRequirements, SigningScope}; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; use crate::model::wallet::Wallet; @@ -31,7 +32,6 @@ use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::prelude::Identifier; use eframe::egui::{self, Frame, Margin}; use egui::{Color32, RichText, Ui}; -use std::collections::HashSet; use std::sync::{Arc, RwLock}; #[derive(PartialEq)] @@ -135,12 +135,11 @@ pub struct AddKeyScreen { impl AddKeyScreen { pub fn new(identity: QualifiedIdentity, app_context: &Arc) -> Self { let identity_clone = identity.clone(); - let selected_key = identity_clone.identity.get_first_public_key_matching( + let selected_key = identity_clone.signing_key_now(KeyRequirements::new( Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::MASTER]), - KeyType::all_key_types().into(), - false, - ); + &[SecurityLevel::MASTER], + SigningScope::NonBatch, + )); let selected_wallet = get_selected_wallet(&identity, None, selected_key) .or_show_error(app_context.egui_ctx()) .unwrap_or(None); @@ -181,12 +180,11 @@ impl AddKeyScreen { app_context: &Arc, ) -> Self { let identity_clone = identity.clone(); - let selected_key = identity_clone.identity.get_first_public_key_matching( + let selected_key = identity_clone.signing_key_now(KeyRequirements::new( Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::MASTER]), - KeyType::all_key_types().into(), - false, - ); + &[SecurityLevel::MASTER], + SigningScope::NonBatch, + )); let selected_wallet = get_selected_wallet(&identity, None, selected_key) .or_show_error(app_context.egui_ctx()) .unwrap_or(None); @@ -232,12 +230,11 @@ impl AddKeyScreen { app_context: &Arc, ) -> Self { let identity_clone = identity.clone(); - let selected_key = identity_clone.identity.get_first_public_key_matching( + let selected_key = identity_clone.signing_key_now(KeyRequirements::new( Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::MASTER]), - KeyType::all_key_types().into(), - false, - ); + &[SecurityLevel::MASTER], + SigningScope::NonBatch, + )); let selected_wallet = get_selected_wallet(&identity, None, selected_key) .or_show_error(app_context.egui_ctx()) .unwrap_or(None); diff --git a/src/ui/identity/mod.rs b/src/ui/identity/mod.rs index c0c2dbd4f..4ca2c50d7 100644 --- a/src/ui/identity/mod.rs +++ b/src/ui/identity/mod.rs @@ -18,6 +18,7 @@ //! `src/ui/components/left_panel.rs`, and the `RootScreenIdentityHub` entry in //! the `main_screens` map in `src/app.rs::AppState::new`. +use crate::model::identity_key_usability::SigningScope; use std::sync::{Arc, RwLock}; use dash_sdk::{ @@ -165,6 +166,17 @@ pub fn removed_identities_banner( } } +/// The wallet that derives `key`, the key the caller signs with, for the +/// unlock prompt. `None` when no key is chosen or no wallet derives it. +pub fn wallet_for_signing_key( + qualified_identity: &QualifiedIdentity, + key: Option<&IdentityPublicKey>, +) -> Option>> { + get_selected_wallet(qualified_identity, None, Some(key?)) + .ok() + .flatten() +} + /// Retrieves the appropriate wallet (if any) associated with the given identity. /// /// # Description @@ -213,7 +225,12 @@ pub fn get_selected_wallet( // Attempt to retrieve the public key from the identity. qualified_identity - .document_signing_key(&preorder_document_type) + .document_signing_key( + SigningScope::ContractWide { + contract_id: dpns_contract.id(), + }, + &preorder_document_type, + ) .ok_or_else(|| { "Identity doesn't have an authentication key for signing document transitions" .to_string() diff --git a/src/ui/identity/register_dpns_name_screen.rs b/src/ui/identity/register_dpns_name_screen.rs index 437f21b60..e142d3b50 100644 --- a/src/ui/identity/register_dpns_name_screen.rs +++ b/src/ui/identity/register_dpns_name_screen.rs @@ -16,6 +16,7 @@ use crate::model::dpns_usernames::{ registration_key_status, }; use crate::model::fee_estimation::{contest_fee_credits, format_credits_as_dash}; +use crate::model::identity_key_usability::SigningScope; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::user_role::UserRole; use crate::model::wallet::Wallet; @@ -596,7 +597,14 @@ impl RegisterDpnsNameScreen { if self.selected_key.is_none() { self.selected_key = document_type .as_ref() - .and_then(|doc_type| identity.document_signing_key(&doc_type.as_ref())) + .and_then(|doc_type| { + identity.document_signing_key( + SigningScope::ContractWide { + contract_id: self.app_context.dpns_contract().id(), + }, + &doc_type.as_ref(), + ) + }) .cloned(); } action |= add_key_chooser_with_doc_type( @@ -606,6 +614,9 @@ impl RegisterDpnsNameScreen { &mut self.selected_key, TransactionType::DocumentAction, document_type.as_ref(), + SigningScope::ContractWide { + contract_id: self.app_context.dpns_contract().id(), + }, ); }); } diff --git a/src/ui/identity/transfer_screen.rs b/src/ui/identity/transfer_screen.rs index a5c281518..1ad7363bd 100644 --- a/src/ui/identity/transfer_screen.rs +++ b/src/ui/identity/transfer_screen.rs @@ -4,6 +4,7 @@ use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::amount::Amount; use crate::model::fee_estimation::{format_credits_as_dash, max_spendable_credits}; +use crate::model::identity_key_usability::{KeyRequirements, SigningScope}; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::user_role::UserRole; use crate::model::wallet::Wallet; @@ -24,7 +25,7 @@ use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; -use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; +use dash_sdk::dpp::identity::{Purpose, SecurityLevel}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::{Identifier, IdentityPublicKey}; use eframe::egui::{self, Frame, Margin, Ui}; @@ -92,13 +93,12 @@ impl TransferScreen { .unwrap_or_default(); let max_amount = identity.identity.balance(); - let identity_clone = identity.identity.clone(); - let selected_key = identity_clone.get_first_public_key_matching( + let identity_clone = identity.clone(); + let selected_key = identity_clone.signing_key_now(KeyRequirements::new( Purpose::TRANSFER, - SecurityLevel::full_range().into(), - KeyType::all_key_types().into(), - false, - ); + &SecurityLevel::full_range(), + SigningScope::NonBatch, + )); let selected_wallet = get_selected_wallet(&identity, None, selected_key).unwrap_or_else(|e| { MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error) @@ -139,6 +139,7 @@ impl TransferScreen { &self.identity, &mut self.selected_key, TransactionType::Transfer, + SigningScope::NonBatch, ) } @@ -603,12 +604,11 @@ impl ScreenLike for TransferScreen { let key_for_info = key_info_when_available( has_keys, - self.identity.identity.get_first_public_key_matching( + self.identity.matching_key_now(KeyRequirements::new( Purpose::TRANSFER, - SecurityLevel::full_range().into(), - KeyType::all_key_types().into(), - false, - ), + &SecurityLevel::full_range(), + SigningScope::NonBatch, + )), ); if !has_keys { diff --git a/src/ui/identity/withdraw_screen.rs b/src/ui/identity/withdraw_screen.rs index 259b38b16..cb6b44524 100644 --- a/src/ui/identity/withdraw_screen.rs +++ b/src/ui/identity/withdraw_screen.rs @@ -4,6 +4,7 @@ use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::amount::Amount; use crate::model::fee_estimation::{format_credits_as_dash, max_spendable_credits}; +use crate::model::identity_key_usability::{KeyRequirements, SigningScope}; use crate::model::qualified_identity::{IdentityType, QualifiedIdentity}; use crate::model::user_role::UserRole; use crate::model::wallet::Wallet; @@ -24,7 +25,7 @@ use dash_sdk::dashcore_rpc::dashcore::{Address, Network}; use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; -use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; +use dash_sdk::dpp::identity::{Purpose, SecurityLevel}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::IdentityPublicKey; use eframe::egui::{self, Frame, Margin, Ui}; @@ -111,6 +112,7 @@ impl WithdrawalScreen { &self.identity, &mut self.selected_key, TransactionType::Withdraw, + SigningScope::NonBatch, ) } @@ -435,18 +437,16 @@ impl ScreenLike for WithdrawalScreen { ui.add_space(10.0); } - let owner_key = self.identity.identity.get_first_public_key_matching( + let owner_key = self.identity.matching_key_now(KeyRequirements::new( Purpose::OWNER, - SecurityLevel::full_range().into(), - KeyType::all_key_types().into(), - false, - ); - let transfer_key = self.identity.identity.get_first_public_key_matching( + &SecurityLevel::full_range(), + SigningScope::NonBatch, + )); + let transfer_key = self.identity.matching_key_now(KeyRequirements::new( Purpose::TRANSFER, - SecurityLevel::full_range().into(), - KeyType::all_key_types().into(), - false, - ); + &SecurityLevel::full_range(), + SigningScope::NonBatch, + )); if let Some(owner_key) = owner_key { if ui.button("Check Owner Key").clicked() { diff --git a/src/ui/tokens/claim_tokens_screen.rs b/src/ui/tokens/claim_tokens_screen.rs index eb4e9f239..d74fc661b 100644 --- a/src/ui/tokens/claim_tokens_screen.rs +++ b/src/ui/tokens/claim_tokens_screen.rs @@ -1,5 +1,6 @@ use crate::backend_task::{BackendTaskSuccessResult, FeeResult}; use crate::model::fee_estimation::format_credits_as_dash; +use crate::model::identity_key_usability::{KeyRequirements, SigningScope}; use crate::model::user_role::UserRole; use crate::ui::components::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; @@ -84,21 +85,22 @@ impl ClaimTokensScreen { .find(|id| id.identity.id() == identity_token_basic_info.identity_id); let (selected_key, selected_wallet) = if let Some(ref id) = identity { - let identity_inner = &id.identity; - let key = identity_inner - .get_first_public_key_matching( + let key = id + .signing_key_now(KeyRequirements::new( Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ) + &[SecurityLevel::CRITICAL], + SigningScope::ContractWide { + contract_id: identity_token_basic_info.contract_id, + }, + )) .or_else(|| { - identity_inner.get_first_public_key_matching( + id.signing_key_now(KeyRequirements::new( Purpose::TRANSFER, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ) + &[SecurityLevel::CRITICAL], + SigningScope::ContractWide { + contract_id: identity_token_basic_info.contract_id, + }, + )) }) .cloned(); @@ -447,6 +449,9 @@ impl ScreenLike for ClaimTokensScreen { identity, &mut self.selected_key, TransactionType::TokenClaim, + SigningScope::ContractWide { + contract_id: self.identity_token_basic_info.contract_id, + }, ); ui.add_space(10.0); } diff --git a/src/ui/tokens/direct_token_purchase_screen.rs b/src/ui/tokens/direct_token_purchase_screen.rs index ecd1724ec..5089da9f8 100644 --- a/src/ui/tokens/direct_token_purchase_screen.rs +++ b/src/ui/tokens/direct_token_purchase_screen.rs @@ -1,3 +1,5 @@ +use crate::model::identity_key_usability::{KeyRequirements, SigningScope}; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use std::collections::HashSet; use std::sync::{Arc, RwLock}; @@ -82,13 +84,13 @@ impl PurchaseTokenScreen { pub fn new(identity_token_info: IdentityTokenInfo, app_context: &Arc) -> Self { let possible_key = identity_token_info .identity - .identity - .get_first_public_key_matching( + .signing_key_now(KeyRequirements::new( Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ) + &[SecurityLevel::CRITICAL], + SigningScope::ContractWide { + contract_id: identity_token_info.data_contract.contract.id(), + }, + )) .cloned(); // Attempt to get an unlocked wallet reference @@ -533,6 +535,9 @@ impl ScreenLike for PurchaseTokenScreen { &self.identity_token_info.identity, &mut self.selected_key, TransactionType::TokenAction, + SigningScope::ContractWide { + contract_id: self.identity_token_info.data_contract.contract.id(), + }, ); ui.add_space(10.0); ui.separator(); diff --git a/src/ui/tokens/set_token_price_screen.rs b/src/ui/tokens/set_token_price_screen.rs index 373a89941..365faf6eb 100644 --- a/src/ui/tokens/set_token_price_screen.rs +++ b/src/ui/tokens/set_token_price_screen.rs @@ -5,6 +5,7 @@ use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::amount::{Amount, DASH_DECIMAL_PLACES}; use crate::model::fee_estimation::format_credits_as_dash; +use crate::model::identity_key_usability::{KeyRequirements, SigningScope}; use crate::model::user_role::UserRole; use crate::model::wallet::Wallet; use crate::ui::components::ComponentResponse; @@ -171,13 +172,13 @@ impl SetTokenPriceScreen { pub fn new(identity_token_info: IdentityTokenInfo, app_context: &Arc) -> Self { let possible_key: Option<&IdentityPublicKey> = identity_token_info .identity - .identity - .get_first_public_key_matching( + .signing_key_now(KeyRequirements::new( Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ); + &[SecurityLevel::CRITICAL], + SigningScope::ContractWide { + contract_id: identity_token_info.data_contract.contract.id(), + }, + )); let set_error_banner = |msg: &str| super::set_error_banner(app_context, msg); @@ -1011,6 +1012,9 @@ impl ScreenLike for SetTokenPriceScreen { &self.identity_token_info.identity, &mut self.selected_key, TransactionType::TokenAction, + SigningScope::ContractWide { + contract_id: self.identity_token_info.data_contract.contract.id(), + }, ); ui.add_space(10.0); ui.separator(); diff --git a/src/ui/tokens/token_action_screen.rs b/src/ui/tokens/token_action_screen.rs index 9c771dc9a..9cec1f51c 100644 --- a/src/ui/tokens/token_action_screen.rs +++ b/src/ui/tokens/token_action_screen.rs @@ -11,6 +11,7 @@ use crate::app::AppAction; use crate::backend_task::{BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::fee_estimation::format_credits_as_dash; +use crate::model::identity_key_usability::{KeyRequirements, SigningScope}; use crate::model::user_role::UserRole; use crate::model::wallet::Wallet; use crate::ui::components::component_trait::Component; @@ -33,6 +34,7 @@ use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; use crate::ui::tokens::validate_signing_key; use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dpp::data_contract::GroupContractPosition; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::associated_token::token_configuration::TokenConfiguration; use dash_sdk::dpp::data_contract::change_control_rules::authorized_action_takers::AuthorizedActionTakers; use dash_sdk::dpp::data_contract::group::Group; @@ -174,13 +176,13 @@ impl TokenActionScreen { pub fn new(identity_token_info: IdentityTokenInfo, app_context: &Arc) -> Self { let possible_key = identity_token_info .identity - .identity - .get_first_public_key_matching( + .signing_key_now(KeyRequirements::new( Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ) + &[SecurityLevel::CRITICAL], + SigningScope::ContractWide { + contract_id: identity_token_info.data_contract.contract.id(), + }, + )) .cloned(); let takers = A::authorized_takers(&identity_token_info.token_config); @@ -533,6 +535,9 @@ impl ScreenLike for TokenActionScreen { &self.common.identity_token_info.identity, &mut self.common.selected_key, TransactionType::TokenAction, + SigningScope::ContractWide { + contract_id: self.common.identity_token_info.data_contract.contract.id(), + }, ); ui.add_space(10.0); ui.separator(); diff --git a/src/ui/tokens/tokens_screen/token_creator.rs b/src/ui/tokens/tokens_screen/token_creator.rs index 433dd862c..2fd0ac89d 100644 --- a/src/ui/tokens/tokens_screen/token_creator.rs +++ b/src/ui/tokens/tokens_screen/token_creator.rs @@ -17,6 +17,7 @@ use crate::ui::ScreenType; use crate::app::{AppAction, BackendTasksExecutionMode}; use crate::backend_task::BackendTask; use crate::backend_task::tokens::TokenTask; +use crate::model::identity_key_usability::SigningScope; use crate::model::token::validate_contract_keyword; use crate::ui::components::styled::{StyledCheckbox}; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; @@ -235,6 +236,7 @@ impl TokensScreen { &mut self.selected_identity, &mut self.selected_key, TransactionType::RegisterContract, + SigningScope::NonBatch, ); let after_id = self .selected_identity diff --git a/src/ui/tokens/transfer_tokens_screen.rs b/src/ui/tokens/transfer_tokens_screen.rs index 1ab107ee3..2f007b139 100644 --- a/src/ui/tokens/transfer_tokens_screen.rs +++ b/src/ui/tokens/transfer_tokens_screen.rs @@ -4,6 +4,7 @@ use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::amount::Amount; use crate::model::fee_estimation::format_credits_as_dash; +use crate::model::identity_key_usability::{KeyRequirements, SigningScope}; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::user_role::UserRole; use crate::model::wallet::Wallet; @@ -95,14 +96,14 @@ impl TransferTokensScreen { let max_amount = Amount::from(&identity_token_balance); let selected_key: Option = identity.as_ref().and_then(|id| { - id.identity - .get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ) - .cloned() + id.signing_key_now(KeyRequirements::new( + Purpose::AUTHENTICATION, + &[SecurityLevel::CRITICAL], + SigningScope::ContractWide { + contract_id: identity_token_balance.data_contract_id, + }, + )) + .cloned() }); let selected_wallet = identity.as_ref().and_then(|id| { get_selected_wallet(id, None, selected_key.as_ref()) @@ -522,6 +523,9 @@ impl ScreenLike for TransferTokensScreen { identity, &mut self.selected_key, TransactionType::TokenTransfer, + SigningScope::ContractWide { + contract_id: self.identity_token_balance.data_contract_id, + }, ); ui.add_space(10.0); ui.separator(); diff --git a/src/ui/tokens/update_token_config.rs b/src/ui/tokens/update_token_config.rs index 54b2b6a7b..bce4db183 100644 --- a/src/ui/tokens/update_token_config.rs +++ b/src/ui/tokens/update_token_config.rs @@ -4,6 +4,7 @@ use crate::backend_task::tokens::TokenTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::fee_estimation::format_credits_as_dash; +use crate::model::identity_key_usability::{KeyRequirements, SigningScope}; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::token::token_change_item_is_supported; use crate::model::user_role::UserRole; @@ -26,6 +27,7 @@ use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; use crate::ui::tokens::validate_signing_key; use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dpp::data_contract::GroupContractPosition; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; use dash_sdk::dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; use dash_sdk::dpp::data_contract::associated_token::token_configuration_convention::TokenConfigurationConvention; @@ -82,13 +84,13 @@ impl UpdateTokenConfigScreen { pub fn new(identity_token_info: IdentityTokenInfo, app_context: &Arc) -> Self { let possible_key = identity_token_info .identity - .identity - .get_first_public_key_matching( + .signing_key_now(KeyRequirements::new( Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ) + &[SecurityLevel::CRITICAL], + SigningScope::ContractWide { + contract_id: identity_token_info.data_contract.contract.id(), + }, + )) .cloned(); // Initialize with no group - will be set when user selects a change item @@ -1013,6 +1015,9 @@ impl ScreenLike for UpdateTokenConfigScreen { &self.identity, &mut self.signing_key, TransactionType::TokenAction, + SigningScope::ContractWide { + contract_id: self.identity_token_info.data_contract.contract.id(), + }, ); ui.add_space(10.0); ui.separator(); diff --git a/tests/backend-e2e/measure_dpns_registration.rs b/tests/backend-e2e/measure_dpns_registration.rs index 6a944e268..e5155fca3 100644 --- a/tests/backend-e2e/measure_dpns_registration.rs +++ b/tests/backend-e2e/measure_dpns_registration.rs @@ -22,6 +22,7 @@ use crate::register_dpns::{ }; use dash_evo_tool::context::AppContext; use dash_evo_tool::model::fee_estimation::contest_fee_credits; +use dash_evo_tool::model::identity_key_usability::SigningScope; use dash_evo_tool::model::qualified_identity::QualifiedIdentity; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; @@ -165,7 +166,12 @@ async fn register_and_log( .document_type_for_name("preorder") .expect("the DPNS contract should define preorder"); let signing_key = identity - .document_signing_key(&preorder) + .document_signing_key( + SigningScope::ContractWide { + contract_id: dpns.id(), + }, + &preorder, + ) .cloned() .expect("the identity should hold a key that signs name documents"); From 74695e7d4f0eefc95126d220afe1a5a2f0b5c4fc Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:36:54 +0000 Subject: [PATCH 12/58] fix(errors): explain moderation, gas sponsorship and distribution limit refusals Protocol version 14 refusals that flows already in DET can hit get dedicated, actionable messages, each keeping the SDK error as its source: - an identity banned or suspended (until when) on a moderated contract; - a gas sponsor short of credits, a gas payer the document type does not offer, and mixed gas payers in one batch; - pre-programmed token distributions of one time over the limit. An overflowing shielded note total is logged at warn: it means corrupt or hostile sync data, not routine noise. Tests cover contracts saved at protocol 12 loading unchanged at 13 and 14, and contracts saved at 14 decoding at 13 and in full again at 14. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- src/backend_task/error.rs | 239 ++++++++++++++++++++++++++++- src/context/contract_token_db.rs | 154 +++++++++++++++++++ src/wallet_backend/event_bridge.rs | 4 +- 3 files changed, 395 insertions(+), 2 deletions(-) diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 42006aefc..86c4bd4e4 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -17,6 +17,7 @@ use dash_sdk::dpp::consensus::basic::basic_error::BasicError; use dash_sdk::dpp::consensus::state::state_error::StateError; use dash_sdk::dpp::dashcore; use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::Identifier; use platform_wallet::wallet::asset_lock::tracked::TrackedAssetLock; @@ -2498,6 +2499,66 @@ pub enum TaskError { source_error: Box, }, + // ────────────────────────────────────────────────────────────────────────── + // Document action fees, moderation and gas sponsorship (protocol version 14) + // ────────────────────────────────────────────────────────────────────────── + /// The identity is banned on the contract. + #[error( + "This identity is banned from contract {} and cannot act on its documents. Use a different identity for this contract.", + contract_id.to_string(Encoding::Base58) + )] + ContractUserBanned { + contract_id: Identifier, + identity_id: Identifier, + #[source] + source_error: Box, + }, + + /// The identity is suspended on the contract until a given time. + #[error( + "This identity is suspended from contract {} until {}. Wait until then, or use a different identity for this contract.", + contract_id.to_string(Encoding::Base58), + crate::model::datetime::local_date_time_from_unix_millis(*until_ms) + )] + ContractUserSuspended { + contract_id: Identifier, + identity_id: Identifier, + /// End of the suspension, in milliseconds since the Unix epoch. + until_ms: u64, + #[source] + source_error: Box, + }, + + /// The contract owner sponsoring the gas cannot cover it. + #[error( + "The contract owner pays the network fee for this action but does not have enough credits for it right now. Try again later." + )] + GasSponsorInsufficientBalance { + sponsor_id: Identifier, + balance: Credits, + required_balance: Credits, + #[source] + source_error: Box, + }, + + /// The transition asked for a gas payer the document type does not offer. + #[error( + "This document type does not let that party pay the network fee for this action. Pay the fee as the document type allows, then try again." + )] + GasFeesPaidByNotAllowed { + #[source] + source_error: Box, + }, + + /// The transitions of one batch named different gas payers. + #[error( + "The actions in this request name different payers for the network fee. Send them one at a time, then try again." + )] + InconsistentGasFeesPayerInBatch { + #[source] + source_error: Box, + }, + // ────────────────────────────────────────────────────────────────────────── // Username, vote and token transfer refusals (protocol versions 14 and 15) // ────────────────────────────────────────────────────────────────────────── @@ -2584,6 +2645,20 @@ pub enum TaskError { source_error: Box, }, + /// The pre-programmed distributions of one time add up to more than the + /// maximum. + #[error( + "The scheduled token distributions at {} add up to more than the allowed maximum. Lower those amounts, then try again.", + crate::model::datetime::local_date_time_from_unix_millis(*timestamp_ms) + )] + PreProgrammedDistributionOverLimit { + token_position: u16, + /// The distribution time, in milliseconds since the Unix epoch. + timestamp_ms: u64, + #[source] + source_error: Box, + }, + // ────────────────────────────────────────────────────────────────────────── // Token query errors // ────────────────────────────────────────────────────────────────────────── @@ -4360,6 +4435,52 @@ impl From for TaskError { source_error, })) } + ConsensusError::StateError(StateError::ContractUserBannedError(e)) => { + let (contract_id, identity_id) = (e.contract_id(), e.identity_id()); + Some(Box::new(move |source_error| { + TaskError::ContractUserBanned { + contract_id, + identity_id, + source_error, + } + })) + } + ConsensusError::StateError(StateError::ContractUserSuspendedError(e)) => { + let (contract_id, identity_id, until_ms) = + (e.contract_id(), e.identity_id(), e.until()); + Some(Box::new(move |source_error| { + TaskError::ContractUserSuspended { + contract_id, + identity_id, + until_ms, + source_error, + } + })) + } + ConsensusError::StateError(StateError::GasSponsorInsufficientBalanceError( + e, + )) => { + let (sponsor_id, balance, required_balance) = + (*e.sponsor_id(), e.balance(), e.required_balance()); + Some(Box::new(move |source_error| { + TaskError::GasSponsorInsufficientBalance { + sponsor_id, + balance, + required_balance, + source_error, + } + })) + } + ConsensusError::StateError(StateError::GasFeesPaidByNotAllowedError(_)) => { + Some(Box::new(|source_error| { + TaskError::GasFeesPaidByNotAllowed { source_error } + })) + } + ConsensusError::StateError( + StateError::InconsistentGasFeesPaidByInBatchError(_), + ) => Some(Box::new(|source_error| { + TaskError::InconsistentGasFeesPayerInBatch { source_error } + })), ConsensusError::StateError( StateError::DocumentContestMaximumContendersReachedError(e), ) => { @@ -4423,6 +4544,18 @@ impl From for TaskError { ) => Some(Box::new(|source_error| { TaskError::DocumentActionFeesWithoutModeration { source_error } })), + ConsensusError::BasicError( + BasicError::PreProgrammedDistributionAmountOverLimitError(e), + ) => { + let (token_position, timestamp_ms) = (e.token_position(), e.timestamp()); + Some(Box::new(move |source_error| { + TaskError::PreProgrammedDistributionOverLimit { + token_position, + timestamp_ms, + source_error, + } + })) + } _ => None, } }); @@ -6848,14 +6981,66 @@ mod tests { #[test] fn protocol_v14_consensus_errors_map_to_their_variants() { use dash_sdk::dpp::consensus::basic::contract_moderation::DocumentActionFeesWithoutModerationError; + use dash_sdk::dpp::consensus::basic::data_contract::PreProgrammedDistributionAmountOverLimitError; + use dash_sdk::dpp::consensus::state::contract_moderation::{ + ContractUserBannedError, ContractUserSuspendedError, + }; use dash_sdk::dpp::consensus::state::document::{ document_immutable_property_changed_error::DocumentImmutablePropertyChangedError, referenced_document_type_not_deletable_error::ReferencedDocumentTypeNotDeletableError, }; + use dash_sdk::dpp::consensus::state::identity::gas_sponsor_insufficient_balance_error::GasSponsorInsufficientBalanceError; + use dash_sdk::dpp::consensus::state::token::{ + GasFeesPaidByNotAllowedError, InconsistentGasFeesPaidByInBatchError, + }; + use dash_sdk::dpp::tokens::gas_fees_paid_by::GasFeesPaidBy; - let contract_id = Identifier::random(); + let (contract_id, identity_id) = (Identifier::random(), Identifier::random()); type Expectation = fn(&TaskError) -> bool; let cases: Vec<(ConsensusError, Expectation)> = vec![ + ( + ContractUserBannedError::new(contract_id, identity_id).into(), + |e| matches!(e, TaskError::ContractUserBanned { .. }), + ), + ( + ContractUserSuspendedError::new(contract_id, identity_id, 1_758_140_722_000).into(), + |e| { + matches!( + e, + TaskError::ContractUserSuspended { + until_ms: 1_758_140_722_000, + .. + } + ) + }, + ), + ( + GasSponsorInsufficientBalanceError::new(contract_id, 1, 2).into(), + |e| { + matches!( + e, + TaskError::GasSponsorInsufficientBalance { + balance: 1, + required_balance: 2, + .. + } + ) + }, + ), + ( + GasFeesPaidByNotAllowedError::new( + "note".to_string(), + "create".to_string(), + GasFeesPaidBy::ContractOwner, + GasFeesPaidBy::DocumentOwner, + ) + .into(), + |e| matches!(e, TaskError::GasFeesPaidByNotAllowed { .. }), + ), + ( + InconsistentGasFeesPaidByInBatchError::new(Some(identity_id), None).into(), + |e| matches!(e, TaskError::InconsistentGasFeesPayerInBatch { .. }), + ), ( DocumentImmutablePropertyChangedError::new( Identifier::random(), @@ -6878,6 +7063,18 @@ mod tests { DocumentActionFeesWithoutModerationError::new("note".to_string()).into(), |e| matches!(e, TaskError::DocumentActionFeesWithoutModeration { .. }), ), + ( + PreProgrammedDistributionAmountOverLimitError::new(1, 1_758_140_722_000).into(), + |e| { + matches!( + e, + TaskError::PreProgrammedDistributionOverLimit { + token_position: 1, + .. + } + ) + }, + ), ]; for (cause, expected) in cases { @@ -7048,6 +7245,46 @@ mod tests { } } + #[test] + fn contract_user_errors_name_the_contract_and_the_suspension_end() { + let contract_id = Identifier::random(); + let banned = TaskError::ContractUserBanned { + contract_id, + identity_id: Identifier::random(), + source_error: Box::new(SdkError::Generic("banned".to_string())), + }; + assert!( + banned + .to_string() + .contains(&contract_id.to_string(Encoding::Base58)) + ); + let suspended = TaskError::ContractUserSuspended { + contract_id, + identity_id: Identifier::random(), + until_ms: 1_758_140_722_000, + source_error: Box::new(SdkError::Generic("suspended".to_string())), + }; + // Dates in messages follow the model's formatter: the user's time zone, + // like every other date the app shows. + let until = crate::model::datetime::local_date_time_from_unix_millis(1_758_140_722_000); + assert!(!until.is_empty()); + assert!( + suspended.to_string().contains(&format!("until {until}.")), + "got: {suspended}" + ); + let over_limit = TaskError::PreProgrammedDistributionOverLimit { + token_position: 0, + timestamp_ms: 1_758_140_722_000, + source_error: Box::new(SdkError::Generic("over".to_string())), + }; + assert!( + over_limit + .to_string() + .contains(&format!("at {until} add up")), + "got: {over_limit}" + ); + } + /// An unclassified SDK failure behind a wallet-runtime token operation /// keeps the operation, so the message names what did not complete. #[test] diff --git a/src/context/contract_token_db.rs b/src/context/contract_token_db.rs index b700d67bf..23f6aaae9 100644 --- a/src/context/contract_token_db.rs +++ b/src/context/contract_token_db.rs @@ -854,6 +854,7 @@ where mod tests { use super::*; use crate::wallet_backend::kv_test_support::{InMemoryKv, StallingReadKv}; + use dash_sdk::dpp::version::PlatformVersion; use platform_wallet_storage::{KvError, KvStore, ObjectId}; use std::sync::{Arc, Mutex}; @@ -926,6 +927,159 @@ mod tests { assert_eq!(decoded, token); } + /// A user contract as DET builds one at `platform_version`: a document + /// type with a unique index (charging `action_fees`, if given), and a token. + fn user_contract_at( + platform_version: &PlatformVersion, + action_fees: Option, + ) -> DataContract { + use dash_sdk::dpp::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; + use dash_sdk::dpp::data_contract::config::DataContractConfig; + use dash_sdk::dpp::data_contract::document_type::DocumentType; + use dash_sdk::dpp::data_contract::v1::DataContractV1; + use dash_sdk::dpp::platform_value::platform_value; + use std::collections::BTreeMap; + + let id = ident(8); + let config = DataContractConfig::default_for_version(platform_version).expect("config"); + let mut schema = platform_value!({ + "type": "object", + "properties": {"name": {"type": "string", "position": 0, "maxLength": 63_u32}}, + "required": ["name"], + "indices": [{"name": "byName", "properties": [{"name": "asc"}], "unique": true}], + "additionalProperties": false, + }); + if let Some(action_fees) = action_fees { + schema + .insert("actionFees".to_string(), action_fees) + .expect("a map schema"); + } + let note = DocumentType::try_from_schema( + id, + 1, + config.version(), + "note", + schema, + None, + &BTreeMap::new(), + &config, + true, + &mut Vec::new(), + platform_version, + ) + .expect("document type"); + DataContract::V1(DataContractV1 { + id, + version: 1, + owner_id: ident(7), + document_types: BTreeMap::from([("note".to_string(), note)]), + config, + schema_defs: None, + groups: BTreeMap::new(), + tokens: BTreeMap::from([( + 0, + TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive()), + )]), + keywords: vec![], + created_at: None, + updated_at: None, + created_at_block_height: None, + updated_at_block_height: None, + created_at_epoch: None, + updated_at_epoch: None, + description: None, + }) + } + + /// The contracts a user may hold locally, encoded as DET encodes them at + /// `protocol_version`. + fn stored_contracts_at(protocol_version: u32) -> Vec<(DataContract, StoredContract)> { + use dash_sdk::dpp::system_data_contracts::{SystemDataContract, load_system_data_contract}; + + let pv = PlatformVersion::get(protocol_version).expect("known version"); + [ + user_contract_at(pv, None), + load_system_data_contract(SystemDataContract::DPNS, pv).expect("dpns"), + load_system_data_contract(SystemDataContract::Dashpay, pv).expect("dashpay"), + load_system_data_contract(SystemDataContract::TokenHistory, pv).expect("history"), + ] + .into_iter() + .map(|contract| { + let stored = StoredContract { + contract_bytes: contract + .serialize_to_bytes_with_platform_version(pv) + .expect("encode"), + alias: Some("saved".to_string()), + }; + (contract, stored) + }) + .collect() + } + + /// Contracts saved while DET built at protocol 12 still load, unchanged, + /// once DET builds at the network's 13 or 14. + #[test] + fn contracts_stored_at_protocol_12_decode_at_13_and_14() { + let v12 = PlatformVersion::get(12).expect("v12"); + let tmp = tempfile::tempdir().expect("tempdir"); + let ctx = crate::context::test_support::test_app_context(tmp.path()); + for version in [13, 14] { + crate::context::test_support::set_sdk_protocol_version(&ctx, version); + for (original, stored) in stored_contracts_at(12) { + let bytes = stored.contract_bytes.clone(); + let decoded = ctx + .decode_stored_contract(stored) + .unwrap_or_else(|e| panic!("protocol {version}: {e:?}")); + assert_eq!(decoded.alias.as_deref(), Some("saved")); + assert_eq!(decoded.contract.id(), original.id()); + assert_eq!( + decoded + .contract + .serialize_to_bytes_with_platform_version(v12) + .expect("re-encode"), + bytes, + "protocol {version}: contract {} lost data", + original.id() + ); + } + } + } + + /// A contract saved at protocol 14 and read back while DET still builds at + /// 13 (after a restart, before the first proven response) decodes without + /// error. Protocol 14 features (here `actionFees`) are not parsed at 13, + /// but the saved bytes are untouched and decode in full once DET builds at + /// 14 again. + #[test] + fn contracts_stored_at_protocol_14_decode_at_13_and_recover_at_14() { + use dash_sdk::dpp::platform_value::platform_value; + + let tmp = tempfile::tempdir().expect("tempdir"); + let ctx = crate::context::test_support::test_app_context(tmp.path()); + crate::context::test_support::set_sdk_protocol_version(&ctx, 13); + for (original, stored) in stored_contracts_at(14) { + let decoded = ctx + .decode_stored_contract(stored) + .unwrap_or_else(|e| panic!("contract {}: {e:?}", original.id())); + assert_eq!(decoded.contract, original); + } + + let v14 = PlatformVersion::get(14).expect("v14"); + let charging = user_contract_at(v14, Some(platform_value!({"create": {"owner": 5_u64}}))); + let stored = || StoredContract { + contract_bytes: charging + .serialize_to_bytes_with_platform_version(v14) + .expect("encode"), + alias: None, + }; + let at_13 = ctx.decode_stored_contract(stored()).expect("decodes at 13"); + assert_eq!(at_13.contract.id(), charging.id()); + + crate::context::test_support::set_sdk_protocol_version(&ctx, 14); + let at_14 = ctx.decode_stored_contract(stored()).expect("decodes at 14"); + assert_eq!(at_14.contract, charging); + } + #[test] fn contract_keys_carry_a_base58_suffix_that_round_trips_to_the_id() { let contract = ident(6); diff --git a/src/wallet_backend/event_bridge.rs b/src/wallet_backend/event_bridge.rs index f1ccb4e0e..29b81fbed 100644 --- a/src/wallet_backend/event_bridge.rs +++ b/src/wallet_backend/event_bridge.rs @@ -584,7 +584,9 @@ fn summary_ok_balances(summary: &ShieldedSyncPassSummary) -> Vec<([u8; 32], u64) WalletShieldedOutcome::Ok(sync) => match sync.balance_total() { Ok(balance) => Some((*wallet_id, balance)), Err(error) => { - tracing::debug!( + // An overflowing note total means corrupt or hostile sync + // data, not routine noise: surface it at warn. + tracing::warn!( ?wallet_id, ?error, "Shielded sync balance total is invalid; preserving the cached balance" From 53d3fb22e44bc3a829586fe4c48970a6513f2676 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:42:24 +0000 Subject: [PATCH 13/58] fix(context): keep one system contract set per protocol version A lookup at another version, as the proof verifier makes at a response's version, swapped out the app's set, so each such lookup reloaded the system contracts and the next app read reloaded them again. Each version now keeps its own set; the app's reads keep the set they last got for a failed reload. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- .../README.md | 7 +- src/context/system_contracts.rs | 72 ++++++++++++++----- 2 files changed, 58 insertions(+), 21 deletions(-) diff --git a/docs/ai-design/2026-10-24-platform-v5.1-dev-bump/README.md b/docs/ai-design/2026-10-24-platform-v5.1-dev-bump/README.md index fe5b8bb3a..50e72fb63 100644 --- a/docs/ai-design/2026-10-24-platform-v5.1-dev-bump/README.md +++ b/docs/ai-design/2026-10-24-platform-v5.1-dev-bump/README.md @@ -31,9 +31,10 @@ from 13 to 14: `SystemContractsCache` serves the system contracts (DPNS, DashPay, withdrawals, token history, keyword search) at the SDK's version and reloads them when it changes. The context provider serves them at the version the proof verifier -asks for (`verifier_version`), which can be newer than the SDK's. `try_get` -fails rather than serving another version's contracts, so a proof is never -checked against the wrong schema. +passes in (`AppContext::system_contracts_at`), which can be newer than the +SDK's. Each version keeps its own set, so the two lookups do not evict each +other. `try_get` fails rather than serving another version's contracts, so a +proof is never checked against the wrong schema. ## Connection state diff --git a/src/context/system_contracts.rs b/src/context/system_contracts.rs index 7a6db8a37..522682fc9 100644 --- a/src/context/system_contracts.rs +++ b/src/context/system_contracts.rs @@ -1,7 +1,8 @@ //! The system data contracts, as defined at the protocol version in use. -use std::sync::Arc; +use std::collections::BTreeMap; use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, PoisonError, RwLock}; use arc_swap::ArcSwap; use dash_sdk::dpp::ProtocolError; @@ -39,13 +40,17 @@ impl SystemContracts { } } -/// [`SystemContracts`] that follow the protocol version in use. +/// [`SystemContracts`], one set per protocol version. /// -/// Lock-free: the set lives in an [`ArcSwap`]. Two callers reloading at once -/// store equivalent sets; a set stored late for an older version is replaced -/// on the next access, so the cache converges on the current version. +/// The proof verifier asks for the set at the version a response reports, the +/// app's own reads at the version the SDK runs; each version keeps its own +/// set, so neither lookup evicts the other's. The map holds at most one set +/// per protocol version table this build defines. Its lock is held only to +/// read or insert, never across a load. #[derive(Debug)] pub(crate) struct SystemContractsCache { + sets: RwLock>>, + /// The set the app's own reads last got, served when a reload fails. current: ArcSwap, /// The last version whose reload failed, so the failure is logged once. failed_version: AtomicU32, @@ -53,35 +58,51 @@ pub(crate) struct SystemContractsCache { impl SystemContractsCache { pub(crate) fn new(contracts: SystemContracts) -> Self { + let contracts = Arc::new(contracts); Self { - current: ArcSwap::from_pointee(contracts), + sets: RwLock::new(BTreeMap::from([( + contracts.protocol_version, + Arc::clone(&contracts), + )])), + current: ArcSwap::new(contracts), failed_version: AtomicU32::new(0), } } - /// The set for exactly `platform_version`, reloaded if the cached one was - /// built for another version. A failed reload is an error, never the set - /// of another version: what verifies a proof must not mix schemas. + /// The set for exactly `platform_version`, loaded on first use. A failed + /// load is an error, never the set of another version: what verifies a + /// proof must not mix schemas. pub(crate) fn try_get( &self, platform_version: &PlatformVersion, ) -> Result, Box> { - let cached = self.current.load_full(); - if cached.protocol_version == platform_version.protocol_version { - return Ok(cached); + let version = platform_version.protocol_version; + if let Some(cached) = self + .sets + .read() + .unwrap_or_else(PoisonError::into_inner) + .get(&version) + { + return Ok(Arc::clone(cached)); } let fresh = Arc::new(SystemContracts::load(platform_version)?); - self.current.store(Arc::clone(&fresh)); - Ok(fresh) + // Two callers loading at once keep the first set inserted. + let mut sets = self.sets.write().unwrap_or_else(PoisonError::into_inner); + Ok(Arc::clone(sets.entry(version).or_insert(fresh))) } /// The set for `platform_version`, for the app's own reads (names, - /// documents, forms). A failed reload keeps the cached set, so the app - /// keeps working on its last loaded contracts; proof verification uses - /// [`Self::try_get`] instead. + /// documents, forms). A failed load keeps the set these reads last got, + /// so the app keeps working on its last loaded contracts; proof + /// verification uses [`Self::try_get`] instead. pub(crate) fn get(&self, platform_version: &PlatformVersion) -> Arc { match self.try_get(platform_version) { - Ok(contracts) => contracts, + Ok(contracts) => { + if !Arc::ptr_eq(&self.current.load(), &contracts) { + self.current.store(Arc::clone(&contracts)); + } + contracts + } Err(error) => { let cached = self.current.load_full(); let version = platform_version.protocol_version; @@ -146,6 +167,21 @@ mod tests { assert!(Arc::ptr_eq(&cache.get(v13), &cache.try_get(v13).unwrap())); } + /// Regression: a lookup at another version (the proof verifier follows a + /// response's version) swapped out the app's set, so every such lookup + /// reloaded the contracts twice. Each version keeps its own set. + #[test] + fn a_lookup_at_another_version_keeps_the_current_set() { + let v13 = version(13); + let v14 = version(14); + let cache = SystemContractsCache::new(SystemContracts::load(v13).unwrap()); + let current = cache.get(v13); + + let other = cache.try_get(v14).unwrap(); + assert!(Arc::ptr_eq(¤t, &cache.get(v13))); + assert!(Arc::ptr_eq(&other, &cache.try_get(v14).unwrap())); + } + /// The same version serves the cached set itself, not a reload. #[test] fn the_same_protocol_version_keeps_the_cached_set() { From e698c7ac14856680ebd50b05e2941ad1b9562523 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:42:24 +0000 Subject: [PATCH 14/58] fix(platform): confirm only a verified protocol version without the epoch When the epoch cannot be read, the proved DPNS fallback confirmed the SDK's version, which starts at the network's upstream minimum (14 on devnet), as the network's. It now confirms the version of the responses the SDK verified, and nothing before any. Restores the completed-withdrawal status list with the reason sending FAILED to a protocol 13 network is safe, and its test, now comparing the protocol 13 schema with the latest one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- src/backend_task/platform_info.rs | 119 ++++++++++++++++++++++++++---- 1 file changed, 103 insertions(+), 16 deletions(-) diff --git a/src/backend_task/platform_info.rs b/src/backend_task/platform_info.rs index 16ad8cc3a..1aa2ad953 100644 --- a/src/backend_task/platform_info.rs +++ b/src/backend_task/platform_info.rs @@ -644,15 +644,14 @@ impl AppContext { "Current epoch fetch failed; keeping the last known fee multiplier" ); // Without the epoch, the network's version is learned from - // the ratchet a proved DPNS fetch drives. Only a successful - // fetch proves it came from the network, not the local seed. + // a proved DPNS fetch: the version of the responses the SDK + // verified, never its local seed. let (generation, on_current_sdk) = self.connection_snapshot_for(sdk); match DataContract::fetch(sdk, self.dpns_contract().id()).await { Ok(_) if on_current_sdk => { - self.raise_platform_protocol_version_on( - generation, - sdk.protocol_version_number(), - ); + if let Some(version) = network_proved_protocol_version(sdk) { + self.raise_platform_protocol_version_on(generation, version); + } } Ok(_) => {} Err(error) => tracing::warn!( @@ -789,11 +788,7 @@ impl AppContext { where_clauses: vec![WhereClause { field: "status".to_string(), operator: WhereOperator::In, - value: Value::Array(vec![ - Value::U8(WithdrawalStatus::COMPLETE as u8), - Value::U8(WithdrawalStatus::EXPIRED as u8), - Value::U8(WithdrawalStatus::FAILED as u8), - ]), + value: Value::Array(completed_withdrawal_statuses()), }], time_range_clauses: Vec::new(), group_by: Vec::new(), @@ -878,11 +873,7 @@ impl AppContext { let start = start_after.map(|id| Start::StartAfter(id.to_buffer().to_vec())); let statuses = if completed { - vec![ - Value::U8(WithdrawalStatus::COMPLETE as u8), - Value::U8(WithdrawalStatus::EXPIRED as u8), - Value::U8(WithdrawalStatus::FAILED as u8), - ] + completed_withdrawal_statuses() } else { vec![ Value::U8(WithdrawalStatus::QUEUED as u8), @@ -989,10 +980,106 @@ impl AppContext { } } +/// The `status` values a finished withdrawal can carry, as the completed +/// withdrawal queries send them. +/// +/// `FAILED` (5) exists only in the v2 withdrawals schema (protocol 14); the v1 +/// schema of a protocol 13 network stops at `EXPIRED` (4). Sending it at +/// protocol 13 is still correct and deliberate: a JSON-schema `enum` constrains +/// documents being created, not query values. Nothing in the query path checks +/// membership — the `status` property is typed as a plain integer, and +/// `DocumentPropertyType::encode_value_for_tree_keys` encodes any value that +/// converts to it. So the out-of-enum member encodes, matches nothing on a +/// protocol 13 network (which cannot produce a failed withdrawal), and leaves +/// `COMPLETE` and `EXPIRED` matching as usual, while the same query stays +/// complete on protocol 14. +fn completed_withdrawal_statuses() -> Vec { + vec![ + Value::U8(WithdrawalStatus::COMPLETE as u8), + Value::U8(WithdrawalStatus::EXPIRED as u8), + Value::U8(WithdrawalStatus::FAILED as u8), + ] +} + +/// The protocol version `sdk` has seen in a response whose proof and quorum +/// signature were verified, or `None` before any such response. Never the +/// SDK's local seed: the SDK's own version starts at the network's upstream +/// minimum and only rises, so a successful read does not prove it. +fn network_proved_protocol_version(sdk: &Sdk) -> Option { + match sdk.query_settings().verified_protocol_version { + 0 => None, + version => Some(version), + } +} + #[cfg(test)] mod tests { use super::*; + /// The completed-withdrawal filter keeps `FAILED` even though the protocol + /// 13 schema stops at `EXPIRED`: a schema `enum` constrains documents, not + /// query values, and every status encodes into the `status` property's key + /// representation at both schema versions. + #[test] + fn completed_withdrawal_filter_encodes_at_both_schema_versions() { + use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; + use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; + use dash_sdk::dpp::system_data_contracts::{SystemDataContract, load_system_data_contract}; + use dash_sdk::dpp::version::v13::PLATFORM_V13; + + assert_eq!( + completed_withdrawal_statuses(), + vec![Value::U8(3), Value::U8(4), Value::U8(5)], + "COMPLETE, EXPIRED and FAILED, in that order" + ); + + let status_property = |version| { + let contract = load_system_data_contract(SystemDataContract::Withdrawals, version) + .expect("withdrawals contract"); + contract + .document_type_cloned_for_name("withdrawal") + .expect("withdrawal document type") + .properties() + .get("status") + .expect("status property") + .clone() + }; + + // v1 (protocol 13) and v2 (protocol 14) differ only in the enum's last + // member, so the property type is identical. + let v13 = status_property(&PLATFORM_V13); + let latest = status_property(PlatformVersion::latest()); + assert_eq!( + v13.property_type, latest.property_type, + "the status property type must not differ across schema versions" + ); + + // FAILED is outside the v1 enum, yet encodes for the query exactly like + // the statuses that are inside it. + for status in completed_withdrawal_statuses() { + assert!( + v13.property_type + .encode_value_for_tree_keys(&status) + .is_ok(), + "status {status:?} must encode against the protocol 13 schema" + ); + } + } + + /// Regression: the DPNS fallback confirmed the SDK's version, which starts + /// at the local seed (14 on devnet), as the network's proved version. A + /// fresh SDK has verified nothing, so nothing is proved. + #[test] + fn a_fresh_sdk_proves_no_protocol_version() { + let sdk = Sdk::new_mock(); + assert_ne!( + sdk.protocol_version_number(), + 0, + "precondition: a seeded SDK" + ); + assert_eq!(network_proved_protocol_version(&sdk), None); + } + /// The limit is whatever the network's own protocol version says it is, /// never a version fixed in this build: protocol 13 reports a flat 2000 /// Dash whatever the total, protocol 14 derives it from that total (15% of From 4abf4d5bbbaf40057a6ab4cc9e4cb52c4fa905e5 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:42:24 +0000 Subject: [PATCH 15/58] fix(wallet): erase the secp256k1 signing key after use The derived key DET signs with is copied into secp256k1's own type, which keeps its bytes after use. Wrap it so it is erased on drop, as the derived key it comes from would be; signatures stay byte-identical. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- src/wallet_backend/det_signer.rs | 35 ++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/src/wallet_backend/det_signer.rs b/src/wallet_backend/det_signer.rs index d0cba01ba..8ce7096b0 100644 --- a/src/wallet_backend/det_signer.rs +++ b/src/wallet_backend/det_signer.rs @@ -128,9 +128,9 @@ impl<'a> DetSigner<'a> { /// Signing stays on the secp256k1 primitive (plain RFC 6979 nonce), so /// signatures are byte-identical to earlier builds; the newer /// `EcdsaSecretKey::sign` grinds for a low R and would change them. - fn derive_secret(&self, path: &DerivationPath) -> Result { + fn derive_secret(&self, path: &DerivationPath) -> Result { let scalar = self.derive_xprv(path)?.private_key.to_bytes(); - secp256k1::SecretKey::from_secret_bytes(*scalar).map_err(DetSignerError::Sign) + ErasedSecretKey::from_scalar(&scalar) } /// Sign `msg` (a 32-byte digest) with the held **single key** directly, @@ -143,8 +143,7 @@ impl<'a> DetSigner<'a> { ) -> Result { match &self.secret { HeldSecret::SingleKey(key) => { - let sk = secp256k1::SecretKey::from_secret_bytes(***key) - .map_err(DetSignerError::Sign)?; + let sk = ErasedSecretKey::from_scalar(key)?; let message = message_from_digest(*msg)?; Ok(dash_sdk::dpp::secp256k1::ecdsa::sign(message, &sk)) } @@ -153,6 +152,34 @@ impl<'a> DetSigner<'a> { } } +/// A secp256k1 secret key erased when dropped. secp256k1's own type keeps its +/// bytes after use, while the derived key it is built from would have wiped +/// them; this keeps the signing primitive (byte-identical signatures) without +/// leaving the key behind in freed memory. +struct ErasedSecretKey(secp256k1::SecretKey); + +impl ErasedSecretKey { + fn from_scalar(scalar: &[u8; 32]) -> Result { + secp256k1::SecretKey::from_secret_bytes(*scalar) + .map(Self) + .map_err(DetSignerError::Sign) + } +} + +impl std::ops::Deref for ErasedSecretKey { + type Target = secp256k1::SecretKey; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Drop for ErasedSecretKey { + fn drop(&mut self) { + self.0.non_secure_erase(); + } +} + impl std::fmt::Debug for DetSigner<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let kind = match self.secret { From 55367457c53798a4f275d6669992546fe158e58c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:42:24 +0000 Subject: [PATCH 16/58] test(protocol): guard a contested name built with the contest fund at protocol 13 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- src/backend_task/protocol_13_transitions.rs | 72 +++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/src/backend_task/protocol_13_transitions.rs b/src/backend_task/protocol_13_transitions.rs index 5811df3f0..ffa25e3f4 100644 --- a/src/backend_task/protocol_13_transitions.rs +++ b/src/backend_task/protocol_13_transitions.rs @@ -234,3 +234,75 @@ async fn a_document_is_created_in_the_protocol_13_encoding() { "protocol 13 cannot decode {base:?}" ); } + +/// A contested username is created on a protocol 13 network the way +/// `BroadcastDocument` creates it, with the contest fund DET states: in the +/// protocol 13 document encoding, and stating exactly the protocol's base +/// fund. +#[tokio::test] +async fn a_contested_name_is_created_in_the_protocol_13_encoding_with_the_base_fund() { + use crate::model::fee_estimation::{contest_fee_credits, document_create_contest_fund_cap}; + use dash_sdk::dpp::state_transition::batch_transition::batched_transition::document_create_transition::v0::v0_methods::DocumentCreateTransitionV0Methods; + use dash_sdk::dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransition; + use dash_sdk::dpp::system_data_contracts::{SystemDataContract, load_system_data_contract}; + + let (_tmp, _ctx, platform_version) = mainnet_context(); + let contract = load_system_data_contract(SystemDataContract::DPNS, platform_version) + .expect("DPNS contract"); + let domain = contract + .document_type_for_name("domain") + .expect("domain type"); + let owner_id = Identifier::from([4; 32]); + let document = crate::context::test_support::dpns_name_document( + owner_id, + "alice", + dash_sdk::dpp::platform_value::Value::Identifier(owner_id.to_buffer()), + ); + let fund = document_create_contest_fund_cap(domain, &document, platform_version) + .expect("contest check") + .expect("a contested name"); + assert_eq!(fund, contest_fee_credits(platform_version)); + let options = StateTransitionCreationOptions { + contest_fund: Some(fund), + ..Default::default() + }; + + let transition = BatchTransition::new_document_creation_transition_from_document( + document, + domain, + [3; 32], + &plain_key(1, SecurityLevel::HIGH), + 1, + UserFeeIncrease::default(), + None, + &PlaceholderSigner, + platform_version, + Some(options), + ) + .await + .expect("contested name create"); + + let StateTransition::Batch(batch) = transition else { + panic!("expected a batch, got {transition:?}"); + }; + let document_transition = match &batch { + BatchTransition::V0(v0) => v0.transitions[0].clone(), + BatchTransition::V1(v1) => match &v1.transitions[0] { + BatchedTransition::Document(document) => document.clone(), + other => panic!("expected a document transition, got {other:?}"), + }, + }; + assert!( + !matches!(document_transition.base(), DocumentBaseTransition::V2(_)), + "protocol 13 cannot decode {:?}", + document_transition.base() + ); + let DocumentTransition::Create(create) = document_transition else { + panic!("expected a create, got {document_transition:?}"); + }; + let stated = create + .prefunded_voting_balance() + .as_ref() + .map(|(_, credits)| *credits); + assert_eq!(stated, Some(contest_fee_credits(platform_version))); +} From 93cea96772ffd844c6a7bbfe026104af7486fbbc Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:42:24 +0000 Subject: [PATCH 17/58] fix(ui): say plainly what the app cannot edit or sign yet The new refusals sent users to another document editor or token client. They now say the app cannot edit this field type, sign this action or change this token setting yet, in one wording, and the shielded setting refusal sits with the token contract errors. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- src/backend_task/error.rs | 9 ++++--- .../document_action_screen.rs | 26 +++++++++++-------- .../group_actions_screen.rs | 2 +- src/ui/tokens/update_token_config.rs | 2 +- 4 files changed, 23 insertions(+), 16 deletions(-) diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 86c4bd4e4..6d0d4c0fa 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -2147,6 +2147,12 @@ pub enum TaskError { // ────────────────────────────────────────────────────────────────────────── // Token contract errors // ────────────────────────────────────────────────────────────────────────── + /// A token configuration change the app cannot make yet (the shielded + /// pool settings), refused by `validate_supported_change` in + /// `backend_task/tokens/update_token_config.rs` before anything is signed. + #[error("The app cannot change this token setting yet. Choose another setting to update.")] + TokenShieldedConfigurationUnsupported, + /// A token at the expected position was not found in the contract. #[error( "Token at position {position} was not found in the contract. Please reload the contract and retry." @@ -2662,9 +2668,6 @@ pub enum TaskError { // ────────────────────────────────────────────────────────────────────────── // Token query errors // ────────────────────────────────────────────────────────────────────────── - #[error("Shielded token settings cannot be changed here yet. Use a supported token setting instead.")] - TokenShieldedConfigurationUnsupported, - /// Querying token data from the platform failed. #[error("Could not retrieve token information from the platform. Please retry.")] TokenQueryError { detail: String }, diff --git a/src/ui/contracts_documents/document_action_screen.rs b/src/ui/contracts_documents/document_action_screen.rs index 56db4e99a..76b56bf95 100644 --- a/src/ui/contracts_documents/document_action_screen.rs +++ b/src/ui/contracts_documents/document_action_screen.rs @@ -85,6 +85,12 @@ pub enum BroadcastStatus { Broadcasted, } +/// Shown in place of a field whose type the document editor cannot edit yet. +const UNEDITABLE_FIELD_NOTICE: &str = "The app cannot edit this field type yet."; + +/// Why a document with a field type the editor cannot edit is not sent. +const UNEDITABLE_DOCUMENT_MESSAGE: &str = "This document has a field type the app cannot edit yet, so it cannot be created or changed here."; + pub struct DocumentActionScreen { pub app_context: Arc, pub action_type: DocumentActionType, @@ -806,9 +812,7 @@ impl DocumentActionScreen { ); } DocumentPropertyType::TypedArray(_) => { - ui.label( - "This field cannot be edited here. Use another document editor.", - ); + ui.label(UNEDITABLE_FIELD_NOTICE); } DocumentPropertyType::Object(_) | DocumentPropertyType::Array(_) @@ -1262,7 +1266,7 @@ impl DocumentActionScreen { .ok_or("No identity selected")?; if !Self::supports_field_editor(doc_type) { - return Err("This document contains fields that cannot be edited here. Use another document editor.".to_string()); + return Err(UNEDITABLE_DOCUMENT_MESSAGE.to_string()); } for (prop, schema) in doc_type.properties() { @@ -1383,9 +1387,9 @@ impl DocumentActionScreen { Value::U64(ts) } DocumentPropertyType::TypedArray(_) => { - return Err(format!( - "Field {name} cannot be edited here. Use another document editor." - )); + // Unreachable while `supports_field_editor` refuses the + // document first; kept so a typed array is never encoded. + return Err(UNEDITABLE_DOCUMENT_MESSAGE.to_string()); } DocumentPropertyType::Object(_) => { return Err(format!( @@ -1457,7 +1461,7 @@ impl DocumentActionScreen { .ok_or("No document-type selected")?; if !Self::supports_field_editor(doc_type) { - return Err("This document contains fields that cannot be edited here. Use another document editor.".to_string()); + return Err(UNEDITABLE_DOCUMENT_MESSAGE.to_string()); } for (prop, schema) in doc_type.properties() { @@ -1578,9 +1582,9 @@ impl DocumentActionScreen { Value::U64(ts) } DocumentPropertyType::TypedArray(_) => { - return Err(format!( - "Field {name} cannot be edited here. Use another document editor." - )); + // Unreachable while `supports_field_editor` refuses the + // document first; kept so a typed array is never encoded. + return Err(UNEDITABLE_DOCUMENT_MESSAGE.to_string()); } DocumentPropertyType::Object(_) => { return Err(format!( diff --git a/src/ui/contracts_documents/group_actions_screen.rs b/src/ui/contracts_documents/group_actions_screen.rs index 58cb3b087..a0a6e650b 100644 --- a/src/ui/contracts_documents/group_actions_screen.rs +++ b/src/ui/contracts_documents/group_actions_screen.rs @@ -236,7 +236,7 @@ impl GroupActionsScreen { row.col(|ui| { if !Self::supports_token_event_action(token_event) { ui.add_enabled(false, egui::Button::new("Take Action")) - .on_hover_text("This action cannot be signed here. Use another token client."); + .on_hover_text("The app cannot sign this action yet."); return; } if ComponentStyles::add_primary_button( diff --git a/src/ui/tokens/update_token_config.rs b/src/ui/tokens/update_token_config.rs index 54b2b6a7b..1685218bf 100644 --- a/src/ui/tokens/update_token_config.rs +++ b/src/ui/tokens/update_token_config.rs @@ -608,7 +608,7 @@ impl UpdateTokenConfigScreen { TokenConfigurationChangeItem::MinimumPoolNotesForOutgoing(_) | TokenConfigurationChangeItem::MinimumPoolNotesForOutgoingControlGroup(_) | TokenConfigurationChangeItem::MinimumPoolNotesForOutgoingAdminGroup(_) => { - ui.label("This setting cannot be changed here. Use another token configuration editor."); + ui.label("The app cannot change this token setting yet. Choose another setting to update."); } } }); From 16c1c7fc5d599173c06dc9344ee8c7a59bb67c4f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:42:24 +0000 Subject: [PATCH 18/58] chore: keep AGENTS.md and the marketplace comment as on v1.0-dev Also corrects the platform compatibility fixtures README: no code reads e3cd7cf.sql any more; it is the V001-V018 checksum reference. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- AGENTS.md | 2 +- src/backend_task/tokens/mod.rs | 4 +--- .../platform_compatibility/fixtures/README.md | 11 +++++++---- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 207af94d8..07f0babe7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,7 +128,7 @@ User-facing error messages (shown in `MessageBanner` via `Display`) must follow 2. **Structure**: *What happened* + *what to do*. Every message must include a concrete action the user can take themselves: retry, wait, try a different approach. Never redirect to "contact support" — users must be able to self-resolve. 3. **Tone**: Calm, direct, brief. Not apologetic ("Sorry!"), not alarming ("Something went wrong!"), not vague ("An error occurred"). 4. **Technical details**: Never in the message itself — no raw error strings, stack traces, SDK internals, or error codes. Attach via `BannerHandle::with_details(e)` — the `Debug` repr goes to the collapsible details panel and logs. Never refer users to "details" or "details panel" — these are not visible in basic mode. Exception: Base58 identifiers (see rule 6) are not technical details — they are user-meaningful handles. -5. **Reference implementation**: `impl From for TaskError` in `src/backend_task/error.rs` demonstrates the pattern for SDK errors. New `TaskError` variants should follow the same style. +5. **Reference implementation**: `sdk_error_user_message()` in `src/backend_task/error.rs` demonstrates the pattern for SDK errors. New `TaskError` variants should follow the same style. 6. **Base58 IDs are allowed in messages**: Contract IDs, identity IDs, document IDs, and similar Base58-encoded identifiers may appear in user-facing messages when they help the user identify which object is involved (e.g., *"This key conflicts with an existing key bound to contract `Abc123…`."*). They are not jargon — they are opaque-but-copyable handles the user can look up. 7. **Use dedicated `TaskError` variants**: Every error should get a dedicated `TaskError` variant with a `#[source]` field that preserves the error chain, enables structural matching, and keeps `Display` / `Debug` separation explicit. For `#[source]` fields in SDK-originated error variants, use `Box` — convert upstream types (e.g. `ProtocolError`) via `SdkError::Protocol(e)`. Use the concrete domain type directly for non-SDK errors (e.g. `rusqlite::Error`). Omit `#[source]` entirely when the upstream error carries no useful diagnostic information (e.g. a channel `SendError`). **Never store user-facing strings in error variants** — error variants must not contain `String` fields that hold messages for the user. The `#[error("...")]` attribute on the variant provides the user-facing message; `String` fields (regardless of name) break this separation. Instead, create a dedicated variant with typed `#[source]` fields, or a fieldless variant if no upstream error exists. When encountering existing variants that store stringified errors, replace them with properly typed variants as part of the change. diff --git a/src/backend_task/tokens/mod.rs b/src/backend_task/tokens/mod.rs index e7f66aee8..9d3b3304c 100644 --- a/src/backend_task/tokens/mod.rs +++ b/src/backend_task/tokens/mod.rs @@ -761,9 +761,7 @@ impl AppContext { token_config_v0.distribution_rules = distribution_rules; token_config_v0.description = token_description; - // Tokens are created NotTradeable. Tradeable needs protocol version 15 - // (TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION) and a transferable token, - // and DET has no way to list a token or make an offer for it yet. + // All tokens are created NotTradeable; a future SDK will add more modes. token_config_v0.marketplace_rules = TokenMarketplaceRules::V0(TokenMarketplaceRulesV0 { trade_mode: TokenTradeMode::NotTradeable, trade_mode_change_rules: marketplace_rules, diff --git a/src/wallet_backend/platform_compatibility/fixtures/README.md b/src/wallet_backend/platform_compatibility/fixtures/README.md index a0f8ae0d4..3def487aa 100644 --- a/src/wallet_backend/platform_compatibility/fixtures/README.md +++ b/src/wallet_backend/platform_compatibility/fixtures/README.md @@ -14,10 +14,13 @@ stored in table/index/trigger order. Refinery history uses its SipHasher13 hash over migration name, i32 version and rendered SQL. Timestamps and the store-generation token are deterministic fixture values. -The production bridge compares these exact histories and materialized -schemas before translating data. The destination itself is created by the -current public `SqlitePersister::open`, not by the fixture SQL. Updating -either schema revision requires reviewing and regenerating these guards. +The production bridge compares the source history and schema in +`67d4ef3.sql`, and the destination guard `033ecae.sql` described below, +before translating data. No code reads `e3cd7cf.sql` any more: it is kept +as the reference for the V001–V018 migration names and checksums. The +destination itself is created by the current public `SqlitePersister::open`, +not by the fixture SQL. Updating either schema revision requires reviewing +and regenerating these guards. The current destination guard is `033ecae.sql`, captured from an empty database opened by `SqlitePersister::open` at Platform revision `033ecaee2f3badc8e04ff8be664062f33516e3e4`. The V001–V018 migration names and From 04a848d26cbdb491937e7cf45213f69ca8f61ebe Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:56:43 +0000 Subject: [PATCH 19/58] fix(identity): unlock the wallet of the key chosen for a username The username screen offered the unlock, and gated paying, on the wallet of the automatically picked key, although a key chosen under Advanced signs the registration. The wallet now follows the key the registration is signed with, and is picked again when that key changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- src/ui/identity/register_dpns_name_screen.rs | 77 +++++++++++++++++++- 1 file changed, 74 insertions(+), 3 deletions(-) diff --git a/src/ui/identity/register_dpns_name_screen.rs b/src/ui/identity/register_dpns_name_screen.rs index e142d3b50..66e9cfb19 100644 --- a/src/ui/identity/register_dpns_name_screen.rs +++ b/src/ui/identity/register_dpns_name_screen.rs @@ -48,7 +48,7 @@ use egui::{RichText, Ui}; use std::sync::{Arc, RwLock}; use std::time::{Duration, Instant}; -use super::get_selected_wallet; +use super::{get_selected_wallet, wallet_for_signing_key}; /// Wait after the last keystroke before asking the network about a name. const AVAILABILITY_DEBOUNCE: Duration = Duration::from_millis(400); @@ -180,10 +180,11 @@ impl RegisterDpnsNameScreen { } } - /// Pick the wallet the identity's signing key comes from, to offer its unlock. + /// Pick the wallet the registration's signing key comes from, to offer + /// its unlock. fn select_wallet(&mut self) { self.selected_wallet = self.selected_qualified_identity.as_ref().and_then(|qi| { - get_selected_wallet(qi, Some(&self.app_context), None) + registration_wallet(qi, self.selected_key.as_ref(), &self.app_context) .or_show_error(self.app_context.egui_ctx()) .unwrap_or(None) }); @@ -585,6 +586,7 @@ impl RegisterDpnsNameScreen { ); }); + let key_before = self.selected_key.as_ref().map(|key| key.id()); if self.app_context.user_role().at_least(UserRole::Power) { egui::CollapsingHeader::new("Advanced") .default_open(false) @@ -620,6 +622,11 @@ impl RegisterDpnsNameScreen { ); }); } + // The wallet to unlock follows the key the registration is signed + // with, so a key chosen under Advanced brings its own wallet. + if self.selected_key.as_ref().map(|key| key.id()) != key_before { + self.select_wallet(); + } if let Some(wallet) = &self.selected_wallet { if !self.wallet_open_attempted { @@ -971,10 +978,74 @@ impl ScreenLike for RegisterDpnsNameScreen { } } +/// The wallet to unlock for a registration: the one holding the key the +/// user chose under Advanced, or, without a choice, the one holding the key +/// picked automatically. +fn registration_wallet( + identity: &QualifiedIdentity, + chosen_key: Option<&IdentityPublicKey>, + app_context: &AppContext, +) -> Result>>, String> { + match chosen_key { + Some(key) => Ok(wallet_for_signing_key(identity, Some(key))), + None => get_selected_wallet(identity, Some(app_context), None), + } +} + #[cfg(test)] mod tests { use super::*; + /// Regression: the unlock prompt and the pay gate followed the + /// automatically picked key's wallet, not the wallet of the key the user + /// chose under Advanced. + #[test] + fn the_unlock_wallet_follows_the_chosen_registration_key() { + use crate::context::test_support::{ + auth_key, bare_user_identity, hold_as_wallet_reference, + }; + use crate::model::wallet::WalletSeed; + use crate::model::wallet::test_support::open_wallet; + use dash_sdk::dpp::identity::SecurityLevel; + use dash_sdk::dpp::identity::accessors::IdentitySettersV0; + use dash_sdk::platform::Identifier; + + let dir = tempfile::tempdir().unwrap(); + let ctx = crate::context::test_support::test_app_context(dir.path()); + let auto_wallet = open_wallet(); + let mut chosen_wallet = open_wallet(); + let WalletSeed::Open(seed) = &mut chosen_wallet.wallet_seed else { + unreachable!("open_wallet builds an open wallet"); + }; + seed.wallet_info.seed_hash = [7; 32]; + let (auto_hash, chosen_hash) = (auto_wallet.seed_hash(), chosen_wallet.seed_hash()); + + // Key 1 (HIGH) is the automatic choice; key 2 (CRITICAL) also signs. + let auto_key = auth_key(1, SecurityLevel::HIGH); + let chosen_key = auth_key(2, SecurityLevel::CRITICAL); + let mut identity = bare_user_identity(Identifier::new([0xD2; 32]), ctx.network()); + identity.identity.set_public_keys( + [(1, auto_key), (2, chosen_key.clone())] + .into_iter() + .collect(), + ); + hold_as_wallet_reference(&mut identity, 1, auto_hash); + hold_as_wallet_reference(&mut identity, 2, chosen_hash); + let auto_wallet = Arc::new(RwLock::new(auto_wallet)); + let chosen_wallet = Arc::new(RwLock::new(chosen_wallet)); + identity + .associated_wallets + .insert(auto_hash, Arc::clone(&auto_wallet)); + identity + .associated_wallets + .insert(chosen_hash, Arc::clone(&chosen_wallet)); + + let automatic = registration_wallet(&identity, None, &ctx).unwrap(); + assert!(automatic.is_some_and(|wallet| Arc::ptr_eq(&wallet, &auto_wallet))); + let chosen = registration_wallet(&identity, Some(&chosen_key), &ctx).unwrap(); + assert!(chosen.is_some_and(|wallet| Arc::ptr_eq(&wallet, &chosen_wallet))); + } + #[test] fn review_regression_unrelated_error_keeps_paid_registration_blocked() { let dir = tempfile::tempdir().unwrap(); From 9b3ce312d201cf63f3c94b55212839a5f97d733a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:56:43 +0000 Subject: [PATCH 20/58] fix(identity): take the least privileged key for every document action The document action and DashPay selectors offered every accepted level at once, so a CRITICAL key could sign where a key at the required level was held. Every document key is now chosen like the username key: each accepted level from the weakest up, unlimited keys first within a level. Also puts the scope and wallet doc comments of the document action screen back on their functions, and builds its test keys and identities from the shared test helpers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- src/context/test_support.rs | 23 ++- src/model/qualified_identity/mod.rs | 160 +++++++----------- .../document_action_screen.rs | 96 ++++------- 3 files changed, 115 insertions(+), 164 deletions(-) diff --git a/src/context/test_support.rs b/src/context/test_support.rs index 61a66b68c..ea18887c9 100644 --- a/src/context/test_support.rs +++ b/src/context/test_support.rs @@ -253,6 +253,18 @@ pub(crate) fn auth_key( .into() } +/// `key`, a version 0 key such as [`auth_key`] builds, bound to `bounds`. +pub(crate) fn bound_to( + key: dash_sdk::platform::IdentityPublicKey, + bounds: dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds, +) -> dash_sdk::platform::IdentityPublicKey { + let dash_sdk::platform::IdentityPublicKey::V0(mut v0) = key else { + panic!("bound_to takes a version 0 key"); + }; + v0.contract_bounds = Some(bounds); + v0.into() +} + /// A testnet user identity publishing `keys` and holding the private half of /// each. pub(crate) fn identity_holding( @@ -304,13 +316,10 @@ pub(crate) fn identity_with_bound_wallet_key( ) { let wallet = crate::model::wallet::test_support::open_wallet(); let seed_hash = wallet.seed_hash(); - let mut key = auth_key(1, dash_sdk::dpp::identity::SecurityLevel::HIGH); - { - let dash_sdk::platform::IdentityPublicKey::V0(v0) = &mut key else { - unreachable!("auth_key builds a version 0 key"); - }; - v0.contract_bounds = Some(bounds); - } + let key = bound_to( + auth_key(1, dash_sdk::dpp::identity::SecurityLevel::HIGH), + bounds, + ); use dash_sdk::dpp::identity::accessors::IdentitySettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; let mut identity = bare_user_identity(Identifier::new([0xD1; 32]), Network::Testnet); diff --git a/src/model/qualified_identity/mod.rs b/src/model/qualified_identity/mod.rs index e4d8620f6..40a8d49e0 100644 --- a/src/model/qualified_identity/mod.rs +++ b/src/model/qualified_identity/mod.rs @@ -1168,23 +1168,20 @@ impl QualifiedIdentity { scope: SigningScope<'_>, document_type: &DocumentTypeRef, ) -> Option<&IdentityPublicKey> { - document_security_levels(*document_type) - .iter() - .rev() - .find_map(|level| { - self.signing_key_now(KeyRequirements::new( - Purpose::AUTHENTICATION, - std::slice::from_ref(level), - scope, - )) - }) + let levels = document_security_levels(*document_type); + self.least_privileged_signing_key_now(KeyRequirements::new( + Purpose::AUTHENTICATION, + &levels, + scope, + )) } /// The key to sign an action on document type `document_type_name` of /// `contract` with: a live AUTHENTICATION key at a level the type accepts /// ([`document_security_levels`]), of one of `key_types` when given, whose - /// bounds permit that document type; unlimited keys first. `None` when the - /// contract has no such type or no key qualifies. + /// bounds permit that document type. The least privileged level is tried + /// first, as in [`Self::document_signing_key`]. `None` when the contract + /// has no such type or no key qualifies. pub fn document_action_signing_key( &self, contract: &DataContract, @@ -1204,7 +1201,7 @@ impl QualifiedIdentity { if let Some(key_types) = key_types { requirements = requirements.with_key_types(key_types); } - self.signing_key_now(requirements) + self.least_privileged_signing_key_now(requirements) } /// The ENCRYPTION key whose wallet seeds DashPay auto-accept proofs @@ -1230,6 +1227,26 @@ impl QualifiedIdentity { .unwrap_or(snapshot) } + /// The key to sign with for `requirements` now, least privileged first: + /// each accepted security level is tried from the weakest up, so a + /// stronger key signs only when no key at a weaker accepted level + /// qualifies. Within a level, [`Self::signing_key_now`] decides (unlimited + /// keys first). + pub fn least_privileged_signing_key_now( + &self, + requirements: KeyRequirements<'_>, + ) -> Option<&IdentityPublicKey> { + let mut levels = requirements.security_levels.to_vec(); + levels.sort_by_key(|level| std::cmp::Reverse(*level as u8)); + levels.dedup(); + levels.iter().find_map(|level| { + self.signing_key_now(KeyRequirements { + security_levels: std::slice::from_ref(level), + ..requirements + }) + }) + } + /// The key to sign with for `requirements` now: the automatic choice of /// [`select_identity_signing_key`] among the keys this device holds the /// private half of. @@ -3371,16 +3388,33 @@ mod decode_limit_tests { #[cfg(test)] mod protocol_v14_compat_tests { use super::*; + use crate::model::identity_key_usability::SigningScope; use dash_sdk::dpp::data_contract::document_type::DocumentTypeRef; use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dash_sdk::dpp::identity::accessors::IdentitySettersV0; - use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v1::IdentityPublicKeyGettersV1; use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; use dash_sdk::dpp::version::PlatformVersion; use dash_sdk::platform::Identifier; + /// Regression: the document action selector offered every accepted level + /// at once, so an unlimited CRITICAL key signed DashPay documents although + /// an unlimited HIGH key, the level the type requires, was held. Both + /// document selectors take the least privileged key that qualifies. + #[test] + fn a_document_action_takes_the_least_privileged_key() { + use crate::context::test_support::{auth_key, dashpay_contract, identity_holding}; + let contract = dashpay_contract(); + let identity = identity_holding(&[ + auth_key(1, SecurityLevel::CRITICAL), + auth_key(2, SecurityLevel::HIGH), + ]); + + let chosen = identity.document_action_signing_key(&contract, "contactRequest", None); + assert_eq!(chosen.map(|key| key.id()), Some(2)); + } + /// The bundled v0.9.3 profile: real identity rows written by DET v0.9.3. const V0_9_3_DATA_SQL: &str = include_str!("../../../tests/migration-fixtures/v0.9.3-public-identities/data.sql"); @@ -3484,8 +3518,8 @@ mod protocol_v14_compat_tests { /// MASTER key never does. #[test] fn document_signing_key_accepts_a_stronger_key_than_required() { + use crate::context::test_support::{auth_key, identity_holding}; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; - use dash_sdk::dpp::identity::{KeyType, Purpose}; let pv = PlatformVersion::latest(); let dpns = dash_sdk::dpp::system_data_contracts::load_system_data_contract( @@ -3496,35 +3530,10 @@ mod protocol_v14_compat_tests { let preorder: DocumentTypeRef = dpns.document_type_for_name("preorder").expect("preorder"); assert_eq!(preorder.security_level_requirement(), SecurityLevel::HIGH); - let auth_key = |id: KeyID, level: SecurityLevel| { - let mut key = IdentityPublicKey::random_key(id, Some(u64::from(id)), pv); - key.set_purpose(Purpose::AUTHENTICATION); - key.set_security_level(level); - key.set_key_type(KeyType::ECDSA_SECP256K1); - key - }; - let all = [ + let qi = identity_holding(&[ auth_key(1, SecurityLevel::MASTER), auth_key(2, SecurityLevel::CRITICAL), - ]; - let mut qi = QualifiedIdentity::from_bytes(&v0_9_3_user_blob()).expect("decodes"); - qi.private_keys = Default::default(); - for key in &all { - qi.private_keys - .insert_non_encrypted( - (PrivateKeyTarget::PrivateKeyOnMainIdentity, key.id()), - ( - QualifiedIdentityPublicKey { - identity_public_key: key.clone(), - in_wallet_at_derivation_path: None, - }, - [key.id() as u8; 32], - ), - ) - .expect("a free slot"); - } - qi.identity - .set_public_keys(all.iter().map(|key| (key.id(), key.clone())).collect()); + ]); let scope = SigningScope::ContractWide { contract_id: dpns.id(), }; @@ -3535,22 +3544,11 @@ mod protocol_v14_compat_tests { ); // A key at the required level is still preferred over a stronger one. - let high = auth_key(3, SecurityLevel::HIGH); - qi.private_keys - .insert_non_encrypted( - (PrivateKeyTarget::PrivateKeyOnMainIdentity, high.id()), - ( - QualifiedIdentityPublicKey { - identity_public_key: high.clone(), - in_wallet_at_derivation_path: None, - }, - [high.id() as u8; 32], - ), - ) - .expect("a free slot"); - let mut keys = qi.identity.public_keys().clone(); - keys.insert(high.id(), high); - qi.identity.set_public_keys(keys); + let qi = identity_holding(&[ + auth_key(1, SecurityLevel::MASTER), + auth_key(2, SecurityLevel::CRITICAL), + auth_key(3, SecurityLevel::HIGH), + ]); assert_eq!( qi.document_signing_key(scope, &preorder).map(|k| k.id()), Some(3), @@ -3562,8 +3560,8 @@ mod protocol_v14_compat_tests { /// bound elsewhere) and prefers an unlimited key over a limited one. #[test] fn document_signing_key_picks_a_usable_unlimited_key() { + use crate::context::test_support::{bound_to, identity_holding}; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; - use dash_sdk::dpp::identity::{KeyType, Purpose}; let pv = PlatformVersion::latest(); let dpns = dash_sdk::dpp::system_data_contracts::load_system_data_contract( @@ -3574,45 +3572,17 @@ mod protocol_v14_compat_tests { let preorder: DocumentTypeRef = dpns.document_type_for_name("preorder").expect("preorder"); let level = preorder.security_level_requirement(); - let auth_key = |id: KeyID| { - let mut key = IdentityPublicKey::random_key(id, Some(u64::from(id)), pv); - key.set_purpose(Purpose::AUTHENTICATION); - key.set_security_level(level); - key.set_key_type(KeyType::ECDSA_SECP256K1); - key - }; + let auth_key = |id: KeyID| crate::context::test_support::auth_key(id, level); let expired = auth_key(1).with_limits(None, Some(1)); - let elsewhere = match auth_key(2) { - IdentityPublicKey::V0(mut v0) => { - v0.contract_bounds = Some(ContractBounds::SingleContract { - id: Identifier::from([9u8; 32]), - }); - IdentityPublicKey::V0(v0) - } - other => other, - }; + let elsewhere = bound_to( + auth_key(2), + ContractBounds::SingleContract { + id: Identifier::from([9u8; 32]), + }, + ); let limited = auth_key(3).with_limits(Some(1_000), None); let plain = auth_key(4); - - let mut qi = QualifiedIdentity::from_bytes(&v0_9_3_user_blob()).expect("decodes"); - let all = [expired, elsewhere, limited, plain]; - qi.private_keys = Default::default(); - for key in &all { - qi.private_keys - .insert_non_encrypted( - (PrivateKeyTarget::PrivateKeyOnMainIdentity, key.id()), - ( - QualifiedIdentityPublicKey { - identity_public_key: key.clone(), - in_wallet_at_derivation_path: None, - }, - [key.id() as u8; 32], - ), - ) - .expect("a free slot"); - } - qi.identity - .set_public_keys(all.iter().map(|key| (key.id(), key.clone())).collect()); + let mut qi = identity_holding(&[expired, elsewhere, limited, plain]); let scope = SigningScope::ContractWide { contract_id: dpns.id(), }; diff --git a/src/ui/contracts_documents/document_action_screen.rs b/src/ui/contracts_documents/document_action_screen.rs index db8986d90..489dc352d 100644 --- a/src/ui/contracts_documents/document_action_screen.rs +++ b/src/ui/contracts_documents/document_action_screen.rs @@ -264,9 +264,6 @@ impl DocumentActionScreen { ui.add_space(10.0); } - /// The contract or document type changed: what was fetched for the old - /// one no longer applies, and the key is picked again for the new scope - /// (a key bound to the old contract or type cannot sign in the new one). /// The wallet holding the key the action is signed with, for the unlock /// prompt. fn signing_wallet(&self) -> Option>> { @@ -274,6 +271,9 @@ impl DocumentActionScreen { wallet_for_signing_key(identity, self.selected_key.as_ref()) } + /// The contract or document type changed: what was fetched for the old + /// one no longer applies, and the key is picked again for the new scope + /// (a key bound to the old contract or type cannot sign in the new one). fn signing_scope_changed(&mut self) { self.no_documents_found = false; self.fetched_documents.clear(); @@ -1971,12 +1971,11 @@ impl DocumentActionScreen { } } -/// The contract-bounds scope of a document action on `document_type` of -/// `contract`. Before a contract is chosen nothing is known about the target, -/// so only keys that can sign anywhere (unbound keys) qualify. /// The key to sign a document action with when the user has not picked one: /// a live AUTHENTICATION key this device can sign with, at a security level /// the document type accepts, within the contract and document type's scope. +/// The least privileged accepted level is tried first, as for every document +/// key ([`QualifiedIdentity::least_privileged_signing_key_now`]). fn auto_selected_document_key( identity: &QualifiedIdentity, contract: Option<&QualifiedContract>, @@ -1989,7 +1988,7 @@ fn auto_selected_document_key( document_type, ); identity - .signing_key_now(KeyRequirements::new( + .least_privileged_signing_key_now(KeyRequirements::new( dash_sdk::dpp::identity::Purpose::AUTHENTICATION, &levels, signing_scope(contract, document_type), @@ -1997,6 +1996,9 @@ fn auto_selected_document_key( .cloned() } +/// The contract-bounds scope of a document action on `document_type` of +/// `contract`. Before a contract is chosen nothing is known about the target, +/// so only keys that can sign anywhere (unbound keys) qualify. fn signing_scope<'a>( contract: Option<&QualifiedContract>, document_type: Option<&'a DocumentType>, @@ -2037,6 +2039,8 @@ mod tests { use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use crate::context::test_support::identity_holding; + /// An AUTHENTICATION key at `level`, optionally bound to `bounds`. fn auth_key( id: u32, @@ -2045,62 +2049,11 @@ mod tests { dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds, >, ) -> IdentityPublicKey { - dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0 { - id, - purpose: dash_sdk::dpp::identity::Purpose::AUTHENTICATION, - security_level: level, - contract_bounds: bounds, - key_type: dash_sdk::dpp::identity::KeyType::ECDSA_SECP256K1, - read_only: false, - data: vec![2; 33].into(), - disabled_at: None, - } - .into() - } - - /// A user identity holding the private half of each of `keys`. - fn identity_holding(keys: &[IdentityPublicKey]) -> QualifiedIdentity { - use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; - use crate::model::qualified_identity::{IdentityStatus, IdentityType, PrivateKeyTarget}; - use dash_sdk::dpp::identity::Identity; - use dash_sdk::dpp::identity::accessors::IdentitySettersV0; - use dash_sdk::dpp::version::PlatformVersion; - - let mut identity = - Identity::create_basic_identity(Identifier::new([0xD0; 32]), PlatformVersion::latest()) - .expect("basic identity"); - identity.set_public_keys(keys.iter().map(|key| (key.id(), key.clone())).collect()); - let mut qi = QualifiedIdentity { - identity, - associated_voter_identity: None, - associated_operator_identity: None, - associated_owner_key_id: None, - identity_type: IdentityType::User, - alias: None, - private_keys: Default::default(), - dpns_names: vec![], - associated_wallets: BTreeMap::new(), - secret_access: None, - wallet_index: None, - top_ups: BTreeMap::new(), - status: IdentityStatus::Active, - network: dash_sdk::dpp::dashcore::Network::Testnet, - }; - for key in keys { - qi.private_keys - .insert_non_encrypted( - (PrivateKeyTarget::PrivateKeyOnMainIdentity, key.id()), - ( - QualifiedIdentityPublicKey { - identity_public_key: key.clone(), - in_wallet_at_derivation_path: None, - }, - [key.id() as u8; 32], - ), - ) - .expect("a free slot"); + let key = crate::context::test_support::auth_key(id, level); + match bounds { + Some(bounds) => crate::context::test_support::bound_to(key, bounds), + None => key, } - qi } /// The DPNS contract, whose document types require a HIGH key. @@ -2148,6 +2101,25 @@ mod tests { assert_eq!(chosen.map(|key| key.id()), Some(1)); } + /// Regression: automatic selection offered every accepted level at once, + /// so an unlimited CRITICAL key signed where an unlimited key at the level + /// the document type requires was held. + #[test] + fn auto_selection_takes_the_least_privileged_key() { + use dash_sdk::dpp::identity::SecurityLevel; + let contract = dpns(); + let domain = owned_document_type(&contract, "domain"); + let required = domain.security_level_requirement(); + assert_eq!(required, SecurityLevel::HIGH, "precondition: a HIGH type"); + let identity = identity_holding(&[ + auth_key(1, SecurityLevel::CRITICAL, None), + auth_key(2, SecurityLevel::HIGH, None), + ]); + + let chosen = auto_selected_document_key(&identity, Some(&contract), Some(&domain)); + assert_eq!(chosen.map(|key| key.id()), Some(2)); + } + /// Regression: the unlock prompt looked for a DPNS signing key, so an /// identity whose only key is bound to the action's contract got no /// wallet to unlock although that key signs the action. From d6035a87ceedce16e0be8f929652547ddf5277b8 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:56:43 +0000 Subject: [PATCH 21/58] fix(ui): label key chooser entries as whole sentences Each key chooser entry with a caveat is one complete template instead of a label plus a concatenated tag. The spending limit warning no longer points at a remaining budget the key details do not show, and IDN-024 names the key chooser, not the key list. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- docs/user-stories.md | 2 +- src/ui/helpers.rs | 25 ++++++++++++------------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/docs/user-stories.md b/docs/user-stories.md index 156721e72..75e3c283a 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -813,7 +813,7 @@ As a user, I want the app to sign with a key that can actually sign the action, - Automatic key choice skips disabled and expired keys, keys limited to other contracts or document types, and keys bound to a contract group. - Only keys this device holds the private key of are chosen; among those, one without limits is preferred over a limited one. -- Where the user picks the key, limited and expired keys stay selectable, and the key list and selection show a warning explaining the limit. Keys bound to another contract or document type are not offered. +- Where the user picks the key, limited and expired keys stay selectable, and the key chooser shows a warning explaining the limit. Keys bound to another contract or document type are not offered. ## DPNS (DPN) diff --git a/src/ui/helpers.rs b/src/ui/helpers.rs index 89229d925..0cf800298 100644 --- a/src/ui/helpers.rs +++ b/src/ui/helpers.rs @@ -528,10 +528,7 @@ fn render_key_combo( // A key Platform may refuse stays selectable (the user may know // better, e.g. a contract group that covers this contract), but // its entry says why it may not work. - let label = match caveats.first() { - Some(caveat) => format!("{label} ({tag})", tag = key_caveat_tag(caveat)), - None => label, - }; + let label = key_entry_label(&label, caveats.first()); if kui .selectable_label(selected_key.as_ref() == Some(key), label) @@ -563,15 +560,17 @@ fn offered_for_manual_choice(caveats: &[KeyCaveat]) -> bool { .any(|caveat| matches!(caveat, KeyCaveat::OutOfBounds)) } -/// A one- or two-word tag for a key list entry, naming the most severe caveat. -fn key_caveat_tag(caveat: &KeyCaveat) -> &'static str { +/// A key list entry for the key labelled `label`, naming its most severe +/// caveat. Each entry is one complete template, so it translates as a unit. +fn key_entry_label(label: &str, caveat: Option<&KeyCaveat>) -> String { match caveat { - KeyCaveat::Disabled => "disabled", - KeyCaveat::Expired { .. } => "expired", - KeyCaveat::OutOfBounds => "other contract", - KeyCaveat::ContractGroupBound { .. } => "contract group", - KeyCaveat::Budgeted { .. } => "spending limit", - KeyCaveat::Expiring { .. } => "expires", + Some(KeyCaveat::Expired { .. }) => format!("{label} (expired)"), + Some(KeyCaveat::ContractGroupBound { .. }) => format!("{label} (contract group)"), + Some(KeyCaveat::Budgeted { .. }) => format!("{label} (spending limit)"), + Some(KeyCaveat::Expiring { .. }) => format!("{label} (expires)"), + // Disabled keys and keys bound to another contract or document type + // are never listed, so no entry names them. + Some(KeyCaveat::Disabled | KeyCaveat::OutOfBounds) | None => label.to_string(), } } @@ -589,7 +588,7 @@ pub(crate) fn key_caveat_message(caveat: &KeyCaveat) -> String { group_id = group_id.to_string(Encoding::Base58) ), KeyCaveat::Budgeted { total_budget } => format!( - "This key has a lifetime spending limit of {amount}. Once the limit is used up, the network rejects actions signed with it. Its details page shows how much is left.", + "This key has a lifetime spending limit of {amount}. Once the limit is used up, the network rejects actions signed with it.", amount = format_credits_as_dash(*total_budget) ), KeyCaveat::Expiring { expires_at } => format!( From 407bbaa53af040a70857136425d175c7f66010d3 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:03:11 +0000 Subject: [PATCH 22/58] fix(test): prove the protocol version the headless refresh learns The protocol version fallback takes only a version the SDK verified, and a mock expectation's metadata is never recorded as verified, so the three headless refresh tests saw the version stay unconfirmed. The mock now has the SDK accept the version from metadata treated as proved, as after a live network's signed response, and a new test pins that a response the SDK never verified leaves the version unconfirmed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- src/mcp/resolve.rs | 45 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/src/mcp/resolve.rs b/src/mcp/resolve.rs index 9dfef54aa..652b7c214 100644 --- a/src/mcp/resolve.rs +++ b/src/mcp/resolve.rs @@ -447,12 +447,31 @@ mod tests { use dash_sdk::dpp::version::LATEST_VERSION; use dash_sdk::platform::DataContract; - /// Mocks the proved DPNS-contract fetch that drives the protocol-version - /// ratchet — the epoch task's fallback when the current epoch cannot be - /// read (the mock has no epoch response). The mock reports `LATEST_VERSION` in - /// the response metadata, so a successful fetch ratchets the SDK exactly as a - /// live network would; a failed one leaves the version unconfirmed. + /// Mocks the DPNS-contract fetch that drives the protocol-version ratchet, + /// the epoch task's fallback when the current epoch cannot be read (the + /// mock has no epoch response). The fallback takes only a version the SDK + /// verified, and a mock expectation is made up, never verified, so the + /// SDK first accepts `LATEST_VERSION` from metadata it is told was proved, + /// as it does after a live network's quorum-signed response. A successful + /// fetch then ratchets the app exactly as a live network would; a failed + /// one leaves the version unconfirmed. async fn mock_sdk_with_dpns_contract(ctx: &Arc) -> Sdk { + let sdk = mock_sdk_with_unproved_dpns_contract(ctx).await; + sdk.verify_response_metadata( + "get_data_contract", + &dash_sdk::dapi_grpc::platform::v0::ResponseMetadata { + protocol_version: LATEST_VERSION, + height: 1, + ..Default::default() + }, + ) + .expect("proved metadata is accepted"); + sdk + } + + /// The DPNS-contract fetch alone: its expectation proves nothing about the + /// network's protocol version. + async fn mock_sdk_with_unproved_dpns_contract(ctx: &Arc) -> Sdk { let contract = DataContract::clone(&ctx.dpns_contract()); let mut sdk = Sdk::new_mock(); sdk.mock() @@ -462,6 +481,22 @@ mod tests { sdk } + /// A DPNS fetch whose response the SDK never verified does not confirm + /// the network's protocol version: features stay gated off. + #[tokio::test] + async fn an_unproved_response_leaves_the_protocol_version_unconfirmed() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = crate::mcp::tests::legacy_wallet_context(temp_dir.path()); + ctx.sdk + .store(Arc::new(mock_sdk_with_unproved_dpns_contract(&ctx).await)); + + refresh_platform_protocol_version(&ctx) + .await + .expect("refresh platform metadata"); + + assert_eq!(ctx.platform_protocol_version(), 0); + } + #[tokio::test] async fn headless_sync_populates_protocol_version_after_spv_is_running() { let temp_dir = tempfile::tempdir().expect("tempdir"); From 9c54f998c7a89b190e8beef068c616ec11e39521 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:53:02 +0000 Subject: [PATCH 23/58] test: drop the protocol 13 transition encoding tests The SDK picks each transition's encoding from the platform version DET passes in, so these tests checked dpp's version tables rather than DET. The mainnet start version and the contest fund cap stay covered by the platform_info and fee_estimation tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- .../README.md | 2 - src/backend_task/mod.rs | 2 - src/backend_task/protocol_13_transitions.rs | 308 ------------------ 3 files changed, 312 deletions(-) delete mode 100644 src/backend_task/protocol_13_transitions.rs diff --git a/docs/ai-design/2026-10-24-platform-v5.1-dev-bump/README.md b/docs/ai-design/2026-10-24-platform-v5.1-dev-bump/README.md index 50e72fb63..387c531ac 100644 --- a/docs/ai-design/2026-10-24-platform-v5.1-dev-bump/README.md +++ b/docs/ai-design/2026-10-24-platform-v5.1-dev-bump/README.md @@ -52,8 +52,6 @@ proof is never checked against the wrong schema. - `context/platform_version_guard.rs` compares the protocol 13 client tables with a recorded snapshot, so an upstream change to them is reviewed. -- `backend_task/protocol_13_transitions.rs` checks that transitions DET builds - on a protocol 13 network use encodings protocol 13 decodes. ## Mainnet compatibility diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 09811cb07..1a2e193b5 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -60,8 +60,6 @@ pub mod grovestark; pub mod identity; pub mod migration; pub mod platform_info; -#[cfg(test)] -mod protocol_13_transitions; pub mod register_contract; pub mod shielded; pub mod system_task; diff --git a/src/backend_task/protocol_13_transitions.rs b/src/backend_task/protocol_13_transitions.rs deleted file mode 100644 index ffa25e3f4..000000000 --- a/src/backend_task/protocol_13_transitions.rs +++ /dev/null @@ -1,308 +0,0 @@ -//! State transitions built the way DET builds them on a protocol 13 network -//! (mainnet and testnet today): only encodings protocol 13 decodes. - -use std::collections::BTreeMap; -use std::sync::Arc; - -use async_trait::async_trait; -use dash_sdk::dpp::ProtocolError; -use dash_sdk::dpp::address_funds::AddressWitness; -use dash_sdk::dpp::dashcore::Network; -use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; -use dash_sdk::dpp::data_contract::config::DataContractConfig; -use dash_sdk::dpp::data_contract::document_type::DocumentType; -use dash_sdk::dpp::document::{Document, DocumentV0}; -use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; -use dash_sdk::dpp::identity::signer::Signer; -use dash_sdk::dpp::identity::{Identity, IdentityPublicKey, KeyType, Purpose, SecurityLevel}; -use dash_sdk::dpp::platform_value::{BinaryData, Identifier, platform_value}; -use dash_sdk::dpp::prelude::UserFeeIncrease; -use dash_sdk::dpp::state_transition::StateTransition; -use dash_sdk::dpp::state_transition::batch_transition::BatchTransition; -use dash_sdk::dpp::state_transition::batch_transition::batched_transition::BatchedTransition; -use dash_sdk::dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransitionV0Methods; -use dash_sdk::dpp::state_transition::batch_transition::document_base_transition::DocumentBaseTransition; -use dash_sdk::dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; -use dash_sdk::dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; -use dash_sdk::dpp::state_transition::identity_update_transition::IdentityUpdateTransition; -use dash_sdk::dpp::state_transition::identity_update_transition::methods::IdentityUpdateTransitionMethodsV0; -use dash_sdk::dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; -use dash_sdk::dpp::version::PlatformVersion; - -use crate::context::AppContext; -use crate::context::test_support::test_app_context_for_network; - -/// Signs everything with placeholder bytes: these tests check what is -/// encoded, not signature validity. -#[derive(Debug)] -struct PlaceholderSigner; - -#[async_trait] -impl Signer for PlaceholderSigner { - async fn sign( - &self, - _key: &IdentityPublicKey, - _data: &[u8], - ) -> Result { - Ok(BinaryData::new(vec![0; 65])) - } - - async fn sign_create_witness( - &self, - _key: &IdentityPublicKey, - _data: &[u8], - ) -> Result { - Err(ProtocolError::Generic( - "no address witnesses here".to_string(), - )) - } - - fn can_sign_with(&self, _key: &IdentityPublicKey) -> bool { - true - } -} - -/// A mainnet context, which builds at protocol 13 until the network says -/// otherwise. -fn mainnet_context() -> (tempfile::TempDir, Arc, &'static PlatformVersion) { - let tmp = tempfile::tempdir().expect("tempdir"); - let ctx = test_app_context_for_network(tmp.path(), Network::Mainnet); - let platform_version = ctx.platform_version(); - assert_eq!(platform_version.protocol_version, 13); - (tmp, ctx, platform_version) -} - -fn plain_key(id: u32, security_level: SecurityLevel) -> IdentityPublicKey { - IdentityPublicKey::from(IdentityPublicKeyV0 { - id, - purpose: Purpose::AUTHENTICATION, - security_level, - contract_bounds: None, - key_type: KeyType::ECDSA_SECP256K1, - read_only: false, - data: vec![2; 33].into(), - disabled_at: None, - }) -} - -/// A key without limits, added on a protocol 13 network, goes out as the -/// version 0 key encoding protocol 13 decodes. -#[tokio::test] -async fn a_plain_key_is_added_in_the_protocol_13_encoding() { - let (_tmp, _ctx, platform_version) = mainnet_context(); - let mut identity = Identity::create_basic_identity(Identifier::from([4; 32]), platform_version) - .expect("identity"); - identity.add_public_key(plain_key(0, SecurityLevel::MASTER)); - - let transition = IdentityUpdateTransition::try_from_identity_with_signer( - &identity, - &0, - vec![plain_key(1, SecurityLevel::HIGH)], - vec![], - 1, - UserFeeIncrease::default(), - &PlaceholderSigner, - platform_version, - None, - ) - .await - .expect("identity update"); - - let StateTransition::IdentityUpdate(IdentityUpdateTransition::V0(update)) = transition else { - panic!("expected a version 0 identity update, got {transition:?}"); - }; - assert_eq!(update.add_public_keys.len(), 1); - assert!(matches!( - update.add_public_keys[0], - IdentityPublicKeyInCreation::V0(_) - )); -} - -fn note_contract(platform_version: &PlatformVersion) -> dash_sdk::platform::DataContract { - use dash_sdk::dpp::data_contract::v1::DataContractV1; - - let id = Identifier::from([8; 32]); - let config = DataContractConfig::default_for_version(platform_version).expect("config"); - let schema = platform_value!({ - "type": "object", - "properties": {"name": {"type": "string", "position": 0, "maxLength": 63_u32}}, - "required": ["name"], - "additionalProperties": false, - }); - let note = DocumentType::try_from_schema( - id, - 1, - config.version(), - "note", - schema, - None, - &BTreeMap::new(), - &config, - true, - &mut Vec::new(), - platform_version, - ) - .expect("document type"); - dash_sdk::platform::DataContract::V1(DataContractV1 { - id, - version: 1, - owner_id: Identifier::from([7; 32]), - document_types: BTreeMap::from([("note".to_string(), note)]), - config, - schema_defs: None, - groups: BTreeMap::new(), - tokens: BTreeMap::new(), - keywords: vec![], - created_at: None, - updated_at: None, - created_at_block_height: None, - updated_at_block_height: None, - created_at_epoch: None, - updated_at_epoch: None, - description: None, - }) -} - -fn note(owner_id: Identifier) -> Document { - Document::V0(DocumentV0 { - moderated_at: None, - moderated_by: None, - id: Identifier::from([9; 32]), - owner_id, - creator_id: None, - properties: BTreeMap::from([("name".to_string(), "hello".into())]), - revision: Some(1), - created_at: None, - updated_at: None, - transferred_at: None, - created_at_block_height: None, - updated_at_block_height: None, - transferred_at_block_height: None, - created_at_core_block_height: None, - updated_at_core_block_height: None, - transferred_at_core_block_height: None, - contract_version: None, - }) -} - -async fn create_note( - platform_version: &PlatformVersion, - options: Option, -) -> Result> { - let contract = note_contract(platform_version); - let document_type = contract.document_type_for_name("note").expect("note type"); - let owner_id = Identifier::from([4; 32]); - BatchTransition::new_document_creation_transition_from_document( - note(owner_id), - document_type, - [3; 32], - &plain_key(1, SecurityLevel::HIGH), - 1, - UserFeeIncrease::default(), - None, - &PlaceholderSigner, - platform_version, - options, - ) - .await - .map_err(Box::new) -} - -/// A document created on a protocol 13 network uses a document base that -/// protocol 13 decodes: not the protocol 14 one that carries a fee agreement. -#[tokio::test] -async fn a_document_is_created_in_the_protocol_13_encoding() { - let (_tmp, _ctx, platform_version) = mainnet_context(); - - let transition = create_note(platform_version, None) - .await - .expect("document create"); - - let StateTransition::Batch(batch) = transition else { - panic!("expected a batch, got {transition:?}"); - }; - let base = match &batch { - BatchTransition::V0(v0) => v0.transitions[0].base().clone(), - BatchTransition::V1(v1) => match &v1.transitions[0] { - BatchedTransition::Document(document) => document.base().clone(), - other => panic!("expected a document transition, got {other:?}"), - }, - }; - assert!( - !matches!(base, DocumentBaseTransition::V2(_)), - "protocol 13 cannot decode {base:?}" - ); -} - -/// A contested username is created on a protocol 13 network the way -/// `BroadcastDocument` creates it, with the contest fund DET states: in the -/// protocol 13 document encoding, and stating exactly the protocol's base -/// fund. -#[tokio::test] -async fn a_contested_name_is_created_in_the_protocol_13_encoding_with_the_base_fund() { - use crate::model::fee_estimation::{contest_fee_credits, document_create_contest_fund_cap}; - use dash_sdk::dpp::state_transition::batch_transition::batched_transition::document_create_transition::v0::v0_methods::DocumentCreateTransitionV0Methods; - use dash_sdk::dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransition; - use dash_sdk::dpp::system_data_contracts::{SystemDataContract, load_system_data_contract}; - - let (_tmp, _ctx, platform_version) = mainnet_context(); - let contract = load_system_data_contract(SystemDataContract::DPNS, platform_version) - .expect("DPNS contract"); - let domain = contract - .document_type_for_name("domain") - .expect("domain type"); - let owner_id = Identifier::from([4; 32]); - let document = crate::context::test_support::dpns_name_document( - owner_id, - "alice", - dash_sdk::dpp::platform_value::Value::Identifier(owner_id.to_buffer()), - ); - let fund = document_create_contest_fund_cap(domain, &document, platform_version) - .expect("contest check") - .expect("a contested name"); - assert_eq!(fund, contest_fee_credits(platform_version)); - let options = StateTransitionCreationOptions { - contest_fund: Some(fund), - ..Default::default() - }; - - let transition = BatchTransition::new_document_creation_transition_from_document( - document, - domain, - [3; 32], - &plain_key(1, SecurityLevel::HIGH), - 1, - UserFeeIncrease::default(), - None, - &PlaceholderSigner, - platform_version, - Some(options), - ) - .await - .expect("contested name create"); - - let StateTransition::Batch(batch) = transition else { - panic!("expected a batch, got {transition:?}"); - }; - let document_transition = match &batch { - BatchTransition::V0(v0) => v0.transitions[0].clone(), - BatchTransition::V1(v1) => match &v1.transitions[0] { - BatchedTransition::Document(document) => document.clone(), - other => panic!("expected a document transition, got {other:?}"), - }, - }; - assert!( - !matches!(document_transition.base(), DocumentBaseTransition::V2(_)), - "protocol 13 cannot decode {:?}", - document_transition.base() - ); - let DocumentTransition::Create(create) = document_transition else { - panic!("expected a create, got {document_transition:?}"); - }; - let stated = create - .prefunded_voting_balance() - .as_ref() - .map(|(_, credits)| *credits); - assert_eq!(stated, Some(contest_fee_credits(platform_version))); -} From 0555714f9d68cfe95071dd0dc2d56b62a8c026a4 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:10:58 +0000 Subject: [PATCH 24/58] refactor(context): read the network's protocol version from the SDK The SDK at this pin already tracks the version the network has proven: it records the version of every response whose proof and quorum signature it verified, and a rebuilt connection is a new SDK that starts from nothing. DET's own copy, raised from proved epochs and DPNS reads and reset on every rebuild, duplicated that state. The app now reads the current SDK's verified version; capabilities still stay closed before a version is proven and for one this build does not define, and network info shows the proven number or says none is confirmed. Proved epochs still set the fee multiplier, in epoch order and only on the current connection. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- .../README.md | 45 +++-- src/backend_task/mod.rs | 5 +- src/backend_task/platform_info.rs | 109 +++++------ src/backend_task/shielded/mod.rs | 5 +- src/context/feature_gate.rs | 45 ++--- src/context/mod.rs | 171 ++++++++---------- src/context/test_support.rs | 22 +++ src/mcp/resolve.rs | 42 ++--- tests/backend-e2e/platform_info.rs | 5 +- 9 files changed, 217 insertions(+), 232 deletions(-) diff --git a/docs/ai-design/2026-10-24-platform-v5.1-dev-bump/README.md b/docs/ai-design/2026-10-24-platform-v5.1-dev-bump/README.md index 387c531ac..f6248ec5d 100644 --- a/docs/ai-design/2026-10-24-platform-v5.1-dev-bump/README.md +++ b/docs/ai-design/2026-10-24-platform-v5.1-dev-bump/README.md @@ -9,22 +9,26 @@ It supersedes the open "Devnet protocol seed" item of ## Which protocol version DET builds for -There are two version sources, and they can disagree while a network moves -from 13 to 14: +DET keeps no copy of the network's protocol version. Both values below come +from the current SDK, and a rebuilt connection is a new SDK that starts from +scratch: - **The SDK's version** (`AppContext::platform_version()`). It starts at upstream's per-network `dash_sdk::sdk::min_protocol_version` (13 on mainnet, testnet and regtest, 14 on devnet) and ratchets to what verified responses - report. Transitions are encoded at this version. The SDK is no longer pinned - to a fixed table: a pinned SDK cannot build any protocol 14 transition, and - cannot follow the versioned identity lookup below. -- **The network's confirmed version** (`AppContext::platform_protocol_version()`). - It comes from a proved epoch read (or, when that fails, a proved DPNS fetch). - It is `0` until confirmed. Capabilities (`context/feature_gate.rs`) read only - this one, through `known_protocol_version`, so an unconfirmed network, or a - version this build does not define, never unlocks a protocol feature. - `connected_platform_version()` selects that version's tables for estimates, - such as the daily withdrawal limit. + report, never to a version this build does not define. Transitions are + encoded at this version, and its tables serve estimates such as the daily + withdrawal limit. The SDK is no longer pinned to a fixed table: a pinned SDK + cannot build any protocol 14 transition, and cannot follow the versioned + identity lookup below. +- **The network's proven version** (`AppContext::proven_protocol_version()`), + the SDK's `verified_protocol_version`: the highest version seen in a + response whose proof and quorum signature were verified, or `None` before + any. It can be a version this build does not define. + `AppContext::proven_platform_version()` is `None` then, as it is before + anything is proven. Capabilities (`context/feature_gate.rs`) read only that, + so an unproven network, or an unknown version, never unlocks a protocol + feature. Network info shows the proven number, or says none is confirmed. ## System contracts @@ -38,15 +42,16 @@ proof is never checked against the wrong schema. ## Connection state -- **Epoch adoption.** A proved epoch sets the fee multiplier and raises the - protocol version (`AppContext::adopt_epoch`). Reads can finish out of order, - so an epoch older than the adopted one is ignored, and the protocol version - only moves up. +- **Epoch adoption.** A proved epoch sets the fee multiplier + (`AppContext::adopt_epoch`). Reads can finish out of order, so an epoch older + than the adopted one is ignored. When the epoch cannot be read, a proved + DPNS fetch still lets the SDK learn the network's version. - **Rebuilding the connection** (`replace_connection`, `rebuild_connection`) - bumps a generation counter, forgets the adopted epoch, and puts the protocol - version back to unknown and the fee multiplier to the default, under one - lock, before the new SDK is published. A read begun on the old connection is - dropped. Capabilities stay closed until the new connection proves its epoch. + bumps a generation counter, forgets the adopted epoch and puts the fee + multiplier back to the default, under one lock, before the new SDK is + published. A read begun on the old connection is dropped. The new SDK has + proven no version, so capabilities stay closed until the new connection + proves one. ## Protocol 13 guards diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 1a2e193b5..59fc1cbed 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -2387,7 +2387,10 @@ mod tests { let tmp = tempfile::tempdir().expect("tempdir"); let ctx = test_app_context(tmp.path()); - ctx.set_platform_protocol_version(SHIELDED_POOL_INITIAL_PROTOCOL_VERSION); + crate::context::test_support::prove_protocol_version( + &ctx, + SHIELDED_POOL_INITIAL_PROTOCOL_VERSION, + ); assert!(!FeatureGate::ShieldedOperations.is_available(&ctx)); let (tx, _rx) = tokio::sync::mpsc::channel::(32); let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); diff --git a/src/backend_task/platform_info.rs b/src/backend_task/platform_info.rs index 1aa2ad953..2d989e428 100644 --- a/src/backend_task/platform_info.rs +++ b/src/backend_task/platform_info.rs @@ -564,9 +564,10 @@ fn build_withdrawals_result( impl AppContext { /// Fetches the current epoch — authenticated by proof — and adopts its - /// fee multiplier and protocol version. The only path by which the fee - /// multiplier cache takes a network value, and fee estimates follow it, - /// so it must never come from an unproven source. + /// fee multiplier. The only path by which the fee multiplier cache takes + /// a network value, and fee estimates follow it, so it must never come + /// from an unproven source. The verified response also ratchets the SDK's + /// protocol version. pub(crate) async fn refresh_current_epoch( &self, sdk: &Sdk, @@ -583,14 +584,13 @@ impl AppContext { Ok(epoch_info) } - /// Adopts the fee multiplier and protocol version of a proved epoch read - /// on connection `generation` (see `AppContext::adopt_epoch`). + /// Adopts the fee multiplier of a proved epoch read on connection + /// `generation` (see `AppContext::adopt_epoch`). fn adopt_epoch_info(&self, generation: u64, epoch_info: &ExtendedEpochInfo) { self.adopt_epoch( generation, epoch_info.index(), epoch_info.fee_multiplier_permille(), - epoch_info.protocol_version(), ); } @@ -643,27 +643,18 @@ impl AppContext { %error, "Current epoch fetch failed; keeping the last known fee multiplier" ); - // Without the epoch, the network's version is learned from - // a proved DPNS fetch: the version of the responses the SDK - // verified, never its local seed. - let (generation, on_current_sdk) = self.connection_snapshot_for(sdk); - match DataContract::fetch(sdk, self.dpns_contract().id()).await { - Ok(_) if on_current_sdk => { - if let Some(version) = network_proved_protocol_version(sdk) { - self.raise_platform_protocol_version_on(generation, version); - } - } - Ok(_) => {} - Err(error) => tracing::warn!( + // Without the epoch, a proved DPNS fetch lets the SDK learn + // the network's version from the response it verifies. + if let Err(error) = + DataContract::fetch(sdk, self.dpns_contract().id()).await + { + tracing::warn!( %error, "Protocol-version ratchet trigger (DPNS contract fetch) failed; \ the network's protocol version stays unconfirmed" - ), + ); } - let confirmed = match self.platform_protocol_version() { - 0 => None, - version => Some(version), - }; + let confirmed = self.proven_protocol_version(); Ok(BackendTaskSuccessResult::PlatformInfo( PlatformInfoTaskResult::TextResult( format_unavailable_current_epoch_info( @@ -761,7 +752,7 @@ impl AppContext { &withdrawal_docs, total_credits.0, self.network, - self.connected_platform_version(), + self.platform_version(), )?; Ok(BackendTaskSuccessResult::PlatformInfo( PlatformInfoTaskResult::TextResult(formatted), @@ -1001,17 +992,6 @@ fn completed_withdrawal_statuses() -> Vec { ] } -/// The protocol version `sdk` has seen in a response whose proof and quorum -/// signature were verified, or `None` before any such response. Never the -/// SDK's local seed: the SDK's own version starts at the network's upstream -/// minimum and only rises, so a successful read does not prove it. -fn network_proved_protocol_version(sdk: &Sdk) -> Option { - match sdk.query_settings().verified_protocol_version { - 0 => None, - version => Some(version), - } -} - #[cfg(test)] mod tests { use super::*; @@ -1071,13 +1051,15 @@ mod tests { /// fresh SDK has verified nothing, so nothing is proved. #[test] fn a_fresh_sdk_proves_no_protocol_version() { - let sdk = Sdk::new_mock(); + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = crate::context::test_support::test_app_context(temp_dir.path()); + ctx.sdk.store(Arc::new(Sdk::new_mock())); assert_ne!( - sdk.protocol_version_number(), + ctx.sdk.load().protocol_version_number(), 0, "precondition: a seeded SDK" ); - assert_eq!(network_proved_protocol_version(&sdk), None); + assert_eq!(ctx.proven_protocol_version(), None); } /// The limit is whatever the network's own protocol version says it is, @@ -1108,10 +1090,9 @@ mod tests { ); } - /// A proved epoch sets both the fee multiplier cache and the protocol - /// version. + /// A proved epoch sets the fee multiplier cache. #[test] - fn a_proved_epoch_updates_the_fee_multiplier_and_protocol_version() { + fn a_proved_epoch_updates_the_fee_multiplier() { use dash_sdk::dpp::block::extended_epoch_info::v0::ExtendedEpochInfoV0; let temp_dir = tempfile::tempdir().expect("tempdir"); let ctx = crate::context::test_support::test_app_context(temp_dir.path()); @@ -1130,11 +1111,10 @@ mod tests { ); assert_eq!(ctx.fee_multiplier_permille(), 1_500); - assert_eq!(ctx.platform_protocol_version(), 14); } /// Epoch reads can finish out of order: an older epoch arriving after a - /// newer one must not put back its fee multiplier or protocol version. + /// newer one must not put back its fee multiplier. #[test] fn an_older_epoch_does_not_overwrite_a_newer_one() { use dash_sdk::dpp::block::extended_epoch_info::v0::ExtendedEpochInfoV0; @@ -1154,36 +1134,34 @@ mod tests { ctx.adopt_epoch_info(ctx.connection_generation(), &epoch(8, 1_500, 14)); ctx.adopt_epoch_info(ctx.connection_generation(), &epoch(7, 1_000, 13)); assert_eq!(ctx.fee_multiplier_permille(), 1_500); - assert_eq!(ctx.platform_protocol_version(), 14); - // The same or a newer epoch is adopted; the protocol version never - // goes back. + // The same or a newer epoch is adopted. ctx.adopt_epoch_info(ctx.connection_generation(), &epoch(8, 1_600, 14)); assert_eq!(ctx.fee_multiplier_permille(), 1_600); ctx.adopt_epoch_info(ctx.connection_generation(), &epoch(9, 1_700, 13)); assert_eq!(ctx.fee_multiplier_permille(), 1_700); - assert_eq!(ctx.platform_protocol_version(), 14); } /// Rebuilding the connection resets everything learned from the old one: - /// the protocol version goes back to unknown, the fee multiplier to the - /// default, and the next proved epoch is adopted whatever its index. + /// the new SDK has proven no protocol version, the fee multiplier goes + /// back to the default, and the next proved epoch is adopted whatever its + /// index. #[test] fn a_rebuilt_connection_starts_from_unknown_protocol_and_default_fees() { let temp_dir = tempfile::tempdir().expect("tempdir"); let ctx = crate::context::test_support::test_app_context(temp_dir.path()); - ctx.adopt_epoch(ctx.connection_generation(), 8, 1_500, 14); + crate::context::test_support::prove_protocol_version(&ctx, 14); + ctx.adopt_epoch(ctx.connection_generation(), 8, 1_500); ctx.replace_connection(Arc::new(Sdk::new_mock())); - assert_eq!(ctx.platform_protocol_version(), 0); + assert_eq!(ctx.proven_protocol_version(), None); assert_eq!( ctx.fee_multiplier_permille(), crate::model::fee_estimation::PlatformFeeEstimator::DEFAULT_FEE_MULTIPLIER_PERMILLE ); - assert!(ctx.adopt_epoch(ctx.connection_generation(), 2, 1_100, 13)); + assert!(ctx.adopt_epoch(ctx.connection_generation(), 2, 1_100)); assert_eq!(ctx.fee_multiplier_permille(), 1_100); - assert_eq!(ctx.platform_protocol_version(), 13); } /// An epoch read that started on the old connection and finishes after @@ -1197,8 +1175,7 @@ mod tests { ctx.replace_connection(Arc::new(Sdk::new_mock())); - assert!(!ctx.adopt_epoch(before_rebuild, 9, 1_500, 14)); - assert_eq!(ctx.platform_protocol_version(), 0); + assert!(!ctx.adopt_epoch(before_rebuild, 9, 1_500)); assert_eq!( ctx.fee_multiplier_permille(), crate::model::fee_estimation::PlatformFeeEstimator::DEFAULT_FEE_MULTIPLIER_PERMILLE @@ -1270,23 +1247,35 @@ mod tests { } /// A failed ratchet trigger leaves the SDK reporting its local seed, which is - /// not a network observation: caching it would open the shielded capability - /// gate and defeat the `0` retry sentinel `mcp::resolve` polls. + /// not a network observation: treating it as proven would open the shielded + /// capability gate and stop the retry `mcp::resolve` makes while nothing is + /// proven. The report names the version as unconfirmed. #[tokio::test] async fn a_failed_ratchet_trigger_leaves_the_protocol_version_unconfirmed() { let temp_dir = tempfile::tempdir().expect("tempdir"); let ctx = crate::context::test_support::test_app_context(temp_dir.path()); - let sdk = dash_sdk::Sdk::new_mock(); + ctx.sdk.store(Arc::new(dash_sdk::Sdk::new_mock())); + let sdk = ctx.sdk.load_full(); assert_ne!( sdk.protocol_version_number(), 0, "precondition: the mock SDK seeds a local version of its own" ); - ctx.run_platform_info_task(PlatformInfoTaskRequestType::CurrentEpochInfo, &sdk) + let result = ctx + .run_platform_info_task(PlatformInfoTaskRequestType::CurrentEpochInfo, &sdk) .await .expect("a failed epoch fetch degrades to a text result"); - assert_eq!(ctx.platform_protocol_version(), 0); + assert_eq!(ctx.proven_protocol_version(), None); + let BackendTaskSuccessResult::PlatformInfo(PlatformInfoTaskResult::TextResult(text)) = + result + else { + panic!("a failed epoch fetch returns a text result"); + }; + assert!( + text.contains("has not confirmed one yet"), + "an unconfirmed version must be named as such, got: {text}" + ); } } diff --git a/src/backend_task/shielded/mod.rs b/src/backend_task/shielded/mod.rs index be0c365db..26a9aae28 100644 --- a/src/backend_task/shielded/mod.rs +++ b/src/backend_task/shielded/mod.rs @@ -342,7 +342,10 @@ mod tests { let tmp = tempfile::tempdir().expect("tempdir"); let ctx = test_app_context(tmp.path()); - ctx.set_platform_protocol_version(SHIELDED_POOL_INITIAL_PROTOCOL_VERSION); + crate::context::test_support::prove_protocol_version( + &ctx, + SHIELDED_POOL_INITIAL_PROTOCOL_VERSION, + ); assert!(!FeatureGate::ShieldedOperations.is_available(&ctx)); assert!(ctx.wallet_backend().is_err(), "precondition"); diff --git a/src/context/feature_gate.rs b/src/context/feature_gate.rs index 014d31dcf..502640abb 100644 --- a/src/context/feature_gate.rs +++ b/src/context/feature_gate.rs @@ -1,6 +1,5 @@ use crate::context::AppContext; use crate::model::user_role::UserRole; -use dash_sdk::dpp::version::PlatformVersion; use dash_sdk::dpp::version::feature_initial_protocol_versions::SHIELDED_POOL_INITIAL_PROTOCOL_VERSION; /// Shielded state transitions activate at upstream rs-platform-version's @@ -29,20 +28,16 @@ pub enum Capability { ShieldedProtocol, } -/// The connected network's protocol version, when this build defines it. +/// Whether the protocol version the connected network has proven is one this +/// build defines and is at least `activation`. /// -/// `None` before the version is fetched (0) and for a version newer than any -/// this build knows, which a proved epoch can report after a network upgrade. -/// Every capability reads the version through this, so both fail closed: this -/// build cannot tell what an unknown version's rules allow. -fn known_protocol_version(ctx: &AppContext) -> Option<&'static PlatformVersion> { - PlatformVersion::get_optional(ctx.platform_protocol_version()) -} - -/// Whether the connected protocol version is one this build defines and is -/// at least `activation`. +/// Every capability reads the version through this, so it fails closed both +/// before the network proves a version and for a version newer than any this +/// build knows ([`AppContext::proven_platform_version`]): this build cannot +/// tell what an unknown version's rules allow. fn known_protocol_version_at_least(ctx: &AppContext, activation: u32) -> bool { - known_protocol_version(ctx).is_some_and(|version| version.protocol_version >= activation) + ctx.proven_platform_version() + .is_some_and(|version| version.protocol_version >= activation) } impl Capability { @@ -188,7 +183,7 @@ impl FeatureGate { #[cfg(test)] mod tests { use super::*; - use crate::context::test_support::test_app_context; + use crate::context::test_support::{prove_protocol_version, test_app_context}; use dash_sdk::dpp::version::PlatformVersion; use std::sync::Arc; @@ -287,7 +282,7 @@ mod tests { fn shielded_capability_is_unmet_before_a_version_is_fetched() { let (_tmp, ctx) = ctx_with_role(UserRole::Developer); - assert_eq!(ctx.platform_protocol_version(), 0, "boot value"); + assert_eq!(ctx.proven_protocol_version(), None, "boot value"); assert!(!Capability::ShieldedProtocol.is_met(&ctx)); assert!( !FeatureGate::ShieldedOperations.is_available(&ctx), @@ -313,7 +308,7 @@ mod tests { // Every protocol version upstream defines — none of which the tab // gate depends on. for version in known_protocol_versions() { - ctx.set_platform_protocol_version(version); + prove_protocol_version(&ctx, version); assert!( FeatureGate::Shielded.is_available(&ctx), "Shielded tab must be available on protocol v{version} for {role:?}" @@ -329,7 +324,7 @@ mod tests { assert!(!versions.is_empty(), "the probe must find some versions"); for version in versions { - ctx.set_platform_protocol_version(version); + prove_protocol_version(&ctx, version); assert_eq!( Capability::ShieldedProtocol.is_met(&ctx), version >= SHIELDED_POOL_INITIAL_PROTOCOL_VERSION, @@ -342,7 +337,7 @@ mod tests { fn shielded_operations_are_available_at_activation_for_unlocked_roles() { for role in [UserRole::Power, UserRole::Developer] { let (_tmp, ctx) = ctx_with_role(role); - ctx.set_platform_protocol_version(SHIELDED_POOL_INITIAL_PROTOCOL_VERSION); + prove_protocol_version(&ctx, SHIELDED_POOL_INITIAL_PROTOCOL_VERSION); assert!( FeatureGate::ShieldedOperations.is_available(&ctx), @@ -357,7 +352,7 @@ mod tests { let version = SHIELDED_POOL_INITIAL_PROTOCOL_VERSION .checked_sub(1) .expect("shielded activation must follow the boot protocol version"); - ctx.set_platform_protocol_version(version); + prove_protocol_version(&ctx, version); assert!(!FeatureGate::ShieldedOperations.is_available(&ctx)); } @@ -365,7 +360,7 @@ mod tests { #[test] fn shielded_operations_are_unavailable_at_activation_for_everyday_user() { let (_tmp, ctx) = ctx_with_role(UserRole::Everyday); - ctx.set_platform_protocol_version(SHIELDED_POOL_INITIAL_PROTOCOL_VERSION); + prove_protocol_version(&ctx, SHIELDED_POOL_INITIAL_PROTOCOL_VERSION); assert!(!FeatureGate::ShieldedOperations.is_available(&ctx)); } @@ -376,21 +371,21 @@ mod tests { let version = SHIELDED_POOL_INITIAL_PROTOCOL_VERSION .checked_sub(1) .expect("shielded activation must follow the boot protocol version"); - ctx.set_platform_protocol_version(version); + prove_protocol_version(&ctx, version); assert_eq!( FeatureGate::ShieldedOperations.first_unmet_check(&ctx), Some(Check::Capability(Capability::ShieldedProtocol)) ); let (_tmp, ctx) = ctx_with_role(UserRole::Everyday); - ctx.set_platform_protocol_version(SHIELDED_POOL_INITIAL_PROTOCOL_VERSION); + prove_protocol_version(&ctx, SHIELDED_POOL_INITIAL_PROTOCOL_VERSION); assert_eq!( FeatureGate::ShieldedOperations.first_unmet_check(&ctx), Some(Check::Experimental(ExperimentalFeature::Shielded)) ); let (_tmp, ctx) = ctx_with_role(UserRole::Developer); - ctx.set_platform_protocol_version(SHIELDED_POOL_INITIAL_PROTOCOL_VERSION); + prove_protocol_version(&ctx, SHIELDED_POOL_INITIAL_PROTOCOL_VERSION); assert_eq!( FeatureGate::ShieldedOperations.first_unmet_check(&ctx), None @@ -444,7 +439,7 @@ mod tests { let (_tmp, ctx) = ctx_with_role(UserRole::Developer); let capabilities = [Capability::ShieldedProtocol]; - ctx.set_platform_protocol_version(LATEST_VERSION); + prove_protocol_version(&ctx, LATEST_VERSION); for capability in capabilities { assert!( capability.is_met(&ctx), @@ -452,7 +447,7 @@ mod tests { ); } - ctx.set_platform_protocol_version(LATEST_VERSION + 1); + prove_protocol_version(&ctx, LATEST_VERSION + 1); for capability in capabilities { assert!( !capability.is_met(&ctx), diff --git a/src/context/mod.rs b/src/context/mod.rs index f2c62f63c..e7aa3c93b 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -69,7 +69,7 @@ use platform_wallet_storage::secrets::SecretStore; use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::PathBuf; use std::str::FromStr as _; -use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock, RwLockWriteGuard}; use system_contracts::{SystemContracts, SystemContractsCache}; @@ -323,10 +323,6 @@ pub struct AppContext { /// Cached fee multiplier permille from current epoch (1000 = 1x, 2000 = 2x) /// Updated when epoch info is fetched from Platform fee_multiplier_permille: AtomicU64, - /// Cached protocol version from the current epoch on the connected network. - /// Updated alongside fee_multiplier when epoch info is fetched. - /// 0 means not yet fetched from the network. - platform_protocol_version: AtomicU32, /// The connection generation and the last adopted proved epoch. See /// [`Self::adopt_epoch`] and [`Self::replace_connection`]. epoch_adoption: Mutex, @@ -654,7 +650,6 @@ impl AppContext { fee_multiplier_permille: AtomicU64::new( PlatformFeeEstimator::DEFAULT_FEE_MULTIPLIER_PERMILLE, ), - platform_protocol_version: AtomicU32::new(0), epoch_adoption: Mutex::new(EpochAdoption::default()), shielded_balances: Arc::new(Mutex::new(std::collections::HashMap::new())), shielded_addresses: Arc::new(Mutex::new(std::collections::HashMap::new())), @@ -860,29 +855,6 @@ impl AppContext { .store(multiplier, Ordering::Relaxed); } - /// Get the cached platform protocol version from the connected network. - /// Returns 0 if not yet fetched from the network. - pub fn platform_protocol_version(&self) -> u32 { - self.platform_protocol_version.load(Ordering::Relaxed) - } - - /// Cache the platform protocol version fetched from the connected network. - /// - /// Read by the shielded-operations capability check to decide whether the - /// network can settle shielded state transitions. `0` means "not fetched - /// yet", which reads as below any activation version. - pub fn set_platform_protocol_version(&self, version: u32) { - self.platform_protocol_version - .store(version, Ordering::Relaxed); - } - - /// Raise the cached platform protocol version to `version`; a lower one - /// is ignored. What the network proves never moves it back. - pub(crate) fn raise_platform_protocol_version(&self, version: u32) { - self.platform_protocol_version - .fetch_max(version, Ordering::Relaxed); - } - /// Synchronous read of the frame-safe shielded balance for `seed_hash`. /// /// Returns the total shielded balance in duffs, summed across all Orchard accounts. @@ -1156,19 +1128,28 @@ impl AppContext { self.system_contracts.try_get(platform_version) } - /// The platform version of the connected network, once its protocol - /// version has been fetched; [`Self::platform_version`] until then, or for - /// a protocol version this build does not know. - /// - /// For values that differ between protocol versions and must match the - /// network, such as fees introduced by a newer protocol. - pub fn connected_platform_version(&self) -> &'static PlatformVersion { - match self.platform_protocol_version() { - 0 => self.platform_version(), - version => PlatformVersion::get_optional(version).unwrap_or(self.platform_version()), + /// The protocol version the current SDK has seen in a response whose + /// proof and quorum signature were verified, or `None` before any such + /// response. A rebuilt connection is a new SDK and starts from `None`. + /// The number may be one this build does not define; see + /// [`Self::proven_platform_version`]. + pub fn proven_protocol_version(&self) -> Option { + match self.sdk.load().query_settings().verified_protocol_version { + 0 => None, + version => Some(version), } } + /// The platform version the connected network has proven + /// ([`Self::proven_protocol_version`]), when this build defines it. + /// `None` before the network proves a version and for a version newer + /// than any this build knows, so every capability reading it fails + /// closed. + pub fn proven_platform_version(&self) -> Option<&'static PlatformVersion> { + self.proven_protocol_version() + .and_then(PlatformVersion::get_optional) + } + pub fn state_transition_options(&self) -> Option { // Signing override: only the Developer role may relax the security-level // and purpose checks when signing a state transition. @@ -1201,22 +1182,20 @@ impl AppContext { .generation } - /// Adopt the fee multiplier and protocol version of the proved epoch - /// `epoch_index`, read on connection `generation`. Returns whether it was - /// adopted. + /// Adopt the fee multiplier of the proved epoch `epoch_index`, read on + /// connection `generation`. Returns whether it was adopted. /// /// Dropped when the connection was rebuilt since the read was sent, or /// when an epoch newer than it was adopted already: epoch reads run /// concurrently (platform info, fee refusals) and can finish out of - /// order. The protocol version only moves up. The lock covers the checks - /// and both stores, so an adoption and a rebuild cannot interleave; - /// nothing is awaited under it. + /// order. The lock covers the checks and the store, so an adoption and a + /// rebuild cannot interleave; nothing is awaited under it. The protocol + /// version is the SDK's own ([`Self::proven_protocol_version`]). pub(crate) fn adopt_epoch( &self, generation: u64, epoch_index: EpochIndex, fee_multiplier_permille: u64, - protocol_version: u32, ) -> bool { let mut adoption = self .epoch_adoption @@ -1231,41 +1210,22 @@ impl AppContext { } adoption.epoch_index = Some(epoch_index); self.set_fee_multiplier_permille(fee_multiplier_permille); - self.raise_platform_protocol_version(protocol_version); - true - } - - /// Raise the protocol version to `protocol_version`, proved on connection - /// `generation`; dropped if the connection was rebuilt since. Returns - /// whether it was applied. - pub(crate) fn raise_platform_protocol_version_on( - &self, - generation: u64, - protocol_version: u32, - ) -> bool { - let adoption = self - .epoch_adoption - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if generation != adoption.generation { - return false; - } - self.raise_platform_protocol_version(protocol_version); true } /// Replace the connection's SDK with `new_sdk` and forget everything /// learned from the old connection: bump the generation, so reads still in - /// flight on it are dropped, forget the adopted epoch, and put the - /// protocol version back to unknown and the fee multiplier to the default. - /// The new endpoints may serve another protocol version or a reset devnet; - /// capabilities stay closed until the new connection proves its epoch. + /// flight on it are dropped, forget the adopted epoch, and put the fee + /// multiplier back to the default. The new SDK has verified nothing, so + /// the proven protocol version is unknown again and capabilities stay + /// closed until the new connection proves one: the new endpoints may serve + /// another protocol version or a reset devnet. /// /// The reset is written before the new SDK is published, under the /// adoption lock: whoever loads the new SDK sees the reset state, and a /// [`Self::connection_snapshot_for`] sees either the old generation and - /// SDK or the new ones. A task still holding the old SDK may see the reset - /// state, which closes capabilities and uses default fees: fail closed. + /// SDK or the new ones. A task still holding the old SDK may see the + /// default fees: fail closed. pub(crate) fn replace_connection(&self, new_sdk: Arc) { self.replace_connection_observed(new_sdk, |_| {}); } @@ -1279,7 +1239,6 @@ impl AppContext { adoption.generation += 1; adoption.epoch_index = None; self.set_fee_multiplier_permille(PlatformFeeEstimator::DEFAULT_FEE_MULTIPLIER_PERMILLE); - self.set_platform_protocol_version(0); observe(self); self.sdk.store(new_sdk); } @@ -2066,20 +2025,61 @@ mod tests { assert_eq!(ctx.dashpay_contract().id(), boot_dashpay.id()); } + /// The proven protocol version is the current SDK's verified one: none at + /// boot, the proven number once a verified response carried it, and none + /// again on a rebuilt connection, whose SDK has verified nothing. + #[test] + fn the_proven_protocol_version_follows_the_current_sdk() { + use crate::context::test_support::{prove_protocol_version, test_app_context}; + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(temp_dir.path()); + assert_eq!(ctx.proven_protocol_version(), None, "boot"); + assert!(ctx.proven_platform_version().is_none(), "boot"); + + prove_protocol_version(&ctx, 13); + assert_eq!(ctx.proven_protocol_version(), Some(13)); + assert_eq!( + ctx.proven_platform_version().map(|v| v.protocol_version), + Some(13) + ); + + ctx.replace_connection(Arc::new(dash_sdk::Sdk::new_mock())); + assert_eq!(ctx.proven_protocol_version(), None, "rebuilt connection"); + assert!( + ctx.proven_platform_version().is_none(), + "rebuilt connection" + ); + } + + /// A proven version this build does not define is reported as proven, but + /// no platform version is derived from it: nothing here knows its rules. + #[test] + fn an_unknown_proven_version_has_no_platform_version() { + use crate::context::test_support::{prove_protocol_version, test_app_context}; + use dash_sdk::dpp::version::LATEST_VERSION; + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(temp_dir.path()); + + prove_protocol_version(&ctx, LATEST_VERSION + 1); + + assert_eq!(ctx.proven_protocol_version(), Some(LATEST_VERSION + 1)); + assert!(ctx.proven_platform_version().is_none()); + } + /// A task that loads the new SDK always sees the reset connection state: - /// the new SDK never pairs with the old connection's protocol version or - /// fee multiplier. + /// the new SDK never pairs with the old connection's fee multiplier, and + /// it has proven no protocol version. #[test] fn the_new_sdk_is_published_only_after_the_connection_state_is_reset() { let temp_dir = tempfile::tempdir().expect("tempdir"); let ctx = crate::context::test_support::test_app_context(temp_dir.path()); - ctx.adopt_epoch(ctx.connection_generation(), 8, 1_500, 14); + ctx.adopt_epoch(ctx.connection_generation(), 8, 1_500); let new_sdk = Arc::new(dash_sdk::Sdk::new_mock()); let new_sdk_ptr = Arc::as_ptr(&new_sdk); ctx.replace_connection_observed(new_sdk, |ctx| { if Arc::as_ptr(&ctx.sdk.load_full()) == new_sdk_ptr { - assert_eq!(ctx.platform_protocol_version(), 0, "new SDK, old protocol"); + assert_eq!(ctx.proven_protocol_version(), None, "new SDK, old protocol"); assert_eq!( ctx.fee_multiplier_permille(), crate::model::fee_estimation::PlatformFeeEstimator::DEFAULT_FEE_MULTIPLIER_PERMILLE, @@ -2089,22 +2089,7 @@ mod tests { }); assert_eq!(Arc::as_ptr(&ctx.sdk.load_full()), new_sdk_ptr); - assert_eq!(ctx.platform_protocol_version(), 0); - } - - /// Until the network's version is fetched, the connected version is the - /// one the SDK builds with. - #[test] - fn connected_platform_version_falls_back_to_the_sdk_version() { - use crate::context::test_support::test_app_context_for_network; - - let tmp = tempfile::tempdir().expect("tempdir"); - let ctx = test_app_context_for_network(tmp.path(), Network::Mainnet); - assert_eq!(ctx.platform_protocol_version(), 0); - assert_eq!( - ctx.connected_platform_version().protocol_version, - ctx.sdk.load().protocol_version_number() - ); + assert_eq!(ctx.proven_protocol_version(), None); } /// Contest windows stay consistent as the SDK ratchets upward from each diff --git a/src/context/test_support.rs b/src/context/test_support.rs index b75e9bd1d..969d9d07a 100644 --- a/src/context/test_support.rs +++ b/src/context/test_support.rs @@ -186,6 +186,28 @@ pub(crate) fn set_sdk_protocol_version(ctx: &AppContext, protocol_version: u32) ctx.sdk.store(Arc::new(sdk)); } +/// Have `ctx`'s SDK learn `protocol_version` as it does from a response whose +/// proof and quorum signature were verified. Only raises it, as the network +/// does; an unknown version is recorded as proven, as upstream records it. +pub(crate) fn prove_protocol_version(ctx: &AppContext, protocol_version: u32) { + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_millis() as u64; + ctx.sdk + .load() + .verify_response_metadata( + "test", + &dash_sdk::dapi_grpc::platform::v0::ResponseMetadata { + height: 1, + time_ms: now_ms, + protocol_version, + ..Default::default() + }, + ) + .expect("fresh proved metadata"); +} + /// A mock SDK that reads data contracts through `ctx`, as the app's own SDK does. pub(crate) fn mock_sdk_for(ctx: &AppContext) -> Sdk { let sdk = Sdk::new_mock(); diff --git a/src/mcp/resolve.rs b/src/mcp/resolve.rs index 652b7c214..f6d12dce1 100644 --- a/src/mcp/resolve.rs +++ b/src/mcp/resolve.rs @@ -349,7 +349,7 @@ async fn wait_for_spv_and_refresh_platform_info( } match protocol_refresh { - ProtocolRefresh::BestEffortIfUnpopulated if ctx.platform_protocol_version() == 0 => { + ProtocolRefresh::BestEffortIfUnpopulated if ctx.proven_protocol_version().is_none() => { let _ = refresh_platform_protocol_version(ctx).await; Ok(()) } @@ -376,7 +376,7 @@ async fn refresh_platform_protocol_version(ctx: &Arc) -> Result<(), })?; tracing::trace!( - protocol_version = ctx.platform_protocol_version(), + protocol_version = ?ctx.proven_protocol_version(), "Platform epoch information refreshed for headless feature gating" ); Ok(()) @@ -449,12 +449,10 @@ mod tests { /// Mocks the DPNS-contract fetch that drives the protocol-version ratchet, /// the epoch task's fallback when the current epoch cannot be read (the - /// mock has no epoch response). The fallback takes only a version the SDK + /// mock has no epoch response). The app reads the version the SDK /// verified, and a mock expectation is made up, never verified, so the - /// SDK first accepts `LATEST_VERSION` from metadata it is told was proved, - /// as it does after a live network's quorum-signed response. A successful - /// fetch then ratchets the app exactly as a live network would; a failed - /// one leaves the version unconfirmed. + /// SDK accepts `LATEST_VERSION` from metadata it is told was proved, as + /// it does after a live network's quorum-signed response. async fn mock_sdk_with_dpns_contract(ctx: &Arc) -> Sdk { let sdk = mock_sdk_with_unproved_dpns_contract(ctx).await; sdk.verify_response_metadata( @@ -494,45 +492,31 @@ mod tests { .await .expect("refresh platform metadata"); - assert_eq!(ctx.platform_protocol_version(), 0); + assert_eq!(ctx.proven_protocol_version(), None); } + /// Once SPV runs, the headless sync completes and reads the protocol + /// version the current SDK has proven; the fee multiplier stays at the + /// default without a proved epoch. #[tokio::test] - async fn headless_sync_populates_protocol_version_after_spv_is_running() { + async fn headless_sync_reads_the_proven_protocol_version_after_spv_is_running() { let temp_dir = tempfile::tempdir().expect("tempdir"); let ctx = crate::mcp::tests::legacy_wallet_context(temp_dir.path()); ctx.sdk .store(Arc::new(mock_sdk_with_dpns_contract(&ctx).await)); ctx.connection_status().set_spv_status(SpvStatus::Running); - assert_eq!(ctx.platform_protocol_version(), 0, "boot value"); - wait_for_spv_and_refresh_platform_info(&ctx, ProtocolRefresh::BestEffortIfUnpopulated) .await .expect("headless sync completion"); - assert_eq!(ctx.platform_protocol_version(), LATEST_VERSION); + assert_eq!(ctx.proven_protocol_version(), Some(LATEST_VERSION)); assert_eq!( ctx.fee_multiplier_permille(), crate::model::fee_estimation::PlatformFeeEstimator::DEFAULT_FEE_MULTIPLIER_PERMILLE ); } - #[tokio::test] - async fn protocol_refresh_observes_activation_after_a_pre_activation_epoch() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let ctx = crate::mcp::tests::legacy_wallet_context(temp_dir.path()); - ctx.set_platform_protocol_version(LATEST_VERSION - 1); - ctx.sdk - .store(Arc::new(mock_sdk_with_dpns_contract(&ctx).await)); - - refresh_platform_protocol_version(&ctx) - .await - .expect("refresh platform metadata"); - - assert_eq!(ctx.platform_protocol_version(), LATEST_VERSION); - } - #[tokio::test] async fn best_effort_protocol_refresh_retries_without_blocking_spv_readiness() { let temp_dir = tempfile::tempdir().expect("tempdir"); @@ -543,7 +527,7 @@ mod tests { wait_for_spv_and_refresh_platform_info(&ctx, ProtocolRefresh::BestEffortIfUnpopulated) .await .expect("SPV readiness survives a Platform metadata failure"); - assert_eq!(ctx.platform_protocol_version(), 0); + assert_eq!(ctx.proven_protocol_version(), None); ctx.sdk .store(Arc::new(mock_sdk_with_dpns_contract(&ctx).await)); @@ -551,7 +535,7 @@ mod tests { .await .expect("SPV readiness retries Platform metadata"); - assert_eq!(ctx.platform_protocol_version(), LATEST_VERSION); + assert_eq!(ctx.proven_protocol_version(), Some(LATEST_VERSION)); } /// Insert a wallet straight into the in-memory map, bypassing registration diff --git a/tests/backend-e2e/platform_info.rs b/tests/backend-e2e/platform_info.rs index fc87b7922..cd5304e75 100644 --- a/tests/backend-e2e/platform_info.rs +++ b/tests/backend-e2e/platform_info.rs @@ -55,9 +55,8 @@ async fn test_pi001_current_epoch_is_fetched_live() { // The fetch is a network observation, so it confirms the protocol version. let ctx = ctx().await; - assert_ne!( - ctx.app_context.platform_protocol_version(), - 0, + assert!( + ctx.app_context.proven_protocol_version().is_some(), "a live epoch fetch must confirm the network's protocol version" ); } From e7a9a451610620847fe7bfcec56b51136b783480 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:17:59 +0000 Subject: [PATCH 25/58] refactor(context): set the fee multiplier from each proved epoch read A connection rebuild never changes the chain, the multiplier changes once per epoch and feeds only fee estimates, so ordering epoch reads and dropping those begun before a rebuild guarded against nothing worth the machinery. A proved epoch read sets the multiplier directly again; an unavailable epoch still keeps the last known value and labels it as such. A rebuild still reads the epoch, so the new SDK proves the network's version again. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- .../README.md | 19 ++- src/backend_task/platform_info.rs | 108 ++------------ src/context/mod.rs | 141 ++---------------- 3 files changed, 31 insertions(+), 237 deletions(-) diff --git a/docs/ai-design/2026-10-24-platform-v5.1-dev-bump/README.md b/docs/ai-design/2026-10-24-platform-v5.1-dev-bump/README.md index f6248ec5d..adb142ed0 100644 --- a/docs/ai-design/2026-10-24-platform-v5.1-dev-bump/README.md +++ b/docs/ai-design/2026-10-24-platform-v5.1-dev-bump/README.md @@ -42,16 +42,15 @@ proof is never checked against the wrong schema. ## Connection state -- **Epoch adoption.** A proved epoch sets the fee multiplier - (`AppContext::adopt_epoch`). Reads can finish out of order, so an epoch older - than the adopted one is ignored. When the epoch cannot be read, a proved - DPNS fetch still lets the SDK learn the network's version. -- **Rebuilding the connection** (`replace_connection`, `rebuild_connection`) - bumps a generation counter, forgets the adopted epoch and puts the fee - multiplier back to the default, under one lock, before the new SDK is - published. A read begun on the old connection is dropped. The new SDK has - proven no version, so capabilities stay closed until the new connection - proves one. +- **Fee multiplier.** A proved epoch read sets the fee multiplier, which feeds + fee estimates only. When the epoch cannot be read, the last known value is + kept and shown as such, never as a fresh network reading, and a proved DPNS + fetch still lets the SDK learn the network's version. +- **Rebuilding the connection** (`rebuild_connection`) builds a new SDK over + the same network, so nothing about the chain changes. The new SDK has proven + no version, so capabilities stay closed until a proved response carries + one; the rebuild reads the epoch right away for that, and the read refreshes + the fee multiplier. ## Protocol 13 guards diff --git a/src/backend_task/platform_info.rs b/src/backend_task/platform_info.rs index 2d989e428..2a1fc82c8 100644 --- a/src/backend_task/platform_info.rs +++ b/src/backend_task/platform_info.rs @@ -563,7 +563,7 @@ fn build_withdrawals_result( } impl AppContext { - /// Fetches the current epoch — authenticated by proof — and adopts its + /// Fetches the current epoch — authenticated by proof — and sets its /// fee multiplier. The only path by which the fee multiplier cache takes /// a network value, and fee estimates follow it, so it must never come /// from an unproven source. The verified response also ratchets the SDK's @@ -572,26 +572,16 @@ impl AppContext { &self, sdk: &Sdk, ) -> Result> { - // Captured together before the read: a read on an SDK a rebuild has - // replaced, or begun before it, is never adopted. - let (generation, on_current_sdk) = self.connection_snapshot_for(sdk); let epoch_info = ExtendedEpochInfo::fetch_current(sdk) .await .map_err(Box::new)?; - if on_current_sdk { - self.adopt_epoch_info(generation, &epoch_info); - } + self.set_fee_multiplier_from(&epoch_info); Ok(epoch_info) } - /// Adopts the fee multiplier of a proved epoch read on connection - /// `generation` (see `AppContext::adopt_epoch`). - fn adopt_epoch_info(&self, generation: u64, epoch_info: &ExtendedEpochInfo) { - self.adopt_epoch( - generation, - epoch_info.index(), - epoch_info.fee_multiplier_permille(), - ); + /// Sets the fee multiplier cache from a proved epoch. + fn set_fee_multiplier_from(&self, epoch_info: &ExtendedEpochInfo) { + self.set_fee_multiplier_permille(epoch_info.fee_multiplier_permille()); } pub async fn run_platform_info_task( @@ -1098,88 +1088,16 @@ mod tests { let ctx = crate::context::test_support::test_app_context(temp_dir.path()); ctx.set_fee_multiplier_permille(1000); - ctx.adopt_epoch_info( - ctx.connection_generation(), - &ExtendedEpochInfo::V0(ExtendedEpochInfoV0 { - index: 7, - first_block_time: 0, - first_block_height: 0, - first_core_block_height: 0, - fee_multiplier_permille: 1_500, - protocol_version: 14, - }), - ); - - assert_eq!(ctx.fee_multiplier_permille(), 1_500); - } - - /// Epoch reads can finish out of order: an older epoch arriving after a - /// newer one must not put back its fee multiplier. - #[test] - fn an_older_epoch_does_not_overwrite_a_newer_one() { - use dash_sdk::dpp::block::extended_epoch_info::v0::ExtendedEpochInfoV0; - let epoch = |index, fee_multiplier_permille, protocol_version| { - ExtendedEpochInfo::V0(ExtendedEpochInfoV0 { - index, - first_block_time: 0, - first_block_height: 0, - first_core_block_height: 0, - fee_multiplier_permille, - protocol_version, - }) - }; - let temp_dir = tempfile::tempdir().expect("tempdir"); - let ctx = crate::context::test_support::test_app_context(temp_dir.path()); + ctx.set_fee_multiplier_from(&ExtendedEpochInfo::V0(ExtendedEpochInfoV0 { + index: 7, + first_block_time: 0, + first_block_height: 0, + first_core_block_height: 0, + fee_multiplier_permille: 1_500, + protocol_version: 14, + })); - ctx.adopt_epoch_info(ctx.connection_generation(), &epoch(8, 1_500, 14)); - ctx.adopt_epoch_info(ctx.connection_generation(), &epoch(7, 1_000, 13)); assert_eq!(ctx.fee_multiplier_permille(), 1_500); - - // The same or a newer epoch is adopted. - ctx.adopt_epoch_info(ctx.connection_generation(), &epoch(8, 1_600, 14)); - assert_eq!(ctx.fee_multiplier_permille(), 1_600); - ctx.adopt_epoch_info(ctx.connection_generation(), &epoch(9, 1_700, 13)); - assert_eq!(ctx.fee_multiplier_permille(), 1_700); - } - - /// Rebuilding the connection resets everything learned from the old one: - /// the new SDK has proven no protocol version, the fee multiplier goes - /// back to the default, and the next proved epoch is adopted whatever its - /// index. - #[test] - fn a_rebuilt_connection_starts_from_unknown_protocol_and_default_fees() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let ctx = crate::context::test_support::test_app_context(temp_dir.path()); - crate::context::test_support::prove_protocol_version(&ctx, 14); - ctx.adopt_epoch(ctx.connection_generation(), 8, 1_500); - - ctx.replace_connection(Arc::new(Sdk::new_mock())); - - assert_eq!(ctx.proven_protocol_version(), None); - assert_eq!( - ctx.fee_multiplier_permille(), - crate::model::fee_estimation::PlatformFeeEstimator::DEFAULT_FEE_MULTIPLIER_PERMILLE - ); - assert!(ctx.adopt_epoch(ctx.connection_generation(), 2, 1_100)); - assert_eq!(ctx.fee_multiplier_permille(), 1_100); - } - - /// An epoch read that started on the old connection and finishes after - /// the rebuild is dropped, so it cannot be the new connection's first - /// adoption. - #[test] - fn an_epoch_read_from_before_a_rebuild_is_dropped() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let ctx = crate::context::test_support::test_app_context(temp_dir.path()); - let before_rebuild = ctx.connection_generation(); - - ctx.replace_connection(Arc::new(Sdk::new_mock())); - - assert!(!ctx.adopt_epoch(before_rebuild, 9, 1_500)); - assert_eq!( - ctx.fee_multiplier_permille(), - crate::model::fee_estimation::PlatformFeeEstimator::DEFAULT_FEE_MULTIPLIER_PERMILLE - ); } #[test] diff --git a/src/context/mod.rs b/src/context/mod.rs index e7aa3c93b..7a2346a44 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -53,7 +53,6 @@ use connection_status::ConnectionStatus; use dash_sdk::Sdk; use dash_sdk::dapi_client::AddressList; use dash_sdk::dashcore_rpc::{Auth, Client}; -use dash_sdk::dpp::block::epoch::EpochIndex; use dash_sdk::dpp::dashcore::Network; #[cfg(any(test, feature = "testing"))] use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -164,15 +163,6 @@ impl DpnsVoteDispatchCoordinator { } } -/// What epoch adoption is keyed on. See [`AppContext::adopt_epoch`]. -#[derive(Debug, Default)] -struct EpochAdoption { - /// The connection generation, bumped by every rebuild. - generation: u64, - /// The index of the last adopted proved epoch on this generation. - epoch_index: Option, -} - #[derive(Debug)] pub struct AppContext { pub(crate) data_dir: PathBuf, @@ -323,9 +313,6 @@ pub struct AppContext { /// Cached fee multiplier permille from current epoch (1000 = 1x, 2000 = 2x) /// Updated when epoch info is fetched from Platform fee_multiplier_permille: AtomicU64, - /// The connection generation and the last adopted proved epoch. See - /// [`Self::adopt_epoch`] and [`Self::replace_connection`]. - epoch_adoption: Mutex, /// Frame-safe shielded balance snapshot (credits, summed across all Orchard accounts). /// /// Written by the Phase-E `on_shielded_sync_completed` event handler from the @@ -650,7 +637,6 @@ impl AppContext { fee_multiplier_permille: AtomicU64::new( PlatformFeeEstimator::DEFAULT_FEE_MULTIPLIER_PERMILLE, ), - epoch_adoption: Mutex::new(EpochAdoption::default()), shielded_balances: Arc::new(Mutex::new(std::collections::HashMap::new())), shielded_addresses: Arc::new(Mutex::new(std::collections::HashMap::new())), platform_balances: Arc::new(Mutex::new(std::collections::HashMap::new())), @@ -1171,95 +1157,12 @@ impl AppContext { } } - /// The generation of the connection to the network: bumped each time the - /// connection is rebuilt. Production reads take it together with the SDK - /// check, via [`Self::connection_snapshot_for`]. - #[cfg(test)] - pub(crate) fn connection_generation(&self) -> u64 { - self.epoch_adoption - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .generation - } - - /// Adopt the fee multiplier of the proved epoch `epoch_index`, read on - /// connection `generation`. Returns whether it was adopted. - /// - /// Dropped when the connection was rebuilt since the read was sent, or - /// when an epoch newer than it was adopted already: epoch reads run - /// concurrently (platform info, fee refusals) and can finish out of - /// order. The lock covers the checks and the store, so an adoption and a - /// rebuild cannot interleave; nothing is awaited under it. The protocol - /// version is the SDK's own ([`Self::proven_protocol_version`]). - pub(crate) fn adopt_epoch( - &self, - generation: u64, - epoch_index: EpochIndex, - fee_multiplier_permille: u64, - ) -> bool { - let mut adoption = self - .epoch_adoption - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if generation != adoption.generation - || adoption - .epoch_index - .is_some_and(|adopted| epoch_index < adopted) - { - return false; - } - adoption.epoch_index = Some(epoch_index); - self.set_fee_multiplier_permille(fee_multiplier_permille); - true - } - - /// Replace the connection's SDK with `new_sdk` and forget everything - /// learned from the old connection: bump the generation, so reads still in - /// flight on it are dropped, forget the adopted epoch, and put the fee - /// multiplier back to the default. The new SDK has verified nothing, so - /// the proven protocol version is unknown again and capabilities stay - /// closed until the new connection proves one: the new endpoints may serve - /// another protocol version or a reset devnet. - /// - /// The reset is written before the new SDK is published, under the - /// adoption lock: whoever loads the new SDK sees the reset state, and a - /// [`Self::connection_snapshot_for`] sees either the old generation and - /// SDK or the new ones. A task still holding the old SDK may see the - /// default fees: fail closed. - pub(crate) fn replace_connection(&self, new_sdk: Arc) { - self.replace_connection_observed(new_sdk, |_| {}); - } - - /// [`Self::replace_connection`]; `observe` runs where the two halves meet (test seam). - fn replace_connection_observed(&self, new_sdk: Arc, observe: impl Fn(&Self)) { - let mut adoption = self - .epoch_adoption - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - adoption.generation += 1; - adoption.epoch_index = None; - self.set_fee_multiplier_permille(PlatformFeeEstimator::DEFAULT_FEE_MULTIPLIER_PERMILLE); - observe(self); - self.sdk.store(new_sdk); - } - - /// The connection generation and whether `sdk` is the current SDK (not - /// one a rebuild replaced, nor a copy), read together under the adoption - /// lock. An epoch read takes this before it is sent and passes the - /// generation to [`Self::adopt_epoch`]. - pub(crate) fn connection_snapshot_for(&self, sdk: &Sdk) -> (u64, bool) { - let adoption = self - .epoch_adoption - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let current = std::ptr::eq(sdk, Arc::as_ptr(&self.sdk.load_full())); - (adoption.generation, current) - } - /// Rebuild the connection ([`Self::reinit_core_client_and_sdk`]) and read - /// the new connection's epoch, so its protocol version and fee multiplier - /// are known again. A failed epoch read only leaves them unknown until - /// the next one; it does not fail the rebuild. + /// the new connection's epoch. The new SDK has proven no protocol version, + /// so protocol capabilities stay closed until a proved response carries + /// one; the epoch read is that response, and it refreshes the fee + /// multiplier too. A failed read only leaves the version unproven until + /// the next proved response; it does not fail the rebuild. pub(crate) async fn rebuild_connection(self: &Arc) -> Result<(), TaskError> { Arc::clone(self).reinit_core_client_and_sdk()?; let sdk = self.sdk.load_full(); @@ -1274,8 +1177,8 @@ impl AppContext { } /// Rebuild both the Dash RPC `core_client` and the `Sdk` using the - /// updated `NetworkConfig` from `self.config`, and forget the state - /// learned from the old connection ([`Self::replace_connection`]). + /// updated `NetworkConfig` from `self.config`. The new SDK starts from + /// nothing learned from the old endpoints. pub fn reinit_core_client_and_sdk(self: Arc) -> Result<(), TaskError> { // 1. Grab a fresh snapshot of your NetworkConfig let cfg = { @@ -1319,7 +1222,7 @@ impl AppContext { let mut client_lock = self.core_client.write()?; *client_lock = new_client; } - self.replace_connection(Arc::new(new_sdk)); + self.sdk.store(Arc::new(new_sdk)); // Rebind the provider to hold the new AppContext reference. // bind_app_context also registers the provider with the SDK. @@ -2043,7 +1946,7 @@ mod tests { Some(13) ); - ctx.replace_connection(Arc::new(dash_sdk::Sdk::new_mock())); + ctx.sdk.store(Arc::new(dash_sdk::Sdk::new_mock())); assert_eq!(ctx.proven_protocol_version(), None, "rebuilt connection"); assert!( ctx.proven_platform_version().is_none(), @@ -2066,32 +1969,6 @@ mod tests { assert!(ctx.proven_platform_version().is_none()); } - /// A task that loads the new SDK always sees the reset connection state: - /// the new SDK never pairs with the old connection's fee multiplier, and - /// it has proven no protocol version. - #[test] - fn the_new_sdk_is_published_only_after_the_connection_state_is_reset() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let ctx = crate::context::test_support::test_app_context(temp_dir.path()); - ctx.adopt_epoch(ctx.connection_generation(), 8, 1_500); - let new_sdk = Arc::new(dash_sdk::Sdk::new_mock()); - let new_sdk_ptr = Arc::as_ptr(&new_sdk); - - ctx.replace_connection_observed(new_sdk, |ctx| { - if Arc::as_ptr(&ctx.sdk.load_full()) == new_sdk_ptr { - assert_eq!(ctx.proven_protocol_version(), None, "new SDK, old protocol"); - assert_eq!( - ctx.fee_multiplier_permille(), - crate::model::fee_estimation::PlatformFeeEstimator::DEFAULT_FEE_MULTIPLIER_PERMILLE, - "new SDK, old fee multiplier" - ); - } - }); - - assert_eq!(Arc::as_ptr(&ctx.sdk.load_full()), new_sdk_ptr); - assert_eq!(ctx.proven_protocol_version(), None); - } - /// Contest windows stay consistent as the SDK ratchets upward from each /// network minimum to a newer supported protocol. #[test] From 318ef38bf45a2d63f9a65888b00c3f1348a7f4b3 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:21:06 +0000 Subject: [PATCH 26/58] docs(context): say why proved epochs need no ordering Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- src/backend_task/platform_info.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/backend_task/platform_info.rs b/src/backend_task/platform_info.rs index 2a1fc82c8..a57aaeadf 100644 --- a/src/backend_task/platform_info.rs +++ b/src/backend_task/platform_info.rs @@ -575,6 +575,10 @@ impl AppContext { let epoch_info = ExtendedEpochInfo::fetch_current(sdk) .await .map_err(Box::new)?; + // No epoch ordering here: an older epoch can only come from a node + // behind the chain, and the SDK refuses a proved response more than + // `metadata_height_tolerance` blocks (1 by default) below the highest + // it has seen (`StaleNodeError::Height`), then bans that DAPI address. self.set_fee_multiplier_from(&epoch_info); Ok(epoch_info) } From 8a18bff867fa7111df150d9cf0bbabb877f7befc Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:24:25 +0000 Subject: [PATCH 27/58] test: drop the protocol 13 client tables snapshot A released protocol's tables and each network's minimum version are upstream's to keep fixed; DET's start is covered by platform_version_starts_at_the_network_minimum. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- .../README.md | 5 - .../fixtures/platform_v13_client_tables.txt | 798 ------------------ src/context/mod.rs | 2 - src/context/platform_version_guard.rs | 59 -- 4 files changed, 864 deletions(-) delete mode 100644 src/context/fixtures/platform_v13_client_tables.txt delete mode 100644 src/context/platform_version_guard.rs diff --git a/docs/ai-design/2026-10-24-platform-v5.1-dev-bump/README.md b/docs/ai-design/2026-10-24-platform-v5.1-dev-bump/README.md index adb142ed0..cc450f39b 100644 --- a/docs/ai-design/2026-10-24-platform-v5.1-dev-bump/README.md +++ b/docs/ai-design/2026-10-24-platform-v5.1-dev-bump/README.md @@ -52,11 +52,6 @@ proof is never checked against the wrong schema. one; the rebuild reads the epoch right away for that, and the read refreshes the fee multiplier. -## Protocol 13 guards - -- `context/platform_version_guard.rs` compares the protocol 13 client tables - with a recorded snapshot, so an upstream change to them is reviewed. - ## Mainnet compatibility dashpay/platform#5515 versions the non-unique identity lookup: protocol 13 diff --git a/src/context/fixtures/platform_v13_client_tables.txt b/src/context/fixtures/platform_v13_client_tables.txt deleted file mode 100644 index 219368253..000000000 --- a/src/context/fixtures/platform_v13_client_tables.txt +++ /dev/null @@ -1,798 +0,0 @@ -protocol_version: 13 -dpp: DPPVersion { - costs: DPPCostsVersions { - signature_verify: 0, - }, - validation: DPPValidationVersions { - json_schema_validator: JsonSchemaValidatorVersions { - new: 0, - validate: 0, - compile: 0, - compile_and_validate: 0, - }, - data_contract: DataContractValidationVersions { - validate: 0, - validate_config_update: 1, - validate_token_config_update: 0, - validate_index_definitions: 0, - validate_index_naming_duplicates: 0, - validate_not_defined_properties: 0, - validate_property_definition: 0, - validate_token_config_groups_exist: 1, - validate_localizations: 0, - validate_once_per_identity_distribution: None, - }, - document_type: DocumentTypeValidationVersions { - validate_update: 0, - unique_index_limit: 10, - contested_index_limit: 1, - }, - voting: VotingValidationVersions { - allow_other_contenders_time_mainnet_ms: 604800000, - allow_other_contenders_time_testing_ms: 2700000, - votes_allowed_per_masternode: 5, - }, - validation_result: ValidationResultMethodVersions { - flatten: 1, - merge_many: 1, - }, - }, - state_transition_serialization_versions: DPPStateTransitionSerializationVersions { - identity_public_key_in_creation: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - identity_create_from_addresses_state_transition: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - identity_create_state_transition: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - identity_update_state_transition: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - identity_key_limits_update_state_transition: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - identity_top_up_state_transition: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - identity_top_up_from_addresses_state_transition: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - identity_credit_withdrawal_state_transition: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - identity_credit_transfer_state_transition: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - identity_credit_transfer_to_addresses_state_transition: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - masternode_vote_state_transition: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - contract_create_state_transition: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - contract_update_state_transition: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - contract_user_moderation_state_transition: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - contract_fee_claim_state_transition: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - batch_state_transition: FeatureVersionBounds { - min_version: 0, - max_version: 1, - default_current_version: 1, - }, - document_base_state_transition: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 1, - }, - document_create_state_transition: DocumentFeatureVersionBounds { - bounds: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - }, - document_replace_state_transition: DocumentFeatureVersionBounds { - bounds: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - }, - document_delete_state_transition: DocumentFeatureVersionBounds { - bounds: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - }, - document_index_only_delete_state_transition: None, - document_transfer_state_transition: DocumentFeatureVersionBounds { - bounds: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - }, - document_update_price_state_transition: DocumentFeatureVersionBounds { - bounds: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - }, - document_purchase_state_transition: DocumentFeatureVersionBounds { - bounds: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - }, - address_funds_transfer_state_transition: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - address_funding_from_asset_lock_state_transition: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - address_credit_withdrawal_state_transition: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - shield_state_transition: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - shielded_transfer_state_transition: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - unshield_state_transition: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - shield_from_asset_lock_state_transition: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - shielded_withdrawal_state_transition: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - identity_create_from_shielded_pool_state_transition: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - shield_from_identity_state_transition: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - identity_top_up_from_shielded_pool_state_transition: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - token_shielded_transfer_with_shielded_fee_state_transition: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - token_unshield_with_shielded_fee_state_transition: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - token_purchase_from_shielded_pool_state_transition: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - }, - state_transition_conversion_versions: DPPStateTransitionConversionVersions { - identity_to_identity_create_transition: 0, - identity_to_identity_top_up_transition: 0, - identity_to_identity_transfer_transition: 0, - identity_to_identity_withdrawal_transition: 1, - identity_to_identity_create_transition_with_signer: 0, - inputs_to_identity_create_from_addresses_transition_with_signer: 0, - address_funds_to_address_funds_transfer_transition: 0, - identity_to_identity_top_up_from_addresses_transition: 0, - address_funding_from_asset_lock_transition: 0, - }, - state_transition_method_versions: DPPStateTransitionMethodVersions { - public_key_in_creation_methods: PublicKeyInCreationMethodVersions { - from_public_key_signed_with_private_key: 0, - from_public_key_signed_external: 0, - hash: 0, - duplicated_key_ids_witness: 0, - duplicated_keys_witness: 0, - validate_identity_public_keys_structure: 0, - }, - verify_identity_signed_signature: 0, - }, - state_transitions: DPPStateTransitionVersions { - documents: DocumentTransitionVersions { - documents_batch_transition: DocumentsBatchTransitionVersions { - validation: DocumentsBatchTransitionValidationVersions { - find_duplicates_by_id: 0, - validate_base_structure: 0, - }, - }, - }, - identities: IdentityTransitionVersions { - max_public_keys_in_creation: 6, - asset_locks: IdentityTransitionAssetLockVersions { - required_asset_lock_duff_balance_for_processing_start_for_identity_create: 200000, - required_asset_lock_duff_balance_for_processing_start_for_identity_top_up: 50000, - required_asset_lock_duff_balance_for_processing_start_for_address_funding: 50000, - validate_asset_lock_transaction_structure: 0, - validate_instant_asset_lock_proof_structure: 0, - max_asset_lock_transaction_inputs: 100, - }, - credit_withdrawal: IdentityCreditWithdrawalTransitionVersions { - default_constructor: 1, - }, - calculate_min_required_fee_on_identity_create_transition: 1, - calculate_min_required_fee_on_identity_top_up_transition: 1, - }, - contract: ContractTransitionVersions { - contract_create_transition_default_version: 0, - contract_update_transition_default_version: 0, - }, - address_funds: AddressFundsTransitionVersions { - address_funds_transition_default_version: 0, - credit_withdrawal: 0, - validate_credit_withdrawal_structure: 0, - min_output_amount: 500000, - min_input_amount: 100000, - min_identity_funding_amount: 200000, - }, - shielded: ShieldedTransitionVersions { - validate_withdrawal_structure: 0, - }, - max_address_inputs: 16, - max_address_outputs: 128, - max_address_fee_strategies: 4, - }, - contract_versions: DPPContractVersions { - max_serialized_size: 65000, - contract_serialization_version: FeatureVersionBounds { - min_version: 0, - max_version: 1, - default_current_version: 1, - }, - contract_structure_version: 1, - created_data_contract_structure: 0, - config: FeatureVersionBounds { - min_version: 1, - max_version: 1, - default_current_version: 1, - }, - methods: DataContractMethodVersions { - validate_document: 0, - validate_update: 0, - schema: 0, - validate_groups: 0, - validate_moderation_config: 0, - equal_ignoring_time_fields: 0, - registration_cost: 1, - }, - document_type_versions: DocumentTypeVersions { - index_versions: DocumentTypeIndexVersions { - index_levels_from_indices: 0, - }, - class_method_versions: DocumentTypeClassMethodVersions { - try_from_schema: 2, - create_document_types_from_document_schemas: 1, - }, - structure_version: 0, - schema: DocumentTypeSchemaVersions { - document_type_schema: 2, - should_add_creator_id: 1, - enrich_with_base_schema: 1, - find_identifier_and_binary_paths: 0, - apply_property_reference: None, - apply_required_since: None, - apply_distinct_from: None, - apply_encrypted_for: None, - apply_max_bytes: None, - parse_typed_array: None, - parse_property_constraints: None, - apply_generated_from: None, - expand_property_type_shorthands: None, - validate_max_depth: 0, - max_depth: 256, - recursive_schema_validator_versions: RecursiveSchemaValidatorVersions { - traversal_validator: 0, - }, - validate_schema_compatibility: 0, - }, - methods: DocumentTypeMethodVersions { - create_document_from_data: 0, - create_document_with_prevalidated_properties: 0, - prefunded_voting_balance_for_document: 0, - contested_vote_poll_for_document: 0, - estimated_size: 0, - index_for_types: 0, - max_size: 0, - serialize_value_for_key: 0, - deserialize_value_for_key: 0, - validate_distinct_from: None, - validate_encrypted_property_shapes: None, - validate_max_bytes: None, - validate_property_constraints: None, - canonical_contested_index_values: None, - fill_generated_properties: None, - validate_generated_from: None, - }, - }, - token_versions: TokenVersions { - validate_structure_interval: 0, - validate_pre_programmed_distribution_amounts: 0, - token_configuration_format: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - }, - }, - document_versions: DPPDocumentVersions { - document_structure_version: 0, - document_serialization_version: FeatureVersionBounds { - min_version: 0, - max_version: 2, - default_current_version: 2, - }, - document_cbor_serialization_version: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - extended_document_structure_version: 0, - extended_document_serialization_version: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - document_method_versions: DocumentMethodVersions { - is_equal_ignoring_timestamps: 0, - hash: 0, - get_raw_for_contract: 0, - get_raw_for_document_type: 0, - try_into_asset_unlock_base_transaction_info: 0, - generate_document_id: 0, - get_raw_array_elements_for_document_type: None, - }, - }, - identity_versions: DPPIdentityVersions { - identity_structure_version: 0, - identity_key_structure_version: 0, - identity_key_type_method_versions: IdentityKeyTypeMethodVersions { - random_public_key_data: 0, - random_public_and_private_key_data: 0, - }, - }, - voting_versions: DPPVotingVersions { - default_vote_poll_time_duration_mainnet_ms: 1209600000, - default_vote_poll_time_duration_test_network_ms: 5400000, - contested_document_vote_poll_stored_info_version: 0, - }, - token_versions: DPPTokenVersions { - identity_token_info_default_structure_version: 0, - identity_token_status_default_structure_version: 0, - token_contract_info_default_structure_version: 0, - token_config_update_action_id_version: 1, - token_set_price_action_id_version: 1, - distribution_function_evaluate_version: 0, - reward_distribution_max_cycle_moment_version: 0, - distribution_function_cycle_epochs_version: 0, - }, - asset_lock_versions: DPPAssetLockVersions { - reduced_asset_lock_value: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, - }, - }, - methods: DPPMethodVersions { - epoch_core_reward_credits_for_distribution: 0, - daily_withdrawal_limit: 1, - deduct_fee_from_outputs_or_remaining_balance_of_inputs: 0, - compute_minimum_shielded_fee: 0, - shielded_extra_sighash_data: 0, - core_credit_pool_unlock_limit: None, - credit_pool_bundle_binding: None, - }, - factory_versions: DPPFactoryVersions { - data_contract_factory_structure_version: 0, - document_factory_structure_version: 0, - }, -} -drive.methods.verify: DriveVerifyMethodVersions { - contract: DriveVerifyContractMethodVersions { - verify_contract: 0, - verify_contract_history: 0, - verify_contract_return_serialization: 0, - verify_contracts_by_range: 0, - verify_contracts_versions: 0, - }, - document: DriveVerifyDocumentMethodVersions { - verify_proof: 0, - verify_proof_keep_serialized: 0, - verify_document_history: 0, - verify_start_at_document_in_proof: 0, - }, - chained_document: DriveVerifyChainedDocumentMethodVersions { - verify_chained_documents_proof: 0, - }, - composite_document: DriveVerifyCompositeDocumentMethodVersions { - verify_composite_documents_proof: 0, - }, - document_count: DriveVerifyDocumentCountMethodVersions { - verify_aggregate_count_proof: 0, - verify_carrier_aggregate_count_proof: 0, - verify_distinct_count_proof: 0, - verify_point_lookup_count_proof: 0, - verify_primary_key_count_tree_proof: 0, - }, - document_sum: DriveVerifyDocumentSumMethodVersions { - verify_aggregate_sum_proof: 0, - verify_carrier_aggregate_sum_proof: 0, - verify_carrier_aggregate_count_and_sum_proof: 0, - verify_aggregate_count_and_sum_proof: 0, - verify_primary_key_sum_tree_proof: 0, - verify_primary_key_count_sum_tree_proof: 0, - verify_point_lookup_sum_proof: 0, - verify_distinct_sum_proof: 0, - verify_distinct_count_and_sum_proof: 0, - verify_point_lookup_count_and_sum_proof: 0, - }, - document_ranked: DriveVerifyDocumentRankedMethodVersions { - verify_ranked_top_k_proof: 0, - verify_having_range_proof: 0, - }, - identity: DriveVerifyIdentityMethodVersions { - verify_full_identities_by_public_key_hashes: 0, - verify_full_identity_by_identity_id: 0, - verify_full_identity_by_public_key_hash: 0, - verify_identity_balance_for_identity_id: 0, - verify_identity_balances_for_identity_ids: 0, - verify_identity_balance_revision_and_addresses_from_inputs: 0, - verify_identity_id_by_unique_public_key_hash: 0, - verify_identity_ids_by_unique_public_key_hashes: 0, - verify_identity_keys_by_identity_id: 0, - verify_identity_nonce: 0, - verify_identity_contract_nonce: 0, - verify_identities_contract_keys: 0, - verify_identity_revision_for_identity_id: 0, - verify_full_identity_by_non_unique_public_key_hash: 0, - verify_full_identity_by_non_unique_public_key_hash_two_proofs: 0, - verify_identity_id_by_non_unique_public_key_hash: 0, - verify_identity_keys_remaining_budgets: 0, - }, - group: DriveVerifyGroupMethodVersions { - verify_group_info: 0, - verify_group_infos_in_contract: 0, - verify_action_infos: 0, - verify_action_signers: 0, - verify_action_signers_total_power: 0, - }, - contract_group: DriveVerifyContractGroupMethodVersions { - verify_contract_group_info: 0, - verify_contract_group_members: 0, - verify_contract_group_memberships_for_contract: 0, - }, - contract_moderation: DriveVerifyContractModerationMethodVersions { - verify_contract_moderation_status: 0, - verify_contract_moderation_entries: 0, - verify_contract_fee_pots: 0, - verify_contract_document_removals: 0, - verify_contract_team_actions: 0, - verify_contract_team_action_signers: 0, - verify_contract_team_action_signature: 0, - verify_contract_moderation_action_counts: 0, - }, - token: DriveVerifyTokenMethodVersions { - verify_token_balances_for_identity_ids: 0, - verify_token_balances_for_identity_id: 0, - verify_token_infos_for_identity_ids: 0, - verify_token_infos_for_identity_id: 0, - verify_token_statuses: 0, - verify_token_total_supply_and_aggregated_identity_balance: 0, - verify_token_balance_for_identity_id: 0, - verify_token_info_for_identity_id: 0, - verify_token_status: 0, - verify_token_pre_programmed_distributions: 0, - verify_token_direct_selling_price: 0, - verify_token_direct_selling_prices: 0, - verify_token_perpetual_distribution_last_paid_time: 0, - verify_token_contract_info: 0, - }, - single_document: DriveVerifySingleDocumentMethodVersions { - verify_proof: 0, - verify_proof_keep_serialized: 0, - }, - system: DriveVerifySystemMethodVersions { - verify_epoch_infos: 0, - verify_epoch_proposers: 0, - verify_elements: 0, - verify_total_credits_in_system: 0, - verify_upgrade_state: 0, - verify_upgrade_vote_status: 0, - verify_finalized_epoch_infos: 0, - }, - voting: DriveVerifyVoteMethodVersions { - verify_masternode_vote: 0, - verify_vote_poll_votes_proof: 0, - verify_identity_votes_given_proof: 0, - verify_vote_poll_vote_state_proof: 0, - verify_contests_proof: 0, - verify_vote_polls_by_end_date_proof: 0, - verify_specialized_balance: 0, - }, - address_funds: DriveVerifyAddressFundsMethodVersions { - verify_address_info: 0, - verify_addresses_infos: 0, - verify_address_funds_trunk_query: 0, - verify_address_funds_branch_query: 0, - verify_recent_address_balance_changes: 0, - verify_compacted_address_balance_changes: 1, - }, - state_transition: DriveVerifyStateTransitionMethodVersions { - verify_state_transition_was_executed_with_proof: 0, - }, - shielded: DriveVerifyShieldedMethodVersions { - verify_shielded_pool_state: 0, - verify_shielded_anchors: 0, - verify_most_recent_shielded_anchor: 0, - verify_shielded_encrypted_notes: 0, - verify_shielded_notes_count: 0, - verify_shielded_nullifiers: 0, - }, -} -fee_version: FeeVersion { - fee_version_number: 1, - uses_version_fee_multiplier_permille: Some( - 1000, - ), - storage: FeeStorageVersion { - storage_disk_usage_credit_per_byte: 27000, - storage_processing_credit_per_byte: 400, - storage_load_credit_per_byte: 20, - non_storage_load_credit_per_byte: 10, - storage_seek_cost: 2000, - ttl_ephemeral_disk_usage_credit_per_byte: 270, - }, - signature: FeeSignatureVersion { - verify_signature_ecdsa_secp256k1: 15000, - verify_signature_bls12_381: 300000, - verify_signature_ecdsa_hash160: 15500, - verify_signature_bip13_script_hash: 300000, - verify_signature_eddsa25519_hash160: 3500, - }, - hashing: FeeHashingVersion { - blake3_base: 100, - blake3_per_block: 300, - sha256_per_block: 5000, - sha256_ripe_md160_base: 6000, - single_sha256_base: 100, - ripemd160_per_block: 5000, - sinsemilla_base: 40000, - }, - processing: FeeProcessingVersion { - fetch_identity_balance_processing_cost: 10000, - fetch_identity_revision_processing_cost: 9000, - fetch_identity_balance_and_revision_processing_cost: 15000, - fetch_identity_cost_per_look_up_key_by_id: 9000, - fetch_identity_token_balance_processing_cost: 10000, - fetch_prefunded_specialized_balance_processing_cost: 10000, - fetch_key_with_type_nonce_and_balance_cost: 12000, - fetch_single_identity_key_processing_cost: 10000, - validate_key_structure: 50, - perform_network_threshold_signing: 100000000, - }, - data_contract_validation: FeeDataContractValidationVersion { - document_type_base_fee: 500, - document_type_schema_size_fee: 10, - document_type_per_property_fee: 40, - document_type_base_non_unique_index_fee: 50, - document_type_non_unique_index_per_property_fee: 30, - document_type_base_unique_index_fee: 100, - document_type_unique_index_per_property_fee: 60, - }, - data_contract_registration: FeeDataContractRegistrationVersion { - base_contract_registration_fee: 10000000000, - document_type_registration_fee: 2000000000, - document_type_base_non_unique_index_registration_fee: 1000000000, - document_type_base_unique_index_registration_fee: 1000000000, - document_type_base_contested_index_registration_fee: 100000000000, - token_registration_fee: 10000000000, - token_uses_perpetual_distribution_fee: 10000000000, - token_uses_pre_programmed_distribution_fee: 10000000000, - token_uses_once_per_identity_distribution_fee: 0, - search_keyword_fee: 10000000000, - }, - state_transition_min_fees: StateTransitionMinFees { - credit_transfer: 100000, - credit_transfer_to_addresses: 500000, - credit_withdrawal: 400000000, - identity_update: 100000, - document_batch_sub_transition: 100000, - contract_create: 100000, - contract_update: 100000, - masternode_vote: 100000, - address_credit_withdrawal: 400000000, - address_funds_transfer_input_cost: 500000, - address_funds_transfer_output_cost: 6000000, - identity_create_base_cost: 2000000, - identity_topup_base_cost: 500000, - identity_key_in_creation_cost: 6500000, - }, - vote_resolution_fund_fees: VoteResolutionFundFees { - contested_document_vote_resolution_fund_required_amount: 20000000000, - contested_document_vote_resolution_unlock_fund_required_amount: 400000000000, - contested_document_single_vote_cost: 10000000, - moderation_vote_resolution_fund_required_amount: 20000000000, - contested_document_contenders_before_fund_doubling: 0, - contested_document_contenders_per_fund_doubling: 0, - }, - document_ttl: FeeDocumentTtlVersion { - tiers: [ - DocumentTtlFeeTier { - max_ttl_seconds: 3600, - credit_per_byte: 1, - }, - DocumentTtlFeeTier { - max_ttl_seconds: 86400, - credit_per_byte: 4, - }, - DocumentTtlFeeTier { - max_ttl_seconds: 172800, - credit_per_byte: 8, - }, - DocumentTtlFeeTier { - max_ttl_seconds: 345600, - credit_per_byte: 15, - }, - DocumentTtlFeeTier { - max_ttl_seconds: 604800, - credit_per_byte: 26, - }, - ], - credit_per_byte_per_period: 34, - pricing_period_seconds: 788400, - cleanup_base_processing_cost: 1200000, - cleanup_processing_cost_per_index_level: 400000, - cleanup_processing_cost_per_document_byte: 420, - }, - document_reference: FeeDocumentReferenceVersion { - choose_first_credits_per_candidate: 100000, - where_list_credits_per_element: 100, - }, -} -system_data_contracts: SystemDataContractVersions { - withdrawals: 1, - dpns: 2, - dashpay: 1, - masternode_reward_shares: 1, - wallet: 1, - token_history: 1, - keyword_search: 1, - document_history: 1, - app_connect: 0, - moderation_charters: 0, -} -system_limits: SystemLimits { - credit_pool_nullifier_backfill_page_size: 0, - credit_pool_nullifier_backfill_batch_size: 0, - estimated_contract_max_serialized_size: 16384, - max_field_value_size: 5120, - max_document_value_depth: Some( - 256, - ), - max_typed_array_items: 1024, - max_references_per_document: 256, - max_reference_operands: 4, - max_reference_expression_depth: 4, - max_property_constraints: 16, - max_property_constraint_nodes: 32, - max_property_constraint_aggregates: 4, - max_state_transition_size: 20480, - max_transitions_in_documents_batch: 1, - withdrawal_transactions_per_block_limit: 4, - retry_signing_expired_withdrawal_documents_per_block_limit: 1, - max_withdrawal_amount: 50000000000000, - daily_withdrawal_limit_percent: None, - core_credit_pool_unlock_limit_percent: None, - core_credit_pool_unlock_limit_floor: None, - core_credit_pool_window_blocks: None, - regtest_core_credit_pool_window_blocks: None, - min_withdrawal_amount: 1000000, - core_dust_relay_fee_per_kb: None, - max_core_fee_per_byte: None, - max_group_member_count: 256, - max_contract_group_memberships_per_contract: 16, - max_contract_group_admins: 16, - max_contract_group_name_length: 64, - max_contract_group_description_length: 256, - max_contract_moderators: 16, - max_contract_suspension_until: 9007199254740991, - max_contract_moderation_reason_length: 1024, - max_contract_warnings_per_identity: 16, - max_contract_moderation_reason_documents: 16, - min_mainnet_contract_moderation_election_window_seconds: 86400, - max_contract_moderation_election_window_seconds: 2419200, - min_contract_moderation_challenge_cool_down_seconds: 1209600, - max_contract_moderation_challenge_cool_down_seconds: 94608000, - contract_document_restore_window_ms: 604800000, - max_contract_moderation_added_moderators: 15, - max_moderation_charter_elected_members: 15, - max_contenders_per_contest: 1000, - max_token_redemption_cycles: 128, - max_evonode_reward_claim_epochs: 100, - max_shielded_transition_actions: 16, - max_token_pool_notes_for_outgoing: 250, - max_time_range_overlap_factor: None, - max_time_range_ttl_seconds: None, - min_time_range_ttl_drop_operations_per_write: None, - min_document_ttl_seconds: None, - max_document_ttl_seconds: None, - max_document_expirations_per_block: 0, - max_document_expiration_weight_per_block: 0, - max_contested_summed_value_magnitude: None, - max_expiring_signed_summed_value_magnitude: None, - max_indexed_array_items: None, - max_windowed_indexed_array_items: None, - max_index_entries_per_document: None, - max_choose_first_candidates: 1000, - max_rekey_batch_moves: None, -} diff --git a/src/context/mod.rs b/src/context/mod.rs index 7a2346a44..b2a133570 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -17,8 +17,6 @@ pub(crate) mod lock_probe; pub(crate) use identity_db::test_staging; pub(crate) mod identity_load_registry; pub mod migration_status; -#[cfg(test)] -mod platform_version_guard; mod settings_db; mod system_contracts; #[cfg(test)] diff --git a/src/context/platform_version_guard.rs b/src/context/platform_version_guard.rs deleted file mode 100644 index d93d8af00..000000000 --- a/src/context/platform_version_guard.rs +++ /dev/null @@ -1,59 +0,0 @@ -//! Guards the protocol version 13 tables DET builds, validates and verifies -//! with on mainnet and testnet against drift across platform pin bumps. -//! -//! Protocol 13 is live, so table changes require compatibility review. The snapshot -//! covers what the client side reads: serialization and validation (`dpp`), -//! proof verification (`drive.methods.verify`), fees, system contract versions -//! and system limits. On a mismatch, diff the fixture, confirm the change is -//! additive (new fields at 0/None) or otherwise safe for a protocol 13 network, -//! then regenerate with `DET_BLESS_PLATFORM_VERSION_SNAPSHOT=1`. -//! -//! Platform #5294 enforces GroveDB proof envelope V1 outside these tables, even -//! for protocol 13. The snapshot does not establish wire compatibility: #5361 -//! removed the legacy discovery response field 2, still served by sampled -//! mainnet nodes, and #5515 brings it back as the answer to a version 0 lookup -//! request, checked by `verify_full_identity_by_non_unique_public_key_hash_two_proofs`. -//! Only a live check against those nodes shows the SDK accepts their proofs. - -use dash_sdk::dpp::dashcore::Network; -use dash_sdk::dpp::version::PlatformVersion; -use dash_sdk::sdk::min_protocol_version; - -const SNAPSHOT_PATH: &str = "src/context/fixtures/platform_v13_client_tables.txt"; -const SNAPSHOT: &str = include_str!("fixtures/platform_v13_client_tables.txt"); - -fn client_tables(platform_version: &PlatformVersion) -> String { - format!( - "protocol_version: {}\ndpp: {:#?}\ndrive.methods.verify: {:#?}\nfee_version: {:#?}\nsystem_data_contracts: {:#?}\nsystem_limits: {:#?}\n", - platform_version.protocol_version, - platform_version.dpp, - platform_version.drive.methods.verify, - platform_version.fee_version, - platform_version.system_data_contracts, - platform_version.system_limits, - ) -} - -#[test] -fn protocol_13_client_tables_match_the_snapshot() { - let actual = client_tables(PlatformVersion::get(13).expect("protocol 13")); - if std::env::var_os("DET_BLESS_PLATFORM_VERSION_SNAPSHOT").is_some() { - let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(SNAPSHOT_PATH); - std::fs::write(&path, &actual).expect("write snapshot"); - return; - } - assert!( - actual == SNAPSHOT, - "The protocol 13 tables changed with the platform pin. Diff {SNAPSHOT_PATH} (rerun with \ - DET_BLESS_PLATFORM_VERSION_SNAPSHOT=1 to rewrite it), check the change is safe for \ - mainnet, and commit the new snapshot." - ); -} - -/// Mainnet and testnet run protocol 13, and DET seeds its SDK from upstream's -/// per-network minimum: the snapshot above is the table DET starts with there. -#[test] -fn mainnet_and_testnet_start_at_protocol_13() { - assert_eq!(min_protocol_version(Network::Mainnet), 13); - assert_eq!(min_protocol_version(Network::Testnet), 13); -} From 325ec45de72480965a1cf1cf59664b9b7816e348 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:59:51 +0000 Subject: [PATCH 28/58] fix(context): decode saved contracts at the proof verifier's version The proof verifier asks for contracts at the version it verifies with, which can be newer than the SDK's. System contracts were already served at that version; a saved user contract was decoded at the SDK's version instead, dropping newer-protocol settings such as action fees. The fallback now decodes it at the version asked for. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- src/context/contract_token_db.rs | 62 ++++++++++++++++++++++++++++---- src/context_provider.rs | 7 ++-- 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/src/context/contract_token_db.rs b/src/context/contract_token_db.rs index 23f6aaae9..a5333ddd0 100644 --- a/src/context/contract_token_db.rs +++ b/src/context/contract_token_db.rs @@ -17,6 +17,7 @@ use dash_sdk::dpp::serialization::{ PlatformDeserializableWithPotentialValidationFromVersionedStructureUntrusted, PlatformSerializableWithPlatformVersion, }; +use dash_sdk::dpp::version::PlatformVersion; use dash_sdk::platform::{DataContract, Identifier}; use dash_sdk::query_types::IndexMap; use serde::{Deserialize, Serialize}; @@ -183,6 +184,14 @@ impl AppContext { fn decode_stored_contract( &self, stored: StoredContract, + ) -> std::result::Result { + Self::decode_stored_contract_at(stored, self.platform_version()) + } + + /// [`Self::decode_stored_contract`] at `platform_version`. + fn decode_stored_contract_at( + stored: StoredContract, + platform_version: &PlatformVersion, ) -> std::result::Result { // DET wrote these bytes, but the untrusted decoder accepts every valid // stored contract under the same no-limit config and does not @@ -191,7 +200,7 @@ impl AppContext { let contract = DataContract::versioned_deserialize_untrusted( &stored.contract_bytes, false, - self.platform_version(), + platform_version, ) .map_err(|source| TaskError::ContractEncoding { source: Box::new(source), @@ -205,6 +214,17 @@ impl AppContext { pub fn get_contract_by_id( &self, contract_id: &Identifier, + ) -> std::result::Result, TaskError> { + self.get_contract_by_id_at(contract_id, self.platform_version()) + } + + /// [`Self::get_contract_by_id`], decoded at `platform_version`. The proof + /// verifier reads contracts at the version it verifies with, which can be + /// newer than the SDK's. + pub(crate) fn get_contract_by_id_at( + &self, + contract_id: &Identifier, + platform_version: &PlatformVersion, ) -> std::result::Result, TaskError> { let backend = self.wallet_backend()?; let key = contract_key(contract_id); @@ -212,10 +232,9 @@ impl AppContext { .kv() .get(DetScope::Global, &key) .map_err(contract_err)?; - match stored { - Some(stored) => Ok(Some(self.decode_stored_contract(stored)?)), - None => Ok(None), - } + stored + .map(|stored| Self::decode_stored_contract_at(stored, platform_version)) + .transpose() } pub fn get_unqualified_contract_by_id( @@ -854,7 +873,6 @@ where mod tests { use super::*; use crate::wallet_backend::kv_test_support::{InMemoryKv, StallingReadKv}; - use dash_sdk::dpp::version::PlatformVersion; use platform_wallet_storage::{KvError, KvStore, ObjectId}; use std::sync::{Arc, Mutex}; @@ -1080,6 +1098,38 @@ mod tests { assert_eq!(at_14.contract, charging); } + /// The proof verifier reads a saved user contract at the version it + /// verifies with, as it reads system contracts: a contract saved at + /// protocol 14 comes back in full when asked at 14 while the SDK still + /// sits at 13. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn the_proof_verifier_reads_a_saved_contract_at_the_version_it_asks_for() { + use dash_sdk::dpp::platform_value::platform_value; + + let tmp = tempfile::tempdir().expect("tempdir"); + let ctx = crate::context::test_support::wired_test_app_context(tmp.path()).await; + crate::context::test_support::set_sdk_protocol_version(&ctx, 13); + let v14 = PlatformVersion::get(14).expect("v14"); + let charging = user_contract_at(v14, Some(platform_value!({"create": {"owner": 5_u64}}))); + let stored = StoredContract { + contract_bytes: charging + .serialize_to_bytes_with_platform_version(v14) + .expect("encode"), + alias: None, + }; + ctx.wallet_backend() + .expect("wired backend") + .kv() + .put(DetScope::Global, &contract_key(&charging.id()), &stored) + .expect("store the contract"); + + let resolved = crate::context_provider::resolve_data_contract(&ctx, &charging.id(), v14) + .expect("resolves") + .expect("the saved contract"); + + assert_eq!(*resolved, charging); + } + #[test] fn contract_keys_carry_a_base58_suffix_that_round_trips_to_the_id() { let contract = ident(6); diff --git a/src/context_provider.rs b/src/context_provider.rs index 8ba42fa9c..171ee8d24 100644 --- a/src/context_provider.rs +++ b/src/context_provider.rs @@ -28,7 +28,7 @@ pub(crate) const MASTERNODE_LIST_NOT_READY_DETAIL: &str = /// All system contracts are listed in `cached` — adding a new one is a single /// array edit. The array size is tied to [`SYSTEM_CONTRACT_COUNT`] so the /// compiler enforces completeness. -fn resolve_data_contract( +pub(crate) fn resolve_data_contract( app_ctx: &AppContext, data_contract_id: &Identifier, platform_version: &PlatformVersion, @@ -58,9 +58,10 @@ fn resolve_data_contract( return Ok(Some(Arc::clone(contract))); } - // K/V fallback for user-added / non-system contracts + // K/V fallback for user-added / non-system contracts, decoded at the + // verifier's version too. let dc = app_ctx - .get_contract_by_id(data_contract_id) + .get_contract_by_id_at(data_contract_id, platform_version) .map_err(|e| ContextProviderError::Generic(e.to_string()))?; Ok(dc.map(|qc| Arc::new(qc.contract))) From 7db8d30091e605e62900610073b32de67be146bf Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:01:17 +0000 Subject: [PATCH 29/58] fix(wallet): report a failed signed-message envelope instead of panicking Building the recoverable-signature envelope returns an error upstream; it is now reported as the existing message-signing failure, with the detail logged, instead of aborting the signing path. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- src/backend_task/wallet/mod.rs | 23 +++++++++++++------ .../wallet/sign_message_with_identity_key.rs | 2 +- .../wallet/sign_message_with_key.rs | 6 +---- src/ui/identity/keys/key_info_screen.rs | 13 +++++++++-- 4 files changed, 29 insertions(+), 15 deletions(-) diff --git a/src/backend_task/wallet/mod.rs b/src/backend_task/wallet/mod.rs index 64afbe9ec..1a74dbbdb 100644 --- a/src/backend_task/wallet/mod.rs +++ b/src/backend_task/wallet/mod.rs @@ -36,11 +36,16 @@ use std::sync::Arc; /// (`27 + recId`, `+4` when `compressed`) followed by the 64-byte signature, so /// a verifier can recover the signer's public key from the signature alone. /// Shared by the wallet-key and identity-key message-signing tasks. +/// +/// # Errors +/// +/// [`TaskError::WalletMessageSigningFailed`] if the signature cannot be put +/// in the envelope; the detail is logged. pub(crate) fn dash_signed_message( message: &str, secret_key: &SecretKey, compressed: bool, -) -> String { +) -> Result { let message_hash = signed_msg_hash(message); let digest = Message::from_digest(*message_hash.as_byte_array()); let recoverable = dash_sdk::dpp::secp256k1::ecdsa::RecoverableSignature::sign_ecdsa_recoverable( @@ -52,8 +57,11 @@ pub(crate) fn dash_signed_message( recovery_id.to_u8(), compressed, ) - .expect("a recovery id from signing is in range"); - sign_message::to_base64(envelope) + .map_err(|detail| { + tracing::warn!(error = %detail, "Sign-message envelope construction failed"); + TaskError::WalletMessageSigningFailed + })?; + Ok(sign_message::to_base64(envelope)) } impl AppContext { @@ -519,7 +527,7 @@ mod tests { let expected_pubkey = PublicKey::from_secret_key(&secret_key); let message = "Bilby was here"; - let base64 = dash_signed_message(message, &secret_key, compressed); + let base64 = dash_signed_message(message, &secret_key, compressed).expect("signs"); let parsed = sign_message::from_base64(&base64).expect("valid envelope"); assert_eq!(parsed.is_compressed().expect("valid header"), compressed); @@ -545,9 +553,10 @@ mod tests { let mut expected = [0u8; 65]; expected[0] = 27 + recovery_id.to_u8() + if compressed { 4 } else { 0 }; expected[1..].copy_from_slice(&compact); - let encoded = - sign_message::from_base64(&dash_signed_message(message, &secret_key, compressed)) - .expect("valid envelope"); + let encoded = sign_message::from_base64( + &dash_signed_message(message, &secret_key, compressed).expect("signs"), + ) + .expect("valid envelope"); assert_eq!(encoded.as_bytes(), &expected); } } diff --git a/src/backend_task/wallet/sign_message_with_identity_key.rs b/src/backend_task/wallet/sign_message_with_identity_key.rs index 9de62fb4b..fc383e5ee 100644 --- a/src/backend_task/wallet/sign_message_with_identity_key.rs +++ b/src/backend_task/wallet/sign_message_with_identity_key.rs @@ -34,7 +34,7 @@ impl AppContext { let signature = self .with_identity_secret_key(identity_id, target.clone(), key_id, |secret_key| { // Identity keys are compressed by convention. - Ok(dash_signed_message(message.as_str(), &secret_key, true)) + dash_signed_message(message.as_str(), &secret_key, true) }) .await?; diff --git a/src/backend_task/wallet/sign_message_with_key.rs b/src/backend_task/wallet/sign_message_with_key.rs index c52dd6a58..e2d0bf60d 100644 --- a/src/backend_task/wallet/sign_message_with_key.rs +++ b/src/backend_task/wallet/sign_message_with_key.rs @@ -45,11 +45,7 @@ impl AppContext { tracing::warn!(error = %detail, "Sign-message secret key construction failed"); TaskError::WalletMessageSigningFailed })?; - Ok(dash_signed_message( - message.as_str(), - &secret_key, - private_key.compressed, - )) + dash_signed_message(message.as_str(), &secret_key, private_key.compressed) }, ) .await?; diff --git a/src/ui/identity/keys/key_info_screen.rs b/src/ui/identity/keys/key_info_screen.rs index 9c0e3d69c..e80279a75 100644 --- a/src/ui/identity/keys/key_info_screen.rs +++ b/src/ui/identity/keys/key_info_screen.rs @@ -1261,7 +1261,16 @@ impl KeyInfoScreen { // Keys that carry their own plaintext sign locally — no wallet seed // is involved, so there is nothing to fetch through the chokepoint. PrivateKeyData::Clear(bytes) | PrivateKeyData::AlwaysClear(bytes) => { - self.signed_message = Some(Self::sign_ecdsa_local(bytes, &self.message_input)); + match Self::sign_ecdsa_local(bytes, &self.message_input) { + Ok(signature) => self.signed_message = Some(signature), + Err(error) => { + MessageBanner::set_global( + self.app_context.egui_ctx(), + error.to_string(), + MessageType::Error, + ); + } + } } // Wallet-derived keys sign in the backend: the seed is fetched // just-in-time through the JIT chokepoint and only the public @@ -1292,7 +1301,7 @@ impl KeyInfoScreen { /// `+4` for a compressed key) followed by the 64-byte signature. These keys /// are compressed by convention, so a verifier can recover the signer's /// public key and address from the signature alone. - fn sign_ecdsa_local(private_key_bytes: &[u8; 32], message: &str) -> String { + fn sign_ecdsa_local(private_key_bytes: &[u8; 32], message: &str) -> Result { let secret_key = SecretKey::from_secret_bytes(*private_key_bytes) .expect("clear private key is a valid 32-byte secret"); crate::backend_task::wallet::dash_signed_message(message, &secret_key, true) From 749f8bd1e81e94cae0d66cb0b5a7e88314e9c37d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:05:03 +0000 Subject: [PATCH 30/58] style: drop blank lines left by the removed secp256k1 context Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- src/backend_task/dashpay/auto_accept_proof.rs | 1 - src/backend_task/identity/add_key_to_identity.rs | 1 - src/backend_task/identity/load_identity.rs | 1 - src/backend_task/identity/mod.rs | 1 - src/backend_task/migration/finish_unwire.rs | 1 - src/backend_task/migration/single_key_restore.rs | 2 -- src/context/wallet_lifecycle/tests.rs | 2 -- src/database/test_helpers.rs | 2 -- src/database/wallet.rs | 2 -- src/model/dashpay_derivation/dip14.rs | 1 - src/model/dashpay_derivation/hd.rs | 1 - src/model/qualified_identity/mod.rs | 2 -- .../qualified_identity/qualified_identity_public_key.rs | 1 - src/model/single_key.rs | 2 -- src/model/wallet/mod.rs | 8 -------- src/model/wallet/single_key.rs | 2 -- src/ui/identity/funding_common.rs | 1 - src/ui/identity/keys/key_info_screen.rs | 1 - src/ui/wallets/wallets_screen/address_table.rs | 1 - src/wallet_backend/det_platform_signer.rs | 3 --- src/wallet_backend/det_signer.rs | 4 ---- src/wallet_backend/secret_access.rs | 1 - src/wallet_backend/single_key.rs | 7 +------ src/wallet_backend/snapshot.rs | 1 - 24 files changed, 1 insertion(+), 48 deletions(-) diff --git a/src/backend_task/dashpay/auto_accept_proof.rs b/src/backend_task/dashpay/auto_accept_proof.rs index 4a3fd5b22..31cd344de 100644 --- a/src/backend_task/dashpay/auto_accept_proof.rs +++ b/src/backend_task/dashpay/auto_accept_proof.rs @@ -274,7 +274,6 @@ pub async fn verify_auto_accept_proof( let mut hasher = Sha256::new(); hasher.update(&message_data); let message_hash = hasher.finalize(); - let message = Message::from_digest(message_hash.into()); // Derive expected pubkey from our seed and key index (timestamp) diff --git a/src/backend_task/identity/add_key_to_identity.rs b/src/backend_task/identity/add_key_to_identity.rs index 91fe6c1fe..e07bcb432 100644 --- a/src/backend_task/identity/add_key_to_identity.rs +++ b/src/backend_task/identity/add_key_to_identity.rs @@ -703,7 +703,6 @@ mod tests { .get_resolve_with_seed(&placement, &wallets, &seed, Network::Testnet) .unwrap() .unwrap(); - let secret = SecretKey::from_secret_bytes(*bytes).unwrap(); let message = Message::from_digest([1; 32]); let signature = dash_sdk::dpp::secp256k1::ecdsa::sign(message, &secret); diff --git a/src/backend_task/identity/load_identity.rs b/src/backend_task/identity/load_identity.rs index b7dc03e53..3c011f6bc 100644 --- a/src/backend_task/identity/load_identity.rs +++ b/src/backend_task/identity/load_identity.rs @@ -19,7 +19,6 @@ use crate::model::wallet::{Wallet, WalletSeedHash}; use crate::ui::identity::add_new_identity_screen::MAX_IDENTITY_INDEX; use dash_sdk::Sdk; use dash_sdk::dashcore_rpc::dashcore::PrivateKey; - use dash_sdk::dpp::dashcore::ProTxHash; use dash_sdk::dpp::dashcore::hashes::Hash; use dash_sdk::dpp::identity::KeyType; diff --git a/src/backend_task/identity/mod.rs b/src/backend_task/identity/mod.rs index 54f6f963a..786bb08e4 100644 --- a/src/backend_task/identity/mod.rs +++ b/src/backend_task/identity/mod.rs @@ -1498,7 +1498,6 @@ mod tests { .expect("resolve master") .expect("master present"); // The resolved private key's public key must match the spec's public key. - let resolved_pub = dash_sdk::dpp::secp256k1::SecretKey::from_secret_bytes(*stored.1) .expect("secret") .public_key(); diff --git a/src/backend_task/migration/finish_unwire.rs b/src/backend_task/migration/finish_unwire.rs index 721eda3de..0d923a32a 100644 --- a/src/backend_task/migration/finish_unwire.rs +++ b/src/backend_task/migration/finish_unwire.rs @@ -3955,7 +3955,6 @@ mod tests { use crate::wallet_backend::single_key::{ SingleKeyView, label_for_address, single_key_namespace_id, }; - use dash_sdk::dpp::dashcore::{Address, Network, PrivateKey, PublicKey}; let dir = tempfile::tempdir().expect("tempdir"); diff --git a/src/backend_task/migration/single_key_restore.rs b/src/backend_task/migration/single_key_restore.rs index dacfc4065..2b2b14476 100644 --- a/src/backend_task/migration/single_key_restore.rs +++ b/src/backend_task/migration/single_key_restore.rs @@ -332,7 +332,6 @@ pub(super) fn derive_p2pkh_address(wif: &str, network: Network) -> Result, ) -> String { use crate::model::wallet::single_key::ClosedSingleKey; - use dash_sdk::dpp::dashcore::{Address, PrivateKey, PublicKey}; let path = ctx.db.db_file_path().expect("data.db path"); @@ -3607,7 +3606,6 @@ fn seed_legacy_protected_single_key( nonce, } = ClosedSingleKey::encrypt_private_key(raw_key, password).expect("encrypt"); let priv_key = PrivateKey::from_byte_array(raw_key, Network::Testnet).expect("priv"); - let pub_key = PublicKey { compressed: priv_key.compressed, inner: priv_key.inner.public_key(), diff --git a/src/database/test_helpers.rs b/src/database/test_helpers.rs index 01248ae89..ed7f2495f 100644 --- a/src/database/test_helpers.rs +++ b/src/database/test_helpers.rs @@ -161,13 +161,11 @@ pub fn legacy_master_epk_bytes( network: dash_sdk::dpp::dashcore::Network, ) -> Vec { use dash_sdk::dpp::dashcore::Network; - use dash_sdk::dpp::key_wallet::bip32::{ ChildNumber, DerivationPath, ExtendedPrivKey, ExtendedPubKey, }; let coin_type = if network == Network::Mainnet { 5 } else { 1 }; - let master = ExtendedPrivKey::new_master(network, seed).expect("master key"); let path = DerivationPath::from(vec![ ChildNumber::Hardened { index: 44 }, diff --git a/src/database/wallet.rs b/src/database/wallet.rs index bbae151d3..75a7b85ee 100644 --- a/src/database/wallet.rs +++ b/src/database/wallet.rs @@ -554,7 +554,6 @@ impl From for rusqlite::Error { mod tests { use super::*; use crate::database::test_helpers::create_test_database; - use dash_sdk::dpp::key_wallet::bip32::DerivationPath; use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, ExtendedPrivKey}; @@ -568,7 +567,6 @@ mod tests { fn create_test_master_epk_bytes(network: Network) -> Vec { let seed = [7u8; 64]; - let master = ExtendedPrivKey::new_master(network, &seed).expect("master key"); let path = DerivationPath::from(vec![ ChildNumber::Hardened { index: 44 }, diff --git a/src/model/dashpay_derivation/dip14.rs b/src/model/dashpay_derivation/dip14.rs index 1009eaf40..7bb826d2b 100644 --- a/src/model/dashpay_derivation/dip14.rs +++ b/src/model/dashpay_derivation/dip14.rs @@ -116,7 +116,6 @@ pub fn derive_dashpay_incoming_xpub_dip14( let base_path = DerivationPath::from_str(&format!("m/9'/{coin_type}'/15'/{account}'"))?; // Derive to the account level using standard BIP32 - let account_xprv = master_xprv.derive_priv(&base_path)?; // Now use DIP-14 256-bit derivation for the identity levels diff --git a/src/model/dashpay_derivation/hd.rs b/src/model/dashpay_derivation/hd.rs index cf4a4862b..9ca674a03 100644 --- a/src/model/dashpay_derivation/hd.rs +++ b/src/model/dashpay_derivation/hd.rs @@ -294,7 +294,6 @@ mod tests { let master_seed = [2u8; 64]; let master_xprv = ExtendedPrivKey::new_master(network, &master_seed).unwrap(); - let xpub = ExtendedPubKey::from_priv(&master_xprv); // Test version 0 diff --git a/src/model/qualified_identity/mod.rs b/src/model/qualified_identity/mod.rs index 4166ab369..3dd57c599 100644 --- a/src/model/qualified_identity/mod.rs +++ b/src/model/qualified_identity/mod.rs @@ -981,7 +981,6 @@ impl QualifiedIdentity { ) -> Result, ProtocolError> { use dash_sdk::dpp::dashcore::PublicKey; use dash_sdk::dpp::dashcore::hashes::{Hash, ripemd160, sha256}; - use dash_sdk::dpp::key_wallet::bip32::{DerivationPath as DP, KeyDerivationType}; let (Some(secret_access), Some(seed_hash)) = @@ -1003,7 +1002,6 @@ impl QualifiedIdentity { let Some(seed) = plaintext.expose_hd_seed() else { return Ok(None); }; - for identity_index in 0..10u32 { let correct_path = DP::identity_authentication_path( network, diff --git a/src/model/qualified_identity/qualified_identity_public_key.rs b/src/model/qualified_identity/qualified_identity_public_key.rs index b5e1e0966..cb042cf9d 100644 --- a/src/model/qualified_identity/qualified_identity_public_key.rs +++ b/src/model/qualified_identity/qualified_identity_public_key.rs @@ -145,7 +145,6 @@ mod tests { fn pubkey(n: u8) -> PublicKey { let mut sk_bytes = [3u8; 32]; sk_bytes[31] = n.max(1); - let sk = SecretKey::from_secret_bytes(sk_bytes).expect("valid secret key"); PublicKey::new(sk.public_key()) } diff --git a/src/model/single_key.rs b/src/model/single_key.rs index 27951c958..a89dd69ca 100644 --- a/src/model/single_key.rs +++ b/src/model/single_key.rs @@ -10,7 +10,6 @@ //! (spending gated) until single-key moves onto the upstream wallet runtime. use dash_sdk::dpp::dashcore::key::Error as KeyError; - use dash_sdk::dpp::dashcore::{Address, Network, PrivateKey, PublicKey}; use serde::{Deserialize, Serialize}; @@ -22,7 +21,6 @@ use serde::{Deserialize, Serialize}; /// task is the authoritative enforcement layer. pub fn validate_wif(wif: &str, network: Network) -> Result { let priv_key = PrivateKey::from_wif(wif)?; - let pub_key = PublicKey { compressed: priv_key.compressed, inner: priv_key.inner.public_key(), diff --git a/src/model/wallet/mod.rs b/src/model/wallet/mod.rs index 600c585e9..d3e593705 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -485,7 +485,6 @@ impl Wallet { } })?; let bip44_path = Self::bip44_account0_path(network); - let account_priv = master_priv.derive_priv(&bip44_path).map_err(|e| { WalletCreationError::KeyDerivation { source: Box::new(e), @@ -1341,7 +1340,6 @@ impl Wallet { app_context: &AppContext, ) -> Result<(), WalletError> { let coin_type = coin_type_for_network(network); - for (change_flag, max) in [ (false, BOOTSTRAP_BIP44_EXTERNAL_COUNT), (true, BOOTSTRAP_BIP44_CHANGE_COUNT), @@ -1631,7 +1629,6 @@ impl Wallet { let private_key = extended_private_key.to_priv(); // Create a P2PKH address for platform payment - let public_key = private_key.public_key(); let platform_address = Address::p2pkh(&public_key, network); @@ -2012,7 +2009,6 @@ pub(crate) fn derive_platform_payment_account_xpub( ChildNumber::Hardened { index: account }, ChildNumber::Hardened { index: key_class }, ]); - let master = ExtendedPrivKey::new_master(network, seed)?; let account_priv = master.derive_priv(&account_path)?; Ok(ExtendedPubKey::from_priv(&account_priv)) @@ -3111,7 +3107,6 @@ mod tests { .derive_priv_ecdsa_for_master_seed(&seed, network) .expect("reference derive") .to_priv(); - let address = Address::p2pkh(&reference.public_key(), network); let mut wallet = wallet; @@ -3139,7 +3134,6 @@ mod tests { let reference_xprv = reference_path .derive_priv_ecdsa_for_master_seed(&seed, network) .expect("reference derive"); - let reference = Address::p2pkh(&reference_xprv.to_priv().public_key(), network); let param = param_wallet @@ -3783,7 +3777,6 @@ mod tests { /// for. Independent of the reconciliation path under test. fn platform_address_at(index: u32, network: Network) -> Address { let path = DerivationPath::platform_payment_path(network, 0, 0, index); - let xprv = path .derive_priv_ecdsa_for_master_seed(&TEST_SEED, network) .expect("derive platform key"); @@ -4010,7 +4003,6 @@ mod tests { let foreign_seed = [0x11u8; 64]; let path = DerivationPath::platform_payment_path(network, 0, 0, 0); - let xprv = path .derive_priv_ecdsa_for_master_seed(&foreign_seed, network) .expect("derive foreign key"); diff --git a/src/model/wallet/single_key.rs b/src/model/wallet/single_key.rs index 9cb27bbca..0f6c84524 100644 --- a/src/model/wallet/single_key.rs +++ b/src/model/wallet/single_key.rs @@ -10,7 +10,6 @@ use aes_gcm::aead::Aead; use aes_gcm::{Aes256Gcm, KeyInit}; - use dash_sdk::dpp::dashcore::{Address, Network, OutPoint, PrivateKey, PublicKey, TxOut}; use sha2::{Digest, Sha256}; use std::collections::HashMap; @@ -516,7 +515,6 @@ mod tests { // Through a SingleKeyWallet that holds the closed key. let priv_key = PrivateKey::from_byte_array(&secret, Network::Testnet).expect("valid key bytes"); - let public_key = priv_key.public_key(); let address = Address::p2pkh(&public_key, Network::Testnet); let wallet = SingleKeyWallet { diff --git a/src/ui/identity/funding_common.rs b/src/ui/identity/funding_common.rs index 946af984a..b65c76374 100644 --- a/src/ui/identity/funding_common.rs +++ b/src/ui/identity/funding_common.rs @@ -687,7 +687,6 @@ mod tests { fn addr(n: u8) -> Address { let mut sk_bytes = [1u8; 32]; sk_bytes[31] = n.max(1); - let sk = SecretKey::from_secret_bytes(sk_bytes).expect("valid secret key"); let pubkey = PublicKey::new(sk.public_key()); Address::p2pkh(&pubkey, Network::Testnet) diff --git a/src/ui/identity/keys/key_info_screen.rs b/src/ui/identity/keys/key_info_screen.rs index e80279a75..692088e5c 100644 --- a/src/ui/identity/keys/key_info_screen.rs +++ b/src/ui/identity/keys/key_info_screen.rs @@ -2288,7 +2288,6 @@ mod tests { /// passes and the placement check is what decides the outcome. fn keypair(id: KeyID, purpose: Purpose) -> (IdentityPublicKey, [u8; 32]) { let secret_bytes = [0x2A; 32]; - let secret = SecretKey::from_secret_bytes(secret_bytes).expect("a valid secret key"); let data = PrivateKey::from_byte_array(secret.as_secret_bytes(), Network::Testnet) .expect("valid secret key") diff --git a/src/ui/wallets/wallets_screen/address_table.rs b/src/ui/wallets/wallets_screen/address_table.rs index ed000b95c..577ab2e29 100644 --- a/src/ui/wallets/wallets_screen/address_table.rs +++ b/src/ui/wallets/wallets_screen/address_table.rs @@ -580,7 +580,6 @@ mod tests { fn addr(n: u8) -> Address { let mut sk_bytes = [1u8; 32]; sk_bytes[31] = n.max(1); - let sk = SecretKey::from_secret_bytes(sk_bytes).unwrap(); let pubkey = PublicKey::new(sk.public_key()); Address::p2pkh(&pubkey, Network::Testnet) diff --git a/src/wallet_backend/det_platform_signer.rs b/src/wallet_backend/det_platform_signer.rs index 5f7c4ec63..bf8729edb 100644 --- a/src/wallet_backend/det_platform_signer.rs +++ b/src/wallet_backend/det_platform_signer.rs @@ -203,7 +203,6 @@ mod tests { use super::*; use crate::model::wallet::{AddressInfo, DerivationPathReference, DerivationPathType}; use dash_sdk::dpp::dashcore::Address; - use zeroize::Zeroizing; /// Reference platform-address signing — reproduces exactly what the @@ -242,7 +241,6 @@ mod tests { let xprv = path .derive_priv_ecdsa_for_master_seed(&*seed, network) .expect("derive platform key"); - let address = Address::p2pkh(&xprv.to_priv().public_key(), network); let platform_address = PlatformAddress::try_from(address.clone()).expect("to platform address"); @@ -352,7 +350,6 @@ mod tests { let other_xprv = other_path .derive_priv_ecdsa_for_master_seed(&*seed, network) .unwrap(); - let other_address = Address::p2pkh(&other_xprv.to_priv().public_key(), network); let other_platform = PlatformAddress::try_from(other_address).unwrap(); diff --git a/src/wallet_backend/det_signer.rs b/src/wallet_backend/det_signer.rs index 8ce7096b0..84df4f0e4 100644 --- a/src/wallet_backend/det_signer.rs +++ b/src/wallet_backend/det_signer.rs @@ -207,7 +207,6 @@ impl Signer for DetSigner<'_> { sighash: [u8; 32], ) -> Result<(EcdsaSignature, EcdsaPublicKey), Self::Error> { let secret = self.derive_secret(path)?; - let msg = message_from_digest(sighash)?; let signature = dash_sdk::dpp::secp256k1::ecdsa::sign(msg, &secret); let public_key = PublicKey::from_secret_key(&secret); @@ -341,7 +340,6 @@ mod tests { // Independent reference: derive the key directly from the seed at // the path, then sign + recover the public key with the same // secp256k1 primitive `DetSigner` uses internally. - let reference_sk = secp256k1::SecretKey::from_secret_bytes( *path .derive_priv_ecdsa_for_master_seed(&PARITY_SEED, network) @@ -396,7 +394,6 @@ mod tests { for network in [Network::Testnet, Network::Mainnet] { // Independent reference: derive the xprv straight from the seed at // the path, then convert to the xpub with the same primitive. - let reference_xprv = path .derive_priv_ecdsa_for_master_seed(&PARITY_SEED, network) .expect("reference derive"); @@ -439,7 +436,6 @@ mod tests { #[test] fn hd_derivation_matches_pinned_vector() { let path: DerivationPath = "m/44'/1'/0'/0/0".parse().unwrap(); - let pk = path .derive_priv_ecdsa_for_master_seed(&PARITY_SEED, Network::Testnet) .expect("derive") diff --git a/src/wallet_backend/secret_access.rs b/src/wallet_backend/secret_access.rs index 2038b5317..cf2ba069b 100644 --- a/src/wallet_backend/secret_access.rs +++ b/src/wallet_backend/secret_access.rs @@ -1748,7 +1748,6 @@ mod tests { let priv_key = PrivateKey::from_wif(&known_testnet_wif()).expect("wif"); let raw: Zeroizing<[u8; 32]> = priv_key.inner.to_bytes(); - let pub_key = PublicKey { compressed: priv_key.compressed, inner: priv_key.inner.public_key(), diff --git a/src/wallet_backend/single_key.rs b/src/wallet_backend/single_key.rs index 2fb1c85f9..6981a88ff 100644 --- a/src/wallet_backend/single_key.rs +++ b/src/wallet_backend/single_key.rs @@ -13,10 +13,9 @@ use std::sync::Arc; +use dash_sdk::dpp::dashcore::{Address, Network, PrivateKey, PublicKey}; #[cfg(test)] use dash_sdk::dpp::secp256k1::Message; - -use dash_sdk::dpp::dashcore::{Address, Network, PrivateKey, PublicKey}; #[cfg(test)] use dash_sdk::dpp::secp256k1::ecdsa::Signature; use platform_wallet_storage::secrets::{ @@ -256,7 +255,6 @@ impl<'a> SingleKeyView<'a> { if !priv_key.compressed { return Err(TaskError::UncompressedWifUnsupported); } - let pub_key = PublicKey { compressed: priv_key.compressed, inner: priv_key.inner.public_key(), @@ -710,7 +708,6 @@ impl<'a> SingleKeyView<'a> { return Ok(None); } }; - let public_key = priv_key.public_key(); let address = Address::p2pkh(&public_key, meta.network); @@ -1268,7 +1265,6 @@ mod tests { // the signer hit the imported-key path (not some other key the // backend might fall back to) without referencing BIP-32 at all. let priv_key = PrivateKey::from_wif(known_wif()).expect("wif"); - let pk = dash_sdk::dpp::secp256k1::PublicKey::from(priv_key.inner.public_key()); dash_sdk::dpp::secp256k1::ecdsa::verify(&sig, Message::from_digest(msg), &pk) .expect("signature verifies against WIF-derived pubkey"); @@ -1922,7 +1918,6 @@ mod tests { .expect("chokepoint signs after the right passphrase"); let priv_key = PrivateKey::from_wif(known_wif()).unwrap(); - let pk = dash_sdk::dpp::secp256k1::PublicKey::from(priv_key.inner.public_key()); dash_sdk::dpp::secp256k1::ecdsa::verify(&sig, Message::from_digest(msg), &pk) .expect("chokepoint signature verifies"); diff --git a/src/wallet_backend/snapshot.rs b/src/wallet_backend/snapshot.rs index 90807d228..7ab2ac053 100644 --- a/src/wallet_backend/snapshot.rs +++ b/src/wallet_backend/snapshot.rs @@ -2287,7 +2287,6 @@ mod tests { fn addr(n: u8) -> Address { let mut sk_bytes = [1u8; 32]; sk_bytes[31] = n.max(1); - let sk = SecretKey::from_secret_bytes(sk_bytes).unwrap(); let pubkey = PublicKey::new(sk.public_key()); Address::p2pkh(&pubkey, Network::Testnet) From a797ee1db3ebc724bb1ba2dfbea2610271c837aa Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:57:33 +0000 Subject: [PATCH 31/58] fix(identity): pick document keys in upstream platform-wallet's order Upstream platform-wallet is the reference for signing key choice. Its selector ranks every unlimited key at any accepted level before any limited key, then key id order. DET walked the levels one at a time, so a budgeted HIGH key signed while an unlimited CRITICAL key was held and spent the delegated key's budget. Drop that walk and use the upstream order. DashPay documents now take ECDSA keys only, as upstream's DashPay writes do (ECDSA_HASH160 included, as dashpay/platform#5546 adds it), through one QualifiedIdentity::dashpay_signing_key for every DashPay caller. The auto-accept proof key follows upstream's select_own_encryption_key: the first enabled ECDSA ENCRYPTION key at any level. The remaining mirrors carry TODO(upstream) markers naming the platform-wallet and dpp APIs that would replace them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- src/backend_task/dashpay/auto_accept_proof.rs | 6 +- src/backend_task/dashpay/contact_info.rs | 32 +-- src/backend_task/dashpay/contact_requests.rs | 2 +- src/model/dpns_usernames.rs | 3 + src/model/identity_key_usability.rs | 8 + src/model/qualified_identity/mod.rs | 245 +++++++++++++----- .../document_action_screen.rs | 18 +- src/ui/dashpay/add_contact_screen.rs | 3 +- src/ui/dashpay/mod.rs | 6 +- src/ui/dashpay/qr_scanner.rs | 47 +--- 10 files changed, 225 insertions(+), 145 deletions(-) diff --git a/src/backend_task/dashpay/auto_accept_proof.rs b/src/backend_task/dashpay/auto_accept_proof.rs index 8974538ba..fa799abd7 100644 --- a/src/backend_task/dashpay/auto_accept_proof.rs +++ b/src/backend_task/dashpay/auto_accept_proof.rs @@ -269,9 +269,9 @@ pub async fn verify_auto_accept_proof( // Derive expected pubkey from our seed and key index (timestamp) // Use ENCRYPTION key (ECDSA_SECP256K1) for HD derivation as per DIP-15 - let signing_key = our_identity - .auto_accept_proof_key() - .ok_or("No suitable key found. This operation requires a MEDIUM security level ECDSA_SECP256K1 ENCRYPTION key.")?; + let signing_key = our_identity.auto_accept_proof_key().ok_or( + "No suitable key found. This operation requires an enabled ECDSA_SECP256K1 ENCRYPTION key.", + )?; // Resolve the ENCRYPTION private key through the JIT chokepoint — no // parked-seed read. let wallet_seed = our_identity diff --git a/src/backend_task/dashpay/contact_info.rs b/src/backend_task/dashpay/contact_info.rs index c22474831..dcd2a9ae2 100644 --- a/src/backend_task/dashpay/contact_info.rs +++ b/src/backend_task/dashpay/contact_info.rs @@ -583,6 +583,11 @@ pub(super) async fn contact_info_is_hidden( /// encryption keys cannot be derived, when an encoded field exceeds its v0 or /// contract limit, when the identity has no usable authentication key, or when /// the state transition is rejected. +// TODO(upstream): publish through platform-wallet's +// `DashPayView::set_contact_info_with_external_signer` once it preserves the +// stored `acceptedAccounts` (it writes an empty list) and DET supplies a +// `ContactCryptoProvider`; until then DET keeps its own write path and takes +// the signing key from `QualifiedIdentity::dashpay_signing_key`. pub async fn create_or_update_contact_info( app_context: &Arc, sdk: &Sdk, @@ -633,7 +638,8 @@ pub async fn create_or_update_contact_info( )); } - let signing_key = contact_info_signing_key(&identity, &dashpay_contract) + let signing_key = identity + .dashpay_signing_key(&dashpay_contract, "contactInfo") .ok_or_else(|| TaskError::DashPay(DashPayError::MissingAuthenticationKey))?; // Create document properties @@ -768,15 +774,6 @@ pub async fn create_or_update_contact_info( }) } -/// The key to sign a contactInfo document with, of any key type: Platform -/// accepts all of them for document transitions. -fn contact_info_signing_key<'i>( - identity: &'i QualifiedIdentity, - dashpay_contract: &dash_sdk::platform::DataContract, -) -> Option<&'i dash_sdk::platform::IdentityPublicKey> { - identity.document_action_signing_key(dashpay_contract, "contactInfo", None) -} - #[cfg(test)] mod tests { use super::*; @@ -1278,19 +1275,4 @@ mod tests { }) )); } - - /// Regression: the contactInfo key choice admitted MEDIUM keys, which the - /// document type refuses, and preferred unlimited keys, so an unlimited - /// MEDIUM key beat a held limited HIGH key and the save was refused. - #[test] - fn a_contact_info_is_signed_with_a_key_its_document_type_accepts() { - use crate::context::test_support::{auth_key, dashpay_contract, identity_holding}; - use dash_sdk::dpp::identity::SecurityLevel; - - let limited_high = auth_key(1, SecurityLevel::HIGH).with_limits(Some(5_000), None); - let identity = identity_holding(&[limited_high, auth_key(2, SecurityLevel::MEDIUM)]); - - let chosen = contact_info_signing_key(&identity, &dashpay_contract()); - assert_eq!(chosen.map(|key| key.id()), Some(1)); - } } diff --git a/src/backend_task/dashpay/contact_requests.rs b/src/backend_task/dashpay/contact_requests.rs index 4a4b02db0..8b280c822 100644 --- a/src/backend_task/dashpay/contact_requests.rs +++ b/src/backend_task/dashpay/contact_requests.rs @@ -716,7 +716,7 @@ pub async fn accept_contact_request( } let signing_key = identity - .document_action_signing_key(&app_context.dashpay_contract(), "contactRequest", None) + .dashpay_signing_key(&app_context.dashpay_contract(), "contactRequest") .ok_or_else(|| TaskError::DashPay(DashPayError::MissingAuthenticationKey))? .clone(); diff --git a/src/model/dpns_usernames.rs b/src/model/dpns_usernames.rs index 8505dbff5..4f046363f 100644 --- a/src/model/dpns_usernames.rs +++ b/src/model/dpns_usernames.rs @@ -138,6 +138,9 @@ pub fn key_can_sign_documents( /// Unlike [`key_can_sign_documents`], an expired key is allowed: the key chooser /// offers it with a warning, and whether it has expired is Platform's call by /// network time, not this device's clock. +// TODO(upstream): call platform-wallet's public explicit-key validation once it +// exists (`resolve_authentication_signing_key` is private) and route the +// registration through `register_name_with_signer`. pub fn chosen_key_can_sign_documents( key: &dash_sdk::platform::IdentityPublicKey, required: dash_sdk::dpp::identity::SecurityLevel, diff --git a/src/model/identity_key_usability.rs b/src/model/identity_key_usability.rs index cb0a62e8c..a26cc0b89 100644 --- a/src/model/identity_key_usability.rs +++ b/src/model/identity_key_usability.rs @@ -78,6 +78,9 @@ impl KeyCaveat { /// Whether a key carrying `bounds` may sign in `scope`, when that can be known /// offline. `None` means only Platform can answer (a contract-group bound on a /// batch transition). +// TODO(upstream): delegate to a dpp pre-build bounds check (dpp's public +// `ContractBounds::check_batched_transition` needs a built transition, and the +// non-batch rule lives in the private `verify_identity_key_bounds`). pub fn bounds_permit(bounds: Option<&ContractBounds>, scope: SigningScope<'_>) -> Option { match (bounds, scope) { (None, _) => Some(true), @@ -162,6 +165,8 @@ pub fn is_auto_selectable( /// The security levels Platform accepts for a document action on /// `document_type`: CRITICAL up to and including the level the type requires, /// since a stronger key satisfies a weaker requirement. Never MASTER. +// TODO(upstream): call dpp's accepted-levels helper for a requirement once it +// is public (platform-wallet's `allowed_signing_security_levels` is private). pub fn document_security_levels(document_type: DocumentTypeRef<'_>) -> Vec { let required = document_type.security_level_requirement() as u8; [ @@ -205,6 +210,9 @@ impl KeyRequirements<'_> { /// this device can sign with wins. So a limited key held here beats an /// unlimited one whose private half lives elsewhere — the delegated-key case /// protocol version 14 limits exist for. +// TODO(upstream): replace with platform-wallet's public signer-aware key +// selector once it exists (`usable_authentication_key` and `first_available` +// are crate-private). pub fn select_signing_key<'k>( keys: impl IntoIterator, requirements: KeyRequirements<'_>, diff --git a/src/model/qualified_identity/mod.rs b/src/model/qualified_identity/mod.rs index aa23f5c2f..acaca8129 100644 --- a/src/model/qualified_identity/mod.rs +++ b/src/model/qualified_identity/mod.rs @@ -574,6 +574,16 @@ impl Signer for QualifiedIdentity { /// compatible with blobs `to_bytes` already wrote. const IDENTITY_BLOB_DECODE_LIMIT: usize = 16 * 1024 * 1024; // 16 MiB +/// The key types a DashPay document (contactRequest, contactInfo, profile) is +/// signed with: ECDSA keys, as upstream platform-wallet's DashPay writes +/// require. `ECDSA_HASH160` is taken too, as dashpay/platform#5546 adds it +/// upstream; DET accepted it before, so dropping it would strand identities +/// whose only document key is a hash160 key. +// TODO(upstream): take the key types from platform-wallet's public DashPay +// key selector once it exists, instead of restating them here. +pub const DASHPAY_SIGNING_KEY_TYPES: &[KeyType] = + &[KeyType::ECDSA_SECP256K1, KeyType::ECDSA_HASH160]; + /// The bincode configuration [`QualifiedIdentity::from_bytes`] decodes under. /// Pulled into its own function (rather than inlined at the one call site) so /// the decode-limit regression test below exercises the *exact* configuration @@ -1158,16 +1168,14 @@ impl QualifiedIdentity { /// The key to sign document transitions on `document_type` with, within /// `scope`: a live AUTHENTICATION key at a level the type accepts /// ([`document_security_levels`]) whose contract bounds permit `scope`, - /// unlimited keys first (see [`crate::model::identity_key_usability`]). - /// The level the type requires is preferred; a stronger key signs only - /// when no key at that level qualifies. + /// in upstream platform-wallet's order (see [`Self::signing_key_now`]). pub fn document_signing_key( &self, scope: SigningScope<'_>, document_type: &DocumentTypeRef, ) -> Option<&IdentityPublicKey> { let levels = document_security_levels(*document_type); - self.least_privileged_signing_key_now(KeyRequirements::new( + self.signing_key_now(KeyRequirements::new( Purpose::AUTHENTICATION, &levels, scope, @@ -1176,41 +1184,57 @@ impl QualifiedIdentity { /// The key to sign an action on document type `document_type_name` of /// `contract` with: a live AUTHENTICATION key at a level the type accepts - /// ([`document_security_levels`]), of one of `key_types` when given, whose - /// bounds permit that document type. The least privileged level is tried - /// first, as in [`Self::document_signing_key`]. `None` when the contract - /// has no such type or no key qualifies. + /// ([`document_security_levels`]), of one of `key_types`, whose bounds + /// permit that document type, in upstream platform-wallet's order (see + /// [`Self::signing_key_now`]). `None` when the contract has no such type + /// or no key qualifies. pub fn document_action_signing_key( &self, contract: &DataContract, document_type_name: &str, - key_types: Option<&[KeyType]>, + key_types: &[KeyType], ) -> Option<&IdentityPublicKey> { let document_type = contract.document_type_for_name(document_type_name).ok()?; let levels = document_security_levels(document_type); - let mut requirements = KeyRequirements::new( - Purpose::AUTHENTICATION, - &levels, - SigningScope::Document { - contract_id: contract.id(), - document_type_name, - }, - ); - if let Some(key_types) = key_types { - requirements = requirements.with_key_types(key_types); - } - self.least_privileged_signing_key_now(requirements) + self.signing_key_now( + KeyRequirements::new( + Purpose::AUTHENTICATION, + &levels, + SigningScope::Document { + contract_id: contract.id(), + document_type_name, + }, + ) + .with_key_types(key_types), + ) + } + + /// The key to sign a `document_type_name` DashPay document with: a + /// [`Self::document_action_signing_key`] of one of + /// [`DASHPAY_SIGNING_KEY_TYPES`]. + pub fn dashpay_signing_key( + &self, + dashpay_contract: &DataContract, + document_type_name: &str, + ) -> Option<&IdentityPublicKey> { + self.document_action_signing_key( + dashpay_contract, + document_type_name, + DASHPAY_SIGNING_KEY_TYPES, + ) } /// The ENCRYPTION key whose wallet seeds DashPay auto-accept proofs - /// (DIP-15): the first MEDIUM ECDSA_SECP256K1 one. + /// (DIP-15): the first enabled ECDSA_SECP256K1 one, at any security level + /// (lowest key id first). + // TODO(upstream): call platform-wallet's `select_own_encryption_key` once + // it is public; this mirrors its policy until then. pub fn auto_accept_proof_key(&self) -> Option<&IdentityPublicKey> { - self.identity.get_first_public_key_matching( - Purpose::ENCRYPTION, - std::collections::HashSet::from([SecurityLevel::MEDIUM]), - std::collections::HashSet::from([KeyType::ECDSA_SECP256K1]), - false, - ) + self.identity.public_keys().values().find(|key| { + key.purpose() == Purpose::ENCRYPTION + && key.key_type() == KeyType::ECDSA_SECP256K1 + && !key.is_disabled() + }) } /// The key as the identity holds it now for `snapshot`, a copy taken @@ -1225,29 +1249,12 @@ impl QualifiedIdentity { .unwrap_or(snapshot) } - /// The key to sign with for `requirements` now, least privileged first: - /// each accepted security level is tried from the weakest up, so a - /// stronger key signs only when no key at a weaker accepted level - /// qualifies. Within a level, [`Self::signing_key_now`] decides (unlimited - /// keys first). - pub fn least_privileged_signing_key_now( - &self, - requirements: KeyRequirements<'_>, - ) -> Option<&IdentityPublicKey> { - let mut levels = requirements.security_levels.to_vec(); - levels.sort_by_key(|level| std::cmp::Reverse(*level as u8)); - levels.dedup(); - levels.iter().find_map(|level| { - self.signing_key_now(KeyRequirements { - security_levels: std::slice::from_ref(level), - ..requirements - }) - }) - } - /// The key to sign with for `requirements` now: the automatic choice of /// [`select_identity_signing_key`] among the keys this device holds the - /// private half of. + /// private half of. Upstream platform-wallet's order: every qualifying + /// unlimited key at any accepted level before any limited one, then key + /// id order, so a budgeted application key is spent only when no + /// unlimited key can sign. pub fn signing_key_now(&self, requirements: KeyRequirements<'_>) -> Option<&IdentityPublicKey> { select_identity_signing_key(&self.identity, requirements, now_ms(), |key| { self.can_sign_with(key) @@ -3396,21 +3403,141 @@ mod protocol_v14_compat_tests { use dash_sdk::dpp::version::PlatformVersion; use dash_sdk::platform::Identifier; - /// Regression: the document action selector offered every accepted level - /// at once, so an unlimited CRITICAL key signed DashPay documents although - /// an unlimited HIGH key, the level the type requires, was held. Both - /// document selectors take the least privileged key that qualifies. + /// Upstream platform-wallet's `usable_authentication_key` is the reference + /// order: every unlimited key at any accepted level comes before any + /// limited key, then key id order. A delegated (budgeted) HIGH key is not + /// spent while an unlimited CRITICAL key is held. #[test] - fn a_document_action_takes_the_least_privileged_key() { + fn a_document_action_prefers_an_unlimited_key_at_any_accepted_level() { use crate::context::test_support::{auth_key, dashpay_contract, identity_holding}; let contract = dashpay_contract(); + let identity = identity_holding(&[ + auth_key(1, SecurityLevel::CRITICAL), + auth_key(2, SecurityLevel::HIGH).with_limits(Some(5_000), None), + ]); + assert_eq!( + identity + .dashpay_signing_key(&contract, "contactRequest") + .map(|key| key.id()), + Some(1), + "the unlimited CRITICAL key, not the budgeted HIGH key" + ); + let identity = identity_holding(&[ auth_key(1, SecurityLevel::CRITICAL), auth_key(2, SecurityLevel::HIGH), ]); + assert_eq!( + identity + .dashpay_signing_key(&contract, "contactRequest") + .map(|key| key.id()), + Some(1), + "key id order among unlimited keys, whatever their level" + ); + } + + /// Regression: the contactRequest and contactInfo key choices admitted + /// MEDIUM keys, which the document types refuse, so an unlimited MEDIUM + /// key beat a held limited HIGH key and the write was refused. + #[test] + fn a_dashpay_document_is_signed_with_a_key_its_document_type_accepts() { + use crate::context::test_support::{auth_key, dashpay_contract, identity_holding}; + let limited_high = auth_key(1, SecurityLevel::HIGH).with_limits(Some(5_000), None); + let identity = identity_holding(&[limited_high, auth_key(2, SecurityLevel::MEDIUM)]); + for name in ["contactRequest", "contactInfo"] { + assert_eq!( + identity + .dashpay_signing_key(&dashpay_contract(), name) + .map(|key| key.id()), + Some(1), + "{name}" + ); + } + } + + /// DashPay documents are signed with an ECDSA key, as upstream + /// platform-wallet's DashPay writes require; a BLS or EdDSA key is + /// skipped even at an accepted level. + #[test] + fn a_dashpay_document_is_signed_with_an_ecdsa_key() { + use crate::context::test_support::{auth_key, dashpay_contract, identity_holding}; + use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + + let typed = |id: KeyID, key_type: KeyType, len: usize| -> IdentityPublicKey { + IdentityPublicKeyV0 { + id, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type, + read_only: false, + data: vec![2; len].into(), + disabled_at: None, + } + .into() + }; + let contract = dashpay_contract(); + let identity = identity_holding(&[ + typed(1, KeyType::BLS12_381, 48), + typed(2, KeyType::EDDSA_25519_HASH160, 20), + auth_key(3, SecurityLevel::HIGH), + ]); + for name in ["contactRequest", "contactInfo"] { + assert_eq!( + identity + .dashpay_signing_key(&contract, name) + .map(|key| key.id()), + Some(3), + "{name}" + ); + } - let chosen = identity.document_action_signing_key(&contract, "contactRequest", None); - assert_eq!(chosen.map(|key| key.id()), Some(2)); + let identity = identity_holding(&[typed(1, KeyType::ECDSA_HASH160, 20)]); + assert_eq!( + identity + .dashpay_signing_key(&contract, "contactRequest") + .map(|key| key.id()), + Some(1), + "an ECDSA_HASH160 key signs like an ECDSA_SECP256K1 key" + ); + } + + /// The auto-accept proof key follows upstream platform-wallet's + /// `select_own_encryption_key`: the first enabled ECDSA_SECP256K1 + /// ENCRYPTION key, at any security level. + #[test] + fn the_auto_accept_proof_key_is_the_first_enabled_ecdsa_encryption_key() { + use crate::context::test_support::identity_holding; + use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + + let encryption = |id: KeyID, level: SecurityLevel, disabled: bool| -> IdentityPublicKey { + IdentityPublicKeyV0 { + id, + purpose: Purpose::ENCRYPTION, + security_level: level, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: vec![2; 33].into(), + disabled_at: disabled.then_some(1), + } + .into() + }; + let identity = identity_holding(&[encryption(3, SecurityLevel::HIGH, false)]); + assert_eq!( + identity.auto_accept_proof_key().map(|key| key.id()), + Some(3) + ); + + let identity = identity_holding(&[ + encryption(1, SecurityLevel::MEDIUM, true), + encryption(2, SecurityLevel::HIGH, false), + ]); + assert_eq!( + identity.auto_accept_proof_key().map(|key| key.id()), + Some(2), + "a disabled key is skipped" + ); } /// The bundled v0.9.3 profile: real identity rows written by DET v0.9.3. @@ -3541,7 +3668,7 @@ mod protocol_v14_compat_tests { "the CRITICAL key, never the MASTER key" ); - // A key at the required level is still preferred over a stronger one. + // Every accepted level is one tier: the lowest key id wins. let qi = identity_holding(&[ auth_key(1, SecurityLevel::MASTER), auth_key(2, SecurityLevel::CRITICAL), @@ -3549,8 +3676,8 @@ mod protocol_v14_compat_tests { ]); assert_eq!( qi.document_signing_key(scope, &preorder).map(|k| k.id()), - Some(3), - "the HIGH key the type asks for" + Some(2), + "key id order among accepted levels, as upstream orders them" ); } diff --git a/src/ui/contracts_documents/document_action_screen.rs b/src/ui/contracts_documents/document_action_screen.rs index 489dc352d..e2ab2d8c9 100644 --- a/src/ui/contracts_documents/document_action_screen.rs +++ b/src/ui/contracts_documents/document_action_screen.rs @@ -1974,8 +1974,8 @@ impl DocumentActionScreen { /// The key to sign a document action with when the user has not picked one: /// a live AUTHENTICATION key this device can sign with, at a security level /// the document type accepts, within the contract and document type's scope. -/// The least privileged accepted level is tried first, as for every document -/// key ([`QualifiedIdentity::least_privileged_signing_key_now`]). +/// Keys are ranked in upstream platform-wallet's order, as for every document +/// key ([`QualifiedIdentity::signing_key_now`]). fn auto_selected_document_key( identity: &QualifiedIdentity, contract: Option<&QualifiedContract>, @@ -1988,7 +1988,7 @@ fn auto_selected_document_key( document_type, ); identity - .least_privileged_signing_key_now(KeyRequirements::new( + .signing_key_now(KeyRequirements::new( dash_sdk::dpp::identity::Purpose::AUTHENTICATION, &levels, signing_scope(contract, document_type), @@ -2101,11 +2101,11 @@ mod tests { assert_eq!(chosen.map(|key| key.id()), Some(1)); } - /// Regression: automatic selection offered every accepted level at once, - /// so an unlimited CRITICAL key signed where an unlimited key at the level - /// the document type requires was held. + /// Automatic selection follows upstream platform-wallet's order: an + /// unlimited key at any accepted level beats a limited key, so a budgeted + /// HIGH key is not spent while an unlimited CRITICAL key is held. #[test] - fn auto_selection_takes_the_least_privileged_key() { + fn auto_selection_prefers_an_unlimited_key_at_any_accepted_level() { use dash_sdk::dpp::identity::SecurityLevel; let contract = dpns(); let domain = owned_document_type(&contract, "domain"); @@ -2113,11 +2113,11 @@ mod tests { assert_eq!(required, SecurityLevel::HIGH, "precondition: a HIGH type"); let identity = identity_holding(&[ auth_key(1, SecurityLevel::CRITICAL, None), - auth_key(2, SecurityLevel::HIGH, None), + auth_key(2, SecurityLevel::HIGH, None).with_limits(Some(5_000), None), ]); let chosen = auto_selected_document_key(&identity, Some(&contract), Some(&domain)); - assert_eq!(chosen.map(|key| key.id()), Some(2)); + assert_eq!(chosen.map(|key| key.id()), Some(1)); } /// Regression: the unlock prompt looked for a DPNS signing key, so an diff --git a/src/ui/dashpay/add_contact_screen.rs b/src/ui/dashpay/add_contact_screen.rs index 986d01b4b..daa738c6c 100644 --- a/src/ui/dashpay/add_contact_screen.rs +++ b/src/ui/dashpay/add_contact_screen.rs @@ -310,10 +310,9 @@ impl ScreenLike for AddContactScreen { if should_auto_select { if let Some(identity) = &self.selected_identity { self.selected_key = identity - .document_action_signing_key( + .dashpay_signing_key( &self.app_context.dashpay_contract(), "contactRequest", - None, ) .cloned(); diff --git a/src/ui/dashpay/mod.rs b/src/ui/dashpay/mod.rs index db90e868e..ea6d87744 100644 --- a/src/ui/dashpay/mod.rs +++ b/src/ui/dashpay/mod.rs @@ -32,11 +32,7 @@ pub(crate) fn dashpay_action_wallet( app_context: &AppContext, document_type_name: &str, ) -> Option>> { - let key = identity.document_action_signing_key( - &app_context.dashpay_contract(), - document_type_name, - None, - ); + let key = identity.dashpay_signing_key(&app_context.dashpay_contract(), document_type_name); crate::ui::identity::wallet_for_signing_key(identity, key) } diff --git a/src/ui/dashpay/qr_scanner.rs b/src/ui/dashpay/qr_scanner.rs index 925a592af..b767fb830 100644 --- a/src/ui/dashpay/qr_scanner.rs +++ b/src/ui/dashpay/qr_scanner.rs @@ -17,7 +17,6 @@ use crate::ui::components::wallet_unlock_popup::{ use crate::ui::dashpay::dashpay_screen::DashPaySubscreen; use crate::ui::identity::wallet_for_signing_key; use crate::ui::{MessageType, RootScreenType, ScreenLike}; -use dash_sdk::dpp::identity::KeyType; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use egui::{RichText, ScrollArea, TextEdit, Ui}; use std::sync::{Arc, RwLock}; @@ -104,15 +103,14 @@ impl QRScannerScreen { if let Some(identity) = &self.selected_identity { if let Some(qr_data) = &self.parsed_qr_data { // Get signing key - let signing_key = match contact_request_signing_key( - identity, - &self.app_context.dashpay_contract(), - ) { + let signing_key = match identity + .dashpay_signing_key(&self.app_context.dashpay_contract(), "contactRequest") + { Some(key) => key, None => { MessageBanner::set_global( self.app_context.egui_ctx(), - "No suitable signing key found. This operation requires a ECDSA_SECP256K1 AUTHENTICATION key.", + "This identity has no key that can send a contact request. Add a high-security authentication key to the identity and try again.", MessageType::Error, ); return AppAction::None; @@ -202,9 +200,9 @@ impl QRScannerScreen { let new_identity_id = self.selected_identity.as_ref().map(|i| i.identity.id()); if prev_identity_id != new_identity_id { if let Some(identity) = &self.selected_identity { - let key = contact_request_signing_key( - identity, + let key = identity.dashpay_signing_key( &self.app_context.dashpay_contract(), + "contactRequest", ); self.selected_wallet = wallet_for_signing_key(identity, key); } else { @@ -345,18 +343,6 @@ impl QRScannerScreen { } } -/// The ECDSA key to sign a contactRequest document with. -fn contact_request_signing_key<'i>( - identity: &'i QualifiedIdentity, - dashpay_contract: &dash_sdk::platform::DataContract, -) -> Option<&'i dash_sdk::platform::IdentityPublicKey> { - identity.document_action_signing_key( - dashpay_contract, - "contactRequest", - Some(&[KeyType::ECDSA_SECP256K1]), - ) -} - impl ScreenLike for QRScannerScreen { fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { let ctx = ui.ctx().clone(); @@ -407,24 +393,3 @@ impl ScreenLike for QRScannerScreen { self.display_task_result(result); } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::context::test_support::{auth_key, dashpay_contract, identity_holding}; - use dash_sdk::dpp::identity::SecurityLevel; - use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; - - /// Regression: the contact-request key choice from a scanned code admitted - /// MEDIUM keys, which the document type refuses, and preferred unlimited - /// keys, so an unlimited MEDIUM key beat a held limited HIGH key and the - /// request was refused. - #[test] - fn a_scanned_contact_request_is_signed_with_a_key_its_document_type_accepts() { - let limited_high = auth_key(1, SecurityLevel::HIGH).with_limits(Some(5_000), None); - let identity = identity_holding(&[limited_high, auth_key(2, SecurityLevel::MEDIUM)]); - - let chosen = contact_request_signing_key(&identity, &dashpay_contract()); - assert_eq!(chosen.map(|key| key.id()), Some(1)); - } -} From fd73522b4e270f9540cdede315efadc6dc64ac7f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:57:05 +0000 Subject: [PATCH 32/58] fix(dashpay): build and read contact QR codes with platform-wallet DET derived the DIP-15 auto-accept key from an identity ENCRYPTION key's private bytes used as a BIP32 seed, so no other DashPay client could verify its proofs, and wrote its own `dash:?di=` QR format. Contact QR codes now go through upstream platform-wallet: - The owner's code is DashPayView::build_auto_accept_qr. Its key is derived from the identity's wallet seed, read through the secret seam for one session, by AutoAcceptKeyExport: a ContactCryptoProvider that exports only an auto-accept path of the current network and refuses everything else. - A scanned code is read with parse_dashpay_contact_uri and decode_auto_accept_key_blob (ContactQr), refused when expired, and its proof is signed by sign_auto_accept_proof over the sender, the recipient resolved from the code's username, and the request's account reference. Following upstream, a code is valid for one hour (the validity and account index fields go) and names the identity's username. DET's own codec, derivation, proof signing and the owner-side verifier, which nothing called, are removed with QualifiedIdentity::auto_accept_proof_key. Interop tests check DET's proofs against upstream's verifier. contactInfo writes go back to v1.0-dev's code here: #1102 moves them to platform-wallet, whose writer chooses the signing key itself. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- docs/user-stories.md | 3 +- src/backend_task/dashpay.rs | 47 +-- .../dashpay/auto_accept_handler.rs | 117 ------ src/backend_task/dashpay/auto_accept_proof.rs | 351 ------------------ src/backend_task/dashpay/contact_info.rs | 22 +- src/backend_task/dashpay/contact_requests.rs | 29 +- src/backend_task/dashpay/errors.rs | 9 - src/backend_task/error.rs | 31 ++ src/model/dashpay.rs | 221 +++++++++++ src/model/dashpay_derivation/hd.rs | 68 +--- src/model/dashpay_derivation/mod.rs | 4 +- src/model/qualified_identity/mod.rs | 51 --- src/ui/dashpay/qr_code_generator.rs | 108 +----- src/ui/dashpay/qr_scanner.rs | 52 ++- src/wallet_backend/auto_accept.rs | 298 +++++++++++++++ src/wallet_backend/mod.rs | 1 + 16 files changed, 637 insertions(+), 775 deletions(-) delete mode 100644 src/backend_task/dashpay/auto_accept_handler.rs delete mode 100644 src/backend_task/dashpay/auto_accept_proof.rs create mode 100644 src/wallet_backend/auto_accept.rs diff --git a/docs/user-stories.md b/docs/user-stories.md index 75e3c283a..c11b0695d 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -1072,7 +1072,8 @@ As a user, I want to view past payments sent and received through DashPay so tha As an everyday user, I want to generate a DashPay QR code so that someone nearby can scan it to add me as a contact or send a payment. -- QR code encodes DashPay profile or payment info. +- The QR code is a DashPay contact code in the standard DashPay format (DIP-15), so other DashPay wallets can scan it. It shows the identity's username and works for one hour. +- Scanning another wallet's contact QR code sends that contact a request it can accept automatically. An expired or unreadable code is refused before anything is sent. ### DPY-009: Edit contact info [Implemented] **Persona:** Alex, Priya diff --git a/src/backend_task/dashpay.rs b/src/backend_task/dashpay.rs index afe1d2ee9..46624bbcd 100644 --- a/src/backend_task/dashpay.rs +++ b/src/backend_task/dashpay.rs @@ -4,8 +4,6 @@ use crate::context::AppContext; use dash_sdk::Sdk; use std::sync::Arc; -pub mod auto_accept_handler; -pub mod auto_accept_proof; pub mod avatar_processing; pub mod contact_info; pub mod contact_requests; @@ -85,12 +83,12 @@ pub enum DashPayTask { to_username: String, account_label: Option, }, + /// Send a contact request to the owner of a scanned contact QR code, + /// carrying the auto-accept proof its key signs. SendContactRequestWithProof { identity: QualifiedIdentity, signing_key: IdentityPublicKey, - to_identity_id: Identifier, - account_label: Option, - qr_auto_accept: crate::backend_task::dashpay::auto_accept_proof::AutoAcceptProofData, + qr: crate::model::dashpay::ContactQr, }, AcceptContactRequest { identity: QualifiedIdentity, @@ -138,13 +136,11 @@ pub enum DashPayTask { DetectIncomingContactPayments { outputs: Vec, }, - /// Build an auto-accept contact QR payload. Resolves the ENCRYPTION key and - /// derives the auto-accept key through the JIT chokepoint in the backend, so - /// the UI never reads a seed. + /// Build the identity's contact QR code through upstream platform-wallet; + /// the wallet seed is read through the secret seam in the backend, so the + /// UI never reads a seed. GenerateAutoAcceptQrCode { identity: QualifiedIdentity, - account_reference: u32, - validity_hours: u32, }, } @@ -206,18 +202,15 @@ impl AppContext { DashPayTask::SendContactRequestWithProof { identity, signing_key, - to_identity_id, - account_label, - qr_auto_accept, + qr, } => Ok(contact_requests::send_contact_request_with_proof( self, sdk, identity, signing_key, - to_identity_id - .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58), - account_label, - Some(qr_auto_accept), + qr.dpns_name(), + None, + Some(qr), ) .await?), DashPayTask::AcceptContactRequest { @@ -370,20 +363,12 @@ impl AppContext { Ok(BackendTaskSuccessResult::Refresh) } } - DashPayTask::GenerateAutoAcceptQrCode { - identity, - account_reference, - validity_hours, - } => { - let proof = auto_accept_proof::generate_auto_accept_proof( - &identity, - account_reference, - validity_hours, - ) - .await?; - Ok(BackendTaskSuccessResult::DashPayAutoAcceptQrCode( - proof.to_qr_string(), - )) + DashPayTask::GenerateAutoAcceptQrCode { identity } => { + let uri = self + .wallet_backend()? + .dashpay_build_auto_accept_qr(&identity) + .await?; + Ok(BackendTaskSuccessResult::DashPayAutoAcceptQrCode(uri)) } } } diff --git a/src/backend_task/dashpay/auto_accept_handler.rs b/src/backend_task/dashpay/auto_accept_handler.rs deleted file mode 100644 index 95781088a..000000000 --- a/src/backend_task/dashpay/auto_accept_handler.rs +++ /dev/null @@ -1,117 +0,0 @@ -use crate::backend_task::dashpay::auto_accept_proof::verify_auto_accept_proof; -use crate::backend_task::dashpay::contact_request_query; -use crate::backend_task::dashpay::contact_requests::accept_contact_request; -use crate::backend_task::error::TaskError; -use crate::context::AppContext; -use crate::model::qualified_identity::QualifiedIdentity; -use dash_sdk::Sdk; -use dash_sdk::dpp::document::DocumentV0Getters; -use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::platform_value::Value; -use dash_sdk::dpp::platform_value::string_encoding::Encoding; -use dash_sdk::drive::query::{OrderClause, WhereClause, WhereOperator}; -use dash_sdk::platform::{Document, FetchMany, Identifier}; -use std::sync::Arc; - -/// Process incoming contact requests and check for autoAcceptProof -/// -/// This function checks all incoming contact requests for valid autoAcceptProof -/// and automatically accepts and reciprocates if the proof is valid. -pub async fn process_auto_accept_requests( - app_context: &Arc, - sdk: &Sdk, - identity: QualifiedIdentity, -) -> Result, TaskError> { - let identity_id = identity.identity.id(); - - // Query for incoming contact requests - let mut incoming_query = contact_request_query(app_context)?; - - incoming_query = incoming_query.with_where(WhereClause { - field: "toUserId".to_string(), - operator: WhereOperator::Equal, - value: Value::Identifier(identity_id.to_buffer()), - }); - - // Add orderBy to avoid platform bug - incoming_query = incoming_query.with_order_by(OrderClause { - field: "$createdAt".to_string(), - ascending: true, - }); - incoming_query.limit = 100; - - let incoming_docs = Document::fetch_many(sdk, incoming_query).await?; - - // Stateless verification; no stored proofs needed - - let mut auto_accepted_requests = Vec::new(); - - for (request_id, doc) in incoming_docs { - if let Some(doc) = doc { - let from_id = doc.owner_id(); - let props = doc.properties(); - - // Check if this request has an autoAcceptProof - if let Some(Value::Bytes(proof_data)) = props.get("autoAcceptProof") { - tracing::debug!( - "Found contact request with autoAcceptProof from {}", - from_id.to_string(Encoding::Base58) - ); - - // Extract accountReference for message construction (default to 0 if missing) - let account_reference = props - .get("accountReference") - .and_then(|value| value.to_integer::().ok()) - .unwrap_or_default(); - - // Verify the proof per DIP-0015 - match verify_auto_accept_proof( - proof_data, - from_id, - identity.identity.id(), - &identity, - account_reference, - ) - .await - { - Ok(true) => { - tracing::debug!( - "Valid autoAcceptProof, auto-accepting contact request from {}", - from_id.to_string(Encoding::Base58) - ); - - // Accept the request (which sends a reciprocal request) - match accept_contact_request(app_context, sdk, identity.clone(), request_id) - .await - { - Ok(_) => { - auto_accepted_requests.push((from_id, true)); - - // Stateless: no persistence required - } - Err(e) => { - tracing::error!("Failed to auto-accept contact request: {}", e); - auto_accepted_requests.push((from_id, false)); - } - } - } - Ok(false) => { - tracing::warn!( - "Invalid or expired autoAcceptProof from {}", - from_id.to_string(Encoding::Base58) - ); - } - Err(e) => { - tracing::error!("Failed to verify autoAcceptProof: {}", e); - } - } - } - } - } - - Ok(auto_accepted_requests) -} - -// No DB persistence required - -// Proof creation moved to contact_requests::send_contact_request_with_proof diff --git a/src/backend_task/dashpay/auto_accept_proof.rs b/src/backend_task/dashpay/auto_accept_proof.rs deleted file mode 100644 index fa799abd7..000000000 --- a/src/backend_task/dashpay/auto_accept_proof.rs +++ /dev/null @@ -1,351 +0,0 @@ -use crate::backend_task::error::TaskError; -use crate::model::dashpay_derivation::derive_auto_accept_key; -use crate::model::qualified_identity::QualifiedIdentity; -use dash_sdk::dpp::dashcore::ecdsa::EcdsaSecretKey; -use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::secp256k1::Message; -use dash_sdk::platform::Identifier; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct AutoAcceptProofData { - pub identity_id: Identifier, - pub proof_key: [u8; 32], - pub account_reference: u32, - pub expires_at: u64, // Unix timestamp -} - -impl AutoAcceptProofData { - pub fn to_qr_string(&self) -> String { - // Format according to DIP-0015: dash:?du={username}&dapk={key_data} - // Key data format: key_type (1 byte) + timestamp (4 bytes) + key_size (1 byte) + key (32 bytes) - let mut key_data = Vec::new(); - key_data.push(0u8); // Key type 0 for ECDSA_SECP256K1 - key_data.extend_from_slice(&(self.expires_at as u32).to_be_bytes()); // Timestamp/expiration - key_data.push(32u8); // Key size - key_data.extend_from_slice(&self.proof_key); // The actual key - - // Encode key data in base58 using dashcore's base58 implementation - let key_data_base58 = dash_sdk::dpp::platform_value::string_encoding::encode( - &key_data, - dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, - ); - - // For QR codes without username (identity-based) - format!( - "dash:?di={}&dapk={}", - self.identity_id - .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58), - key_data_base58 - ) - } - - pub fn from_qr_string(qr_data: &str) -> Result { - // Parse DIP-0015 format: dash:?du={username}&dapk={key_data} or dash:?di={identity}&dapk={key_data} - if !qr_data.starts_with("dash:?") { - return Err("Invalid QR code format - must start with 'dash:?'".to_string()); - } - - let query_string = &qr_data[6..]; // Skip "dash:?" - let mut identity_id = None; - let mut key_data_base58 = None; - let mut account_reference = 0u32; // Default to account 0 - - // Parse query parameters - for param in query_string.split('&') { - let parts: Vec<&str> = param.split('=').collect(); - if parts.len() != 2 { - continue; - } - - match parts[0] { - "di" => { - identity_id = Some( - Identifier::from_string( - parts[1], - dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, - ) - .map_err(|e| format!("Invalid identity ID: {}", e))?, - ) - } - "dapk" => { - key_data_base58 = Some(parts[1].to_string()); - } - "account" => { - account_reference = parts[1] - .parse::() - .map_err(|e| format!("Invalid account reference: {}", e))?; - } - _ => {} // Ignore unknown parameters - } - } - - let identity_id = identity_id.ok_or("Missing identity ID in QR code".to_string())?; - let key_data_base58 = - key_data_base58.ok_or("Missing proof key data in QR code".to_string())?; - - // Decode the key data from base58 - let key_data = dash_sdk::dpp::dashcore::base58::decode(&key_data_base58) - .map_err(|e| format!("Invalid base58 key data: {}", e))?; - - // Parse key data format: key_type (1) + timestamp (4) + key_size (1) + key (32-64) - if key_data.len() < 38 { - return Err("Key data too short".to_string()); - } - - let _key_type = key_data[0]; - let expires_at = - u32::from_be_bytes([key_data[1], key_data[2], key_data[3], key_data[4]]) as u64; - let key_size = key_data[5] as usize; - - if key_data.len() < 6 + key_size { - return Err("Invalid key data length".to_string()); - } - - let mut proof_key = [0u8; 32]; - if key_size == 32 { - proof_key.copy_from_slice(&key_data[6..38]); - } else { - return Err(format!("Unsupported key size: {}", key_size)); - } - - Ok(Self { - identity_id, - proof_key, - account_reference, - expires_at, - }) - } -} - -/// Generate an auto-accept proof for QR code sharing -/// -/// According to DIP-0015, the autoAcceptProof is a signature that allows the recipient -/// to automatically accept the contact request and send one back without user interaction. -pub async fn generate_auto_accept_proof( - identity: &QualifiedIdentity, - account_reference: u32, - validity_hours: u32, -) -> Result { - use crate::backend_task::dashpay::errors::DashPayError; - - // Calculate expiration timestamp - let expires_at = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_err(|_| DashPayError::SystemClockInvalid)? - .as_secs() - + (validity_hours as u64 * 3600); - - // Get wallet seed for HD derivation - use ENCRYPTION key (ECDSA_SECP256K1) as per DIP-15 - // The auto-accept proof uses HD derivation from the wallet, and ENCRYPTION keys are ECDSA_SECP256K1 - let signing_key = identity - .auto_accept_proof_key() - .ok_or(DashPayError::MissingEncryptionKey)?; - - // Resolve the ENCRYPTION private key through the JIT chokepoint — no - // parked-seed read. - let wallet_seed = identity - .resolve_private_key_bytes(signing_key) - .await? - .map(|(_, private_key)| private_key) - .ok_or(TaskError::WalletLocked)?; - - // Determine network from the identity - let network = identity.network; - - // Derive the auto-accept key using DIP-0015 path: m/9'/coin'/16'/timestamp' - // Using expiration timestamp as the derivation index - let auto_accept_xprv = derive_auto_accept_key( - &wallet_seed[..], - network, - expires_at as u32, // Truncate to u32 for derivation - ) - .map_err(|e| TaskError::DashPay(DashPayError::from(e)))?; - - // Extract the private key bytes (32 bytes) - let proof_key = *auto_accept_xprv.private_key.to_bytes(); - - Ok(AutoAcceptProofData { - identity_id: identity.identity.id(), - proof_key, - account_reference, - expires_at, - }) -} - -/// Create the autoAcceptProof bytes for inclusion in a contact request -/// -/// Format according to DIP-0015: -/// - key type (1 byte) -/// - key index (4 bytes) - the timestamp used for derivation -/// - signature size (1 byte) -/// - signature (32-96 bytes) -pub fn create_auto_accept_proof_bytes_with_key( - expires_at: u64, - signing_key_bytes: &[u8; 32], - sender_id: &Identifier, - recipient_id: &Identifier, - account_reference: u32, -) -> Result, String> { - // Derive the auto-accept key - // Sign using the provided ephemeral key from the QR - - // Create the message to sign: ownerId + toUserId + accountReference - let mut message_data = Vec::new(); - message_data.extend_from_slice(&sender_id.to_buffer()); - message_data.extend_from_slice(&recipient_id.to_buffer()); - message_data.extend_from_slice(&account_reference.to_le_bytes()); - - // Hash the message - let mut hasher = Sha256::new(); - hasher.update(&message_data); - let message_hash = hasher.finalize(); - - // Sign the digest. The key erases itself when it drops. The signature is - // low-S with a low R ground as Dash Core grinds it; any ECDSA verifier - // accepts it. - let secret_key = EcdsaSecretKey::from_bytes(signing_key_bytes) - .map_err(|e| format!("Failed to create secret key: {}", e))?; - - let sig_bytes = secret_key.sign(&message_hash.into()).to_bytes(); - - // Build the proof bytes - let mut proof_bytes = Vec::new(); - proof_bytes.push(0u8); // Key type 0 for ECDSA_SECP256K1 - proof_bytes.extend_from_slice(&(expires_at as u32).to_be_bytes()); // Key index (timestamp) - proof_bytes.push(sig_bytes.len() as u8); // Signature size - proof_bytes.extend_from_slice(&sig_bytes); // The signature - - Ok(proof_bytes) -} - -/// Verify an auto-accept proof from a contact request -/// -/// This would be called when receiving a contact request with an autoAcceptProof field -/// to determine if we should automatically accept and reciprocate. -pub async fn verify_auto_accept_proof( - proof_data: &[u8], - sender_identity_id: Identifier, - recipient_identity_id: Identifier, - our_identity: &QualifiedIdentity, - account_reference: u32, -) -> Result { - // Parse: key type (1) | key index/timestamp (4) | sig size (1) | signature - if proof_data.len() < 6 { - return Ok(false); - } - let _key_type = proof_data[0]; - let key_index = - u32::from_be_bytes([proof_data[1], proof_data[2], proof_data[3], proof_data[4]]); - let sig_len = proof_data[5] as usize; - // Compact ECDSA signatures are exactly 64 bytes - if sig_len != 64 { - return Ok(false); - } - if proof_data.len() < 6 + sig_len { - return Ok(false); - } - let signature_bytes = &proof_data[6..6 + sig_len]; - - // Expiry check - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_err(|e| format!("Time error: {}", e))? - .as_secs(); - if now > key_index as u64 { - return Ok(false); - } - - // Message: ownerId + toUserId + accountReference - let mut message_data = Vec::new(); - message_data.extend_from_slice(&sender_identity_id.to_buffer()); - message_data.extend_from_slice(&recipient_identity_id.to_buffer()); - message_data.extend_from_slice(&account_reference.to_le_bytes()); - let mut hasher = Sha256::new(); - hasher.update(&message_data); - let message_hash = hasher.finalize(); - let message = Message::from_digest(message_hash.into()); - - // Derive expected pubkey from our seed and key index (timestamp) - // Use ENCRYPTION key (ECDSA_SECP256K1) for HD derivation as per DIP-15 - let signing_key = our_identity.auto_accept_proof_key().ok_or( - "No suitable key found. This operation requires an enabled ECDSA_SECP256K1 ENCRYPTION key.", - )?; - // Resolve the ENCRYPTION private key through the JIT chokepoint — no - // parked-seed read. - let wallet_seed = our_identity - .resolve_private_key_bytes(signing_key) - .await - .map_err(|e| format!("Error resolving private key: {}", e))? - .map(|(_, private_key)| private_key) - .ok_or("Private key not found")?; - let xprv = derive_auto_accept_key(&wallet_seed[..], our_identity.network, key_index) - .map_err(|e| format!("Failed to derive auto-accept key: {}", e))?; - let pubkey = dash_sdk::dpp::secp256k1::PublicKey::from(xprv.private_key.public_key()); - let sig = dash_sdk::dpp::secp256k1::ecdsa::Signature::from_compact(signature_bytes) - .map_err(|e| format!("Invalid signature bytes: {}", e))?; - - match dash_sdk::dpp::secp256k1::ecdsa::verify(&sig, message, &pubkey) { - Ok(_) => Ok(true), - Err(_) => Ok(false), - } -} - -// No local persistence required - -#[cfg(test)] -mod tests { - use super::*; - use dash_sdk::dpp::secp256k1::{PublicKey, SecretKey, ecdsa}; - - /// The proof's signature verifies, the way `verify_auto_accept_proof` - /// checks it, against the public key of the key it was signed with. The - /// signature is low-S with a low R ground as Dash Core grinds it; R lands - /// low half the time on its own, so this signs for many accounts. - #[test] - fn proof_signature_verifies_and_has_low_r() { - let key_bytes = [0x2Bu8; 32]; - let public_key = - PublicKey::from_secret_key(&SecretKey::from_secret_bytes(key_bytes).expect("valid")); - let sender = Identifier::from([0x01; 32]); - let recipient = Identifier::from([0x02; 32]); - let expires_at = 1_900_000_000u64; - - for account_reference in 0..32u32 { - let proof = create_auto_accept_proof_bytes_with_key( - expires_at, - &key_bytes, - &sender, - &recipient, - account_reference, - ) - .expect("proof"); - - assert_eq!(proof[0], 0, "key type"); - assert_eq!( - &proof[1..5], - &(expires_at as u32).to_be_bytes(), - "key index" - ); - assert_eq!(proof[5], 64, "signature size"); - assert_eq!(proof.len(), 6 + 64); - - let mut message_data = Vec::new(); - message_data.extend_from_slice(&sender.to_buffer()); - message_data.extend_from_slice(&recipient.to_buffer()); - message_data.extend_from_slice(&account_reference.to_le_bytes()); - let digest: [u8; 32] = Sha256::digest(&message_data).into(); - - let signature = ecdsa::Signature::from_compact(&proof[6..]).expect("compact"); - let mut normalized = signature; - normalized.normalize_s(); - assert_eq!(normalized, signature, "high S for {account_reference}"); - assert!( - ecdsa::verify(&signature, Message::from_digest(digest), &public_key).is_ok(), - "proof does not verify for {account_reference}" - ); - assert!(proof[6] < 0x80, "R is not low for {account_reference}"); - } - } -} diff --git a/src/backend_task/dashpay/contact_info.rs b/src/backend_task/dashpay/contact_info.rs index dcd2a9ae2..91d881c7a 100644 --- a/src/backend_task/dashpay/contact_info.rs +++ b/src/backend_task/dashpay/contact_info.rs @@ -17,13 +17,14 @@ use dash_sdk::dpp::document::{ }; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::dpp::platform_value::{Bytes32, Value}; use dash_sdk::drive::query::{OrderClause, WhereClause, WhereOperator}; use dash_sdk::platform::documents::transitions::DocumentCreateTransitionBuilder; use dash_sdk::platform::proto::get_documents_request::get_documents_request_v0::Start; use dash_sdk::platform::{Document, DocumentQuery, FetchMany, Identifier}; use dash_sdk::query_types::Documents; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; use std::future::Future; use std::sync::Arc; use zeroize::Zeroizing; @@ -583,11 +584,6 @@ pub(super) async fn contact_info_is_hidden( /// encryption keys cannot be derived, when an encoded field exceeds its v0 or /// contract limit, when the identity has no usable authentication key, or when /// the state transition is rejected. -// TODO(upstream): publish through platform-wallet's -// `DashPayView::set_contact_info_with_external_signer` once it preserves the -// stored `acceptedAccounts` (it writes an empty list) and DET supplies a -// `ContactCryptoProvider`; until then DET keeps its own write path and takes -// the signing key from `QualifiedIdentity::dashpay_signing_key`. pub async fn create_or_update_contact_info( app_context: &Arc, sdk: &Sdk, @@ -638,8 +634,20 @@ pub async fn create_or_update_contact_info( )); } + // Get signing key — accept any key type (BLS, ECDSA, EDDSA) since + // Platform accepts all for document state transitions. let signing_key = identity - .dashpay_signing_key(&dashpay_contract, "contactInfo") + .identity + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([ + SecurityLevel::CRITICAL, + SecurityLevel::HIGH, + SecurityLevel::MEDIUM, + ]), + KeyType::all_key_types().into(), + false, + ) .ok_or_else(|| TaskError::DashPay(DashPayError::MissingAuthenticationKey))?; // Create document properties diff --git a/src/backend_task/dashpay/contact_requests.rs b/src/backend_task/dashpay/contact_requests.rs index 8b280c822..da65530b4 100644 --- a/src/backend_task/dashpay/contact_requests.rs +++ b/src/backend_task/dashpay/contact_requests.rs @@ -5,9 +5,6 @@ use super::encryption::{ use super::errors::DashPayError; use super::validation::validate_contact_request_before_send; use crate::backend_task::BackendTaskSuccessResult; -use crate::backend_task::dashpay::auto_accept_proof::{ - AutoAcceptProofData, create_auto_accept_proof_bytes_with_key, -}; use crate::backend_task::error::TaskError; use crate::context::AppContext; use crate::model::dashpay::{ @@ -238,7 +235,7 @@ pub async fn send_contact_request_with_proof( signing_key: IdentityPublicKey, to_username_or_id: String, account_label: Option, - qr_auto_accept: Option, + qr_auto_accept: Option, ) -> Result { if let Some(label) = account_label.as_deref() && let Err(error) = crate::model::dashpay::validate_account_label(label) @@ -491,23 +488,21 @@ pub async fn send_contact_request_with_proof( ); } - // If QR auto-accept data is provided, create the proof bytes now to match the final accountReference + // A scanned contact QR code signs the auto-accept proof over this + // request's final accountReference. The recipient was resolved from the + // code's own username, and the proof binds it, the sender and the account + // reference, so it is useless for any other request. if let Some(qr) = qr_auto_accept { - // Ensure the QR target matches the resolved recipient - if qr.identity_id != to_identity_id { - return Err(DashPayError::InvalidQrCode { - reason: "QR code target identity does not match recipient".to_string(), - } - .into()); - } - let proof = create_auto_accept_proof_bytes_with_key( - qr.expires_at, - &qr.proof_key, + let now_secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|_| DashPayError::SystemClockInvalid)? + .as_secs(); + let proof = qr.sign_proof( &identity.identity.id(), &to_identity_id, account_reference, - ) - .map_err(|e| TaskError::EncryptionError { detail: e })?; + now_secs, + )?; tracing::debug!( "Including autoAcceptProof in contact request ({} bytes)", proof.len() diff --git a/src/backend_task/dashpay/errors.rs b/src/backend_task/dashpay/errors.rs index a8561ed0c..ee343863f 100644 --- a/src/backend_task/dashpay/errors.rs +++ b/src/backend_task/dashpay/errors.rs @@ -66,13 +66,6 @@ pub enum DashPayError { #[error("The contact request could not be verified. Please check the details and try again.")] ValidationFailed { errors: Vec }, - // Auto Accept Proof Errors - #[error("The QR code format is not recognized. Please scan a valid contact QR code.")] - InvalidQrCode { reason: String }, - - #[error("This QR code has expired. Please ask for a new one.")] - QrCodeExpired { expired_at: u64, current_time: u64 }, - // Network/SDK Errors #[error("Network connection failed. Please check your internet connection and retry.")] NetworkError, @@ -177,8 +170,6 @@ impl DashPayError { matches!( self, DashPayError::UsernameResolutionFailed { .. } - | DashPayError::InvalidQrCode { .. } - | DashPayError::QrCodeExpired { .. } | DashPayError::ValidationFailed { .. } | DashPayError::AccountLabelTooLong { .. } | DashPayError::InvalidUsername { .. } diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index a68f00508..461c19f55 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -2471,6 +2471,37 @@ pub enum TaskError { )] ContactWalletSeedUnavailable, + /// The scanned text is not a DashPay contact QR code (DIP-15 + /// `dash:?du=…&dapk=…`), as upstream platform-wallet's codecs read it. + #[error( + "This is not a DashPay contact QR code. Scan your contact's QR code again, or ask them for a new one." + )] + ContactQrInvalid { + #[source] + source: std::sync::Arc, + }, + + /// The scanned contact QR code's auto-accept key has expired. + #[error("This contact QR code has expired. Ask your contact for a new one.")] + ContactQrExpired, + + /// The identity has no wallet on this device whose recovery phrase can + /// derive its contact QR code's auto-accept key. + #[error( + "This identity's wallet is not on this device, so it cannot create a contact QR code. Import the wallet that created the identity and try again." + )] + ContactQrWalletMissing, + + /// Upstream platform-wallet could not build the contact QR code, for + /// example because the identity has no username to put in it. + #[error( + "The contact QR code could not be created. Make sure this identity has a username, then try again." + )] + ContactQrBuildFailed { + #[source] + source: std::sync::Arc, + }, + /// Deriving the per-contact encryption keys from the wallet's recovery /// phrase failed. The seam already proved the seed is present, so this is a /// derivation-math failure rather than a missing wallet. diff --git a/src/model/dashpay.rs b/src/model/dashpay.rs index 3965a56d9..15820b484 100644 --- a/src/model/dashpay.rs +++ b/src/model/dashpay.rs @@ -6,6 +6,7 @@ use dash_sdk::platform::{Document, Identifier}; use serde::{Deserialize, Serialize}; use super::validation::{TextLengthError, validate_char_count}; +use crate::backend_task::error::TaskError; /// Independent reads used to populate an identity's contact lists. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] @@ -589,3 +590,223 @@ mod tests { assert!(validate_account_label(&"l".repeat(101)).is_err()); } } + +/// A scanned DashPay contact QR code: DIP-15 `dash:?du=&dapk=`, +/// read with upstream platform-wallet's codecs. The key is the QR owner's +/// auto-accept key, a bearer credential that expires; it signs the +/// auto-accept proof of a contact request sent to the owner. +#[derive(Clone, PartialEq, Eq)] +pub struct ContactQr { + username: String, + key_blob: zeroize::Zeroizing>, + expires_at: u32, +} + +impl std::fmt::Debug for ContactQr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ContactQr") + .field("username", &self.username) + .field("expires_at", &self.expires_at) + .finish_non_exhaustive() + } +} + +impl ContactQr { + /// Reads `uri` as a contact QR code that is still valid at `now_secs` + /// (Unix seconds). + /// + /// # Errors + /// + /// [`TaskError::ContactQrInvalid`] when `uri` is not a DIP-15 contact QR + /// code, [`TaskError::ContactQrExpired`] when its key has expired. + pub fn parse(uri: &str, now_secs: u64) -> Result { + use platform_wallet::wallet::identity::crypto::auto_accept::{ + decode_auto_accept_key_blob, parse_dashpay_contact_uri, + }; + let invalid = |source| TaskError::ContactQrInvalid { + source: std::sync::Arc::new(source), + }; + let (username, key_blob) = parse_dashpay_contact_uri(uri.trim()).map_err(invalid)?; + let key_blob = zeroize::Zeroizing::new(key_blob); + let (_, expires_at) = decode_auto_accept_key_blob(&key_blob).map_err(invalid)?; + let qr = Self { + username, + key_blob, + expires_at, + }; + qr.ensure_unexpired(now_secs)?; + Ok(qr) + } + + /// Upstream's rule: a code is valid up to and including its expiry second. + fn ensure_unexpired(&self, now_secs: u64) -> Result<(), TaskError> { + if now_secs > u64::from(self.expires_at) { + return Err(TaskError::ContactQrExpired); + } + Ok(()) + } + + /// The QR owner's username, without the `.dash` suffix. + pub fn username(&self) -> &str { + &self.username + } + + /// The owner's full DPNS name, `.dash`. + pub fn dpns_name(&self) -> String { + format!("{}.dash", self.username) + } + + /// When the code stops working, in Unix seconds. + pub fn expires_at(&self) -> u32 { + self.expires_at + } + + /// The auto-accept proof for a contact request `sender` sends to the QR + /// owner `recipient` with `account_reference`, signed with the code's key + /// by upstream's `sign_auto_accept_proof`. The proof binds sender, + /// recipient and account reference, so it is useless for any other + /// request. + /// + /// # Errors + /// + /// [`TaskError::ContactQrExpired`] when the code has expired at `now_secs`. + pub fn sign_proof( + &self, + sender: &Identifier, + recipient: &Identifier, + account_reference: u32, + now_secs: u64, + ) -> Result, TaskError> { + use platform_wallet::wallet::identity::crypto::auto_accept::{ + decode_auto_accept_key_blob, sign_auto_accept_proof, + }; + self.ensure_unexpired(now_secs)?; + let (key, expires_at) = decode_auto_accept_key_blob(&self.key_blob).map_err(|source| { + TaskError::ContactQrInvalid { + source: std::sync::Arc::new(source), + } + })?; + Ok(sign_auto_accept_proof( + &key, + sender, + recipient, + account_reference, + expires_at, + )) + } +} + +#[cfg(test)] +mod contact_qr_tests { + use super::*; + use dash_sdk::dpp::dashcore::Network; + use dash_sdk::dpp::key_wallet::wallet::Wallet; + use dash_sdk::dpp::key_wallet::wallet::initialization::WalletAccountCreationOptions; + use platform_wallet::wallet::identity::crypto::auto_accept::{ + derive_auto_accept_private_key, encode_auto_accept_key_blob, encode_dashpay_contact_uri, + verify_auto_accept_proof, + }; + + const NETWORK: Network = Network::Testnet; + const EXPIRY: u32 = 1_900_000_000; + + /// A wallet from a seed generated for this test only. + fn owner_wallet() -> Wallet { + Wallet::from_seed_bytes( + rand::random::<[u8; 64]>(), + NETWORK, + WalletAccountCreationOptions::None, + ) + .expect("a test wallet") + } + + /// The contact QR code `owner` would hand out, as upstream builds it. + fn owner_qr(owner: &Wallet) -> String { + let key = derive_auto_accept_private_key(owner, NETWORK, EXPIRY).expect("a key"); + encode_dashpay_contact_uri("alice", &encode_auto_accept_key_blob(&key, EXPIRY)) + } + + /// Interop: a proof DET signs from an upstream-built QR code verifies with + /// upstream's verifier against the owner's wallet, and only for the + /// sender, recipient and account reference it was signed for. + #[test] + fn a_proof_from_an_upstream_qr_code_verifies_upstream() { + let owner = owner_wallet(); + let qr = ContactQr::parse(&owner_qr(&owner), u64::from(EXPIRY) - 60).expect("valid"); + assert_eq!(qr.username(), "alice"); + assert_eq!(qr.dpns_name(), "alice.dash"); + assert_eq!(qr.expires_at(), EXPIRY); + + let sender = Identifier::new([1; 32]); + let recipient = Identifier::new([2; 32]); + let proof = qr + .sign_proof(&sender, &recipient, 7, u64::from(EXPIRY) - 60) + .expect("a proof"); + let verifies = |sender: &Identifier, recipient: &Identifier, account: u32| { + verify_auto_accept_proof(&owner, NETWORK, &proof, sender, recipient, account) + .expect("verifiable") + }; + assert!(verifies(&sender, &recipient, 7)); + assert!( + !verifies(&Identifier::new([3; 32]), &recipient, 7), + "sender" + ); + assert!( + !verifies(&sender, &Identifier::new([3; 32]), 7), + "recipient" + ); + assert!(!verifies(&sender, &recipient, 8), "account reference"); + assert!( + !verify_auto_accept_proof(&owner_wallet(), NETWORK, &proof, &sender, &recipient, 7) + .expect("verifiable"), + "another wallet's key" + ); + } + + /// An expired code is refused when read and again when signing. + #[test] + fn an_expired_code_is_refused() { + let uri = owner_qr(&owner_wallet()); + assert!(matches!( + ContactQr::parse(&uri, u64::from(EXPIRY) + 1), + Err(TaskError::ContactQrExpired) + )); + let qr = ContactQr::parse(&uri, u64::from(EXPIRY)).expect("valid until its expiry"); + assert!(matches!( + qr.sign_proof( + &Identifier::new([1; 32]), + &Identifier::new([2; 32]), + 0, + u64::from(EXPIRY) + 1 + ), + Err(TaskError::ContactQrExpired) + )); + } + + /// Codes in DET's former `di=` format, and anything else that + /// is not a DIP-15 contact code, are refused as invalid. + #[test] + fn a_code_that_is_not_dip15_is_invalid() { + let former = "dash:?di=4EfA9Jrvv3nnCFdSf7fad59851iiTRZ6Wcu6YVJ4iSeF&dapk=1"; + for uri in [former, "dash:?du=alice", "bitcoin:?du=alice&dapk=1", ""] { + assert!( + matches!( + ContactQr::parse(uri, 0), + Err(TaskError::ContactQrInvalid { .. }) + ), + "{uri}" + ); + } + } + + /// The key never shows in debug output. + #[test] + fn debug_output_hides_the_key() { + let qr = ContactQr::parse(&owner_qr(&owner_wallet()), 0).expect("valid"); + let shown = format!("{qr:?}"); + assert!( + shown.contains("alice") && !shown.contains("key_blob"), + "{shown}" + ); + } +} diff --git a/src/model/dashpay_derivation/hd.rs b/src/model/dashpay_derivation/hd.rs index 9ca674a03..8b4bb921a 100644 --- a/src/model/dashpay_derivation/hd.rs +++ b/src/model/dashpay_derivation/hd.rs @@ -1,17 +1,11 @@ use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::dashcore::hashes::{Hash, HashEngine}; -use dash_sdk::dpp::key_wallet::bip32::{ - ChildNumber, DerivationPath, ExtendedPrivKey, ExtendedPubKey, -}; +use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, ExtendedPubKey}; use dash_sdk::platform::Identifier; -use std::str::FromStr; use super::DerivationError; use super::dip14::derive_dashpay_incoming_xpub_dip14; -/// DashPay auto-accept proof feature index - use the constant from dip9 if available -const DASHPAY_AUTO_ACCEPT_FEATURE: u32 = 16; - /// Derive the DashPay incoming funds extended public key for a contact relationship /// Path: m/9'/coin'/15'/account'/(sender_id)/(recipient_id) /// @@ -75,30 +69,6 @@ pub fn generate_contact_xpub_data( Ok((parent_fingerprint, chain_code, public_key_bytes)) } -/// Derive auto-accept proof key according to DIP-0015 -/// Path: m/9'/coin'/16'/timestamp' -pub fn derive_auto_accept_key( - master_seed: &[u8], - network: Network, - timestamp: u32, -) -> Result { - use crate::model::wallet::coin_type_for_network; - - // Create extended private key from seed - let master_xprv = ExtendedPrivKey::new_master(network, master_seed)?; - - // Build derivation path: m/9'/coin'/16'/timestamp' - let coin_type = coin_type_for_network(network); - let path = DerivationPath::from_str(&format!( - "m/9'/{coin_type}'/{DASHPAY_AUTO_ACCEPT_FEATURE}'/{timestamp}'" - ))?; - - // Derive the key - let auto_accept_key = master_xprv.derive_priv(&path)?; - - Ok(auto_accept_key) -} - /// Calculate account reference as specified in DIP-0015 pub fn calculate_account_reference( sender_secret_key: &[u8], @@ -132,6 +102,8 @@ pub fn calculate_account_reference( #[cfg(test)] mod tests { use super::*; + use dash_sdk::dpp::key_wallet::bip32::{DerivationPath, ExtendedPrivKey}; + use std::str::FromStr; #[test] fn test_dashpay_derivation_path() { @@ -221,40 +193,6 @@ mod tests { ); } - #[test] - fn test_auto_accept_key_derivation() { - // Test auto-accept key derivation - let network = Network::Testnet; - let master_seed = [0x42u8; 64]; - let timestamp = 1700000000u32; - - let key = derive_auto_accept_key(&master_seed, network, timestamp) - .expect("Should derive auto-accept key"); - - // Verify the key was derived correctly - assert_eq!(key.network, network); - assert_eq!(key.depth, 4); // m/9'/1'/16'/timestamp' on testnet = depth 4 - } - - #[test] - fn test_auto_accept_key_different_timestamps() { - // Different timestamps should produce different keys - let network = Network::Testnet; - let master_seed = [0x42u8; 64]; - - let key1 = - derive_auto_accept_key(&master_seed, network, 1700000000).expect("Should derive key 1"); - let key2 = - derive_auto_accept_key(&master_seed, network, 1700000001).expect("Should derive key 2"); - - // Different timestamps should produce different keys - assert_ne!( - key1.private_key.to_bytes(), - key2.private_key.to_bytes(), - "Different timestamps should produce different keys" - ); - } - #[test] fn test_generate_contact_xpub_data() { // Test generating contact xpub data diff --git a/src/model/dashpay_derivation/mod.rs b/src/model/dashpay_derivation/mod.rs index 5ed877885..64ad20c6a 100644 --- a/src/model/dashpay_derivation/mod.rs +++ b/src/model/dashpay_derivation/mod.rs @@ -8,8 +8,8 @@ pub mod dip14; pub mod hd; pub use hd::{ - calculate_account_reference, derive_auto_accept_key, derive_dashpay_incoming_xpub, - derive_payment_address, generate_contact_xpub_data, + calculate_account_reference, derive_dashpay_incoming_xpub, derive_payment_address, + generate_contact_xpub_data, }; use thiserror::Error; diff --git a/src/model/qualified_identity/mod.rs b/src/model/qualified_identity/mod.rs index acaca8129..32d039314 100644 --- a/src/model/qualified_identity/mod.rs +++ b/src/model/qualified_identity/mod.rs @@ -1224,19 +1224,6 @@ impl QualifiedIdentity { ) } - /// The ENCRYPTION key whose wallet seeds DashPay auto-accept proofs - /// (DIP-15): the first enabled ECDSA_SECP256K1 one, at any security level - /// (lowest key id first). - // TODO(upstream): call platform-wallet's `select_own_encryption_key` once - // it is public; this mirrors its policy until then. - pub fn auto_accept_proof_key(&self) -> Option<&IdentityPublicKey> { - self.identity.public_keys().values().find(|key| { - key.purpose() == Purpose::ENCRYPTION - && key.key_type() == KeyType::ECDSA_SECP256K1 - && !key.is_disabled() - }) - } - /// The key as the identity holds it now for `snapshot`, a copy taken /// earlier (e.g. stored next to its private half): budget and expiry move /// with key limits updates, so the snapshot may be stale. Falls back to @@ -3502,44 +3489,6 @@ mod protocol_v14_compat_tests { ); } - /// The auto-accept proof key follows upstream platform-wallet's - /// `select_own_encryption_key`: the first enabled ECDSA_SECP256K1 - /// ENCRYPTION key, at any security level. - #[test] - fn the_auto_accept_proof_key_is_the_first_enabled_ecdsa_encryption_key() { - use crate::context::test_support::identity_holding; - use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; - - let encryption = |id: KeyID, level: SecurityLevel, disabled: bool| -> IdentityPublicKey { - IdentityPublicKeyV0 { - id, - purpose: Purpose::ENCRYPTION, - security_level: level, - contract_bounds: None, - key_type: KeyType::ECDSA_SECP256K1, - read_only: false, - data: vec![2; 33].into(), - disabled_at: disabled.then_some(1), - } - .into() - }; - let identity = identity_holding(&[encryption(3, SecurityLevel::HIGH, false)]); - assert_eq!( - identity.auto_accept_proof_key().map(|key| key.id()), - Some(3) - ); - - let identity = identity_holding(&[ - encryption(1, SecurityLevel::MEDIUM, true), - encryption(2, SecurityLevel::HIGH, false), - ]); - assert_eq!( - identity.auto_accept_proof_key().map(|key| key.id()), - Some(2), - "a disabled key is skipped" - ); - } - /// The bundled v0.9.3 profile: real identity rows written by DET v0.9.3. const V0_9_3_DATA_SQL: &str = include_str!("../../../tests/migration-fixtures/v0.9.3-public-identities/data.sql"); diff --git a/src/ui/dashpay/qr_code_generator.rs b/src/ui/dashpay/qr_code_generator.rs index 34c24c43e..319c0af8d 100644 --- a/src/ui/dashpay/qr_code_generator.rs +++ b/src/ui/dashpay/qr_code_generator.rs @@ -16,36 +16,29 @@ use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; use crate::ui::dashpay::dashpay_screen::DashPaySubscreen; -use crate::ui::identity::wallet_for_signing_key; use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, ScreenLike}; -use egui::{RichText, ScrollArea, TextEdit, Ui}; +use egui::{RichText, ScrollArea, Ui}; use std::sync::{Arc, RwLock}; const QR_CODE_INFO_TEXT: &str = "About Contact QR Codes:\n\n\ - QR codes allow instant mutual contact establishment.\n\n\ - The recipient can scan to automatically send and accept contact requests.\n\n\ - QR codes expire after the specified validity period.\n\n\ - Each QR code is unique and can only be used once.\n\n\ - WARNING: Anyone with this QR code can automatically become your contact."; - -const ACCOUNT_INDEX_INFO_TEXT: &str = "Account Index:\n\n\ - The account index determines which HD wallet account is used for this contact relationship.\n\n\ - Most users should leave this at 0 (the default).\n\n\ - Advanced users may use different account indices to segregate contacts \ - (e.g., separate personal and business contacts into different wallet accounts).\n\n\ - The account index is used in the derivation path: m/9'/5'/15'/account'/..."; + Someone who scans your QR code sends you a contact request that DashPay wallets can accept automatically.\n\n\ + The QR code shows your username and works for one hour.\n\n\ + WARNING: Anyone who has this QR code before it expires can send you such a request. Share it only with people you want as contacts."; + +/// The wallet whose recovery phrase derives `identity`'s contact QR code key, +/// for the unlock prompt. +fn qr_wallet(identity: &QualifiedIdentity) -> Option>> { + identity.dashpay_wallet().map(|(_, wallet)| wallet.clone()) +} pub struct QRCodeGeneratorScreen { pub app_context: Arc, pub selected_identity: Option, selected_identity_string: String, - account_index: String, - validity_hours: String, generated_qr_data: Option, qr_code: Option, show_info_popup: bool, - show_advanced_options: bool, selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, wallet_open_attempted: bool, @@ -57,12 +50,9 @@ impl QRCodeGeneratorScreen { app_context: app_context.clone(), selected_identity: None, selected_identity_string: String::new(), - account_index: "0".to_string(), - validity_hours: "24".to_string(), generated_qr_data: None, qr_code: None, show_info_popup: false, - show_advanced_options: false, selected_wallet: None, wallet_unlock_popup: WalletUnlockPopup::new(), wallet_open_attempted: false, @@ -84,8 +74,7 @@ impl QRCodeGeneratorScreen { new_self.selected_identity_string = preferred.identity.id().to_string(Encoding::Base58); // Get wallet for the selected identity - new_self.selected_wallet = - wallet_for_signing_key(&preferred, preferred.auto_accept_proof_key()); + new_self.selected_wallet = qr_wallet(&preferred); } new_self @@ -103,35 +92,9 @@ impl QRCodeGeneratorScreen { return AppAction::None; }; - let account_idx = match self.account_index.parse::() { - Ok(v) => v, - Err(_) => { - MessageBanner::set_global( - self.app_context.egui_ctx(), - "Invalid account index number", - MessageType::Error, - ); - return AppAction::None; - } - }; - - let validity = match self.validity_hours.parse::() { - Ok(v) if v > 0 && v <= 720 => v, // Max 30 days - _ => { - MessageBanner::set_global( - self.app_context.egui_ctx(), - "Validity hours must be between 1 and 720", - MessageType::Error, - ); - return AppAction::None; - } - }; - AppAction::BackendTask(BackendTask::DashPayTask(Box::new( DashPayTask::GenerateAutoAcceptQrCode { identity: identity.clone(), - account_reference: account_idx, - validity_hours: validity, }, ))) } @@ -150,9 +113,6 @@ impl QRCodeGeneratorScreen { if crate::ui::helpers::info_icon_button(ui, QR_CODE_INFO_TEXT).clicked() { self.show_info_popup = true; } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.checkbox(&mut self.show_advanced_options, "Advanced Options"); - }); }); ui.separator(); @@ -204,10 +164,7 @@ impl QRCodeGeneratorScreen { if response.changed() { // Update wallet for the newly selected identity if let Some(identity) = &self.selected_identity { - self.selected_wallet = wallet_for_signing_key( - identity, - identity.auto_accept_proof_key(), - ); + self.selected_wallet = qr_wallet(identity); } else { self.selected_wallet = None; } @@ -219,47 +176,6 @@ impl QRCodeGeneratorScreen { ui.end_row(); }); - // Advanced options (only shown when checkbox is checked) - if self.show_advanced_options { - ui.add_space(10.0); - egui::Grid::new("qr_advanced_config_grid") - .num_columns(2) - .spacing([10.0, 10.0]) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new("Account Index:") - .color(DashColors::text_primary(dark_mode)), - ); - crate::ui::helpers::info_icon_button(ui, ACCOUNT_INDEX_INFO_TEXT); - }); - ui.add( - TextEdit::singleline(&mut self.account_index) - .hint_text("0") - .desired_width(100.0), - ); - ui.end_row(); - - ui.label( - RichText::new("Validity (hours):") - .color(DashColors::text_primary(dark_mode)), - ); - ui.horizontal(|ui| { - ui.add( - TextEdit::singleline(&mut self.validity_hours) - .hint_text("24") - .desired_width(100.0), - ); - ui.label( - RichText::new("How long the QR code remains valid (default: 24)") - .small() - .color(DashColors::text_secondary(dark_mode)), - ); - }); - ui.end_row(); - }); - } - ui.add_space(10.0); // Check wallet lock status before showing generate button diff --git a/src/ui/dashpay/qr_scanner.rs b/src/ui/dashpay/qr_scanner.rs index b767fb830..6ee08a637 100644 --- a/src/ui/dashpay/qr_scanner.rs +++ b/src/ui/dashpay/qr_scanner.rs @@ -1,8 +1,8 @@ use crate::app::AppAction; use crate::backend_task::dashpay::DashPayTask; -use crate::backend_task::dashpay::auto_accept_proof::AutoAcceptProofData; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; +use crate::model::dashpay::ContactQr; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::components::MessageBanner; @@ -26,7 +26,7 @@ pub struct QRScannerScreen { pub selected_identity: Option, selected_identity_string: String, qr_data_input: String, - parsed_qr_data: Option, + parsed_qr_data: Option, sending: bool, selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, @@ -78,7 +78,7 @@ impl QRScannerScreen { return; } - match AutoAcceptProofData::from_qr_string(&self.qr_data_input) { + match ContactQr::parse(&self.qr_data_input, now_secs()) { Ok(data) => { self.parsed_qr_data = Some(data); MessageBanner::set_global( @@ -91,7 +91,7 @@ impl QRScannerScreen { self.parsed_qr_data = None; MessageBanner::set_global( self.app_context.egui_ctx(), - "The QR code is not valid for DashPay. Scan a DashPay code and try again.", + error.to_string(), MessageType::Error, ) .with_details(error); @@ -124,12 +124,7 @@ impl QRScannerScreen { BackendTask::DashPayTask(Box::new(DashPayTask::SendContactRequestWithProof { identity: identity.clone(), signing_key: signing_key.clone(), - to_identity_id: qr_data.identity_id, - account_label: Some(format!( - "QR Contact (Account #{})", - qr_data.account_reference - )), - qr_auto_accept: qr_data.clone(), + qr: qr_data.clone(), })); return AppAction::BackendTask(task); @@ -222,7 +217,7 @@ impl QRScannerScreen { ui.add( TextEdit::multiline(&mut self.qr_data_input) - .hint_text("dash:?di=...") + .hint_text("dash:?du=...&dapk=...") .desired_rows(3) .desired_width(f32::INFINITY) ); @@ -251,19 +246,14 @@ impl QRScannerScreen { .num_columns(2) .spacing([10.0, 5.0]) .show(ui, |ui| { - ui.label("Contact Identity:"); - ui.label(qr_data.identity_id.to_string( - dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58 - )); - ui.end_row(); - - ui.label("Account Reference:"); - ui.label(format!("{}", qr_data.account_reference)); + ui.label("Contact:"); + ui.label(qr_data.dpns_name()); ui.end_row(); ui.label("Expires:"); - let expiry_time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(qr_data.expires_at); - ui.label(format!("{:?}", expiry_time)); + ui.label(crate::model::datetime::local_date_time_from_unix_millis( + u64::from(qr_data.expires_at()) * 1000, + )); ui.end_row(); }); @@ -305,8 +295,7 @@ impl QRScannerScreen { ui.add_space(10.0); - ui.label(RichText::new("ℹ️ This will send a contact request that will be automatically accepted").small()); - ui.label(RichText::new("⚡ Both you and the contact will become mutual contacts instantly").small()); + ui.label(RichText::new("ℹ️ This sends a contact request that your contact's DashPay wallet can accept automatically.").small()); }); } @@ -316,11 +305,9 @@ impl QRScannerScreen { ui.group(|ui| { ui.label(RichText::new("ℹ️ About QR Code Scanning").strong()); ui.separator(); - ui.label("• QR codes enable instant mutual contact establishment"); - ui.label("• The contact request is automatically accepted by both parties"); - ui.label("• No manual approval is needed when using valid QR codes"); - ui.label("• QR codes expire after the specified time period"); - ui.label("• Each QR code can only be used once"); + ui.label("• A contact QR code names your contact's username"); + ui.label("• Your contact's DashPay wallet can accept a request sent from it automatically"); + ui.label("• A contact QR code works for one hour after it is created"); }); }); @@ -393,3 +380,12 @@ impl ScreenLike for QRScannerScreen { self.display_task_result(result); } } + +/// The wall clock in Unix seconds, the clock a contact QR code's expiry is +/// compared against. +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs()) + .unwrap_or_default() +} diff --git a/src/wallet_backend/auto_accept.rs b/src/wallet_backend/auto_accept.rs new file mode 100644 index 000000000..fcc1e2999 --- /dev/null +++ b/src/wallet_backend/auto_accept.rs @@ -0,0 +1,298 @@ +//! DashPay contact QR codes (DIP-15 auto-accept) through upstream +//! platform-wallet. +//! +//! The owner's auto-accept key is derived from the identity's wallet seed at +//! `m/9'/coin'/16'/expiry'`. The seed is read only through the secret seam, +//! for one operation, and only [`AutoAcceptKeyExport`] sees it: a +//! [`ContactCryptoProvider`] that exports auto-accept keys and refuses every +//! other request. + +use std::sync::Arc; + +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, DerivationPath, ExtendedPubKey}; +use dash_sdk::dpp::secp256k1::{PublicKey, SecretKey}; +use platform_wallet::error::PlatformWalletError; +use platform_wallet::wallet::identity::crypto::auto_accept::auto_accept_derivation_path; +use platform_wallet::{ContactCryptoProvider, ContactInfoOpened, ContactInfoSealed}; +use zeroize::Zeroizing; + +use super::{SecretScope, WalletBackend}; +use crate::backend_task::error::TaskError; +use crate::model::qualified_identity::QualifiedIdentity; + +impl WalletBackend { + /// The contact QR code of `identity` (`dash:?du=&dapk=`), + /// built by upstream's `DashPayView::build_auto_accept_qr`: valid for + /// upstream's auto-accept lifetime, naming the identity's first cached + /// username (or one upstream finds on Platform). + /// + /// # Errors + /// + /// [`TaskError::ContactQrWalletMissing`] when the identity has no wallet + /// on this device, the seam's error when the seed cannot be read, and + /// [`TaskError::ContactQrBuildFailed`] when upstream cannot build the code. + pub(crate) async fn dashpay_build_auto_accept_qr( + &self, + identity: &QualifiedIdentity, + ) -> Result { + let seed_hash = identity + .dashpay_wallet_seed_hash() + .ok_or(TaskError::ContactQrWalletMissing)?; + let wallet = self.resolve_wallet(&seed_hash).await?; + let owner = identity.identity.id(); + // Upstream resolves a name on Platform when given none. + let username = identity + .dpns_names + .first() + .map(|name| { + let name = name.name.trim(); + name.strip_suffix(".dash").unwrap_or(name).to_string() + }) + .unwrap_or_default(); + let network = identity.network; + let identity_wallet = wallet.identity(); + let dashpay = identity_wallet.dashpay(); + + self.secret_access() + .with_secret_session(&SecretScope::HdSeed { seed_hash }, async |session| { + let plaintext = session.plaintext(); + let seed = plaintext.expose_hd_seed().ok_or(TaskError::WalletLocked)?; + let provider = AutoAcceptKeyExport::new(seed, network); + dashpay + .build_auto_accept_qr(&owner, &username, &provider) + .await + .map_err(|source| TaskError::ContactQrBuildFailed { + source: Arc::new(source), + }) + }) + .await + } +} + +/// Exports the DIP-15 auto-accept private key of one wallet seed, and nothing +/// else: a request for any other path, or for another network's path, is +/// refused. +/// +/// Borrows the seed for one secret session; it never copies it out. +pub(crate) struct AutoAcceptKeyExport<'a> { + seed: &'a [u8; 64], + network: Network, +} + +impl<'a> AutoAcceptKeyExport<'a> { + pub(crate) fn new(seed: &'a [u8; 64], network: Network) -> Self { + Self { seed, network } + } +} + +/// The provider serves auto-accept key exports only. +fn not_auto_accept() -> PlatformWalletError { + PlatformWalletError::InvalidParameter( + "this crypto provider exports DashPay auto-accept keys only".to_string(), + ) +} + +#[async_trait::async_trait] +impl ContactCryptoProvider for AutoAcceptKeyExport<'_> { + async fn receiving_xpub( + &self, + _path: &DerivationPath, + ) -> Result { + Err(not_auto_accept()) + } + + async fn ecdh_shared_secret( + &self, + _path: &DerivationPath, + _peer: &PublicKey, + ) -> Result, PlatformWalletError> { + Err(not_auto_accept()) + } + + async fn export_auto_accept_private_key( + &self, + path: &DerivationPath, + ) -> Result { + // Only `m/9'/coin'/16'/expiry'` of this network: the hardened leaf is + // the expiry, and the whole path must be the one upstream builds for it. + let expiry = match path.into_iter().last() { + Some(ChildNumber::Hardened { index }) => *index, + _ => return Err(not_auto_accept()), + }; + if auto_accept_derivation_path(self.network, expiry)? != *path { + return Err(not_auto_accept()); + } + let extended = path + .derive_priv_ecdsa_for_master_seed(self.seed, self.network) + .map_err(|error| PlatformWalletError::KeyDerivation(error.to_string()))?; + let secret = Zeroizing::new(*extended.private_key.to_bytes()); + SecretKey::from_secret_bytes(*secret) + .map_err(|error| PlatformWalletError::KeyDerivation(error.to_string())) + } + + async fn account_reference( + &self, + _path: &DerivationPath, + _compact_xpub: &[u8], + _account_index: u32, + _version: u32, + ) -> Result { + Err(not_auto_accept()) + } + + async fn unmask_account_reference( + &self, + _path: &DerivationPath, + _compact_xpub: &[u8], + _account_reference: u32, + ) -> Result<(u32, u32), PlatformWalletError> { + Err(not_auto_accept()) + } + + async fn contact_info_seal( + &self, + _root_path: &DerivationPath, + _derivation_index: u32, + _contact_id: &[u8; 32], + _private_data_plaintext: &[u8], + _private_data_iv: &[u8; 16], + ) -> Result { + Err(not_auto_accept()) + } + + async fn contact_info_open( + &self, + _root_path: &DerivationPath, + _derivation_index: u32, + _enc_to_user_id: &[u8; 32], + _private_data_blob: &[u8], + ) -> Result { + Err(not_auto_accept()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::dashpay::ContactQr; + use dash_sdk::dpp::key_wallet::wallet::Wallet; + use dash_sdk::dpp::key_wallet::wallet::initialization::WalletAccountCreationOptions; + use dash_sdk::platform::Identifier; + use platform_wallet::wallet::identity::crypto::auto_accept::{ + derive_auto_accept_private_key, encode_auto_accept_key_blob, encode_dashpay_contact_uri, + verify_auto_accept_proof, + }; + + const NETWORK: Network = Network::Testnet; + const EXPIRY: u32 = 1_900_000_000; + + /// Regression: DET derived the auto-accept key from an identity + /// ENCRYPTION key's private bytes, so no other DashPay client could + /// verify its proofs. The exported key is the one upstream derives from + /// the wallet seed. + #[tokio::test] + async fn the_exported_key_is_the_wallets_dip15_auto_accept_key() { + let seed = Zeroizing::new(rand::random::<[u8; 64]>()); + let wallet = Wallet::from_seed_bytes(*seed, NETWORK, WalletAccountCreationOptions::None) + .expect("a test wallet"); + let provider = AutoAcceptKeyExport::new(&seed, NETWORK); + + let path = auto_accept_derivation_path(NETWORK, EXPIRY).expect("a path"); + let exported = provider + .export_auto_accept_private_key(&path) + .await + .expect("an auto-accept path"); + assert_eq!( + exported, + derive_auto_accept_private_key(&wallet, NETWORK, EXPIRY).expect("upstream key") + ); + } + + /// Interop end to end: the code the owner's provider feeds upstream's + /// QR codec is read by DET's scanner, whose proof upstream verifies + /// against the owner's wallet. + #[tokio::test] + async fn a_scanned_code_yields_a_proof_the_owner_verifies() { + let seed = Zeroizing::new(rand::random::<[u8; 64]>()); + let wallet = Wallet::from_seed_bytes(*seed, NETWORK, WalletAccountCreationOptions::None) + .expect("a test wallet"); + let provider = AutoAcceptKeyExport::new(&seed, NETWORK); + let key = provider + .export_auto_accept_private_key( + &auto_accept_derivation_path(NETWORK, EXPIRY).expect("a path"), + ) + .await + .expect("a key"); + let uri = encode_dashpay_contact_uri("alice", &encode_auto_accept_key_blob(&key, EXPIRY)); + + let now = u64::from(EXPIRY) - 60; + let (sender, owner) = (Identifier::new([1; 32]), Identifier::new([2; 32])); + let proof = ContactQr::parse(&uri, now) + .expect("valid") + .sign_proof(&sender, &owner, 3, now) + .expect("a proof"); + assert!( + verify_auto_accept_proof(&wallet, NETWORK, &proof, &sender, &owner, 3) + .expect("verifiable") + ); + } + + /// Only an auto-accept path on the provider's network is exported; every + /// other request is refused. + #[tokio::test] + async fn everything_but_an_auto_accept_key_is_refused() { + let seed = Zeroizing::new(rand::random::<[u8; 64]>()); + let provider = AutoAcceptKeyExport::new(&seed, NETWORK); + + let mainnet = auto_accept_derivation_path(Network::Mainnet, EXPIRY).expect("a path"); + let identity_key: DerivationPath = vec![ + ChildNumber::from_hardened_idx(9).expect("valid"), + ChildNumber::from_hardened_idx(1).expect("valid"), + ChildNumber::from_hardened_idx(5).expect("valid"), + ChildNumber::from_hardened_idx(0).expect("valid"), + ] + .into(); + let mut unhardened = auto_accept_derivation_path(NETWORK, EXPIRY) + .expect("a path") + .into_iter() + .copied() + .collect::>(); + unhardened.pop(); + unhardened.push(ChildNumber::from_normal_idx(EXPIRY & 0x7FFF_FFFF).expect("valid")); + for path in [mainnet, identity_key, DerivationPath::from(unhardened)] { + assert!( + provider + .export_auto_accept_private_key(&path) + .await + .is_err(), + "{path}" + ); + } + + let path = auto_accept_derivation_path(NETWORK, EXPIRY).expect("a path"); + let peer = PublicKey::from_secret_key(&SecretKey::from_secret_bytes([7; 32]).expect("key")); + assert!(provider.receiving_xpub(&path).await.is_err()); + assert!(provider.ecdh_shared_secret(&path, &peer).await.is_err()); + assert!(provider.account_reference(&path, &[], 0, 0).await.is_err()); + assert!( + provider + .unmask_account_reference(&path, &[], 0) + .await + .is_err() + ); + assert!( + provider + .contact_info_seal(&path, 0, &[0; 32], &[], &[0; 16]) + .await + .is_err() + ); + assert!( + provider + .contact_info_open(&path, 0, &[0; 32], &[]) + .await + .is_err() + ); + } +} diff --git a/src/wallet_backend/mod.rs b/src/wallet_backend/mod.rs index 35b149c85..ca3d96f0e 100644 --- a/src/wallet_backend/mod.rs +++ b/src/wallet_backend/mod.rs @@ -23,6 +23,7 @@ pub mod auth_pubkey_cache; #[cfg(not(any(test, feature = "bench")))] pub(crate) mod auth_pubkey_cache; +mod auto_accept; mod avatar_cache; mod contact_profile_cache; mod coordinator_gate; From 9808ca9efc2bad7154a4f8f393452f0143adad86 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:22:58 +0000 Subject: [PATCH 33/58] fix(identity): pass over unobtainable keys and accept MASTER document types - signing_key_now chose among keys with any private half filed, so an unlimited key filed under a wallet that is not on this device, or in the legacy encrypted form, won over a usable limited key and signing then failed. It now keeps only keys whose private half this device can obtain (holds_obtainable_key) and falls through to the next usable key. - document_security_levels returned no level for a document type that requires MASTER, as MASTER sorts below CRITICAL. It now returns exactly MASTER for it, as upstream's allowed_signing_security_levels does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- src/model/identity_key_usability.rs | 49 ++++++++++++++++++- src/model/qualified_identity/mod.rs | 46 +++++++++++++++-- .../document_action_screen.rs | 2 +- 3 files changed, 90 insertions(+), 7 deletions(-) diff --git a/src/model/identity_key_usability.rs b/src/model/identity_key_usability.rs index a26cc0b89..8bb98acf7 100644 --- a/src/model/identity_key_usability.rs +++ b/src/model/identity_key_usability.rs @@ -164,10 +164,14 @@ pub fn is_auto_selectable( /// The security levels Platform accepts for a document action on /// `document_type`: CRITICAL up to and including the level the type requires, -/// since a stronger key satisfies a weaker requirement. Never MASTER. +/// since a stronger key satisfies a weaker requirement; exactly MASTER for a +/// type that requires MASTER. // TODO(upstream): call dpp's accepted-levels helper for a requirement once it // is public (platform-wallet's `allowed_signing_security_levels` is private). pub fn document_security_levels(document_type: DocumentTypeRef<'_>) -> Vec { + if document_type.security_level_requirement() == SecurityLevel::MASTER { + return vec![SecurityLevel::MASTER]; + } let required = document_type.security_level_requirement() as u8; [ SecurityLevel::CRITICAL, @@ -280,6 +284,49 @@ mod tests { use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dash_sdk::dpp::platform_value::BinaryData; + /// Review fix: a document type requiring MASTER accepted no level, as + /// MASTER sorts below CRITICAL. Platform accepts exactly MASTER for it, + /// as upstream platform-wallet's `allowed_signing_security_levels` does. + #[test] + fn a_master_document_type_accepts_only_master_keys() { + use dash_sdk::dpp::data_contract::config::DataContractConfig; + use dash_sdk::dpp::data_contract::document_type::DocumentType; + use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; + use dash_sdk::dpp::platform_value::platform_value; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::Identifier; + + let version = PlatformVersion::get(14).expect("protocol 14"); + let config = DataContractConfig::default_for_version(version).expect("config"); + let document_type = DocumentType::try_from_schema( + Identifier::new([1; 32]), + 1, + config.version(), + "vault", + platform_value!({ + "type": "object", + "signatureSecurityLevelRequirement": 0_u32, + "properties": {"value": {"type": "string", "maxLength": 20_u32, "position": 0_u32}}, + "additionalProperties": false, + }), + None, + &std::collections::BTreeMap::new(), + &config, + false, + &mut Vec::new(), + version, + ) + .expect("a document type requiring MASTER"); + assert_eq!( + document_type.security_level_requirement(), + SecurityLevel::MASTER + ); + assert_eq!( + document_security_levels(document_type.as_ref()), + vec![SecurityLevel::MASTER] + ); + } + /// A HIGH document type accepts CRITICAL and HIGH keys, never MEDIUM. #[test] fn a_high_document_type_accepts_critical_and_high_keys() { diff --git a/src/model/qualified_identity/mod.rs b/src/model/qualified_identity/mod.rs index 32d039314..f82bdbb72 100644 --- a/src/model/qualified_identity/mod.rs +++ b/src/model/qualified_identity/mod.rs @@ -1237,14 +1237,16 @@ impl QualifiedIdentity { } /// The key to sign with for `requirements` now: the automatic choice of - /// [`select_identity_signing_key`] among the keys this device holds the - /// private half of. Upstream platform-wallet's order: every qualifying - /// unlimited key at any accepted level before any limited one, then key - /// id order, so a budgeted application key is spent only when no + /// [`select_identity_signing_key`] among the keys whose private half this + /// device can obtain ([`Self::holds_obtainable_key`]), so a key filed + /// under a missing wallet or in the legacy encrypted form is passed over + /// for the next usable one. Upstream platform-wallet's order: every + /// qualifying unlimited key at any accepted level before any limited one, + /// then key id order, so a budgeted application key is spent only when no /// unlimited key can sign. pub fn signing_key_now(&self, requirements: KeyRequirements<'_>) -> Option<&IdentityPublicKey> { select_identity_signing_key(&self.identity, requirements, now_ms(), |key| { - self.can_sign_with(key) + self.holds_obtainable_key(key) }) } @@ -3423,6 +3425,40 @@ mod protocol_v14_compat_tests { ); } + /// Review fix: the automatic choice took any key with a private half + /// filed, so an unlimited key filed under a wallet this device does not + /// hold, or in the legacy encrypted form, won over a usable limited key, + /// and signing then failed instead of falling back to the usable key. + #[test] + fn automatic_choice_skips_a_key_whose_private_half_cannot_be_obtained() { + use crate::context::test_support::{auth_key, dashpay_contract, identity_holding}; + use crate::model::qualified_identity::encrypted_key_storage::{ + PrivateKeyData, WalletDerivationPath, + }; + let unlimited = auth_key(1, SecurityLevel::HIGH); + let limited = auth_key(2, SecurityLevel::HIGH).with_limits(Some(5_000), None); + for unobtainable in [ + PrivateKeyData::AtWalletDerivationPath(WalletDerivationPath { + wallet_seed_hash: [0x7D; 32], + derivation_path: Default::default(), + }), + PrivateKeyData::Encrypted(vec![0; 48]), + ] { + let mut identity = identity_holding(&[unlimited.clone(), limited.clone()]); + identity.private_keys.insert_at( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, 1), + (unlimited.clone().into(), unobtainable.clone()), + ); + assert_eq!( + identity + .dashpay_signing_key(&dashpay_contract(), "contactRequest") + .map(|key| key.id()), + Some(2), + "{unobtainable:?}" + ); + } + } + /// Regression: the contactRequest and contactInfo key choices admitted /// MEDIUM keys, which the document types refuse, so an unlimited MEDIUM /// key beat a held limited HIGH key and the write was refused. diff --git a/src/ui/contracts_documents/document_action_screen.rs b/src/ui/contracts_documents/document_action_screen.rs index e2ab2d8c9..46039798c 100644 --- a/src/ui/contracts_documents/document_action_screen.rs +++ b/src/ui/contracts_documents/document_action_screen.rs @@ -1981,7 +1981,7 @@ fn auto_selected_document_key( contract: Option<&QualifiedContract>, document_type: Option<&DocumentType>, ) -> Option { - // The levels the document type accepts (never MASTER), so the preference + // The levels the document type accepts, so the preference // for unlimited keys only ever chooses among keys Platform accepts. let levels = crate::ui::helpers::compute_allowed_security_levels( TransactionType::DocumentAction, From b693a12fb1224b8b18547e2a6f641aa724a74872 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:16:58 +0000 Subject: [PATCH 34/58] fix(identity): register usernames with ECDSA keys and judge readiness by the live key Review fixes for the DPNS signing key (#1117): - A username registration took a key of any type, so a BLS or EdDSA key could be chosen although upstream platform-wallet's DPNS writes sign with ECDSA keys only and Platform refuses EdDSA. The automatic choice (QualifiedIdentity::username_registration_key) and the explicit-choice check take ECDSA_SECP256K1 and ECDSA_HASH160 keys only (DPNS_SIGNING_KEY_TYPES). - A contract-group bound key the user chose was refused locally, though whether the group covers DPNS is Platform state. The explicit check now refuses only a key certainly bound elsewhere; the automatic choice still never takes a group-bound key. - Registration readiness and the missing-wallet watch judged the stored copy of a key, so a key whose expiry Platform had extended was reported unusable. They judge the live key now. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- .../identity/register_dpns_name.rs | 10 +- src/context/test_support.rs | 2 + src/model/dpns_usernames.rs | 152 ++++++++++++++++-- src/model/qualified_identity/mod.rs | 79 +++++++-- src/ui/identity/mod.rs | 10 +- src/ui/identity/register_dpns_name_screen.rs | 12 +- .../backend-e2e/measure_dpns_registration.rs | 12 +- 7 files changed, 216 insertions(+), 61 deletions(-) diff --git a/src/backend_task/identity/register_dpns_name.rs b/src/backend_task/identity/register_dpns_name.rs index ca130a762..fb5a03c16 100644 --- a/src/backend_task/identity/register_dpns_name.rs +++ b/src/backend_task/identity/register_dpns_name.rs @@ -1,4 +1,3 @@ -use crate::model::identity_key_usability::SigningScope; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -158,7 +157,7 @@ impl AppContext { let qualified_identity = input.qualified_identity; - let preorder_document_type = dpns_contract + dpns_contract .document_type_for_name("preorder") .map_err(|_| TaskError::DataContractNotFound)?; let domain_document_type = dpns_contract @@ -199,12 +198,7 @@ impl AppContext { dpns_signing_requirement(&self.dpns_contract()), ) }), - None => qualified_identity.document_signing_key( - SigningScope::ContractWide { - contract_id: dpns_contract.id(), - }, - &preorder_document_type, - ), + None => qualified_identity.username_registration_key(&dpns_contract), } .ok_or(TaskError::NoDocumentSigningKey)?; diff --git a/src/context/test_support.rs b/src/context/test_support.rs index 3d376432f..550a6d821 100644 --- a/src/context/test_support.rs +++ b/src/context/test_support.rs @@ -64,6 +64,8 @@ pub(crate) fn user_identity_with_signing_key( key.set_id(1); key.set_purpose(Purpose::AUTHENTICATION); key.set_security_level(SecurityLevel::HIGH); + // Username registrations are signed with ECDSA keys only. + key.set_key_type(dash_sdk::dpp::identity::KeyType::ECDSA_SECP256K1); let mut identity = bare_user_identity(id, network); identity.identity.add_public_key(key.clone()); if held { diff --git a/src/model/dpns_usernames.rs b/src/model/dpns_usernames.rs index 4f046363f..93169c123 100644 --- a/src/model/dpns_usernames.rs +++ b/src/model/dpns_usernames.rs @@ -116,9 +116,10 @@ impl SeenMarkAttempts { } } -/// Whether `key` may sign documents that require `required` security: an enabled, -/// unexpired authentication key at least as strong as required, and never a master -/// key. The rule for choosing a key automatically. +/// Whether `key` may sign documents that require `required` security: a +/// [`chosen_key_can_sign_documents`] key that is also unexpired and whose +/// bounds are known to permit DPNS (never a contract-group bound key). The +/// rule for choosing a key automatically. pub fn key_can_sign_documents( key: &dash_sdk::platform::IdentityPublicKey, required: dash_sdk::dpp::identity::SecurityLevel, @@ -132,12 +133,15 @@ pub fn key_can_sign_documents( } /// Whether the user may sign documents that require `required` security with -/// `key` they chose: an enabled authentication key at least as strong as -/// required, never a master key, and not bound to another contract. +/// `key` they chose: an enabled ECDSA ([`DPNS_SIGNING_KEY_TYPES`]) +/// authentication key at least as strong as required, never a master key, and +/// not bound to another contract. /// /// Unlike [`key_can_sign_documents`], an expired key is allowed: the key chooser /// offers it with a warning, and whether it has expired is Platform's call by -/// network time, not this device's clock. +/// network time, not this device's clock. So is a key bound to a contract +/// group: whether the group covers DPNS is Platform state, so only a key +/// certainly bound elsewhere is refused here. // TODO(upstream): call platform-wallet's public explicit-key validation once it // exists (`resolve_authentication_signing_key` is private) and route the // registration through `register_name_with_signer`. @@ -148,15 +152,29 @@ pub fn chosen_key_can_sign_documents( use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::identity::{Purpose, SecurityLevel}; key.purpose() == Purpose::AUTHENTICATION + && DPNS_SIGNING_KEY_TYPES.contains(&key.key_type()) && !key.is_disabled() && super::identity_key_usability::bounds_permit(key.contract_bounds(), dpns_signing_scope()) - == Some(true) + != Some(false) && key.security_level() != SecurityLevel::MASTER && key.security_level() <= required } +/// The key types a username registration is signed with: ECDSA keys, as +/// upstream platform-wallet's DPNS writes require (Platform refuses EdDSA +/// outright). `ECDSA_HASH160` is taken too, as for DashPay +/// ([`super::qualified_identity::DASHPAY_SIGNING_KEY_TYPES`]): Platform accepts +/// it and DET signed with it before, so dropping it would strand identities +/// whose only document key is a hash160 key. +// TODO(upstream): take the key types from platform-wallet's public DPNS key +// selector once it exists, instead of restating them here. +pub const DPNS_SIGNING_KEY_TYPES: &[dash_sdk::dpp::identity::KeyType] = &[ + dash_sdk::dpp::identity::KeyType::ECDSA_SECP256K1, + dash_sdk::dpp::identity::KeyType::ECDSA_HASH160, +]; + /// The scope a username registration signs in: the DPNS contract. -fn dpns_signing_scope() -> super::identity_key_usability::SigningScope<'static> { +pub fn dpns_signing_scope() -> super::identity_key_usability::SigningScope<'static> { super::identity_key_usability::SigningScope::ContractWide { contract_id: dash_sdk::dpp::system_data_contracts::SystemDataContract::DPNS.id(), } @@ -198,8 +216,10 @@ pub fn missing_registration_wallets( .values() .filter_map(move |(key, data)| match data { PrivateKeyData::AtWalletDerivationPath(path) - if key_can_sign_documents(&key.identity_public_key, required) - && !identity.key_secret_is_obtainable(data) => + if key_can_sign_documents( + identity.live_public_key(&key.identity_public_key), + required, + ) && !identity.key_secret_is_obtainable(data) => { Some(&path.wallet_seed_hash) } @@ -241,7 +261,9 @@ pub fn registration_key_status( use dash_sdk::dpp::identity::accessors::IdentityGettersV0; let mut held_without_its_wallet = false; for (key, data) in identity.private_keys.values() { - if !key_can_sign_documents(&key.identity_public_key, required) { + // The stored copy may predate a limits update: judge the key as the + // identity holds it now. + if !key_can_sign_documents(identity.live_public_key(&key.identity_public_key), required) { continue; } // TODO(legacy-encrypted-key-gate): this version cannot open the legacy @@ -1182,6 +1204,7 @@ mod tests { dash_sdk::dpp::version::PlatformVersion::latest(), ); key.set_purpose(purpose); + key.set_key_type(dash_sdk::dpp::identity::KeyType::ECDSA_SECP256K1); key.set_security_level(level); key }; @@ -1222,6 +1245,7 @@ mod tests { dash_sdk::dpp::version::PlatformVersion::latest(), ); key.set_purpose(purpose); + key.set_key_type(dash_sdk::dpp::identity::KeyType::ECDSA_SECP256K1); key.set_security_level(level); key.with_limits(None, Some(1)) }; @@ -1245,6 +1269,111 @@ mod tests { assert!(!chosen_key_can_sign_documents(&disabled, high)); } + /// Review fix: a username registration was signed with any key type, so a + /// BLS or EdDSA key could be picked although upstream platform-wallet's + /// DPNS writes sign with ECDSA keys only, and Platform refuses EdDSA. + #[test] + fn a_username_is_registered_with_an_ecdsa_key() { + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + use dash_sdk::dpp::identity::{KeyType, SecurityLevel}; + let high = SecurityLevel::HIGH; + let typed = |key_type| { + let mut key = crate::context::test_support::auth_key(1, high); + key.set_key_type(key_type); + key + }; + for refused in [ + KeyType::BLS12_381, + KeyType::EDDSA_25519_HASH160, + KeyType::BIP13_SCRIPT_HASH, + ] { + assert!( + !key_can_sign_documents(&typed(refused), high), + "{refused:?}" + ); + assert!( + !chosen_key_can_sign_documents(&typed(refused), high), + "{refused:?}" + ); + } + for accepted in [KeyType::ECDSA_SECP256K1, KeyType::ECDSA_HASH160] { + assert!( + key_can_sign_documents(&typed(accepted), high), + "{accepted:?}" + ); + assert!( + chosen_key_can_sign_documents(&typed(accepted), high), + "{accepted:?}" + ); + } + } + + /// Review fix: a contract-group bound key the user chose was refused here, + /// though whether the group covers DPNS is only known to Platform. The + /// automatic choice still never takes one; a key bound elsewhere is + /// refused either way. + #[test] + fn a_chosen_contract_group_key_is_left_for_platform_to_judge() { + use crate::context::test_support::{auth_key, bound_to}; + use dash_sdk::dpp::identity::SecurityLevel; + use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; + let high = SecurityLevel::HIGH; + let grouped = bound_to( + auth_key(1, high), + ContractBounds::ContractGroup { id: id(7) }, + ); + assert!(chosen_key_can_sign_documents(&grouped, high)); + assert!(!key_can_sign_documents(&grouped, high)); + + let elsewhere = bound_to( + auth_key(1, high), + ContractBounds::SingleContract { id: id(9) }, + ); + assert!(!chosen_key_can_sign_documents(&elsewhere, high)); + } + + /// Review fix: readiness judged the stored copy of a key, so a key whose + /// expiry Platform has since extended was reported unusable, and its + /// missing wallet was not reported as what would lift the refusal. + #[test] + fn readiness_judges_a_key_as_the_identity_holds_it_now() { + use crate::context::test_support::{auth_key, hold_as_wallet_reference, identity_holding}; + use dash_sdk::dpp::identity::SecurityLevel; + use dash_sdk::dpp::identity::accessors::IdentitySettersV0; + let high = SecurityLevel::HIGH; + let stale = auth_key(1, high).with_limits(None, Some(1)); + let extended = stale.clone().with_limits(None, Some(u64::MAX)); + let extend = |identity: &mut QualifiedIdentity| { + identity + .identity + .set_public_keys(std::collections::BTreeMap::from([(1, extended.clone())])); + }; + + // The stored copy says expired, the live key does not. + let mut identity = identity_holding(std::slice::from_ref(&stale)); + assert_eq!( + registration_key_status(&identity, high), + RegistrationKeyStatus::NoSuitableKey, + "the expired key alone cannot register" + ); + extend(&mut identity); + assert_eq!( + registration_key_status(&identity, high), + RegistrationKeyStatus::Ready + ); + + // The same, for a key held as a reference into a missing wallet. + let mut identity = identity_holding(std::slice::from_ref(&stale)); + hold_as_wallet_reference(&mut identity, 1, [0x5A; 32]); + extend(&mut identity); + assert_eq!( + missing_registration_wallets(&identity, high) + .copied() + .collect::>(), + vec![[0x5A; 32]] + ); + } + /// A testnet identity publishing `published` keys and holding `held` of them. fn identity_with_keys( published: &[(u32, dash_sdk::dpp::identity::SecurityLevel)], @@ -1267,6 +1396,7 @@ mod tests { dash_sdk::dpp::version::PlatformVersion::latest(), ); key.set_id(*key_id); + key.set_key_type(dash_sdk::dpp::identity::KeyType::ECDSA_SECP256K1); key.set_purpose(Purpose::AUTHENTICATION); key.set_security_level(*level); identity.identity.add_public_key(key.clone()); diff --git a/src/model/qualified_identity/mod.rs b/src/model/qualified_identity/mod.rs index f82bdbb72..2a1662fca 100644 --- a/src/model/qualified_identity/mod.rs +++ b/src/model/qualified_identity/mod.rs @@ -8,6 +8,7 @@ pub mod qualified_identity_public_key; // requires making that secret-seam chokepoint generic over the closure error // type — a wallet_backend change out of scope here. use crate::backend_task::error::{SigningKeyUnavailableCause, TaskError}; +use crate::model::dpns_usernames::DPNS_SIGNING_KEY_TYPES; use crate::model::identity_key_usability::{ KeyRequirements, SigningScope, document_security_levels, now_ms, select_identity_signing_key, }; @@ -1166,20 +1167,39 @@ impl QualifiedIdentity { } /// The key to sign document transitions on `document_type` with, within - /// `scope`: a live AUTHENTICATION key at a level the type accepts - /// ([`document_security_levels`]) whose contract bounds permit `scope`, - /// in upstream platform-wallet's order (see [`Self::signing_key_now`]). + /// `scope`: a live AUTHENTICATION key of one of `key_types`, at a level the + /// type accepts ([`document_security_levels`]), whose contract bounds + /// permit `scope`, in upstream platform-wallet's order (see + /// [`Self::signing_key_now`]). pub fn document_signing_key( &self, scope: SigningScope<'_>, document_type: &DocumentTypeRef, + key_types: &[KeyType], ) -> Option<&IdentityPublicKey> { let levels = document_security_levels(*document_type); - self.signing_key_now(KeyRequirements::new( - Purpose::AUTHENTICATION, - &levels, - scope, - )) + self.signing_key_now( + KeyRequirements::new(Purpose::AUTHENTICATION, &levels, scope).with_key_types(key_types), + ) + } + + /// The key to register a username with: a [`Self::document_signing_key`] + /// for DPNS `preorder` documents, across the DPNS contract (a registration + /// writes preorder and domain documents), of one of + /// [`DPNS_SIGNING_KEY_TYPES`]. `None` when `dpns_contract` has no + /// `preorder` type or no key qualifies. + pub fn username_registration_key( + &self, + dpns_contract: &DataContract, + ) -> Option<&IdentityPublicKey> { + let preorder = dpns_contract.document_type_for_name("preorder").ok()?; + self.document_signing_key( + SigningScope::ContractWide { + contract_id: dpns_contract.id(), + }, + &preorder, + DPNS_SIGNING_KEY_TYPES, + ) } /// The key to sign an action on document type `document_type_name` of @@ -3648,7 +3668,8 @@ mod protocol_v14_compat_tests { contract_id: dpns.id(), }; assert_eq!( - qi.document_signing_key(scope, &preorder).map(|k| k.id()), + qi.document_signing_key(scope, &preorder, DPNS_SIGNING_KEY_TYPES) + .map(|k| k.id()), Some(2), "the CRITICAL key, never the MASTER key" ); @@ -3660,12 +3681,41 @@ mod protocol_v14_compat_tests { auth_key(3, SecurityLevel::HIGH), ]); assert_eq!( - qi.document_signing_key(scope, &preorder).map(|k| k.id()), + qi.document_signing_key(scope, &preorder, DPNS_SIGNING_KEY_TYPES) + .map(|k| k.id()), Some(2), "key id order among accepted levels, as upstream orders them" ); } + /// Review fix: the automatic username registration key was taken of any + /// key type, so a BLS or EdDSA key with a lower id won over an ECDSA key. + #[test] + fn a_username_registration_key_is_an_ecdsa_key() { + use crate::context::test_support::{auth_key, identity_holding}; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + + let dpns = dash_sdk::dpp::system_data_contracts::load_system_data_contract( + dash_sdk::dpp::system_data_contracts::SystemDataContract::DPNS, + PlatformVersion::latest(), + ) + .expect("the DPNS contract"); + let typed = |id: KeyID, key_type: KeyType| { + let mut key = auth_key(id, SecurityLevel::HIGH); + key.set_key_type(key_type); + key + }; + let qi = identity_holding(&[ + typed(1, KeyType::BLS12_381), + typed(2, KeyType::EDDSA_25519_HASH160), + typed(3, KeyType::ECDSA_SECP256K1), + ]); + assert_eq!(qi.username_registration_key(&dpns).map(|k| k.id()), Some(3)); + + let qi = identity_holding(&[typed(1, KeyType::BLS12_381)]); + assert!(qi.username_registration_key(&dpns).is_none()); + } + /// The document signing key skips a key Platform would refuse (expired, /// bound elsewhere) and prefers an unlimited key over a limited one. #[test] @@ -3697,7 +3747,8 @@ mod protocol_v14_compat_tests { contract_id: dpns.id(), }; assert_eq!( - qi.document_signing_key(scope, &preorder).map(|k| k.id()), + qi.document_signing_key(scope, &preorder, DPNS_SIGNING_KEY_TYPES) + .map(|k| k.id()), Some(4), "the unlimited, unbound, live key" ); @@ -3706,7 +3757,8 @@ mod protocol_v14_compat_tests { keys.remove(&4); qi.identity.set_public_keys(keys); assert_eq!( - qi.document_signing_key(scope, &preorder).map(|k| k.id()), + qi.document_signing_key(scope, &preorder, DPNS_SIGNING_KEY_TYPES) + .map(|k| k.id()), Some(3), "a limited key when it is the only usable one" ); @@ -3727,7 +3779,8 @@ mod protocol_v14_compat_tests { keys.insert(5, auth_key(5)); qi.identity.set_public_keys(keys); assert_eq!( - qi.document_signing_key(scope, &preorder).map(|k| k.id()), + qi.document_signing_key(scope, &preorder, DPNS_SIGNING_KEY_TYPES) + .map(|k| k.id()), Some(3), "the unlimited key 5 cannot be signed with here" ); diff --git a/src/ui/identity/mod.rs b/src/ui/identity/mod.rs index 4ca2c50d7..02fe59b9f 100644 --- a/src/ui/identity/mod.rs +++ b/src/ui/identity/mod.rs @@ -18,7 +18,6 @@ //! `src/ui/components/left_panel.rs`, and the `RootScreenIdentityHub` entry in //! the `main_screens` map in `src/app.rs::AppState::new`. -use crate::model::identity_key_usability::SigningScope; use std::sync::{Arc, RwLock}; use dash_sdk::{ @@ -219,18 +218,13 @@ pub fn get_selected_wallet( let dpns_contract = &context.dpns_contract(); // Attempt to fetch the `preorder` document type from the DPNS contract. - let preorder_document_type = dpns_contract + dpns_contract .document_type_for_name("preorder") .map_err(|e| format!("DPNS preorder document type not found: {}", e))?; // Attempt to retrieve the public key from the identity. qualified_identity - .document_signing_key( - SigningScope::ContractWide { - contract_id: dpns_contract.id(), - }, - &preorder_document_type, - ) + .username_registration_key(dpns_contract) .ok_or_else(|| { "Identity doesn't have an authentication key for signing document transitions" .to_string() diff --git a/src/ui/identity/register_dpns_name_screen.rs b/src/ui/identity/register_dpns_name_screen.rs index 66e9cfb19..fe30c52b2 100644 --- a/src/ui/identity/register_dpns_name_screen.rs +++ b/src/ui/identity/register_dpns_name_screen.rs @@ -597,16 +597,8 @@ impl RegisterDpnsNameScreen { .document_type_cloned_for_name("domain") .ok(); if self.selected_key.is_none() { - self.selected_key = document_type - .as_ref() - .and_then(|doc_type| { - identity.document_signing_key( - SigningScope::ContractWide { - contract_id: self.app_context.dpns_contract().id(), - }, - &doc_type.as_ref(), - ) - }) + self.selected_key = identity + .username_registration_key(&self.app_context.dpns_contract()) .cloned(); } action |= add_key_chooser_with_doc_type( diff --git a/tests/backend-e2e/measure_dpns_registration.rs b/tests/backend-e2e/measure_dpns_registration.rs index e5155fca3..947841e6e 100644 --- a/tests/backend-e2e/measure_dpns_registration.rs +++ b/tests/backend-e2e/measure_dpns_registration.rs @@ -22,9 +22,7 @@ use crate::register_dpns::{ }; use dash_evo_tool::context::AppContext; use dash_evo_tool::model::fee_estimation::contest_fee_credits; -use dash_evo_tool::model::identity_key_usability::SigningScope; use dash_evo_tool::model::qualified_identity::QualifiedIdentity; -use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::system_data_contracts::{SystemDataContract, load_system_data_contract}; use dash_sdk::platform::dpns_usernames::{ @@ -162,16 +160,8 @@ async fn register_and_log( let dpns = load_system_data_contract(SystemDataContract::DPNS, app_context.sdk_platform_version()) .expect("the DPNS contract should load"); - let preorder = dpns - .document_type_for_name("preorder") - .expect("the DPNS contract should define preorder"); let signing_key = identity - .document_signing_key( - SigningScope::ContractWide { - contract_id: dpns.id(), - }, - &preorder, - ) + .username_registration_key(&dpns) .cloned() .expect("the identity should hold a key that signs name documents"); From 375140a16684c2fc7118c5f175fdd1eac9a122ff Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:33:46 +0000 Subject: [PATCH 35/58] fix(ui): judge key chooser entries and its no-key gate by one rule Review fixes for the manual key chooser (#1117): - The "no eligible key" gate counted any filed key of the right purpose and security level, while the list left out disabled keys and keys bound to another contract and judged the live copy. An identity holding only such a key got an empty list instead of the guidance to add one. KeyFilter now decides both, on the live key. - The contact-request chooser offered BLS and EdDSA keys although the DashPay writes sign with ECDSA keys. The username registration chooser (new TransactionType::UsernameRegistration) did the same for DPNS. Each now offers the key types its automatic choice takes; developer mode still lists the rest, tagged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- src/ui/helpers.rs | 307 +++++++++++++++---- src/ui/identity/register_dpns_name_screen.rs | 2 +- 2 files changed, 245 insertions(+), 64 deletions(-) diff --git a/src/ui/helpers.rs b/src/ui/helpers.rs index 0cf800298..102bc7479 100644 --- a/src/ui/helpers.rs +++ b/src/ui/helpers.rs @@ -96,7 +96,7 @@ use dash_sdk::{ group::{Group, accessors::v0::GroupV0Getters}, }, identity::{ - Purpose, SecurityLevel, accessors::IdentityGettersV0, + KeyType, Purpose, SecurityLevel, TimestampMillis, accessors::IdentityGettersV0, identity_public_key::accessors::v0::IdentityPublicKeyGettersV0, }, platform_value::string_encoding::Encoding, @@ -150,9 +150,10 @@ pub fn compute_allowed_security_levels( document_type: Option<&DocumentType>, ) -> Vec { match (transaction_type, document_type) { - (TransactionType::DocumentAction, Some(doc_type)) => { - crate::model::identity_key_usability::document_security_levels(doc_type.as_ref()) - } + ( + TransactionType::DocumentAction | TransactionType::UsernameRegistration, + Some(doc_type), + ) => crate::model::identity_key_usability::document_security_levels(doc_type.as_ref()), _ => transaction_type.allowed_security_levels(), } } @@ -333,6 +334,8 @@ pub enum TransactionType { TokenClaim, /// DashPay contact request - requires Authentication keys for signing (ENCRYPTION key for ECDH is auto-selected) ContactRequest, + /// DPNS username registration - requires ECDSA Authentication keys; security level depends on the document type + UsernameRegistration, } impl TransactionType { @@ -350,7 +353,30 @@ impl TransactionType { TransactionType::TokenTransfer | TransactionType::TokenClaim => { vec![Purpose::TRANSFER, Purpose::AUTHENTICATION] } - TransactionType::ContactRequest => vec![Purpose::AUTHENTICATION], + TransactionType::ContactRequest | TransactionType::UsernameRegistration => { + vec![Purpose::AUTHENTICATION] + } + } + } + + /// The key types this transaction type is signed with, or `None` when + /// every key type is accepted: the ones its automatic key choice takes. + pub fn allowed_key_types(&self) -> Option<&'static [KeyType]> { + match self { + TransactionType::ContactRequest => { + Some(crate::model::qualified_identity::DASHPAY_SIGNING_KEY_TYPES) + } + TransactionType::UsernameRegistration => { + Some(crate::model::dpns_usernames::DPNS_SIGNING_KEY_TYPES) + } + TransactionType::RegisterContract + | TransactionType::UpdateContract + | TransactionType::Transfer + | TransactionType::Withdraw + | TransactionType::DocumentAction + | TransactionType::TokenAction + | TransactionType::TokenTransfer + | TransactionType::TokenClaim => None, } } @@ -369,7 +395,9 @@ impl TransactionType { TransactionType::TokenAction | TransactionType::TokenTransfer | TransactionType::TokenClaim => vec![SecurityLevel::CRITICAL], - TransactionType::ContactRequest => vec![SecurityLevel::CRITICAL, SecurityLevel::HIGH], + TransactionType::ContactRequest | TransactionType::UsernameRegistration => { + vec![SecurityLevel::CRITICAL, SecurityLevel::HIGH] + } } } @@ -385,40 +413,86 @@ impl TransactionType { TransactionType::TokenTransfer => "Token Transfer", TransactionType::TokenClaim => "Token Claim", TransactionType::ContactRequest => "Contact Request", + TransactionType::UsernameRegistration => "Username Registration", } } } -/// Key chooser that filters keys based on transaction type and dev mode. -/// Use this when you already have a specific identity and just need to select a key. -/// Whether `identity` has (a) a loaded private key eligible for the transaction and -/// (b) an eligible public key whose private key is not yet loaded. +/// What a key chooser offers for one transaction: the purposes, security +/// levels and key types the transaction accepts, and the scope its key signs +/// in. The "no eligible key" gate and the list entries both judge keys by it, +/// so the gate never opens onto an empty list. +#[derive(Debug, Clone, Copy)] +struct KeyFilter<'a> { + purposes: &'a [Purpose], + security_levels: &'a [SecurityLevel], + /// `None` accepts every key type. + key_types: Option<&'a [KeyType]>, + scope: SigningScope<'a>, +} + +impl<'a> KeyFilter<'a> { + /// The filter for `transaction_type` within `scope`, at + /// `security_levels` (see [`compute_allowed_security_levels`]). + fn new( + transaction_type: TransactionType, + purposes: &'a [Purpose], + security_levels: &'a [SecurityLevel], + scope: SigningScope<'a>, + ) -> Self { + Self { + purposes, + security_levels, + key_types: transaction_type.allowed_key_types(), + scope, + } + } + + /// Whether `key` has a purpose, security level and key type the + /// transaction accepts. Developer mode lists keys failing this, tagged. + fn accepts(&self, key: &IdentityPublicKey) -> bool { + self.purposes.contains(&key.purpose()) + && self.security_levels.contains(&key.security_level()) + && self + .key_types + .is_none_or(|types| types.contains(&key.key_type())) + } + + /// Whether `key` is ever listed: Platform refuses a disabled key, and a + /// key bound to another contract or document type, so no mode offers + /// them. + fn may_sign(&self, key: &IdentityPublicKey, now: TimestampMillis) -> bool { + !key.is_disabled() && offered_for_manual_choice(&key_caveats(key, self.scope, now)) + } + + /// Whether `key` is listed outside developer mode. + fn offers(&self, key: &IdentityPublicKey, now: TimestampMillis) -> bool { + self.accepts(key) && self.may_sign(key, now) + } +} + +/// Whether `identity` has (a) a loaded private key `filter` offers and (b) a +/// public key `filter` offers whose private key is not loaded. Keys are judged +/// as the identity holds them now, as the list judges them. fn key_eligibility( identity: &QualifiedIdentity, - allowed_purposes: &[Purpose], - allowed_security_levels: &[SecurityLevel], + filter: &KeyFilter<'_>, + now: TimestampMillis, ) -> (bool, bool) { - let has_suitable_keys_with_private = - identity - .private_keys - .identity_public_keys() - .iter() - .any(|key_ref| { - let key = &key_ref.1.identity_public_key; - allowed_purposes.contains(&key.purpose()) - && allowed_security_levels.contains(&key.security_level()) - }); + let filed = identity.private_keys.identity_public_keys(); + let has_suitable_keys_with_private = filed.iter().any(|key_ref| { + filter.offers( + identity.live_public_key(&key_ref.1.identity_public_key), + now, + ) + }); let has_eligible_public_keys_without_private = - identity.identity.public_keys().iter().any(|(_, pub_key)| { - let basic_ok = allowed_purposes.contains(&pub_key.purpose()) - && allowed_security_levels.contains(&pub_key.security_level()); - let has_private = identity - .private_keys - .identity_public_keys() - .iter() - .any(|key_ref| key_ref.1.identity_public_key.id() == pub_key.id()); - basic_ok && !has_private + identity.identity.public_keys().values().any(|pub_key| { + filter.offers(pub_key, now) + && !filed + .iter() + .any(|key_ref| key_ref.1.identity_public_key.id() == pub_key.id()) }); ( @@ -464,21 +538,19 @@ fn render_no_eligible_key_group( action } -/// Renders a ComboBox listing `identity`'s loaded private keys eligible for the transaction. +/// Renders a ComboBox listing `identity`'s loaded private keys `filter` offers. /// /// With `identity` `None`, prompts the user to pick an identity first. Developer mode also -/// lists otherwise-ineligible keys, tagged via [`format_key_label_dev`]. -#[allow(clippy::too_many_arguments)] +/// lists keys of another purpose, security level or key type, tagged via +/// [`format_key_label_dev`]. fn render_key_combo( ui: &mut Ui, combo_id: &str, width: f32, identity: Option<&QualifiedIdentity>, selected_key: &mut Option, - allowed_purposes: &[Purpose], - allowed_security_levels: &[SecurityLevel], + filter: &KeyFilter<'_>, is_dev_mode: bool, - scope: SigningScope<'_>, ) { let now = now_ms(); ComboBox::from_id_salt(combo_id) @@ -499,36 +571,27 @@ fn render_key_combo( // hand on the key as the identity holds it now. let key = qi.live_public_key(&key_ref.1.identity_public_key); - // Platform rejects signing with a disabled key, so never offer one - // — not even in dev mode, where the override only relaxes purpose and - // security-level filters, not signability. - if key.is_disabled() { + // Platform rejects signing with a disabled key or a key bound + // elsewhere, so never offer one — not even in dev mode, where + // the override only relaxes purpose, security-level and key + // type filters, not signability. + if !filter.may_sign(key, now) { continue; } - - let is_allowed = is_dev_mode - || (allowed_purposes.contains(&key.purpose()) - && allowed_security_levels.contains(&key.security_level())); - if !is_allowed { + let is_dev_override = !filter.accepts(key); + if is_dev_override && !is_dev_mode { continue; } - let is_dev_override = is_dev_mode - && (!allowed_purposes.contains(&key.purpose()) - || !allowed_security_levels.contains(&key.security_level())); let label = if is_dev_override { format_key_label_dev(key) } else { format_key_label(key) }; - let caveats = key_caveats(key, scope, now); - if !offered_for_manual_choice(&caveats) { - continue; - } // A key Platform may refuse stays selectable (the user may know // better, e.g. a contract group that covers this contract), but // its entry says why it may not work. - let label = key_entry_label(&label, caveats.first()); + let label = key_entry_label(&label, key_caveats(key, filter.scope, now).first()); if kui .selectable_label(selected_key.as_ref() == Some(key), label) @@ -546,7 +609,7 @@ fn render_key_combo( } } if let Some(key) = selected_key.as_ref() { - render_key_caveats(ui, &key_caveats(key, scope, now)); + render_key_caveats(ui, &key_caveats(key, filter.scope, now)); } } @@ -673,9 +736,15 @@ pub fn add_key_chooser_with_doc_type( let allowed_purposes = transaction_type.allowed_purposes(); let allowed_security_levels = compute_allowed_security_levels(transaction_type, document_type); + let filter = KeyFilter::new( + transaction_type, + &allowed_purposes, + &allowed_security_levels, + scope, + ); let (has_suitable_keys_with_private, has_eligible_public_keys_without_private) = - key_eligibility(identity, &allowed_purposes, &allowed_security_levels); + key_eligibility(identity, &filter, now_ms()); if !is_dev_mode && !has_suitable_keys_with_private { action = render_no_eligible_key_group( @@ -697,10 +766,8 @@ pub fn add_key_chooser_with_doc_type( 300.0, Some(identity), selected_key, - &allowed_purposes, - &allowed_security_levels, + &filter, is_dev_mode, - scope, ); }); } @@ -788,13 +855,19 @@ where let allowed_purposes = transaction_type.allowed_purposes(); let allowed_security_levels = compute_allowed_security_levels(transaction_type, document_type); + let filter = KeyFilter::new( + transaction_type, + &allowed_purposes, + &allowed_security_levels, + scope, + ); // Show the "no eligible key" notice instead of the combo when the selected // identity has no loaded eligible key (outside developer mode). let mut show_combo = true; if let Some(qi) = selected_identity.as_ref() { let (has_suitable_keys_with_private, has_eligible_public_keys_without_private) = - key_eligibility(qi, &allowed_purposes, &allowed_security_levels); + key_eligibility(qi, &filter, now_ms()); if !is_dev_mode && !has_suitable_keys_with_private { show_combo = false; action = render_no_eligible_key_group( @@ -814,10 +887,8 @@ where 220.0, selected_identity.as_ref(), selected_key, - &allowed_purposes, - &allowed_security_levels, + &filter, is_dev_mode, - scope, ); } }); @@ -1222,6 +1293,116 @@ mod tests { use super::*; + /// The filter the contact-request chooser applies, within DashPay's + /// contactRequest document type. + fn contact_request_filter<'a>( + purposes: &'a [Purpose], + levels: &'a [SecurityLevel], + dashpay: Identifier, + ) -> KeyFilter<'a> { + KeyFilter { + purposes, + security_levels: levels, + key_types: TransactionType::ContactRequest.allowed_key_types(), + scope: SigningScope::Document { + contract_id: dashpay, + document_type_name: "contactRequest", + }, + } + } + + /// Review fix: the chooser gate counted any filed key of the right purpose + /// and level, while the list left out disabled keys and keys bound + /// elsewhere, so an identity holding only such a key got an empty list + /// instead of the no-usable-key guidance. Both now apply one predicate, + /// to the key as the identity holds it now. + #[test] + fn the_chooser_gate_counts_only_keys_the_list_offers() { + use crate::context::test_support::{auth_key, bound_to, identity_holding}; + use dash_sdk::dpp::identity::KeyType; + use dash_sdk::dpp::identity::accessors::IdentitySettersV0; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; + + let dashpay = Identifier::new([0xDA; 32]); + let purposes = TransactionType::ContactRequest.allowed_purposes(); + let levels = TransactionType::ContactRequest.allowed_security_levels(); + let filter = contact_request_filter(&purposes, &levels, dashpay); + let high = |id| auth_key(id, SecurityLevel::HIGH); + let now = 1_000; + + let usable = identity_holding(&[high(1)]); + assert_eq!(key_eligibility(&usable, &filter, now), (true, false)); + + let elsewhere = identity_holding(&[bound_to( + high(1), + ContractBounds::SingleContract { + id: Identifier::new([9; 32]), + }, + )]); + assert_eq!(key_eligibility(&elsewhere, &filter, now), (false, false)); + + let mut bls = high(1); + bls.set_key_type(KeyType::BLS12_381); + assert_eq!( + key_eligibility(&identity_holding(&[bls]), &filter, now), + (false, false), + "a DashPay document is signed with an ECDSA key" + ); + + // Filed while enabled, disabled on the network since. + let mut disabled_live = identity_holding(&[high(1)]); + let mut disabled = high(1); + disabled.set_disabled_at(1); + disabled_live + .identity + .set_public_keys(std::collections::BTreeMap::from([(1, disabled)])); + assert_eq!( + key_eligibility(&disabled_live, &filter, now), + (false, false) + ); + + // Published only: guidance to load it, unless it could not sign anyway. + let mut public_only = identity_holding(&[]); + public_only + .identity + .set_public_keys(std::collections::BTreeMap::from([(1, high(1))])); + assert_eq!(key_eligibility(&public_only, &filter, now), (false, true)); + public_only + .identity + .set_public_keys(std::collections::BTreeMap::from([( + 1, + bound_to( + high(1), + ContractBounds::SingleContract { + id: Identifier::new([9; 32]), + }, + ), + )])); + assert_eq!(key_eligibility(&public_only, &filter, now), (false, false)); + } + + /// DashPay and username registration choosers offer the key types their + /// automatic choice takes; other transactions accept every key type. + #[test] + fn document_choosers_offer_the_key_types_their_writes_sign_with() { + use crate::model::dpns_usernames::DPNS_SIGNING_KEY_TYPES; + use crate::model::qualified_identity::DASHPAY_SIGNING_KEY_TYPES; + assert_eq!( + TransactionType::ContactRequest.allowed_key_types(), + Some(DASHPAY_SIGNING_KEY_TYPES) + ); + assert_eq!( + TransactionType::UsernameRegistration.allowed_key_types(), + Some(DPNS_SIGNING_KEY_TYPES) + ); + assert_eq!(TransactionType::DocumentAction.allowed_key_types(), None); + assert_eq!( + TransactionType::UsernameRegistration.allowed_purposes(), + vec![Purpose::AUTHENTICATION] + ); + } + fn outside_press() -> egui::RawInput { let outside_pos = egui::pos2(0.0, 0.0); egui::RawInput { diff --git a/src/ui/identity/register_dpns_name_screen.rs b/src/ui/identity/register_dpns_name_screen.rs index fe30c52b2..b9614fdec 100644 --- a/src/ui/identity/register_dpns_name_screen.rs +++ b/src/ui/identity/register_dpns_name_screen.rs @@ -606,7 +606,7 @@ impl RegisterDpnsNameScreen { &self.app_context, &identity, &mut self.selected_key, - TransactionType::DocumentAction, + TransactionType::UsernameRegistration, document_type.as_ref(), SigningScope::ContractWide { contract_id: self.app_context.dpns_contract().id(), From 961488b64ca55d26c2b326142f56050a4e144ef5 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:56:37 +0000 Subject: [PATCH 36/58] fix(tokens): sign token transfers with a TRANSFER key when no authentication key can Review fix (#1117): the automatic token-transfer key was only a CRITICAL AUTHENTICATION key, and the no-key gate counted only those. DPP also accepts a CRITICAL TRANSFER key for a batch of one token transfer or claim, so an identity holding only such a key was told it had no usable key. QualifiedIdentity::single_token_transition_key tries AUTHENTICATION, then TRANSFER, for both the transfer and claim screens; the transfer gate counts both purposes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- src/model/qualified_identity/mod.rs | 74 +++++++++++++++++++++++++ src/ui/tokens/claim_tokens_screen.rs | 19 +------ src/ui/tokens/transfer_tokens_screen.rs | 37 +++++-------- 3 files changed, 91 insertions(+), 39 deletions(-) diff --git a/src/model/qualified_identity/mod.rs b/src/model/qualified_identity/mod.rs index 2a1662fca..c092e868c 100644 --- a/src/model/qualified_identity/mod.rs +++ b/src/model/qualified_identity/mod.rs @@ -1270,6 +1270,40 @@ impl QualifiedIdentity { }) } + /// The key to sign a batch of one token transfer or claim on + /// `contract_id` with: a CRITICAL AUTHENTICATION key, else a CRITICAL + /// TRANSFER key, which DPP also accepts for such a batch (and requires + /// CRITICAL for), each picked as [`Self::signing_key_now`] picks. + pub fn single_token_transition_key( + &self, + contract_id: dash_sdk::platform::Identifier, + ) -> Option<&IdentityPublicKey> { + let scope = SigningScope::ContractWide { contract_id }; + [Purpose::AUTHENTICATION, Purpose::TRANSFER] + .into_iter() + .find_map(|purpose| { + self.signing_key_now(KeyRequirements::new( + purpose, + &[SecurityLevel::CRITICAL], + scope, + )) + }) + } + + /// Whether a private key of a purpose and level + /// [`Self::single_token_transition_key`] takes is filed for the identity: + /// the gate in front of the token transfer and claim forms. + pub fn holds_single_token_transition_key(&self) -> bool { + self.private_keys + .identity_public_keys() + .iter() + .any(|key_ref| { + let key = &key_ref.1.identity_public_key; + matches!(key.purpose(), Purpose::AUTHENTICATION | Purpose::TRANSFER) + && key.security_level() == SecurityLevel::CRITICAL + }) + } + /// The key [`select_identity_signing_key`] would pick for `requirements` /// now, whether or not this device holds its private half. For /// informational lookups only (e.g. pointing the user at a key whose @@ -3688,6 +3722,46 @@ mod protocol_v14_compat_tests { ); } + /// Review fix: a token transfer took only a CRITICAL AUTHENTICATION key, + /// so an identity holding only a CRITICAL TRANSFER key, which DPP also + /// accepts for a batch of one token transfer or claim, could not send. + #[test] + fn a_single_token_transition_falls_back_to_a_transfer_key() { + use crate::context::test_support::{auth_key, identity_holding}; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + + let contract_id = Identifier::new([0x70; 32]); + let transfer = |id: KeyID, level| { + let mut key = auth_key(id, level); + key.set_purpose(Purpose::TRANSFER); + key + }; + + let qi = identity_holding(&[transfer(1, SecurityLevel::CRITICAL)]); + assert_eq!( + qi.single_token_transition_key(contract_id).map(|k| k.id()), + Some(1) + ); + assert!(qi.holds_single_token_transition_key()); + + let qi = identity_holding(&[ + transfer(1, SecurityLevel::CRITICAL), + auth_key(2, SecurityLevel::CRITICAL), + ]); + assert_eq!( + qi.single_token_transition_key(contract_id).map(|k| k.id()), + Some(2), + "an authentication key first" + ); + + let qi = identity_holding(&[ + transfer(1, SecurityLevel::HIGH), + auth_key(2, SecurityLevel::HIGH), + ]); + assert!(qi.single_token_transition_key(contract_id).is_none()); + assert!(!qi.holds_single_token_transition_key()); + } + /// Review fix: the automatic username registration key was taken of any /// key type, so a BLS or EdDSA key with a lower id won over an ECDSA key. #[test] diff --git a/src/ui/tokens/claim_tokens_screen.rs b/src/ui/tokens/claim_tokens_screen.rs index d74fc661b..e9ff9bd18 100644 --- a/src/ui/tokens/claim_tokens_screen.rs +++ b/src/ui/tokens/claim_tokens_screen.rs @@ -1,6 +1,6 @@ use crate::backend_task::{BackendTaskSuccessResult, FeeResult}; use crate::model::fee_estimation::format_credits_as_dash; -use crate::model::identity_key_usability::{KeyRequirements, SigningScope}; +use crate::model::identity_key_usability::SigningScope; use crate::model::user_role::UserRole; use crate::ui::components::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; @@ -86,22 +86,7 @@ impl ClaimTokensScreen { let (selected_key, selected_wallet) = if let Some(ref id) = identity { let key = id - .signing_key_now(KeyRequirements::new( - Purpose::AUTHENTICATION, - &[SecurityLevel::CRITICAL], - SigningScope::ContractWide { - contract_id: identity_token_basic_info.contract_id, - }, - )) - .or_else(|| { - id.signing_key_now(KeyRequirements::new( - Purpose::TRANSFER, - &[SecurityLevel::CRITICAL], - SigningScope::ContractWide { - contract_id: identity_token_basic_info.contract_id, - }, - )) - }) + .single_token_transition_key(identity_token_basic_info.contract_id) .cloned(); let selected_wallet = get_selected_wallet(id, None, key.as_ref()).unwrap_or(None); diff --git a/src/ui/tokens/transfer_tokens_screen.rs b/src/ui/tokens/transfer_tokens_screen.rs index 2f007b139..67dcc917b 100644 --- a/src/ui/tokens/transfer_tokens_screen.rs +++ b/src/ui/tokens/transfer_tokens_screen.rs @@ -4,7 +4,7 @@ use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::amount::Amount; use crate::model::fee_estimation::format_credits_as_dash; -use crate::model::identity_key_usability::{KeyRequirements, SigningScope}; +use crate::model::identity_key_usability::SigningScope; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::user_role::UserRole; use crate::model::wallet::Wallet; @@ -96,14 +96,8 @@ impl TransferTokensScreen { let max_amount = Amount::from(&identity_token_balance); let selected_key: Option = identity.as_ref().and_then(|id| { - id.signing_key_now(KeyRequirements::new( - Purpose::AUTHENTICATION, - &[SecurityLevel::CRITICAL], - SigningScope::ContractWide { - contract_id: identity_token_balance.data_contract_id, - }, - )) - .cloned() + id.single_token_transition_key(identity_token_balance.data_contract_id) + .cloned() }); let selected_wallet = identity.as_ref().and_then(|id| { get_selected_wallet(id, None, selected_key.as_ref()) @@ -431,28 +425,27 @@ impl ScreenLike for TransferTokensScreen { let has_keys = if self.app_context.user_role().at_least(UserRole::Developer) { !identity.identity.public_keys().is_empty() } else { - !identity - .available_authentication_keys_with_critical_security_level() - .is_empty() + identity.holds_single_token_transition_key() }; if !has_keys { let identity_type = identity.identity_type; // Extract key data before releasing the borrow (needed for click handlers). - let key = identity - .identity - .get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ) + let key = [Purpose::AUTHENTICATION, Purpose::TRANSFER] + .into_iter() + .find_map(|purpose| { + identity.identity.get_first_public_key_matching( + purpose, + HashSet::from([SecurityLevel::CRITICAL]), + KeyType::all_key_types().into(), + false, + ) + }) .cloned(); ui.colored_label( DashColors::error_color(dark_mode), format!( - "You do not have any authentication keys with CRITICAL security level loaded for this {} identity.", - identity_type + "You do not have a CRITICAL authentication or transfer key loaded for this {identity_type} identity." ), ); ui.add_space(10.0); From 05dcfc6ae8ee489f26e922a8e8c2cbd397289e7e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:06:25 +0000 Subject: [PATCH 37/58] fix(dashpay): check the signing wallet's lock before sending from a scanned QR code Review fix (#1117): the QR scanner preselected an identity but no wallet, and picked the wallet only when the identity changed, so the default path never checked whether the wallet holding the DashPay signing key was locked and offered to send anyway. The constructor now picks that wallet for the preselected identity, the same way an identity change does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- src/ui/dashpay/qr_scanner.rs | 62 ++++++++++++++++++++++++++++++------ 1 file changed, 52 insertions(+), 10 deletions(-) diff --git a/src/ui/dashpay/qr_scanner.rs b/src/ui/dashpay/qr_scanner.rs index 6ee08a637..3826c5d3c 100644 --- a/src/ui/dashpay/qr_scanner.rs +++ b/src/ui/dashpay/qr_scanner.rs @@ -54,6 +54,10 @@ impl QRScannerScreen { }) .unwrap_or_default(); + let selected_wallet = selected_identity + .as_ref() + .and_then(|identity| signing_key_wallet(&app_context, identity)); + Self { app_context, selected_identity, @@ -61,7 +65,7 @@ impl QRScannerScreen { qr_data_input: String::new(), parsed_qr_data: None, sending: false, - selected_wallet: None, + selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), wallet_open_attempted: false, } @@ -194,15 +198,10 @@ impl QRScannerScreen { // Update wallet if identity changed let new_identity_id = self.selected_identity.as_ref().map(|i| i.identity.id()); if prev_identity_id != new_identity_id { - if let Some(identity) = &self.selected_identity { - let key = identity.dashpay_signing_key( - &self.app_context.dashpay_contract(), - "contactRequest", - ); - self.selected_wallet = wallet_for_signing_key(identity, key); - } else { - self.selected_wallet = None; - } + self.selected_wallet = self + .selected_identity + .as_ref() + .and_then(|identity| signing_key_wallet(&self.app_context, identity)); self.wallet_open_attempted = false; } }); @@ -389,3 +388,46 @@ fn now_secs() -> u64 { .map(|elapsed| elapsed.as_secs()) .unwrap_or_default() } + +/// The wallet holding the key a contact request from `identity` is signed +/// with (the key [`QRScannerScreen::send_contact_request_with_proof`] takes), +/// for the unlock prompt. +fn signing_key_wallet( + app_context: &AppContext, + identity: &QualifiedIdentity, +) -> Option>> { + let key = identity.dashpay_signing_key(&app_context.dashpay_contract(), "contactRequest"); + wallet_for_signing_key(identity, key) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Review fix: the screen preselected an identity but no wallet, and + /// picked the wallet only when the identity changed, so the default path + /// never checked whether the wallet holding the DashPay signing key was + /// locked before offering to send. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn the_preselected_identity_brings_its_signing_key_wallet() { + use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; + use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; + let dir = tempfile::tempdir().expect("tempdir"); + let ctx = crate::context::test_support::wired_test_app_context(dir.path()).await; + let (identity, wallet) = crate::context::test_support::identity_with_bound_wallet_key( + ContractBounds::SingleContract { + id: ctx.dashpay_contract().id(), + }, + ); + let seed_hash = wallet.read().expect("wallet").seed_hash(); + ctx.wallet_context().insert_test_wallet(seed_hash, wallet); + ctx.insert_local_qualified_identity(&identity, &None) + .expect("store identity"); + + let screen = QRScannerScreen::new(ctx); + let selected = screen + .selected_wallet + .expect("the wallet holding the DashPay signing key"); + assert_eq!(selected.read().expect("wallet").seed_hash(), seed_hash); + } +} From 0f489a9e39b5ee4f7c19314a4e94e9c298059065 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:06:25 +0000 Subject: [PATCH 38/58] fix(context): own an unlinked identity by its signing-key wallet first Review fix (#1117): an identity stored without a wallet link was owned by the wallet of its lowest key id, no longer by the wallet of its username signing key, so for keys from two wallets Add Identity could preselect the other wallet. The signing-key wallet comes first again; the any-key scan stays as the fallback for identities whose keys are all bound elsewhere. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- src/context/mod.rs | 55 +++++++++++++++++++++++++++++++++++------ src/model/wallet/mod.rs | 6 ++++- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/src/context/mod.rs b/src/context/mod.rs index 40e472276..b24befebf 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -1566,19 +1566,24 @@ impl AppContext { self.selected_single_key_hash.lock().ok().and_then(|g| *g) } - /// Stored owner, falling back for legacy records to the loaded wallet that - /// derives one of the identity's keys, whatever the key is bound to. + /// Stored owner, falling back for legacy records to the wallet of the + /// identity's username signing key, then to the loaded wallet that derives + /// any of its keys, whatever the key is bound to. fn owning_wallet_hash(&self, id: Identifier) -> Option { if let Ok(Some((hash, _))) = self.stored_identity_wallet_link(&id) { return Some(hash); } let identities = self.load_local_qualified_identities().ok()?; let qi = identities.into_iter().find(|qi| qi.identity.id() == id)?; - let wallet = qi - .identity - .public_keys() - .values() - .find_map(|key| crate::ui::identity::wallet_for_signing_key(&qi, Some(key)))?; + let wallet = crate::ui::identity::get_selected_wallet(&qi, Some(self), None) + .ok() + .flatten() + .or_else(|| { + qi.identity + .public_keys() + .values() + .find_map(|key| crate::ui::identity::wallet_for_signing_key(&qi, Some(key))) + })?; let hash = wallet.read().ok()?.seed_hash(); Some(hash) } @@ -2577,4 +2582,40 @@ mod tests { Some(seed_hash) ); } + + /// Review fix: an unlinked identity whose keys come from two wallets was + /// owned by the wallet of its lowest key id, no longer by the wallet of + /// its DPNS signing key as before, so Add Identity could preselect the + /// other wallet. The signing-key wallet comes first again. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn an_unlinked_identity_is_owned_by_its_signing_key_wallet_first() { + use crate::context::test_support::{auth_key, hold_as_wallet_reference}; + use dash_sdk::dpp::identity::SecurityLevel; + use dash_sdk::dpp::identity::accessors::IdentitySettersV0; + use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; + let dir = tempfile::tempdir().expect("tempdir"); + let ctx = crate::context::test_support::wired_test_app_context(dir.path()).await; + // Key 1, bound to DashPay, from wallet A; key 2, the DPNS signing + // key, from wallet B. + let (mut identity, wallet_a) = crate::context::test_support::identity_with_bound_wallet_key( + ContractBounds::SingleContract { + id: ctx.dashpay_contract().id(), + }, + ); + let wallet_b = crate::model::wallet::test_support::open_wallet_on([7; 64]); + let hash_a = wallet_a.read().expect("wallet").seed_hash(); + let hash_b = wallet_b.seed_hash(); + assert_ne!(hash_a, hash_b); + let mut keys = identity.identity.public_keys().clone(); + keys.insert(2, auth_key(2, SecurityLevel::HIGH)); + identity.identity.set_public_keys(keys); + hold_as_wallet_reference(&mut identity, 2, hash_b); + ctx.wallet_context().insert_test_wallet(hash_a, wallet_a); + ctx.wallet_context() + .insert_test_wallet(hash_b, Arc::new(std::sync::RwLock::new(wallet_b))); + ctx.insert_local_qualified_identity(&identity, &None) + .expect("store identity"); + + assert_eq!(ctx.owning_wallet_hash(identity.identity.id()), Some(hash_b)); + } } diff --git a/src/model/wallet/mod.rs b/src/model/wallet/mod.rs index d3e593705..bccb01626 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -2327,7 +2327,11 @@ pub(crate) mod test_support { /// A minimal open wallet on [`TEST_SEED`], with its BIP44 master public key /// derived and every optional field left empty. pub(crate) fn open_wallet() -> Wallet { - let seed = TEST_SEED; + open_wallet_on(TEST_SEED) + } + + /// [`open_wallet`] on `seed`, for a test that needs two distinct wallets. + pub(crate) fn open_wallet_on(seed: [u8; 64]) -> Wallet { let network = Network::Testnet; // Derive master private key from seed From b5003c96787512f45095b7c75147594af64eb16d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:24:35 +0000 Subject: [PATCH 39/58] test(kittest): register usernames with an ECDSA fixture key The username fixture took `random_key`'s key type, which can be BLS or BIP13. Username registration takes ECDSA keys only since b693a12fb1, so the fixture pins an ECDSA_SECP256K1 key. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- tests/kittest/identity_usernames.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/kittest/identity_usernames.rs b/tests/kittest/identity_usernames.rs index eef8d09eb..bd78a624a 100644 --- a/tests/kittest/identity_usernames.rs +++ b/tests/kittest/identity_usernames.rs @@ -54,6 +54,15 @@ pub(crate) fn seed_username_identity( key.set_id(1); key.set_purpose(Purpose::AUTHENTICATION); key.set_security_level(SecurityLevel::HIGH); + // Usernames are registered with ECDSA keys only; `random_key` may pick + // any key type. + key.set_key_type(dash_sdk::dpp::identity::KeyType::ECDSA_SECP256K1); + key.set_data( + std::iter::once(2) + .chain(std::iter::repeat_n(byte, 32)) + .collect::>() + .into(), + ); let mut identity = Identity::new_with_id_and_keys( Identifier::from([byte; 32]), BTreeMap::from([(key.id(), key.clone())]), From 294d51d7df900dfcf6ee9ca75fb1333f657aca20 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:03:50 +0000 Subject: [PATCH 40/58] fix(tokens): gate token claims like their key choice and skip keys Platform refuses Pre-push review fixes (#1117): - The claim screen picks a CRITICAL AUTHENTICATION key, else a CRITICAL TRANSFER key, but its gate counted only authentication keys for a user identity, so one holding only a transfer key was told it had no key. The gate now takes the same kind of key. - The single token transition key and its gate took any key type, so a CRITICAL EdDSA authentication key, which Platform refuses, shadowed a usable transfer key. Both, and the token transfer and claim choosers, take only the key types Platform verifies (PLATFORM_SIGNING_KEY_TYPES). - Document that DPNS leaves BLS keys out for upstream parity, not as a Platform rule, and that the username readiness gate keeps the automatic rule, so an identity with only expired or contract-group keys never reaches the chooser. - Test docstrings follow the repo's "Regression:" convention. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ujJpB9DBhvtbjhYB3aG9Q --- src/context/mod.rs | 2 +- src/model/dpns_usernames.rs | 28 +++++++++---- src/model/identity_key_usability.rs | 14 ++++++- src/model/qualified_identity/mod.rs | 63 ++++++++++++++++++++++------ src/ui/dashpay/qr_scanner.rs | 2 +- src/ui/helpers.rs | 16 +++++-- src/ui/tokens/claim_tokens_screen.rs | 50 +++++++++++++++++----- 7 files changed, 137 insertions(+), 38 deletions(-) diff --git a/src/context/mod.rs b/src/context/mod.rs index b24befebf..848ceaa50 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -2583,7 +2583,7 @@ mod tests { ); } - /// Review fix: an unlinked identity whose keys come from two wallets was + /// Regression: an unlinked identity whose keys come from two wallets was /// owned by the wallet of its lowest key id, no longer by the wallet of /// its DPNS signing key as before, so Add Identity could preselect the /// other wallet. The signing-key wallet comes first again. diff --git a/src/model/dpns_usernames.rs b/src/model/dpns_usernames.rs index 93169c123..0447040b6 100644 --- a/src/model/dpns_usernames.rs +++ b/src/model/dpns_usernames.rs @@ -142,6 +142,12 @@ pub fn key_can_sign_documents( /// network time, not this device's clock. So is a key bound to a contract /// group: whether the group covers DPNS is Platform state, so only a key /// certainly bound elsewhere is refused here. +/// +/// By design, the relaxation reaches only an identity that can register at +/// all: [`registration_key_status`] applies the automatic rule +/// ([`key_can_sign_documents`]), so an identity whose only registration-capable +/// keys are expired or contract-group bound is told it has no suitable key and +/// never reaches the chooser that would offer them. // TODO(upstream): call platform-wallet's public explicit-key validation once it // exists (`resolve_authentication_signing_key` is private) and route the // registration through `register_name_with_signer`. @@ -161,11 +167,17 @@ pub fn chosen_key_can_sign_documents( } /// The key types a username registration is signed with: ECDSA keys, as -/// upstream platform-wallet's DPNS writes require (Platform refuses EdDSA -/// outright). `ECDSA_HASH160` is taken too, as for DashPay -/// ([`super::qualified_identity::DASHPAY_SIGNING_KEY_TYPES`]): Platform accepts -/// it and DET signed with it before, so dropping it would strand identities -/// whose only document key is a hash160 key. +/// upstream platform-wallet's DPNS writes require. `ECDSA_HASH160` is taken +/// too, as for DashPay ([`super::qualified_identity::DASHPAY_SIGNING_KEY_TYPES`]): +/// Platform accepts it and DET signed with it before, so dropping it would +/// strand identities whose only document key is a hash160 key. +/// +/// `BLS12_381` is left out for parity with upstream, not because Platform +/// refuses it: Platform verifies BLS signatures +/// ([`super::identity_key_usability::PLATFORM_SIGNING_KEY_TYPES`]) and DET can +/// sign with a BLS key, so an identity whose only registration-capable key is +/// a BLS key cannot register a username here. EdDSA and BIP13 keys Platform +/// refuses outright. // TODO(upstream): take the key types from platform-wallet's public DPNS key // selector once it exists, instead of restating them here. pub const DPNS_SIGNING_KEY_TYPES: &[dash_sdk::dpp::identity::KeyType] = &[ @@ -1269,7 +1281,7 @@ mod tests { assert!(!chosen_key_can_sign_documents(&disabled, high)); } - /// Review fix: a username registration was signed with any key type, so a + /// Regression: a username registration was signed with any key type, so a /// BLS or EdDSA key could be picked although upstream platform-wallet's /// DPNS writes sign with ECDSA keys only, and Platform refuses EdDSA. #[test] @@ -1308,7 +1320,7 @@ mod tests { } } - /// Review fix: a contract-group bound key the user chose was refused here, + /// Regression: a contract-group bound key the user chose was refused here, /// though whether the group covers DPNS is only known to Platform. The /// automatic choice still never takes one; a key bound elsewhere is /// refused either way. @@ -1332,7 +1344,7 @@ mod tests { assert!(!chosen_key_can_sign_documents(&elsewhere, high)); } - /// Review fix: readiness judged the stored copy of a key, so a key whose + /// Regression: readiness judged the stored copy of a key, so a key whose /// expiry Platform has since extended was reported unusable, and its /// missing wallet was not reported as what would lift the refusal. #[test] diff --git a/src/model/identity_key_usability.rs b/src/model/identity_key_usability.rs index 8bb98acf7..b62e547c7 100644 --- a/src/model/identity_key_usability.rs +++ b/src/model/identity_key_usability.rs @@ -24,6 +24,18 @@ use dash_sdk::dpp::identity::{ }; use dash_sdk::platform::Identifier; +/// The key types Platform verifies an identity-signed state transition's +/// signature with (drive-abci's `SUPPORTED_KEY_TYPES`), all of which DET's +/// signer signs with. Platform refuses `EDDSA_25519_HASH160` and +/// `BIP13_SCRIPT_HASH` keys, whatever the transition. +// TODO(upstream): take the set from dpp once it exposes one; drive-abci's +// is private. +pub const PLATFORM_SIGNING_KEY_TYPES: &[KeyType] = &[ + KeyType::ECDSA_SECP256K1, + KeyType::ECDSA_HASH160, + KeyType::BLS12_381, +]; + /// What a signature authorizes, as far as contract bounds are concerned. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SigningScope<'a> { @@ -284,7 +296,7 @@ mod tests { use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dash_sdk::dpp::platform_value::BinaryData; - /// Review fix: a document type requiring MASTER accepted no level, as + /// Regression: a document type requiring MASTER accepted no level, as /// MASTER sorts below CRITICAL. Platform accepts exactly MASTER for it, /// as upstream platform-wallet's `allowed_signing_security_levels` does. #[test] diff --git a/src/model/qualified_identity/mod.rs b/src/model/qualified_identity/mod.rs index c092e868c..b1b55bb8d 100644 --- a/src/model/qualified_identity/mod.rs +++ b/src/model/qualified_identity/mod.rs @@ -10,7 +10,8 @@ pub mod qualified_identity_public_key; use crate::backend_task::error::{SigningKeyUnavailableCause, TaskError}; use crate::model::dpns_usernames::DPNS_SIGNING_KEY_TYPES; use crate::model::identity_key_usability::{ - KeyRequirements, SigningScope, document_security_levels, now_ms, select_identity_signing_key, + KeyRequirements, PLATFORM_SIGNING_KEY_TYPES, SigningScope, document_security_levels, now_ms, + select_identity_signing_key, }; use crate::model::qualified_identity::encrypted_key_storage::{ KeyStorage, ResolvedPrivateKey, same_key, @@ -585,6 +586,11 @@ const IDENTITY_BLOB_DECODE_LIMIT: usize = 16 * 1024 * 1024; // 16 MiB pub const DASHPAY_SIGNING_KEY_TYPES: &[KeyType] = &[KeyType::ECDSA_SECP256K1, KeyType::ECDSA_HASH160]; +/// The key purposes DPP accepts for a batch of one token transfer or claim, +/// in the order [`QualifiedIdentity::single_token_transition_key`] tries them +/// (`BatchTransitionV1::purpose_requirement`; TRANSFER requires CRITICAL). +const SINGLE_TOKEN_TRANSITION_PURPOSES: [Purpose; 2] = [Purpose::AUTHENTICATION, Purpose::TRANSFER]; + /// The bincode configuration [`QualifiedIdentity::from_bytes`] decodes under. /// Pulled into its own function (rather than inlined at the one call site) so /// the decode-limit regression test below exercises the *exact* configuration @@ -1279,28 +1285,30 @@ impl QualifiedIdentity { contract_id: dash_sdk::platform::Identifier, ) -> Option<&IdentityPublicKey> { let scope = SigningScope::ContractWide { contract_id }; - [Purpose::AUTHENTICATION, Purpose::TRANSFER] + SINGLE_TOKEN_TRANSITION_PURPOSES .into_iter() .find_map(|purpose| { - self.signing_key_now(KeyRequirements::new( - purpose, - &[SecurityLevel::CRITICAL], - scope, - )) + self.signing_key_now( + KeyRequirements::new(purpose, &[SecurityLevel::CRITICAL], scope) + .with_key_types(PLATFORM_SIGNING_KEY_TYPES), + ) }) } - /// Whether a private key of a purpose and level + /// Whether a private key of a purpose, level and key type /// [`Self::single_token_transition_key`] takes is filed for the identity: - /// the gate in front of the token transfer and claim forms. + /// the gate in front of the token transfer form, and of the claim form + /// for a user identity. Expiry and contract bounds are left to the key + /// chooser's warnings, as for every other form. pub fn holds_single_token_transition_key(&self) -> bool { self.private_keys .identity_public_keys() .iter() .any(|key_ref| { let key = &key_ref.1.identity_public_key; - matches!(key.purpose(), Purpose::AUTHENTICATION | Purpose::TRANSFER) + SINGLE_TOKEN_TRANSITION_PURPOSES.contains(&key.purpose()) && key.security_level() == SecurityLevel::CRITICAL + && PLATFORM_SIGNING_KEY_TYPES.contains(&key.key_type()) }) } @@ -3479,7 +3487,7 @@ mod protocol_v14_compat_tests { ); } - /// Review fix: the automatic choice took any key with a private half + /// Regression: the automatic choice took any key with a private half /// filed, so an unlimited key filed under a wallet this device does not /// hold, or in the legacy encrypted form, won over a usable limited key, /// and signing then failed instead of falling back to the usable key. @@ -3722,7 +3730,7 @@ mod protocol_v14_compat_tests { ); } - /// Review fix: a token transfer took only a CRITICAL AUTHENTICATION key, + /// Regression: a token transfer took only a CRITICAL AUTHENTICATION key, /// so an identity holding only a CRITICAL TRANSFER key, which DPP also /// accepts for a batch of one token transfer or claim, could not send. #[test] @@ -3762,7 +3770,36 @@ mod protocol_v14_compat_tests { assert!(!qi.holds_single_token_transition_key()); } - /// Review fix: the automatic username registration key was taken of any + /// Regression: the single token transition key took any key type, so a + /// CRITICAL EdDSA authentication key, which Platform refuses, shadowed a + /// usable CRITICAL TRANSFER key, and the gate counted it as usable. + #[test] + fn a_single_token_transition_skips_a_key_platform_cannot_verify() { + use crate::context::test_support::{auth_key, identity_holding}; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + + let mut eddsa = auth_key(1, SecurityLevel::CRITICAL); + eddsa.set_key_type(KeyType::EDDSA_25519_HASH160); + let mut transfer = auth_key(2, SecurityLevel::CRITICAL); + transfer.set_purpose(Purpose::TRANSFER); + + let qi = identity_holding(&[eddsa.clone(), transfer]); + assert_eq!( + qi.single_token_transition_key(Identifier::new([0x70; 32])) + .map(|k| k.id()), + Some(2) + ); + assert!(!identity_holding(&[eddsa]).holds_single_token_transition_key()); + + let mut bls = auth_key(3, SecurityLevel::CRITICAL); + bls.set_key_type(KeyType::BLS12_381); + assert!( + identity_holding(&[bls]).holds_single_token_transition_key(), + "Platform verifies BLS signatures" + ); + } + + /// Regression: the automatic username registration key was taken of any /// key type, so a BLS or EdDSA key with a lower id won over an ECDSA key. #[test] fn a_username_registration_key_is_an_ecdsa_key() { diff --git a/src/ui/dashpay/qr_scanner.rs b/src/ui/dashpay/qr_scanner.rs index 3826c5d3c..f6223dd82 100644 --- a/src/ui/dashpay/qr_scanner.rs +++ b/src/ui/dashpay/qr_scanner.rs @@ -404,7 +404,7 @@ fn signing_key_wallet( mod tests { use super::*; - /// Review fix: the screen preselected an identity but no wallet, and + /// Regression: the screen preselected an identity but no wallet, and /// picked the wallet only when the identity changed, so the default path /// never checked whether the wallet holding the DashPay signing key was /// locked before offering to send. diff --git a/src/ui/helpers.rs b/src/ui/helpers.rs index 102bc7479..372494b9c 100644 --- a/src/ui/helpers.rs +++ b/src/ui/helpers.rs @@ -369,14 +369,15 @@ impl TransactionType { TransactionType::UsernameRegistration => { Some(crate::model::dpns_usernames::DPNS_SIGNING_KEY_TYPES) } + TransactionType::TokenTransfer | TransactionType::TokenClaim => { + Some(crate::model::identity_key_usability::PLATFORM_SIGNING_KEY_TYPES) + } TransactionType::RegisterContract | TransactionType::UpdateContract | TransactionType::Transfer | TransactionType::Withdraw | TransactionType::DocumentAction - | TransactionType::TokenAction - | TransactionType::TokenTransfer - | TransactionType::TokenClaim => None, + | TransactionType::TokenAction => None, } } @@ -1311,7 +1312,7 @@ mod tests { } } - /// Review fix: the chooser gate counted any filed key of the right purpose + /// Regression: the chooser gate counted any filed key of the right purpose /// and level, while the list left out disabled keys and keys bound /// elsewhere, so an identity holding only such a key got an empty list /// instead of the no-usable-key guidance. Both now apply one predicate, @@ -1397,6 +1398,13 @@ mod tests { Some(DPNS_SIGNING_KEY_TYPES) ); assert_eq!(TransactionType::DocumentAction.allowed_key_types(), None); + for token in [TransactionType::TokenTransfer, TransactionType::TokenClaim] { + assert_eq!( + token.allowed_key_types(), + Some(crate::model::identity_key_usability::PLATFORM_SIGNING_KEY_TYPES), + "{token:?}" + ); + } assert_eq!( TransactionType::UsernameRegistration.allowed_purposes(), vec![Purpose::AUTHENTICATION] diff --git a/src/ui/tokens/claim_tokens_screen.rs b/src/ui/tokens/claim_tokens_screen.rs index e9ff9bd18..573168d43 100644 --- a/src/ui/tokens/claim_tokens_screen.rs +++ b/src/ui/tokens/claim_tokens_screen.rs @@ -341,22 +341,15 @@ impl ScreenLike for ClaimTokensScreen { let has_keys = if self.app_context.user_role().at_least(UserRole::Developer) { !identity.identity.public_keys().is_empty() } else { - match identity.identity_type { - IdentityType::User => !identity - .available_authentication_keys_with_critical_security_level() - .is_empty(), - IdentityType::Masternode | IdentityType::Evonode => { - !identity.available_transfer_keys().is_empty() - } - } + holds_claim_key(identity) }; if !has_keys { ui.colored_label( Color32::RED, format!( - "No authentication keys with CRITICAL security level found for this {} identity.", - identity.identity_type, + "No CRITICAL authentication or transfer key is loaded for this {identity_type} identity.", + identity_type = identity.identity_type, ), ); ui.add_space(10.0); @@ -622,3 +615,40 @@ impl ScreenLike for ClaimTokensScreen { action } } + +/// Whether `identity` holds a key the claim form can sign with, the gate in +/// front of the form outside developer mode: for a user identity, a key of +/// the kind [`QualifiedIdentity::single_token_transition_key`] picks. +fn holds_claim_key(identity: &QualifiedIdentity) -> bool { + match identity.identity_type { + IdentityType::User => identity.holds_single_token_transition_key(), + IdentityType::Masternode | IdentityType::Evonode => { + !identity.available_transfer_keys().is_empty() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + + /// Regression: the claim key falls back to a CRITICAL TRANSFER key, but + /// the gate in front of the form counted only CRITICAL AUTHENTICATION + /// keys for a user identity, so one holding only a transfer key was told + /// it had no key. + #[test] + fn the_claim_gate_admits_the_keys_the_claim_signs_with() { + use crate::context::test_support::{auth_key, identity_holding}; + let mut transfer = auth_key(1, SecurityLevel::CRITICAL); + transfer.set_purpose(Purpose::TRANSFER); + let identity = identity_holding(&[transfer]); + assert_eq!(identity.identity_type, IdentityType::User); + assert!(holds_claim_key(&identity)); + + assert!(!holds_claim_key(&identity_holding(&[auth_key( + 1, + SecurityLevel::HIGH + )]))); + } +} From 895ff8a96fea4a301043b80e61b005aa5b4cee58 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:46:16 +0000 Subject: [PATCH 41/58] fix(identity): gate token forms on the key the transition signs with The token transfer and claim forms opened whenever a CRITICAL key was filed, while the transition signs only with a live key valid for the token's contract whose private half this device can read. An identity holding only an expired, elsewhere-bound or unreadable key got a form it could not send. The gate now opens exactly when the key choice picks a key, and says why no key on this device can sign when one is filed. Signing requirements and key choosers default to the key types Platform verifies, so no caller can pick an EdDSA or BIP13 key Platform refuses. Co-Authored-By: Claude Opus 5.5 --- src/model/identity_key_usability.rs | 47 +++++++++- src/model/qualified_identity/mod.rs | 112 ++++++++++++++++++++++-- src/ui/helpers.rs | 48 ++++++++-- src/ui/tokens/claim_tokens_screen.rs | 111 ++++++++++++++++++----- src/ui/tokens/transfer_tokens_screen.rs | 26 ++++-- 5 files changed, 299 insertions(+), 45 deletions(-) diff --git a/src/model/identity_key_usability.rs b/src/model/identity_key_usability.rs index b62e547c7..0cc4bc71a 100644 --- a/src/model/identity_key_usability.rs +++ b/src/model/identity_key_usability.rs @@ -200,7 +200,8 @@ pub fn document_security_levels(document_type: DocumentTypeRef<'_>) -> Vec { pub purpose: Purpose, pub security_levels: &'a [SecurityLevel], - /// `None` accepts every key type. + /// `None` accepts every key type. [`Self::new`] sets + /// [`PLATFORM_SIGNING_KEY_TYPES`]. pub key_types: Option<&'a [KeyType]>, pub scope: SigningScope<'a>, } @@ -247,7 +248,8 @@ pub fn select_signing_key<'k>( } impl<'a> KeyRequirements<'a> { - /// Requirements accepting every key type. + /// Requirements accepting the key types Platform verifies signatures + /// with, [`PLATFORM_SIGNING_KEY_TYPES`]. pub const fn new( purpose: Purpose, security_levels: &'a [SecurityLevel], @@ -256,12 +258,12 @@ impl<'a> KeyRequirements<'a> { Self { purpose, security_levels, - key_types: None, + key_types: Some(PLATFORM_SIGNING_KEY_TYPES), scope, } } - /// Restricts the accepted key types. + /// Accepts only `key_types` instead of the default. pub const fn with_key_types(mut self, key_types: &'a [KeyType]) -> Self { self.key_types = Some(key_types); self @@ -293,6 +295,7 @@ pub fn now_ms() -> TimestampMillis { #[cfg(test)] mod tests { use super::*; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dash_sdk::dpp::platform_value::BinaryData; @@ -476,6 +479,42 @@ mod tests { ); } + /// Regression: requirements accepted every key type unless a caller + /// narrowed them, so a caller that did not picked an EdDSA or BIP13 key, + /// which Platform refuses for every identity-signed transition. + #[test] + fn requirements_take_only_keys_platform_verifies_by_default() { + let typed = |id: u32, key_type: KeyType| { + let mut k = key(id, None); + k.set_key_type(key_type); + k + }; + let eddsa = typed(0, KeyType::EDDSA_25519_HASH160); + let bip13 = typed(1, KeyType::BIP13_SCRIPT_HASH); + let bls = typed(2, KeyType::BLS12_381); + let defaults = + KeyRequirements::new(Purpose::AUTHENTICATION, &[SecurityLevel::HIGH], doc_scope()); + assert_eq!( + select_signing_key([&eddsa, &bip13], defaults, NOW, |_| true), + None + ); + assert_eq!( + select_signing_key([&eddsa, &bip13, &bls], defaults, NOW, |_| true).map(|k| k.id()), + Some(2) + ); + assert_eq!( + select_signing_key( + [&eddsa], + defaults.with_key_types(&[KeyType::EDDSA_25519_HASH160]), + NOW, + |_| true + ) + .map(|k| k.id()), + Some(0), + "an explicit key type list overrides the default" + ); + } + #[test] fn expiry_is_reached_at_the_instant_itself() { let k = key(1, None).with_limits(None, Some(NOW)); diff --git a/src/model/qualified_identity/mod.rs b/src/model/qualified_identity/mod.rs index b1b55bb8d..8a96019d5 100644 --- a/src/model/qualified_identity/mod.rs +++ b/src/model/qualified_identity/mod.rs @@ -591,6 +591,23 @@ pub const DASHPAY_SIGNING_KEY_TYPES: &[KeyType] = /// (`BatchTransitionV1::purpose_requirement`; TRANSFER requires CRITICAL). const SINGLE_TOKEN_TRANSITION_PURPOSES: [Purpose; 2] = [Purpose::AUTHENTICATION, Purpose::TRANSFER]; +/// Whether an identity can sign a batch of one token transfer or claim on a +/// contract on this device, as +/// [`QualifiedIdentity::single_token_transition_key_status`] reports it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SingleTokenKeyStatus { + /// [`QualifiedIdentity::single_token_transition_key`] picks a key. + Usable, + /// A private key of a purpose, level and key type the transition takes + /// is filed, but none can sign it now: each is disabled or expired, bound + /// to another contract, or its private half is not readable on this + /// device. + HeldButUnusable, + /// No private key of a purpose, level and key type the transition takes + /// is filed. + NoneHeld, +} + /// The bincode configuration [`QualifiedIdentity::from_bytes`] decodes under. /// Pulled into its own function (rather than inlined at the one call site) so /// the decode-limit regression test below exercises the *exact* configuration @@ -1295,12 +1312,29 @@ impl QualifiedIdentity { }) } + /// Whether this device can sign a batch of one token transfer or claim on + /// `contract_id`: the gate in front of the token transfer form, and of + /// the claim form for a user identity. It opens exactly when + /// [`Self::single_token_transition_key`] picks a key, so an open form can + /// always sign; when closed, it tells a missing key apart from keys that + /// cannot sign here, which need different guidance. + pub fn single_token_transition_key_status( + &self, + contract_id: dash_sdk::platform::Identifier, + ) -> SingleTokenKeyStatus { + if self.single_token_transition_key(contract_id).is_some() { + SingleTokenKeyStatus::Usable + } else if self.holds_single_token_transition_key() { + SingleTokenKeyStatus::HeldButUnusable + } else { + SingleTokenKeyStatus::NoneHeld + } + } + /// Whether a private key of a purpose, level and key type - /// [`Self::single_token_transition_key`] takes is filed for the identity: - /// the gate in front of the token transfer form, and of the claim form - /// for a user identity. Expiry and contract bounds are left to the key - /// chooser's warnings, as for every other form. - pub fn holds_single_token_transition_key(&self) -> bool { + /// [`Self::single_token_transition_key`] takes is filed for the identity, + /// whether or not it can sign now. + fn holds_single_token_transition_key(&self) -> bool { self.private_keys .identity_public_keys() .iter() @@ -3799,6 +3833,74 @@ mod protocol_v14_compat_tests { ); } + /// Regression: the token transfer and claim gate counted any filed + /// CRITICAL key, while the transition signs only with a live key valid + /// for the contract whose private half this device can read, so an + /// identity holding only an expired, elsewhere-bound or unreadable key got + /// a form it could not send. The gate opens exactly when the key choice + /// picks a key. + #[test] + fn the_single_token_gate_opens_exactly_when_a_key_is_picked() { + use crate::context::test_support::{auth_key, bound_to, identity_holding}; + use crate::model::qualified_identity::encrypted_key_storage::PrivateKeyData; + use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; + + let contract_id = Identifier::new([0x70; 32]); + let critical = |id| auth_key(id, SecurityLevel::CRITICAL); + let expired = identity_holding(&[critical(1).with_limits(None, Some(1))]); + let elsewhere = identity_holding(&[bound_to( + critical(1), + ContractBounds::SingleContract { + id: Identifier::new([0x09; 32]), + }, + )]); + let mut unreadable = identity_holding(&[critical(1)]); + unreadable.private_keys.insert_at( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, 1), + ( + QualifiedIdentityPublicKey { + identity_public_key: critical(1), + in_wallet_at_derivation_path: None, + }, + PrivateKeyData::Encrypted(vec![1; 48]), + ), + ); + for (case, qi) in [ + ("expired", &expired), + ("bound elsewhere", &elsewhere), + ("not readable here", &unreadable), + ] { + assert!( + qi.single_token_transition_key(contract_id).is_none(), + "{case}" + ); + assert_eq!( + qi.single_token_transition_key_status(contract_id), + SingleTokenKeyStatus::HeldButUnusable, + "{case}" + ); + } + + let bound_here = identity_holding(&[bound_to( + critical(1), + ContractBounds::SingleContract { id: contract_id }, + )]); + assert!( + bound_here + .single_token_transition_key(contract_id) + .is_some() + ); + assert_eq!( + bound_here.single_token_transition_key_status(contract_id), + SingleTokenKeyStatus::Usable + ); + assert_eq!( + identity_holding(&[auth_key(1, SecurityLevel::HIGH)]) + .single_token_transition_key_status(contract_id), + SingleTokenKeyStatus::NoneHeld + ); + } + /// Regression: the automatic username registration key was taken of any /// key type, so a BLS or EdDSA key with a lower id won over an ECDSA key. #[test] diff --git a/src/ui/helpers.rs b/src/ui/helpers.rs index 372494b9c..fcdd9dcd4 100644 --- a/src/ui/helpers.rs +++ b/src/ui/helpers.rs @@ -361,6 +361,7 @@ impl TransactionType { /// The key types this transaction type is signed with, or `None` when /// every key type is accepted: the ones its automatic key choice takes. + /// Never a type Platform refuses. pub fn allowed_key_types(&self) -> Option<&'static [KeyType]> { match self { TransactionType::ContactRequest => { @@ -369,15 +370,16 @@ impl TransactionType { TransactionType::UsernameRegistration => { Some(crate::model::dpns_usernames::DPNS_SIGNING_KEY_TYPES) } - TransactionType::TokenTransfer | TransactionType::TokenClaim => { - Some(crate::model::identity_key_usability::PLATFORM_SIGNING_KEY_TYPES) - } - TransactionType::RegisterContract + TransactionType::TokenTransfer + | TransactionType::TokenClaim + | TransactionType::RegisterContract | TransactionType::UpdateContract | TransactionType::Transfer | TransactionType::Withdraw | TransactionType::DocumentAction - | TransactionType::TokenAction => None, + | TransactionType::TokenAction => { + Some(crate::model::identity_key_usability::PLATFORM_SIGNING_KEY_TYPES) + } } } @@ -1384,7 +1386,7 @@ mod tests { } /// DashPay and username registration choosers offer the key types their - /// automatic choice takes; other transactions accept every key type. + /// automatic choice takes; other transactions the types Platform verifies. #[test] fn document_choosers_offer_the_key_types_their_writes_sign_with() { use crate::model::dpns_usernames::DPNS_SIGNING_KEY_TYPES; @@ -1397,7 +1399,10 @@ mod tests { TransactionType::UsernameRegistration.allowed_key_types(), Some(DPNS_SIGNING_KEY_TYPES) ); - assert_eq!(TransactionType::DocumentAction.allowed_key_types(), None); + assert_eq!( + TransactionType::DocumentAction.allowed_key_types(), + Some(crate::model::identity_key_usability::PLATFORM_SIGNING_KEY_TYPES) + ); for token in [TransactionType::TokenTransfer, TransactionType::TokenClaim] { assert_eq!( token.allowed_key_types(), @@ -1411,6 +1416,35 @@ mod tests { ); } + /// Regression: every chooser except the token, DashPay and username ones + /// offered keys of any type, including EdDSA and BIP13 keys Platform + /// refuses for every identity-signed transition. + #[test] + fn no_chooser_offers_a_key_type_platform_refuses() { + for transaction_type in [ + TransactionType::RegisterContract, + TransactionType::UpdateContract, + TransactionType::Transfer, + TransactionType::Withdraw, + TransactionType::DocumentAction, + TransactionType::TokenAction, + TransactionType::TokenTransfer, + TransactionType::TokenClaim, + TransactionType::ContactRequest, + TransactionType::UsernameRegistration, + ] { + let types = transaction_type + .allowed_key_types() + .unwrap_or_else(|| panic!("{transaction_type:?} accepts every key type")); + for refused in [KeyType::EDDSA_25519_HASH160, KeyType::BIP13_SCRIPT_HASH] { + assert!( + !types.contains(&refused), + "{transaction_type:?}: {refused:?}" + ); + } + } + } + fn outside_press() -> egui::RawInput { let outside_pos = egui::pos2(0.0, 0.0); egui::RawInput { diff --git a/src/ui/tokens/claim_tokens_screen.rs b/src/ui/tokens/claim_tokens_screen.rs index 332e81c3b..d04b80688 100644 --- a/src/ui/tokens/claim_tokens_screen.rs +++ b/src/ui/tokens/claim_tokens_screen.rs @@ -33,7 +33,7 @@ use crate::backend_task::error::TaskError; use crate::backend_task::tokens::TokenTask; use crate::context::AppContext; use crate::model::qualified_contract::QualifiedContract; -use crate::model::qualified_identity::{IdentityType, QualifiedIdentity}; +use crate::model::qualified_identity::{IdentityType, QualifiedIdentity, SingleTokenKeyStatus}; use crate::model::wallet::Wallet; use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; use crate::ui::{MessageType, Screen, ScreenLike}; @@ -481,20 +481,26 @@ impl ScreenLike for ClaimTokensScreen { ui.add_space(10.0); // Check if user has any auth keys - let has_keys = if self.app_context.user_role().at_least(UserRole::Developer) { - !identity.identity.public_keys().is_empty() + let key_status = if self.app_context.user_role().at_least(UserRole::Developer) { + if identity.identity.public_keys().is_empty() { + SingleTokenKeyStatus::NoneHeld + } else { + SingleTokenKeyStatus::Usable + } } else { - holds_claim_key(identity) + claim_key_status(identity, self.identity_token_basic_info.contract_id) }; - if !has_keys { - ui.colored_label( - Color32::RED, + if key_status != SingleTokenKeyStatus::Usable { + let message = if key_status == SingleTokenKeyStatus::HeldButUnusable { + "No key on this device can sign this token claim. The identity's keys are disabled, expired, reserved for another app or token, or not available on this device. Add a new key, or load the wallet that holds one, then try again.".to_string() + } else { format!( "No CRITICAL authentication or transfer key is loaded for this {identity_type} identity.", identity_type = identity.identity_type, - ), - ); + ) + }; + ui.colored_label(Color32::RED, message); ui.add_space(10.0); let first_key = identity.identity.get_first_public_key_matching( @@ -762,14 +768,21 @@ impl ScreenLike for ClaimTokensScreen { } } -/// Whether `identity` holds a key the claim form can sign with, the gate in -/// front of the form outside developer mode: for a user identity, a key of -/// the kind [`QualifiedIdentity::single_token_transition_key`] picks. -fn holds_claim_key(identity: &QualifiedIdentity) -> bool { +/// Whether `identity` can sign a claim on `contract_id`, the gate in front +/// of the form outside developer mode: for a user identity, whether +/// [`QualifiedIdentity::single_token_transition_key`] picks a key. +fn claim_key_status( + identity: &QualifiedIdentity, + contract_id: dash_sdk::platform::Identifier, +) -> SingleTokenKeyStatus { match identity.identity_type { - IdentityType::User => identity.holds_single_token_transition_key(), + IdentityType::User => identity.single_token_transition_key_status(contract_id), IdentityType::Masternode | IdentityType::Evonode => { - !identity.available_transfer_keys().is_empty() + if identity.available_transfer_keys().is_empty() { + SingleTokenKeyStatus::NoneHeld + } else { + SingleTokenKeyStatus::Usable + } } } } @@ -1032,11 +1045,69 @@ mod tests { transfer.set_purpose(Purpose::TRANSFER); let identity = identity_holding(&[transfer]); assert_eq!(identity.identity_type, IdentityType::User); - assert!(holds_claim_key(&identity)); + let contract_id = Identifier::new([0x70; 32]); + assert_eq!( + claim_key_status(&identity, contract_id), + SingleTokenKeyStatus::Usable + ); + + assert_eq!( + claim_key_status( + &identity_holding(&[auth_key(1, SecurityLevel::HIGH)]), + contract_id + ), + SingleTokenKeyStatus::NoneHeld + ); + } - assert!(!holds_claim_key(&identity_holding(&[auth_key( - 1, - SecurityLevel::HIGH - )]))); + /// Regression: the claim gate counted any filed CRITICAL key, while the + /// claim signs only with a live key valid for the token's contract whose + /// private half this device can read, so an identity holding only an + /// expired, elsewhere-bound or unreadable key got the form with Claim + /// disabled. The gate and the claim's key choice now agree. + #[test] + fn the_claim_gate_refuses_keys_the_claim_cannot_sign_with() { + use crate::context::test_support::{auth_key, bound_to, identity_holding}; + use crate::model::qualified_identity::PrivateKeyTarget; + use crate::model::qualified_identity::encrypted_key_storage::PrivateKeyData; + use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; + use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; + + let contract_id = Identifier::new([0x70; 32]); + let expired = + identity_holding(&[auth_key(1, SecurityLevel::CRITICAL).with_limits(None, Some(1))]); + let elsewhere = identity_holding(&[bound_to( + auth_key(1, SecurityLevel::CRITICAL), + ContractBounds::SingleContract { + id: Identifier::new([0x09; 32]), + }, + )]); + let key = auth_key(1, SecurityLevel::CRITICAL); + let mut unreadable = identity_holding(std::slice::from_ref(&key)); + unreadable.private_keys.insert_at( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, 1), + ( + QualifiedIdentityPublicKey { + identity_public_key: key, + in_wallet_at_derivation_path: None, + }, + PrivateKeyData::Encrypted(vec![1; 48]), + ), + ); + for (case, identity) in [ + ("expired", expired), + ("bound elsewhere", elsewhere), + ("not readable here", unreadable), + ] { + assert!( + identity.single_token_transition_key(contract_id).is_none(), + "{case}" + ); + assert_eq!( + claim_key_status(&identity, contract_id), + SingleTokenKeyStatus::HeldButUnusable, + "{case}" + ); + } } } diff --git a/src/ui/tokens/transfer_tokens_screen.rs b/src/ui/tokens/transfer_tokens_screen.rs index 67dcc917b..67fa0103f 100644 --- a/src/ui/tokens/transfer_tokens_screen.rs +++ b/src/ui/tokens/transfer_tokens_screen.rs @@ -5,7 +5,7 @@ use crate::context::AppContext; use crate::model::amount::Amount; use crate::model::fee_estimation::format_credits_as_dash; use crate::model::identity_key_usability::SigningScope; -use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::qualified_identity::{QualifiedIdentity, SingleTokenKeyStatus}; use crate::model::user_role::UserRole; use crate::model::wallet::Wallet; use crate::ui::components::amount_input::AmountInput; @@ -422,13 +422,19 @@ impl ScreenLike for TransferTokensScreen { )); ui.add_space(10.0); - let has_keys = if self.app_context.user_role().at_least(UserRole::Developer) { - !identity.identity.public_keys().is_empty() + let key_status = if self.app_context.user_role().at_least(UserRole::Developer) { + if identity.identity.public_keys().is_empty() { + SingleTokenKeyStatus::NoneHeld + } else { + SingleTokenKeyStatus::Usable + } } else { - identity.holds_single_token_transition_key() + identity.single_token_transition_key_status( + self.identity_token_balance.data_contract_id, + ) }; - if !has_keys { + if key_status != SingleTokenKeyStatus::Usable { let identity_type = identity.identity_type; // Extract key data before releasing the borrow (needed for click handlers). let key = [Purpose::AUTHENTICATION, Purpose::TRANSFER] @@ -442,12 +448,14 @@ impl ScreenLike for TransferTokensScreen { ) }) .cloned(); - ui.colored_label( - DashColors::error_color(dark_mode), + let message = if key_status == SingleTokenKeyStatus::HeldButUnusable { + "No key on this device can sign this token transfer. The identity's keys are disabled, expired, reserved for another app or token, or not available on this device. Add a new key, or load the wallet that holds one, then try again.".to_string() + } else { format!( "You do not have a CRITICAL authentication or transfer key loaded for this {identity_type} identity." - ), - ); + ) + }; + ui.colored_label(DashColors::error_color(dark_mode), message); ui.add_space(10.0); if let Some(key) = key { From 057bbe1ebb2662aa8a1c973e1a2d095c501d917c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:34:36 +0000 Subject: [PATCH 42/58] fix(dashpay): drop a contact QR code generated for another identity The QR screen stored every generated code, so one that arrived after the user picked another identity was shown under the new one. The screen now waits on its own request with the identity it was for, and shows a result only while that request is current and that identity is still selected. Co-Authored-By: Claude Opus 5.5 --- src/ui/dashpay/qr_code_generator.rs | 110 +++++++++++++++++++++++----- 1 file changed, 93 insertions(+), 17 deletions(-) diff --git a/src/ui/dashpay/qr_code_generator.rs b/src/ui/dashpay/qr_code_generator.rs index 319c0af8d..ddf8d8cc9 100644 --- a/src/ui/dashpay/qr_code_generator.rs +++ b/src/ui/dashpay/qr_code_generator.rs @@ -1,6 +1,7 @@ use crate::app::AppAction; use crate::backend_task::dashpay::DashPayTask; -use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; +use crate::backend_task::error::TaskError; +use crate::backend_task::{BackendTask, BackendTaskContext, BackendTaskSuccessResult}; use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; @@ -16,8 +17,10 @@ use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; use crate::ui::dashpay::dashpay_screen::DashPaySubscreen; +use crate::ui::state::PendingRequest; use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, ScreenLike}; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use egui::{RichText, ScrollArea, Ui}; use std::sync::{Arc, RwLock}; @@ -37,6 +40,9 @@ pub struct QRCodeGeneratorScreen { pub selected_identity: Option, selected_identity_string: String, generated_qr_data: Option, + /// The outstanding generate request and the identity it is for. Only its + /// result is shown, and only while that identity is still selected. + qr_request: PendingRequest, qr_code: Option, show_info_popup: bool, selected_wallet: Option>>, @@ -51,6 +57,7 @@ impl QRCodeGeneratorScreen { selected_identity: None, selected_identity_string: String::new(), generated_qr_data: None, + qr_request: PendingRequest::default(), qr_code: None, show_info_popup: false, selected_wallet: None, @@ -62,7 +69,6 @@ impl QRCodeGeneratorScreen { if let Ok(identities) = app_context.load_local_user_identities() && !identities.is_empty() { - use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::string_encoding::Encoding; let selected_id = app_context.selected_identity_id(); @@ -80,6 +86,15 @@ impl QRCodeGeneratorScreen { new_self } + /// React to a new `selected_identity`: follow its wallet and drop the QR + /// code shown for the previous one. + fn identity_changed(&mut self) { + self.selected_wallet = self.selected_identity.as_ref().and_then(qr_wallet); + self.wallet_open_attempted = false; + self.generated_qr_data = None; + self.qr_request.clear(); + } + /// Dispatch the auto-accept QR build to the backend, which resolves the key /// and derives the proof through the JIT chokepoint (no seed in the UI). fn generate_qr_code(&mut self) -> AppAction { @@ -92,11 +107,13 @@ impl QRCodeGeneratorScreen { return AppAction::None; }; - AppAction::BackendTask(BackendTask::DashPayTask(Box::new( - DashPayTask::GenerateAutoAcceptQrCode { + let identity_id = identity.identity.id(); + self.qr_request.dispatch_with( + BackendTask::DashPayTask(Box::new(DashPayTask::GenerateAutoAcceptQrCode { identity: identity.clone(), - }, - ))) + })), + identity_id, + ) } pub fn render(&mut self, ui: &mut Ui) -> AppAction { @@ -162,15 +179,7 @@ impl QRCodeGeneratorScreen { ); if response.changed() { - // Update wallet for the newly selected identity - if let Some(identity) = &self.selected_identity { - self.selected_wallet = qr_wallet(identity); - } else { - self.selected_wallet = None; - } - self.wallet_open_attempted = false; - // Clear generated QR code when identity changes - self.generated_qr_data = None; + self.identity_changed(); } }); ui.end_row(); @@ -354,9 +363,76 @@ impl ScreenLike for QRCodeGeneratorScreen { // Banner display is handled globally by AppState; no side-effects needed. } - fn display_task_result(&mut self, result: BackendTaskSuccessResult) { - if let BackendTaskSuccessResult::DashPayAutoAcceptQrCode(qr_string) = result { + fn display_backend_task_result( + &mut self, + context: &BackendTaskContext, + result: BackendTaskSuccessResult, + ) { + let BackendTaskSuccessResult::DashPayAutoAcceptQrCode(qr_string) = result else { + return; + }; + // A superseded request, or one for an identity no longer selected, + // would show another identity's contact code under this one. + let Some(requested_for) = self.qr_request.settle(context) else { + return; + }; + if self + .selected_identity + .as_ref() + .is_some_and(|identity| identity.identity.id() == requested_for) + { self.generated_qr_data = Some(qr_string); } } + + fn display_backend_task_error(&mut self, context: &BackendTaskContext, _error: &TaskError) { + self.qr_request.settle(context); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend_task::BackendTaskContext; + use crate::context::test_support::bare_user_identity; + use dash_sdk::dpp::dashcore::Network; + use dash_sdk::platform::Identifier; + + /// The context the screen's generate request is reported under. + fn dispatch_context(action: AppAction) -> BackendTaskContext { + match action { + AppAction::BackendTaskWithContext { context, .. } => context, + _ => BackendTaskContext::Unknown, + } + } + + fn qr_result(qr: &str) -> BackendTaskSuccessResult { + BackendTaskSuccessResult::DashPayAutoAcceptQrCode(qr.to_string()) + } + + /// Regression: a QR code generated for one identity arrived after the user + /// had picked another and was shown under it, handing out the first + /// identity's contact code as if it were the second's. + #[test] + fn a_qr_code_for_a_previously_selected_identity_is_dropped() { + let dir = tempfile::tempdir().unwrap(); + let context = crate::context::test_support::test_app_context(dir.path()); + let mut screen = QRCodeGeneratorScreen::new(context); + let first = bare_user_identity(Identifier::new([1; 32]), Network::Testnet); + let second = bare_user_identity(Identifier::new([2; 32]), Network::Testnet); + + screen.selected_identity = Some(first); + let for_first = dispatch_context(screen.generate_qr_code()); + screen.selected_identity = Some(second); + screen.identity_changed(); + screen.display_backend_task_result(&for_first, qr_result("first")); + assert_eq!(screen.generated_qr_data, None, "the identity changed"); + + // A newer request for the second identity supersedes the first one. + let for_second = dispatch_context(screen.generate_qr_code()); + screen.display_backend_task_result(&for_first, qr_result("first")); + assert_eq!(screen.generated_qr_data, None, "a superseded request"); + screen.display_backend_task_result(&for_second, qr_result("second")); + assert_eq!(screen.generated_qr_data.as_deref(), Some("second")); + } } From a727ac60b1f58ef010f2c24bfa699d1fcc3b3fb8 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:34:36 +0000 Subject: [PATCH 43/58] fix(dpns): stop reporting a key saved by an older version as ready A key kept only in the per-key encrypted form of early versions counted as ready to register usernames, though this version cannot open it, so registration failed at signing. It now has its own status, with a hint to enter the key again, and the key's page offers the input to do so. Co-Authored-By: Claude Opus 5.5 --- src/model/dpns_usernames.rs | 41 +++++++++++++++++++++---- src/ui/identity/keys/key_info_screen.rs | 17 ++++++++-- src/ui/identity/username_copy.rs | 4 +++ src/ui/identity/usernames_card.rs | 29 +++++++++++++++-- 4 files changed, 79 insertions(+), 12 deletions(-) diff --git a/src/model/dpns_usernames.rs b/src/model/dpns_usernames.rs index 0447040b6..2c2a49a76 100644 --- a/src/model/dpns_usernames.rs +++ b/src/model/dpns_usernames.rs @@ -247,6 +247,10 @@ pub enum RegistrationKeyStatus { /// This device holds such a key only as a reference into a wallet that is /// not on this device, so nothing here can produce its private half. KeyWalletNotOnDevice, + /// This device holds such a key only as saved by an early version, in a + /// per-key encrypted form this version cannot open; entering the key + /// again makes it usable. + KeySavedByOlderVersion, /// The identity has such a key, but this device does not hold it. KeyNotOnDevice, /// The identity has no key that can sign a registration. @@ -255,7 +259,9 @@ pub enum RegistrationKeyStatus { impl RegistrationKeyStatus { /// Whether the identity has a key that can sign a registration, but this - /// device cannot use it. + /// device cannot use it and restoring from the previous version may bring + /// it back. [`Self::KeySavedByOlderVersion`] is not among them: the restore + /// cannot open that key either, it must be entered again. pub fn key_is_unusable_here(self) -> bool { matches!(self, Self::KeyNotOnDevice | Self::KeyWalletNotOnDevice) } @@ -272,22 +278,28 @@ pub fn registration_key_status( ) -> RegistrationKeyStatus { use dash_sdk::dpp::identity::accessors::IdentityGettersV0; let mut held_without_its_wallet = false; + let mut held_in_legacy_form = false; for (key, data) in identity.private_keys.values() { // The stored copy may predate a limits update: judge the key as the // identity holds it now. if !key_can_sign_documents(identity.live_public_key(&key.identity_public_key), required) { continue; } - // TODO(legacy-encrypted-key-gate): this version cannot open the legacy - // per-key envelope, so such a key fails at signing; it is still reported - // as ready because it needs its own status and guidance first. - if identity.key_secret_is_obtainable(data) || matches!(data, PrivateKeyData::Encrypted(_)) { + if identity.key_secret_is_obtainable(data) { return RegistrationKeyStatus::Ready; } - held_without_its_wallet = true; + // Not obtainable: kept in the legacy per-key envelope this version + // cannot open, or a reference into a wallet not on this device. + if matches!(data, PrivateKeyData::Encrypted(_)) { + held_in_legacy_form = true; + } else { + held_without_its_wallet = true; + } } if held_without_its_wallet { RegistrationKeyStatus::KeyWalletNotOnDevice + } else if held_in_legacy_form { + RegistrationKeyStatus::KeySavedByOlderVersion } else if identity .identity .public_keys() @@ -1520,6 +1532,23 @@ mod tests { ); } + /// Regression: a key kept only in the per-key encrypted format of early + /// versions was reported as ready, though this version cannot open it, so + /// the username flow offered a registration that failed at signing. + #[test] + fn a_legacy_encrypted_key_cannot_register() { + use dash_sdk::dpp::identity::SecurityLevel; + let high = SecurityLevel::HIGH; + let mut identity = identity_with_keys(&[(1, high)], &[1]); + crate::context::test_support::hold_as_legacy_encrypted(&mut identity, 1); + + assert!(!can_register_usernames(&identity, high)); + assert_eq!( + registration_key_status(&identity, high), + RegistrationKeyStatus::KeySavedByOlderVersion + ); + } + /// A reference into a missing wallet for a key that cannot register anyway /// says nothing about registration: the key the identity needs is simply /// not held. diff --git a/src/ui/identity/keys/key_info_screen.rs b/src/ui/identity/keys/key_info_screen.rs index adf23c995..fcce61a17 100644 --- a/src/ui/identity/keys/key_info_screen.rs +++ b/src/ui/identity/keys/key_info_screen.rs @@ -602,10 +602,21 @@ impl ScreenLike for KeyInfoScreen { self.render_sign_input(ui); } PrivateKeyData::Encrypted(_) => { - ui.label(RichText::new("Key is encrypted").color(text_primary)); + // This version cannot open the per-key envelope of + // early versions; entering the key again files a + // copy it can use in the same place. + ui.label( + RichText::new( + "This key was saved by an older version of Dash Evo Tool and must be entered again before it can be used.", + ) + .color(text_primary), + ); ui.add_space(10.0); - - //todo decrypt key + ui.label(RichText::new("Enter Private Key:").color(text_primary)); + self.private_key_input.show(ui); + if ui.button("Add Private Key").clicked() { + self.validate_and_store_private_key(); + } } PrivateKeyData::AtWalletDerivationPath(derivation_path) => { if self.wallet_open diff --git a/src/ui/identity/username_copy.rs b/src/ui/identity/username_copy.rs index bf9c6cb9d..a6f2ff0c6 100644 --- a/src/ui/identity/username_copy.rs +++ b/src/ui/identity/username_copy.rs @@ -173,6 +173,10 @@ pub const RESTORE_KEYS_TO_REGISTER: &str = "The key this identity needs to regis /// version, when no restore is offered in place. pub const RESTORE_FROM_PREVIOUS_VERSION_TO_REGISTER: &str = "The key this identity needs to register usernames is not on this device. If you updated from an earlier version of Dash Evo Tool, open Settings and use Restore from Previous Version to bring back missing wallets and keys."; +/// Why an identity cannot register when its key was saved by an early version +/// in a form this version cannot open. +pub const REENTER_KEY_TO_REGISTER: &str = "The key this identity needs to register usernames was saved by an older version of Dash Evo Tool and must be entered again. Open this identity's keys and enter the key to register usernames."; + /// Why an identity cannot register when its key was never saved on this device. pub const ENTER_KEY_TO_REGISTER: &str = "The key this identity needs to register usernames is not on this device. Open this identity's keys and enter the key to use it here."; diff --git a/src/ui/identity/usernames_card.rs b/src/ui/identity/usernames_card.rs index 5e163557d..61d8d29b1 100644 --- a/src/ui/identity/usernames_card.rs +++ b/src/ui/identity/usernames_card.rs @@ -22,8 +22,9 @@ use crate::ui::components::qr_code_image::QrCodeImage; use crate::ui::identity::register_dpns_name_screen::{RegisterDpnsNameSource, status_line}; use crate::ui::identity::username_copy::{ ENTER_KEY_TO_REGISTER, IMPORT_OR_RESTORE_WALLET_TO_REGISTER, IMPORT_WALLET_TO_REGISTER, - NEEDS_KEY_TO_REGISTER, RESTORE_FROM_PREVIOUS_VERSION_TO_REGISTER, RESTORE_KEYS_TO_REGISTER, - Tone, finished_request_detail, pending_request_detail, phase_label, registered_on_line, + NEEDS_KEY_TO_REGISTER, REENTER_KEY_TO_REGISTER, RESTORE_FROM_PREVIOUS_VERSION_TO_REGISTER, + RESTORE_KEYS_TO_REGISTER, Tone, finished_request_detail, pending_request_detail, phase_label, + registered_on_line, }; use crate::ui::masternodes::KeyVocabulary; use crate::ui::state::legacy_recovery::LegacyRecoveryState; @@ -137,6 +138,9 @@ pub(crate) enum KeyHint { PreviousVersion, /// An earlier version saved nothing: enter the key. EnterKey, + /// An early version saved the key in a form this version cannot open: + /// enter it again. + ReenterKey, /// The key comes from a wallet that is not on this device: import it. ImportWallet, /// The same on an updated install: import the wallet or restore from Settings. @@ -155,6 +159,7 @@ impl KeyHint { match status { RegistrationKeyStatus::Ready => None, RegistrationKeyStatus::NoSuitableKey => Some(Self::AddKey), + RegistrationKeyStatus::KeySavedByOlderVersion => Some(Self::ReenterKey), RegistrationKeyStatus::KeyNotOnDevice | RegistrationKeyStatus::KeyWalletNotOnDevice if restorable_here => { @@ -176,6 +181,7 @@ impl KeyHint { Self::Restore => RESTORE_KEYS_TO_REGISTER, Self::PreviousVersion => RESTORE_FROM_PREVIOUS_VERSION_TO_REGISTER, Self::EnterKey => ENTER_KEY_TO_REGISTER, + Self::ReenterKey => REENTER_KEY_TO_REGISTER, Self::ImportWallet => IMPORT_WALLET_TO_REGISTER, Self::ImportWalletOrPreviousVersion => IMPORT_OR_RESTORE_WALLET_TO_REGISTER, } @@ -211,7 +217,7 @@ pub(crate) fn key_hint_links( // has nowhere to enter a key this identity already has an entry for. let keys_link = match hint { KeyHint::AddKey => Some("Add a key"), - KeyHint::PreviousVersion | KeyHint::EnterKey => Some("Open keys"), + KeyHint::PreviousVersion | KeyHint::EnterKey | KeyHint::ReenterKey => Some("Open keys"), KeyHint::Restore | KeyHint::ImportWallet | KeyHint::ImportWalletOrPreviousVersion => None, }; if keys_link.is_some_and(|label| ui.link(label).clicked()) { @@ -891,6 +897,23 @@ mod tests { ); } + /// A key an early version saved in a form this version cannot open is + /// entered again; restoring from the previous version cannot open it + /// either, so no restore is offered for it. + #[test] + fn a_key_saved_by_an_older_version_is_entered_again() { + let status = RegistrationKeyStatus::KeySavedByOlderVersion; + assert!(!status.key_is_unusable_here()); + for (restorable_here, updated_install) in + [(true, true), (true, false), (false, true), (false, false)] + { + assert_eq!( + KeyHint::for_status(status, restorable_here, updated_install), + Some(KeyHint::ReenterKey) + ); + } + } + /// The hub reads the identity again on every frame, so a wallet imported /// while the card is open enables the button without reopening anything. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] From 6da52f9f90f45d4d03fe8ebd406ba850c7eba51a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:34:36 +0000 Subject: [PATCH 44/58] fix(identity): open key choosers and token action forms only for keys that can sign The key chooser gate and list counted a filed key whose private half this device cannot obtain, and the mint, burn, pricing, purchase and configuration forms opened for any filed CRITICAL authentication key. Both now apply the same rule as the signing key choice, and say why no key can sign when one is filed. Co-Authored-By: Claude Opus 5.5 --- src/model/identity_key_usability.rs | 2 +- src/model/qualified_identity/mod.rs | 141 ++++++++++++-- src/ui/helpers.rs | 108 +++++++---- src/ui/tokens/claim_tokens_screen.rs | 22 +-- src/ui/tokens/direct_token_purchase_screen.rs | 40 ++-- src/ui/tokens/mod.rs | 38 +++- src/ui/tokens/set_token_price_screen.rs | 40 ++-- src/ui/tokens/token_action_screen.rs | 41 ++-- src/ui/tokens/transfer_tokens_screen.rs | 10 +- src/ui/tokens/update_token_config.rs | 177 ++++++++---------- 10 files changed, 374 insertions(+), 245 deletions(-) diff --git a/src/model/identity_key_usability.rs b/src/model/identity_key_usability.rs index 0cc4bc71a..c9e117e4a 100644 --- a/src/model/identity_key_usability.rs +++ b/src/model/identity_key_usability.rs @@ -207,7 +207,7 @@ pub struct KeyRequirements<'a> { } impl KeyRequirements<'_> { - fn matches(&self, key: &IdentityPublicKey) -> bool { + pub(crate) fn matches(&self, key: &IdentityPublicKey) -> bool { key.purpose() == self.purpose && self.security_levels.contains(&key.security_level()) && self diff --git a/src/model/qualified_identity/mod.rs b/src/model/qualified_identity/mod.rs index d7555ff8f..3814399fd 100644 --- a/src/model/qualified_identity/mod.rs +++ b/src/model/qualified_identity/mod.rs @@ -591,12 +591,12 @@ pub const DASHPAY_SIGNING_KEY_TYPES: &[KeyType] = /// (`BatchTransitionV1::purpose_requirement`; TRANSFER requires CRITICAL). const SINGLE_TOKEN_TRANSITION_PURPOSES: [Purpose; 2] = [Purpose::AUTHENTICATION, Purpose::TRANSFER]; -/// Whether an identity can sign a batch of one token transfer or claim on a -/// contract on this device, as -/// [`QualifiedIdentity::single_token_transition_key_status`] reports it. +/// Whether an identity can sign a token transition on a contract on this +/// device, as [`QualifiedIdentity::single_token_transition_key_status`] and +/// [`QualifiedIdentity::token_action_key_status`] report it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SingleTokenKeyStatus { - /// [`QualifiedIdentity::single_token_transition_key`] picks a key. +pub enum SigningKeyStatus { + /// The transition's key choice picks a key. Usable, /// A private key of a purpose, level and key type the transition takes /// is filed, but none can sign it now: each is disabled or expired, bound @@ -1321,13 +1321,60 @@ impl QualifiedIdentity { pub fn single_token_transition_key_status( &self, contract_id: dash_sdk::platform::Identifier, - ) -> SingleTokenKeyStatus { + ) -> SigningKeyStatus { if self.single_token_transition_key(contract_id).is_some() { - SingleTokenKeyStatus::Usable + SigningKeyStatus::Usable } else if self.holds_single_token_transition_key() { - SingleTokenKeyStatus::HeldButUnusable + SigningKeyStatus::HeldButUnusable } else { - SingleTokenKeyStatus::NoneHeld + SigningKeyStatus::NoneHeld + } + } + + /// The requirements of a token action on `contract_id` other than a + /// single transfer or claim (mint, burn, freeze, pause, pricing, purchase, + /// configuration updates): a CRITICAL AUTHENTICATION key valid for the + /// token's contract, of a type Platform verifies. + fn token_action_requirements( + contract_id: dash_sdk::platform::Identifier, + ) -> KeyRequirements<'static> { + KeyRequirements::new( + Purpose::AUTHENTICATION, + &[SecurityLevel::CRITICAL], + SigningScope::ContractWide { contract_id }, + ) + } + + /// The key to sign a token action on `contract_id` with, as + /// [`Self::signing_key_now`] picks it. + pub fn token_action_key( + &self, + contract_id: dash_sdk::platform::Identifier, + ) -> Option<&IdentityPublicKey> { + self.signing_key_now(Self::token_action_requirements(contract_id)) + } + + /// Whether this device can sign a token action on `contract_id`: the gate + /// in front of the token action forms. It opens exactly when + /// [`Self::token_action_key`] picks a key, so an open form can always + /// sign; when closed, it tells a missing key apart from keys that cannot + /// sign here. + pub fn token_action_key_status( + &self, + contract_id: dash_sdk::platform::Identifier, + ) -> SigningKeyStatus { + let requirements = Self::token_action_requirements(contract_id); + if self.signing_key_now(requirements).is_some() { + SigningKeyStatus::Usable + } else if self + .private_keys + .identity_public_keys() + .iter() + .any(|key_ref| requirements.matches(&key_ref.1.identity_public_key)) + { + SigningKeyStatus::HeldButUnusable + } else { + SigningKeyStatus::NoneHeld } } @@ -1593,7 +1640,7 @@ impl QualifiedIdentity { /// Whether this device holds `key` under a placement whose private half it /// can obtain. Stricter than [`Signer::can_sign_with`], which also accepts a /// key that only refers to a wallet this device does not hold. - fn holds_obtainable_key(&self, key: &IdentityPublicKey) -> bool { + pub(crate) fn holds_obtainable_key(&self, key: &IdentityPublicKey) -> bool { self.private_keys.candidates(key).any(|placement| { self.private_keys .entry_at(&placement) @@ -3862,7 +3909,7 @@ mod protocol_v14_compat_tests { ); assert_eq!( qi.single_token_transition_key_status(contract_id), - SingleTokenKeyStatus::HeldButUnusable, + SigningKeyStatus::HeldButUnusable, "{case}" ); } @@ -3878,15 +3925,83 @@ mod protocol_v14_compat_tests { ); assert_eq!( bound_here.single_token_transition_key_status(contract_id), - SingleTokenKeyStatus::Usable + SigningKeyStatus::Usable ); assert_eq!( identity_holding(&[auth_key(1, SecurityLevel::HIGH)]) .single_token_transition_key_status(contract_id), - SingleTokenKeyStatus::NoneHeld + SigningKeyStatus::NoneHeld ); } + /// Regression: the generic token action forms (mint, burn, pricing, + /// purchase, configuration…) opened whenever a CRITICAL AUTHENTICATION key + /// was filed, while the action signs only with a live key valid for the + /// token's contract whose private half this device can obtain. The gate + /// opens exactly when the action's key choice picks a key. + #[test] + fn the_token_action_gate_opens_exactly_when_a_key_is_picked() { + use crate::context::test_support::{ + auth_key, bound_to, hold_as_legacy_encrypted, hold_as_wallet_reference, + identity_holding, + }; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + + let contract_id = Identifier::new([0x70; 32]); + let critical = |id| auth_key(id, SecurityLevel::CRITICAL); + let expired = identity_holding(&[critical(1).with_limits(None, Some(1))]); + let elsewhere = identity_holding(&[bound_to( + critical(1), + ContractBounds::SingleContract { + id: Identifier::new([0x09; 32]), + }, + )]); + let mut legacy = identity_holding(&[critical(1)]); + hold_as_legacy_encrypted(&mut legacy, 1); + let mut walletless = identity_holding(&[critical(1)]); + hold_as_wallet_reference(&mut walletless, 1, [0x5A; 32]); + let mut transfer_only = critical(1); + transfer_only.set_purpose(Purpose::TRANSFER); + let transfer_only = identity_holding(&[transfer_only]); + + for (case, qi) in [ + ("expired", &expired), + ("bound elsewhere", &elsewhere), + ("legacy encrypted", &legacy), + ("wallet not here", &walletless), + ] { + assert!(qi.token_action_key(contract_id).is_none(), "{case}"); + assert_eq!( + qi.token_action_key_status(contract_id), + SigningKeyStatus::HeldButUnusable, + "{case}" + ); + } + + let bound_here = identity_holding(&[bound_to( + critical(1), + ContractBounds::SingleContract { id: contract_id }, + )]); + assert!(bound_here.token_action_key(contract_id).is_some()); + assert_eq!( + bound_here.token_action_key_status(contract_id), + SigningKeyStatus::Usable + ); + for (case, qi) in [ + ( + "a HIGH key", + identity_holding(&[auth_key(1, SecurityLevel::HIGH)]), + ), + ("a transfer key", transfer_only), + ] { + assert_eq!( + qi.token_action_key_status(contract_id), + SigningKeyStatus::NoneHeld, + "{case}: a token action signs only with an authentication key" + ); + } + } + /// Regression: the automatic username registration key was taken of any /// key type, so a BLS or EdDSA key with a lower id won over an ECDSA key. #[test] diff --git a/src/ui/helpers.rs b/src/ui/helpers.rs index 7c60cd154..649dd1cec 100644 --- a/src/ui/helpers.rs +++ b/src/ui/helpers.rs @@ -474,29 +474,50 @@ impl<'a> KeyFilter<'a> { } } -/// Whether `identity` has (a) a loaded private key `filter` offers and (b) a -/// public key `filter` offers whose private key is not loaded. Keys are judged -/// as the identity holds them now, as the list judges them. +/// The keys `identity`'s chooser lists under `filter`, each as the identity +/// holds it now: filed keys whose private half this device can obtain and +/// that Platform may accept here. Outside developer mode also only keys of a +/// purpose, security level and key type the transaction accepts. +fn listed_keys<'a>( + identity: &'a QualifiedIdentity, + filter: KeyFilter<'a>, + is_dev_mode: bool, + now: TimestampMillis, +) -> impl Iterator + 'a { + identity + .private_keys + .identity_public_keys() + .into_iter() + // The stored copy may predate a limits raise: judge, label and hand + // on the key as the identity holds it now. + .map(|key_ref| identity.live_public_key(&key_ref.1.identity_public_key)) + // Platform rejects signing with a disabled key or a key bound + // elsewhere, and no signer here can produce a private half this + // device cannot obtain, so never offer one — not even in dev mode, + // where the override only relaxes purpose, security-level and key + // type filters, not signability. + .filter(move |key| filter.may_sign(key, now) && identity.holds_obtainable_key(key)) + .filter(move |key| is_dev_mode || filter.accepts(key)) +} + +/// Whether `identity` has (a) a key the chooser lists outside developer mode +/// and (b) a public key `filter` offers whose private half this device cannot +/// obtain: not loaded, kept in the legacy encrypted form, or derived from a +/// wallet that is not here. Keys are judged as the identity holds them now, +/// as the list judges them. fn key_eligibility( identity: &QualifiedIdentity, filter: &KeyFilter<'_>, now: TimestampMillis, ) -> (bool, bool) { - let filed = identity.private_keys.identity_public_keys(); - let has_suitable_keys_with_private = filed.iter().any(|key_ref| { - filter.offers( - identity.live_public_key(&key_ref.1.identity_public_key), - now, - ) - }); + let has_suitable_keys_with_private = + listed_keys(identity, *filter, false, now).next().is_some(); - let has_eligible_public_keys_without_private = - identity.identity.public_keys().values().any(|pub_key| { - filter.offers(pub_key, now) - && !filed - .iter() - .any(|key_ref| key_ref.1.identity_public_key.id() == pub_key.id()) - }); + let has_eligible_public_keys_without_private = identity + .identity + .public_keys() + .values() + .any(|pub_key| filter.offers(pub_key, now) && !identity.holds_obtainable_key(pub_key)); ( has_suitable_keys_with_private, @@ -569,23 +590,8 @@ fn render_key_combo( kui.label("Pick an identity first"); return; }; - for key_ref in qi.private_keys.identity_public_keys() { - // The stored copy may predate a limits raise: judge, label and - // hand on the key as the identity holds it now. - let key = qi.live_public_key(&key_ref.1.identity_public_key); - - // Platform rejects signing with a disabled key or a key bound - // elsewhere, so never offer one — not even in dev mode, where - // the override only relaxes purpose, security-level and key - // type filters, not signability. - if !filter.may_sign(key, now) { - continue; - } + for key in listed_keys(qi, *filter, is_dev_mode, now) { let is_dev_override = !filter.accepts(key); - if is_dev_override && !is_dev_mode { - continue; - } - let label = if is_dev_override { format_key_label_dev(key) } else { @@ -1401,6 +1407,42 @@ mod tests { assert_eq!(key_eligibility(&public_only, &filter, now), (false, false)); } + /// Regression: the chooser gate and its list counted a filed key whose + /// private half this device cannot obtain (kept in the legacy encrypted + /// form, or as a reference into a wallet not on this device), so the form + /// opened onto a key that cannot sign. Such a key is offered nowhere, and + /// the gate gives the guidance for a key whose private half is missing. + #[test] + fn the_chooser_gate_and_list_skip_keys_this_device_cannot_obtain() { + use crate::context::test_support::{ + auth_key, hold_as_legacy_encrypted, hold_as_wallet_reference, identity_holding, + }; + + let dashpay = Identifier::new([0xDA; 32]); + let purposes = TransactionType::ContactRequest.allowed_purposes(); + let levels = TransactionType::ContactRequest.allowed_security_levels(); + let filter = contact_request_filter(&purposes, &levels, dashpay); + let now = 1_000; + + let mut legacy = identity_holding(&[auth_key(1, SecurityLevel::HIGH)]); + hold_as_legacy_encrypted(&mut legacy, 1); + let mut walletless = identity_holding(&[auth_key(1, SecurityLevel::HIGH)]); + hold_as_wallet_reference(&mut walletless, 1, [0x5A; 32]); + + for (case, identity) in [("legacy encrypted", legacy), ("wallet missing", walletless)] { + assert_eq!( + key_eligibility(&identity, &filter, now), + (false, true), + "{case}" + ); + assert_eq!( + listed_keys(&identity, filter, true, now).count(), + 0, + "{case}: not listed, not even in developer mode" + ); + } + } + /// DashPay and username registration choosers offer the key types their /// automatic choice takes; other transactions the types Platform verifies. #[test] diff --git a/src/ui/tokens/claim_tokens_screen.rs b/src/ui/tokens/claim_tokens_screen.rs index 008b9cb77..b01e05772 100644 --- a/src/ui/tokens/claim_tokens_screen.rs +++ b/src/ui/tokens/claim_tokens_screen.rs @@ -33,7 +33,7 @@ use crate::backend_task::error::TaskError; use crate::backend_task::tokens::TokenTask; use crate::context::AppContext; use crate::model::qualified_contract::QualifiedContract; -use crate::model::qualified_identity::{IdentityType, QualifiedIdentity, SingleTokenKeyStatus}; +use crate::model::qualified_identity::{IdentityType, QualifiedIdentity, SigningKeyStatus}; use crate::model::wallet::Wallet; use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; use crate::ui::{MessageType, Screen, ScreenLike}; @@ -491,16 +491,16 @@ impl ScreenLike for ClaimTokensScreen { // Check if user has any auth keys let key_status = if self.app_context.user_role().at_least(UserRole::Developer) { if identity.identity.public_keys().is_empty() { - SingleTokenKeyStatus::NoneHeld + SigningKeyStatus::NoneHeld } else { - SingleTokenKeyStatus::Usable + SigningKeyStatus::Usable } } else { claim_key_status(identity, self.identity_token_basic_info.contract_id) }; - if key_status != SingleTokenKeyStatus::Usable { - let message = if key_status == SingleTokenKeyStatus::HeldButUnusable { + if key_status != SigningKeyStatus::Usable { + let message = if key_status == SigningKeyStatus::HeldButUnusable { "No key on this device can sign this token claim. The identity's keys are disabled, expired, reserved for another app or token, or not available on this device. Add a new key, or load the wallet that holds one, then try again.".to_string() } else { format!( @@ -782,14 +782,14 @@ impl ScreenLike for ClaimTokensScreen { fn claim_key_status( identity: &QualifiedIdentity, contract_id: dash_sdk::platform::Identifier, -) -> SingleTokenKeyStatus { +) -> SigningKeyStatus { match identity.identity_type { IdentityType::User => identity.single_token_transition_key_status(contract_id), IdentityType::Masternode | IdentityType::Evonode => { if identity.available_transfer_keys().is_empty() { - SingleTokenKeyStatus::NoneHeld + SigningKeyStatus::NoneHeld } else { - SingleTokenKeyStatus::Usable + SigningKeyStatus::Usable } } } @@ -1053,7 +1053,7 @@ mod tests { let contract_id = Identifier::new([0x70; 32]); assert_eq!( claim_key_status(&identity, contract_id), - SingleTokenKeyStatus::Usable + SigningKeyStatus::Usable ); assert_eq!( @@ -1061,7 +1061,7 @@ mod tests { &identity_holding(&[auth_key(1, SecurityLevel::HIGH)]), contract_id ), - SingleTokenKeyStatus::NoneHeld + SigningKeyStatus::NoneHeld ); } @@ -1110,7 +1110,7 @@ mod tests { ); assert_eq!( claim_key_status(&identity, contract_id), - SingleTokenKeyStatus::HeldButUnusable, + SigningKeyStatus::HeldButUnusable, "{case}" ); } diff --git a/src/ui/tokens/direct_token_purchase_screen.rs b/src/ui/tokens/direct_token_purchase_screen.rs index f55426d39..55b66866a 100644 --- a/src/ui/tokens/direct_token_purchase_screen.rs +++ b/src/ui/tokens/direct_token_purchase_screen.rs @@ -1,4 +1,4 @@ -use crate::model::identity_key_usability::{KeyRequirements, SigningScope}; +use crate::model::identity_key_usability::SigningScope; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use std::collections::HashSet; use std::sync::{Arc, RwLock}; @@ -18,7 +18,7 @@ use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::amount::{Amount, DASH_DECIMAL_PLACES}; use crate::model::fee_estimation::format_credits_as_dash; -use crate::model::user_role::UserRole; +use crate::model::qualified_identity::SigningKeyStatus; use crate::model::wallet::Wallet; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; @@ -84,13 +84,7 @@ impl PurchaseTokenScreen { pub fn new(identity_token_info: IdentityTokenInfo, app_context: &Arc) -> Self { let possible_key = identity_token_info .identity - .signing_key_now(KeyRequirements::new( - Purpose::AUTHENTICATION, - &[SecurityLevel::CRITICAL], - SigningScope::ContractWide { - contract_id: identity_token_info.data_contract.contract.id(), - }, - )) + .token_action_key(identity_token_info.data_contract.contract.id()) .cloned(); // Attempt to get an unlocked wallet reference @@ -444,29 +438,17 @@ impl ScreenLike for PurchaseTokenScreen { ui.heading("Purchase Tokens"); ui.add_space(10.0); - // Check if user has any auth keys - let has_keys = if self.app_context.user_role().at_least(UserRole::Developer) { - !self - .identity_token_info - .identity - .identity - .public_keys() - .is_empty() - } else { - !self - .identity_token_info - .identity - .available_authentication_keys_with_critical_security_level() - .is_empty() - }; + // Open the form only when the action's key choice picks a key. + let key_status = super::token_action_key_status( + &self.app_context, + &self.identity_token_info.identity, + self.identity_token_info.data_contract.contract.id(), + ); - if !has_keys { + if key_status != SigningKeyStatus::Usable { ui.colored_label( DashColors::error_color(dark_mode), - format!( - "No authentication keys with CRITICAL security level found for this {} identity.", - self.identity_token_info.identity.identity_type, - ), + super::token_action_key_message(key_status, &self.identity_token_info.identity), ); ui.add_space(10.0); diff --git a/src/ui/tokens/mod.rs b/src/ui/tokens/mod.rs index 3b60a9d75..d1a0eafe3 100644 --- a/src/ui/tokens/mod.rs +++ b/src/ui/tokens/mod.rs @@ -16,9 +16,11 @@ pub mod update_token_config; pub mod view_token_claims_screen; use crate::context::AppContext; -use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::qualified_identity::{QualifiedIdentity, SigningKeyStatus}; +use crate::model::user_role::UserRole; use crate::ui::MessageType; use crate::ui::components::MessageBanner; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::platform::IdentityPublicKey; /// Loads local identities, displaying an error banner on failure. @@ -58,3 +60,37 @@ pub fn validate_signing_key( } } } + +/// The gate in front of a token action form (mint, burn, freeze, pause, +/// pricing, purchase, configuration updates). Outside developer mode it opens +/// exactly when [`QualifiedIdentity::token_action_key`] picks a key, the key +/// the form's constructor selects, so an open form can always sign. +pub(crate) fn token_action_key_status( + app_context: &AppContext, + identity: &QualifiedIdentity, + contract_id: dash_sdk::platform::Identifier, +) -> SigningKeyStatus { + if app_context.user_role().at_least(UserRole::Developer) { + if identity.identity.public_keys().is_empty() { + SigningKeyStatus::NoneHeld + } else { + SigningKeyStatus::Usable + } + } else { + identity.token_action_key_status(contract_id) + } +} + +/// Why a token action form stays closed for `identity` at `status`. +pub(crate) fn token_action_key_message( + status: SigningKeyStatus, + identity: &QualifiedIdentity, +) -> String { + match status { + SigningKeyStatus::HeldButUnusable => "No key on this device can sign this token action. The identity's keys are disabled, expired, reserved for another app or token, or not available on this device. Add a new key, or load the wallet that holds one, then try again.".to_string(), + SigningKeyStatus::Usable | SigningKeyStatus::NoneHeld => format!( + "No authentication keys with CRITICAL security level found for this {identity_type} identity.", + identity_type = identity.identity_type, + ), + } +} diff --git a/src/ui/tokens/set_token_price_screen.rs b/src/ui/tokens/set_token_price_screen.rs index 05e195887..9aa7a2d38 100644 --- a/src/ui/tokens/set_token_price_screen.rs +++ b/src/ui/tokens/set_token_price_screen.rs @@ -5,8 +5,8 @@ use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::amount::{Amount, DASH_DECIMAL_PLACES}; use crate::model::fee_estimation::format_credits_as_dash; -use crate::model::identity_key_usability::{KeyRequirements, SigningScope}; -use crate::model::user_role::UserRole; +use crate::model::identity_key_usability::SigningScope; +use crate::model::qualified_identity::SigningKeyStatus; use crate::model::wallet::Wallet; use crate::ui::components::ComponentResponse; use crate::ui::components::amount_input::AmountInput; @@ -172,13 +172,7 @@ impl SetTokenPriceScreen { pub fn new(identity_token_info: IdentityTokenInfo, app_context: &Arc) -> Self { let possible_key: Option<&IdentityPublicKey> = identity_token_info .identity - .signing_key_now(KeyRequirements::new( - Purpose::AUTHENTICATION, - &[SecurityLevel::CRITICAL], - SigningScope::ContractWide { - contract_id: identity_token_info.data_contract.contract.id(), - }, - )); + .token_action_key(identity_token_info.data_contract.contract.id()); let set_error_banner = |msg: &str| super::set_error_banner(app_context, msg); @@ -921,29 +915,17 @@ impl ScreenLike for SetTokenPriceScreen { ui.heading("Set Token Pricing Schedule"); ui.add_space(10.0); - // Check if user has any auth keys - let has_keys = if self.app_context.user_role().at_least(UserRole::Developer) { - !self - .identity_token_info - .identity - .identity - .public_keys() - .is_empty() - } else { - !self - .identity_token_info - .identity - .available_authentication_keys_with_critical_security_level() - .is_empty() - }; + // Open the form only when the action's key choice picks a key. + let key_status = super::token_action_key_status( + &self.app_context, + &self.identity_token_info.identity, + self.identity_token_info.data_contract.contract.id(), + ); - if !has_keys { + if key_status != SigningKeyStatus::Usable { ui.colored_label( Color32::DARK_RED, - format!( - "No authentication keys with CRITICAL security level found for this {} identity.", - self.identity_token_info.identity.identity_type, - ), + super::token_action_key_message(key_status, &self.identity_token_info.identity), ); ui.add_space(10.0); diff --git a/src/ui/tokens/token_action_screen.rs b/src/ui/tokens/token_action_screen.rs index 795751805..65b1bb95c 100644 --- a/src/ui/tokens/token_action_screen.rs +++ b/src/ui/tokens/token_action_screen.rs @@ -11,7 +11,8 @@ use crate::app::AppAction; use crate::backend_task::{BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::fee_estimation::format_credits_as_dash; -use crate::model::identity_key_usability::{KeyRequirements, SigningScope}; +use crate::model::identity_key_usability::SigningScope; +use crate::model::qualified_identity::SigningKeyStatus; use crate::model::user_role::UserRole; use crate::model::wallet::Wallet; use crate::ui::components::component_trait::Component; @@ -176,13 +177,7 @@ impl TokenActionScreen { pub fn new(identity_token_info: IdentityTokenInfo, app_context: &Arc) -> Self { let possible_key = identity_token_info .identity - .signing_key_now(KeyRequirements::new( - Purpose::AUTHENTICATION, - &[SecurityLevel::CRITICAL], - SigningScope::ContractWide { - contract_id: identity_token_info.data_contract.contract.id(), - }, - )) + .token_action_key(identity_token_info.data_contract.contract.id()) .cloned(); let takers = A::authorized_takers(&identity_token_info.token_config); @@ -248,17 +243,14 @@ impl TokenActionScreen { } /// Renders the missing-key banner with "Check Keys" / "Add key" shortcuts. - fn render_no_keys(&self, ui: &mut Ui) -> AppAction { + fn render_no_keys(&self, ui: &mut Ui, key_status: SigningKeyStatus) -> AppAction { let mut action = AppAction::None; let identity = &self.common.identity_token_info.identity; let dark_mode = ui.style().visuals.dark_mode; ui.colored_label( DashColors::error_color(dark_mode), - format!( - "No authentication keys with CRITICAL security level found for this {} identity.", - identity.identity_type, - ), + super::token_action_key_message(key_status, identity), ); ui.add_space(10.0); @@ -476,24 +468,15 @@ impl ScreenLike for TokenActionScreen { return self.show_success_screen(ui); } - let identity = &self.common.identity_token_info.identity; - let has_keys = if self - .common - .app_context - .user_role() - .at_least(UserRole::Developer) - { - !identity.identity.public_keys().is_empty() - } else { - !identity - .available_authentication_keys_with_critical_security_level() - .is_empty() - }; - - if !has_keys { + let key_status = super::token_action_key_status( + &self.common.app_context, + &self.common.identity_token_info.identity, + self.common.identity_token_info.data_contract.contract.id(), + ); + if key_status != SigningKeyStatus::Usable { ui.heading(A::PAGE_HEADING); ui.add_space(10.0); - return self.render_no_keys(ui); + return self.render_no_keys(ui, key_status); } let mut inner_action = AppAction::None; diff --git a/src/ui/tokens/transfer_tokens_screen.rs b/src/ui/tokens/transfer_tokens_screen.rs index 326637e55..1baf9c33b 100644 --- a/src/ui/tokens/transfer_tokens_screen.rs +++ b/src/ui/tokens/transfer_tokens_screen.rs @@ -5,7 +5,7 @@ use crate::context::AppContext; use crate::model::amount::Amount; use crate::model::fee_estimation::format_credits_as_dash; use crate::model::identity_key_usability::SigningScope; -use crate::model::qualified_identity::{QualifiedIdentity, SingleTokenKeyStatus}; +use crate::model::qualified_identity::{QualifiedIdentity, SigningKeyStatus}; use crate::model::user_role::UserRole; use crate::model::wallet::Wallet; use crate::ui::components::amount_input::AmountInput; @@ -435,9 +435,9 @@ impl ScreenLike for TransferTokensScreen { let key_status = if self.app_context.user_role().at_least(UserRole::Developer) { if identity.identity.public_keys().is_empty() { - SingleTokenKeyStatus::NoneHeld + SigningKeyStatus::NoneHeld } else { - SingleTokenKeyStatus::Usable + SigningKeyStatus::Usable } } else { identity.single_token_transition_key_status( @@ -445,7 +445,7 @@ impl ScreenLike for TransferTokensScreen { ) }; - if key_status != SingleTokenKeyStatus::Usable { + if key_status != SigningKeyStatus::Usable { let identity_type = identity.identity_type; // Extract key data before releasing the borrow (needed for click handlers). let key = [Purpose::AUTHENTICATION, Purpose::TRANSFER] @@ -459,7 +459,7 @@ impl ScreenLike for TransferTokensScreen { ) }) .cloned(); - let message = if key_status == SingleTokenKeyStatus::HeldButUnusable { + let message = if key_status == SigningKeyStatus::HeldButUnusable { "No key on this device can sign this token transfer. The identity's keys are disabled, expired, reserved for another app or token, or not available on this device. Add a new key, or load the wallet that holds one, then try again.".to_string() } else { format!( diff --git a/src/ui/tokens/update_token_config.rs b/src/ui/tokens/update_token_config.rs index 215cd12c7..3589031b6 100644 --- a/src/ui/tokens/update_token_config.rs +++ b/src/ui/tokens/update_token_config.rs @@ -4,8 +4,9 @@ use crate::backend_task::tokens::TokenTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::fee_estimation::format_credits_as_dash; -use crate::model::identity_key_usability::{KeyRequirements, SigningScope}; +use crate::model::identity_key_usability::SigningScope; use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::qualified_identity::SigningKeyStatus; use crate::model::token::token_change_item_is_supported; use crate::model::user_role::UserRole; use crate::model::wallet::Wallet; @@ -84,13 +85,7 @@ impl UpdateTokenConfigScreen { pub fn new(identity_token_info: IdentityTokenInfo, app_context: &Arc) -> Self { let possible_key = identity_token_info .identity - .signing_key_now(KeyRequirements::new( - Purpose::AUTHENTICATION, - &[SecurityLevel::CRITICAL], - SigningScope::ContractWide { - contract_id: identity_token_info.data_contract.contract.id(), - }, - )) + .token_action_key(identity_token_info.data_contract.contract.id()) .cloned(); // Initialize with no group - will be set when user selects a change item @@ -926,107 +921,101 @@ impl ScreenLike for UpdateTokenConfigScreen { ui.heading("Update Token Configuration"); ui.add_space(10.0); - // Check if user has any auth keys - let has_keys = if self.app_context.user_role().at_least(UserRole::Developer) { - !self.identity.identity.public_keys().is_empty() - } else { - !self - .identity - .available_authentication_keys_with_critical_security_level() - .is_empty() - }; - - if !has_keys { - ui.colored_label( - Color32::DARK_RED, - format!( - "No authentication keys with CRITICAL security level found for this {} identity.", - self.identity.identity_type, - ), + // Open the form only when the action's key choice picks a key. + let key_status = super::token_action_key_status( + &self.app_context, + &self.identity, + self.identity_token_info.data_contract.contract.id(), ); - ui.add_space(10.0); - // Show "Add key" or "Check keys" option - let first_key = self.identity.identity.get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ); + if key_status != SigningKeyStatus::Usable { + ui.colored_label( + Color32::DARK_RED, + super::token_action_key_message(key_status, &self.identity), + ); + ui.add_space(10.0); + + // Show "Add key" or "Check keys" option + let first_key = self.identity.identity.get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([SecurityLevel::CRITICAL]), + KeyType::all_key_types().into(), + false, + ); - if let Some(key) = first_key { - if ui.button("Check Keys").clicked() { - action |= AppAction::AddScreen(Screen::KeyInfoScreen(KeyInfoScreen::new( + if let Some(key) = first_key { + if ui.button("Check Keys").clicked() { + action |= + AppAction::AddScreen(Screen::KeyInfoScreen(KeyInfoScreen::new( + self.identity.clone(), + key.clone(), + None, + &self.app_context, + ))); + } + ui.add_space(5.0); + } + + if ui.button("Add key").clicked() { + action |= AppAction::AddScreen(Screen::AddKeyScreen(AddKeyScreen::new( self.identity.clone(), - key.clone(), - None, &self.app_context, ))); } - ui.add_space(5.0); - } - - if ui.button("Add key").clicked() { - action |= AppAction::AddScreen(Screen::AddKeyScreen(AddKeyScreen::new( - self.identity.clone(), - &self.app_context, - ))); - } - } else { - // Possibly handle locked wallet scenario (similar to TransferTokens) - if let Some(wallet) = &self.selected_wallet { - if !self.wallet_open_attempted { - if let Err(e) = try_open_wallet_no_password(&self.app_context, wallet) { - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error) - .disable_auto_dismiss(); + } else { + // Possibly handle locked wallet scenario (similar to TransferTokens) + if let Some(wallet) = &self.selected_wallet { + if !self.wallet_open_attempted { + if let Err(e) = try_open_wallet_no_password(&self.app_context, wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error) + .disable_auto_dismiss(); + } + self.wallet_open_attempted = true; } - self.wallet_open_attempted = true; - } - if wallet_needs_unlock(wallet) { - ui.add_space(10.0); - ui.colored_label( - egui::Color32::from_rgb(200, 150, 50), - "Wallet is locked. Please unlock to continue.", - ); - ui.add_space(8.0); - if ui.button("Unlock Wallet").clicked() { - self.wallet_unlock_popup.open(); + if wallet_needs_unlock(wallet) { + ui.add_space(10.0); + ui.colored_label( + egui::Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to continue.", + ); + ui.add_space(8.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } + return; } - return; } - } - // Header with Advanced Options checkbox - ui.horizontal(|ui| { - ui.heading("Update Token Config"); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.checkbox(&mut self.show_advanced_options, "Advanced Options"); + // Header with Advanced Options checkbox + ui.horizontal(|ui| { + ui.heading("Update Token Config"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.checkbox(&mut self.show_advanced_options, "Advanced Options"); + }); }); - }); - ui.add_space(10.0); - - // Key selection (only in advanced mode) - if self.show_advanced_options { - ui.heading("1. Select the key to sign the transaction with"); ui.add_space(10.0); - add_key_chooser( - ui, - &self.app_context, - &self.identity, - &mut self.signing_key, - TransactionType::TokenAction, - SigningScope::ContractWide { - contract_id: self.identity_token_info.data_contract.contract.id(), - }, - ); - ui.add_space(10.0); - ui.separator(); - } - ui.add_space(10.0); - action |= self.render_token_config_updater(ui); + // Key selection (only in advanced mode) + if self.show_advanced_options { + ui.heading("1. Select the key to sign the transaction with"); + ui.add_space(10.0); + add_key_chooser( + ui, + &self.app_context, + &self.identity, + &mut self.signing_key, + TransactionType::TokenAction, + SigningScope::ContractWide { + contract_id: self.identity_token_info.data_contract.contract.id(), + }, + ); + ui.add_space(10.0); + ui.separator(); + } + ui.add_space(10.0); - } + action |= self.render_token_config_updater(ui); + } }); // end of ScrollArea }); From 7afed21703e5b9e3da16955d32179a4a053362d7 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:47:47 +0000 Subject: [PATCH 45/58] fix(security): keep no plaintext copy of a key entered on its Key Info page After a private key was entered and saved, the screen kept the in-memory record holding it in plaintext, and the input kept the text, so removing the key later showed it again for a one-click re-add. The screen now drops that record, shows the record as stored with the key in the vault, and clears the input. Tests cover re-entering a legacy encrypted key, refusing a wrong key in its place, and refusing it on a password-protected identity. The test keypair is random instead of fixed key material. Co-Authored-By: Claude Opus 5.5 --- src/ui/identity/keys/key_info_screen.rs | 179 +++++++++++++++++++++++- 1 file changed, 173 insertions(+), 6 deletions(-) diff --git a/src/ui/identity/keys/key_info_screen.rs b/src/ui/identity/keys/key_info_screen.rs index fcce61a17..573c44976 100644 --- a/src/ui/identity/keys/key_info_screen.rs +++ b/src/ui/identity/keys/key_info_screen.rs @@ -1181,11 +1181,14 @@ impl KeyInfoScreen { ) }); match updated { - Ok(fresh) => { - // Resolved from the record just persisted, so the screen - // shows exactly what was filed. - self.private_key_data = fresh.private_keys.held_private_key_data(&self.key); - self.identity = fresh; + Ok(edited) => { + // `edited` is the record as changed in memory, holding the + // key in plaintext; the store filed it in the vault. Drop + // that copy and show the record as stored, and clear the + // input so a later removal does not offer the key again. + drop(edited); + self.private_key_input.clear(); + self.reload_identity(); } Err(error) => { // An input refusal dismisses on its own; a record that @@ -2366,7 +2369,7 @@ mod tests { /// A key and the private key that opens it, so the paste path's validation /// passes and the placement check is what decides the outcome. fn keypair(id: KeyID, purpose: Purpose) -> (IdentityPublicKey, [u8; 32]) { - let secret_bytes = [0x2A; 32]; + let secret_bytes = rand::random::<[u8; 32]>(); let secret = SecretKey::from_secret_bytes(secret_bytes).expect("a valid secret key"); let data = PrivateKey::from_byte_array(secret.as_secret_bytes(), Network::Testnet) .expect("valid secret key") @@ -2515,6 +2518,170 @@ mod tests { .await; } + /// `identity` with `key` published and its private half held only in the + /// per-key encrypted form of early versions, which this version cannot + /// open. + fn hold_as_legacy(identity: &mut QualifiedIdentity, key: &IdentityPublicKey) { + use dash_sdk::dpp::identity::accessors::IdentitySettersV0; + let mut published = identity.identity.public_keys().clone(); + published.insert(key.id(), key.clone()); + identity.identity.set_public_keys(published); + identity.private_keys.insert_at( + (MAIN, key.id()), + ( + QualifiedIdentityPublicKey::from(key.clone()), + PrivateKeyData::Encrypted(rand::random::<[u8; 48]>().to_vec()), + ), + ); + } + + /// The data `identity` holds for `key`, without its wallet details. + fn held_data(identity: &QualifiedIdentity, key: &IdentityPublicKey) -> Option { + identity + .private_keys + .held_private_key_data(key) + .map(|(data, _)| data) + } + + /// Re-entering a key an early version saved in its legacy encrypted form + /// overwrites that slot in the vault. Afterwards the screen holds no + /// plaintext copy, and the input is empty, so removing the key later does + /// not show it again for a one-click re-add. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn reentering_a_legacy_key_files_it_in_the_vault_and_forgets_it() { + let (app_context, _dir) = offline_ctx().await; + let (key, secret) = keypair(1, Purpose::AUTHENTICATION); + let mut stored = identity_with(0x5D, &[]); + hold_as_legacy(&mut stored, &key); + let identity_id = stored.identity.id(); + app_context + .insert_local_qualified_identity(&stored, &None) + .expect("insert the record"); + + let held = stored.private_keys.held_private_key_data(&key); + let mut screen = KeyInfoScreen::new(stored, key.clone(), held, &app_context); + screen.private_key_input.set_text(hex::encode(secret)); + screen.validate_and_store_private_key(); + + let on_disk = app_context + .get_local_qualified_identity(&identity_id) + .expect("read back") + .expect("still stored"); + assert_eq!( + held_data(&on_disk, &key), + Some(PrivateKeyData::InVault), + "the legacy slot is overwritten with the vault copy", + ); + assert_eq!( + screen.private_key_data.as_ref().map(|(data, _)| data), + Some(&PrivateKeyData::InVault), + "the screen shows the stored placement, not the plaintext", + ); + assert_eq!( + held_data(&screen.identity, &key), + Some(PrivateKeyData::InVault) + ); + assert!( + screen.private_key_input.is_empty(), + "the entered key does not stay in the input", + ); + + app_context + .wallet_backend() + .expect("backend") + .shutdown() + .await; + } + + /// A key that does not match is refused while the slot holds the legacy + /// encrypted form, and the slot is left as it was. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_wrong_key_does_not_replace_a_legacy_key() { + let (app_context, _dir) = offline_ctx().await; + let (key, _) = keypair(1, Purpose::AUTHENTICATION); + let (_, other_secret) = keypair(2, Purpose::AUTHENTICATION); + let mut stored = identity_with(0x5E, &[]); + hold_as_legacy(&mut stored, &key); + let identity_id = stored.identity.id(); + app_context + .insert_local_qualified_identity(&stored, &None) + .expect("insert the record"); + + let held = stored.private_keys.held_private_key_data(&key); + let mut screen = KeyInfoScreen::new(stored, key.clone(), held, &app_context); + screen.private_key_input.set_text(hex::encode(other_secret)); + screen.validate_and_store_private_key(); + + let on_disk = app_context + .get_local_qualified_identity(&identity_id) + .expect("read back") + .expect("still stored"); + assert!(matches!( + held_data(&on_disk, &key), + Some(PrivateKeyData::Encrypted(_)) + )); + assert!(matches!( + screen.private_key_data, + Some((PrivateKeyData::Encrypted(_), _)) + )); + assert_eq!( + crate::ui::components::message_banner::global_banner_texts(app_context.egui_ctx()), + ["Private key does not match the public key."] + ); + + app_context + .wallet_backend() + .expect("backend") + .shutdown() + .await; + } + + /// On a password-protected identity a re-entered legacy key cannot be + /// stored without its password, so the keyless persist is refused with + /// its own message and the legacy slot is left as it was. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn reentering_a_legacy_key_of_a_protected_identity_is_refused() { + let (app_context, _dir) = offline_ctx().await; + let sealed = public_key(0, Purpose::AUTHENTICATION); + let (key, secret) = keypair(1, Purpose::AUTHENTICATION); + let mut stored = protected_identity(&app_context, 0x7D, &sealed, &[]); + hold_as_legacy(&mut stored, &key); + let identity_id = stored.identity.id(); + app_context + .insert_local_qualified_identity(&stored, &None) + .expect("a record with no resident plaintext inserts cleanly"); + + let held = stored.private_keys.held_private_key_data(&key); + let mut screen = KeyInfoScreen::new(stored, key.clone(), held, &app_context); + screen.private_key_input.set_text(hex::encode(secret)); + screen.validate_and_store_private_key(); + + let on_disk = app_context + .get_local_qualified_identity(&identity_id) + .expect("read back") + .expect("still stored"); + assert!(matches!( + held_data(&on_disk, &key), + Some(PrivateKeyData::Encrypted(_)) + )); + assert!(matches!( + screen.private_key_data, + Some((PrivateKeyData::Encrypted(_), _)) + )); + let banner_texts = + crate::ui::components::message_banner::global_banner_texts(app_context.egui_ctx()); + assert!( + banner_texts.contains(&TaskError::IdentityKeyProtectionDowngrade.to_string()), + "got {banner_texts:?}", + ); + + app_context + .wallet_backend() + .expect("backend") + .shutdown() + .await; + } + /// The mirror of the paste refusal: a removal that cannot persist must not /// leave the screen claiming the key is gone. The locked read-modify-write /// edits the record as stored, so a record that vanished while this screen From b2f42b44ce8ba27fd4e98d430c44a9b1a97b83b3 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 29 Oct 2026 08:57:01 +0000 Subject: [PATCH 46/58] fix(identity): pick and gate credit and username keys by what Platform accepts - Automatic transfer and withdrawal keys now go through the live signing selector (`signing_key_now`, non-batch): an expired, contract-bound or unsupported-type key is passed over for the next usable one, TRANSFER before OWNER for masternodes. Explicitly chosen keys keep their own check. - The transfer and withdrawal forms (and the identity hub's Move to wallet button) open exactly when that choice picks a key, and say when the identity holds a key that cannot sign instead of claiming none is loaded. - Username registration takes its accepted key levels from both DPNS documents (`dpns_signing_requirement`) through one source, `dpns_security_levels`, shared by the automatic choice, the Advanced chooser and the check of a chosen key. Co-Authored-By: Claude Opus 5.5 --- src/context/test_support.rs | 44 ++ src/model/dpns_usernames.rs | 27 +- src/model/qualified_identity/mod.rs | 455 ++++++++++++++----- src/ui/helpers.rs | 28 +- src/ui/identity/register_dpns_name_screen.rs | 27 +- src/ui/identity/transfer_screen.rs | 34 +- src/ui/identity/withdraw_screen.rs | 37 +- tests/kittest/transfer_screen.rs | 61 +++ tests/kittest/withdraw_screen.rs | 57 ++- 9 files changed, 590 insertions(+), 180 deletions(-) diff --git a/src/context/test_support.rs b/src/context/test_support.rs index f087c3ffe..6ce1f3408 100644 --- a/src/context/test_support.rs +++ b/src/context/test_support.rs @@ -353,6 +353,50 @@ pub(crate) fn dashpay_contract() -> dash_sdk::platform::DataContract { .expect("the DashPay contract") } +/// The DPNS system contract with its `preorder` and `domain` document types +/// replaced by minimal types requiring `preorder` and `domain` security, for +/// tests where the two registration documents disagree. +pub(crate) fn dpns_contract_requiring( + preorder: dash_sdk::dpp::identity::SecurityLevel, + domain: dash_sdk::dpp::identity::SecurityLevel, +) -> dash_sdk::platform::DataContract { + use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; + use dash_sdk::dpp::data_contract::config::DataContractConfig; + use dash_sdk::dpp::data_contract::document_type::DocumentType; + use dash_sdk::dpp::platform_value::platform_value; + use dash_sdk::dpp::system_data_contracts::{SystemDataContract, load_system_data_contract}; + + let version = dash_sdk::dpp::version::PlatformVersion::latest(); + let mut contract = + load_system_data_contract(SystemDataContract::DPNS, version).expect("the DPNS contract"); + let config = DataContractConfig::default_for_version(version).expect("config"); + for (name, level) in [("preorder", preorder), ("domain", domain)] { + let document_type = DocumentType::try_from_schema( + contract.id(), + 1, + config.version(), + name, + platform_value!({ + "type": "object", + "signatureSecurityLevelRequirement": level as u8 as u32, + "properties": {"value": {"type": "string", "maxLength": 20_u32, "position": 0_u32}}, + "additionalProperties": false, + }), + None, + &std::collections::BTreeMap::new(), + &config, + false, + &mut Vec::new(), + version, + ) + .expect("a document type"); + contract + .document_types_mut() + .insert(name.to_string(), document_type); + } + contract +} + /// A user identity whose only key, key 1 (an ECDSA HIGH AUTHENTICATION key /// bound to `bounds`), is held as a reference into an open test wallet, and /// that wallet. diff --git a/src/model/dpns_usernames.rs b/src/model/dpns_usernames.rs index 2c2a49a76..4b7d3d9b6 100644 --- a/src/model/dpns_usernames.rs +++ b/src/model/dpns_usernames.rs @@ -155,15 +155,14 @@ pub fn chosen_key_can_sign_documents( key: &dash_sdk::platform::IdentityPublicKey, required: dash_sdk::dpp::identity::SecurityLevel, ) -> bool { + use dash_sdk::dpp::identity::Purpose; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; - use dash_sdk::dpp::identity::{Purpose, SecurityLevel}; key.purpose() == Purpose::AUTHENTICATION && DPNS_SIGNING_KEY_TYPES.contains(&key.key_type()) && !key.is_disabled() && super::identity_key_usability::bounds_permit(key.contract_bounds(), dpns_signing_scope()) != Some(false) - && key.security_level() != SecurityLevel::MASTER - && key.security_level() <= required + && dpns_security_levels(required).contains(&key.security_level()) } /// The key types a username registration is signed with: ECDSA keys, as @@ -207,6 +206,28 @@ pub fn dpns_signing_requirement( .unwrap_or(dash_sdk::dpp::identity::SecurityLevel::HIGH) } +/// The key security levels that may sign documents requiring `required` +/// security in a username registration: CRITICAL up to and including +/// `required`, since a stronger key meets a weaker requirement, and never +/// MASTER. Empty when `required` is MASTER. The one source of the levels for +/// the automatic choice ([`QualifiedIdentity::username_registration_key`]), +/// the Advanced key chooser, and the check of a key the user chose +/// ([`chosen_key_can_sign_documents`]); pass [`dpns_signing_requirement`] so +/// both registration documents decide. +pub fn dpns_security_levels( + required: dash_sdk::dpp::identity::SecurityLevel, +) -> Vec { + use dash_sdk::dpp::identity::SecurityLevel; + [ + SecurityLevel::CRITICAL, + SecurityLevel::HIGH, + SecurityLevel::MEDIUM, + ] + .into_iter() + .filter(|level| *level <= required) + .collect() +} + /// Whether this device holds a private key that can sign username registrations /// (documents requiring `required` security) for `identity`, and can obtain it. pub fn can_register_usernames( diff --git a/src/model/qualified_identity/mod.rs b/src/model/qualified_identity/mod.rs index 3814399fd..0e7fa2f4b 100644 --- a/src/model/qualified_identity/mod.rs +++ b/src/model/qualified_identity/mod.rs @@ -8,7 +8,9 @@ pub mod qualified_identity_public_key; // requires making that secret-seam chokepoint generic over the closure error // type — a wallet_backend change out of scope here. use crate::backend_task::error::{SigningKeyUnavailableCause, TaskError}; -use crate::model::dpns_usernames::DPNS_SIGNING_KEY_TYPES; +use crate::model::dpns_usernames::{ + DPNS_SIGNING_KEY_TYPES, dpns_security_levels, dpns_signing_requirement, +}; use crate::model::identity_key_usability::{ KeyRequirements, PLATFORM_SIGNING_KEY_TYPES, SigningScope, document_security_levels, now_ms, select_identity_signing_key, @@ -1206,22 +1208,27 @@ impl QualifiedIdentity { ) } - /// The key to register a username with: a [`Self::document_signing_key`] - /// for DPNS `preorder` documents, across the DPNS contract (a registration - /// writes preorder and domain documents), of one of - /// [`DPNS_SIGNING_KEY_TYPES`]. `None` when `dpns_contract` has no - /// `preorder` type or no key qualifies. + /// The key to register a username with: a live AUTHENTICATION key of one + /// of [`DPNS_SIGNING_KEY_TYPES`], at a level both registration documents + /// (preorder and domain) accept ([`dpns_security_levels`] of + /// [`dpns_signing_requirement`], the levels the Advanced chooser offers + /// and the backend checks a chosen key against), whose bounds permit the + /// DPNS contract as a whole, picked as [`Self::signing_key_now`] picks. + /// `None` when no key qualifies. pub fn username_registration_key( &self, dpns_contract: &DataContract, ) -> Option<&IdentityPublicKey> { - let preorder = dpns_contract.document_type_for_name("preorder").ok()?; - self.document_signing_key( - SigningScope::ContractWide { - contract_id: dpns_contract.id(), - }, - &preorder, - DPNS_SIGNING_KEY_TYPES, + let levels = dpns_security_levels(dpns_signing_requirement(dpns_contract)); + self.signing_key_now( + KeyRequirements::new( + Purpose::AUTHENTICATION, + &levels, + SigningScope::ContractWide { + contract_id: dpns_contract.id(), + }, + ) + .with_key_types(DPNS_SIGNING_KEY_TYPES), ) } @@ -1440,95 +1447,115 @@ impl QualifiedIdentity { keys } - /// Whether the active role can attempt a withdrawal with this identity. + /// Whether the active role can attempt a withdrawal with this identity: + /// exactly when [`Self::withdrawal_signing_key`] picks a key, the key the + /// withdrawal form pre-selects and the backend signs with when none is + /// chosen, so an open form can always sign. /// - /// Uniform across every role: a withdrawal is only ever executable with a - /// locally-held TRANSFER or OWNER key (see - /// [`resolve_withdrawal_signing_key`](Self::resolve_withdrawal_signing_key), - /// the backend's unconditional enforcement layer — no role currently - /// relaxes it). A Developer-only "any on-chain key" carve-out previously - /// existed here and in [`default_withdrawal_key`](Self::default_withdrawal_key)'s - /// caller, but nothing downstream ever accepted an on-chain-only key for - /// signing, so that carve-out only produced an enabled button that led to - /// a blank or failing withdrawal screen. Gate on the same invariant the - /// backend enforces, so this predicate never lies about what the screen - /// can actually do. + /// Uniform across every role. A Developer-only "any on-chain key" + /// carve-out previously existed here, but nothing downstream ever accepted + /// an on-chain-only key for signing, so it only produced an enabled button + /// that led to a blank or failing withdrawal screen. pub fn can_attempt_withdrawal(&self, _role: UserRole) -> bool { - !self.available_withdrawal_keys().is_empty() + self.withdrawal_key_status() == SigningKeyStatus::Usable } - /// Returns the key to pre-select for signing a withdrawal. - /// - /// Only keys whose private material is held locally are considered (via - /// [`available_withdrawal_keys`](Self::available_withdrawal_keys)). A - /// `TRANSFER` key is preferred, falling back to an `OWNER` key — mirroring - /// Platform's `TransferPreferred` signing-key selection. Returns `None` when - /// no locally-signable withdrawal key exists, so callers never pre-select an - /// on-chain key the signer cannot actually use. - pub fn default_withdrawal_key(&self) -> Option<&QualifiedIdentityPublicKey> { - let keys = self.available_withdrawal_keys(); - keys.iter() - .find(|qk| qk.identity_public_key.purpose() == Purpose::TRANSFER) - .or_else(|| { - keys.iter() - .find(|qk| qk.identity_public_key.purpose() == Purpose::OWNER) + /// The purposes a withdrawal from this identity may be signed with, in + /// preference order: `TRANSFER`, then `OWNER` for a masternode or evonode, + /// mirroring Platform's `TransferPreferred` selection. + fn withdrawal_purposes(&self) -> &'static [Purpose] { + match self.identity_type { + IdentityType::User => &[Purpose::TRANSFER], + IdentityType::Masternode | IdentityType::Evonode => { + &[Purpose::TRANSFER, Purpose::OWNER] + } + } + } + + /// The key to sign a withdrawal with when none is chosen: a live key of + /// one of [`Self::withdrawal_purposes`] (the first purpose with a usable + /// key wins), of a type Platform verifies, not bound to a contract (a + /// withdrawal is not a batch), picked as [`Self::signing_key_now`] picks, + /// so a key Platform refuses is passed over for the next usable one. + pub fn withdrawal_signing_key(&self) -> Option<&IdentityPublicKey> { + let levels = SecurityLevel::full_range(); + self.withdrawal_purposes().iter().find_map(|purpose| { + self.signing_key_now(KeyRequirements::new( + *purpose, + &levels, + SigningScope::NonBatch, + )) + }) + } + + /// Whether this device can sign a withdrawal from this identity: usable + /// exactly when [`Self::withdrawal_signing_key`] picks a key; when not, it + /// tells a filed key that cannot sign apart from no filed key at all. + pub fn withdrawal_key_status(&self) -> SigningKeyStatus { + if self.withdrawal_signing_key().is_some() { + SigningKeyStatus::Usable + } else if self.holds_main_identity_key_of(self.withdrawal_purposes()) { + SigningKeyStatus::HeldButUnusable + } else { + SigningKeyStatus::NoneHeld + } + } + + /// Whether a private key of one of `purposes` is filed for the main + /// identity, whether or not it can sign now. + fn holds_main_identity_key_of(&self, purposes: &[Purpose]) -> bool { + self.private_keys + .identity_public_keys() + .iter() + .any(|(target, key)| { + **target == PrivateKeyTarget::PrivateKeyOnMainIdentity + && purposes.contains(&key.identity_public_key.purpose()) }) - .copied() } /// Resolves the effective key to sign an identity credit withdrawal, /// returning its [`KeyID`]. /// - /// `requested` is the caller's explicit key choice, or `None` to auto-select. - /// Resolution runs against `self.identity`, which the caller must have - /// refreshed from Platform first: a key disabled on-chain since the identity - /// was last loaded is rejected here rather than reaching signing. Only keys - /// whose private material this app holds and whose purpose is `TRANSFER` - /// (preferred) or `OWNER` are eligible, mirroring Platform's - /// `TransferPreferred` selection. + /// `requested` is the caller's explicit key choice, or `None` to take + /// [`Self::withdrawal_signing_key`]'s choice. Resolution runs against + /// `self.identity`, which the caller must have refreshed from Platform + /// first: a key disabled on-chain since the identity was last loaded is + /// rejected here rather than reaching signing. /// - /// An explicit `requested` id that is unknown, disabled, or not locally - /// signable is rejected — never silently replaced with a different key. - /// Returns [`NoUsableWithdrawalKey`] when nothing eligible remains, so the - /// caller can surface a clear error instead of letting the SDK pick a key - /// (which would allow a disabled key or an unintended `OWNER` fallback). + /// An explicit `requested` id must be a locally held `TRANSFER` or `OWNER` + /// key ([`Self::available_withdrawal_keys`]) that is active and whose + /// private half this device can obtain; otherwise it is rejected — never + /// silently replaced with a different key. Its expiry is not checked: the + /// key chooser offers an expired key with a warning, and Platform decides + /// by network time. Returns [`NoUsableWithdrawalKey`] when nothing + /// eligible remains, so the caller can surface a clear error instead of + /// letting the SDK pick a key (which would allow a disabled key or an + /// unintended `OWNER` fallback). pub fn resolve_withdrawal_signing_key( &self, requested: Option, ) -> Result { - // Locally-held TRANSFER/OWNER keys that are also active in the current - // (refreshed) identity snapshot. `available_withdrawal_keys` filters on - // the stored copy's disabled flag; the extra checks catch a key that was - // disabled on-chain after this identity was loaded, and one whose - // private half this device cannot obtain. - let usable: Vec<&QualifiedIdentityPublicKey> = self - .available_withdrawal_keys() - .into_iter() - .filter(|qk| { - matches!( - self.identity.get_public_key_by_id(qk.identity_public_key.id()), - Some(current) if !current.is_disabled() - ) && self.holds_obtainable_key(&qk.identity_public_key) - }) - .collect(); - - if let Some(requested_id) = requested { - return usable - .iter() - .any(|qk| qk.identity_public_key.id() == requested_id) - .then_some(requested_id) + let Some(requested_id) = requested else { + return self + .withdrawal_signing_key() + .map(|key| key.id()) .ok_or(NoUsableWithdrawalKey); - } - - usable - .iter() - .find(|qk| qk.identity_public_key.purpose() == Purpose::TRANSFER) - .or_else(|| { - usable - .iter() - .find(|qk| qk.identity_public_key.purpose() == Purpose::OWNER) + }; + // `available_withdrawal_keys` filters on the stored copy's disabled + // flag; the extra checks catch a key that was disabled on-chain after + // this identity was loaded, and one whose private half this device + // cannot obtain. + self.available_withdrawal_keys() + .into_iter() + .any(|qk| { + qk.identity_public_key.id() == requested_id + && matches!( + self.identity.get_public_key_by_id(requested_id), + Some(current) if !current.is_disabled() + ) + && self.holds_obtainable_key(&qk.identity_public_key) }) - .map(|qk| qk.identity_public_key.id()) + .then_some(requested_id) .ok_or(NoUsableWithdrawalKey) } @@ -1579,34 +1606,61 @@ impl QualifiedIdentity { } } + /// The key to sign a credit transfer with when none is chosen: a live + /// `TRANSFER` key of a type Platform verifies, not bound to a contract (a + /// transfer is not a batch), picked as [`Self::signing_key_now`] picks, so + /// a key Platform refuses is passed over for the next usable one. + pub fn transfer_signing_key(&self) -> Option<&IdentityPublicKey> { + self.signing_key_now(KeyRequirements::new( + Purpose::TRANSFER, + &SecurityLevel::full_range(), + SigningScope::NonBatch, + )) + } + + /// Whether this device can sign a credit transfer from this identity: + /// usable exactly when [`Self::transfer_signing_key`] picks a key; when + /// not, it tells a filed `TRANSFER` key that cannot sign apart from no + /// filed `TRANSFER` key at all. + pub fn transfer_key_status(&self) -> SigningKeyStatus { + if self.transfer_signing_key().is_some() { + SigningKeyStatus::Usable + } else if self.holds_main_identity_key_of(&[Purpose::TRANSFER]) { + SigningKeyStatus::HeldButUnusable + } else { + SigningKeyStatus::NoneHeld + } + } + /// Resolves the key to sign an identity credit transfer, returning its /// [`KeyID`]. /// - /// `requested` is the caller's explicit key choice, or `None` to take the - /// usable key with the lowest id. A key is usable when `self.identity` - /// lists it as an active `TRANSFER` key and this device can obtain its - /// private half ([`holds_obtainable_key`](Self::holds_obtainable_key)). + /// `requested` is the caller's explicit key choice, or `None` to take + /// [`Self::transfer_signing_key`]'s choice. /// - /// An explicit `requested` id that is unknown, disabled, or not locally - /// signable is rejected — never silently replaced with a different key. - /// Returns [`NoUsableTransferKey`] when nothing usable remains, so the - /// caller can say so instead of letting the SDK take the identity's first - /// `TRANSFER` key, which may be disabled or not held here. + /// An explicit `requested` id must be a `TRANSFER` key `self.identity` + /// lists as active, whose private half this device can obtain + /// ([`holds_obtainable_key`](Self::holds_obtainable_key)); otherwise it is + /// rejected — never silently replaced with a different key. Its expiry is + /// not checked: the key chooser offers an expired key with a warning, and + /// Platform decides by network time. Returns [`NoUsableTransferKey`] when + /// nothing usable remains, so the caller can say so instead of letting + /// the SDK take the identity's first `TRANSFER` key, which may be disabled + /// or not held here. pub fn resolve_transfer_signing_key( &self, requested: Option, ) -> Result { - let usable = |key: &&IdentityPublicKey| { - key.purpose() == Purpose::TRANSFER - && !key.is_disabled() - && self.holds_obtainable_key(key) - }; match requested { Some(requested_id) => self .identity .get_public_key_by_id(requested_id) - .filter(usable), - None => self.identity.public_keys().values().find(usable), + .filter(|key| { + key.purpose() == Purpose::TRANSFER + && !key.is_disabled() + && self.holds_obtainable_key(key) + }), + None => self.transfer_signing_key(), } .map(|key| key.id()) .ok_or(NoUsableTransferKey) @@ -2803,11 +2857,16 @@ pub(crate) mod withdrawal_key_tests { use dash_sdk::dpp::version::PlatformVersion; use dash_sdk::platform::Identifier; + /// A CRITICAL `purpose` key of a type Platform verifies (ECDSA secp256k1), + /// derived from `id` so two calls with the same id build the same key. pub(crate) fn key(id: KeyID, purpose: Purpose) -> IdentityPublicKey { - let mut k = IdentityPublicKey::random_key(id, Some(id as u64), PlatformVersion::latest()); - k.set_id(id); + let (mut k, _) = IdentityPublicKey::random_ecdsa_critical_level_authentication_key( + id, + Some(id as u64), + PlatformVersion::latest(), + ) + .expect("a random ECDSA key"); k.set_purpose(purpose); - k.set_security_level(SecurityLevel::CRITICAL); k } @@ -2861,7 +2920,7 @@ pub(crate) mod withdrawal_key_tests { fn ghost_transfer_key_is_not_selected() { let transfer = key(1, Purpose::TRANSFER); let qi = build_identity(IdentityType::User, vec![transfer], vec![]); - assert!(qi.default_withdrawal_key().is_none()); + assert!(qi.withdrawal_signing_key().is_none()); } /// Regression: the withdraw screen's "Check Owner Key" / "Check Payout @@ -2892,18 +2951,18 @@ pub(crate) mod withdrawal_key_tests { fn private_backed_transfer_key_is_selected() { let transfer = key(1, Purpose::TRANSFER); let qi = build_identity(IdentityType::User, vec![transfer.clone()], vec![transfer]); - let selected = qi.default_withdrawal_key().expect("a key"); - assert_eq!(selected.identity_public_key.id(), 1); - assert_eq!(selected.identity_public_key.purpose(), Purpose::TRANSFER); + let selected = qi.withdrawal_signing_key().expect("a key"); + assert_eq!(selected.id(), 1); + assert_eq!(selected.purpose(), Purpose::TRANSFER); } #[test] fn owner_key_is_used_as_fallback_when_no_transfer() { let owner = key(2, Purpose::OWNER); let qi = build_identity(IdentityType::Masternode, vec![owner.clone()], vec![owner]); - let selected = qi.default_withdrawal_key().expect("a key"); - assert_eq!(selected.identity_public_key.id(), 2); - assert_eq!(selected.identity_public_key.purpose(), Purpose::OWNER); + let selected = qi.withdrawal_signing_key().expect("a key"); + assert_eq!(selected.id(), 2); + assert_eq!(selected.purpose(), Purpose::OWNER); } #[test] @@ -2915,8 +2974,8 @@ pub(crate) mod withdrawal_key_tests { vec![owner.clone(), transfer.clone()], vec![owner, transfer], ); - let selected = qi.default_withdrawal_key().expect("a key"); - assert_eq!(selected.identity_public_key.purpose(), Purpose::TRANSFER); + let selected = qi.withdrawal_signing_key().expect("a key"); + assert_eq!(selected.purpose(), Purpose::TRANSFER); } pub(crate) fn disabled_key(id: KeyID, purpose: Purpose) -> IdentityPublicKey { @@ -2938,9 +2997,9 @@ pub(crate) mod withdrawal_key_tests { vec![disabled.clone(), active.clone()], vec![disabled, active], ); - let selected = qi.default_withdrawal_key().expect("a key"); - assert_eq!(selected.identity_public_key.id(), 1); - assert!(!selected.identity_public_key.is_disabled()); + let selected = qi.withdrawal_signing_key().expect("a key"); + assert_eq!(selected.id(), 1); + assert!(!selected.is_disabled()); } #[test] @@ -2999,7 +3058,7 @@ pub(crate) mod withdrawal_key_tests { vec![disabled.clone()], vec![disabled], ); - assert!(qi.default_withdrawal_key().is_none()); + assert!(qi.withdrawal_signing_key().is_none()); assert!(qi.available_withdrawal_keys().is_empty()); } @@ -3020,9 +3079,9 @@ pub(crate) mod withdrawal_key_tests { ], vec![disabled_transfer, owner, active_transfer], ); - let selected = qi.default_withdrawal_key().expect("a key"); - assert_eq!(selected.identity_public_key.id(), 2); - assert_eq!(selected.identity_public_key.purpose(), Purpose::TRANSFER); + let selected = qi.withdrawal_signing_key().expect("a key"); + assert_eq!(selected.id(), 2); + assert_eq!(selected.purpose(), Purpose::TRANSFER); } /// Auto-select (no explicit id) resolves the active TRANSFER key, matching @@ -3342,6 +3401,110 @@ pub(crate) mod withdrawal_key_tests { ); } + /// Keys Platform refuses for a credit transfer or withdrawal, each with id + /// 1: expired, bound to a contract (refused outside a batch), and of a type + /// Platform does not verify. + fn refused_credit_keys(purpose: Purpose) -> Vec<(&'static str, IdentityPublicKey)> { + use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; + let mut bound = key(1, purpose); + if let IdentityPublicKey::V0(v0) = &mut bound { + v0.contract_bounds = Some(ContractBounds::SingleContract { + id: Identifier::new([0x09; 32]), + }); + } + let mut eddsa = key(1, purpose); + eddsa.set_key_type(KeyType::EDDSA_25519_HASH160); + vec![ + ("expired", key(1, purpose).with_limits(None, Some(1))), + ("contract bound", bound), + ("EdDSA", eddsa), + ] + } + + /// Regression: the automatic transfer and withdrawal choices checked only + /// purpose, disabled state and the private half, so a key Platform + /// refuses won over a later usable key and the send failed. + #[test] + fn automatic_credit_keys_skip_a_key_platform_refuses() { + for (case, refused) in refused_credit_keys(Purpose::TRANSFER) { + let usable = key(2, Purpose::TRANSFER); + let qi = build_identity( + IdentityType::User, + vec![refused.clone(), usable.clone()], + vec![refused, usable], + ); + assert_eq!(qi.resolve_transfer_signing_key(None), Ok(2), "{case}"); + assert_eq!(qi.transfer_signing_key().map(|k| k.id()), Some(2), "{case}"); + assert_eq!(qi.resolve_withdrawal_signing_key(None), Ok(2), "{case}"); + assert_eq!( + qi.withdrawal_signing_key().map(|k| k.id()), + Some(2), + "{case}" + ); + } + } + + /// A masternode whose TRANSFER key Platform refuses withdraws with its + /// OWNER key instead. + #[test] + fn a_masternode_withdrawal_falls_back_to_owner_past_a_refused_transfer_key() { + for (case, refused) in refused_credit_keys(Purpose::TRANSFER) { + let owner = key(2, Purpose::OWNER); + let qi = build_identity( + IdentityType::Masternode, + vec![refused.clone(), owner.clone()], + vec![refused, owner], + ); + assert_eq!(qi.resolve_withdrawal_signing_key(None), Ok(2), "{case}"); + } + } + + /// A user identity never withdraws with an OWNER key, as before. + #[test] + fn a_user_withdrawal_never_takes_an_owner_key() { + let owner = key(1, Purpose::OWNER); + let qi = build_identity(IdentityType::User, vec![owner.clone()], vec![owner]); + assert!(qi.withdrawal_signing_key().is_none()); + assert_eq!(qi.withdrawal_key_status(), SigningKeyStatus::NoneHeld); + } + + /// The transfer and withdrawal gates open exactly when the automatic + /// choice picks a key, and tell a held-but-unusable key apart from none. + #[test] + fn the_credit_key_gates_open_exactly_when_a_key_is_picked() { + for (case, refused) in refused_credit_keys(Purpose::TRANSFER) { + let qi = build_identity(IdentityType::User, vec![refused.clone()], vec![refused]); + assert_eq!( + qi.transfer_key_status(), + SigningKeyStatus::HeldButUnusable, + "{case}" + ); + assert_eq!( + qi.withdrawal_key_status(), + SigningKeyStatus::HeldButUnusable, + "{case}" + ); + assert!(!qi.can_attempt_withdrawal(UserRole::Developer), "{case}"); + } + + let public_only = + build_identity(IdentityType::User, vec![key(1, Purpose::TRANSFER)], vec![]); + assert_eq!( + public_only.transfer_key_status(), + SigningKeyStatus::NoneHeld + ); + assert_eq!( + public_only.withdrawal_key_status(), + SigningKeyStatus::NoneHeld + ); + + let usable = key(1, Purpose::TRANSFER); + let qi = build_identity(IdentityType::User, vec![usable.clone()], vec![usable]); + assert_eq!(qi.transfer_key_status(), SigningKeyStatus::Usable); + assert_eq!(qi.withdrawal_key_status(), SigningKeyStatus::Usable); + assert!(qi.can_attempt_withdrawal(UserRole::Everyday)); + } + fn payout_key(id: KeyID, hash: [u8; 20]) -> IdentityPublicKey { let mut k = key(id, Purpose::TRANSFER); k.set_key_type(KeyType::ECDSA_HASH160); @@ -4030,6 +4193,58 @@ mod protocol_v14_compat_tests { assert!(qi.username_registration_key(&dpns).is_none()); } + /// Regression: the automatic username registration key met only the + /// `preorder` document's requirement, so when `domain` asked for a + /// stronger key the registration signed the second document with a key + /// Platform refuses. Both documents' requirement decides, whichever is + /// stricter. + #[test] + fn a_username_registration_key_meets_both_registration_documents() { + use crate::context::test_support::{auth_key, dpns_contract_requiring, identity_holding}; + use crate::model::dpns_usernames::{dpns_security_levels, dpns_signing_requirement}; + + let qi = identity_holding(&[ + auth_key(1, SecurityLevel::HIGH), + auth_key(2, SecurityLevel::CRITICAL), + ]); + for (preorder, domain) in [ + (SecurityLevel::HIGH, SecurityLevel::CRITICAL), + (SecurityLevel::CRITICAL, SecurityLevel::HIGH), + ] { + let dpns = dpns_contract_requiring(preorder, domain); + let case = format!("preorder {preorder:?}, domain {domain:?}"); + assert_eq!( + qi.username_registration_key(&dpns).map(|k| k.id()), + Some(2), + "{case}" + ); + assert_eq!( + dpns_security_levels(dpns_signing_requirement(&dpns)), + vec![SecurityLevel::CRITICAL], + "{case}: the Advanced chooser offers the same levels" + ); + } + } + + /// The DPNS levels are the ones the explicit-key check accepts: CRITICAL + /// up to the requirement, never MASTER. + #[test] + fn dpns_levels_match_the_explicit_key_check() { + use crate::context::test_support::auth_key; + use crate::model::dpns_usernames::{chosen_key_can_sign_documents, dpns_security_levels}; + + for required in SecurityLevel::full_range() { + let levels = dpns_security_levels(required); + for level in SecurityLevel::full_range() { + assert_eq!( + levels.contains(&level), + chosen_key_can_sign_documents(&auth_key(1, level), required), + "required {required:?}, key {level:?}" + ); + } + } + } + /// The document signing key skips a key Platform would refuse (expired, /// bound elsewhere) and prefers an unlimited key over a limited one. #[test] diff --git a/src/ui/helpers.rs b/src/ui/helpers.rs index 649dd1cec..7c9ddcc39 100644 --- a/src/ui/helpers.rs +++ b/src/ui/helpers.rs @@ -739,16 +739,40 @@ pub fn add_key_chooser_with_doc_type( transaction_type: TransactionType, document_type: Option<&DocumentType>, scope: SigningScope<'_>, +) -> AppAction { + let allowed_security_levels = compute_allowed_security_levels(transaction_type, document_type); + add_key_chooser_with_security_levels( + ui, + app_context, + identity, + selected_key, + transaction_type, + &allowed_security_levels, + scope, + ) +} + +/// Key chooser offering keys at `allowed_security_levels` only, for a +/// transaction whose accepted levels no single document type decides (a +/// username registration signs both DPNS preorder and domain documents). +/// Otherwise as [`add_key_chooser_with_doc_type`]. +pub fn add_key_chooser_with_security_levels( + ui: &mut Ui, + app_context: &Arc, + identity: &QualifiedIdentity, + selected_key: &mut Option, + transaction_type: TransactionType, + allowed_security_levels: &[SecurityLevel], + scope: SigningScope<'_>, ) -> AppAction { let is_dev_mode = app_context.user_role().at_least(UserRole::Developer); let mut action = AppAction::None; let allowed_purposes = transaction_type.allowed_purposes(); - let allowed_security_levels = compute_allowed_security_levels(transaction_type, document_type); let filter = KeyFilter::new( transaction_type, &allowed_purposes, - &allowed_security_levels, + allowed_security_levels, scope, ); diff --git a/src/ui/identity/register_dpns_name_screen.rs b/src/ui/identity/register_dpns_name_screen.rs index 77d71aae9..c9ef322b0 100644 --- a/src/ui/identity/register_dpns_name_screen.rs +++ b/src/ui/identity/register_dpns_name_screen.rs @@ -12,8 +12,8 @@ use crate::model::dpns::{ validate_dpns_name, }; use crate::model::dpns_usernames::{ - UsernameAvailability, dpns_signing_requirement, missing_registration_wallets, - registration_key_status, + UsernameAvailability, dpns_security_levels, dpns_signing_requirement, + missing_registration_wallets, registration_key_status, }; use crate::model::fee_estimation::{contest_fee_credits, format_credits_as_dash}; use crate::model::identity_key_usability::SigningScope; @@ -31,7 +31,7 @@ use crate::ui::components::wallet_unlock_popup::{ use crate::ui::components::{ MessageBanner, OptionOverlayExt, OverlayConfig, OverlayHandle, ResultBannerExt, }; -use crate::ui::helpers::{TransactionType, add_key_chooser_with_doc_type}; +use crate::ui::helpers::{TransactionType, add_key_chooser_with_security_levels}; use crate::ui::identity::username_copy::{ self, AvailabilityRow, Tone, USERNAME_RULES, availability_line, offers_suggestions, }; @@ -591,25 +591,24 @@ impl RegisterDpnsNameScreen { egui::CollapsingHeader::new("Advanced") .default_open(false) .show(ui, |ui| { - let document_type = self - .app_context - .dpns_contract() - .document_type_cloned_for_name("domain") - .ok(); + let dpns_contract = self.app_context.dpns_contract(); + // The levels both registration documents accept, as the + // automatic choice and the backend's check of a chosen + // key use them. + let levels = dpns_security_levels(dpns_signing_requirement(&dpns_contract)); if self.selected_key.is_none() { - self.selected_key = identity - .username_registration_key(&self.app_context.dpns_contract()) - .cloned(); + self.selected_key = + identity.username_registration_key(&dpns_contract).cloned(); } - action |= add_key_chooser_with_doc_type( + action |= add_key_chooser_with_security_levels( ui, &self.app_context, &identity, &mut self.selected_key, TransactionType::UsernameRegistration, - document_type.as_ref(), + &levels, SigningScope::ContractWide { - contract_id: self.app_context.dpns_contract().id(), + contract_id: dpns_contract.id(), }, ); }); diff --git a/src/ui/identity/transfer_screen.rs b/src/ui/identity/transfer_screen.rs index 3e017ef8d..fddd7c1c3 100644 --- a/src/ui/identity/transfer_screen.rs +++ b/src/ui/identity/transfer_screen.rs @@ -5,7 +5,7 @@ use crate::context::AppContext; use crate::model::amount::Amount; use crate::model::fee_estimation::{format_credits_as_dash, max_spendable_credits}; use crate::model::identity_key_usability::{KeyRequirements, SigningScope}; -use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::qualified_identity::{QualifiedIdentity, SigningKeyStatus}; use crate::model::user_role::UserRole; use crate::model::wallet::Wallet; use crate::ui::components::amount_input::AmountInput; @@ -94,11 +94,7 @@ impl TransferScreen { let max_amount = identity.identity.balance(); let identity_clone = identity.clone(); - let selected_key = identity_clone.signing_key_now(KeyRequirements::new( - Purpose::TRANSFER, - &SecurityLevel::full_range(), - SigningScope::NonBatch, - )); + let selected_key = identity_clone.transfer_signing_key(); let selected_wallet = get_selected_wallet(&identity, None, selected_key).unwrap_or_else(|e| { MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error) @@ -604,11 +600,18 @@ impl ScreenLike for TransferScreen { return inner_action; } - let has_keys = if self.app_context.user_role().at_least(UserRole::Developer) { - !self.identity.identity.public_keys().is_empty() + // Outside developer mode the form opens exactly when the key its + // constructor pre-selects can sign, as the backend's own choice. + let key_status = if self.app_context.user_role().at_least(UserRole::Developer) { + if self.identity.identity.public_keys().is_empty() { + SigningKeyStatus::NoneHeld + } else { + SigningKeyStatus::Usable + } } else { - !self.identity.available_transfer_keys().is_empty() + self.identity.transfer_key_status() }; + let has_keys = key_status == SigningKeyStatus::Usable; let key_for_info = key_info_when_available( has_keys, @@ -620,13 +623,18 @@ impl ScreenLike for TransferScreen { ); if !has_keys { - ui.colored_label( - egui::Color32::DARK_RED, + let message = if key_status == SigningKeyStatus::HeldButUnusable { + "No transfer key on this device can sign a transfer from this identity. \ + Its transfer keys are disabled, expired, or not available on this device. \ + Add a new key, or load the wallet that holds one, then try again." + .to_string() + } else { format!( "You do not have any transfer keys loaded for this {identity_type} identity.", identity_type = self.identity.identity_type - ), - ); + ) + }; + ui.colored_label(egui::Color32::DARK_RED, message); ui.add_space(10.0); ui.horizontal(|ui| { diff --git a/src/ui/identity/withdraw_screen.rs b/src/ui/identity/withdraw_screen.rs index 0f4e3a1ab..5cc1eef4c 100644 --- a/src/ui/identity/withdraw_screen.rs +++ b/src/ui/identity/withdraw_screen.rs @@ -5,7 +5,7 @@ use crate::context::AppContext; use crate::model::amount::Amount; use crate::model::fee_estimation::{format_credits_as_dash, max_spendable_credits}; use crate::model::identity_key_usability::{KeyRequirements, SigningScope}; -use crate::model::qualified_identity::{IdentityType, QualifiedIdentity}; +use crate::model::qualified_identity::{IdentityType, QualifiedIdentity, SigningKeyStatus}; use crate::model::user_role::UserRole; use crate::model::wallet::Wallet; use crate::ui::components::amount_input::AmountInput; @@ -68,15 +68,12 @@ pub struct WithdrawalScreen { impl WithdrawalScreen { pub fn new(identity: QualifiedIdentity, app_context: &Arc) -> Self { let max_amount = identity.identity.balance(); - // Only pre-select a withdrawal key whose private material is held locally - // (TRANSFER preferred, OWNER fallback). Pre-selecting an on-chain-only key - // the signer cannot use is what surfaced the raw signing error. This is the - // same invariant `can_attempt_withdrawal` gates on for every role, so a - // `None` here means the gate itself should have kept this screen from - // being reachable in the first place. - let selected_key: Option = identity - .default_withdrawal_key() - .map(|qk| qk.identity_public_key.clone()); + // Pre-select the key the backend signs with when none is chosen: one + // this device can sign with and Platform accepts (TRANSFER preferred, + // OWNER fallback). `can_attempt_withdrawal` gates on the same choice + // for every role, so a `None` here means the gate itself should have + // kept this screen from being reachable in the first place. + let selected_key: Option = identity.withdrawal_signing_key().cloned(); // With no key there is nothing to resolve a wallet from; skip the call so // get_selected_wallet's "no key provided" Err path stays unreachable here. let selected_wallet = match selected_key.as_ref() { @@ -427,16 +424,20 @@ impl ScreenLike for WithdrawalScreen { }); ui.add_space(10.0); - if !self - .identity - .can_attempt_withdrawal(self.app_context.user_role()) - { - ui.colored_label( - egui::Color32::DARK_RED, + // The same choice `can_attempt_withdrawal` gates the entry points on. + let key_status = self.identity.withdrawal_key_status(); + if key_status != SigningKeyStatus::Usable { + let message = if key_status == SigningKeyStatus::HeldButUnusable { + "No key on this device can approve a withdrawal from this identity. Its \ + keys are disabled, expired, or not available on this device. Load or \ + import a key that can send funds from this identity, or load the wallet \ + that holds one, then try again." + } else { "This identity has no loaded key that can approve a withdrawal. Load or \ import a key that can send funds from this identity, or use a different \ - identity.", - ); + identity." + }; + ui.colored_label(egui::Color32::DARK_RED, message); ui.add_space(10.0); if self.identity.identity_type != IdentityType::User { diff --git a/tests/kittest/transfer_screen.rs b/tests/kittest/transfer_screen.rs index 7723c0abc..94734258e 100644 --- a/tests/kittest/transfer_screen.rs +++ b/tests/kittest/transfer_screen.rs @@ -46,6 +46,67 @@ fn identity_without_transfer_keys() -> QualifiedIdentity { } } +/// An identity holding only an expired transfer key: it has a key, but no +/// key that can sign a transfer. +fn identity_with_only_an_expired_transfer_key() -> QualifiedIdentity { + use dash_evo_tool::model::qualified_identity::PrivateKeyTarget; + use dash_evo_tool::model::qualified_identity::encrypted_key_storage::PrivateKeyData; + use dash_evo_tool::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; + use dash_sdk::dpp::identity::accessors::IdentitySettersV0; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + use dash_sdk::dpp::identity::{IdentityPublicKey, Purpose}; + + let (mut transfer, _) = IdentityPublicKey::random_ecdsa_critical_level_authentication_key( + 1, + None, + PlatformVersion::latest(), + ) + .expect("a random ECDSA key"); + transfer.set_purpose(Purpose::TRANSFER); + let expired = transfer.with_limits(None, Some(1)); + + let mut qi = identity_without_transfer_keys(); + qi.identity + .set_public_keys(BTreeMap::from([(1, expired.clone())])); + qi.private_keys = KeyStorage::from(BTreeMap::from([( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, 1), + ( + QualifiedIdentityPublicKey::from(expired), + PrivateKeyData::Clear(rand::random()), + ), + )])); + qi +} + +/// The gate opens only for a key the form can sign with: an identity holding +/// only an expired transfer key is told its key cannot sign, not handed a +/// form with no key selected. +#[test] +fn an_expired_transfer_key_closes_the_form_with_its_own_message() { + with_isolated_data_dir(|| { + let (_rt, app_context) = fresh_app_context(); + let mut screen = + TransferScreen::new(identity_with_only_an_expired_transfer_key(), &app_context); + let mut harness = Harness::builder() + .with_size(egui::vec2(1280.0, 900.0)) + .build_ui(move |ui| { + screen.ui(ui); + }); + harness.run_steps(3); + + assert!( + harness + .query_by_label_contains("No transfer key on this device can sign") + .is_some(), + "an expired-key-only identity must be told its key cannot sign" + ); + assert!( + harness.query_by_label("Manage keys").is_some(), + "the closed gate still offers the keys list" + ); + }); +} + /// The no-key branch signposts the keys list instead of dead-ending on "Add /// key", and clicking it opens that list. #[test] diff --git a/tests/kittest/withdraw_screen.rs b/tests/kittest/withdraw_screen.rs index bfa898172..d2570a3df 100644 --- a/tests/kittest/withdraw_screen.rs +++ b/tests/kittest/withdraw_screen.rs @@ -6,8 +6,8 @@ //! on-chain TRANSFER key with no local private material, e.g. a loaded //! masternode identity with the payout field left blank) could get silently //! pre-selected even though the signer can never use it. The fix -//! (`identity.default_withdrawal_key()`, TRANSFER-preferred / OWNER-fallback, -//! private-key-backed only) is unit-tested at the model layer in +//! (`identity.withdrawal_signing_key()`, TRANSFER-preferred / OWNER-fallback, +//! signable keys only) is unit-tested at the model layer in //! `src/model/qualified_identity/mod.rs::withdrawal_key_tests`. This file //! verifies the fix holds when `WithdrawalScreen` is actually constructed and //! rendered — the layer the model-only tests never touched. @@ -37,7 +37,7 @@ use dash_evo_tool::ui::identity::withdraw_screen::WithdrawalScreen; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::{ IdentityPublicKeyGettersV0, IdentityPublicKeySettersV0, }; -use dash_sdk::dpp::identity::{Identity, KeyID, KeyType, Purpose, SecurityLevel}; +use dash_sdk::dpp::identity::{Identity, KeyID, KeyType, Purpose}; use dash_sdk::dpp::platform_value::BinaryData; use dash_sdk::dpp::version::PlatformVersion; use dash_sdk::platform::{Identifier, IdentityPublicKey}; @@ -50,10 +50,13 @@ use std::sync::Arc; /// `model/qualified_identity/mod.rs::withdrawal_key_tests` so the two test /// suites stay in lockstep on fixture shape. fn key(id: KeyID, purpose: Purpose) -> IdentityPublicKey { - let mut k = IdentityPublicKey::random_key(id, Some(id as u64), PlatformVersion::latest()); - k.set_id(id); + let (mut k, _) = IdentityPublicKey::random_ecdsa_critical_level_authentication_key( + id, + Some(id as u64), + PlatformVersion::latest(), + ) + .expect("a random ECDSA key"); k.set_purpose(purpose); - k.set_security_level(SecurityLevel::CRITICAL); k } @@ -150,7 +153,7 @@ fn fresh_context() -> (tokio::runtime::Runtime, Arc) { /// Ghost-key repro: an identity whose only on-chain key is a `Purpose::TRANSFER` /// key with no local private material (like a loaded masternode identity with /// the payout field left blank). The withdraw form must never render for it — -/// `available_withdrawal_keys()` (private-key-backed only) is empty, so the +/// `withdrawal_signing_key()` (signable keys only) picks nothing, so the /// screen shows the "no eligible key" empty state, not a Confirm-enabled form. #[test] fn ghost_transfer_key_shows_no_keys_empty_state_not_a_form() { @@ -188,7 +191,41 @@ fn ghost_transfer_key_shows_no_keys_empty_state_not_a_form() { }); } -/// Regression lock for a bug this fix (default_withdrawal_key) briefly +/// An identity whose only withdrawal key has expired holds a key but cannot +/// sign with it: the gate stays closed, as the backend would refuse to pick +/// that key, and says why instead of claiming no key is loaded. +#[test] +fn an_expired_withdrawal_key_closes_the_form_with_its_own_message() { + with_isolated_data_dir(|| { + let (_rt, app_context) = fresh_context(); + + let expired = key(1, Purpose::TRANSFER).with_limits(None, Some(1)); + let identity = build_identity( + &app_context, + IdentityType::User, + vec![expired.clone()], + vec![expired], + ); + + let screen = WithdrawalScreen::new(identity, &app_context); + let harness = mount_withdrawal_screen(screen); + + assert!( + harness + .query_by_label_contains("No key on this device can approve a withdrawal") + .is_some(), + "an expired-key-only identity must be told its key cannot sign" + ); + assert!( + harness + .query_by_label_contains("Amount to withdraw") + .is_none(), + "the withdraw form must not render without a key that can sign" + ); + }); +} + +/// Regression lock for a bug this fix (then `default_withdrawal_key`) briefly /// introduced and a follow-up fix (2edbc18e, "skip wallet resolution when no /// signable key exists") closed: constructing `WithdrawalScreen` for a /// ghost-key-only identity used to leak a raw, technical error banner @@ -320,7 +357,7 @@ fn private_backed_transfer_key_is_selected_and_rendered_in_combo() { } /// Owner fallback: a masternode-type identity with only a private-key-backed -/// OWNER key, no TRANSFER key at all. `default_withdrawal_key()` must fall +/// OWNER key, no TRANSFER key at all. `withdrawal_signing_key()` must fall /// back to OWNER, and the rendered ComboBox must reflect that exact key. #[test] fn owner_key_fallback_is_selected_and_rendered_in_combo_when_no_transfer_key() { @@ -380,7 +417,7 @@ fn owner_key_destination_is_locked_at_every_role() { MessageBanner::clear_all_global(app_context.egui_ctx()); // Only the OWNER key is signable locally; the payout key is on-chain - // only, so `default_withdrawal_key()` falls back to OWNER while + // only, so `withdrawal_signing_key()` falls back to OWNER while // `masternode_payout_address()` still resolves the forced destination. let owner = key(1, Purpose::OWNER); let payout = payout_key(2, [0x11; 20]); From b691463707b8dce18557ecd904e9d841419e07a4 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:13:56 +0000 Subject: [PATCH 47/58] fix(identity): name the payout and owner keys when a node withdrawal has no usable key A masternode or evonode withdrawal falls back from the payout (transfer) key to the owner key, but the no-usable-key message only mentioned a transfer key. Nodes now get a dedicated NoUsableNodeWithdrawalKey message naming both; user identities keep the transfer-key wording. Co-Authored-By: Claude Opus 5.5 --- src/backend_task/error.rs | 48 +++++++++++++++++++ .../identity/withdraw_from_identity.rs | 2 +- src/ui/wallets/send_screen.rs | 4 +- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index cd43f34c9..d9c0206a3 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -5,6 +5,7 @@ use crate::model::asset_lock::validate_asset_lock_minimum; use crate::model::fee_estimation::{format_credits_as_dash, format_duffs_as_dash}; +use crate::model::qualified_identity::IdentityType; use crate::wallet_backend::platform_compatibility::StorageFailure; use dash_sdk::Error as SdkError; use dash_sdk::dapi_client::DapiClientError; @@ -1750,6 +1751,16 @@ pub enum TaskError { )] NoUsableWithdrawalKey, + /// No active key this app can sign with was available for a masternode or + /// evonode withdrawal, which may be signed with the payout (transfer) key + /// or, failing that, the owner key — the chosen key may have been disabled, + /// or neither is loaded for this identity. See + /// [`TaskError::no_usable_withdrawal_key`]. + #[error( + "This withdrawal can't be signed because no active payout or owner key for this identity is available in this app. Load an active payout or owner key for this identity and try again." + )] + NoUsableNodeWithdrawalKey, + /// No active key this app can sign with was available for the transfer — /// the chosen key may have been disabled, or no transfer key is loaded for /// this identity. @@ -4309,6 +4320,20 @@ impl From for TaskError { } impl TaskError { + /// The error for a withdrawal from an identity of `identity_type` that no + /// usable key can sign. A masternode or evonode may sign with its owner key + /// as well as its payout key, so it gets + /// [`TaskError::NoUsableNodeWithdrawalKey`], which names both; a user + /// identity gets [`TaskError::NoUsableWithdrawalKey`]. + pub fn no_usable_withdrawal_key(identity_type: IdentityType) -> Self { + match identity_type { + IdentityType::User => TaskError::NoUsableWithdrawalKey, + IdentityType::Masternode | IdentityType::Evonode => { + TaskError::NoUsableNodeWithdrawalKey + } + } + } + /// The error for a token operation the wallet runtime reported as failed /// (`PlatformWalletError::TokenOperationFailed`). /// @@ -8376,4 +8401,27 @@ mod tests { ); } } + + /// A masternode or evonode withdrawal may also be signed with the owner + /// key, so its "no usable key" message must name both remedies; a user + /// identity, which can only use a transfer key, keeps its own message. + #[test] + fn no_usable_withdrawal_key_names_the_purposes_the_identity_type_allows() { + assert!(matches!( + TaskError::no_usable_withdrawal_key(IdentityType::User), + TaskError::NoUsableWithdrawalKey + )); + for node in [IdentityType::Masternode, IdentityType::Evonode] { + let error = TaskError::no_usable_withdrawal_key(node); + assert!( + matches!(error, TaskError::NoUsableNodeWithdrawalKey), + "{node:?} must get the node message, got {error:?}" + ); + let msg = error.to_string(); + assert!( + msg.contains("payout") && msg.contains("owner"), + "the node message must name both usable keys, got: {msg}" + ); + } + } } diff --git a/src/backend_task/identity/withdraw_from_identity.rs b/src/backend_task/identity/withdraw_from_identity.rs index df927b999..5e98431bb 100644 --- a/src/backend_task/identity/withdraw_from_identity.rs +++ b/src/backend_task/identity/withdraw_from_identity.rs @@ -59,7 +59,7 @@ impl AppContext { // fall back to an OWNER key and bypass the owner-address policy. let signing_key_id = qualified_identity .resolve_withdrawal_signing_key(id) - .map_err(|_| TaskError::NoUsableWithdrawalKey)?; + .map_err(|_| TaskError::no_usable_withdrawal_key(qualified_identity.identity_type))?; let signing_key = qualified_identity .identity .get_public_key_by_id(signing_key_id); diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 3fbf271f1..240fbd3e8 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -2300,7 +2300,9 @@ impl WalletSendScreen { // withdrawal no key can sign before the confirmation. qualified_identity .resolve_withdrawal_signing_key(None) - .map_err(|_| TaskError::NoUsableWithdrawalKey.to_string())?; + .map_err(|_| { + TaskError::no_usable_withdrawal_key(qualified_identity.identity_type).to_string() + })?; let core_address = self .validated_destination From 2f419243167be2cf8c2be25c9079a9c77b364882 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:59:02 +0000 Subject: [PATCH 48/58] fix(identity): skip filed keys the identity no longer holds Username readiness, its missing-wallet hint and the key chooser judged a filed private key by its stored public key when the identity no longer held that key (removed, or replaced under the same id). Readiness then reported a key the registration cannot pick, and the chooser offered a key Platform cannot verify. They now use the identity's current key and skip a filed key without one. Co-Authored-By: Claude Opus 5.5 --- src/model/dpns_usernames.rs | 41 ++++++++++++++++++++++++----- src/model/qualified_identity/mod.rs | 8 +++++- src/ui/helpers.rs | 41 +++++++++++++++++++++++++++-- 3 files changed, 81 insertions(+), 9 deletions(-) diff --git a/src/model/dpns_usernames.rs b/src/model/dpns_usernames.rs index 4b7d3d9b6..340ce8cd2 100644 --- a/src/model/dpns_usernames.rs +++ b/src/model/dpns_usernames.rs @@ -249,10 +249,10 @@ pub fn missing_registration_wallets( .values() .filter_map(move |(key, data)| match data { PrivateKeyData::AtWalletDerivationPath(path) - if key_can_sign_documents( - identity.live_public_key(&key.identity_public_key), - required, - ) && !identity.key_secret_is_obtainable(data) => + if identity + .current_public_key(&key.identity_public_key) + .is_some_and(|live| key_can_sign_documents(live, required)) + && !identity.key_secret_is_obtainable(data) => { Some(&path.wallet_seed_hash) } @@ -302,8 +302,11 @@ pub fn registration_key_status( let mut held_in_legacy_form = false; for (key, data) in identity.private_keys.values() { // The stored copy may predate a limits update: judge the key as the - // identity holds it now. - if !key_can_sign_documents(identity.live_public_key(&key.identity_public_key), required) { + // identity holds it now, and skip one it no longer holds. + if !identity + .current_public_key(&key.identity_public_key) + .is_some_and(|live| key_can_sign_documents(live, required)) + { continue; } if identity.key_secret_is_obtainable(data) { @@ -1538,6 +1541,32 @@ mod tests { assert!(missing(&identity).is_empty()); } + /// Regression: a private key filed for a key the identity no longer holds + /// was judged by its stored copy, so readiness reported a key the + /// registration's key choice cannot pick, and asked for its wallet. + #[test] + fn a_filed_key_the_identity_no_longer_holds_cannot_register() { + use dash_sdk::dpp::identity::SecurityLevel; + use dash_sdk::dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; + let high = SecurityLevel::HIGH; + let drop_key_1 = |identity: &mut QualifiedIdentity| { + let mut keys = identity.identity.public_keys().clone(); + keys.remove(&1); + identity.identity.set_public_keys(keys); + }; + let mut identity = identity_with_keys(&[(0, SecurityLevel::MASTER), (1, high)], &[0, 1]); + drop_key_1(&mut identity); + assert_eq!( + registration_key_status(&identity, high), + RegistrationKeyStatus::NoSuitableKey + ); + + let mut identity = identity_with_keys(&[(0, SecurityLevel::MASTER), (1, high)], &[]); + crate::context::test_support::hold_as_wallet_reference(&mut identity, 1, [0x5A; 32]); + drop_key_1(&mut identity); + assert_eq!(missing_registration_wallets(&identity, high).count(), 0); + } + /// One usable key is enough: a second key stranded in a missing wallet /// does not take the identity's ability to register away. #[test] diff --git a/src/model/qualified_identity/mod.rs b/src/model/qualified_identity/mod.rs index 1ce05b01a..5e4268676 100644 --- a/src/model/qualified_identity/mod.rs +++ b/src/model/qualified_identity/mod.rs @@ -1279,11 +1279,17 @@ impl QualifiedIdentity { /// with key limits updates, so the snapshot may be stale. Falls back to /// the snapshot when the identity no longer holds the same key. pub fn live_public_key<'a>(&'a self, snapshot: &'a IdentityPublicKey) -> &'a IdentityPublicKey { + self.current_public_key(snapshot).unwrap_or(snapshot) + } + + /// The key as the identity holds it now for `snapshot`, or `None` when + /// the identity no longer holds the same key, so nothing it signs can + /// authorize a transition. + pub fn current_public_key(&self, snapshot: &IdentityPublicKey) -> Option<&IdentityPublicKey> { self.identity .public_keys() .get(&snapshot.id()) .filter(|live| encrypted_key_storage::same_key(snapshot, live)) - .unwrap_or(snapshot) } /// The key to sign with for `requirements` now: the automatic choice of diff --git a/src/ui/helpers.rs b/src/ui/helpers.rs index 7c9ddcc39..e806ca3cd 100644 --- a/src/ui/helpers.rs +++ b/src/ui/helpers.rs @@ -489,8 +489,9 @@ fn listed_keys<'a>( .identity_public_keys() .into_iter() // The stored copy may predate a limits raise: judge, label and hand - // on the key as the identity holds it now. - .map(|key_ref| identity.live_public_key(&key_ref.1.identity_public_key)) + // on the key as the identity holds it now, skipping one it no longer + // holds. + .filter_map(|key_ref| identity.current_public_key(&key_ref.1.identity_public_key)) // Platform rejects signing with a disabled key or a key bound // elsewhere, and no signer here can produce a private half this // device cannot obtain, so never offer one — not even in dev mode, @@ -1467,6 +1468,42 @@ mod tests { } } + /// Regression: a filed key the identity no longer holds, removed or + /// replaced under the same id, was listed by its stored copy, though + /// Platform verifies against the identity's current key. + #[test] + fn the_chooser_lists_no_key_the_identity_no_longer_holds() { + use crate::context::test_support::{auth_key, identity_holding}; + use dash_sdk::dpp::identity::accessors::IdentitySettersV0; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + + let dashpay = Identifier::new([0xDA; 32]); + let purposes = TransactionType::ContactRequest.allowed_purposes(); + let levels = TransactionType::ContactRequest.allowed_security_levels(); + let filter = contact_request_filter(&purposes, &levels, dashpay); + let now = 1_000; + + let mut removed = identity_holding(&[auth_key(1, SecurityLevel::HIGH)]); + removed + .identity + .set_public_keys(std::collections::BTreeMap::new()); + let mut replaced = identity_holding(&[auth_key(1, SecurityLevel::HIGH)]); + let mut other = auth_key(1, SecurityLevel::HIGH); + other.set_data(vec![3; 33].into()); + replaced + .identity + .set_public_keys(std::collections::BTreeMap::from([(1, other)])); + + for (case, identity) in [("removed", removed), ("replaced", replaced)] { + assert_eq!( + listed_keys(&identity, filter, true, now).count(), + 0, + "{case}" + ); + assert!(!key_eligibility(&identity, &filter, now).0, "{case}"); + } + } + /// DashPay and username registration choosers offer the key types their /// automatic choice takes; other transactions the types Platform verifies. #[test] From 2622ac73a09e1ffa02dd02dd563f46677a2fea5e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:59:02 +0000 Subject: [PATCH 49/58] fix(tokens): gate a node's token claim by the claim's key choice A masternode or evonode passed the claim gate with any filed transfer key, while the claim signs with the key single_token_transition_key picks, so a node holding only an expired, disabled or unreadable key got a form that could not sign. Every identity type now uses that choice's status. Co-Authored-By: Claude Opus 5.5 --- src/ui/tokens/claim_tokens_screen.rs | 50 ++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/src/ui/tokens/claim_tokens_screen.rs b/src/ui/tokens/claim_tokens_screen.rs index b01e05772..8b5eb6135 100644 --- a/src/ui/tokens/claim_tokens_screen.rs +++ b/src/ui/tokens/claim_tokens_screen.rs @@ -33,7 +33,7 @@ use crate::backend_task::error::TaskError; use crate::backend_task::tokens::TokenTask; use crate::context::AppContext; use crate::model::qualified_contract::QualifiedContract; -use crate::model::qualified_identity::{IdentityType, QualifiedIdentity, SigningKeyStatus}; +use crate::model::qualified_identity::{QualifiedIdentity, SigningKeyStatus}; use crate::model::wallet::Wallet; use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; use crate::ui::{MessageType, Screen, ScreenLike}; @@ -777,22 +777,14 @@ impl ScreenLike for ClaimTokensScreen { } /// Whether `identity` can sign a claim on `contract_id`, the gate in front -/// of the form outside developer mode: for a user identity, whether -/// [`QualifiedIdentity::single_token_transition_key`] picks a key. +/// of the form outside developer mode: whether +/// [`QualifiedIdentity::single_token_transition_key`], the claim's key +/// choice for every identity type, picks a key. fn claim_key_status( identity: &QualifiedIdentity, contract_id: dash_sdk::platform::Identifier, ) -> SigningKeyStatus { - match identity.identity_type { - IdentityType::User => identity.single_token_transition_key_status(contract_id), - IdentityType::Masternode | IdentityType::Evonode => { - if identity.available_transfer_keys().is_empty() { - SigningKeyStatus::NoneHeld - } else { - SigningKeyStatus::Usable - } - } - } + identity.single_token_transition_key_status(contract_id) } /// The claim confirmation text: which distribution of `token` is claimed and, @@ -837,7 +829,7 @@ fn load_once_per_identity_claim( mod tests { use super::*; use crate::context::test_support::identity_public_key; - use crate::model::qualified_identity::IdentityStatus; + use crate::model::qualified_identity::{IdentityStatus, IdentityType}; use crate::ui::tokens::tokens_screen::IdentityTokenBasicInfo; use dash_sdk::dpp::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; use dash_sdk::dpp::data_contract::associated_token::token_distribution_rules::TokenDistributionRules; @@ -1115,4 +1107,34 @@ mod tests { ); } } + + /// Regression: a node's claim gate counted any filed transfer key, while + /// the claim signs with the key `single_token_transition_key` picks, so a + /// node holding only an expired one got a form that could not sign. + #[test] + fn a_nodes_claim_gate_follows_the_claims_key_choice() { + use crate::context::test_support::{auth_key, identity_holding}; + let contract_id = Identifier::new([0x70; 32]); + let transfer = |expires_at| { + let mut key = auth_key(1, SecurityLevel::CRITICAL).with_limits(None, expires_at); + key.set_purpose(Purpose::TRANSFER); + key + }; + for identity_type in [IdentityType::Masternode, IdentityType::Evonode] { + let mut expired = identity_holding(&[transfer(Some(1))]); + expired.identity_type = identity_type; + assert_eq!( + claim_key_status(&expired, contract_id), + SigningKeyStatus::HeldButUnusable, + "{identity_type:?}" + ); + let mut live = identity_holding(&[transfer(None)]); + live.identity_type = identity_type; + assert_eq!( + claim_key_status(&live, contract_id), + SigningKeyStatus::Usable, + "{identity_type:?}" + ); + } + } } From 02f10bc9af45b86e5b638e52119f68080cb20d87 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:59:02 +0000 Subject: [PATCH 50/58] fix(contracts): say when the identity has no key that can sign the contract The register and update contract screens stopped without a word when the selected identity passed their gate but held no key the automatic choice can sign with (expired, disabled, bound to a contract, or not on this device). They now show the document action screen's no-signing-key notice. Co-Authored-By: Claude Opus 5.5 --- .../document_action_screen.rs | 4 +- .../register_contract_screen.rs | 44 +++++++++++++++++++ .../update_contract_screen.rs | 39 ++++++++++++++++ 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/ui/contracts_documents/document_action_screen.rs b/src/ui/contracts_documents/document_action_screen.rs index ed0e58420..28549d1d6 100644 --- a/src/ui/contracts_documents/document_action_screen.rs +++ b/src/ui/contracts_documents/document_action_screen.rs @@ -175,8 +175,8 @@ pub struct DocumentActionScreen { } /// Shown when the selected identity holds no key this device can sign the -/// chosen document action with. -const NO_SIGNING_KEY_NOTICE: &str = "This identity has no key on this device that can sign this action. Add or load a suitable key from the identity's keys, or select another identity."; +/// chosen document or contract action with. +pub(super) const NO_SIGNING_KEY_NOTICE: &str = "This identity has no key on this device that can sign this action. Add or load a suitable key from the identity's keys, or select another identity."; impl DocumentActionScreen { pub fn new( diff --git a/src/ui/contracts_documents/register_contract_screen.rs b/src/ui/contracts_documents/register_contract_screen.rs index 061ac58d4..9dda2c096 100644 --- a/src/ui/contracts_documents/register_contract_screen.rs +++ b/src/ui/contracts_documents/register_contract_screen.rs @@ -504,6 +504,13 @@ impl ScreenLike for RegisterDataContractScreen { } if self.selected_key.is_none() { + if self.selected_qualified_identity.is_some() { + let dark_mode = ui.style().visuals.dark_mode; + ui.label( + RichText::new(super::document_action_screen::NO_SIGNING_KEY_NOTICE) + .color(DashColors::warning_color(dark_mode)), + ); + } return AppAction::None; } @@ -572,3 +579,40 @@ impl ScreenLike for RegisterDataContractScreen { action } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::context::test_support::{auth_key, identity_holding}; + use egui_kittest::kittest::Queryable; + + /// Regression: an identity holding only a key that cannot sign (here an + /// expired one) passed the screen's gate, and the form stopped with no + /// word once the key choice found nothing. + #[test] + fn an_identity_without_a_usable_key_is_told_so() { + let dir = tempfile::tempdir().expect("tempdir"); + let ctx = crate::context::test_support::test_app_context(dir.path()); + let identity = + identity_holding(&[auth_key(1, SecurityLevel::CRITICAL).with_limits(None, Some(1))]); + let mut screen = RegisterDataContractScreen::new(&ctx); + screen.qualified_identities = vec![identity.clone()]; + screen.selected_qualified_identity = Some(identity); + screen.selected_key = None; + + let mut harness = egui_kittest::Harness::builder() + .with_size(egui::vec2(1280.0, 900.0)) + .build_ui_state( + |ui, screen: &mut RegisterDataContractScreen| { + screen.ui(ui); + }, + screen, + ); + harness.run(); + assert!( + harness + .query_by_label(super::super::document_action_screen::NO_SIGNING_KEY_NOTICE) + .is_some() + ); + } +} diff --git a/src/ui/contracts_documents/update_contract_screen.rs b/src/ui/contracts_documents/update_contract_screen.rs index 1f7dde2f3..5959def4f 100644 --- a/src/ui/contracts_documents/update_contract_screen.rs +++ b/src/ui/contracts_documents/update_contract_screen.rs @@ -533,6 +533,13 @@ impl ScreenLike for UpdateDataContractScreen { } if self.selected_key.is_none() { + if self.selected_qualified_identity.is_some() { + let dark_mode = ui.style().visuals.dark_mode; + ui.label( + RichText::new(super::document_action_screen::NO_SIGNING_KEY_NOTICE) + .color(DashColors::warning_color(dark_mode)), + ); + } return AppAction::None; } @@ -656,4 +663,36 @@ mod tests { assert!(screen.known_contracts.is_empty()); } + + /// Regression: an identity holding only a key that cannot sign (here an + /// expired one) passed the screen's gate, and the form stopped with no + /// word once the key choice found nothing. + #[test] + fn an_identity_without_a_usable_key_is_told_so() { + use crate::context::test_support::{auth_key, identity_holding}; + use egui_kittest::kittest::Queryable; + let dir = tempfile::tempdir().expect("tempdir"); + let ctx = crate::context::test_support::test_app_context(dir.path()); + let identity = + identity_holding(&[auth_key(1, SecurityLevel::CRITICAL).with_limits(None, Some(1))]); + let mut screen = UpdateDataContractScreen::new(&ctx); + screen.qualified_identities = vec![identity.clone()]; + screen.selected_qualified_identity = Some(identity); + screen.selected_key = None; + + let mut harness = egui_kittest::Harness::builder() + .with_size(egui::vec2(1280.0, 900.0)) + .build_ui_state( + |ui, screen: &mut UpdateDataContractScreen| { + screen.ui(ui); + }, + screen, + ); + harness.run(); + assert!( + harness + .query_by_label(super::super::document_action_screen::NO_SIGNING_KEY_NOTICE) + .is_some() + ); + } } From b06feb0ccfc8b01c94f3744498fdc3515767d078 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:59:02 +0000 Subject: [PATCH 51/58] fix(dashpay): let the QR scanner send again after a failed request Only a successful result cleared the scanner's sending state, so a failed contact request, such as one whose code expired before signing, left a spinner and no Send button until the screen was reopened. Co-Authored-By: Claude Opus 5.5 --- src/ui/dashpay/qr_scanner.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/ui/dashpay/qr_scanner.rs b/src/ui/dashpay/qr_scanner.rs index f6223dd82..d99422e2d 100644 --- a/src/ui/dashpay/qr_scanner.rs +++ b/src/ui/dashpay/qr_scanner.rs @@ -378,6 +378,15 @@ impl ScreenLike for QRScannerScreen { fn display_task_result(&mut self, result: BackendTaskSuccessResult) { self.display_task_result(result); } + + fn display_backend_task_error( + &mut self, + _context: &crate::backend_task::BackendTaskContext, + _error: &crate::backend_task::error::TaskError, + ) { + // The banner reports the failure; the form must offer Send again. + self.sending = false; + } } /// The wall clock in Unix seconds, the clock a contact QR code's expiry is @@ -430,4 +439,19 @@ mod tests { .expect("the wallet holding the DashPay signing key"); assert_eq!(selected.read().expect("wallet").seed_hash(), seed_hash); } + + /// Regression: a failed send left the screen spinning with no way to + /// retry, as only a success cleared the sending state. + #[test] + fn a_failed_send_lets_the_user_retry() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut screen = + QRScannerScreen::new(crate::context::test_support::test_app_context(dir.path())); + screen.sending = true; + screen.display_backend_task_error( + &crate::backend_task::BackendTaskContext::Unknown, + &crate::backend_task::error::TaskError::ContactQrExpired, + ); + assert!(!screen.sending); + } } From 2e9e9278cc32e4b582dcca6b6d38f9ed49650e2f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:47:25 +0000 Subject: [PATCH 52/58] test: draw identity_holding and provider-test keys at runtime identity_holding filed `[id; 32]` as each key's private half and the auto-accept provider test built its peer from `[7; 32]`; both are valid keys committed to the repository. They now come from test_support::keys (a labelled secret per key id, and a fresh random key). Co-Authored-By: Claude Opus 5.5 --- src/context/test_support.rs | 5 ++++- src/wallet_backend/auto_accept.rs | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/context/test_support.rs b/src/context/test_support.rs index 6ce1f3408..6a1e8edd9 100644 --- a/src/context/test_support.rs +++ b/src/context/test_support.rs @@ -335,7 +335,10 @@ pub(crate) fn identity_holding( identity_public_key: key.clone(), in_wallet_at_derivation_path: None, }, - [key.id() as u8; 32], + crate::test_support::keys::labelled_secret( + "context::test_support::identity_holding", + u64::from(key.id()), + ), ), ) .expect("a free slot"); diff --git a/src/wallet_backend/auto_accept.rs b/src/wallet_backend/auto_accept.rs index fcc1e2999..317963109 100644 --- a/src/wallet_backend/auto_accept.rs +++ b/src/wallet_backend/auto_accept.rs @@ -272,7 +272,7 @@ mod tests { } let path = auto_accept_derivation_path(NETWORK, EXPIRY).expect("a path"); - let peer = PublicKey::from_secret_key(&SecretKey::from_secret_bytes([7; 32]).expect("key")); + let peer = PublicKey::from_secret_key(&crate::test_support::keys::secret_key()); assert!(provider.receiving_xpub(&path).await.is_err()); assert!(provider.ecdh_shared_secret(&path, &peer).await.is_err()); assert!(provider.account_reference(&path, &[], 0, 0).await.is_err()); From d39265740cf185e6c8e4a20fd8e98ffea6f9a494 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:47:25 +0000 Subject: [PATCH 53/58] fix(tokens): let the user choose a contract-group key for a token action A key bound to a contract group is never picked automatically, as only Platform can tell whether the group covers the token's contract, so the token gates closed before the Advanced chooser that offers such a key with its warning. A new SigningKeyStatus::ManualChoiceOnly reports a live, obtainable group-bound key when nothing else can sign; the token action, transfer, claim, purchase, pricing and configuration forms then open with the key chooser shown. Expired, disabled and out-of-bounds keys still close the form. Co-Authored-By: Claude Opus 5.5 --- src/model/qualified_identity/mod.rs | 84 ++++++++++++++++++- src/ui/tokens/claim_tokens_screen.rs | 5 +- src/ui/tokens/direct_token_purchase_screen.rs | 5 +- src/ui/tokens/mod.rs | 36 +++++++- src/ui/tokens/set_token_price_screen.rs | 5 +- src/ui/tokens/token_action_screen.rs | 5 +- src/ui/tokens/transfer_tokens_screen.rs | 5 +- src/ui/tokens/update_token_config.rs | 5 +- 8 files changed, 139 insertions(+), 11 deletions(-) diff --git a/src/model/qualified_identity/mod.rs b/src/model/qualified_identity/mod.rs index 5e4268676..16c25c7fd 100644 --- a/src/model/qualified_identity/mod.rs +++ b/src/model/qualified_identity/mod.rs @@ -12,8 +12,8 @@ use crate::model::dpns_usernames::{ DPNS_SIGNING_KEY_TYPES, dpns_security_levels, dpns_signing_requirement, }; use crate::model::identity_key_usability::{ - KeyRequirements, PLATFORM_SIGNING_KEY_TYPES, SigningScope, document_security_levels, now_ms, - select_identity_signing_key, + KeyRequirements, PLATFORM_SIGNING_KEY_TYPES, SigningScope, bounds_permit, + document_security_levels, now_ms, select_identity_signing_key, }; use crate::model::qualified_identity::encrypted_key_storage::{ KeyStorage, ResolvedPrivateKey, same_key, @@ -600,6 +600,10 @@ const SINGLE_TOKEN_TRANSITION_PURPOSES: [Purpose; 2] = [Purpose::AUTHENTICATION, pub enum SigningKeyStatus { /// The transition's key choice picks a key. Usable, + /// The key choice picks none, but a live key this device holds is bound + /// to a contract group, which only Platform can match to the contract, + /// so the user may choose it. + ManualChoiceOnly, /// A private key of a purpose, level and key type the transition takes /// is filed, but none can sign it now: each is disabled or expired, bound /// to another contract, or its private half is not readable on this @@ -1331,12 +1335,22 @@ impl QualifiedIdentity { /// [`Self::single_token_transition_key`] picks a key, so an open form can /// always sign; when closed, it tells a missing key apart from keys that /// cannot sign here, which need different guidance. + /// [`SigningKeyStatus::ManualChoiceOnly`] opens it onto the key chooser. pub fn single_token_transition_key_status( &self, contract_id: dash_sdk::platform::Identifier, ) -> SigningKeyStatus { + let scope = SigningScope::ContractWide { contract_id }; if self.single_token_transition_key(contract_id).is_some() { SigningKeyStatus::Usable + } else if SINGLE_TOKEN_TRANSITION_PURPOSES.into_iter().any(|purpose| { + self.holds_contract_group_key_for(KeyRequirements::new( + purpose, + &[SecurityLevel::CRITICAL], + scope, + )) + }) { + SigningKeyStatus::ManualChoiceOnly } else if self.holds_single_token_transition_key() { SigningKeyStatus::HeldButUnusable } else { @@ -1371,7 +1385,8 @@ impl QualifiedIdentity { /// in front of the token action forms. It opens exactly when /// [`Self::token_action_key`] picks a key, so an open form can always /// sign; when closed, it tells a missing key apart from keys that cannot - /// sign here. + /// sign here. [`SigningKeyStatus::ManualChoiceOnly`] opens it onto the key + /// chooser. pub fn token_action_key_status( &self, contract_id: dash_sdk::platform::Identifier, @@ -1379,6 +1394,8 @@ impl QualifiedIdentity { let requirements = Self::token_action_requirements(contract_id); if self.signing_key_now(requirements).is_some() { SigningKeyStatus::Usable + } else if self.holds_contract_group_key_for(requirements) { + SigningKeyStatus::ManualChoiceOnly } else if self .private_keys .identity_public_keys() @@ -1391,6 +1408,22 @@ impl QualifiedIdentity { } } + /// Whether a live key matching `requirements`, whose private half this + /// device can obtain, is bound to a contract group: never picked + /// automatically, as only Platform can tell whether the group covers the + /// contract, but the user may choose it. + fn holds_contract_group_key_for(&self, requirements: KeyRequirements<'_>) -> bool { + use dash_sdk::dpp::identity::identity_public_key::accessors::v1::IdentityPublicKeyGettersV1; + let now = now_ms(); + self.identity.public_keys().values().any(|key| { + requirements.matches(key) + && !key.is_disabled() + && !key.is_expired_at(now) + && bounds_permit(key.contract_bounds(), requirements.scope).is_none() + && self.holds_obtainable_key(key) + }) + } + /// Whether a private key of a purpose, level and key type /// [`Self::single_token_transition_key`] takes is filed for the identity, /// whether or not it can sign now. @@ -4176,6 +4209,51 @@ mod protocol_v14_compat_tests { } } + /// Regression: a key bound to a contract group, which only Platform can + /// match to the token's contract, is never picked automatically, and the + /// token gates then closed before the Advanced chooser that offers it. + /// Such a key leaves the choice to the user; an expired one does not. + #[test] + fn a_contract_group_key_leaves_the_token_key_to_the_user() { + use crate::context::test_support::{auth_key, bound_to, identity_holding}; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + + let contract_id = Identifier::new([0x70; 32]); + let group = |key| { + bound_to( + key, + ContractBounds::ContractGroup { + id: Identifier::new([0x6A; 32]), + }, + ) + }; + let auth = group(auth_key(1, SecurityLevel::CRITICAL)); + let mut transfer = auth.clone(); + transfer.set_purpose(Purpose::TRANSFER); + + let qi = identity_holding(std::slice::from_ref(&auth)); + assert!(qi.token_action_key(contract_id).is_none()); + assert_eq!( + qi.token_action_key_status(contract_id), + SigningKeyStatus::ManualChoiceOnly + ); + for (case, key) in [("authentication", auth), ("transfer", transfer)] { + let qi = identity_holding(std::slice::from_ref(&key)); + assert!(qi.single_token_transition_key(contract_id).is_none()); + assert_eq!( + qi.single_token_transition_key_status(contract_id), + SigningKeyStatus::ManualChoiceOnly, + "{case}" + ); + let expired = identity_holding(&[key.with_limits(None, Some(1))]); + assert_eq!( + expired.single_token_transition_key_status(contract_id), + SigningKeyStatus::HeldButUnusable, + "{case}, expired" + ); + } + } + /// Regression: the automatic username registration key was taken of any /// key type, so a BLS or EdDSA key with a lower id won over an ECDSA key. #[test] diff --git a/src/ui/tokens/claim_tokens_screen.rs b/src/ui/tokens/claim_tokens_screen.rs index 8b5eb6135..8b9ae0bab 100644 --- a/src/ui/tokens/claim_tokens_screen.rs +++ b/src/ui/tokens/claim_tokens_screen.rs @@ -499,7 +499,10 @@ impl ScreenLike for ClaimTokensScreen { claim_key_status(identity, self.identity_token_basic_info.contract_id) }; - if key_status != SigningKeyStatus::Usable { + if key_status == SigningKeyStatus::ManualChoiceOnly { + // Only a contract-group key can sign it: the user picks it below. + self.show_advanced_options = true; + } else if key_status != SigningKeyStatus::Usable { let message = if key_status == SigningKeyStatus::HeldButUnusable { "No key on this device can sign this token claim. The identity's keys are disabled, expired, reserved for another app or token, or not available on this device. Add a new key, or load the wallet that holds one, then try again.".to_string() } else { diff --git a/src/ui/tokens/direct_token_purchase_screen.rs b/src/ui/tokens/direct_token_purchase_screen.rs index 682f99020..2aea6c696 100644 --- a/src/ui/tokens/direct_token_purchase_screen.rs +++ b/src/ui/tokens/direct_token_purchase_screen.rs @@ -451,7 +451,10 @@ impl ScreenLike for PurchaseTokenScreen { self.identity_token_info.data_contract.contract.id(), ); - if key_status != SigningKeyStatus::Usable { + if key_status == SigningKeyStatus::ManualChoiceOnly { + // Only a contract-group key can sign it: the user picks it below. + self.show_advanced_options = true; + } else if key_status != SigningKeyStatus::Usable { ui.colored_label( DashColors::error_color(dark_mode), super::token_action_key_message(key_status, &self.identity_token_info.identity), diff --git a/src/ui/tokens/mod.rs b/src/ui/tokens/mod.rs index 7f115cceb..171d7260d 100644 --- a/src/ui/tokens/mod.rs +++ b/src/ui/tokens/mod.rs @@ -65,7 +65,8 @@ pub fn validate_signing_key( /// The gate in front of a token action form (mint, burn, freeze, pause, /// pricing, purchase, configuration updates). Outside developer mode it opens /// exactly when [`QualifiedIdentity::token_action_key`] picks a key, the key -/// the form's constructor selects, so an open form can always sign. +/// the form's constructor selects, so an open form can always sign; with +/// [`SigningKeyStatus::ManualChoiceOnly`] it opens onto the key chooser. pub(crate) fn token_action_key_status( app_context: &AppContext, identity: &QualifiedIdentity, @@ -89,9 +90,40 @@ pub(crate) fn token_action_key_message( ) -> String { match status { SigningKeyStatus::HeldButUnusable => "No key on this device can sign this token action. The identity's keys are disabled, expired, reserved for another app or token, or not available on this device. Add a new key, or load the wallet that holds one, then try again.".to_string(), - SigningKeyStatus::Usable | SigningKeyStatus::NoneHeld => format!( + SigningKeyStatus::Usable + | SigningKeyStatus::ManualChoiceOnly + | SigningKeyStatus::NoneHeld => format!( "No authentication keys with CRITICAL security level found for this {identity_type} identity.", identity_type = identity.identity_type, ), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::context::test_support::{auth_key, bound_to, identity_holding}; + use dash_sdk::dpp::identity::SecurityLevel; + use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; + use dash_sdk::platform::Identifier; + + /// Regression: for a Power user whose only key is bound to a contract + /// group, the token action gate closed before the Advanced chooser that + /// offers that key. + #[test] + fn a_power_users_contract_group_key_opens_the_chooser() { + let dir = tempfile::tempdir().expect("tempdir"); + let ctx = crate::context::test_support::test_app_context(dir.path()); + ctx.set_user_role(UserRole::Power); + let identity = identity_holding(&[bound_to( + auth_key(1, SecurityLevel::CRITICAL), + ContractBounds::ContractGroup { + id: Identifier::new([0x6A; 32]), + }, + )]); + assert_eq!( + token_action_key_status(&ctx, &identity, Identifier::new([0x70; 32])), + SigningKeyStatus::ManualChoiceOnly + ); + } +} diff --git a/src/ui/tokens/set_token_price_screen.rs b/src/ui/tokens/set_token_price_screen.rs index 745701cd8..2a132803e 100644 --- a/src/ui/tokens/set_token_price_screen.rs +++ b/src/ui/tokens/set_token_price_screen.rs @@ -928,7 +928,10 @@ impl ScreenLike for SetTokenPriceScreen { self.identity_token_info.data_contract.contract.id(), ); - if key_status != SigningKeyStatus::Usable { + if key_status == SigningKeyStatus::ManualChoiceOnly { + // Only a contract-group key can sign it: the user picks it below. + self.show_advanced_options = true; + } else if key_status != SigningKeyStatus::Usable { ui.colored_label( Color32::DARK_RED, super::token_action_key_message(key_status, &self.identity_token_info.identity), diff --git a/src/ui/tokens/token_action_screen.rs b/src/ui/tokens/token_action_screen.rs index f112e2932..a218f7988 100644 --- a/src/ui/tokens/token_action_screen.rs +++ b/src/ui/tokens/token_action_screen.rs @@ -503,7 +503,10 @@ impl ScreenLike for TokenActionScreen { &self.common.identity_token_info.identity, self.common.identity_token_info.data_contract.contract.id(), ); - if key_status != SigningKeyStatus::Usable { + if key_status == SigningKeyStatus::ManualChoiceOnly { + // Only a contract-group key can sign it: the user picks it below. + self.common.show_advanced_options = true; + } else if key_status != SigningKeyStatus::Usable { ui.heading(A::PAGE_HEADING); ui.add_space(10.0); return self.render_no_keys(ui, key_status); diff --git a/src/ui/tokens/transfer_tokens_screen.rs b/src/ui/tokens/transfer_tokens_screen.rs index 0a4adb944..8178d7184 100644 --- a/src/ui/tokens/transfer_tokens_screen.rs +++ b/src/ui/tokens/transfer_tokens_screen.rs @@ -451,7 +451,10 @@ impl ScreenLike for TransferTokensScreen { ) }; - if key_status != SigningKeyStatus::Usable { + if key_status == SigningKeyStatus::ManualChoiceOnly { + // Only a contract-group key can sign it: the user picks it below. + self.show_advanced_options = true; + } else if key_status != SigningKeyStatus::Usable { let identity_type = identity.identity_type; // Extract key data before releasing the borrow (needed for click handlers). let key = [Purpose::AUTHENTICATION, Purpose::TRANSFER] diff --git a/src/ui/tokens/update_token_config.rs b/src/ui/tokens/update_token_config.rs index 373d8a83d..867baf4d4 100644 --- a/src/ui/tokens/update_token_config.rs +++ b/src/ui/tokens/update_token_config.rs @@ -978,7 +978,10 @@ impl ScreenLike for UpdateTokenConfigScreen { self.identity_token_info.data_contract.contract.id(), ); - if key_status != SigningKeyStatus::Usable { + if key_status == SigningKeyStatus::ManualChoiceOnly { + // Only a contract-group key can sign it: the user picks it below. + self.show_advanced_options = true; + } else if key_status != SigningKeyStatus::Usable { ui.colored_label( Color32::DARK_RED, super::token_action_key_message(key_status, &self.identity), From 4373bc5a613f489019329a2755183bf8d1d3bdd8 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:53:29 +0000 Subject: [PATCH 54/58] fix(tokens): render the form and say why its key chooser opened The contract-group branch sat in the token gates' if/else chain, so five of the six forms skipped their form body for it and showed nothing. The branch now only opens the form's own Advanced Options, and the form renders as for a usable key, with a sentence above the chooser saying that only a contract-group key can sign and when to choose it. Co-Authored-By: Claude Opus 5.5 --- src/ui/tokens/claim_tokens_screen.rs | 46 ++++++++++++++++++- src/ui/tokens/direct_token_purchase_screen.rs | 10 +++- src/ui/tokens/mod.rs | 4 ++ src/ui/tokens/set_token_price_screen.rs | 10 +++- src/ui/tokens/token_action_screen.rs | 10 +++- src/ui/tokens/transfer_tokens_screen.rs | 10 +++- src/ui/tokens/update_token_config.rs | 10 +++- 7 files changed, 94 insertions(+), 6 deletions(-) diff --git a/src/ui/tokens/claim_tokens_screen.rs b/src/ui/tokens/claim_tokens_screen.rs index 8b9ae0bab..2a70fcead 100644 --- a/src/ui/tokens/claim_tokens_screen.rs +++ b/src/ui/tokens/claim_tokens_screen.rs @@ -502,7 +502,11 @@ impl ScreenLike for ClaimTokensScreen { if key_status == SigningKeyStatus::ManualChoiceOnly { // Only a contract-group key can sign it: the user picks it below. self.show_advanced_options = true; - } else if key_status != SigningKeyStatus::Usable { + } + if !matches!( + key_status, + SigningKeyStatus::Usable | SigningKeyStatus::ManualChoiceOnly + ) { let message = if key_status == SigningKeyStatus::HeldButUnusable { "No key on this device can sign this token claim. The identity's keys are disabled, expired, reserved for another app or token, or not available on this device. Add a new key, or load the wallet that holds one, then try again.".to_string() } else { @@ -581,6 +585,10 @@ impl ScreenLike for ClaimTokensScreen { if self.show_advanced_options { ui.heading("1. Select the key to sign the Claim transition"); ui.add_space(10.0); + if key_status == SigningKeyStatus::ManualChoiceOnly { + ui.label(super::CONTRACT_GROUP_KEY_NOTE); + ui.add_space(5.0); + } add_key_chooser( ui, &self.app_context, @@ -1111,6 +1119,42 @@ mod tests { } } + /// Regression: a claim whose only key is bound to a contract group closed + /// before the key chooser. The form now opens its own Advanced Options + /// with a note saying why, and leaves the app's role as it was. + #[test] + fn a_contract_group_key_opens_the_claims_chooser_with_a_note() { + use crate::context::test_support::{auth_key, bound_to, identity_holding}; + use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; + use egui_kittest::kittest::Queryable; + let (mut screen, _dir) = screen_for(once_per_identity_token(5)); + let role = screen.app_context.user_role(); + screen.identity = Some(identity_holding(&[bound_to( + auth_key(1, SecurityLevel::CRITICAL), + ContractBounds::ContractGroup { + id: Identifier::new([0x6A; 32]), + }, + )])); + screen.selected_key = None; + + let mut harness = egui_kittest::Harness::builder() + .with_size(egui::vec2(1280.0, 900.0)) + .build_ui_state( + |ui, screen: &mut ClaimTokensScreen| { + screen.ui(ui); + }, + screen, + ); + harness.run(); + assert!(harness.state().show_advanced_options); + assert!( + harness + .query_by_label(super::super::CONTRACT_GROUP_KEY_NOTE) + .is_some() + ); + assert_eq!(harness.state().app_context.user_role(), role); + } + /// Regression: a node's claim gate counted any filed transfer key, while /// the claim signs with the key `single_token_transition_key` picks, so a /// node holding only an expired one got a form that could not sign. diff --git a/src/ui/tokens/direct_token_purchase_screen.rs b/src/ui/tokens/direct_token_purchase_screen.rs index 2aea6c696..aa357b213 100644 --- a/src/ui/tokens/direct_token_purchase_screen.rs +++ b/src/ui/tokens/direct_token_purchase_screen.rs @@ -454,7 +454,11 @@ impl ScreenLike for PurchaseTokenScreen { if key_status == SigningKeyStatus::ManualChoiceOnly { // Only a contract-group key can sign it: the user picks it below. self.show_advanced_options = true; - } else if key_status != SigningKeyStatus::Usable { + } + if !matches!( + key_status, + SigningKeyStatus::Usable | SigningKeyStatus::ManualChoiceOnly + ) { ui.colored_label( DashColors::error_color(dark_mode), super::token_action_key_message(key_status, &self.identity_token_info.identity), @@ -519,6 +523,10 @@ impl ScreenLike for PurchaseTokenScreen { if self.show_advanced_options { ui.heading("1. Select the key to sign the Purchase transaction"); ui.add_space(10.0); + if key_status == SigningKeyStatus::ManualChoiceOnly { + ui.label(super::CONTRACT_GROUP_KEY_NOTE); + ui.add_space(5.0); + } add_key_chooser( ui, &self.app_context, diff --git a/src/ui/tokens/mod.rs b/src/ui/tokens/mod.rs index 171d7260d..96bd1380f 100644 --- a/src/ui/tokens/mod.rs +++ b/src/ui/tokens/mod.rs @@ -83,6 +83,10 @@ pub(crate) fn token_action_key_status( } } +/// Why a token form opened with its key chooser shown +/// ([`SigningKeyStatus::ManualChoiceOnly`]). +pub(crate) const CONTRACT_GROUP_KEY_NOTE: &str = "No key can be chosen automatically for this action, because the only key that can sign it is limited to a contract group. Choose that key below if the group includes this token's contract."; + /// Why a token action form stays closed for `identity` at `status`. pub(crate) fn token_action_key_message( status: SigningKeyStatus, diff --git a/src/ui/tokens/set_token_price_screen.rs b/src/ui/tokens/set_token_price_screen.rs index 2a132803e..68ca66fde 100644 --- a/src/ui/tokens/set_token_price_screen.rs +++ b/src/ui/tokens/set_token_price_screen.rs @@ -931,7 +931,11 @@ impl ScreenLike for SetTokenPriceScreen { if key_status == SigningKeyStatus::ManualChoiceOnly { // Only a contract-group key can sign it: the user picks it below. self.show_advanced_options = true; - } else if key_status != SigningKeyStatus::Usable { + } + if !matches!( + key_status, + SigningKeyStatus::Usable | SigningKeyStatus::ManualChoiceOnly + ) { ui.colored_label( Color32::DARK_RED, super::token_action_key_message(key_status, &self.identity_token_info.identity), @@ -997,6 +1001,10 @@ impl ScreenLike for SetTokenPriceScreen { if self.show_advanced_options { ui.heading("1. Select the key to sign the Set Price transaction"); ui.add_space(10.0); + if key_status == SigningKeyStatus::ManualChoiceOnly { + ui.label(super::CONTRACT_GROUP_KEY_NOTE); + ui.add_space(5.0); + } add_key_chooser( ui, &self.app_context, diff --git a/src/ui/tokens/token_action_screen.rs b/src/ui/tokens/token_action_screen.rs index a218f7988..83f42ac0e 100644 --- a/src/ui/tokens/token_action_screen.rs +++ b/src/ui/tokens/token_action_screen.rs @@ -506,7 +506,11 @@ impl ScreenLike for TokenActionScreen { if key_status == SigningKeyStatus::ManualChoiceOnly { // Only a contract-group key can sign it: the user picks it below. self.common.show_advanced_options = true; - } else if key_status != SigningKeyStatus::Usable { + } + if !matches!( + key_status, + SigningKeyStatus::Usable | SigningKeyStatus::ManualChoiceOnly + ) { ui.heading(A::PAGE_HEADING); ui.add_space(10.0); return self.render_no_keys(ui, key_status); @@ -559,6 +563,10 @@ impl ScreenLike for TokenActionScreen { A::VERB )); ui.add_space(10.0); + if key_status == SigningKeyStatus::ManualChoiceOnly { + ui.label(super::CONTRACT_GROUP_KEY_NOTE); + ui.add_space(5.0); + } add_key_chooser( ui, &self.common.app_context, diff --git a/src/ui/tokens/transfer_tokens_screen.rs b/src/ui/tokens/transfer_tokens_screen.rs index 8178d7184..71c770433 100644 --- a/src/ui/tokens/transfer_tokens_screen.rs +++ b/src/ui/tokens/transfer_tokens_screen.rs @@ -454,7 +454,11 @@ impl ScreenLike for TransferTokensScreen { if key_status == SigningKeyStatus::ManualChoiceOnly { // Only a contract-group key can sign it: the user picks it below. self.show_advanced_options = true; - } else if key_status != SigningKeyStatus::Usable { + } + if !matches!( + key_status, + SigningKeyStatus::Usable | SigningKeyStatus::ManualChoiceOnly + ) { let identity_type = identity.identity_type; // Extract key data before releasing the borrow (needed for click handlers). let key = [Purpose::AUTHENTICATION, Purpose::TRANSFER] @@ -529,6 +533,10 @@ impl ScreenLike for TransferTokensScreen { ui.add_space(10.0); // Reborrow identity as ref for add_key_chooser; selected_key is &mut self. let identity = self.identity.as_ref().expect("checked above"); + if key_status == SigningKeyStatus::ManualChoiceOnly { + ui.label(super::CONTRACT_GROUP_KEY_NOTE); + ui.add_space(5.0); + } add_key_chooser( ui, &self.app_context, diff --git a/src/ui/tokens/update_token_config.rs b/src/ui/tokens/update_token_config.rs index 867baf4d4..9831f9cd1 100644 --- a/src/ui/tokens/update_token_config.rs +++ b/src/ui/tokens/update_token_config.rs @@ -981,7 +981,11 @@ impl ScreenLike for UpdateTokenConfigScreen { if key_status == SigningKeyStatus::ManualChoiceOnly { // Only a contract-group key can sign it: the user picks it below. self.show_advanced_options = true; - } else if key_status != SigningKeyStatus::Usable { + } + if !matches!( + key_status, + SigningKeyStatus::Usable | SigningKeyStatus::ManualChoiceOnly + ) { ui.colored_label( Color32::DARK_RED, super::token_action_key_message(key_status, &self.identity), @@ -1043,6 +1047,10 @@ impl ScreenLike for UpdateTokenConfigScreen { if self.show_advanced_options { ui.heading("1. Select the key to sign the transaction with"); ui.add_space(10.0); + if key_status == SigningKeyStatus::ManualChoiceOnly { + ui.label(super::CONTRACT_GROUP_KEY_NOTE); + ui.add_space(5.0); + } add_key_chooser( ui, &self.app_context, From afb5fb7a2e701f6818053c8802d83401d5a9dbb0 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:33:24 +0000 Subject: [PATCH 55/58] fix(dashpay): derive the contact QR code key from the identity's own wallet Hydration lists every loaded wallet with an identity, and the QR code took the lowest-sorting one, so an unrelated wallet could derive the code's key and the owner's wallet would not verify its proofs. The key and the unlock prompt now use the wallet the identity's stored record names as its owner; without one on this device the code is refused. Co-Authored-By: Claude Opus 5.5 --- src/backend_task/dashpay.rs | 3 +- src/context/identity_db.rs | 17 ++++++++ src/ui/dashpay/qr_code_generator.rs | 61 +++++++++++++++++++++++++++-- src/wallet_backend/auto_accept.rs | 11 +++--- 4 files changed, 82 insertions(+), 10 deletions(-) diff --git a/src/backend_task/dashpay.rs b/src/backend_task/dashpay.rs index becbd8be0..f4f4c08d1 100644 --- a/src/backend_task/dashpay.rs +++ b/src/backend_task/dashpay.rs @@ -369,9 +369,10 @@ impl AppContext { } } DashPayTask::GenerateAutoAcceptQrCode { identity } => { + let seed_hash = self.contact_qr_wallet_seed_hash(&identity)?; let uri = self .wallet_backend()? - .dashpay_build_auto_accept_qr(&identity) + .dashpay_build_auto_accept_qr(&identity, seed_hash) .await?; Ok(BackendTaskSuccessResult::DashPayAutoAcceptQrCode(uri)) } diff --git a/src/context/identity_db.rs b/src/context/identity_db.rs index 322f432fa..b124cc243 100644 --- a/src/context/identity_db.rs +++ b/src/context/identity_db.rs @@ -1261,6 +1261,23 @@ impl AppContext { .map(|stored| stored.qi_bytes)) } + /// The wallet whose seed derives `identity`'s contact QR code key: the + /// owner its stored record names, if loaded with the identity. + /// + /// # Errors + /// + /// [`TaskError::ContactQrWalletMissing`] when no owner is recorded or it + /// is not on this device; the store's error when the record cannot be read. + pub(crate) fn contact_qr_wallet_seed_hash( + &self, + identity: &QualifiedIdentity, + ) -> std::result::Result { + self.stored_identity_wallet_link(&identity.identity.id())? + .map(|(seed_hash, _)| seed_hash) + .filter(|seed_hash| identity.associated_wallets.contains_key(seed_hash)) + .ok_or(TaskError::ContactQrWalletMissing) + } + /// The wallet link recorded for `id`, or `None` when the identity is not /// stored or was never linked to a wallet. /// diff --git a/src/ui/dashpay/qr_code_generator.rs b/src/ui/dashpay/qr_code_generator.rs index ddf8d8cc9..56317d59e 100644 --- a/src/ui/dashpay/qr_code_generator.rs +++ b/src/ui/dashpay/qr_code_generator.rs @@ -31,8 +31,12 @@ const QR_CODE_INFO_TEXT: &str = "About Contact QR Codes:\n\n\ /// The wallet whose recovery phrase derives `identity`'s contact QR code key, /// for the unlock prompt. -fn qr_wallet(identity: &QualifiedIdentity) -> Option>> { - identity.dashpay_wallet().map(|(_, wallet)| wallet.clone()) +fn qr_wallet( + app_context: &AppContext, + identity: &QualifiedIdentity, +) -> Option>> { + let seed_hash = app_context.contact_qr_wallet_seed_hash(identity).ok()?; + identity.associated_wallets.get(&seed_hash).cloned() } pub struct QRCodeGeneratorScreen { @@ -80,7 +84,7 @@ impl QRCodeGeneratorScreen { new_self.selected_identity_string = preferred.identity.id().to_string(Encoding::Base58); // Get wallet for the selected identity - new_self.selected_wallet = qr_wallet(&preferred); + new_self.selected_wallet = qr_wallet(&new_self.app_context, &preferred); } new_self @@ -89,7 +93,10 @@ impl QRCodeGeneratorScreen { /// React to a new `selected_identity`: follow its wallet and drop the QR /// code shown for the previous one. fn identity_changed(&mut self) { - self.selected_wallet = self.selected_identity.as_ref().and_then(qr_wallet); + self.selected_wallet = self + .selected_identity + .as_ref() + .and_then(|identity| qr_wallet(&self.app_context, identity)); self.wallet_open_attempted = false; self.generated_qr_data = None; self.qr_request.clear(); @@ -435,4 +442,50 @@ mod tests { screen.display_backend_task_result(&for_second, qr_result("second")); assert_eq!(screen.generated_qr_data.as_deref(), Some("second")); } + + /// Regression: hydration lists every loaded wallet with an identity, and + /// the contact QR code took the lowest-sorting one, so an unrelated + /// wallet could derive the code's key. It now comes from the wallet the + /// identity's record names as its owner, and none without that record. + #[test] + fn the_contact_qr_key_comes_from_the_identitys_own_wallet() { + use crate::model::wallet::test_support::open_wallet_on; + use crate::wallet_backend::DetKv; + use crate::wallet_backend::kv_test_support::InMemoryKv; + use dash_sdk::dpp::identity::accessors::IdentitySettersV0; + let dir = tempfile::tempdir().unwrap(); + let ctx = crate::context::test_support::test_app_context(dir.path()); + ctx.set_det_kv_override_for_test(DetKv::from_store(Arc::new(InMemoryKv::default()))); + let mut wallets = [ + open_wallet_on(crate::test_support::keys::seed()), + open_wallet_on(crate::test_support::keys::seed()), + ]; + wallets.sort_by_key(|wallet| wallet.seed_hash()); + let [unrelated, owner] = wallets.map(|wallet| (wallet.seed_hash(), wallet)); + + let mut identity = bare_user_identity(Identifier::new([1; 32]), Network::Testnet); + for (hash, wallet) in [unrelated, owner.clone()] { + identity + .associated_wallets + .insert(hash, Arc::new(RwLock::new(wallet))); + } + ctx.insert_local_qualified_identity(&identity, &Some((owner.0, 0))) + .expect("store identity"); + assert_ne!(identity.dashpay_wallet_seed_hash(), Some(owner.0)); + assert_eq!( + ctx.contact_qr_wallet_seed_hash(&identity).ok(), + Some(owner.0) + ); + let unlock = qr_wallet(&ctx, &identity).expect("the owner's wallet"); + assert_eq!(unlock.read().expect("wallet").seed_hash(), owner.0); + + // No record, so no owner on file. + let mut unlinked = identity.clone(); + unlinked.identity.set_id(Identifier::new([2; 32])); + assert!(matches!( + ctx.contact_qr_wallet_seed_hash(&unlinked), + Err(TaskError::ContactQrWalletMissing) + )); + assert!(qr_wallet(&ctx, &unlinked).is_none()); + } } diff --git a/src/wallet_backend/auto_accept.rs b/src/wallet_backend/auto_accept.rs index 317963109..d70e6d67f 100644 --- a/src/wallet_backend/auto_accept.rs +++ b/src/wallet_backend/auto_accept.rs @@ -21,25 +21,26 @@ use zeroize::Zeroizing; use super::{SecretScope, WalletBackend}; use crate::backend_task::error::TaskError; use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::wallet::WalletSeedHash; impl WalletBackend { /// The contact QR code of `identity` (`dash:?du=&dapk=`), + /// derived from the seed of `seed_hash`, the identity's owning wallet + /// (`AppContext::contact_qr_wallet_seed_hash`), and /// built by upstream's `DashPayView::build_auto_accept_qr`: valid for /// upstream's auto-accept lifetime, naming the identity's first cached /// username (or one upstream finds on Platform). /// /// # Errors /// - /// [`TaskError::ContactQrWalletMissing`] when the identity has no wallet - /// on this device, the seam's error when the seed cannot be read, and + /// The wallet lookup's error when that wallet is not loaded, the seam's + /// error when the seed cannot be read, and /// [`TaskError::ContactQrBuildFailed`] when upstream cannot build the code. pub(crate) async fn dashpay_build_auto_accept_qr( &self, identity: &QualifiedIdentity, + seed_hash: WalletSeedHash, ) -> Result { - let seed_hash = identity - .dashpay_wallet_seed_hash() - .ok_or(TaskError::ContactQrWalletMissing)?; let wallet = self.resolve_wallet(&seed_hash).await?; let owner = identity.identity.id(); // Upstream resolves a name on Platform when given none. From 6a63dc5ba07ccb42dc96314e735837391db605c0 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:33:24 +0000 Subject: [PATCH 56/58] fix(identity): sign an identity update with the live master key Adding a key signed with the first filed MASTER key, chosen before the identity was fetched, so after a master rotation a disabled predecessor shadowed its usable replacement and Platform refused the update. The master key is now picked from the fetched keys as signing_key_now picks, and the add-key screen unlocks the wallet of that same key. Co-Authored-By: Claude Opus 5.5 --- .../identity/add_key_to_identity.rs | 10 +++--- src/model/qualified_identity/mod.rs | 35 +++++++++++++++++++ src/ui/identity/keys/add_key_screen.rs | 19 ++-------- 3 files changed, 44 insertions(+), 20 deletions(-) diff --git a/src/backend_task/identity/add_key_to_identity.rs b/src/backend_task/identity/add_key_to_identity.rs index 5d01adfe4..47e0cbe45 100644 --- a/src/backend_task/identity/add_key_to_identity.rs +++ b/src/backend_task/identity/add_key_to_identity.rs @@ -198,16 +198,18 @@ impl AppContext { let new_identity_nonce = sdk .get_identity_nonce(qualified_identity.identity.id(), true, None) .await?; - let Some(master_key) = qualified_identity.can_sign_with_master_key() else { - return Err(TaskError::MasterKeyNotFound); - }; - let master_key_id = master_key.identity_public_key.id(); let (identity, fetched) = Identity::fetch_with_metadata(sdk, qualified_identity.identity.id(), None).await?; let identity = identity.ok_or(TaskError::IdentityNotFoundLocally)?; // The fetched balance stands until the update's proof reports a newer one. let mut balance_provenance = BalanceProvenance::ObservedAt(fetched.height); qualified_identity.identity = identity; + // Picked from the keys as just fetched, so a master disabled on the + // network since the last refresh is passed over. + let master_key_id = qualified_identity + .identity_update_signing_key() + .map(|key| key.id()) + .ok_or(TaskError::MasterKeyNotFound)?; qualified_identity.identity.bump_revision(); let assigned_key_id = qualified_identity.identity.get_public_key_max_id() + 1; if let NewKeyMaterial::Derived { expected_key_id } = material { diff --git a/src/model/qualified_identity/mod.rs b/src/model/qualified_identity/mod.rs index 16c25c7fd..b8de20040 100644 --- a/src/model/qualified_identity/mod.rs +++ b/src/model/qualified_identity/mod.rs @@ -1177,6 +1177,21 @@ impl QualifiedIdentity { }) } + /// The MASTER key to sign an identity update (adding or disabling keys) + /// with, picked as [`Self::signing_key_now`] picks, so a disabled or + /// expired master, or one whose private half is not here, is passed over. + /// Only user identities hold a master key. + pub fn identity_update_signing_key(&self) -> Option<&IdentityPublicKey> { + if self.identity_type != IdentityType::User { + return None; + } + self.signing_key_now(KeyRequirements::new( + Purpose::AUTHENTICATION, + &[SecurityLevel::MASTER], + SigningScope::NonBatch, + )) + } + pub fn can_sign_with_master_key(&self) -> Option<&QualifiedIdentityPublicKey> { if self.identity_type != IdentityType::User { return None; @@ -4209,6 +4224,26 @@ mod protocol_v14_compat_tests { } } + /// Regression: an identity update signed with the first filed MASTER key, + /// so after a master rotation the disabled predecessor shadowed its + /// usable replacement and Platform refused the update. + #[test] + fn an_identity_update_signs_with_the_live_master_key() { + use crate::context::test_support::{auth_key, identity_holding}; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + let mut predecessor = auth_key(0, SecurityLevel::MASTER); + predecessor.set_disabled_at(1); + let qi = identity_holding(&[predecessor, auth_key(1, SecurityLevel::MASTER)]); + assert_eq!( + qi.identity_update_signing_key().map(|key| key.id()), + Some(1) + ); + + let mut node = qi.clone(); + node.identity_type = IdentityType::Masternode; + assert!(node.identity_update_signing_key().is_none()); + } + /// Regression: a key bound to a contract group, which only Platform can /// match to the token's contract, is never picked automatically, and the /// token gates then closed before the Advanced chooser that offers it. diff --git a/src/ui/identity/keys/add_key_screen.rs b/src/ui/identity/keys/add_key_screen.rs index f2a3f2bb5..beac62e50 100644 --- a/src/ui/identity/keys/add_key_screen.rs +++ b/src/ui/identity/keys/add_key_screen.rs @@ -4,7 +4,6 @@ use crate::backend_task::identity::IdentityTask; use crate::backend_task::{BackendTask, BackendTaskContext, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::fee_estimation::format_credits_as_dash; -use crate::model::identity_key_usability::{KeyRequirements, SigningScope}; use crate::model::key_input::decode_hex_secret; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; @@ -137,11 +136,7 @@ pub struct AddKeyScreen { impl AddKeyScreen { pub fn new(identity: QualifiedIdentity, app_context: &Arc) -> Self { let identity_clone = identity.clone(); - let selected_key = identity_clone.signing_key_now(KeyRequirements::new( - Purpose::AUTHENTICATION, - &[SecurityLevel::MASTER], - SigningScope::NonBatch, - )); + let selected_key = identity_clone.identity_update_signing_key(); let selected_wallet = get_selected_wallet(&identity, None, selected_key) .or_show_error(app_context.egui_ctx()) .unwrap_or(None); @@ -182,11 +177,7 @@ impl AddKeyScreen { app_context: &Arc, ) -> Self { let identity_clone = identity.clone(); - let selected_key = identity_clone.signing_key_now(KeyRequirements::new( - Purpose::AUTHENTICATION, - &[SecurityLevel::MASTER], - SigningScope::NonBatch, - )); + let selected_key = identity_clone.identity_update_signing_key(); let selected_wallet = get_selected_wallet(&identity, None, selected_key) .or_show_error(app_context.egui_ctx()) .unwrap_or(None); @@ -232,11 +223,7 @@ impl AddKeyScreen { app_context: &Arc, ) -> Self { let identity_clone = identity.clone(); - let selected_key = identity_clone.signing_key_now(KeyRequirements::new( - Purpose::AUTHENTICATION, - &[SecurityLevel::MASTER], - SigningScope::NonBatch, - )); + let selected_key = identity_clone.identity_update_signing_key(); let selected_wallet = get_selected_wallet(&identity, None, selected_key) .or_show_error(app_context.egui_ctx()) .unwrap_or(None); From 39d41fd98481b67bcb1d0e7db3524e5190814f81 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:33:24 +0000 Subject: [PATCH 57/58] fix(tokens): look up no unlock wallet while no key is chosen A token form opened for a user-chosen contract-group key has no key yet, and its constructor still asked for that key's wallet, raising a "No key provided" error banner. The lookup now runs only for a key; its real errors still show. Co-Authored-By: Claude Opus 5.5 --- src/ui/tokens/direct_token_purchase_screen.rs | 15 ++++++----- src/ui/tokens/mint_tokens_screen.rs | 26 +++++++++++++++++++ src/ui/tokens/set_token_price_screen.rs | 15 ++++++----- src/ui/tokens/token_action_screen.rs | 15 ++++++----- src/ui/tokens/transfer_tokens_screen.rs | 4 ++- src/ui/tokens/update_token_config.rs | 11 +++++--- 6 files changed, 62 insertions(+), 24 deletions(-) diff --git a/src/ui/tokens/direct_token_purchase_screen.rs b/src/ui/tokens/direct_token_purchase_screen.rs index aa357b213..3e52fdbea 100644 --- a/src/ui/tokens/direct_token_purchase_screen.rs +++ b/src/ui/tokens/direct_token_purchase_screen.rs @@ -88,12 +88,15 @@ impl PurchaseTokenScreen { .cloned(); // Attempt to get an unlocked wallet reference - let selected_wallet = - get_selected_wallet(&identity_token_info.identity, None, possible_key.as_ref()) - .unwrap_or_else(|e| { - MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); - None - }); + let selected_wallet = possible_key + .as_ref() + .map_or(Ok(None), |key| { + get_selected_wallet(&identity_token_info.identity, None, Some(key)) + }) + .unwrap_or_else(|e| { + MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); + None + }); Self { identity_token_info, diff --git a/src/ui/tokens/mint_tokens_screen.rs b/src/ui/tokens/mint_tokens_screen.rs index 5c2a56b37..63c706018 100644 --- a/src/ui/tokens/mint_tokens_screen.rs +++ b/src/ui/tokens/mint_tokens_screen.rs @@ -506,4 +506,30 @@ mod tests { let mintable = MintAction::new(&token_info(&context, None, true), &context); assert_eq!(mintable.disabled_reason(), FILL_IN_THE_FORM); } + + /// Regression: with no automatic key (here only a contract-group key, for + /// the user to choose) the constructor still looked up the unlock wallet + /// for no key and raised its error banner over a form that is fine. + #[test] + fn opening_the_form_without_an_automatic_key_raises_no_wallet_error() { + use crate::context::test_support::{auth_key, bound_to, identity_holding}; + use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; + let dir = tempfile::tempdir().unwrap(); + let context = crate::context::test_support::test_app_context(dir.path()); + let mut info = token_info(&context, None, false); + info.identity = identity_holding(&[bound_to( + auth_key(1, SecurityLevel::CRITICAL), + ContractBounds::ContractGroup { + id: Identifier::from([0x6A; 32]), + }, + )]); + + let _screen = MintTokensScreen::new(info, &context); + let banners = + crate::ui::components::message_banner::global_banner_texts(context.egui_ctx()); + assert!( + !banners.iter().any(|text| text.contains("No key provided")), + "{banners:?}" + ); + } } diff --git a/src/ui/tokens/set_token_price_screen.rs b/src/ui/tokens/set_token_price_screen.rs index 68ca66fde..aebfff73e 100644 --- a/src/ui/tokens/set_token_price_screen.rs +++ b/src/ui/tokens/set_token_price_screen.rs @@ -256,13 +256,14 @@ impl SetTokenPriceScreen { }; // Attempt to get an unlocked wallet reference - let selected_wallet = - get_selected_wallet(&identity_token_info.identity, None, possible_key).unwrap_or_else( - |e| { - set_error_banner(&e); - None - }, - ); + let selected_wallet = possible_key + .map_or(Ok(None), |key| { + get_selected_wallet(&identity_token_info.identity, None, Some(key)) + }) + .unwrap_or_else(|e| { + set_error_banner(&e); + None + }); Self { identity_token_info: identity_token_info.clone(), diff --git a/src/ui/tokens/token_action_screen.rs b/src/ui/tokens/token_action_screen.rs index 83f42ac0e..8862bfcb2 100644 --- a/src/ui/tokens/token_action_screen.rs +++ b/src/ui/tokens/token_action_screen.rs @@ -216,12 +216,15 @@ impl TokenActionScreen { super::set_error_banner(app_context, &msg); } - let selected_wallet = - get_selected_wallet(&identity_token_info.identity, None, possible_key.as_ref()) - .unwrap_or_else(|e| { - super::set_error_banner(app_context, &e); - None - }); + let selected_wallet = possible_key + .as_ref() + .map_or(Ok(None), |key| { + get_selected_wallet(&identity_token_info.identity, None, Some(key)) + }) + .unwrap_or_else(|e| { + super::set_error_banner(app_context, &e); + None + }); let action = A::new(&identity_token_info, app_context); diff --git a/src/ui/tokens/transfer_tokens_screen.rs b/src/ui/tokens/transfer_tokens_screen.rs index 71c770433..026844a4f 100644 --- a/src/ui/tokens/transfer_tokens_screen.rs +++ b/src/ui/tokens/transfer_tokens_screen.rs @@ -100,7 +100,9 @@ impl TransferTokensScreen { .cloned() }); let selected_wallet = identity.as_ref().and_then(|id| { - get_selected_wallet(id, None, selected_key.as_ref()) + selected_key + .as_ref() + .map_or(Ok(None), |key| get_selected_wallet(id, None, Some(key))) .or_show_error(app_context.egui_ctx()) .unwrap_or(None) }); diff --git a/src/ui/tokens/update_token_config.rs b/src/ui/tokens/update_token_config.rs index 9831f9cd1..cc087c567 100644 --- a/src/ui/tokens/update_token_config.rs +++ b/src/ui/tokens/update_token_config.rs @@ -103,10 +103,13 @@ impl UpdateTokenConfigScreen { let is_unilateral_group_member = false; // Attempt to get an unlocked wallet reference - let selected_wallet = - get_selected_wallet(&identity_token_info.identity, None, possible_key.as_ref()) - .or_show_error(app_context.egui_ctx()) - .unwrap_or(None); + let selected_wallet = possible_key + .as_ref() + .map_or(Ok(None), |key| { + get_selected_wallet(&identity_token_info.identity, None, Some(key)) + }) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None); Self { identity_token_info: identity_token_info.clone(), From 1e47ac2c5844721384de6447f8599f80e2fa5956 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:47:08 +0000 Subject: [PATCH 58/58] fix(tokens): pick the signing key on refresh once one became usable The token action, transfer, claim, purchase and pricing forms picked their signing key only when they opened, and refresh() replaced only the identity. Once the identity's key became usable (expiry extended, a key added through Add key, or its wallet loaded) the gate opened the form with no key, and confirming failed with "No signing key selected". refresh() now picks the automatic key and its unlock wallet while none is chosen, keeping a key chosen by hand. Co-Authored-By: Claude Opus 5.5 --- src/ui/tokens/claim_tokens_screen.rs | 42 ++++++++++++++ src/ui/tokens/direct_token_purchase_screen.rs | 7 +++ src/ui/tokens/mod.rs | 48 +++++++++++++++- src/ui/tokens/set_token_price_screen.rs | 7 +++ src/ui/tokens/token_action_screen.rs | 55 +++++++++++++++++++ src/ui/tokens/transfer_tokens_screen.rs | 9 +++ 6 files changed, 167 insertions(+), 1 deletion(-) diff --git a/src/ui/tokens/claim_tokens_screen.rs b/src/ui/tokens/claim_tokens_screen.rs index 2a70fcead..3ee46a2cc 100644 --- a/src/ui/tokens/claim_tokens_screen.rs +++ b/src/ui/tokens/claim_tokens_screen.rs @@ -440,6 +440,15 @@ impl ScreenLike for ClaimTokensScreen { { self.identity = Some(updated); } + if let Some(identity) = &self.identity { + let contract_id = self.identity_token_basic_info.contract_id; + super::pick_key_if_none( + identity, + &mut self.selected_key, + &mut self.selected_wallet, + |identity| identity.single_token_transition_key(contract_id), + ); + } self.once_per_identity_claimed_at = load_once_per_identity_claim(&self.app_context, &self.identity_token_basic_info); } @@ -1119,6 +1128,39 @@ mod tests { } } + /// Regression: the claim key was picked only when the screen opened, so + /// once the identity's key became usable (expiry extended, key added or + /// its wallet loaded) a refresh opened the form with no key to sign. + /// A refresh picks it then, with its wallet, and keeps a manual choice. + #[test] + fn a_refresh_picks_the_claim_key_that_became_usable() { + use crate::ui::tokens::tests::{ + identity_with_expired_key, store_identity_with_usable_wallet_key, + }; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + let (mut screen, _dir) = screen_for(once_per_identity_token(5)); + screen.identity = Some(identity_with_expired_key()); + screen.selected_key = None; + screen.selected_wallet = None; + let seed_hash = store_identity_with_usable_wallet_key(&screen.app_context); + + screen.refresh(); + assert_eq!(screen.selected_key.as_ref().map(|key| key.id()), Some(1)); + let wallet = screen.selected_wallet.as_ref().expect("the key's wallet"); + assert_eq!(wallet.read().expect("wallet").seed_hash(), seed_hash); + + let manual = identity_public_key( + 7, + Purpose::AUTHENTICATION, + SecurityLevel::CRITICAL, + KeyType::ECDSA_SECP256K1, + vec![2; 33], + ); + screen.selected_key = Some(manual); + screen.refresh(); + assert_eq!(screen.selected_key.as_ref().map(|key| key.id()), Some(7)); + } + /// Regression: a claim whose only key is bound to a contract group closed /// before the key chooser. The form now opens its own Advanced Options /// with a note saying why, and leaves the app's role as it was. diff --git a/src/ui/tokens/direct_token_purchase_screen.rs b/src/ui/tokens/direct_token_purchase_screen.rs index 3e52fdbea..2c21c1733 100644 --- a/src/ui/tokens/direct_token_purchase_screen.rs +++ b/src/ui/tokens/direct_token_purchase_screen.rs @@ -403,6 +403,13 @@ impl ScreenLike for PurchaseTokenScreen { { self.identity_token_info.identity = updated_identity; } + let contract_id = self.identity_token_info.data_contract.contract.id(); + super::pick_key_if_none( + &self.identity_token_info.identity, + &mut self.selected_key, + &mut self.selected_wallet, + |identity| identity.token_action_key(contract_id), + ); } fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { diff --git a/src/ui/tokens/mod.rs b/src/ui/tokens/mod.rs index 96bd1380f..13b15e6bf 100644 --- a/src/ui/tokens/mod.rs +++ b/src/ui/tokens/mod.rs @@ -19,10 +19,12 @@ pub mod view_token_claims_screen; use crate::context::AppContext; use crate::model::qualified_identity::{QualifiedIdentity, SigningKeyStatus}; use crate::model::user_role::UserRole; +use crate::model::wallet::Wallet; use crate::ui::MessageType; use crate::ui::components::MessageBanner; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::platform::IdentityPublicKey; +use std::sync::{Arc, RwLock}; /// Loads local identities, displaying an error banner on failure. pub fn load_identities_with_banner(app_context: &AppContext) -> Vec { @@ -83,6 +85,23 @@ pub(crate) fn token_action_key_status( } } +/// After `identity` was refreshed, picks the automatic signing key with +/// `pick` and its unlock wallet when no key is chosen yet, so a key that +/// became usable since the form opened is the one it signs with. A chosen +/// key, picked automatically or by hand, is kept. +pub(crate) fn pick_key_if_none( + identity: &QualifiedIdentity, + key: &mut Option, + wallet: &mut Option>>, + pick: impl FnOnce(&QualifiedIdentity) -> Option<&IdentityPublicKey>, +) { + if key.is_some() { + return; + } + *key = pick(identity).cloned(); + *wallet = crate::ui::identity::wallet_for_signing_key(identity, key.as_ref()); +} + /// Why a token form opened with its key chooser shown /// ([`SigningKeyStatus::ManualChoiceOnly`]). pub(crate) const CONTRACT_GROUP_KEY_NOTE: &str = "No key can be chosen automatically for this action, because the only key that can sign it is limited to a contract group. Choose that key below if the group includes this token's contract."; @@ -104,13 +123,40 @@ pub(crate) fn token_action_key_message( } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use crate::context::test_support::{auth_key, bound_to, identity_holding}; use dash_sdk::dpp::identity::SecurityLevel; use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; use dash_sdk::platform::Identifier; + /// The identity [`identity_holding`] builds, holding only an expired + /// CRITICAL authentication key `1`: no key can sign a token action. + pub(crate) fn identity_with_expired_key() -> QualifiedIdentity { + identity_holding(&[auth_key(1, SecurityLevel::CRITICAL).with_limits(None, Some(1))]) + } + + /// Stores in `ctx` the same identity holding a live key `1` from a wallet + /// loaded in `ctx`, as a screen reads it on refresh once the key became + /// usable, and returns that wallet's seed hash. + pub(crate) fn store_identity_with_usable_wallet_key( + ctx: &AppContext, + ) -> crate::model::wallet::WalletSeedHash { + use crate::wallet_backend::DetKv; + use crate::wallet_backend::kv_test_support::InMemoryKv; + use std::sync::{Arc, RwLock}; + let wallet = crate::model::wallet::test_support::open_wallet(); + let seed_hash = wallet.seed_hash(); + ctx.wallet_context() + .insert_test_wallet(seed_hash, Arc::new(RwLock::new(wallet))); + let mut identity = identity_holding(&[auth_key(1, SecurityLevel::CRITICAL)]); + crate::context::test_support::hold_as_wallet_reference(&mut identity, 1, seed_hash); + ctx.set_det_kv_override_for_test(DetKv::from_store(Arc::new(InMemoryKv::default()))); + ctx.insert_local_qualified_identity(&identity, &Some((seed_hash, 0))) + .expect("store identity"); + seed_hash + } + /// Regression: for a Power user whose only key is bound to a contract /// group, the token action gate closed before the Advanced chooser that /// offers that key. diff --git a/src/ui/tokens/set_token_price_screen.rs b/src/ui/tokens/set_token_price_screen.rs index aebfff73e..088d6d486 100644 --- a/src/ui/tokens/set_token_price_screen.rs +++ b/src/ui/tokens/set_token_price_screen.rs @@ -863,6 +863,13 @@ impl ScreenLike for SetTokenPriceScreen { { self.identity_token_info.identity = updated_identity; } + let contract_id = self.identity_token_info.data_contract.contract.id(); + super::pick_key_if_none( + &self.identity_token_info.identity, + &mut self.selected_key, + &mut self.selected_wallet, + |identity| identity.token_action_key(contract_id), + ); } fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { diff --git a/src/ui/tokens/token_action_screen.rs b/src/ui/tokens/token_action_screen.rs index 8862bfcb2..dc35b4a60 100644 --- a/src/ui/tokens/token_action_screen.rs +++ b/src/ui/tokens/token_action_screen.rs @@ -465,6 +465,13 @@ impl ScreenLike for TokenActionScreen { { self.common.identity_token_info.identity = updated; } + let contract_id = self.common.identity_token_info.data_contract.contract.id(); + super::pick_key_if_none( + &self.common.identity_token_info.identity, + &mut self.common.selected_key, + &mut self.common.selected_wallet, + |identity| identity.token_action_key(contract_id), + ); } fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { @@ -662,3 +669,51 @@ impl ScreenLike for TokenActionScreen { action } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::qualified_contract::QualifiedContract; + use crate::ui::tokens::mint_tokens_screen::MintTokensScreen; + use crate::ui::tokens::tests::{ + identity_with_expired_key, store_identity_with_usable_wallet_key, + }; + use dash_sdk::dpp::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; + use dash_sdk::platform::Identifier; + + /// Regression: the action's key was picked only when the form opened, so + /// once the identity's key became usable a refresh opened the form with + /// no key to sign. A refresh picks it then, with its wallet. + #[test] + fn a_refresh_picks_the_token_action_key_that_became_usable() { + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + let dir = tempfile::tempdir().expect("tempdir"); + let context = crate::context::test_support::test_app_context(dir.path()); + let info = IdentityTokenInfo { + token_id: Identifier::from([4; 32]), + token_alias: "Test token".into(), + identity: identity_with_expired_key(), + data_contract: QualifiedContract { + contract: context.dpns_contract().as_ref().clone(), + alias: None, + }, + token_config: TokenConfigurationV0::default_most_restrictive().into(), + token_position: 0, + }; + let mut screen = MintTokensScreen::new(info, &context); + assert!(screen.common.selected_key.is_none()); + let seed_hash = store_identity_with_usable_wallet_key(&context); + + screen.refresh(); + assert_eq!( + screen.common.selected_key.as_ref().map(|key| key.id()), + Some(1) + ); + let wallet = screen + .common + .selected_wallet + .as_ref() + .expect("the key's wallet"); + assert_eq!(wallet.read().expect("wallet").seed_hash(), seed_hash); + } +} diff --git a/src/ui/tokens/transfer_tokens_screen.rs b/src/ui/tokens/transfer_tokens_screen.rs index 026844a4f..9a6446686 100644 --- a/src/ui/tokens/transfer_tokens_screen.rs +++ b/src/ui/tokens/transfer_tokens_screen.rs @@ -356,6 +356,15 @@ impl ScreenLike for TransferTokensScreen { self.identity = Some(refreshed); } } + if let Some(identity) = &self.identity { + let contract_id = self.identity_token_balance.data_contract_id; + super::pick_key_if_none( + identity, + &mut self.selected_key, + &mut self.selected_wallet, + |identity| identity.single_token_transition_key(contract_id), + ); + } if let Some(current_id) = self.identity.as_ref().map(|id| id.identity.id()) { match self.app_context.identity_token_balances() { Ok(token_balances) => {