From a1113422786d87bc72fc1454a33084580f5fc975 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:59:39 +0000 Subject: [PATCH 01/20] =?UTF-8?q?chore:=20bump=20dashpay/platform=20pin=20?= =?UTF-8?q?to=20PR=20#3968=20tip=20(d18020f=20=E2=86=92=20f376d32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the four dashpay/platform git dependencies (dash-sdk, rs-sdk-trusted-context-provider, platform-wallet, platform-wallet-storage) from d18020f to f376d32, the tip of platform PR #3968 — "feat(platform- wallet-storage): embeddable SQLite persistence backend with seedless rehydration". Cargo.lock is regenerated scoped to the platform package set: only dashpay-ecosystem git deps moved (platform, rust-dashcore be6e776→0091c4a, grovedb v5.0.0→v5.0.1, orchard) — all transitively pinned by the new platform rev; no crates.io registry drift. API-drift fixes required by the upstream jump (each verified against the new upstream source, not guessed): - AssetLockFunding::FromExistingAssetLock gained a consume_invitation_voucher field. Set false at the three generic funding sites (identity register, identity top-up, platform-address funding) — none is the DashPay invitation-voucher reclaim flow, so a bearer-voucher lock is never consumed. - shielded_transfer_to / shielded_unshield_to / shielded_withdraw_to each gained a seed: &[u8] parameter. This is the seedless-rehydration privilege- separation change: the Orchard spend keyset (ASK included) is re-derived from the seed for the single call and dropped on return, never left resident. DET's shielded_transfer/unshield/withdraw now resolve the HD seed just-in-time through the secret-seam chokepoint (with_secret_session), mirroring the existing shield_from_balance path; stale "no seed scope needed" doc comments corrected. - PlatformWalletError gained PersisterStore and PersisterRestore variants. Added to both exhaustive (no wildcard) match sites — map_shielded_op_error and identity_op_error_kind — in the generic persistence bucket alongside PersisterLoad / Persistence. Verification (via cargo-cached.sh ledger): - cargo clippy --all-features --all-targets -- -D warnings — exit 0 - cargo test --lib --all-features — 2009 passed, 0 failed, 1 ignored - cargo fmt --all -- --check — exit 0 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 13 ++ Cargo.lock | 174 +++++++++--------- Cargo.toml | 8 +- .../identity/register_identity.rs | 4 + src/backend_task/identity/top_up_identity.rs | 4 + .../fund_platform_address_from_asset_lock.rs | 8 +- src/wallet_backend/mod.rs | 4 + src/wallet_backend/shielded.rs | 115 ++++++++---- 8 files changed, 199 insertions(+), 131 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4de229b17..1c3da6dd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- **Upstream wallet backend updated (`platform-wallet` / `platform-wallet-storage`)**: + the `dashpay/platform` dependency is bumped to the PR #3968 tip + (`d18020f` → `f376d32`), which lands an embeddable SQLite persistence backend with + *seedless rehydration*. The wallet manager now restores watch-only wallet state + (accounts, balances, identities, platform addresses) from the on-disk store + without the HD seed, re-deriving spend authority just-in-time from the seed only + when an operation actually signs — so private key material is never left resident + between operations. DET's shielded send / unshield / withdraw were updated to + match: each now resolves the HD seed through the secret-seam chokepoint for the + single spend and drops it on return. The update also adds persistence and + rehydration for provider (masternode / evonode) platform-node key pools and for + DashPay invitations. + - **Shielded transactions are available on supported networks**: sending, receiving, shielding, and unshielding are enabled when the connected network's protocol version supports them, including mainnet. These operations were diff --git a/Cargo.lock b/Cargo.lock index 76f93a5cf..e95f3f060 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1877,7 +1877,7 @@ dependencies = [ [[package]] name = "dapi-grpc" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=d18020f526e2a8eb1d1e868b436a7a9735795abb#d18020f526e2a8eb1d1e868b436a7a9735795abb" +source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" dependencies = [ "dash-platform-macros", "futures-core", @@ -1979,7 +1979,7 @@ dependencies = [ [[package]] name = "dash-async" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=d18020f526e2a8eb1d1e868b436a7a9735795abb#d18020f526e2a8eb1d1e868b436a7a9735795abb" +source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" dependencies = [ "thiserror 2.0.18", "tokio", @@ -1989,7 +1989,7 @@ dependencies = [ [[package]] name = "dash-context-provider" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=d18020f526e2a8eb1d1e868b436a7a9735795abb#d18020f526e2a8eb1d1e868b436a7a9735795abb" +source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" dependencies = [ "dash-async", "dpp", @@ -2081,7 +2081,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=be6e776d69af9f31ec622898176e4b33bdc969d3#be6e776d69af9f31ec622898176e4b33bdc969d3" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0091c4a93db446cb2f950b8f2aa1326a4ffc94c7#0091c4a93db446cb2f950b8f2aa1326a4ffc94c7" dependencies = [ "bincode 2.0.1", "bincode_derive", @@ -2092,7 +2092,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=be6e776d69af9f31ec622898176e4b33bdc969d3#be6e776d69af9f31ec622898176e4b33bdc969d3" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0091c4a93db446cb2f950b8f2aa1326a4ffc94c7#0091c4a93db446cb2f950b8f2aa1326a4ffc94c7" dependencies = [ "dash-network", ] @@ -2100,7 +2100,7 @@ dependencies = [ [[package]] name = "dash-platform-macros" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=d18020f526e2a8eb1d1e868b436a7a9735795abb#d18020f526e2a8eb1d1e868b436a7a9735795abb" +source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" dependencies = [ "heck", "quote", @@ -2110,7 +2110,7 @@ dependencies = [ [[package]] name = "dash-sdk" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=d18020f526e2a8eb1d1e868b436a7a9735795abb#d18020f526e2a8eb1d1e868b436a7a9735795abb" +source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" dependencies = [ "arc-swap", "async-trait", @@ -2148,7 +2148,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=be6e776d69af9f31ec622898176e4b33bdc969d3#be6e776d69af9f31ec622898176e4b33bdc969d3" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0091c4a93db446cb2f950b8f2aa1326a4ffc94c7#0091c4a93db446cb2f950b8f2aa1326a4ffc94c7" dependencies = [ "async-trait", "chrono", @@ -2177,7 +2177,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=be6e776d69af9f31ec622898176e4b33bdc969d3#be6e776d69af9f31ec622898176e4b33bdc969d3" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0091c4a93db446cb2f950b8f2aa1326a4ffc94c7#0091c4a93db446cb2f950b8f2aa1326a4ffc94c7" dependencies = [ "anyhow", "base64-compat", @@ -2203,12 +2203,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=be6e776d69af9f31ec622898176e4b33bdc969d3#be6e776d69af9f31ec622898176e4b33bdc969d3" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0091c4a93db446cb2f950b8f2aa1326a4ffc94c7#0091c4a93db446cb2f950b8f2aa1326a4ffc94c7" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=be6e776d69af9f31ec622898176e4b33bdc969d3#be6e776d69af9f31ec622898176e4b33bdc969d3" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0091c4a93db446cb2f950b8f2aa1326a4ffc94c7#0091c4a93db446cb2f950b8f2aa1326a4ffc94c7" dependencies = [ "dashcore-rpc-json", "hex", @@ -2221,7 +2221,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=be6e776d69af9f31ec622898176e4b33bdc969d3#be6e776d69af9f31ec622898176e4b33bdc969d3" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0091c4a93db446cb2f950b8f2aa1326a4ffc94c7#0091c4a93db446cb2f950b8f2aa1326a4ffc94c7" dependencies = [ "bincode 2.0.1", "dashcore", @@ -2236,7 +2236,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=be6e776d69af9f31ec622898176e4b33bdc969d3#be6e776d69af9f31ec622898176e4b33bdc969d3" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0091c4a93db446cb2f950b8f2aa1326a4ffc94c7#0091c4a93db446cb2f950b8f2aa1326a4ffc94c7" dependencies = [ "bincode 2.0.1", "dashcore-private", @@ -2247,7 +2247,7 @@ dependencies = [ [[package]] name = "dashpay-contract" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=d18020f526e2a8eb1d1e868b436a7a9735795abb#d18020f526e2a8eb1d1e868b436a7a9735795abb" +source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" dependencies = [ "platform-value", "platform-version", @@ -2258,7 +2258,7 @@ dependencies = [ [[package]] name = "data-contracts" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=d18020f526e2a8eb1d1e868b436a7a9735795abb#d18020f526e2a8eb1d1e868b436a7a9735795abb" +source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" dependencies = [ "dashpay-contract", "dpns-contract", @@ -2544,7 +2544,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "dpns-contract" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=d18020f526e2a8eb1d1e868b436a7a9735795abb#d18020f526e2a8eb1d1e868b436a7a9735795abb" +source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" dependencies = [ "platform-value", "platform-version", @@ -2555,7 +2555,7 @@ dependencies = [ [[package]] name = "dpp" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=d18020f526e2a8eb1d1e868b436a7a9735795abb#d18020f526e2a8eb1d1e868b436a7a9735795abb" +source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" dependencies = [ "anyhow", "async-trait", @@ -2605,7 +2605,7 @@ dependencies = [ [[package]] name = "dpp-json-convertible-derive" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=d18020f526e2a8eb1d1e868b436a7a9735795abb#d18020f526e2a8eb1d1e868b436a7a9735795abb" +source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" dependencies = [ "proc-macro2", "quote", @@ -2615,17 +2615,17 @@ dependencies = [ [[package]] name = "drive" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=d18020f526e2a8eb1d1e868b436a7a9735795abb#d18020f526e2a8eb1d1e868b436a7a9735795abb" +source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" dependencies = [ "bincode 2.0.1", "byteorder", "derive_more 1.0.0", "dpp", - "grovedb 5.0.0", - "grovedb-costs 5.0.0", + "grovedb 5.0.1", + "grovedb-costs 5.0.1", "grovedb-epoch-based-storage-flags", - "grovedb-path 5.0.0", - "grovedb-version 5.0.0", + "grovedb-path 5.0.1", + "grovedb-version 5.0.1", "hex", "indexmap 2.14.0", "integer-encoding", @@ -2640,7 +2640,7 @@ dependencies = [ [[package]] name = "drive-proof-verifier" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=d18020f526e2a8eb1d1e868b436a7a9735795abb#d18020f526e2a8eb1d1e868b436a7a9735795abb" +source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" dependencies = [ "bincode 2.0.1", "dapi-grpc", @@ -3663,7 +3663,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=be6e776d69af9f31ec622898176e4b33bdc969d3#be6e776d69af9f31ec622898176e4b33bdc969d3" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0091c4a93db446cb2f950b8f2aa1326a4ffc94c7#0091c4a93db446cb2f950b8f2aa1326a4ffc94c7" [[package]] name = "gl_generator" @@ -3860,21 +3860,21 @@ dependencies = [ [[package]] name = "grovedb" -version = "5.0.0" -source = "git+https://github.com/dashpay/grovedb?tag=v5.0.0#9b98a35644cdea73cc1b21d7c122cb58ae9fafd8" +version = "5.0.1" +source = "git+https://github.com/dashpay/grovedb?tag=v5.0.1#6bb2ebff62a842a3c416126bbcfcf7774dfe0262" dependencies = [ "bincode 2.0.1", "bincode_derive", "blake3", "grovedb-bulk-append-tree", - "grovedb-costs 5.0.0", + "grovedb-costs 5.0.1", "grovedb-dense-fixed-sized-merkle-tree", - "grovedb-element 5.0.0", - "grovedb-merk 5.0.0", + "grovedb-element 5.0.1", + "grovedb-merk 5.0.1", "grovedb-merkle-mountain-range", - "grovedb-path 5.0.0", + "grovedb-path 5.0.1", "grovedb-query", - "grovedb-version 5.0.0", + "grovedb-version 5.0.1", "hex", "indexmap 2.14.0", "integer-encoding", @@ -3883,12 +3883,12 @@ dependencies = [ [[package]] name = "grovedb-bulk-append-tree" -version = "5.0.0" -source = "git+https://github.com/dashpay/grovedb?tag=v5.0.0#9b98a35644cdea73cc1b21d7c122cb58ae9fafd8" +version = "5.0.1" +source = "git+https://github.com/dashpay/grovedb?tag=v5.0.1#6bb2ebff62a842a3c416126bbcfcf7774dfe0262" dependencies = [ "bincode 2.0.1", "blake3", - "grovedb-costs 5.0.0", + "grovedb-costs 5.0.1", "grovedb-dense-fixed-sized-merkle-tree", "grovedb-merkle-mountain-range", "grovedb-query", @@ -3898,12 +3898,12 @@ dependencies = [ [[package]] name = "grovedb-commitment-tree" -version = "5.0.0" -source = "git+https://github.com/dashpay/grovedb?tag=v5.0.0#9b98a35644cdea73cc1b21d7c122cb58ae9fafd8" +version = "5.0.1" +source = "git+https://github.com/dashpay/grovedb?tag=v5.0.1#6bb2ebff62a842a3c416126bbcfcf7774dfe0262" dependencies = [ "blake3", "grovedb-bulk-append-tree", - "grovedb-costs 5.0.0", + "grovedb-costs 5.0.1", "incrementalmerkletree", "orchard", "rusqlite", @@ -3923,8 +3923,8 @@ dependencies = [ [[package]] name = "grovedb-costs" -version = "5.0.0" -source = "git+https://github.com/dashpay/grovedb?tag=v5.0.0#9b98a35644cdea73cc1b21d7c122cb58ae9fafd8" +version = "5.0.1" +source = "git+https://github.com/dashpay/grovedb?tag=v5.0.1#6bb2ebff62a842a3c416126bbcfcf7774dfe0262" dependencies = [ "integer-encoding", "intmap", @@ -3933,12 +3933,12 @@ dependencies = [ [[package]] name = "grovedb-dense-fixed-sized-merkle-tree" -version = "5.0.0" -source = "git+https://github.com/dashpay/grovedb?tag=v5.0.0#9b98a35644cdea73cc1b21d7c122cb58ae9fafd8" +version = "5.0.1" +source = "git+https://github.com/dashpay/grovedb?tag=v5.0.1#6bb2ebff62a842a3c416126bbcfcf7774dfe0262" dependencies = [ "bincode 2.0.1", "blake3", - "grovedb-costs 5.0.0", + "grovedb-costs 5.0.1", "grovedb-query", "thiserror 2.0.18", ] @@ -3959,13 +3959,13 @@ dependencies = [ [[package]] name = "grovedb-element" -version = "5.0.0" -source = "git+https://github.com/dashpay/grovedb?tag=v5.0.0#9b98a35644cdea73cc1b21d7c122cb58ae9fafd8" +version = "5.0.1" +source = "git+https://github.com/dashpay/grovedb?tag=v5.0.1#6bb2ebff62a842a3c416126bbcfcf7774dfe0262" dependencies = [ "bincode 2.0.1", "bincode_derive", - "grovedb-path 5.0.0", - "grovedb-version 5.0.0", + "grovedb-path 5.0.1", + "grovedb-version 5.0.1", "hex", "integer-encoding", "thiserror 2.0.18", @@ -3973,10 +3973,10 @@ dependencies = [ [[package]] name = "grovedb-epoch-based-storage-flags" -version = "5.0.0" -source = "git+https://github.com/dashpay/grovedb?tag=v5.0.0#9b98a35644cdea73cc1b21d7c122cb58ae9fafd8" +version = "5.0.1" +source = "git+https://github.com/dashpay/grovedb?tag=v5.0.1#6bb2ebff62a842a3c416126bbcfcf7774dfe0262" dependencies = [ - "grovedb-costs 5.0.0", + "grovedb-costs 5.0.1", "hex", "integer-encoding", "intmap", @@ -4006,20 +4006,20 @@ dependencies = [ [[package]] name = "grovedb-merk" -version = "5.0.0" -source = "git+https://github.com/dashpay/grovedb?tag=v5.0.0#9b98a35644cdea73cc1b21d7c122cb58ae9fafd8" +version = "5.0.1" +source = "git+https://github.com/dashpay/grovedb?tag=v5.0.1#6bb2ebff62a842a3c416126bbcfcf7774dfe0262" dependencies = [ "bincode 2.0.1", "bincode_derive", "blake3", "byteorder", "ed", - "grovedb-costs 5.0.0", - "grovedb-element 5.0.0", - "grovedb-path 5.0.0", + "grovedb-costs 5.0.1", + "grovedb-element 5.0.1", + "grovedb-path 5.0.1", "grovedb-query", - "grovedb-version 5.0.0", - "grovedb-visualize 5.0.0", + "grovedb-version 5.0.1", + "grovedb-visualize 5.0.1", "hex", "indexmap 2.14.0", "integer-encoding", @@ -4028,12 +4028,12 @@ dependencies = [ [[package]] name = "grovedb-merkle-mountain-range" -version = "5.0.0" -source = "git+https://github.com/dashpay/grovedb?tag=v5.0.0#9b98a35644cdea73cc1b21d7c122cb58ae9fafd8" +version = "5.0.1" +source = "git+https://github.com/dashpay/grovedb?tag=v5.0.1#6bb2ebff62a842a3c416126bbcfcf7774dfe0262" dependencies = [ "bincode 2.0.1", "blake3", - "grovedb-costs 5.0.0", + "grovedb-costs 5.0.1", ] [[package]] @@ -4046,16 +4046,16 @@ dependencies = [ [[package]] name = "grovedb-path" -version = "5.0.0" -source = "git+https://github.com/dashpay/grovedb?tag=v5.0.0#9b98a35644cdea73cc1b21d7c122cb58ae9fafd8" +version = "5.0.1" +source = "git+https://github.com/dashpay/grovedb?tag=v5.0.1#6bb2ebff62a842a3c416126bbcfcf7774dfe0262" dependencies = [ "hex", ] [[package]] name = "grovedb-query" -version = "5.0.0" -source = "git+https://github.com/dashpay/grovedb?tag=v5.0.0#9b98a35644cdea73cc1b21d7c122cb58ae9fafd8" +version = "5.0.1" +source = "git+https://github.com/dashpay/grovedb?tag=v5.0.1#6bb2ebff62a842a3c416126bbcfcf7774dfe0262" dependencies = [ "bincode 2.0.1", "byteorder", @@ -4077,8 +4077,8 @@ dependencies = [ [[package]] name = "grovedb-version" -version = "5.0.0" -source = "git+https://github.com/dashpay/grovedb?tag=v5.0.0#9b98a35644cdea73cc1b21d7c122cb58ae9fafd8" +version = "5.0.1" +source = "git+https://github.com/dashpay/grovedb?tag=v5.0.1#6bb2ebff62a842a3c416126bbcfcf7774dfe0262" dependencies = [ "thiserror 2.0.18", "versioned-feature-core 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", @@ -4095,8 +4095,8 @@ dependencies = [ [[package]] name = "grovedb-visualize" -version = "5.0.0" -source = "git+https://github.com/dashpay/grovedb?tag=v5.0.0#9b98a35644cdea73cc1b21d7c122cb58ae9fafd8" +version = "5.0.1" +source = "git+https://github.com/dashpay/grovedb?tag=v5.0.1#6bb2ebff62a842a3c416126bbcfcf7774dfe0262" dependencies = [ "hex", "itertools 0.14.0", @@ -4987,7 +4987,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=be6e776d69af9f31ec622898176e4b33bdc969d3#be6e776d69af9f31ec622898176e4b33bdc969d3" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0091c4a93db446cb2f950b8f2aa1326a4ffc94c7#0091c4a93db446cb2f950b8f2aa1326a4ffc94c7" dependencies = [ "async-trait", "base58ck", @@ -5010,7 +5010,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=be6e776d69af9f31ec622898176e4b33bdc969d3#be6e776d69af9f31ec622898176e4b33bdc969d3" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0091c4a93db446cb2f950b8f2aa1326a4ffc94c7#0091c4a93db446cb2f950b8f2aa1326a4ffc94c7" dependencies = [ "async-trait", "dashcore", @@ -5034,7 +5034,7 @@ dependencies = [ [[package]] name = "keyword-search-contract" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=d18020f526e2a8eb1d1e868b436a7a9735795abb#d18020f526e2a8eb1d1e868b436a7a9735795abb" +source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" dependencies = [ "platform-value", "platform-version", @@ -5247,7 +5247,7 @@ dependencies = [ [[package]] name = "masternode-reward-shares-contract" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=d18020f526e2a8eb1d1e868b436a7a9735795abb#d18020f526e2a8eb1d1e868b436a7a9735795abb" +source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" dependencies = [ "platform-value", "platform-version", @@ -6107,7 +6107,7 @@ dependencies = [ [[package]] name = "orchard" version = "0.14.0" -source = "git+https://github.com/dashpay/orchard.git?tag=dashified-0.14.0#f05557390a5843bc4eb04c66d8140bc9ef0fe9b7" +source = "git+https://github.com/dashpay/orchard.git?tag=dashified-0.14.1#38ac9c19a2df7bf3eeadc22ab23053e8fd538828" dependencies = [ "aes", "bitvec", @@ -6459,7 +6459,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "platform-encryption" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=d18020f526e2a8eb1d1e868b436a7a9735795abb#d18020f526e2a8eb1d1e868b436a7a9735795abb" +source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" dependencies = [ "aes", "cbc", @@ -6472,7 +6472,7 @@ dependencies = [ [[package]] name = "platform-serialization" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=d18020f526e2a8eb1d1e868b436a7a9735795abb#d18020f526e2a8eb1d1e868b436a7a9735795abb" +source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" dependencies = [ "bincode 2.0.1", "platform-version", @@ -6481,7 +6481,7 @@ dependencies = [ [[package]] name = "platform-serialization-derive" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=d18020f526e2a8eb1d1e868b436a7a9735795abb#d18020f526e2a8eb1d1e868b436a7a9735795abb" +source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" dependencies = [ "proc-macro2", "quote", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "platform-value" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=d18020f526e2a8eb1d1e868b436a7a9735795abb#d18020f526e2a8eb1d1e868b436a7a9735795abb" +source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" dependencies = [ "base64 0.22.1", "bincode 2.0.1", @@ -6512,10 +6512,10 @@ dependencies = [ [[package]] name = "platform-version" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=d18020f526e2a8eb1d1e868b436a7a9735795abb#d18020f526e2a8eb1d1e868b436a7a9735795abb" +source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" dependencies = [ "bincode 2.0.1", - "grovedb-version 5.0.0", + "grovedb-version 5.0.1", "thiserror 2.0.18", "versioned-feature-core 1.0.0 (git+https://github.com/dashpay/versioned-feature-core)", ] @@ -6523,7 +6523,7 @@ dependencies = [ [[package]] name = "platform-versioning" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=d18020f526e2a8eb1d1e868b436a7a9735795abb#d18020f526e2a8eb1d1e868b436a7a9735795abb" +source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" dependencies = [ "proc-macro2", "quote", @@ -6533,7 +6533,7 @@ dependencies = [ [[package]] name = "platform-wallet" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=d18020f526e2a8eb1d1e868b436a7a9735795abb#d18020f526e2a8eb1d1e868b436a7a9735795abb" +source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" dependencies = [ "arc-swap", "async-trait", @@ -6565,7 +6565,7 @@ dependencies = [ [[package]] name = "platform-wallet-storage" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=d18020f526e2a8eb1d1e868b436a7a9735795abb#d18020f526e2a8eb1d1e868b436a7a9735795abb" +source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" dependencies = [ "apple-native-keyring-store", "argon2", @@ -7562,7 +7562,7 @@ dependencies = [ [[package]] name = "rs-dapi-client" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=d18020f526e2a8eb1d1e868b436a7a9735795abb#d18020f526e2a8eb1d1e868b436a7a9735795abb" +source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" dependencies = [ "backon", "chrono", @@ -7588,7 +7588,7 @@ dependencies = [ [[package]] name = "rs-sdk-trusted-context-provider" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=d18020f526e2a8eb1d1e868b436a7a9735795abb#d18020f526e2a8eb1d1e868b436a7a9735795abb" +source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" dependencies = [ "arc-swap", "dash-async", @@ -8830,7 +8830,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "token-history-contract" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=d18020f526e2a8eb1d1e868b436a7a9735795abb#d18020f526e2a8eb1d1e868b436a7a9735795abb" +source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" dependencies = [ "platform-value", "platform-version", @@ -9675,7 +9675,7 @@ dependencies = [ [[package]] name = "wallet-utils-contract" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=d18020f526e2a8eb1d1e868b436a7a9735795abb#d18020f526e2a8eb1d1e868b436a7a9735795abb" +source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" dependencies = [ "platform-value", "platform-version", @@ -10931,7 +10931,7 @@ dependencies = [ [[package]] name = "withdrawals-contract" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=d18020f526e2a8eb1d1e868b436a7a9735795abb#d18020f526e2a8eb1d1e868b436a7a9735795abb" +source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" dependencies = [ "num_enum 0.5.11", "platform-value", diff --git a/Cargo.toml b/Cargo.toml index c52314cac..5a0c9f630 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ qrcode = "0.14.1" nix = { version = "0.31.1", features = ["signal"] } eframe = { version = "0.35.0", features = ["persistence", "wgpu"] } base64 = "0.22.1" -dash-sdk = { git = "https://github.com/dashpay/platform", rev = "d18020f526e2a8eb1d1e868b436a7a9735795abb", features = [ +dash-sdk = { git = "https://github.com/dashpay/platform", rev = "f376d32bff1bb2436f245b23fe82a55cff7fbe95", features = [ "core_key_wallet", "core_key_wallet_manager", "core_bincode", @@ -28,12 +28,12 @@ dash-sdk = { git = "https://github.com/dashpay/platform", rev = "d18020f526e2a8e "core_spv", "shielded", ] } -rs-sdk-trusted-context-provider = { git = "https://github.com/dashpay/platform", rev = "d18020f526e2a8eb1d1e868b436a7a9735795abb" } -platform-wallet = { git = "https://github.com/dashpay/platform", rev = "d18020f526e2a8eb1d1e868b436a7a9735795abb", features = [ +rs-sdk-trusted-context-provider = { git = "https://github.com/dashpay/platform", rev = "f376d32bff1bb2436f245b23fe82a55cff7fbe95" } +platform-wallet = { git = "https://github.com/dashpay/platform", rev = "f376d32bff1bb2436f245b23fe82a55cff7fbe95", features = [ "serde", "shielded", ] } -platform-wallet-storage = { git = "https://github.com/dashpay/platform", rev = "d18020f526e2a8eb1d1e868b436a7a9735795abb", features = [ +platform-wallet-storage = { git = "https://github.com/dashpay/platform", rev = "f376d32bff1bb2436f245b23fe82a55cff7fbe95", features = [ "shielded", ] } zip32 = "0.2.0" diff --git a/src/backend_task/identity/register_identity.rs b/src/backend_task/identity/register_identity.rs index fca03f667..0e9a0e593 100644 --- a/src/backend_task/identity/register_identity.rs +++ b/src/backend_task/identity/register_identity.rs @@ -79,6 +79,10 @@ impl AppContext { let funding = platform_wallet::wallet::asset_lock::AssetLockFunding::FromExistingAssetLock { out_point, + // Generic identity-registration resume, not the DashPay + // invitation-voucher reclaim flow, so it must never + // consume a bearer-voucher (invitation-typed) lock. + consume_invitation_voucher: false, }; self.register_identity_via_wallet_backend( funding, diff --git a/src/backend_task/identity/top_up_identity.rs b/src/backend_task/identity/top_up_identity.rs index 2723375e8..091b6c98d 100644 --- a/src/backend_task/identity/top_up_identity.rs +++ b/src/backend_task/identity/top_up_identity.rs @@ -75,6 +75,10 @@ impl AppContext { } => { let funding = platform_wallet::wallet::asset_lock::AssetLockFunding::FromExistingAssetLock { out_point, + // Generic identity top-up resume, not the DashPay + // invitation-voucher reclaim flow, so it must never + // consume a bearer-voucher (invitation-typed) lock. + consume_invitation_voucher: false, }; (funding, identity_index, top_up_index, None) } diff --git a/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs b/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs index 38f58d58f..8b0f42552 100644 --- a/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs +++ b/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs @@ -94,7 +94,13 @@ impl AppContext { backend .fund_platform_address( &seed_hash, - AssetLockFunding::FromExistingAssetLock { out_point }, + // Generic platform-address funding resume, not the DashPay + // invitation-voucher reclaim flow, so it must never consume a + // bearer-voucher (invitation-typed) lock. + AssetLockFunding::FromExistingAssetLock { + out_point, + consume_invitation_voucher: false, + }, 0, outputs, fee_strategy, diff --git a/src/wallet_backend/mod.rs b/src/wallet_backend/mod.rs index 546967902..ff464f78e 100644 --- a/src/wallet_backend/mod.rs +++ b/src/wallet_backend/mod.rs @@ -2787,6 +2787,8 @@ fn map_shielded_op_error(e: platform_wallet::error::PlatformWalletError) -> Task | P::AddressNotFound(_) | P::KeyDerivation(_) | P::Persistence(_) + | P::PersisterStore(_) + | P::PersisterRestore(_) | P::SeedMismatch { .. } | P::WalletLocked | P::SpvAlreadyRunning @@ -2974,6 +2976,8 @@ fn identity_op_error_kind(e: &platform_wallet::error::PlatformWalletError) -> Id | P::ShieldedNoRecordedAnchor(_) | P::ShieldedNotBound | P::PersisterLoad(_) + | P::PersisterStore(_) + | P::PersisterRestore(_) | P::Persistence(_) | P::SeedMismatch { .. } // Address nonce desync is a precondition/state fault unrelated to diff --git a/src/wallet_backend/shielded.rs b/src/wallet_backend/shielded.rs index 48ce66ffd..1d03b3e43 100644 --- a/src/wallet_backend/shielded.rs +++ b/src/wallet_backend/shielded.rs @@ -197,8 +197,12 @@ impl WalletBackend { /// Shielded → shielded transfer from `account`'s notes to `recipient`. /// - /// No seed scope needed — the Orchard ASK is already resident in the - /// wallet's bound `shielded_keys` slot from `ensure_shielded_bound`. + /// The HD seed is resolved just-in-time through the + /// [`SecretAccess`](super::SecretAccess) chokepoint: upstream re-derives the + /// Orchard spend keyset (ASK included) from it for this call only and drops + /// it on return, so the spend authority is never left resident. An + /// unprotected wallet resolves prompt-free; a protected one needs its seed + /// already promoted to the session cache, else [`TaskError::WalletLocked`]. /// /// The Orchard prover is created internally via /// `CachedOrchardProver::new()` — callers do not supply a prover. @@ -211,25 +215,37 @@ impl WalletBackend { memo: [u8; 36], ) -> Result<(), TaskError> { let coordinator = self.shielded_coordinator_arc().await?; - let wallet = self.resolve_wallet(seed_hash).await?; - let prover = platform_wallet::wallet::shielded::CachedOrchardProver::new(); - wallet - .shielded_transfer_to( - &coordinator, - account, - recipient_raw_43, - amount, - memo, - &prover, - ) + let scope = Self::hd_scope(seed_hash); + self.inner + .secret_access + .with_secret_session(&scope, async |session| { + let plaintext = session.plaintext(); + let seed = plaintext.expose_hd_seed().ok_or(TaskError::WalletLocked)?; + let wallet = self.resolve_wallet(seed_hash).await?; + let prover = platform_wallet::wallet::shielded::CachedOrchardProver::new(); + wallet + .shielded_transfer_to( + &coordinator, + seed, + account, + recipient_raw_43, + amount, + memo, + &prover, + ) + .await + .map_err(map_shielded_op_error) + }) .await - .map_err(map_shielded_op_error) } /// Unshield from `account`'s notes to a transparent platform address /// (bech32m `"dash1…"` / `"tdash1…"` string). /// - /// No seed scope needed — keys are already bound. + /// The HD seed is resolved just-in-time through the + /// [`SecretAccess`](super::SecretAccess) chokepoint (see + /// [`shielded_transfer`](Self::shielded_transfer) for the spend-authority + /// lifetime and locked-wallet behaviour). /// /// The Orchard prover is created internally via /// `CachedOrchardProver::new()` — callers do not supply a prover. @@ -241,23 +257,35 @@ impl WalletBackend { amount: u64, ) -> Result<(), TaskError> { let coordinator = self.shielded_coordinator_arc().await?; - let wallet = self.resolve_wallet(seed_hash).await?; - let prover = platform_wallet::wallet::shielded::CachedOrchardProver::new(); - wallet - .shielded_unshield_to( - &coordinator, - account, - to_platform_addr_bech32m, - amount, - &prover, - ) + let scope = Self::hd_scope(seed_hash); + self.inner + .secret_access + .with_secret_session(&scope, async |session| { + let plaintext = session.plaintext(); + let seed = plaintext.expose_hd_seed().ok_or(TaskError::WalletLocked)?; + let wallet = self.resolve_wallet(seed_hash).await?; + let prover = platform_wallet::wallet::shielded::CachedOrchardProver::new(); + wallet + .shielded_unshield_to( + &coordinator, + seed, + account, + to_platform_addr_bech32m, + amount, + &prover, + ) + .await + .map_err(map_shielded_op_error) + }) .await - .map_err(map_shielded_op_error) } /// Withdraw from `account`'s notes to a Core L1 address (Base58Check). /// - /// No seed scope needed — keys are already bound. + /// The HD seed is resolved just-in-time through the + /// [`SecretAccess`](super::SecretAccess) chokepoint (see + /// [`shielded_transfer`](Self::shielded_transfer) for the spend-authority + /// lifetime and locked-wallet behaviour). /// /// The Orchard prover is created internally via /// `CachedOrchardProver::new()` — callers do not supply a prover. @@ -270,19 +298,28 @@ impl WalletBackend { core_fee_per_byte: u32, ) -> Result<(), TaskError> { let coordinator = self.shielded_coordinator_arc().await?; - let wallet = self.resolve_wallet(seed_hash).await?; - let prover = platform_wallet::wallet::shielded::CachedOrchardProver::new(); - wallet - .shielded_withdraw_to( - &coordinator, - account, - to_core_address, - amount, - core_fee_per_byte, - &prover, - ) + let scope = Self::hd_scope(seed_hash); + self.inner + .secret_access + .with_secret_session(&scope, async |session| { + let plaintext = session.plaintext(); + let seed = plaintext.expose_hd_seed().ok_or(TaskError::WalletLocked)?; + let wallet = self.resolve_wallet(seed_hash).await?; + let prover = platform_wallet::wallet::shielded::CachedOrchardProver::new(); + wallet + .shielded_withdraw_to( + &coordinator, + seed, + account, + to_core_address, + amount, + core_fee_per_byte, + &prover, + ) + .await + .map_err(map_shielded_op_error) + }) .await - .map_err(map_shielded_op_error) } /// Per-account unspent shielded balance for `seed_hash`'s wallet. From d43baee8d60a2be5605d5b8a7e8993e60423302f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:14:25 +0000 Subject: [PATCH 02/20] docs: fold in security-review follow-ups for the platform#3968 pin bump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups from the security due-diligence pass on the dashpay/platform PR #3968 diff (companion to the pin-bump commit on this branch). No behavior change — comment-only edits plus a supply-chain audit. - Verified the new reserve-on-hand-out receive-address behavior does NOT reach DET. Upstream PlatformAddressWallet::next_unused_receive_address now reserves (via key-wallet next_unused_and_reserve), but DET calls neither it (DET's platform receive path is its own index-based generate_platform_receive_address_with_seed) nor a reserving Core variant. DET's Core receive path (next_receive_address → next_receive_address_for_- account → AddressPool::next_unused) stays non-reserving; its only change (!used → is_available()) is behavior-neutral because DET never reserves. Refreshed the TC-012 TODO's stale merge-status note: rust-dashcore#818 is now present in the pinned rev, while the Core reserving surface (CoreWallet::next_receive_address_and_reserve_for_account) still is not. - Fixed two stale doc comments spotted in the diff: new_watch_only → new_external_signable (context/wallet_lifecycle/tests.rs) and AddressInfo.used → AddressInfo.is_used() (backend_task/dashpay/payments.rs). - cargo audit over the regenerated Cargo.lock: advisory-ID set is identical to base v1.0-dev (14 advisories; zero introduced, zero removed) and the yanked- crate set is identical — no new advisory beyond the pre-existing, already- acknowledged bincode RUSTSEC-2025-0141. dashcore/key-wallet unify to a single rev (0091c4a) in the lock; no conflicting revisions. Verification (via cargo-cached.sh ledger, final tree): - cargo clippy --all-features --all-targets -- -D warnings — exit 0 - cargo test --lib --all-features — 2009 passed, 0 failed, 1 ignored - cargo fmt --all -- --check — exit 0 Co-Authored-By: Claude Opus 4.8 --- src/backend_task/dashpay/payments.rs | 2 +- src/context/wallet_lifecycle/tests.rs | 2 +- src/wallet_backend/mod.rs | 11 ++++++++--- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/backend_task/dashpay/payments.rs b/src/backend_task/dashpay/payments.rs index de0adca4c..43c0c5e9b 100644 --- a/src/backend_task/dashpay/payments.rs +++ b/src/backend_task/dashpay/payments.rs @@ -634,7 +634,7 @@ pub(super) async fn mirror_incoming_payment_to_backend( /// platform rev `35e4a2f` /// (`PlatformWalletManager::account_address_pools_blocking` → /// `AccountAddressInfoSnapshot::is_used`, sourced from the SPV-tracked -/// `AddressInfo.used`), but it is keyed by `(wallet_id, AccountType)`, +/// `AddressInfo.is_used()`), but it is keyed by `(wallet_id, AccountType)`, /// not by an arbitrary address. This function receives a context-free /// address list, so it cannot route a lookup. More fundamentally, the /// only DashPay addresses with derivation context are the contact-SEND diff --git a/src/context/wallet_lifecycle/tests.rs b/src/context/wallet_lifecycle/tests.rs index 235e7efcf..a06682985 100644 --- a/src/context/wallet_lifecycle/tests.rs +++ b/src/context/wallet_lifecycle/tests.rs @@ -3449,7 +3449,7 @@ async fn rediscovery_update_preserves_user_alias_and_wallet_binding() { /// /// DET always reloads wallets **seedless** from the upstream persister. /// `WalletBackend::new` → `load_from_persistor_seedless` → upstream -/// `load_from_persistor()` → `Wallet::new_watch_only(…)`. The wallet has +/// `load_from_persistor()` → `Wallet::new_external_signable(…)`. The wallet has /// the BIP44/BIP32 accounts it was persisted with, but **no root private /// key**. /// diff --git a/src/wallet_backend/mod.rs b/src/wallet_backend/mod.rs index ff464f78e..86c294375 100644 --- a/src/wallet_backend/mod.rs +++ b/src/wallet_backend/mod.rs @@ -810,10 +810,15 @@ impl WalletBackend { // adopt it: // 1. dashpay/rust-dashcore#818 "feat(key-wallet): reserve receive // addresses on hand-out" — adds `next_unused_and_reserve` - // (+ reserve/release/sweep); ready-for-review, NOT yet merged. + // (+ reserve/release/sweep). MERGED and present in the pinned rev; + // `AddressPool::next_unused` (DET's path) stays non-reserving — + // it now returns the lowest `is_available()` (neither used nor + // reserved) address, which equals the old lowest-unused for DET + // because DET never reserves. // 2. dashpay/platform — surface it as - // `CoreWallet::next_receive_address_and_reserve_for_account` (the - // pinned rev still calls the old non-reserving path). + // `CoreWallet::next_receive_address_and_reserve_for_account`. NOT + // yet present; the pinned rev's `next_receive_address_for_account` + // still calls the old non-reserving path. // 3. DET — bump the platform dep, then switch // `next_receive_address()` to the reserving variant. // Until all three land, `next_receive_address` stays on `next_unused` From 2b6373ba3078785404a10b327f9bbf0cc13cc104 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:28:57 +0000 Subject: [PATCH 03/20] docs(shielded): correct WalletLocked doc, add guard test (Marvin QA-002/003) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial-review follow-ups on the shielded seed-resolution change from the platform#3968 pin bump. No functional change to the happy path. - QA-003: the doc comments on shielded_transfer/unshield/withdraw claimed a locked protected wallet yields WalletLocked "if its seed isn't cached". That is wrong: with_secret_session PROMPTS for a protected wallet; a dismissed or headless prompt surfaces as SecretPromptCancelled / SecretPromptUnavailable. WalletLocked comes ONLY from a non-HD-seed secret sitting at an HD scope (a misconfigured wallet). Rewrote all three docs to describe the real prompt path and reframe WalletLocked as that defensive type guard. - QA-002: the new expose_hd_seed()->WalletLocked branch (a genuinely new failure mode — these fns took no seed before the bump) had zero unit coverage, reachable today only via network-gated #[ignore] e2e. Extracted the mapping into a small documented helper, hd_seed_for_shielded_spend, and added two unit tests pinning HdSeed->Ok and SingleKey->WalletLocked. - QA-005: hoisted resolve_wallet + CachedOrchardProver::new() (neither needs the seed) out of the secret session in all three fns, matching the already-hoisted coordinator — trims the secret-session window's front edge and fails fast (ShieldedNotConfigured / WalletNotLoaded) without prompting. - QA-004: CHANGELOG now names the transitive dashpay git-dep bumps carried by the platform pin — rust-dashcore be6e776->0091c4a, grovedb v5.0.0->v5.0.1, and the orchard fork dashified-0.14.0->0.14.1. Verification (via cargo-cached.sh ledger, final tree): - cargo clippy --all-features --all-targets -- -D warnings — exit 0 - cargo test --lib --all-features — 2011 passed, 0 failed, 1 ignored (+2 new: hd_seed_for_shielded_spend_{accepts_hd_seed, rejects_non_hd_secret_as_wallet_locked}) - cargo fmt --all -- --check — exit 0 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 6 ++- src/wallet_backend/shielded.rs | 85 ++++++++++++++++++++++++++++------ 2 files changed, 76 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c3da6dd8..025bad131 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,7 +63,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). match: each now resolves the HD seed through the secret-seam chokepoint for the single spend and drops it on return. The update also adds persistence and rehydration for provider (masternode / evonode) platform-node key pools and for - DashPay invitations. + DashPay invitations. Transitively, the pinned dashpay git dependencies advance + with it: `rust-dashcore` (`be6e776` → `0091c4a`, which lands the reserve-on- + hand-out receive-address APIs), `grovedb` (`v5.0.0` → `v5.0.1`), and the + `orchard` shielded-crypto fork (`dashified-0.14.0` → `dashified-0.14.1`); no + crates.io dependencies change. - **Shielded transactions are available on supported networks**: sending, receiving, shielding, and unshielding are enabled when the connected network's diff --git a/src/wallet_backend/shielded.rs b/src/wallet_backend/shielded.rs index 1d03b3e43..6e0479f5c 100644 --- a/src/wallet_backend/shielded.rs +++ b/src/wallet_backend/shielded.rs @@ -12,9 +12,28 @@ use crate::model::wallet::WalletSeedHash; use std::sync::Arc; use super::{ - DetPlatformSigner, DetSigner, PlatformPathIndex, WalletBackend, map_shielded_op_error, + DetPlatformSigner, DetSigner, PlatformPathIndex, SecretPlaintext, WalletBackend, + map_shielded_op_error, }; +/// Borrow the 64-byte HD seed from a resolved secret session for a shielded +/// spend. Upstream re-derives the Orchard spend keyset from this seed on every +/// `shielded_*_to` call. +/// +/// A resolved secret that is not an HD seed — a single-key or identity-key +/// secret sitting at an HD scope, i.e. a misconfigured wallet — cannot yield a +/// shielded spend key, so it maps to [`TaskError::WalletLocked`]. This is the +/// ONLY `WalletLocked` source in the shielded transfer / unshield / withdraw +/// paths, and it is a defensive type guard — NOT the locked-protected-wallet +/// path. An actually-locked protected wallet is *prompted* by +/// `with_secret_session`; a dismissed or headless prompt surfaces as +/// [`TaskError::SecretPromptCancelled`] / [`TaskError::SecretPromptUnavailable`]. +fn hd_seed_for_shielded_spend<'a>( + plaintext: &'a SecretPlaintext<'_>, +) -> Result<&'a [u8; 64], TaskError> { + plaintext.expose_hd_seed().ok_or(TaskError::WalletLocked) +} + impl WalletBackend { /// Resolve the network-scoped shielded coordinator. /// @@ -201,8 +220,12 @@ impl WalletBackend { /// [`SecretAccess`](super::SecretAccess) chokepoint: upstream re-derives the /// Orchard spend keyset (ASK included) from it for this call only and drops /// it on return, so the spend authority is never left resident. An - /// unprotected wallet resolves prompt-free; a protected one needs its seed - /// already promoted to the session cache, else [`TaskError::WalletLocked`]. + /// unprotected wallet resolves prompt-free; a protected one is prompted for + /// its passphrase unless already cached, and a dismissed or headless prompt + /// surfaces as [`TaskError::SecretPromptCancelled`] / + /// [`TaskError::SecretPromptUnavailable`]. [`TaskError::WalletLocked`] is + /// returned only if the HD scope resolves to a non-HD-seed secret (a + /// misconfigured wallet) — see `hd_seed_for_shielded_spend`. /// /// The Orchard prover is created internally via /// `CachedOrchardProver::new()` — callers do not supply a prover. @@ -214,15 +237,18 @@ impl WalletBackend { amount: u64, memo: [u8; 36], ) -> Result<(), TaskError> { + // coordinator / wallet / prover need no seed, so resolve them before the + // secret session to trim its front edge and fail fast (ShieldedNotConfigured + // / WalletNotLoaded) without prompting for a passphrase. let coordinator = self.shielded_coordinator_arc().await?; + let wallet = self.resolve_wallet(seed_hash).await?; + let prover = platform_wallet::wallet::shielded::CachedOrchardProver::new(); let scope = Self::hd_scope(seed_hash); self.inner .secret_access .with_secret_session(&scope, async |session| { let plaintext = session.plaintext(); - let seed = plaintext.expose_hd_seed().ok_or(TaskError::WalletLocked)?; - let wallet = self.resolve_wallet(seed_hash).await?; - let prover = platform_wallet::wallet::shielded::CachedOrchardProver::new(); + let seed = hd_seed_for_shielded_spend(&plaintext)?; wallet .shielded_transfer_to( &coordinator, @@ -245,7 +271,7 @@ impl WalletBackend { /// The HD seed is resolved just-in-time through the /// [`SecretAccess`](super::SecretAccess) chokepoint (see /// [`shielded_transfer`](Self::shielded_transfer) for the spend-authority - /// lifetime and locked-wallet behaviour). + /// lifetime and the prompt / failure-mode behaviour). /// /// The Orchard prover is created internally via /// `CachedOrchardProver::new()` — callers do not supply a prover. @@ -257,14 +283,14 @@ impl WalletBackend { amount: u64, ) -> Result<(), TaskError> { let coordinator = self.shielded_coordinator_arc().await?; + let wallet = self.resolve_wallet(seed_hash).await?; + let prover = platform_wallet::wallet::shielded::CachedOrchardProver::new(); let scope = Self::hd_scope(seed_hash); self.inner .secret_access .with_secret_session(&scope, async |session| { let plaintext = session.plaintext(); - let seed = plaintext.expose_hd_seed().ok_or(TaskError::WalletLocked)?; - let wallet = self.resolve_wallet(seed_hash).await?; - let prover = platform_wallet::wallet::shielded::CachedOrchardProver::new(); + let seed = hd_seed_for_shielded_spend(&plaintext)?; wallet .shielded_unshield_to( &coordinator, @@ -285,7 +311,7 @@ impl WalletBackend { /// The HD seed is resolved just-in-time through the /// [`SecretAccess`](super::SecretAccess) chokepoint (see /// [`shielded_transfer`](Self::shielded_transfer) for the spend-authority - /// lifetime and locked-wallet behaviour). + /// lifetime and the prompt / failure-mode behaviour). /// /// The Orchard prover is created internally via /// `CachedOrchardProver::new()` — callers do not supply a prover. @@ -298,14 +324,14 @@ impl WalletBackend { core_fee_per_byte: u32, ) -> Result<(), TaskError> { let coordinator = self.shielded_coordinator_arc().await?; + let wallet = self.resolve_wallet(seed_hash).await?; + let prover = platform_wallet::wallet::shielded::CachedOrchardProver::new(); let scope = Self::hd_scope(seed_hash); self.inner .secret_access .with_secret_session(&scope, async |session| { let plaintext = session.plaintext(); - let seed = plaintext.expose_hd_seed().ok_or(TaskError::WalletLocked)?; - let wallet = self.resolve_wallet(seed_hash).await?; - let prover = platform_wallet::wallet::shielded::CachedOrchardProver::new(); + let seed = hd_seed_for_shielded_spend(&plaintext)?; wallet .shielded_withdraw_to( &coordinator, @@ -368,3 +394,34 @@ impl WalletBackend { self.inner.pwm.shielded_sync_arc().sync_now(force).await; } } + +#[cfg(test)] +mod tests { + use super::*; + use zeroize::Zeroizing; + + /// An HD-seed secret resolves to its 64 raw bytes — the common shielded-spend + /// path. + #[test] + fn hd_seed_for_shielded_spend_accepts_hd_seed() { + let seed = Zeroizing::new([0x11u8; 64]); + let plaintext = SecretPlaintext::HdSeed(&seed); + let got = hd_seed_for_shielded_spend(&plaintext).expect("an HD seed must resolve"); + assert_eq!(got, &[0x11u8; 64]); + } + + /// A non-HD-seed secret at an HD scope (a single-key secret — a misconfigured + /// wallet) cannot yield a shielded spend key, so it maps to `WalletLocked`. + /// This pins the ONLY `WalletLocked` source in the shielded + /// transfer / unshield / withdraw paths, and guards against it being confused + /// with the locked-protected-wallet prompt path (which yields + /// `SecretPromptCancelled` / `SecretPromptUnavailable` instead). + #[test] + fn hd_seed_for_shielded_spend_rejects_non_hd_secret_as_wallet_locked() { + let key = Zeroizing::new([0x22u8; 32]); + let plaintext = SecretPlaintext::SingleKey(&key); + let err = hd_seed_for_shielded_spend(&plaintext) + .expect_err("a non-HD-seed secret must not yield a shielded spend key"); + assert!(matches!(err, TaskError::WalletLocked), "got {err:?}"); + } +} From f7ca95fd310ca669aa26646fd76272b1fb77d1cb Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:10:15 +0000 Subject: [PATCH 04/20] =?UTF-8?q?chore:=20bump=20dashpay/platform=20pin=20?= =?UTF-8?q?to=20PR=20#3968=20tip=20(f376d32=20=E2=86=92=20ebbd15c4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-bumps the four dashpay/platform git dependencies (dash-sdk, rs-sdk-trusted-context-provider, platform-wallet, platform-wallet-storage) from f376d32 to ebbd15c4, the current tip of platform PR #3968 — "feat(platform-wallet-storage): embeddable SQLite persistence backend with seedless rehydration". Verified via git ls-remote against feat/platform-wallet-storage-rehydration that the PR has not moved past this rev. Cargo.lock is regenerated scoped to the platform package set: 27 platform git packages moved to ebbd15c (document-history-contract added transitively); rust-dashcore, grovedb, orchard, and grovestark pins are unchanged; no crates.io registry drift. API-drift fixes required by the upstream jump (each verified against the new upstream source, not guessed): - PlatformWalletError gained PlatformNodePool, CoreInsufficientFunds, AssetLockNotTracked, AssetLockAlreadyConsumed, and AssetLockFundingMismatch variants. Classified in both exhaustive (no wildcard) match sites -- map_shielded_op_error and identity_op_error_kind -- alongside the existing precondition/wallet-state bucket. - The upstream changeset schema gained a shielded.viewing_keys field with a new PersistenceCapabilities::SHIELDED_VIEWING_KEYS contract, needed for seedless shielded restart (Orchard FVKs must survive a restart without the HD seed). Added src/wallet_backend/persister.rs: a thin DetPersister adapter wrapping the upstream SqlitePersister -- delegates everything except the new viewing-key changeset, which it persists itself via the existing DetKv abstraction (already used for app data alongside wallet state), scoped per wallet_id and guarded by a mutex. Rejects any mixed changeset (viewing keys plus anything else) to preserve upstream's ATOMIC_CHANGESETS invariant rather than splitting one commit across the typed SQLite tables and the metadata table. Note this supersedes the prior "DET does not write its own persister" doc comment: DET now owns exactly the one host-specific extension upstream doesn't yet provide, not a reimplementation of the persister itself. - The later review tip also retries transient startup rehydration, selects platform-address transfer/withdrawal inputs from hydrated candidates with authoritative on-chain balances, freezes the SPV sync watermark when persistence fails, and persists address-reservation timestamps plus DashPay address used-state updates -- no DET call-site changes required for these; CHANGELOG updated to describe the user-visible effect. Verification ledger logs under /data/artifacts/dash-evo-tool/2026-07-22/platform-pin-bump/final-ledger/logs/, all exit 0: fmt --check, build --all-features, clippy --all-features --all-targets -D warnings, plus targeted lib tests -- wallet_backend (327 passed), context::wallet_lifecycle (62 passed), shielded (71 passed). Co-Authored-By: Codex gpt-5.6-sol --- CHANGELOG.md | 16 +-- Cargo.lock | 66 +++++++----- Cargo.toml | 8 +- src/wallet_backend/mod.rs | 23 ++-- src/wallet_backend/persister.rs | 186 ++++++++++++++++++++++++++++++++ 5 files changed, 255 insertions(+), 44 deletions(-) create mode 100644 src/wallet_backend/persister.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c9ac1bb05..4cceef205 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,7 +54,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Upstream wallet backend updated (`platform-wallet` / `platform-wallet-storage`)**: the `dashpay/platform` dependency is bumped to the PR #3968 tip - (`d18020f` → `f376d32`), which lands an embeddable SQLite persistence backend with + (`d18020f` → `ebbd15c`), which lands an embeddable SQLite persistence backend with *seedless rehydration*. The wallet manager now restores watch-only wallet state (accounts, balances, identities, platform addresses) from the on-disk store without the HD seed, re-deriving spend authority just-in-time from the seed only @@ -63,11 +63,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). match: each now resolves the HD seed through the secret-seam chokepoint for the single spend and drops it on return. The update also adds persistence and rehydration for provider (masternode / evonode) platform-node key pools and for - DashPay invitations. Transitively, the pinned dashpay git dependencies advance - with it: `rust-dashcore` (`be6e776` → `0091c4a`, which lands the reserve-on- - hand-out receive-address APIs), `grovedb` (`v5.0.0` → `v5.0.1`), and the - `orchard` shielded-crypto fork (`dashified-0.14.0` → `dashified-0.14.1`); no - crates.io dependencies change. + DashPay invitations. The later review tip also retries transient startup + rehydration, selects platform-address transfer and withdrawal inputs from + hydrated candidates with authoritative on-chain balances, freezes the SPV sync + watermark when persistence fails, and persists address-reservation timestamps + plus DashPay address used-state updates. Transitively, the pinned dashpay git + dependencies advance with it: `rust-dashcore` (`be6e776` → `0091c4a`, which + lands the reserve-on-hand-out receive-address APIs), `grovedb` (`v5.0.0` → + `v5.0.1`), and the `orchard` shielded-crypto fork (`dashified-0.14.0` → + `dashified-0.14.1`); no crates.io dependencies change. - **Shielded transactions are available on supported networks**: sending, receiving, shielding, and unshielding are enabled when the connected network's diff --git a/Cargo.lock b/Cargo.lock index e95f3f060..8092d0024 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1877,7 +1877,7 @@ dependencies = [ [[package]] name = "dapi-grpc" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" +source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" dependencies = [ "dash-platform-macros", "futures-core", @@ -1979,7 +1979,7 @@ dependencies = [ [[package]] name = "dash-async" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" +source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" dependencies = [ "thiserror 2.0.18", "tokio", @@ -1989,7 +1989,7 @@ dependencies = [ [[package]] name = "dash-context-provider" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" +source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" dependencies = [ "dash-async", "dpp", @@ -2100,7 +2100,7 @@ dependencies = [ [[package]] name = "dash-platform-macros" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" +source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" dependencies = [ "heck", "quote", @@ -2110,7 +2110,7 @@ dependencies = [ [[package]] name = "dash-sdk" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" +source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" dependencies = [ "arc-swap", "async-trait", @@ -2247,7 +2247,7 @@ dependencies = [ [[package]] name = "dashpay-contract" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" +source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" dependencies = [ "platform-value", "platform-version", @@ -2258,9 +2258,10 @@ dependencies = [ [[package]] name = "data-contracts" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" +source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" dependencies = [ "dashpay-contract", + "document-history-contract", "dpns-contract", "keyword-search-contract", "masternode-reward-shares-contract", @@ -2523,6 +2524,17 @@ dependencies = [ "litrs", ] +[[package]] +name = "document-history-contract" +version = "4.0.0" +source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" +dependencies = [ + "platform-value", + "platform-version", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -2544,7 +2556,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "dpns-contract" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" +source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" dependencies = [ "platform-value", "platform-version", @@ -2555,7 +2567,7 @@ dependencies = [ [[package]] name = "dpp" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" +source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" dependencies = [ "anyhow", "async-trait", @@ -2605,7 +2617,7 @@ dependencies = [ [[package]] name = "dpp-json-convertible-derive" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" +source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" dependencies = [ "proc-macro2", "quote", @@ -2615,7 +2627,7 @@ dependencies = [ [[package]] name = "drive" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" +source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" dependencies = [ "bincode 2.0.1", "byteorder", @@ -2640,7 +2652,7 @@ dependencies = [ [[package]] name = "drive-proof-verifier" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" +source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" dependencies = [ "bincode 2.0.1", "dapi-grpc", @@ -5034,7 +5046,7 @@ dependencies = [ [[package]] name = "keyword-search-contract" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" +source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" dependencies = [ "platform-value", "platform-version", @@ -5247,7 +5259,7 @@ dependencies = [ [[package]] name = "masternode-reward-shares-contract" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" +source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" dependencies = [ "platform-value", "platform-version", @@ -6459,7 +6471,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "platform-encryption" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" +source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" dependencies = [ "aes", "cbc", @@ -6472,7 +6484,7 @@ dependencies = [ [[package]] name = "platform-serialization" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" +source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" dependencies = [ "bincode 2.0.1", "platform-version", @@ -6481,7 +6493,7 @@ dependencies = [ [[package]] name = "platform-serialization-derive" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" +source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" dependencies = [ "proc-macro2", "quote", @@ -6492,7 +6504,7 @@ dependencies = [ [[package]] name = "platform-value" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" +source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" dependencies = [ "base64 0.22.1", "bincode 2.0.1", @@ -6512,7 +6524,7 @@ dependencies = [ [[package]] name = "platform-version" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" +source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" dependencies = [ "bincode 2.0.1", "grovedb-version 5.0.1", @@ -6523,7 +6535,7 @@ dependencies = [ [[package]] name = "platform-versioning" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" +source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" dependencies = [ "proc-macro2", "quote", @@ -6533,7 +6545,7 @@ dependencies = [ [[package]] name = "platform-wallet" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" +source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" dependencies = [ "arc-swap", "async-trait", @@ -6565,7 +6577,7 @@ dependencies = [ [[package]] name = "platform-wallet-storage" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" +source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" dependencies = [ "apple-native-keyring-store", "argon2", @@ -7562,7 +7574,7 @@ dependencies = [ [[package]] name = "rs-dapi-client" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" +source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" dependencies = [ "backon", "chrono", @@ -7588,7 +7600,7 @@ dependencies = [ [[package]] name = "rs-sdk-trusted-context-provider" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" +source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" dependencies = [ "arc-swap", "dash-async", @@ -8830,7 +8842,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "token-history-contract" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" +source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" dependencies = [ "platform-value", "platform-version", @@ -9675,7 +9687,7 @@ dependencies = [ [[package]] name = "wallet-utils-contract" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" +source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" dependencies = [ "platform-value", "platform-version", @@ -10931,7 +10943,7 @@ dependencies = [ [[package]] name = "withdrawals-contract" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=f376d32bff1bb2436f245b23fe82a55cff7fbe95#f376d32bff1bb2436f245b23fe82a55cff7fbe95" +source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" dependencies = [ "num_enum 0.5.11", "platform-value", diff --git a/Cargo.toml b/Cargo.toml index 5a0c9f630..5ff4f2215 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ qrcode = "0.14.1" nix = { version = "0.31.1", features = ["signal"] } eframe = { version = "0.35.0", features = ["persistence", "wgpu"] } base64 = "0.22.1" -dash-sdk = { git = "https://github.com/dashpay/platform", rev = "f376d32bff1bb2436f245b23fe82a55cff7fbe95", features = [ +dash-sdk = { git = "https://github.com/dashpay/platform", rev = "ebbd15c4b322579bc2a4a84e1db5da6035220f5f", features = [ "core_key_wallet", "core_key_wallet_manager", "core_bincode", @@ -28,12 +28,12 @@ dash-sdk = { git = "https://github.com/dashpay/platform", rev = "f376d32bff1bb24 "core_spv", "shielded", ] } -rs-sdk-trusted-context-provider = { git = "https://github.com/dashpay/platform", rev = "f376d32bff1bb2436f245b23fe82a55cff7fbe95" } -platform-wallet = { git = "https://github.com/dashpay/platform", rev = "f376d32bff1bb2436f245b23fe82a55cff7fbe95", features = [ +rs-sdk-trusted-context-provider = { git = "https://github.com/dashpay/platform", rev = "ebbd15c4b322579bc2a4a84e1db5da6035220f5f" } +platform-wallet = { git = "https://github.com/dashpay/platform", rev = "ebbd15c4b322579bc2a4a84e1db5da6035220f5f", features = [ "serde", "shielded", ] } -platform-wallet-storage = { git = "https://github.com/dashpay/platform", rev = "f376d32bff1bb2436f245b23fe82a55cff7fbe95", features = [ +platform-wallet-storage = { git = "https://github.com/dashpay/platform", rev = "ebbd15c4b322579bc2a4a84e1db5da6035220f5f", features = [ "shielded", ] } zip32 = "0.2.0" diff --git a/src/wallet_backend/mod.rs b/src/wallet_backend/mod.rs index 34b7ddb95..72e62e775 100644 --- a/src/wallet_backend/mod.rs +++ b/src/wallet_backend/mod.rs @@ -47,6 +47,7 @@ pub(crate) mod kv_test_support; pub(crate) mod leak_test_support; mod loader; mod payments; +mod persister; pub(crate) mod poison; pub mod secret_access; pub mod secret_prompt; @@ -159,10 +160,7 @@ use crate::model::wallet::meta::wallet_label; use crate::model::wallet::{PlatformAddressEntry, WalletSeedHash}; use crate::utils::egui_mpsc::SenderAsync; -/// The upstream persister DET consumes. Authored upstream (PR #3625) — DET -/// does not write its own persister (removal-inventory: consume, don't -/// reimplement). -type DetPersister = SqlitePersister; +use persister::DetPersister; /// Which side of a contact relationship /// [`WalletBackend::record_contact_request`] writes into the local @@ -325,7 +323,7 @@ struct Inner { /// Shared handle to the same persister `pwm` consumes. Kept so the /// typed key/value adapter ([`DetKv`]) can read/write app data /// alongside wallet state without opening a second connection. - persister: Arc, + persister: Arc, /// Display-only snapshot store (balance/tx/utxo), pushed by the /// `EventBridge`. See [`snapshot`]. DISPLAY-ONLY — never feeds coin /// selection (A04 fund-safety gate). @@ -460,10 +458,11 @@ impl WalletBackend { let persister_config = SqlitePersisterConfig::new(spv_storage_dir.join("platform-wallet.sqlite")); - let persister = Arc::new( + let sqlite_persister = Arc::new( SqlitePersister::open(persister_config) .map_err(TaskError::from_wallet_storage_open_error)?, ); + let persister = Arc::new(DetPersister::new(Arc::clone(&sqlite_persister))); // Reuse the vault handle `AppContext` already opened at boot. The file // backend holds an exclusive advisory lock for the handle's lifetime, @@ -520,7 +519,7 @@ impl WalletBackend { let backend = Self { inner: Arc::new(Inner { pwm, - persister, + persister: sqlite_persister, snapshots, token_balances: Arc::new(TokenBalanceStore::new()), id_map: std::sync::RwLock::new(std::collections::BTreeMap::new()), @@ -2765,6 +2764,7 @@ fn map_shielded_op_error(e: platform_wallet::error::PlatformWalletError) -> Task // `PlatformWalletError` at this rev; it belongs in this bucket once // platform re-adds it. other @ (P::WalletCreation(_) + | P::PlatformNodePool(_) | P::PersisterLoad(_) | P::AddressNonceMismatch { .. } | P::WalletNotFound(_) @@ -2778,9 +2778,13 @@ fn map_shielded_op_error(e: platform_wallet::error::PlatformWalletError) -> Task | P::DashpayReceivingAccountAlreadyExists { .. } | P::DashpayExternalAccountAlreadyExists { .. } | P::AssetLockTransaction(_) + | P::AssetLockNotTracked(_) + | P::AssetLockAlreadyConsumed(_) + | P::AssetLockFundingMismatch { .. } | P::TransactionBroadcast(_) | P::TransactionBroadcastUnconfirmed(_) | P::TransactionBuild(_) + | P::CoreInsufficientFunds { .. } | P::NoSpendableInputs { .. } | P::Sdk(_) | P::AddressSync(_) @@ -2945,6 +2949,7 @@ fn identity_op_error_kind(e: &platform_wallet::error::PlatformWalletError) -> Id // Everything else — preconditions, wallet state, builder errors. P::WalletCreation(_) + | P::PlatformNodePool(_) | P::WalletNotFound(_) | P::WalletAlreadyExists(_) | P::IdentityAlreadyExists(_) @@ -2954,7 +2959,11 @@ fn identity_op_error_kind(e: &platform_wallet::error::PlatformWalletError) -> Id | P::DashpayReceivingAccountAlreadyExists { .. } | P::DashpayExternalAccountAlreadyExists { .. } | P::AssetLockTransaction(_) + | P::AssetLockNotTracked(_) + | P::AssetLockAlreadyConsumed(_) + | P::AssetLockFundingMismatch { .. } | P::TransactionBuild(_) + | P::CoreInsufficientFunds { .. } | P::NoSpendableInputs { .. } | P::AddressSync(_) | P::AddressOperation(_) diff --git a/src/wallet_backend/persister.rs b/src/wallet_backend/persister.rs new file mode 100644 index 000000000..777a03426 --- /dev/null +++ b/src/wallet_backend/persister.rs @@ -0,0 +1,186 @@ +//! DET adapter for upstream wallet persistence capabilities. +//! +//! The upstream SQLite persister owns wallet state, while DET adds the one +//! host-specific persistence path it needs for seedless shielded restart: +//! wallet-scoped Orchard full-viewing keys stored in the same SQLite file's +//! metadata table. + +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex}; + +use platform_wallet::changeset::{ + ClientStartState, PersistenceCapabilities, PersistenceError, PlatformWalletChangeSet, + PlatformWalletPersistence, +}; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet::wallet::shielded::SubwalletId; +use platform_wallet_storage::SqlitePersister; + +use super::{DetKv, DetScope}; + +const SHIELDED_FVK_KEY: &str = "shielded_fvks.v1"; + +/// Upstream wallet persistence plus DET's wallet-scoped shielded FVK rows. +pub(super) struct DetPersister { + inner: Arc, + kv: DetKv, + shielded_fvk_lock: Mutex<()>, +} + +impl DetPersister { + pub(super) fn new(inner: Arc) -> Self { + Self { + kv: DetKv::new(Arc::clone(&inner)), + inner, + shielded_fvk_lock: Mutex::new(()), + } + } + + fn store_viewing_keys( + &self, + wallet_id: WalletId, + incoming: &BTreeMap>, + ) -> Result<(), PersistenceError> { + let _guard = self + .shielded_fvk_lock + .lock() + .map_err(|_| PersistenceError::LockPoisoned)?; + let mut stored: BTreeMap> = self + .kv + .get(DetScope::Wallet(&wallet_id), SHIELDED_FVK_KEY) + .map_err(PersistenceError::backend)? + .unwrap_or_default(); + + for (subwallet_id, fvk) in incoming { + if subwallet_id.wallet_id != wallet_id { + return Err(PersistenceError::backend(std::io::Error::other( + "shielded viewing-key wallet id does not match changeset wallet id", + ))); + } + stored.insert(subwallet_id.account_index, fvk.clone()); + } + + self.kv + .put(DetScope::Wallet(&wallet_id), SHIELDED_FVK_KEY, &stored) + .map_err(PersistenceError::backend) + } + + fn load_viewing_keys( + &self, + wallet_id: WalletId, + ) -> Result>, PersistenceError> { + let stored: BTreeMap> = self + .kv + .get(DetScope::Wallet(&wallet_id), SHIELDED_FVK_KEY) + .map_err(PersistenceError::backend)? + .unwrap_or_default(); + Ok(stored + .into_iter() + .map(|(account, fvk)| (SubwalletId::new(wallet_id, account), fvk)) + .collect()) + } + + /// The FVK adapter owns only viewing-key deltas. Reject a mixed changeset + /// rather than splitting one logical commit across the typed SQLite tables + /// and the metadata table, which would violate `ATOMIC_CHANGESETS`. + fn is_viewing_key_only(changeset: &PlatformWalletChangeSet) -> bool { + let PlatformWalletChangeSet { + core, + identities, + identity_keys, + contacts, + platform_addresses, + asset_locks, + invitations, + token_balances, + dashpay_profiles, + dashpay_payments_overlay, + wallet_metadata, + account_registrations, + provider_key_account_registrations, + account_address_pools, + pending_contact_crypto_added, + pending_contact_crypto_cleared, + shielded, + } = changeset; + let Some(shielded) = shielded else { + return false; + }; + + core.is_none() + && identities.is_none() + && identity_keys.is_none() + && contacts.is_none() + && platform_addresses.is_none() + && asset_locks.is_none() + && invitations.is_none() + && token_balances.is_none() + && dashpay_profiles.is_none() + && dashpay_payments_overlay.is_none() + && wallet_metadata.is_none() + && account_registrations.is_empty() + && provider_key_account_registrations.is_empty() + && account_address_pools.is_empty() + && pending_contact_crypto_added.is_empty() + && pending_contact_crypto_cleared.is_empty() + && shielded.notes_saved.is_empty() + && shielded.nullifiers_spent.is_empty() + && shielded.outgoing_notes.is_empty() + && shielded.synced_indices.is_empty() + && shielded.activity_entries.is_empty() + && !shielded.viewing_keys.is_empty() + } +} + +impl PlatformWalletPersistence for DetPersister { + fn persistence_capabilities(&self) -> PersistenceCapabilities { + self.inner + .persistence_capabilities() + .union(PersistenceCapabilities::SHIELDED_VIEWING_KEYS) + } + + fn store( + &self, + wallet_id: WalletId, + changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + if let Some(shielded) = changeset.shielded.as_ref() + && !shielded.viewing_keys.is_empty() + { + if !Self::is_viewing_key_only(&changeset) { + return Err(PersistenceError::backend(std::io::Error::other( + "shielded viewing keys must be persisted in a dedicated changeset", + ))); + } + self.store_viewing_keys(wallet_id, &shielded.viewing_keys)?; + return Ok(()); + } + self.inner.store(wallet_id, changeset) + } + + fn flush(&self, wallet_id: WalletId) -> Result<(), PersistenceError> { + self.inner.flush(wallet_id) + } + + fn load(&self) -> Result { + let mut start = self.inner.load()?; + for wallet_id in start.wallets.keys().copied() { + start + .shielded + .viewing_keys + .extend(self.load_viewing_keys(wallet_id)?); + } + Ok(start) + } + + fn get_core_tx_record( + &self, + wallet_id: WalletId, + txid: &dash_sdk::dpp::dashcore::Txid, + ) -> Result< + Option, + PersistenceError, + > { + self.inner.get_core_tx_record(wallet_id, txid) + } +} From eb7e0faecb2cf2d511ebd4262ef90ab45af45c00 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:11:51 +0000 Subject: [PATCH 05/20] fix(wallet-backend): close FVK-persister due-diligence findings (SEC-007, RUST-001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent security (Smythe) and structural (Adams) due-diligence review of f7ca95fd3 (the platform#3968 pin re-bump) surfaced two MEDIUM findings that escalated to a confirmed HIGH once Marvin produced a red repro, plus two cheap LOW nits. All fixed here, on top of f7ca95fd3, before push. - SEC-007 (HIGH, confirmed via red repro): DetPersister::load() previously `?`-propagated ANY single wallet's corrupt/undecodable shielded-FVK row, failing cold-boot rehydration for every wallet in the store, not just the corrupted one. load_viewing_keys now distinguishes decode/schema-version/ truncated/oversized-value failures (degrade to an empty FVK set for the affected wallet, with a warning log carrying the wallet id and error shape, no FVK bytes) from genuine backend/IO failures (still fatal, e.g. LockPoisoned), matching this module's established kv_get_logged degrade-to-absent convention (kv.rs) and the sibling cold_boot_keeps_wallet_visible_when_persisted_transaction_txid_is_corrupt precedent. - RUST-001 (MEDIUM): is_viewing_key_only now exhaustively destructures the nested ShieldedChangeSet (all six upstream fields), not just field-access checks — a future 7th upstream field is now a compile error instead of being silently ignored by the mixed-changeset reject guard. - PROJ-001 (LOW): SHIELDED_FVK_KEY renamed "shielded_fvks.v1" -> "shielded:fvks:v1" to match this module's colon-namespaced key convention, before the old spelling became on-disk schema. - PROJ-002 (LOW): corrected the Inner.persister doc comment, which read as if `pwm` held this exact Arc directly; it now describes that `pwm` consumes the DetPersister wrapper around this same SqlitePersister. - Added 6 tests: a table-driven is_viewing_key_only classification test; a store/load FVK round-trip; a test proving oversized/decode failures degrade to absent while genuine backend failures stay fatal; mixed- changeset and cross-wallet-id rejection tests (both asserting no partial write); and a real two-wallet, two-boot cold-start regression test (cold_boot_skips_corrupt_fvks_for_one_wallet_and_restores_healthy_wallet) that persists both wallets for real, corrupts one's on-disk FVK row under its actual upstream wallet id, and confirms cold boot succeeds with the healthy wallet's viewing key still installed. A prior throwaway proof-of-concept test from the review pass (green-from-start, non-probative) was reverted rather than kept. An open follow-up from the same review — whether persister.rs scoping FVK rows by the upstream WalletId instead of this module's usual WalletSeedHash orphans the row when a wallet is later removed (both are bare `[u8; 32]` aliases, so it type-checks either way) — is still being verified empirically and will land separately if it proves to be a real gap. Verification (via cargo-cached.sh ledger): - cargo fmt --all -- --check — exit 0 - cargo clippy --all-features --all-targets -- -D warnings — exit 0 - cargo test --lib --all-features wallet_backend — 332 passed, 0 failed - cargo test --lib --all-features context::wallet_lifecycle — 63 passed, 0 failed - cargo test --lib --all-features shielded — 71 passed, 0 failed Co-Authored-By: Codex gpt-5.6-sol --- src/context/wallet_lifecycle/tests.rs | 103 +++++++++ src/wallet_backend/mod.rs | 6 +- src/wallet_backend/persister.rs | 290 ++++++++++++++++++++++++-- src/wallet_backend/shielded.rs | 15 ++ 4 files changed, 396 insertions(+), 18 deletions(-) diff --git a/src/context/wallet_lifecycle/tests.rs b/src/context/wallet_lifecycle/tests.rs index a06682985..d37db5c06 100644 --- a/src/context/wallet_lifecycle/tests.rs +++ b/src/context/wallet_lifecycle/tests.rs @@ -3733,6 +3733,109 @@ async fn cold_boot_keeps_wallet_visible_when_persisted_transaction_txid_is_corru backend.shutdown().await; } +/// A corrupt FVK row is isolated to its wallet during a real seedless cold boot. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cold_boot_skips_corrupt_fvks_for_one_wallet_and_restores_healthy_wallet() { + use platform_wallet_storage::{KvStore, ObjectId, SqlitePersister, SqlitePersisterConfig}; + + let _guard = backend_reopen_lock().await; + let source_dir = tempfile::tempdir().expect("source tempdir"); + let corrupt_seed = [0xE4u8; 64]; + let healthy_seed = [0xE5u8; 64]; + + let (corrupt_hash, corrupt_wallet_id, healthy_hash) = { + let corrupt_wallet = + crate::model::wallet::Wallet::new_from_seed(corrupt_seed, Network::Testnet, None, None) + .expect("build corrupt-row wallet"); + let corrupt_hash = corrupt_wallet.seed_hash(); + let healthy_wallet = + crate::model::wallet::Wallet::new_from_seed(healthy_seed, Network::Testnet, None, None) + .expect("build healthy wallet"); + let healthy_hash = healthy_wallet.seed_hash(); + let (ctx, sender) = offline_testnet_context_at(source_dir.path()); + + ctx.register_wallet(corrupt_wallet, &corrupt_seed, WalletOrigin::Fresh) + .expect("persist corrupt-row wallet sidecars"); + ctx.register_wallet(healthy_wallet, &healthy_seed, WalletOrigin::Fresh) + .expect("persist healthy wallet sidecars"); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire boot-1 backend"); + let backend = ctx.wallet_backend().expect("boot-1 backend"); + + for (seed_hash, seed) in [ + (&corrupt_hash, &corrupt_seed), + (&healthy_hash, &healthy_seed), + ] { + backend + .register_wallet_from_seed(seed_hash, seed, Some(0)) + .await + .expect("persist upstream wallet"); + backend + .ensure_shielded_bound(seed_hash, seed) + .await + .expect("persist wallet viewing key"); + } + let corrupt_wallet_id = backend + .registered_wallet_id(&corrupt_hash) + .expect("corrupt-row upstream wallet id"); + + backend.shutdown().await; + let _ = ctx.subtasks.shutdown_async().await; + (corrupt_hash, corrupt_wallet_id, healthy_hash) + }; + + let cold_dir = tempfile::tempdir().expect("cold tempdir"); + copy_dir_recursive(source_dir.path(), cold_dir.path()); + let persister_path = cold_dir + .path() + .join("spv") + .join("testnet") + .join("platform-wallet.sqlite"); + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&persister_path)) + .expect("open persister for corrupt FVK fixture"); + KvStore::put( + &persister, + &ObjectId::Wallet(corrupt_wallet_id), + "shielded:fvks:v1", + &[0], + ) + .expect("corrupt one wallet's FVK row"); + drop(persister); + + let (ctx, sender) = offline_testnet_context_at(cold_dir.path()); + ctx.ensure_wallet_backend(sender) + .await + .expect("one corrupt FVK row must not fail cold-boot rehydration"); + let backend = ctx.wallet_backend().expect("boot-2 backend"); + + assert!( + backend.is_wallet_registered(&corrupt_hash), + "the wallet with a corrupt FVK row must remain registered" + ); + assert!( + backend.is_wallet_registered(&healthy_hash), + "the healthy wallet must remain registered" + ); + assert!( + backend + .bind_shielded_from_persisted_for_test(&healthy_hash) + .await + .expect("load the healthy wallet's persisted viewing key"), + "the healthy wallet's viewing key must remain available" + ); + assert!( + backend + .shielded_default_address(&healthy_hash, 0) + .await + .expect("read rebound shielded address") + .is_some(), + "the healthy persisted viewing key must be installed on the wallet" + ); + + backend.shutdown().await; +} + /// Build a minimal basic identity for manager-reconcile tests — only its /// id() and (empty) public_keys() are read by `add_identity`. fn basic_test_identity() -> dash_sdk::dpp::identity::Identity { diff --git a/src/wallet_backend/mod.rs b/src/wallet_backend/mod.rs index 72e62e775..cfd98aaba 100644 --- a/src/wallet_backend/mod.rs +++ b/src/wallet_backend/mod.rs @@ -320,9 +320,9 @@ impl RegistrationFlight { struct Inner { pwm: PlatformWalletManager, - /// Shared handle to the same persister `pwm` consumes. Kept so the - /// typed key/value adapter ([`DetKv`]) can read/write app data - /// alongside wallet state without opening a second connection. + /// Shared handle to the SQLite persister wrapped by `pwm`'s + /// [`DetPersister`]. Kept so [`DetKv`] can read/write app data alongside + /// wallet state without opening a second connection. persister: Arc, /// Display-only snapshot store (balance/tx/utxo), pushed by the /// `EventBridge`. See [`snapshot`]. DISPLAY-ONLY — never feeds coin diff --git a/src/wallet_backend/persister.rs b/src/wallet_backend/persister.rs index 777a03426..4195873dd 100644 --- a/src/wallet_backend/persister.rs +++ b/src/wallet_backend/persister.rs @@ -10,15 +10,15 @@ use std::sync::{Arc, Mutex}; use platform_wallet::changeset::{ ClientStartState, PersistenceCapabilities, PersistenceError, PlatformWalletChangeSet, - PlatformWalletPersistence, + PlatformWalletPersistence, ShieldedChangeSet, }; use platform_wallet::wallet::platform_wallet::WalletId; use platform_wallet::wallet::shielded::SubwalletId; -use platform_wallet_storage::SqlitePersister; +use platform_wallet_storage::{KvError, SqlitePersister}; -use super::{DetKv, DetScope}; +use super::{DetKv, DetScope, KvAdapterError}; -const SHIELDED_FVK_KEY: &str = "shielded_fvks.v1"; +const SHIELDED_FVK_KEY: &str = "shielded:fvks:v1"; /// Upstream wallet persistence plus DET's wallet-scoped shielded FVK rows. pub(super) struct DetPersister { @@ -69,11 +69,26 @@ impl DetPersister { &self, wallet_id: WalletId, ) -> Result>, PersistenceError> { - let stored: BTreeMap> = self - .kv - .get(DetScope::Wallet(&wallet_id), SHIELDED_FVK_KEY) - .map_err(PersistenceError::backend)? - .unwrap_or_default(); + let stored: BTreeMap> = + match self.kv.get(DetScope::Wallet(&wallet_id), SHIELDED_FVK_KEY) { + Ok(stored) => stored.unwrap_or_default(), + Err( + error @ (KvAdapterError::SchemaVersion { .. } + | KvAdapterError::Truncated + | KvAdapterError::Decode(_) + | KvAdapterError::Store(KvError::ValueTooLarge { .. })), + ) => { + tracing::warn!( + target: "wallet_backend::persister", + wallet_id = %hex::encode(wallet_id), + key = SHIELDED_FVK_KEY, + error = ?error, + "Skipping unreadable shielded viewing-key row", + ); + BTreeMap::new() + } + Err(error) => return Err(PersistenceError::backend(error)), + }; Ok(stored .into_iter() .map(|(account, fvk)| (SubwalletId::new(wallet_id, account), fvk)) @@ -106,6 +121,14 @@ impl DetPersister { let Some(shielded) = shielded else { return false; }; + let ShieldedChangeSet { + notes_saved, + nullifiers_spent, + outgoing_notes, + synced_indices, + activity_entries, + viewing_keys, + } = shielded; core.is_none() && identities.is_none() @@ -123,12 +146,12 @@ impl DetPersister { && account_address_pools.is_empty() && pending_contact_crypto_added.is_empty() && pending_contact_crypto_cleared.is_empty() - && shielded.notes_saved.is_empty() - && shielded.nullifiers_spent.is_empty() - && shielded.outgoing_notes.is_empty() - && shielded.synced_indices.is_empty() - && shielded.activity_entries.is_empty() - && !shielded.viewing_keys.is_empty() + && notes_saved.is_empty() + && nullifiers_spent.is_empty() + && outgoing_notes.is_empty() + && synced_indices.is_empty() + && activity_entries.is_empty() + && !viewing_keys.is_empty() } } @@ -184,3 +207,240 @@ impl PlatformWalletPersistence for DetPersister { self.inner.get_core_tx_record(wallet_id, txid) } } + +#[cfg(test)] +mod tests { + use super::*; + + use platform_wallet::changeset::{ + PersistenceErrorKind, PlatformWalletChangeSet, ShieldedChangeSet, + }; + use platform_wallet::wallet::shielded::ShieldedNote; + use platform_wallet_storage::{KvStore, ObjectId, SqlitePersisterConfig}; + + struct FailingReadKv { + oversized: bool, + } + + impl KvStore for FailingReadKv { + fn get(&self, _scope: &ObjectId, _key: &str) -> Result>, KvError> { + if self.oversized { + Err(KvError::ValueTooLarge { found: 2, max: 1 }) + } else { + Err(KvError::LockPoisoned) + } + } + + fn put(&self, _scope: &ObjectId, _key: &str, _value: &[u8]) -> Result<(), KvError> { + Ok(()) + } + + fn delete(&self, _scope: &ObjectId, _key: &str) -> Result<(), KvError> { + Ok(()) + } + + fn list_keys( + &self, + _scope: &ObjectId, + _prefix: Option<&str>, + ) -> Result, KvError> { + Ok(Vec::new()) + } + } + + fn test_persister() -> (tempfile::TempDir, DetPersister) { + let dir = tempfile::tempdir().expect("persister tempdir"); + let inner = Arc::new( + SqlitePersister::open(SqlitePersisterConfig::new( + dir.path().join("platform-wallet.sqlite"), + )) + .expect("open test persister"), + ); + (dir, DetPersister::new(inner)) + } + + fn viewing_key_changeset( + wallet_id: WalletId, + account: u32, + fvk: Vec, + ) -> PlatformWalletChangeSet { + let mut shielded = ShieldedChangeSet::default(); + shielded + .viewing_keys + .insert(SubwalletId::new(wallet_id, account), fvk); + PlatformWalletChangeSet { + shielded: Some(shielded), + ..Default::default() + } + } + + #[test] + fn is_viewing_key_only_classifies_supported_shapes() { + let wallet_id = [0x11; 32]; + let subwallet_id = SubwalletId::new(wallet_id, 0); + + let viewing_only = viewing_key_changeset(wallet_id, 0, vec![0xA1; 96]); + + let mut viewing_and_synced = viewing_key_changeset(wallet_id, 0, vec![0xA2; 96]); + viewing_and_synced + .shielded + .as_mut() + .expect("shielded changeset") + .synced_indices + .insert(subwallet_id, 7); + + let empty = PlatformWalletChangeSet::default(); + + let mut synced_without_viewing = ShieldedChangeSet::default(); + synced_without_viewing + .synced_indices + .insert(subwallet_id, 8); + let synced_without_viewing = PlatformWalletChangeSet { + shielded: Some(synced_without_viewing), + ..Default::default() + }; + + for (case, changeset, expected) in [ + ("viewing keys only", viewing_only, true), + ("viewing keys plus another field", viewing_and_synced, false), + ("no shielded changeset", empty, false), + ( + "shielded delta without viewing keys", + synced_without_viewing, + false, + ), + ] { + assert_eq!( + DetPersister::is_viewing_key_only(&changeset), + expected, + "case: {case}" + ); + } + } + + #[test] + fn viewing_keys_round_trip_through_store() { + let (_dir, persister) = test_persister(); + let wallet_id = [0x21; 32]; + let subwallet_id = SubwalletId::new(wallet_id, 3); + let fvk = vec![0xB1; 96]; + + persister + .store( + wallet_id, + viewing_key_changeset(wallet_id, subwallet_id.account_index, fvk.clone()), + ) + .expect("store viewing key"); + + assert_eq!( + persister + .load_viewing_keys(wallet_id) + .expect("load viewing key"), + BTreeMap::from([(subwallet_id, fvk)]) + ); + } + + #[test] + fn load_viewing_keys_skips_oversized_values_but_propagates_backend_failures() { + let wallet_id = [0x29; 32]; + let (_dir, mut persister) = test_persister(); + persister.kv = DetKv::from_store(Arc::new(FailingReadKv { oversized: true })); + assert!( + persister + .load_viewing_keys(wallet_id) + .expect("oversized row must degrade to absent") + .is_empty() + ); + + let (_dir, mut persister) = test_persister(); + persister.kv = DetKv::from_store(Arc::new(FailingReadKv { oversized: false })); + let error = persister + .load_viewing_keys(wallet_id) + .expect_err("backend failure must remain fatal"); + match error { + PersistenceError::Backend { kind, source } => { + assert_eq!(kind, PersistenceErrorKind::Fatal); + assert!(matches!( + source.downcast_ref::(), + Some(KvAdapterError::Store(KvError::LockPoisoned)) + )); + } + other => panic!("unexpected persistence error: {other:?}"), + } + } + + #[test] + fn mixed_viewing_key_and_note_changeset_is_rejected_without_partial_write() { + let (_dir, persister) = test_persister(); + let wallet_id = [0x31; 32]; + let subwallet_id = SubwalletId::new(wallet_id, 0); + let mut changeset = viewing_key_changeset(wallet_id, 0, vec![0xC1; 96]); + changeset + .shielded + .as_mut() + .expect("shielded changeset") + .notes_saved + .insert( + subwallet_id, + vec![ShieldedNote { + position: 1, + cmx: [0xC2; 32], + nullifier: [0xC3; 32], + block_height: 2, + is_spent: false, + value: 3, + note_data: vec![0xC4; 115], + }], + ); + + let error = persister + .store(wallet_id, changeset) + .expect_err("mixed changeset must be rejected"); + match error { + PersistenceError::Backend { kind, source } => { + assert_eq!(kind, PersistenceErrorKind::Fatal); + assert_eq!( + source.to_string(), + "shielded viewing keys must be persisted in a dedicated changeset" + ); + } + other => panic!("unexpected persistence error: {other:?}"), + } + assert!( + persister + .load_viewing_keys(wallet_id) + .expect("load after rejection") + .is_empty(), + "a rejected mixed changeset must not partially persist viewing keys" + ); + } + + #[test] + fn viewing_key_for_another_wallet_is_rejected_without_partial_write() { + let (_dir, persister) = test_persister(); + let wallet_id = [0x41; 32]; + let other_wallet_id = [0x42; 32]; + let changeset = viewing_key_changeset(other_wallet_id, 0, vec![0xD1; 96]); + + let error = persister + .store(wallet_id, changeset) + .expect_err("cross-wallet viewing key must be rejected"); + match error { + PersistenceError::Backend { kind, source } => { + assert_eq!(kind, PersistenceErrorKind::Fatal); + assert_eq!( + source.to_string(), + "shielded viewing-key wallet id does not match changeset wallet id" + ); + } + other => panic!("unexpected persistence error: {other:?}"), + } + assert!( + persister + .load_viewing_keys(wallet_id) + .expect("load after rejection") + .is_empty(), + "a rejected cross-wallet changeset must not persist viewing keys" + ); + } +} diff --git a/src/wallet_backend/shielded.rs b/src/wallet_backend/shielded.rs index 6e0479f5c..909267688 100644 --- a/src/wallet_backend/shielded.rs +++ b/src/wallet_backend/shielded.rs @@ -78,6 +78,21 @@ impl WalletBackend { }) } + #[cfg(test)] + pub(crate) async fn bind_shielded_from_persisted_for_test( + &self, + seed_hash: &WalletSeedHash, + ) -> Result { + let wallet = self.resolve_wallet(seed_hash).await?; + let coordinator = self.shielded_coordinator_arc().await?; + wallet + .bind_shielded_from_persisted(&[0], &coordinator) + .await + .map_err(|error| TaskError::WalletBackend { + source: Arc::new(error), + }) + } + /// Bind Orchard keys for `seed_hash` by resolving its HD seed just-in-time /// through the [`SecretAccess`](super::SecretAccess) chokepoint, then delegating to /// [`Self::ensure_shielded_bound`]. From e1ba7b3e1a3579185893fde6bbd67816b84962b5 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:12:39 +0000 Subject: [PATCH 06/20] fix(wallet-backend): reap persisted FVK row on wallet removal (SEC-008/SEC-010) Fast-follow from the same platform#3968 pin-bump due-diligence review that produced eb7e0faec. Confirmed empirically (Smythe, twice): removing a wallet via forget_wallet_local_state + remove_upstream_wallet never deletes the persisted shielded:fvks:v1 row persister.rs (eb7e0faec) added. Root cause: upstream's PlatformWalletPersistence trait has no delete_wallet method at this rev (a separately-tracked, pre-existing upstream gap - out of scope here), and DET's own removal path never touched this new KV row either. Net effect: an Orchard viewing key outlives the wallet it belonged to, orphaned on disk indefinitely. Privacy-only (no spend secrets involved - those are already correctly wiped by the seed-vault deletion this function already does) and fund-safety-neutral, so this is a fast-follow rather than a blocker alongside SEC-007/RUST-001, per reviewer sign-off. - persister.rs: added forget_wallet_viewing_keys(kv, wallet_id), keyed by the upstream WalletId (matching what store_viewing_keys/load_viewing_keys actually key by - not this module's usual WalletSeedHash), reusing the existing SHIELDED_FVK_KEY constant rather than re-typing the key string. - mod.rs: forget_wallet_local_state now calls this best-effort in the `if let Some(wallet_id)` block (wallet_id is only Some when the wallet was actually upstream-registered, so there's never a row to reap otherwise), logging a warning on failure rather than aborting the rest of the wipe - consistent with this function's existing resilience contract for lesser (non-secret) cleanup steps. - tests.rs: added remove_wallet_reaps_persisted_shielded_viewing_keys, which registers a wallet, binds shielded (persists an FVK row), verifies the row exists, runs the real removal path, then re-opens the SQLite file directly and asserts the row is gone. Verification (via cargo-cached.sh ledger): - cargo fmt --all -- --check - exit 0 - cargo clippy --all-features --all-targets -- -D warnings - exit 0 - cargo test --lib --all-features wallet_backend - 332 passed, 0 failed (includes remove_wallet_reaps_persisted_shielded_viewing_keys) Co-Authored-By: Codex gpt-5.6-sol --- src/context/wallet_lifecycle/tests.rs | 62 +++++++++++++++++++++++++++ src/wallet_backend/mod.rs | 10 ++++- src/wallet_backend/persister.rs | 8 ++++ 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/src/context/wallet_lifecycle/tests.rs b/src/context/wallet_lifecycle/tests.rs index d37db5c06..6e689f845 100644 --- a/src/context/wallet_lifecycle/tests.rs +++ b/src/context/wallet_lifecycle/tests.rs @@ -1691,6 +1691,68 @@ async fn remove_wallet_wipes_seed_envelope() { backend.shutdown().await; } +/// Removing a wallet reaps its persisted Orchard full-viewing-key row. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remove_wallet_reaps_persisted_shielded_viewing_keys() { + use std::collections::BTreeMap; + + use platform_wallet_storage::{KvStore, ObjectId, SqlitePersister, SqlitePersisterConfig}; + + let _guard = backend_reopen_lock().await; + let source_dir = tempfile::tempdir().expect("source tempdir"); + let seed = [0xA2u8; 64]; + let wallet = crate::model::wallet::Wallet::new_from_seed(seed, Network::Testnet, None, None) + .expect("build wallet"); + let seed_hash = wallet.seed_hash(); + let (ctx, sender) = offline_testnet_context_at(source_dir.path()); + + ctx.register_wallet(wallet, &seed, WalletOrigin::Fresh) + .expect("register wallet"); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend"); + let backend = ctx.wallet_backend().expect("backend wired"); + backend + .register_wallet_from_seed(&seed_hash, &seed, Some(0)) + .await + .expect("persist upstream wallet"); + backend + .ensure_shielded_bound(&seed_hash, &seed) + .await + .expect("persist wallet viewing key"); + let wallet_id = backend + .registered_wallet_id(&seed_hash) + .expect("registered upstream wallet id"); + let stored: Option>> = backend + .kv() + .get( + crate::wallet_backend::DetScope::Wallet(&wallet_id), + "shielded:fvks:v1", + ) + .expect("read persisted viewing keys"); + assert!(stored.is_some(), "precondition: the FVK row must exist"); + + ctx.remove_wallet(&seed_hash).expect("remove wallet"); + let _ = ctx.subtasks.shutdown_async().await; + backend.shutdown().await; + + let inspection_dir = tempfile::tempdir().expect("inspection tempdir"); + copy_dir_recursive(source_dir.path(), inspection_dir.path()); + let persister_path = inspection_dir + .path() + .join("spv") + .join("testnet") + .join("platform-wallet.sqlite"); + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&persister_path)) + .expect("open post-removal persister"); + assert!( + KvStore::get(&persister, &ObjectId::Wallet(wallet_id), "shielded:fvks:v1") + .expect("read post-removal FVK row") + .is_none(), + "the FVK row must be deleted with the wallet" + ); +} + /// The receive-address snapshot is empty until a bind publishes into it, so a /// wallet that is locked or not yet bound reports `None` and the Shielded tab /// falls back to its "not ready yet" copy instead of rendering a wrong address. diff --git a/src/wallet_backend/mod.rs b/src/wallet_backend/mod.rs index cfd98aaba..bdf502e7a 100644 --- a/src/wallet_backend/mod.rs +++ b/src/wallet_backend/mod.rs @@ -1251,7 +1251,8 @@ impl WalletBackend { /// Wipe every piece of DET-local state for a forgotten wallet — the /// encrypted seed-envelope vault, the session secret cache, the wallet-meta - /// sidecar, and the in-memory `id_map`/`wallets`/snapshot registration. + /// sidecar, persisted shielded viewing keys, and the in-memory + /// `id_map`/`wallets`/snapshot registration. /// (Orchard state lives in the upstream coordinator now and is detached by /// [`Self::remove_upstream_wallet`].) /// @@ -1324,6 +1325,13 @@ impl WalletBackend { // In-memory maps + snapshot registration. if let Some(wallet_id) = wallet_id { + if let Err(error) = persister::forget_wallet_viewing_keys(&self.kv(), wallet_id) { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = ?error, + "Failed to delete shielded viewing keys during wallet removal" + ); + } self.inner.id_map.write()?.remove(seed_hash); self.inner.wallets.write()?.remove(&wallet_id); self.inner.snapshots.forget_wallet(seed_hash, &wallet_id); diff --git a/src/wallet_backend/persister.rs b/src/wallet_backend/persister.rs index 4195873dd..fa209158d 100644 --- a/src/wallet_backend/persister.rs +++ b/src/wallet_backend/persister.rs @@ -20,6 +20,14 @@ use super::{DetKv, DetScope, KvAdapterError}; const SHIELDED_FVK_KEY: &str = "shielded:fvks:v1"; +/// Delete one wallet's persisted shielded full-viewing keys. +pub(super) fn forget_wallet_viewing_keys( + kv: &DetKv, + wallet_id: WalletId, +) -> Result<(), KvAdapterError> { + kv.delete(DetScope::Wallet(&wallet_id), SHIELDED_FVK_KEY) +} + /// Upstream wallet persistence plus DET's wallet-scoped shielded FVK rows. pub(super) struct DetPersister { inner: Arc, From 69b7546eda20be6fcb79e2adbdb4b675eedfe905 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:53:31 +0000 Subject: [PATCH 07/20] =?UTF-8?q?chore:=20bump=20dashpay/platform=20pin=20?= =?UTF-8?q?to=20PR=20#3968=20tip=20(ebbd15c4=20=E2=86=92=20e75f259)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-bumps the four dashpay/platform git dependencies (dash-sdk, rs-sdk-trusted-context-provider, platform-wallet, platform-wallet-storage) from ebbd15c4 to e75f259, the current tip of platform PR #3968. Verified via gh pr view headRefOid immediately before commit. Cargo.lock is regenerated scoped to the platform package set: 28 platform git packages moved to e75f259. rust-dashcore, grovedb, orchard, and grovestark pins are unchanged; no crates.io registry versions, checksums, or dependency edges moved. API-drift fixes required by the upstream jump (each verified against the new upstream source, not guessed): - The hardened file and SQLite backends now validate permissions on every parent directory. Normalize the app-data root, SPV directory, and per-network directory to owner-only on Unix before opening storage; copied cold-boot fixtures preserve that invariant. - PlatformWalletPersistence gained delete_wallet with a default UnsupportedOperation result. DetPersister now delegates the method to its inner SqlitePersister so wallet deletion keeps the typed backend behavior and cascade cleanup. - The secret store now enforces an eight-byte passphrase floor. Reject incompatible passwords for new HD wallets before creating an envelope, while keeping unmigrated short-password legacy wallets usable by deferring rejected Tier-2 rewraps and retaining the legacy envelope. - Upstream now persists shielded viewing keys natively. The legacy DET metadata adapter remains in place for databases written by prior pins; platform-wallet and platform-wallet-storage retain matching shielded feature flags. Known upstream compatibility limitation: July 2026 weekly builds could already write a short-password Tier-2 seed under the previous one-byte floor and delete its legacy envelope. e75f259 rejects that password before decryption, and no public downstream compatibility reader exists. CHANGELOG documents that affected profiles must not upgrade until upstream provides read-old/write-new support; this commit must not be pushed or merged as-is for those profiles. PlatformWalletError did not gain variants in this jump, so the existing exhaustive match classifications remain complete. Verification: - cargo fmt --all -- --check — exit 0 - cargo clippy --all-features --all-targets -- -D warnings — exit 0 - cargo test --lib --all-features — 2054 passed, 0 failed, 1 ignored - cargo test --all-features wallet_backend — 335 passed, 0 failed - cargo test --all-features context::wallet_lifecycle — 64 passed, 0 failed - cargo test --all-features shielded — 72 passed, 0 failed, plus 3 ignored backend-e2e cases Co-Authored-By: Codex gpt-5.6-sol --- CHANGELOG.md | 21 +++++++--- Cargo.lock | 56 +++++++++++++-------------- Cargo.toml | 9 ++--- src/app_dir.rs | 29 ++++++++++++++ src/backend_task/error.rs | 8 ++++ src/boot.rs | 7 ++-- src/context/mod.rs | 5 +++ src/context/wallet_lifecycle/tests.rs | 4 +- src/model/wallet/mod.rs | 26 +++++++++++++ src/wallet_backend/mod.rs | 28 ++++++++++++-- src/wallet_backend/persister.rs | 19 +++++++++ src/wallet_backend/secret_access.rs | 52 +++++++++++++++++++++++-- src/wallet_backend/single_key.rs | 4 +- 13 files changed, 216 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cceef205..d4046375c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,7 +54,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Upstream wallet backend updated (`platform-wallet` / `platform-wallet-storage`)**: the `dashpay/platform` dependency is bumped to the PR #3968 tip - (`d18020f` → `ebbd15c`), which lands an embeddable SQLite persistence backend with + (`d18020f` → `e75f259`), which lands an embeddable SQLite persistence backend with *seedless rehydration*. The wallet manager now restores watch-only wallet state (accounts, balances, identities, platform addresses) from the on-disk store without the HD seed, re-deriving spend authority just-in-time from the seed only @@ -67,11 +67,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). rehydration, selects platform-address transfer and withdrawal inputs from hydrated candidates with authoritative on-chain balances, freezes the SPV sync watermark when persistence fails, and persists address-reservation timestamps - plus DashPay address used-state updates. Transitively, the pinned dashpay git - dependencies advance with it: `rust-dashcore` (`be6e776` → `0091c4a`, which - lands the reserve-on-hand-out receive-address APIs), `grovedb` (`v5.0.0` → - `v5.0.1`), and the `orchard` shielded-crypto fork (`dashified-0.14.0` → - `dashified-0.14.1`); no crates.io dependencies change. + plus DashPay address used-state updates. On Unix, DET now tightens app and + per-network storage directories to owner-only before opening the hardened + upstream database, so permissive system defaults do not prevent startup. New + wallet passwords must now be at least eight characters after trimming; + existing wallets with shorter passwords that are still in DET's legacy + encrypted format remain usable instead of failing during lazy migration. + Wallets already migrated to Tier-2 storage by a July 2026 weekly build with + a shorter password cannot be opened at this upstream tip; do not upgrade + those profiles until upstream provides a compatibility reader. + Transitively, the pinned dashpay git dependencies advance with it: + `rust-dashcore` (`be6e776` → `0091c4a`, which lands the reserve-on-hand-out + receive-address APIs), `grovedb` (`v5.0.0` → `v5.0.1`), and the `orchard` + shielded-crypto fork (`dashified-0.14.0` → `dashified-0.14.1`); no crates.io + dependencies change. - **Shielded transactions are available on supported networks**: sending, receiving, shielding, and unshielding are enabled when the connected network's diff --git a/Cargo.lock b/Cargo.lock index 8092d0024..314f30e5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1877,7 +1877,7 @@ dependencies = [ [[package]] name = "dapi-grpc" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" +source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" dependencies = [ "dash-platform-macros", "futures-core", @@ -1979,7 +1979,7 @@ dependencies = [ [[package]] name = "dash-async" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" +source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" dependencies = [ "thiserror 2.0.18", "tokio", @@ -1989,7 +1989,7 @@ dependencies = [ [[package]] name = "dash-context-provider" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" +source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" dependencies = [ "dash-async", "dpp", @@ -2100,7 +2100,7 @@ dependencies = [ [[package]] name = "dash-platform-macros" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" +source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" dependencies = [ "heck", "quote", @@ -2110,7 +2110,7 @@ dependencies = [ [[package]] name = "dash-sdk" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" +source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" dependencies = [ "arc-swap", "async-trait", @@ -2247,7 +2247,7 @@ dependencies = [ [[package]] name = "dashpay-contract" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" +source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" dependencies = [ "platform-value", "platform-version", @@ -2258,7 +2258,7 @@ dependencies = [ [[package]] name = "data-contracts" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" +source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" dependencies = [ "dashpay-contract", "document-history-contract", @@ -2527,7 +2527,7 @@ dependencies = [ [[package]] name = "document-history-contract" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" +source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" dependencies = [ "platform-value", "platform-version", @@ -2556,7 +2556,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "dpns-contract" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" +source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" dependencies = [ "platform-value", "platform-version", @@ -2567,7 +2567,7 @@ dependencies = [ [[package]] name = "dpp" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" +source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" dependencies = [ "anyhow", "async-trait", @@ -2617,7 +2617,7 @@ dependencies = [ [[package]] name = "dpp-json-convertible-derive" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" +source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" dependencies = [ "proc-macro2", "quote", @@ -2627,7 +2627,7 @@ dependencies = [ [[package]] name = "drive" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" +source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" dependencies = [ "bincode 2.0.1", "byteorder", @@ -2652,7 +2652,7 @@ dependencies = [ [[package]] name = "drive-proof-verifier" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" +source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" dependencies = [ "bincode 2.0.1", "dapi-grpc", @@ -5046,7 +5046,7 @@ dependencies = [ [[package]] name = "keyword-search-contract" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" +source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" dependencies = [ "platform-value", "platform-version", @@ -5259,7 +5259,7 @@ dependencies = [ [[package]] name = "masternode-reward-shares-contract" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" +source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" dependencies = [ "platform-value", "platform-version", @@ -6471,7 +6471,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "platform-encryption" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" +source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" dependencies = [ "aes", "cbc", @@ -6484,7 +6484,7 @@ dependencies = [ [[package]] name = "platform-serialization" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" +source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" dependencies = [ "bincode 2.0.1", "platform-version", @@ -6493,7 +6493,7 @@ dependencies = [ [[package]] name = "platform-serialization-derive" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" +source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" dependencies = [ "proc-macro2", "quote", @@ -6504,7 +6504,7 @@ dependencies = [ [[package]] name = "platform-value" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" +source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" dependencies = [ "base64 0.22.1", "bincode 2.0.1", @@ -6524,7 +6524,7 @@ dependencies = [ [[package]] name = "platform-version" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" +source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" dependencies = [ "bincode 2.0.1", "grovedb-version 5.0.1", @@ -6535,7 +6535,7 @@ dependencies = [ [[package]] name = "platform-versioning" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" +source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" dependencies = [ "proc-macro2", "quote", @@ -6545,7 +6545,7 @@ dependencies = [ [[package]] name = "platform-wallet" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" +source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" dependencies = [ "arc-swap", "async-trait", @@ -6577,7 +6577,7 @@ dependencies = [ [[package]] name = "platform-wallet-storage" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" +source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" dependencies = [ "apple-native-keyring-store", "argon2", @@ -7574,7 +7574,7 @@ dependencies = [ [[package]] name = "rs-dapi-client" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" +source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" dependencies = [ "backon", "chrono", @@ -7600,7 +7600,7 @@ dependencies = [ [[package]] name = "rs-sdk-trusted-context-provider" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" +source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" dependencies = [ "arc-swap", "dash-async", @@ -8842,7 +8842,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "token-history-contract" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" +source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" dependencies = [ "platform-value", "platform-version", @@ -9687,7 +9687,7 @@ dependencies = [ [[package]] name = "wallet-utils-contract" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" +source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" dependencies = [ "platform-value", "platform-version", @@ -10943,7 +10943,7 @@ dependencies = [ [[package]] name = "withdrawals-contract" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=ebbd15c4b322579bc2a4a84e1db5da6035220f5f#ebbd15c4b322579bc2a4a84e1db5da6035220f5f" +source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" dependencies = [ "num_enum 0.5.11", "platform-value", diff --git a/Cargo.toml b/Cargo.toml index 5ff4f2215..3618b4073 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ qrcode = "0.14.1" nix = { version = "0.31.1", features = ["signal"] } eframe = { version = "0.35.0", features = ["persistence", "wgpu"] } base64 = "0.22.1" -dash-sdk = { git = "https://github.com/dashpay/platform", rev = "ebbd15c4b322579bc2a4a84e1db5da6035220f5f", features = [ +dash-sdk = { git = "https://github.com/dashpay/platform", rev = "e75f259fadeb24bb888d33f5b2ec1991c26dc200", features = [ "core_key_wallet", "core_key_wallet_manager", "core_bincode", @@ -28,12 +28,12 @@ dash-sdk = { git = "https://github.com/dashpay/platform", rev = "ebbd15c4b322579 "core_spv", "shielded", ] } -rs-sdk-trusted-context-provider = { git = "https://github.com/dashpay/platform", rev = "ebbd15c4b322579bc2a4a84e1db5da6035220f5f" } -platform-wallet = { git = "https://github.com/dashpay/platform", rev = "ebbd15c4b322579bc2a4a84e1db5da6035220f5f", features = [ +rs-sdk-trusted-context-provider = { git = "https://github.com/dashpay/platform", rev = "e75f259fadeb24bb888d33f5b2ec1991c26dc200" } +platform-wallet = { git = "https://github.com/dashpay/platform", rev = "e75f259fadeb24bb888d33f5b2ec1991c26dc200", features = [ "serde", "shielded", ] } -platform-wallet-storage = { git = "https://github.com/dashpay/platform", rev = "ebbd15c4b322579bc2a4a84e1db5da6035220f5f", features = [ +platform-wallet-storage = { git = "https://github.com/dashpay/platform", rev = "e75f259fadeb24bb888d33f5b2ec1991c26dc200", features = [ "shielded", ] } zip32 = "0.2.0" @@ -179,4 +179,3 @@ check-cfg = ["cfg(tokio_unstable)", "cfg(feature, values(\"testing\", \"bench\", [lints.clippy] uninlined_format_args = "allow" - diff --git a/src/app_dir.rs b/src/app_dir.rs index ecf9b0b45..4952751c9 100644 --- a/src/app_dir.rs +++ b/src/app_dir.rs @@ -83,6 +83,11 @@ pub fn ensure_data_dir_exists(data_dir: &Path) -> Result<(), std::io::Error> { if !metadata.is_dir() { return Err(std::io::Error::other("Created path is not a directory")); } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(data_dir, fs::Permissions::from_mode(0o700))?; + } Ok(()) } @@ -144,3 +149,27 @@ pub fn create_dash_core_config_if_not_exists( Ok(config_path) } + +#[cfg(all(test, unix))] +mod tests { + use std::os::unix::fs::PermissionsExt; + + use super::ensure_data_dir_exists; + + #[test] + fn ensure_data_dir_exists_restricts_existing_directory_permissions() { + let parent = tempfile::tempdir().expect("temp parent"); + let data_dir = parent.path().join("data"); + std::fs::create_dir(&data_dir).expect("create data dir"); + std::fs::set_permissions(&data_dir, std::fs::Permissions::from_mode(0o775)) + .expect("make data dir group-writable"); + + ensure_data_dir_exists(&data_dir).expect("secure data dir"); + + let mode = std::fs::metadata(&data_dir) + .expect("read data dir metadata") + .permissions() + .mode(); + assert_eq!(mode & 0o077, 0, "data dir must be owner-only"); + } +} diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 55b7ede98..a7da75126 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -1920,6 +1920,11 @@ pub enum TaskError { #[error("This wallet has already been imported for this network.")] WalletAlreadyImported, + /// A new wallet password is shorter than the persistent secret store's + /// minimum and therefore could not be migrated to Tier-2 protection. + #[error("Wallet passwords must be at least {min} characters after trimming.")] + WalletPasswordTooShort { min: u32 }, + /// Wallet key derivation failed during construction. #[error("Could not create the wallet. Key derivation failed — please try again.")] WalletKeyDerivationFailed { @@ -2736,6 +2741,9 @@ impl From for TaskError { fn from(e: crate::model::wallet::WalletCreationError) -> Self { use crate::model::wallet::WalletCreationError; match e { + WalletCreationError::PasswordTooShort { min } => { + TaskError::WalletPasswordTooShort { min } + } WalletCreationError::Encryption { detail } => TaskError::EncryptionError { detail }, WalletCreationError::KeyDerivation { source } => { TaskError::WalletKeyDerivationFailed { source } diff --git a/src/boot.rs b/src/boot.rs index d7e8a087a..448009f63 100644 --- a/src/boot.rs +++ b/src/boot.rs @@ -272,7 +272,7 @@ fn classify_open(data_dir: &std::path::Path) -> Result #[cfg(test)] mod tests { use super::*; - use crate::wallet_backend::single_key::{open_secret_store, open_secret_store_with_passphrase}; + use crate::wallet_backend::single_key::open_secret_store; /// A normal keyless vault classifies as ready — the happy path never gates. #[test] @@ -294,8 +294,7 @@ mod tests { #[test] fn classify_open_unlock_for_passphrase_vault() { let tmp = tempfile::tempdir().expect("tempdir"); - let vault = tmp.path().join("secrets").join("det-secrets.pwsvault"); - open_secret_store_with_passphrase(&vault, SecretString::new("legacy")) + AppContext::open_secret_store_with_passphrase(tmp.path(), SecretString::new("legacy-pass")) .expect("create passphrase vault"); let decision = classify_open(tmp.path()).expect("classify"); @@ -330,7 +329,7 @@ mod tests { fn keyless_open_of_passphrase_vault_is_the_gated_failure() { let tmp = tempfile::tempdir().expect("tempdir"); let vault = tmp.path().join("secrets").join("det-secrets.pwsvault"); - open_secret_store_with_passphrase(&vault, SecretString::new("legacy")) + AppContext::open_secret_store_with_passphrase(tmp.path(), SecretString::new("legacy-pass")) .expect("create passphrase vault"); let err = open_secret_store(&vault).expect_err("keyless open must fail"); diff --git a/src/context/mod.rs b/src/context/mod.rs index 31703d8c5..c3c47747f 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -548,6 +548,7 @@ impl AppContext { data_dir: &std::path::Path, ) -> Result, platform_wallet_storage::WalletStorageError> { use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; + crate::app_dir::ensure_data_dir_exists(data_dir)?; let path = data_dir.join("det-app.sqlite"); let config = SqlitePersisterConfig::new(path); let persister = Arc::new(SqlitePersister::open(config)?); @@ -562,6 +563,8 @@ impl AppContext { /// of the same vault would fail with `AlreadyLocked`. The vault is /// cross-network: seeds and imported keys are scoped by hash, not chain. pub fn open_secret_store(data_dir: &std::path::Path) -> Result, TaskError> { + crate::app_dir::ensure_data_dir_exists(data_dir) + .map_err(|source| TaskError::FileSystem { source })?; crate::wallet_backend::single_key::open_secret_store(&Self::secret_store_path(data_dir)) .map(Arc::new) .map_err(|source| TaskError::SecretStore { @@ -581,6 +584,8 @@ impl AppContext { data_dir: &std::path::Path, passphrase: platform_wallet_storage::secrets::SecretString, ) -> Result, TaskError> { + crate::app_dir::ensure_data_dir_exists(data_dir) + .map_err(|source| TaskError::FileSystem { source })?; crate::wallet_backend::single_key::open_secret_store_with_passphrase( &Self::secret_store_path(data_dir), passphrase, diff --git a/src/context/wallet_lifecycle/tests.rs b/src/context/wallet_lifecycle/tests.rs index 6e689f845..ffd23f5fb 100644 --- a/src/context/wallet_lifecycle/tests.rs +++ b/src/context/wallet_lifecycle/tests.rs @@ -1,6 +1,6 @@ use super::*; use crate::app::TaskResult; -use crate::app_dir::ensure_env_file; +use crate::app_dir::{ensure_data_dir_exists, ensure_env_file}; use crate::context::AppContext; use crate::context::connection_status::ConnectionStatus; use crate::context::migration_status::MigrationState; @@ -80,7 +80,7 @@ fn offline_testnet_context_with_db( /// over a fresh path (identical on-disk bytes) to sidestep the persister's /// single-open advisory lock a lingering subtask may still hold. fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) { - std::fs::create_dir_all(dst).expect("mkdir dst"); + ensure_data_dir_exists(dst).expect("create secure destination directory"); for entry in std::fs::read_dir(src).expect("read_dir") { let entry = entry.expect("dir entry"); let from = entry.path(); diff --git a/src/model/wallet/mod.rs b/src/model/wallet/mod.rs index f3ab6dec7..77940012b 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -18,6 +18,7 @@ use dash_sdk::dpp::key_wallet::bip32::{ }; use dash_sdk::dpp::prelude::AddressNonce; use dash_sdk::platform::address_sync::{AddressFunds, AddressIndex, AddressProvider}; +use platform_wallet_storage::secrets::MIN_PASSPHRASE_LEN; use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; use dash_sdk::dpp::dashcore::{ @@ -50,6 +51,9 @@ pub enum PaymentValidationError { /// backend-task layer; `TaskError` provides a `From` conversion. #[derive(Debug, Error)] pub enum WalletCreationError { + /// The password cannot satisfy the persistent secret store's minimum. + #[error("Wallet passwords must be at least {min} characters after trimming.")] + PasswordTooShort { min: u32 }, /// Encrypting the seed with the supplied password failed. #[error("Could not process encrypted data. Please check your keys and try again.")] Encryption { detail: String }, @@ -432,6 +436,11 @@ impl Wallet { // Encrypt seed or store plaintext let (encrypted_seed, salt, nonce, uses_password) = match password { Some(pw) if !pw.is_empty() => { + if pw.expose_secret().trim().chars().count() < MIN_PASSPHRASE_LEN { + return Err(WalletCreationError::PasswordTooShort { + min: MIN_PASSPHRASE_LEN as u32, + }); + } // `encrypt_seed` is fully typed; `WalletCreationError::Encryption` // still carries a `String` (pre-existing debt tracked for a later // type-through), so the typed error is rendered via `Display` here. @@ -2620,6 +2629,23 @@ mod tests { assert_eq!(hash1, hash2); } + #[test] + fn new_wallet_rejects_password_below_storage_floor() { + let short_password = Secret::new("1234567"); + let result = Wallet::new_from_seed( + test_seed(), + Network::Testnet, + Some("short-password".to_string()), + Some(&short_password), + ); + + assert!(matches!( + result, + Err(WalletCreationError::PasswordTooShort { min }) + if min == MIN_PASSPHRASE_LEN as u32 + )); + } + /// R3 capstone: an open wallet retains NO plaintext seed. /// /// `WalletSeed::Open` is the verify-not-park state — it carries only the diff --git a/src/wallet_backend/mod.rs b/src/wallet_backend/mod.rs index bdf502e7a..8bc366daf 100644 --- a/src/wallet_backend/mod.rs +++ b/src/wallet_backend/mod.rs @@ -2670,10 +2670,12 @@ impl WalletBackend { app_data_dir: &Path, network: Network, ) -> Result { - let mut dir = app_data_dir.to_path_buf(); - dir.push("spv"); + let mut dir = app_data_dir.join("spv"); + crate::app_dir::ensure_data_dir_exists(&dir) + .map_err(|source| TaskError::FileSystem { source })?; dir.push(kv::network_prefix(network)); - std::fs::create_dir_all(&dir).map_err(|source| TaskError::FileSystem { source })?; + crate::app_dir::ensure_data_dir_exists(&dir) + .map_err(|source| TaskError::FileSystem { source })?; Ok(dir) } } @@ -3022,6 +3024,26 @@ fn identity_op_error_kind(e: &platform_wallet::error::PlatformWalletError) -> Id mod tests { use super::*; + #[cfg(unix)] + #[test] + fn spv_storage_directory_ancestors_are_owner_only() { + use std::os::unix::fs::PermissionsExt; + + let app_data = tempfile::tempdir().expect("app data tempdir"); + crate::app_dir::ensure_data_dir_exists(app_data.path()).expect("secure app data dir"); + + let network_dir = WalletBackend::resolve_spv_storage_dir(app_data.path(), Network::Testnet) + .expect("resolve SPV storage dir"); + + for dir in [app_data.path().join("spv"), network_dir] { + let mode = std::fs::metadata(&dir) + .expect("read storage dir metadata") + .permissions() + .mode(); + assert_eq!(mode & 0o077, 0, "{} must be owner-only", dir.display()); + } + } + #[tokio::test] async fn token_balance_sync_reports_an_already_running_upstream_pass() { let sync_called = std::cell::Cell::new(false); diff --git a/src/wallet_backend/persister.rs b/src/wallet_backend/persister.rs index fa209158d..007eb38eb 100644 --- a/src/wallet_backend/persister.rs +++ b/src/wallet_backend/persister.rs @@ -204,6 +204,10 @@ impl PlatformWalletPersistence for DetPersister { Ok(start) } + fn delete_wallet(&self, wallet_id: WalletId) -> Result<(), PersistenceError> { + PlatformWalletPersistence::delete_wallet(self.inner.as_ref(), wallet_id) + } + fn get_core_tx_record( &self, wallet_id: WalletId, @@ -258,6 +262,8 @@ mod tests { fn test_persister() -> (tempfile::TempDir, DetPersister) { let dir = tempfile::tempdir().expect("persister tempdir"); + crate::app_dir::ensure_data_dir_exists(dir.path()) + .expect("restrict persister tempdir permissions"); let inner = Arc::new( SqlitePersister::open(SqlitePersisterConfig::new( dir.path().join("platform-wallet.sqlite"), @@ -348,6 +354,19 @@ mod tests { ); } + #[test] + fn delete_wallet_delegates_to_inner_persister() { + let (_dir, persister) = test_persister(); + let error = persister + .delete_wallet([0x25; 32]) + .expect_err("an unknown wallet must reach the inner persister"); + + assert!( + matches!(error, PersistenceError::Backend { .. }), + "the wrapper must not fall back to UnsupportedOperation: {error:?}" + ); + } + #[test] fn load_viewing_keys_skips_oversized_values_but_propagates_backend_failures() { let wallet_id = [0x29; 32]; diff --git a/src/wallet_backend/secret_access.rs b/src/wallet_backend/secret_access.rs index cdab7ee4b..ddc4f22d6 100644 --- a/src/wallet_backend/secret_access.rs +++ b/src/wallet_backend/secret_access.rs @@ -499,8 +499,9 @@ impl SecretAccess { /// The lazy legacy→steady-state re-wrap happens inside [`Self::decrypt_jit`]: /// a protected seed re-wraps to **Tier-2 under the same password** (protection /// KEPT, never downgraded to a raw secret), an unprotected one to the raw - /// label. So there is nothing for the unlock callsite to "finalize" — the - /// wallet's `uses_password` stays accurate (`true` for a protected wallet). + /// label. A protected re-wrap rejected by the current storage policy is + /// deferred without blocking the successfully decrypted seed; the legacy + /// envelope remains intact for the next unlock. pub fn promote_hd_seed_with_passphrase( &self, seed_hash: &WalletSeedHash, @@ -848,7 +849,13 @@ impl SecretAccess { let seed = decrypt_hd_seed(&envelope, passphrase)?; if envelope.uses_password { let pw = passphrase.ok_or(TaskError::HdPassphraseIncorrect)?; - view.set_protected(seed_hash, &seed, pw)?; + if let Err(e) = view.set_protected(seed_hash, &seed, pw) { + tracing::warn!( + target = "wallet_backend::secret_access", + error = ?e, + "HD seed lazy Tier-2 re-wrap deferred (storage rejected write)", + ); + } } else { view.set_raw(seed_hash, &seed)?; } @@ -2637,6 +2644,45 @@ mod tests { ); } + /// A legacy wallet may use a password shorter than the upstream Tier-2 + /// floor. Successful legacy decryption must still release the seed for the + /// requested operation; the unsupported re-wrap is deferred and the + /// legacy envelope remains the source of truth. + #[tokio::test] + async fn short_legacy_seed_password_remains_usable_without_tier2_migration() { + const SHORT_LEGACY_PASSWORD: &str = "short"; + + let dir = tempfile::tempdir().unwrap(); + let store = fresh_store(dir.path()); + let seed_hash: WalletSeedHash = [0x73; 32]; + store_protected_hd(&store, &seed_hash, &SENTINEL_SEED, SHORT_LEGACY_PASSWORD); + + let prompt = Arc::new(TestPrompt::new([ScriptedAnswer::once( + SHORT_LEGACY_PASSWORD, + )])); + let sa = access(store.clone(), prompt.clone()); + let scope = SecretScope::HdSeed { seed_hash }; + + sa.with_secret(&scope, |pt| { + assert_eq!(pt.expose_hd_seed().copied(), Some(SENTINEL_SEED)); + Ok(()) + }) + .await + .expect("legacy password must keep unlocking the seed"); + assert_eq!(prompt.ask_count(), 1); + + let view = WalletSeedView::new(&store); + assert_eq!( + view.scheme(&seed_hash).unwrap(), + SecretScheme::Absent, + "a sub-floor password must not be enrolled into Tier-2" + ); + assert!( + view.get(&seed_hash).unwrap().is_some(), + "the legacy envelope must remain for the next unlock" + ); + } + /// TS-T2-02 — a Tier-2 seed re-asks on a wrong object password (upstream /// `WrongPassword` ⇒ re-prompt, not abort) and then succeeds. #[tokio::test] diff --git a/src/wallet_backend/single_key.rs b/src/wallet_backend/single_key.rs index 87742b78c..f57b5825d 100644 --- a/src/wallet_backend/single_key.rs +++ b/src/wallet_backend/single_key.rs @@ -825,7 +825,7 @@ pub fn open_secret_store(path: &std::path::Path) -> Result Date: Wed, 22 Jul 2026 16:57:15 +0000 Subject: [PATCH 08/20] refactor(wallet-backend): drop obsolete DetPersister Use upstream SqlitePersister for native viewing-key persistence. Drive delete_wallet after runtime detachment to trigger its verified cascade. Retain the removal integration test and drop redundant workaround coverage. Document why unreleased interim metadata rows are not migrated. Co-Authored-By: OpenAI Codex --- CHANGELOG.md | 4 + src/context/wallet_lifecycle/tests.rs | 161 ++------- src/wallet_backend/mod.rs | 56 +-- src/wallet_backend/persister.rs | 473 -------------------------- 4 files changed, 63 insertions(+), 631 deletions(-) delete mode 100644 src/wallet_backend/persister.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index d4046375c..485e238ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Wallets already migrated to Tier-2 storage by a July 2026 weekly build with a shorter password cannot be opened at this upstream tip; do not upgrade those profiles until upstream provides a compatibility reader. + Development builds between `f7ca95f` and `69b7546` stored shielded viewing + keys in an interim metadata row that this final pin does not migrate; those + commits were never released, and unlocking the wallet safely re-derives and + persists the same viewing key in upstream's native table. Transitively, the pinned dashpay git dependencies advance with it: `rust-dashcore` (`be6e776` → `0091c4a`, which lands the reserve-on-hand-out receive-address APIs), `grovedb` (`v5.0.0` → `v5.0.1`), and the `orchard` diff --git a/src/context/wallet_lifecycle/tests.rs b/src/context/wallet_lifecycle/tests.rs index ffd23f5fb..03701e77d 100644 --- a/src/context/wallet_lifecycle/tests.rs +++ b/src/context/wallet_lifecycle/tests.rs @@ -1691,13 +1691,9 @@ async fn remove_wallet_wipes_seed_envelope() { backend.shutdown().await; } -/// Removing a wallet reaps its persisted Orchard full-viewing-key row. +/// Removing a wallet drives upstream's native viewing-key cascade. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn remove_wallet_reaps_persisted_shielded_viewing_keys() { - use std::collections::BTreeMap; - - use platform_wallet_storage::{KvStore, ObjectId, SqlitePersister, SqlitePersisterConfig}; - let _guard = backend_reopen_lock().await; let source_dir = tempfile::tempdir().expect("source tempdir"); let seed = [0xA2u8; 64]; @@ -1723,34 +1719,35 @@ async fn remove_wallet_reaps_persisted_shielded_viewing_keys() { let wallet_id = backend .registered_wallet_id(&seed_hash) .expect("registered upstream wallet id"); - let stored: Option>> = backend - .kv() - .get( - crate::wallet_backend::DetScope::Wallet(&wallet_id), - "shielded:fvks:v1", + let persister_path = source_dir + .path() + .join("spv") + .join("testnet") + .join("platform-wallet.sqlite"); + let count_viewing_keys = || { + rusqlite::Connection::open_with_flags( + &persister_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, ) - .expect("read persisted viewing keys"); - assert!(stored.is_some(), "precondition: the FVK row must exist"); + .expect("open persister inspection connection") + .query_row( + "SELECT COUNT(*) FROM shielded_viewing_keys WHERE wallet_id = ?1", + [wallet_id.as_slice()], + |row| row.get::<_, i64>(0), + ) + .expect("count persisted viewing keys") + }; + assert_eq!(count_viewing_keys(), 1, "precondition: native FVK row"); ctx.remove_wallet(&seed_hash).expect("remove wallet"); let _ = ctx.subtasks.shutdown_async().await; - backend.shutdown().await; - let inspection_dir = tempfile::tempdir().expect("inspection tempdir"); - copy_dir_recursive(source_dir.path(), inspection_dir.path()); - let persister_path = inspection_dir - .path() - .join("spv") - .join("testnet") - .join("platform-wallet.sqlite"); - let persister = SqlitePersister::open(SqlitePersisterConfig::new(&persister_path)) - .expect("open post-removal persister"); - assert!( - KvStore::get(&persister, &ObjectId::Wallet(wallet_id), "shielded:fvks:v1") - .expect("read post-removal FVK row") - .is_none(), - "the FVK row must be deleted with the wallet" + assert_eq!( + count_viewing_keys(), + 0, + "upstream deletion must cascade to the native FVK row" ); + backend.shutdown().await; } /// The receive-address snapshot is empty until a bind publishes into it, so a @@ -1822,6 +1819,13 @@ async fn cache_shielded_receive_address_publishes_bound_account_zero_address() { .ensure_shielded_bound(&seed_hash, &seed) .await .expect("bind Orchard keys offline"); + assert!( + backend + .bind_shielded_from_persisted_for_test(&seed_hash) + .await + .expect("restore the viewing key through upstream persistence"), + "the seed-backed bind must persist a restorable viewing key" + ); ctx.cache_shielded_receive_address(&backend, &seed_hash) .await; @@ -3795,109 +3799,6 @@ async fn cold_boot_keeps_wallet_visible_when_persisted_transaction_txid_is_corru backend.shutdown().await; } -/// A corrupt FVK row is isolated to its wallet during a real seedless cold boot. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn cold_boot_skips_corrupt_fvks_for_one_wallet_and_restores_healthy_wallet() { - use platform_wallet_storage::{KvStore, ObjectId, SqlitePersister, SqlitePersisterConfig}; - - let _guard = backend_reopen_lock().await; - let source_dir = tempfile::tempdir().expect("source tempdir"); - let corrupt_seed = [0xE4u8; 64]; - let healthy_seed = [0xE5u8; 64]; - - let (corrupt_hash, corrupt_wallet_id, healthy_hash) = { - let corrupt_wallet = - crate::model::wallet::Wallet::new_from_seed(corrupt_seed, Network::Testnet, None, None) - .expect("build corrupt-row wallet"); - let corrupt_hash = corrupt_wallet.seed_hash(); - let healthy_wallet = - crate::model::wallet::Wallet::new_from_seed(healthy_seed, Network::Testnet, None, None) - .expect("build healthy wallet"); - let healthy_hash = healthy_wallet.seed_hash(); - let (ctx, sender) = offline_testnet_context_at(source_dir.path()); - - ctx.register_wallet(corrupt_wallet, &corrupt_seed, WalletOrigin::Fresh) - .expect("persist corrupt-row wallet sidecars"); - ctx.register_wallet(healthy_wallet, &healthy_seed, WalletOrigin::Fresh) - .expect("persist healthy wallet sidecars"); - ctx.ensure_wallet_backend(sender) - .await - .expect("wire boot-1 backend"); - let backend = ctx.wallet_backend().expect("boot-1 backend"); - - for (seed_hash, seed) in [ - (&corrupt_hash, &corrupt_seed), - (&healthy_hash, &healthy_seed), - ] { - backend - .register_wallet_from_seed(seed_hash, seed, Some(0)) - .await - .expect("persist upstream wallet"); - backend - .ensure_shielded_bound(seed_hash, seed) - .await - .expect("persist wallet viewing key"); - } - let corrupt_wallet_id = backend - .registered_wallet_id(&corrupt_hash) - .expect("corrupt-row upstream wallet id"); - - backend.shutdown().await; - let _ = ctx.subtasks.shutdown_async().await; - (corrupt_hash, corrupt_wallet_id, healthy_hash) - }; - - let cold_dir = tempfile::tempdir().expect("cold tempdir"); - copy_dir_recursive(source_dir.path(), cold_dir.path()); - let persister_path = cold_dir - .path() - .join("spv") - .join("testnet") - .join("platform-wallet.sqlite"); - let persister = SqlitePersister::open(SqlitePersisterConfig::new(&persister_path)) - .expect("open persister for corrupt FVK fixture"); - KvStore::put( - &persister, - &ObjectId::Wallet(corrupt_wallet_id), - "shielded:fvks:v1", - &[0], - ) - .expect("corrupt one wallet's FVK row"); - drop(persister); - - let (ctx, sender) = offline_testnet_context_at(cold_dir.path()); - ctx.ensure_wallet_backend(sender) - .await - .expect("one corrupt FVK row must not fail cold-boot rehydration"); - let backend = ctx.wallet_backend().expect("boot-2 backend"); - - assert!( - backend.is_wallet_registered(&corrupt_hash), - "the wallet with a corrupt FVK row must remain registered" - ); - assert!( - backend.is_wallet_registered(&healthy_hash), - "the healthy wallet must remain registered" - ); - assert!( - backend - .bind_shielded_from_persisted_for_test(&healthy_hash) - .await - .expect("load the healthy wallet's persisted viewing key"), - "the healthy wallet's viewing key must remain available" - ); - assert!( - backend - .shielded_default_address(&healthy_hash, 0) - .await - .expect("read rebound shielded address") - .is_some(), - "the healthy persisted viewing key must be installed on the wallet" - ); - - backend.shutdown().await; -} - /// Build a minimal basic identity for manager-reconcile tests — only its /// id() and (empty) public_keys() are read by `add_identity`. fn basic_test_identity() -> dash_sdk::dpp::identity::Identity { diff --git a/src/wallet_backend/mod.rs b/src/wallet_backend/mod.rs index 8bc366daf..fb3c06bb1 100644 --- a/src/wallet_backend/mod.rs +++ b/src/wallet_backend/mod.rs @@ -47,7 +47,6 @@ pub(crate) mod kv_test_support; pub(crate) mod leak_test_support; mod loader; mod payments; -mod persister; pub(crate) mod poison; pub mod secret_access; pub mod secret_prompt; @@ -160,8 +159,6 @@ use crate::model::wallet::meta::wallet_label; use crate::model::wallet::{PlatformAddressEntry, WalletSeedHash}; use crate::utils::egui_mpsc::SenderAsync; -use persister::DetPersister; - /// Which side of a contact relationship /// [`WalletBackend::record_contact_request`] writes into the local /// wallet-manager. Selects the upstream `add_*_contact_request` call and the @@ -319,11 +316,11 @@ impl RegistrationFlight { } struct Inner { - pwm: PlatformWalletManager, - /// Shared handle to the SQLite persister wrapped by `pwm`'s - /// [`DetPersister`]. Kept so [`DetKv`] can read/write app data alongside - /// wallet state without opening a second connection. - persister: Arc, + pwm: PlatformWalletManager, + /// Native persistence handle for operations the manager does not expose, + /// including DET metadata and durable deletion. At this pin, the manager's + /// `remove_wallet` only evicts runtime state. + wallet_persister: Arc, /// Display-only snapshot store (balance/tx/utxo), pushed by the /// `EventBridge`. See [`snapshot`]. DISPLAY-ONLY — never feeds coin /// selection (A04 fund-safety gate). @@ -458,12 +455,10 @@ impl WalletBackend { let persister_config = SqlitePersisterConfig::new(spv_storage_dir.join("platform-wallet.sqlite")); - let sqlite_persister = Arc::new( + let persister = Arc::new( SqlitePersister::open(persister_config) .map_err(TaskError::from_wallet_storage_open_error)?, ); - let persister = Arc::new(DetPersister::new(Arc::clone(&sqlite_persister))); - // Reuse the vault handle `AppContext` already opened at boot. The file // backend holds an exclusive advisory lock for the handle's lifetime, // so opening a second handle here would fail with `AlreadyLocked` — and @@ -519,7 +514,7 @@ impl WalletBackend { let backend = Self { inner: Arc::new(Inner { pwm, - persister: sqlite_persister, + wallet_persister: persister, snapshots, token_balances: Arc::new(TokenBalanceStore::new()), id_map: std::sync::RwLock::new(std::collections::BTreeMap::new()), @@ -1212,7 +1207,11 @@ impl WalletBackend { continue; } }; - match self.inner.persister.get_core_tx_record(*wallet_id, &txid) { + match self + .inner + .wallet_persister + .get_core_tx_record(*wallet_id, &txid) + { Ok(Some(record)) => records.push(record), Ok(None) => { record_persisted_transaction_skip( @@ -1251,8 +1250,7 @@ impl WalletBackend { /// Wipe every piece of DET-local state for a forgotten wallet — the /// encrypted seed-envelope vault, the session secret cache, the wallet-meta - /// sidecar, persisted shielded viewing keys, and the in-memory - /// `id_map`/`wallets`/snapshot registration. + /// sidecar, and the in-memory `id_map`/`wallets`/snapshot registration. /// (Orchard state lives in the upstream coordinator now and is detached by /// [`Self::remove_upstream_wallet`].) /// @@ -1325,13 +1323,6 @@ impl WalletBackend { // In-memory maps + snapshot registration. if let Some(wallet_id) = wallet_id { - if let Err(error) = persister::forget_wallet_viewing_keys(&self.kv(), wallet_id) { - tracing::warn!( - wallet_id = %hex::encode(wallet_id), - error = ?error, - "Failed to delete shielded viewing keys during wallet removal" - ); - } self.inner.id_map.write()?.remove(seed_hash); self.inner.wallets.write()?.remove(&wallet_id); self.inner.snapshots.forget_wallet(seed_hash, &wallet_id); @@ -1384,11 +1375,20 @@ impl WalletBackend { wallet_id: &WalletId, ) -> Result<(), TaskError> { use platform_wallet::error::PlatformWalletError; + use platform_wallet_storage::WalletStorageError; + match self.inner.pwm.remove_wallet(wallet_id).await { - Ok(_) | Err(PlatformWalletError::WalletNotFound(_)) => Ok(()), - Err(e) => Err(TaskError::WalletBackend { - source: Arc::new(e), - }), + Ok(_) | Err(PlatformWalletError::WalletNotFound(_)) => {} + Err(source) => { + return Err(TaskError::WalletBackend { + source: Arc::new(source), + }); + } + } + + match self.inner.wallet_persister.delete_wallet(*wallet_id) { + Ok(_) | Err(WalletStorageError::WalletNotFound { .. }) => Ok(()), + Err(source) => Err(TaskError::WalletStorage { source }), } } @@ -1702,7 +1702,7 @@ impl WalletBackend { /// schema of its own. See [`DetKv`] for namespacing conventions and /// the schema-version envelope. pub fn kv(&self) -> DetKv { - DetKv::new(Arc::clone(&self.inner.persister)) + DetKv::new(Arc::clone(&self.inner.wallet_persister)) } /// Persisted platform-address warm-start data, read straight from the @@ -1717,7 +1717,7 @@ impl WalletBackend { /// first coordinator push warms the UI once the network is reachable. pub(crate) fn persisted_platform_address_warm_start(&self) -> PlatformWarmStartSeed { use platform_wallet::changeset::PlatformWalletPersistence; - let start = match self.inner.persister.load() { + let start = match self.inner.wallet_persister.load() { Ok(start) => start, Err(e) => { tracing::debug!(error = ?e, "platform-address warm-start: persister load failed"); diff --git a/src/wallet_backend/persister.rs b/src/wallet_backend/persister.rs deleted file mode 100644 index 007eb38eb..000000000 --- a/src/wallet_backend/persister.rs +++ /dev/null @@ -1,473 +0,0 @@ -//! DET adapter for upstream wallet persistence capabilities. -//! -//! The upstream SQLite persister owns wallet state, while DET adds the one -//! host-specific persistence path it needs for seedless shielded restart: -//! wallet-scoped Orchard full-viewing keys stored in the same SQLite file's -//! metadata table. - -use std::collections::BTreeMap; -use std::sync::{Arc, Mutex}; - -use platform_wallet::changeset::{ - ClientStartState, PersistenceCapabilities, PersistenceError, PlatformWalletChangeSet, - PlatformWalletPersistence, ShieldedChangeSet, -}; -use platform_wallet::wallet::platform_wallet::WalletId; -use platform_wallet::wallet::shielded::SubwalletId; -use platform_wallet_storage::{KvError, SqlitePersister}; - -use super::{DetKv, DetScope, KvAdapterError}; - -const SHIELDED_FVK_KEY: &str = "shielded:fvks:v1"; - -/// Delete one wallet's persisted shielded full-viewing keys. -pub(super) fn forget_wallet_viewing_keys( - kv: &DetKv, - wallet_id: WalletId, -) -> Result<(), KvAdapterError> { - kv.delete(DetScope::Wallet(&wallet_id), SHIELDED_FVK_KEY) -} - -/// Upstream wallet persistence plus DET's wallet-scoped shielded FVK rows. -pub(super) struct DetPersister { - inner: Arc, - kv: DetKv, - shielded_fvk_lock: Mutex<()>, -} - -impl DetPersister { - pub(super) fn new(inner: Arc) -> Self { - Self { - kv: DetKv::new(Arc::clone(&inner)), - inner, - shielded_fvk_lock: Mutex::new(()), - } - } - - fn store_viewing_keys( - &self, - wallet_id: WalletId, - incoming: &BTreeMap>, - ) -> Result<(), PersistenceError> { - let _guard = self - .shielded_fvk_lock - .lock() - .map_err(|_| PersistenceError::LockPoisoned)?; - let mut stored: BTreeMap> = self - .kv - .get(DetScope::Wallet(&wallet_id), SHIELDED_FVK_KEY) - .map_err(PersistenceError::backend)? - .unwrap_or_default(); - - for (subwallet_id, fvk) in incoming { - if subwallet_id.wallet_id != wallet_id { - return Err(PersistenceError::backend(std::io::Error::other( - "shielded viewing-key wallet id does not match changeset wallet id", - ))); - } - stored.insert(subwallet_id.account_index, fvk.clone()); - } - - self.kv - .put(DetScope::Wallet(&wallet_id), SHIELDED_FVK_KEY, &stored) - .map_err(PersistenceError::backend) - } - - fn load_viewing_keys( - &self, - wallet_id: WalletId, - ) -> Result>, PersistenceError> { - let stored: BTreeMap> = - match self.kv.get(DetScope::Wallet(&wallet_id), SHIELDED_FVK_KEY) { - Ok(stored) => stored.unwrap_or_default(), - Err( - error @ (KvAdapterError::SchemaVersion { .. } - | KvAdapterError::Truncated - | KvAdapterError::Decode(_) - | KvAdapterError::Store(KvError::ValueTooLarge { .. })), - ) => { - tracing::warn!( - target: "wallet_backend::persister", - wallet_id = %hex::encode(wallet_id), - key = SHIELDED_FVK_KEY, - error = ?error, - "Skipping unreadable shielded viewing-key row", - ); - BTreeMap::new() - } - Err(error) => return Err(PersistenceError::backend(error)), - }; - Ok(stored - .into_iter() - .map(|(account, fvk)| (SubwalletId::new(wallet_id, account), fvk)) - .collect()) - } - - /// The FVK adapter owns only viewing-key deltas. Reject a mixed changeset - /// rather than splitting one logical commit across the typed SQLite tables - /// and the metadata table, which would violate `ATOMIC_CHANGESETS`. - fn is_viewing_key_only(changeset: &PlatformWalletChangeSet) -> bool { - let PlatformWalletChangeSet { - core, - identities, - identity_keys, - contacts, - platform_addresses, - asset_locks, - invitations, - token_balances, - dashpay_profiles, - dashpay_payments_overlay, - wallet_metadata, - account_registrations, - provider_key_account_registrations, - account_address_pools, - pending_contact_crypto_added, - pending_contact_crypto_cleared, - shielded, - } = changeset; - let Some(shielded) = shielded else { - return false; - }; - let ShieldedChangeSet { - notes_saved, - nullifiers_spent, - outgoing_notes, - synced_indices, - activity_entries, - viewing_keys, - } = shielded; - - core.is_none() - && identities.is_none() - && identity_keys.is_none() - && contacts.is_none() - && platform_addresses.is_none() - && asset_locks.is_none() - && invitations.is_none() - && token_balances.is_none() - && dashpay_profiles.is_none() - && dashpay_payments_overlay.is_none() - && wallet_metadata.is_none() - && account_registrations.is_empty() - && provider_key_account_registrations.is_empty() - && account_address_pools.is_empty() - && pending_contact_crypto_added.is_empty() - && pending_contact_crypto_cleared.is_empty() - && notes_saved.is_empty() - && nullifiers_spent.is_empty() - && outgoing_notes.is_empty() - && synced_indices.is_empty() - && activity_entries.is_empty() - && !viewing_keys.is_empty() - } -} - -impl PlatformWalletPersistence for DetPersister { - fn persistence_capabilities(&self) -> PersistenceCapabilities { - self.inner - .persistence_capabilities() - .union(PersistenceCapabilities::SHIELDED_VIEWING_KEYS) - } - - fn store( - &self, - wallet_id: WalletId, - changeset: PlatformWalletChangeSet, - ) -> Result<(), PersistenceError> { - if let Some(shielded) = changeset.shielded.as_ref() - && !shielded.viewing_keys.is_empty() - { - if !Self::is_viewing_key_only(&changeset) { - return Err(PersistenceError::backend(std::io::Error::other( - "shielded viewing keys must be persisted in a dedicated changeset", - ))); - } - self.store_viewing_keys(wallet_id, &shielded.viewing_keys)?; - return Ok(()); - } - self.inner.store(wallet_id, changeset) - } - - fn flush(&self, wallet_id: WalletId) -> Result<(), PersistenceError> { - self.inner.flush(wallet_id) - } - - fn load(&self) -> Result { - let mut start = self.inner.load()?; - for wallet_id in start.wallets.keys().copied() { - start - .shielded - .viewing_keys - .extend(self.load_viewing_keys(wallet_id)?); - } - Ok(start) - } - - fn delete_wallet(&self, wallet_id: WalletId) -> Result<(), PersistenceError> { - PlatformWalletPersistence::delete_wallet(self.inner.as_ref(), wallet_id) - } - - fn get_core_tx_record( - &self, - wallet_id: WalletId, - txid: &dash_sdk::dpp::dashcore::Txid, - ) -> Result< - Option, - PersistenceError, - > { - self.inner.get_core_tx_record(wallet_id, txid) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use platform_wallet::changeset::{ - PersistenceErrorKind, PlatformWalletChangeSet, ShieldedChangeSet, - }; - use platform_wallet::wallet::shielded::ShieldedNote; - use platform_wallet_storage::{KvStore, ObjectId, SqlitePersisterConfig}; - - struct FailingReadKv { - oversized: bool, - } - - impl KvStore for FailingReadKv { - fn get(&self, _scope: &ObjectId, _key: &str) -> Result>, KvError> { - if self.oversized { - Err(KvError::ValueTooLarge { found: 2, max: 1 }) - } else { - Err(KvError::LockPoisoned) - } - } - - fn put(&self, _scope: &ObjectId, _key: &str, _value: &[u8]) -> Result<(), KvError> { - Ok(()) - } - - fn delete(&self, _scope: &ObjectId, _key: &str) -> Result<(), KvError> { - Ok(()) - } - - fn list_keys( - &self, - _scope: &ObjectId, - _prefix: Option<&str>, - ) -> Result, KvError> { - Ok(Vec::new()) - } - } - - fn test_persister() -> (tempfile::TempDir, DetPersister) { - let dir = tempfile::tempdir().expect("persister tempdir"); - crate::app_dir::ensure_data_dir_exists(dir.path()) - .expect("restrict persister tempdir permissions"); - let inner = Arc::new( - SqlitePersister::open(SqlitePersisterConfig::new( - dir.path().join("platform-wallet.sqlite"), - )) - .expect("open test persister"), - ); - (dir, DetPersister::new(inner)) - } - - fn viewing_key_changeset( - wallet_id: WalletId, - account: u32, - fvk: Vec, - ) -> PlatformWalletChangeSet { - let mut shielded = ShieldedChangeSet::default(); - shielded - .viewing_keys - .insert(SubwalletId::new(wallet_id, account), fvk); - PlatformWalletChangeSet { - shielded: Some(shielded), - ..Default::default() - } - } - - #[test] - fn is_viewing_key_only_classifies_supported_shapes() { - let wallet_id = [0x11; 32]; - let subwallet_id = SubwalletId::new(wallet_id, 0); - - let viewing_only = viewing_key_changeset(wallet_id, 0, vec![0xA1; 96]); - - let mut viewing_and_synced = viewing_key_changeset(wallet_id, 0, vec![0xA2; 96]); - viewing_and_synced - .shielded - .as_mut() - .expect("shielded changeset") - .synced_indices - .insert(subwallet_id, 7); - - let empty = PlatformWalletChangeSet::default(); - - let mut synced_without_viewing = ShieldedChangeSet::default(); - synced_without_viewing - .synced_indices - .insert(subwallet_id, 8); - let synced_without_viewing = PlatformWalletChangeSet { - shielded: Some(synced_without_viewing), - ..Default::default() - }; - - for (case, changeset, expected) in [ - ("viewing keys only", viewing_only, true), - ("viewing keys plus another field", viewing_and_synced, false), - ("no shielded changeset", empty, false), - ( - "shielded delta without viewing keys", - synced_without_viewing, - false, - ), - ] { - assert_eq!( - DetPersister::is_viewing_key_only(&changeset), - expected, - "case: {case}" - ); - } - } - - #[test] - fn viewing_keys_round_trip_through_store() { - let (_dir, persister) = test_persister(); - let wallet_id = [0x21; 32]; - let subwallet_id = SubwalletId::new(wallet_id, 3); - let fvk = vec![0xB1; 96]; - - persister - .store( - wallet_id, - viewing_key_changeset(wallet_id, subwallet_id.account_index, fvk.clone()), - ) - .expect("store viewing key"); - - assert_eq!( - persister - .load_viewing_keys(wallet_id) - .expect("load viewing key"), - BTreeMap::from([(subwallet_id, fvk)]) - ); - } - - #[test] - fn delete_wallet_delegates_to_inner_persister() { - let (_dir, persister) = test_persister(); - let error = persister - .delete_wallet([0x25; 32]) - .expect_err("an unknown wallet must reach the inner persister"); - - assert!( - matches!(error, PersistenceError::Backend { .. }), - "the wrapper must not fall back to UnsupportedOperation: {error:?}" - ); - } - - #[test] - fn load_viewing_keys_skips_oversized_values_but_propagates_backend_failures() { - let wallet_id = [0x29; 32]; - let (_dir, mut persister) = test_persister(); - persister.kv = DetKv::from_store(Arc::new(FailingReadKv { oversized: true })); - assert!( - persister - .load_viewing_keys(wallet_id) - .expect("oversized row must degrade to absent") - .is_empty() - ); - - let (_dir, mut persister) = test_persister(); - persister.kv = DetKv::from_store(Arc::new(FailingReadKv { oversized: false })); - let error = persister - .load_viewing_keys(wallet_id) - .expect_err("backend failure must remain fatal"); - match error { - PersistenceError::Backend { kind, source } => { - assert_eq!(kind, PersistenceErrorKind::Fatal); - assert!(matches!( - source.downcast_ref::(), - Some(KvAdapterError::Store(KvError::LockPoisoned)) - )); - } - other => panic!("unexpected persistence error: {other:?}"), - } - } - - #[test] - fn mixed_viewing_key_and_note_changeset_is_rejected_without_partial_write() { - let (_dir, persister) = test_persister(); - let wallet_id = [0x31; 32]; - let subwallet_id = SubwalletId::new(wallet_id, 0); - let mut changeset = viewing_key_changeset(wallet_id, 0, vec![0xC1; 96]); - changeset - .shielded - .as_mut() - .expect("shielded changeset") - .notes_saved - .insert( - subwallet_id, - vec![ShieldedNote { - position: 1, - cmx: [0xC2; 32], - nullifier: [0xC3; 32], - block_height: 2, - is_spent: false, - value: 3, - note_data: vec![0xC4; 115], - }], - ); - - let error = persister - .store(wallet_id, changeset) - .expect_err("mixed changeset must be rejected"); - match error { - PersistenceError::Backend { kind, source } => { - assert_eq!(kind, PersistenceErrorKind::Fatal); - assert_eq!( - source.to_string(), - "shielded viewing keys must be persisted in a dedicated changeset" - ); - } - other => panic!("unexpected persistence error: {other:?}"), - } - assert!( - persister - .load_viewing_keys(wallet_id) - .expect("load after rejection") - .is_empty(), - "a rejected mixed changeset must not partially persist viewing keys" - ); - } - - #[test] - fn viewing_key_for_another_wallet_is_rejected_without_partial_write() { - let (_dir, persister) = test_persister(); - let wallet_id = [0x41; 32]; - let other_wallet_id = [0x42; 32]; - let changeset = viewing_key_changeset(other_wallet_id, 0, vec![0xD1; 96]); - - let error = persister - .store(wallet_id, changeset) - .expect_err("cross-wallet viewing key must be rejected"); - match error { - PersistenceError::Backend { kind, source } => { - assert_eq!(kind, PersistenceErrorKind::Fatal); - assert_eq!( - source.to_string(), - "shielded viewing-key wallet id does not match changeset wallet id" - ); - } - other => panic!("unexpected persistence error: {other:?}"), - } - assert!( - persister - .load_viewing_keys(wallet_id) - .expect("load after rejection") - .is_empty(), - "a rejected cross-wallet changeset must not persist viewing keys" - ); - } -} From 4b8e421fb8902a0cb606e93d63f2732d2607aec5 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:26:32 +0000 Subject: [PATCH 09/20] docs(wallet-backend): note upstream read-path floor blocks short-password Tier-2 seeds The new pin's password-length floor rejects a sub-8-character passphrase on read, not just write, mirroring the write-side check as a deliberate defense against a backend-write attacker planting a weakly sealed envelope. Mark the affected call site so it gets revisited once upstream adds a scoped migration-read capability. Co-Authored-By: Claude Sonnet 5 --- src/wallet_backend/secret_access.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/wallet_backend/secret_access.rs b/src/wallet_backend/secret_access.rs index ddc4f22d6..7a6c52b7f 100644 --- a/src/wallet_backend/secret_access.rs +++ b/src/wallet_backend/secret_access.rs @@ -830,6 +830,19 @@ impl SecretAccess { Ok(Plaintext::HdSeed(seed)) } // Tier-2 — unseal with this seed's own object password. + // + // TODO(v1.1): `get_protected` rejects any password below + // `platform_wallet_storage::secrets::MIN_PASSPHRASE_LEN` + // (8 chars) on READ, not just write (see + // `rs-platform-wallet-storage/src/secrets/wire/envelope.rs`, + // `unwrap_password_payload`, comment `(a0)` — intentional, + // mirrors the write floor so a backend-write attacker can't + // plant a weakly sealed envelope). A wallet already migrated + // to Tier-2 with a <8-char password by the July 17/21 weekly + // builds (see CHANGELOG "Compatibility blocker" entry) has no + // downstream escape hatch here and stays permanently + // unreadable until upstream adds a scoped migration-read + // capability. Revisit this call once that lands upstream. SecretScheme::Protected => { let pw = passphrase.ok_or(TaskError::HdPassphraseIncorrect)?; let seed = view From ecbb65987dc503c53a866ec657ad34ea9cb94d13 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:22:12 +0000 Subject: [PATCH 10/20] =?UTF-8?q?chore:=20bump=20dashpay/platform=20pin=20?= =?UTF-8?q?to=20PR=20#3968=20tip=20(e75f259=20=E2=86=92=204a1ba64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-bumps the four dashpay/platform git dependencies (dash-sdk, rs-sdk-trusted-context-provider, platform-wallet, platform-wallet-storage) from e75f259 to 4a1ba64, the current tip of platform PR #3968. Cargo.lock is regenerated scoped to the platform package set: 28 platform git packages moved to 4a1ba64 and 11 rust-dashcore packages moved from 0091c4a to 18c68d4. No crates.io registry versions, checksums, dependency edges, or unrelated sources moved. API-drift fixes required by the upstream jump: - DataContractUpdate waits for affected state instead of generic broadcast completion, while retaining the typed TaskError conversion and existing DriveProofError recovery path. - Contact receiving accounts use ManagedAccountOperations so newly added accounts invalidate stale compact-filter coverage. Existing contact keys remain a true no-op, and only real insertions bump monitor_revision. - The changelog records authoritative pure-SPV broadcast outcomes and the contact-account filter-coverage fix without claiming shielded identity creation support in DET. Verification ledger evidence (all exit 0): - cargo fmt --all — key 9ff5de5fa46203d0ebbf5580836bddf9 — /data/git-worktrees/home-ubuntu-git-dash-evo-tool-platform-pin-bump/.claudius-ledger/logs/20260723T111934-9ff5de5fa46203d0ebbf5580836bddf9-2.log - cargo test contact_account_registration_invalidates_filter_coverage_once --all-features — key 3cdb20d7ef39ab598af859ff3fcf6020 — 1 passed — /data/git-worktrees/home-ubuntu-git-dash-evo-tool-platform-pin-bump/.claudius-ledger/logs/20260723T111944-3cdb20d7ef39ab598af859ff3fcf6020-2.log - cargo test --lib --all-features wallet_backend — key b062125daa4c22c77c2199c8f74d74a2 — 330 passed — /data/git-worktrees/home-ubuntu-git-dash-evo-tool-platform-pin-bump/.claudius-ledger/logs/20260723T112028-b062125daa4c22c77c2199c8f74d74a2-2.log - cargo test --lib --all-features backend_task — key 2fa67207f0f111422cde6544d6a29bac — 411 passed — /data/git-worktrees/home-ubuntu-git-dash-evo-tool-platform-pin-bump/.claudius-ledger/logs/20260723T112049-2fa67207f0f111422cde6544d6a29bac-2.log - cargo clippy --lib --all-features -- -D warnings — key 4a6af394e0e2293a6da5a0e5f824af5c — /data/git-worktrees/home-ubuntu-git-dash-evo-tool-platform-pin-bump/.claudius-ledger/logs/20260723T112100-4a6af394e0e2293a6da5a0e5f824af5c-2.log - git diff --check — exit 0 Co-Authored-By: Codex gpt-5.6-sol --- CHANGELOG.md | 12 ++- Cargo.lock | 78 +++++++------- Cargo.toml | 8 +- src/backend_task/update_data_contract.rs | 5 +- src/wallet_backend/mod.rs | 132 ++++++++++++++++------- 5 files changed, 150 insertions(+), 85 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 485e238ac..9dae34566 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,7 +54,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Upstream wallet backend updated (`platform-wallet` / `platform-wallet-storage`)**: the `dashpay/platform` dependency is bumped to the PR #3968 tip - (`d18020f` → `e75f259`), which lands an embeddable SQLite persistence backend with + (`d18020f` → `4a1ba64`), which lands an embeddable SQLite persistence backend with *seedless rehydration*. The wallet manager now restores watch-only wallet state (accounts, balances, identities, platform addresses) from the on-disk store without the HD seed, re-deriving spend authority just-in-time from the seed only @@ -80,9 +80,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). keys in an interim metadata row that this final pin does not migrate; those commits were never released, and unlocking the wallet safely re-derives and persists the same viewing key in upstream's native table. + SPV broadcasts are now pure peer-to-peer and report success only after peer + echo, InstantSend, or confirmation; an ambiguous outcome keeps the input + reservation until sync or its TTL reconciles it. Adding a DashPay contact + receiving account also invalidates prior compact-filter coverage, preventing + an in-flight scan from certifying a wallet account set it did not scan. Transitively, the pinned dashpay git dependencies advance with it: - `rust-dashcore` (`be6e776` → `0091c4a`, which lands the reserve-on-hand-out - receive-address APIs), `grovedb` (`v5.0.0` → `v5.0.1`), and the `orchard` + `rust-dashcore` (`be6e776` → `18c68d4`, which lands the reserve-on-hand-out + receive-address APIs and SPV acceptance tracking), `grovedb` (`v5.0.0` → + `v5.0.1`), and the `orchard` shielded-crypto fork (`dashified-0.14.0` → `dashified-0.14.1`); no crates.io dependencies change. diff --git a/Cargo.lock b/Cargo.lock index 314f30e5b..8c5f66ac3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1877,7 +1877,7 @@ dependencies = [ [[package]] name = "dapi-grpc" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" +source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" dependencies = [ "dash-platform-macros", "futures-core", @@ -1979,7 +1979,7 @@ dependencies = [ [[package]] name = "dash-async" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" +source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" dependencies = [ "thiserror 2.0.18", "tokio", @@ -1989,7 +1989,7 @@ dependencies = [ [[package]] name = "dash-context-provider" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" +source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" dependencies = [ "dash-async", "dpp", @@ -2081,7 +2081,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=0091c4a93db446cb2f950b8f2aa1326a4ffc94c7#0091c4a93db446cb2f950b8f2aa1326a4ffc94c7" +source = "git+https://github.com/dashpay/rust-dashcore?rev=18c68d4c10ed349f11358e0a9f7c8c59eab8e098#18c68d4c10ed349f11358e0a9f7c8c59eab8e098" dependencies = [ "bincode 2.0.1", "bincode_derive", @@ -2092,7 +2092,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=0091c4a93db446cb2f950b8f2aa1326a4ffc94c7#0091c4a93db446cb2f950b8f2aa1326a4ffc94c7" +source = "git+https://github.com/dashpay/rust-dashcore?rev=18c68d4c10ed349f11358e0a9f7c8c59eab8e098#18c68d4c10ed349f11358e0a9f7c8c59eab8e098" dependencies = [ "dash-network", ] @@ -2100,7 +2100,7 @@ dependencies = [ [[package]] name = "dash-platform-macros" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" +source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" dependencies = [ "heck", "quote", @@ -2110,7 +2110,7 @@ dependencies = [ [[package]] name = "dash-sdk" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" +source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" dependencies = [ "arc-swap", "async-trait", @@ -2148,7 +2148,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=0091c4a93db446cb2f950b8f2aa1326a4ffc94c7#0091c4a93db446cb2f950b8f2aa1326a4ffc94c7" +source = "git+https://github.com/dashpay/rust-dashcore?rev=18c68d4c10ed349f11358e0a9f7c8c59eab8e098#18c68d4c10ed349f11358e0a9f7c8c59eab8e098" dependencies = [ "async-trait", "chrono", @@ -2177,7 +2177,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=0091c4a93db446cb2f950b8f2aa1326a4ffc94c7#0091c4a93db446cb2f950b8f2aa1326a4ffc94c7" +source = "git+https://github.com/dashpay/rust-dashcore?rev=18c68d4c10ed349f11358e0a9f7c8c59eab8e098#18c68d4c10ed349f11358e0a9f7c8c59eab8e098" dependencies = [ "anyhow", "base64-compat", @@ -2203,12 +2203,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=0091c4a93db446cb2f950b8f2aa1326a4ffc94c7#0091c4a93db446cb2f950b8f2aa1326a4ffc94c7" +source = "git+https://github.com/dashpay/rust-dashcore?rev=18c68d4c10ed349f11358e0a9f7c8c59eab8e098#18c68d4c10ed349f11358e0a9f7c8c59eab8e098" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=0091c4a93db446cb2f950b8f2aa1326a4ffc94c7#0091c4a93db446cb2f950b8f2aa1326a4ffc94c7" +source = "git+https://github.com/dashpay/rust-dashcore?rev=18c68d4c10ed349f11358e0a9f7c8c59eab8e098#18c68d4c10ed349f11358e0a9f7c8c59eab8e098" dependencies = [ "dashcore-rpc-json", "hex", @@ -2221,7 +2221,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=0091c4a93db446cb2f950b8f2aa1326a4ffc94c7#0091c4a93db446cb2f950b8f2aa1326a4ffc94c7" +source = "git+https://github.com/dashpay/rust-dashcore?rev=18c68d4c10ed349f11358e0a9f7c8c59eab8e098#18c68d4c10ed349f11358e0a9f7c8c59eab8e098" dependencies = [ "bincode 2.0.1", "dashcore", @@ -2236,7 +2236,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=0091c4a93db446cb2f950b8f2aa1326a4ffc94c7#0091c4a93db446cb2f950b8f2aa1326a4ffc94c7" +source = "git+https://github.com/dashpay/rust-dashcore?rev=18c68d4c10ed349f11358e0a9f7c8c59eab8e098#18c68d4c10ed349f11358e0a9f7c8c59eab8e098" dependencies = [ "bincode 2.0.1", "dashcore-private", @@ -2247,7 +2247,7 @@ dependencies = [ [[package]] name = "dashpay-contract" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" +source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" dependencies = [ "platform-value", "platform-version", @@ -2258,7 +2258,7 @@ dependencies = [ [[package]] name = "data-contracts" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" +source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" dependencies = [ "dashpay-contract", "document-history-contract", @@ -2527,7 +2527,7 @@ dependencies = [ [[package]] name = "document-history-contract" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" +source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" dependencies = [ "platform-value", "platform-version", @@ -2556,7 +2556,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "dpns-contract" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" +source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" dependencies = [ "platform-value", "platform-version", @@ -2567,7 +2567,7 @@ dependencies = [ [[package]] name = "dpp" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" +source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" dependencies = [ "anyhow", "async-trait", @@ -2617,7 +2617,7 @@ dependencies = [ [[package]] name = "dpp-json-convertible-derive" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" +source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" dependencies = [ "proc-macro2", "quote", @@ -2627,7 +2627,7 @@ dependencies = [ [[package]] name = "drive" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" +source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" dependencies = [ "bincode 2.0.1", "byteorder", @@ -2652,7 +2652,7 @@ dependencies = [ [[package]] name = "drive-proof-verifier" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" +source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" dependencies = [ "bincode 2.0.1", "dapi-grpc", @@ -3675,7 +3675,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=0091c4a93db446cb2f950b8f2aa1326a4ffc94c7#0091c4a93db446cb2f950b8f2aa1326a4ffc94c7" +source = "git+https://github.com/dashpay/rust-dashcore?rev=18c68d4c10ed349f11358e0a9f7c8c59eab8e098#18c68d4c10ed349f11358e0a9f7c8c59eab8e098" [[package]] name = "gl_generator" @@ -4999,7 +4999,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=0091c4a93db446cb2f950b8f2aa1326a4ffc94c7#0091c4a93db446cb2f950b8f2aa1326a4ffc94c7" +source = "git+https://github.com/dashpay/rust-dashcore?rev=18c68d4c10ed349f11358e0a9f7c8c59eab8e098#18c68d4c10ed349f11358e0a9f7c8c59eab8e098" dependencies = [ "async-trait", "base58ck", @@ -5022,7 +5022,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=0091c4a93db446cb2f950b8f2aa1326a4ffc94c7#0091c4a93db446cb2f950b8f2aa1326a4ffc94c7" +source = "git+https://github.com/dashpay/rust-dashcore?rev=18c68d4c10ed349f11358e0a9f7c8c59eab8e098#18c68d4c10ed349f11358e0a9f7c8c59eab8e098" dependencies = [ "async-trait", "dashcore", @@ -5046,7 +5046,7 @@ dependencies = [ [[package]] name = "keyword-search-contract" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" +source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" dependencies = [ "platform-value", "platform-version", @@ -5259,7 +5259,7 @@ dependencies = [ [[package]] name = "masternode-reward-shares-contract" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" +source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" dependencies = [ "platform-value", "platform-version", @@ -6471,7 +6471,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "platform-encryption" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" +source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" dependencies = [ "aes", "cbc", @@ -6484,7 +6484,7 @@ dependencies = [ [[package]] name = "platform-serialization" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" +source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" dependencies = [ "bincode 2.0.1", "platform-version", @@ -6493,7 +6493,7 @@ dependencies = [ [[package]] name = "platform-serialization-derive" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" +source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" dependencies = [ "proc-macro2", "quote", @@ -6504,7 +6504,7 @@ dependencies = [ [[package]] name = "platform-value" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" +source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" dependencies = [ "base64 0.22.1", "bincode 2.0.1", @@ -6524,7 +6524,7 @@ dependencies = [ [[package]] name = "platform-version" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" +source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" dependencies = [ "bincode 2.0.1", "grovedb-version 5.0.1", @@ -6535,7 +6535,7 @@ dependencies = [ [[package]] name = "platform-versioning" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" +source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" dependencies = [ "proc-macro2", "quote", @@ -6545,7 +6545,7 @@ dependencies = [ [[package]] name = "platform-wallet" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" +source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" dependencies = [ "arc-swap", "async-trait", @@ -6577,7 +6577,7 @@ dependencies = [ [[package]] name = "platform-wallet-storage" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" +source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" dependencies = [ "apple-native-keyring-store", "argon2", @@ -7574,7 +7574,7 @@ dependencies = [ [[package]] name = "rs-dapi-client" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" +source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" dependencies = [ "backon", "chrono", @@ -7600,7 +7600,7 @@ dependencies = [ [[package]] name = "rs-sdk-trusted-context-provider" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" +source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" dependencies = [ "arc-swap", "dash-async", @@ -8842,7 +8842,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "token-history-contract" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" +source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" dependencies = [ "platform-value", "platform-version", @@ -9687,7 +9687,7 @@ dependencies = [ [[package]] name = "wallet-utils-contract" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" +source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" dependencies = [ "platform-value", "platform-version", @@ -10943,7 +10943,7 @@ dependencies = [ [[package]] name = "withdrawals-contract" version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=e75f259fadeb24bb888d33f5b2ec1991c26dc200#e75f259fadeb24bb888d33f5b2ec1991c26dc200" +source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" dependencies = [ "num_enum 0.5.11", "platform-value", diff --git a/Cargo.toml b/Cargo.toml index 3618b4073..4fda511b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ qrcode = "0.14.1" nix = { version = "0.31.1", features = ["signal"] } eframe = { version = "0.35.0", features = ["persistence", "wgpu"] } base64 = "0.22.1" -dash-sdk = { git = "https://github.com/dashpay/platform", rev = "e75f259fadeb24bb888d33f5b2ec1991c26dc200", features = [ +dash-sdk = { git = "https://github.com/dashpay/platform", rev = "4a1ba64cc6c5cab387e9ac848c6bf5e497548d60", features = [ "core_key_wallet", "core_key_wallet_manager", "core_bincode", @@ -28,12 +28,12 @@ dash-sdk = { git = "https://github.com/dashpay/platform", rev = "e75f259fadeb24b "core_spv", "shielded", ] } -rs-sdk-trusted-context-provider = { git = "https://github.com/dashpay/platform", rev = "e75f259fadeb24bb888d33f5b2ec1991c26dc200" } -platform-wallet = { git = "https://github.com/dashpay/platform", rev = "e75f259fadeb24bb888d33f5b2ec1991c26dc200", features = [ +rs-sdk-trusted-context-provider = { git = "https://github.com/dashpay/platform", rev = "4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" } +platform-wallet = { git = "https://github.com/dashpay/platform", rev = "4a1ba64cc6c5cab387e9ac848c6bf5e497548d60", features = [ "serde", "shielded", ] } -platform-wallet-storage = { git = "https://github.com/dashpay/platform", rev = "e75f259fadeb24bb888d33f5b2ec1991c26dc200", features = [ +platform-wallet-storage = { git = "https://github.com/dashpay/platform", rev = "4a1ba64cc6c5cab387e9ac848c6bf5e497548d60", features = [ "shielded", ] } zip32 = "0.2.0" diff --git a/src/backend_task/update_data_contract.rs b/src/backend_task/update_data_contract.rs index f25ab869d..4d2655058 100644 --- a/src/backend_task/update_data_contract.rs +++ b/src/backend_task/update_data_contract.rs @@ -74,7 +74,10 @@ impl AppContext { .await .map_err(|e| TaskError::from(dash_sdk::Error::Protocol(e)))?; - match state_transition.broadcast_and_wait(sdk, None).await { + match state_transition + .broadcast_and_wait_for_affected_state(sdk, None) + .await + { Ok(returned_contract) => { self.replace_contract(data_contract.id(), &returned_contract)?; Ok(BackendTaskSuccessResult::UpdatedContract( diff --git a/src/wallet_backend/mod.rs b/src/wallet_backend/mod.rs index fb3c06bb1..298f71b5b 100644 --- a/src/wallet_backend/mod.rs +++ b/src/wallet_backend/mod.rs @@ -2125,18 +2125,16 @@ impl WalletBackend { /// Register every established contact's DIP-15 receiving account so the SPV /// layer watches the addresses each contact pays us at. /// - /// The receiving-account path `m/9'/coin'/15'/0'/owner/friend` is hardened, - /// so the upstream `IdentityWallet::register_contact_account` — which derives - /// from the live wallet — returns a watch-only error on the wallets DET - /// rehydrates at boot (they cannot do hardened derivation). This derives the - /// account xpub from a seed-built (signable) wallet instead and inserts the - /// managed `DashpayReceivingFunds` account directly: the contained - /// seed-bearing dual-insert exception, sibling to + /// The receiving-account path `m/9'/coin'/15'/0'/owner/friend` is hardened. + /// DET derives its xpub from a seed-built wallet inside the JIT seed scope, + /// then adds it to the rehydrated managed state through + /// `ManagedAccountOperations`: the contained seed-bearing derivation + /// exception, sibling to /// [`Self::provision_identity_funding_account`]. /// - /// Upstream keeps the managed account in runtime state only, so this re-runs - /// on every cold boot / unlock and is idempotent (the account-map insert - /// overwrites in place). Only **newly-added** accounts trigger a + /// DET rebuilds these monitored accounts from established contacts on every + /// cold boot / unlock and skips keys already in the account map. Only + /// **newly-added** accounts trigger a /// `bump_monitor_revision`: the `dash-spv` mempool sync manager (shared via /// one `Arc`) checks the aggregate revision on each 100ms tick and rebuilds /// the peer bloom filter when it changes. The tick only runs when the mempool @@ -2159,9 +2157,7 @@ impl WalletBackend { dash_sdk::platform::Identifier, )], ) -> Result { - use dash_sdk::dpp::key_wallet::Account; use dash_sdk::dpp::key_wallet::AccountType; - use dash_sdk::dpp::key_wallet::managed_account::ManagedCoreFundsAccount; use dash_sdk::dpp::key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; use dash_sdk::dpp::key_wallet::managed_account::managed_account_type::ManagedAccountType; if contacts.is_empty() { @@ -2193,13 +2189,7 @@ impl WalletBackend { } }; match seed_wallet.derive_extended_public_key(&path) { - Ok(account_xpub) => accounts.push(Account { - parent_wallet_id: Some(wallet_id), - account_type, - network, - account_xpub, - is_watch_only: false, - }), + Ok(account_xpub) => accounts.push((account_type, account_xpub)), Err(error) => { tracing::debug!(%error, "Skipping contact account: xpub derivation failed"); } @@ -2209,31 +2199,22 @@ impl WalletBackend { return Ok(0); } - // Insert under the manager write lock — purely synchronous, no await - // held. Track accounts that are genuinely new (map len growth) so we - // only bump monitor_revision — and thus trigger a bloom-filter rebuild - // — when the set actually changes, not on every idempotent re-run. + // Register under the manager write lock, invalidating prior filter + // coverage only for genuinely new accounts. let mut wm = wallet.wallet_manager().write().await; let info = wm .get_wallet_info_mut(&wallet_id) .ok_or(TaskError::WalletStateInconsistent)?; - let before = info.core_wallet.accounts.dashpay_receival_accounts.len(); - for account in &accounts { - let managed = ManagedCoreFundsAccount::from_account(account); - if let Err(error) = info - .core_wallet - .accounts - .insert_funds_bearing_account(managed) - { - tracing::debug!(%error, "Skipping contact account: managed insert failed"); + let mut newly_inserted = 0; + for (account_type, account_xpub) in accounts { + match add_contact_receiving_account(&mut info.core_wallet, account_type, account_xpub) { + Ok(true) => newly_inserted += 1, + Ok(false) => {} + Err(error) => { + tracing::debug!(%error, "Skipping contact account: managed insert failed"); + } } } - let newly_inserted = info - .core_wallet - .accounts - .dashpay_receival_accounts - .len() - .saturating_sub(before); // Only bump the monitor revision when new accounts were added: a // revision bump on every unlock would cause a spurious bloom-filter // rebuild on each boot even when the contact set is unchanged. @@ -2680,6 +2661,37 @@ impl WalletBackend { } } +fn add_contact_receiving_account( + info: &mut dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::ManagedWalletInfo, + account_type: dash_sdk::dpp::key_wallet::AccountType, + account_xpub: dash_sdk::dpp::key_wallet::bip32::ExtendedPubKey, +) -> Result { + use dash_sdk::dpp::key_wallet::account::account_collection::DashpayAccountKey; + use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::ManagedAccountOperations; + + let dash_sdk::dpp::key_wallet::AccountType::DashpayReceivingFunds { + index, + user_identity_id, + friend_identity_id, + } = account_type + else { + return Err(dash_sdk::dpp::key_wallet::error::Error::InvalidParameter( + "contact receiving account type required".to_owned(), + )); + }; + let key = DashpayAccountKey { + index, + user_identity_id, + friend_identity_id, + }; + if info.accounts.dashpay_receival_accounts.contains_key(&key) { + return Ok(false); + } + + info.add_managed_account_from_xpub(account_type, account_xpub)?; + Ok(true) +} + /// The BIP44 account-0 extended public key among `accounts`, or `None` when /// there is no BIP44 account-0. /// @@ -3024,6 +3036,50 @@ fn identity_op_error_kind(e: &platform_wallet::error::PlatformWalletError) -> Id mod tests { use super::*; + #[test] + fn contact_account_registration_invalidates_filter_coverage_once() { + use dash_sdk::dpp::key_wallet::account::AccountType; + use dash_sdk::dpp::key_wallet::wallet::Wallet as UpstreamWallet; + use dash_sdk::dpp::key_wallet::wallet::initialization::WalletAccountCreationOptions; + use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + + let network = Network::Testnet; + let wallet = UpstreamWallet::from_seed_bytes( + [0x5Au8; 64], + network, + WalletAccountCreationOptions::Default, + ) + .expect("build seeded wallet"); + let account_type = AccountType::DashpayReceivingFunds { + index: 0, + user_identity_id: [1u8; 32], + friend_identity_id: [2u8; 32], + }; + let path = account_type + .derivation_path(network) + .expect("derive contact account path"); + let account_xpub = wallet + .derive_extended_public_key(&path) + .expect("derive contact account xpub"); + let mut info = ManagedWalletInfo::new(network, wallet.wallet_id); + info.update_synced_height(500); + + assert!( + add_contact_receiving_account(&mut info, account_type, account_xpub) + .expect("register new contact account") + ); + assert_eq!(info.account_generation(), 1); + assert_eq!(info.synced_height(), 0); + + assert!( + !add_contact_receiving_account(&mut info, account_type, account_xpub) + .expect("re-register contact account") + ); + assert_eq!(info.account_generation(), 1); + assert_eq!(info.synced_height(), 0); + } + #[cfg(unix)] #[test] fn spv_storage_directory_ancestors_are_owner_only() { From e7b995e50815da9ecd883770c25d49515e5bd512 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:53:49 +0000 Subject: [PATCH 11/20] test(wallet-backend): cover contact monitor revision gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the post-derivation contact-account registration loop into the smallest production seam that can be exercised without wallet persistence, SPV, or network infrastructure. The regression now proves that a first contact registration increments the aggregate monitor revision, a duplicate registration leaves it unchanged, and a mixed existing/new batch increments it exactly once. It retains the account-generation and filter-coverage invalidation assertions from the original lower-level test. Document the DashpayReceivingFunds-only helper contract and its boolean result. Keep the defensive variant check for future callers, and retain the contact-key pre-check because upstream reports duplicate managed account types as InvalidParameter instead of providing idempotent no-op semantics. Verification ledger evidence (all exit 0): - cargo fmt --all — key 7e9402392e7b60075b2c66376240c2b1 — /data/git-worktrees/home-ubuntu-git-dash-evo-tool-platform-pin-bump/.claudius-ledger/logs/20260723T115050-7e9402392e7b60075b2c66376240c2b1-2.log - cargo test contact_registration_bumps_monitor_revision_only_for_new_keys --all-features — key 979201f10ee2e2db3aba25ed91ba11d6 — 1 passed — /data/git-worktrees/home-ubuntu-git-dash-evo-tool-platform-pin-bump/.claudius-ledger/logs/20260723T115104-979201f10ee2e2db3aba25ed91ba11d6-2.log - cargo test --lib --all-features wallet_backend — key c35703b8723b6de9ce7b907df35e85e9 — 330 passed — /data/git-worktrees/home-ubuntu-git-dash-evo-tool-platform-pin-bump/.claudius-ledger/logs/20260723T115206-c35703b8723b6de9ce7b907df35e85e9-2.log - cargo clippy --lib --all-features -- -D warnings — key 7cf2092cee6223ee239e8c37f9e4a35b — /data/git-worktrees/home-ubuntu-git-dash-evo-tool-platform-pin-bump/.claudius-ledger/logs/20260723T115214-7cf2092cee6223ee239e8c37f9e4a35b-2.log - git diff --check — exit 0 Co-Authored-By: Codex gpt-5.6-sol --- src/wallet_backend/mod.rs | 136 ++++++++++++++++++++++++++------------ 1 file changed, 94 insertions(+), 42 deletions(-) diff --git a/src/wallet_backend/mod.rs b/src/wallet_backend/mod.rs index 298f71b5b..6a1dd7271 100644 --- a/src/wallet_backend/mod.rs +++ b/src/wallet_backend/mod.rs @@ -2158,8 +2158,6 @@ impl WalletBackend { )], ) -> Result { use dash_sdk::dpp::key_wallet::AccountType; - use dash_sdk::dpp::key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; - use dash_sdk::dpp::key_wallet::managed_account::managed_account_type::ManagedAccountType; if contacts.is_empty() { return Ok(0); } @@ -2205,29 +2203,8 @@ impl WalletBackend { let info = wm .get_wallet_info_mut(&wallet_id) .ok_or(TaskError::WalletStateInconsistent)?; - let mut newly_inserted = 0; - for (account_type, account_xpub) in accounts { - match add_contact_receiving_account(&mut info.core_wallet, account_type, account_xpub) { - Ok(true) => newly_inserted += 1, - Ok(false) => {} - Err(error) => { - tracing::debug!(%error, "Skipping contact account: managed insert failed"); - } - } - } - // Only bump the monitor revision when new accounts were added: a - // revision bump on every unlock would cause a spurious bloom-filter - // rebuild on each boot even when the contact set is unchanged. - if newly_inserted > 0 { - for account in info.core_wallet.accounts.all_funding_accounts_mut() { - if matches!( - account.managed_account_type(), - ManagedAccountType::DashpayReceivingFunds { .. } - ) { - account.bump_monitor_revision(); - } - } - } + let newly_inserted = + register_contact_accounts_in_managed_wallet(&mut info.core_wallet, accounts); Ok(newly_inserted) } @@ -2661,6 +2638,46 @@ impl WalletBackend { } } +/// Registers contact accounts and bumps their aggregate monitor revision for real insertions. +fn register_contact_accounts_in_managed_wallet( + info: &mut dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::ManagedWalletInfo, + accounts: impl IntoIterator< + Item = ( + dash_sdk::dpp::key_wallet::AccountType, + dash_sdk::dpp::key_wallet::bip32::ExtendedPubKey, + ), + >, +) -> usize { + use dash_sdk::dpp::key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use dash_sdk::dpp::key_wallet::managed_account::managed_account_type::ManagedAccountType; + + let mut newly_inserted = 0; + for (account_type, account_xpub) in accounts { + match add_contact_receiving_account(info, account_type, account_xpub) { + Ok(true) => newly_inserted += 1, + Ok(false) => {} + Err(error) => { + tracing::debug!(%error, "Skipping contact account: managed insert failed"); + } + } + } + if newly_inserted > 0 { + for account in info.accounts.all_funding_accounts_mut() { + if matches!( + account.managed_account_type(), + ManagedAccountType::DashpayReceivingFunds { .. } + ) { + account.bump_monitor_revision(); + } + } + } + newly_inserted +} + +/// Adds one `DashpayReceivingFunds` account to managed state. +/// +/// Returns `true` for a new insertion and `false` when the contact key already exists. +/// Any other account variant returns `InvalidParameter`. fn add_contact_receiving_account( info: &mut dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::ManagedWalletInfo, account_type: dash_sdk::dpp::key_wallet::AccountType, @@ -2675,6 +2692,7 @@ fn add_contact_receiving_account( friend_identity_id, } = account_type else { + // Validate at this seam so future callers cannot register a non-contact account. return Err(dash_sdk::dpp::key_wallet::error::Error::InvalidParameter( "contact receiving account type required".to_owned(), )); @@ -2684,6 +2702,7 @@ fn add_contact_receiving_account( user_identity_id, friend_identity_id, }; + // Convert upstream's duplicate InvalidParameter into DET's idempotent false result. if info.accounts.dashpay_receival_accounts.contains_key(&key) { return Ok(false); } @@ -3036,9 +3055,12 @@ fn identity_op_error_kind(e: &platform_wallet::error::PlatformWalletError) -> Id mod tests { use super::*; + /// Covers the managed-state seam after contact xpub derivation, including revision gating. + /// Wallet resolution and manager-lock wiring still require backend-e2e infrastructure. #[test] - fn contact_account_registration_invalidates_filter_coverage_once() { + fn contact_registration_bumps_monitor_revision_only_for_new_keys() { use dash_sdk::dpp::key_wallet::account::AccountType; + use dash_sdk::dpp::key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; use dash_sdk::dpp::key_wallet::wallet::Wallet as UpstreamWallet; use dash_sdk::dpp::key_wallet::wallet::initialization::WalletAccountCreationOptions; use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; @@ -3051,33 +3073,63 @@ mod tests { WalletAccountCreationOptions::Default, ) .expect("build seeded wallet"); - let account_type = AccountType::DashpayReceivingFunds { - index: 0, - user_identity_id: [1u8; 32], - friend_identity_id: [2u8; 32], + let contact_account = |friend_identity_id| { + let account_type = AccountType::DashpayReceivingFunds { + index: 0, + user_identity_id: [1u8; 32], + friend_identity_id, + }; + let path = account_type + .derivation_path(network) + .expect("derive contact account path"); + let account_xpub = wallet + .derive_extended_public_key(&path) + .expect("derive contact account xpub"); + (account_type, account_xpub) + }; + let first_contact = contact_account([2u8; 32]); + let second_contact = contact_account([3u8; 32]); + let max_monitor_revision = |info: &ManagedWalletInfo| { + info.accounts + .dashpay_receival_accounts + .values() + .map(ManagedAccountTrait::monitor_revision) + .max() + .unwrap_or_default() }; - let path = account_type - .derivation_path(network) - .expect("derive contact account path"); - let account_xpub = wallet - .derive_extended_public_key(&path) - .expect("derive contact account xpub"); let mut info = ManagedWalletInfo::new(network, wallet.wallet_id); info.update_synced_height(500); - assert!( - add_contact_receiving_account(&mut info, account_type, account_xpub) - .expect("register new contact account") + assert_eq!( + register_contact_accounts_in_managed_wallet(&mut info, [first_contact]), + 1 ); assert_eq!(info.account_generation(), 1); assert_eq!(info.synced_height(), 0); + assert_eq!(max_monitor_revision(&info), 1); - assert!( - !add_contact_receiving_account(&mut info, account_type, account_xpub) - .expect("re-register contact account") + assert_eq!( + register_contact_accounts_in_managed_wallet(&mut info, [first_contact]), + 0 ); assert_eq!(info.account_generation(), 1); assert_eq!(info.synced_height(), 0); + assert_eq!(max_monitor_revision(&info), 1); + + assert_eq!( + register_contact_accounts_in_managed_wallet(&mut info, [first_contact, second_contact]), + 1 + ); + assert_eq!(info.account_generation(), 2); + assert_eq!(max_monitor_revision(&info), 2); + let mut revisions = info + .accounts + .dashpay_receival_accounts + .values() + .map(ManagedAccountTrait::monitor_revision) + .collect::>(); + revisions.sort_unstable(); + assert_eq!(revisions, [1, 2]); } #[cfg(unix)] From 7566c7745b6cf2ade0cbee8023cd8990db92440b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:12:16 +0000 Subject: [PATCH 12/20] fix(wallet-backend): address grumpy-review findings from PR #919 - cover corrupt FVK cold boot and surface asynchronous removal failures (PROJ-001, CALL-001) - align password validation, documentation, and actionable errors (PROJ-003, PROJ-004, CODE-001) - narrow lazy Tier-2 re-wrap handling and fix shielded invariant mapping (CODE-003, CODE-004) - document protected headless shielded spends and harden data-directory creation (CALL-003, SEC-004/CALL-002) Co-Authored-By: Codex GPT-5 --- docs/CLI.md | 23 ++- docs/MCP.md | 17 ++ docs/user-stories.md | 2 + src/app_dir.rs | 108 ++++++++++- src/backend_task/error.rs | 10 +- src/context/wallet_lifecycle/removal.rs | 31 ++- src/context/wallet_lifecycle/tests.rs | 242 ++++++++++++++++++++++++ src/model/wallet/mod.rs | 28 ++- src/wallet_backend/secret_access.rs | 53 +++++- src/wallet_backend/shielded.rs | 37 ++-- 10 files changed, 507 insertions(+), 44 deletions(-) diff --git a/docs/CLI.md b/docs/CLI.md index 04293b302..c432e0270 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -52,6 +52,23 @@ det-cli --addr http://127.0.0.1:9000/mcp core-wallets-list Force standalone mode with `--standalone` even when an API key is present. +## Protected wallets and shielded spends + +Shielded transfer, unshield, and withdraw operations resolve the wallet seed +just in time for each operation. A password-protected (Tier-2) wallet therefore +requires an interactive passphrase session. + +Standalone `det-cli`, its stdio MCP server, and the headless HTTP server use a +null secret prompt because no authorized GUI session is available. These modes +return `SecretPromptUnavailable` when a protected wallet needs to authorize a +shielded spend. There is currently no environment-variable or CLI-passphrase +workaround, so unattended shielded spending from a protected wallet is not +supported. Use an unprotected wallet for that automation. + +This limitation does not apply in the same way when `det-cli` connects to MCP +embedded in a running GUI. The embedded server can use the GUI's existing +interactive prompt and authorized secret session. + ## Usage Commands use hyphens (`core-wallets-list`, not `core_wallets_list`). Parameters are passed as `key=value` pairs: @@ -185,8 +202,10 @@ det-cli masternode-credits-withdraw \ ## Shielded self-verification loop (testnet) The shielded read/control tools let an agent drive and verify a full shielded -lifecycle headlessly — no GUI. Onboard a pre-funded testnet seed, prepare the -wallet, then move funds and confirm each balance change with `shielded-sync`. +lifecycle headlessly — no GUI. This loop intentionally imports an unprotected +wallet because standalone `det-cli` cannot prompt for a protected wallet's +passphrase. Onboard a pre-funded testnet seed, prepare the wallet, then move +funds and confirm each balance change with `shielded-sync`. ```bash # 1. Import the funded testnet seed (returns its seed_hash; idempotent) diff --git a/docs/MCP.md b/docs/MCP.md index 21341721d..8dbebb5ae 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -54,6 +54,23 @@ Standalone/headless mode skips the GUI event loop and egui rendering, resulting The GUI-embedded HTTP mode shares the app's `AppContext` and follows network switches in real time, which is useful when you want to interactively switch networks from the GUI while MCP clients remain connected. +## Protected wallets and shielded spends + +Shielded transfer, unshield, and withdraw tools resolve the wallet seed just in +time for each operation. A password-protected (Tier-2) wallet therefore +requires an interactive passphrase session. + +Standalone stdio MCP and headless HTTP MCP use a null secret prompt because +they have no authorized GUI session. These transports return +`SecretPromptUnavailable` when a protected wallet needs to authorize a +shielded spend. There is currently no environment-variable or CLI-passphrase +workaround, so unattended shielded spending from a protected wallet is not +supported. Use an unprotected wallet for that automation. + +MCP embedded in a running GUI is not affected in the same way: it can use the +GUI's existing interactive prompt and authorized secret session. This caveat +applies only to standalone and headless transports. + ## Environment variables | Variable | Default | Description | diff --git a/docs/user-stories.md b/docs/user-stories.md index f7bff300c..c255988fc 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -33,6 +33,7 @@ As a user, I want to create a new wallet with a generated mnemonic so that I can - Mnemonic is generated using mouse entropy for randomness. - User can select mnemonic language and wallet name. - Optional password protection is offered. +- An optional wallet password must be at least 8 UTF-8 bytes after trimming surrounding whitespace. - Recovery phrase is displayed for backup. ### WAL-002: Import wallet via mnemonic [Implemented] @@ -42,6 +43,7 @@ As a user, I want to import an existing wallet by entering its seed phrase so th - Accepts standard BIP39 mnemonic phrases. - User can assign a name and optional password. +- An optional wallet password must be at least 8 UTF-8 bytes after trimming surrounding whitespace. - Wallet syncs balances after import. ### WAL-003: Import single private key [Implemented] diff --git a/src/app_dir.rs b/src/app_dir.rs index 4952751c9..414ef778d 100644 --- a/src/app_dir.rs +++ b/src/app_dir.rs @@ -78,15 +78,50 @@ pub fn create_app_user_data_directory_if_not_exists() -> Result<(), std::io::Err /// Creates the given data directory if it does not exist and verifies it is a directory. pub fn ensure_data_dir_exists(data_dir: &Path) -> Result<(), std::io::Error> { - fs::create_dir_all(data_dir)?; - let metadata = fs::metadata(data_dir)?; - if !metadata.is_dir() { - return Err(std::io::Error::other("Created path is not a directory")); - } #[cfg(unix)] { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(data_dir, fs::Permissions::from_mode(0o700))?; + use std::os::unix::fs::{DirBuilderExt, PermissionsExt}; + + match fs::symlink_metadata(data_dir) { + Ok(metadata) => { + if metadata.file_type().is_symlink() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Data directory path must not be a symbolic link", + )); + } + if !metadata.is_dir() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Data directory path is not a directory", + )); + } + // Accepted narrow residual: a local process with precise timing can swap this path before chmod; closing it requires descriptor-relative plumbing. + fs::set_permissions(data_dir, fs::Permissions::from_mode(0o700))?; + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + let mut builder = fs::DirBuilder::new(); + builder.recursive(true).mode(0o700); + builder.create(data_dir)?; + } + Err(error) => return Err(error), + } + + let metadata = fs::symlink_metadata(data_dir)?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Created data path is not a real directory", + )); + } + } + #[cfg(not(unix))] + { + fs::create_dir_all(data_dir)?; + let metadata = fs::metadata(data_dir)?; + if !metadata.is_dir() { + return Err(std::io::Error::other("Created path is not a directory")); + } } Ok(()) } @@ -172,4 +207,63 @@ mod tests { .mode(); assert_eq!(mode & 0o077, 0, "data dir must be owner-only"); } + + #[test] + fn ensure_data_dir_exists_creates_nested_directories_owner_only() { + let parent = tempfile::tempdir().expect("temp parent"); + let first = parent.path().join("first"); + let nested = first.join("second").join("data"); + + ensure_data_dir_exists(&nested).expect("create secure nested data dir"); + + for directory in [first, parent.path().join("first/second"), nested] { + let mode = std::fs::symlink_metadata(&directory) + .expect("read nested directory metadata") + .permissions() + .mode(); + assert_eq!( + mode & 0o077, + 0, + "{} must be owner-only", + directory.display() + ); + } + } + + #[test] + fn ensure_data_dir_exists_rejects_symlink_without_changing_target_permissions() { + use std::os::unix::fs::symlink; + + let parent = tempfile::tempdir().expect("temp parent"); + let target = parent.path().join("target"); + let data_dir = parent.path().join("data"); + std::fs::create_dir(&target).expect("create symlink target"); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)) + .expect("set target permissions"); + symlink(&target, &data_dir).expect("create data-dir symlink"); + + let error = ensure_data_dir_exists(&data_dir).expect_err("symlink must be rejected"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + let target_mode = std::fs::metadata(&target) + .expect("read target metadata") + .permissions() + .mode(); + assert_eq!( + target_mode & 0o777, + 0o755, + "rejecting a symlink must not chmod its target" + ); + } + + #[test] + fn ensure_data_dir_exists_rejects_existing_regular_file() { + let parent = tempfile::tempdir().expect("temp parent"); + let data_dir = parent.path().join("data"); + std::fs::write(&data_dir, b"not a directory").expect("create regular file"); + + let error = ensure_data_dir_exists(&data_dir).expect_err("regular file must be rejected"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + } } diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index a7da75126..6d2eecf51 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -1922,7 +1922,7 @@ pub enum TaskError { /// A new wallet password is shorter than the persistent secret store's /// minimum and therefore could not be migrated to Tier-2 protection. - #[error("Wallet passwords must be at least {min} characters after trimming.")] + #[error("Wallet passwords must be at least {min} characters. Pick a longer one and try again.")] WalletPasswordTooShort { min: u32 }, /// Wallet key derivation failed during construction. @@ -3183,6 +3183,14 @@ mod tests { } } + #[test] + fn wallet_password_too_short_display_matches_model_guidance() { + assert_eq!( + TaskError::WalletPasswordTooShort { min: 8 }.to_string(), + "Wallet passwords must be at least 8 characters. Pick a longer one and try again." + ); + } + #[test] fn dapi_availability_context_keeps_connection_refused_when_an_address_is_live() { let contextualized = diff --git a/src/context/wallet_lifecycle/removal.rs b/src/context/wallet_lifecycle/removal.rs index b6c9e5625..956e2c464 100644 --- a/src/context/wallet_lifecycle/removal.rs +++ b/src/context/wallet_lifecycle/removal.rs @@ -1,6 +1,16 @@ //! Wallet removal: evicting a wallet and wiping its at-rest secrets. use super::*; +use crate::ui::MessageType; +use crate::ui::components::MessageBanner; + +const WALLET_DATA_REMOVAL_WARNING: &str = "Some wallet data could not be deleted and may remain on this device. Open Network settings and clear this network's database to remove it."; + +fn show_wallet_data_removal_warning(ctx: &egui::Context, error: TaskError) { + let handle = MessageBanner::set_global(ctx, WALLET_DATA_REMOVAL_WARNING, MessageType::Warning); + handle.with_details(error); + handle.disable_auto_dismiss(); +} impl AppContext { pub fn remove_wallet(self: &Arc, seed_hash: &WalletSeedHash) -> Result<(), TaskError> { @@ -52,13 +62,22 @@ impl AppContext { // sole async step; it carries no secret, so drive it off-thread. if let Some(wallet_id) = upstream_id { let backend = Arc::clone(&backend); - let _ = self + let egui_ctx = self.egui_ctx().clone(); + let removal = async move { + if let Err(error) = backend.remove_upstream_wallet(&wallet_id).await { + show_wallet_data_removal_warning(&egui_ctx, error); + } + }; + if self .subtasks - .spawn_sync("wallet_upstream_removal", async move { - if let Err(error) = backend.remove_upstream_wallet(&wallet_id).await { - tracing::warn!(%error, "Upstream wallet removal failed"); - } - }); + .spawn_sync("wallet_upstream_removal", removal) + .is_err() + { + show_wallet_data_removal_warning( + self.egui_ctx(), + TaskError::TaskManagerShuttingDown, + ); + } } } diff --git a/src/context/wallet_lifecycle/tests.rs b/src/context/wallet_lifecycle/tests.rs index 03701e77d..90b4b657c 100644 --- a/src/context/wallet_lifecycle/tests.rs +++ b/src/context/wallet_lifecycle/tests.rs @@ -1750,6 +1750,75 @@ async fn remove_wallet_reaps_persisted_shielded_viewing_keys() { backend.shutdown().await; } +/// A failed upstream delete must keep the native FVK row and warn the user +/// that wallet data may remain on disk. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remove_wallet_warns_when_persisted_shielded_viewing_key_delete_fails() { + let _guard = backend_reopen_lock().await; + let source_dir = tempfile::tempdir().expect("source tempdir"); + let seed = [0xA3u8; 64]; + let wallet = crate::model::wallet::Wallet::new_from_seed(seed, Network::Testnet, None, None) + .expect("build wallet"); + let seed_hash = wallet.seed_hash(); + let (ctx, sender) = offline_testnet_context_at(source_dir.path()); + + ctx.register_wallet(wallet, &seed, WalletOrigin::Fresh) + .expect("register wallet"); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend"); + let backend = ctx.wallet_backend().expect("backend wired"); + backend + .register_wallet_from_seed(&seed_hash, &seed, Some(0)) + .await + .expect("persist upstream wallet"); + backend + .ensure_shielded_bound(&seed_hash, &seed) + .await + .expect("persist wallet viewing key"); + let wallet_id = backend + .registered_wallet_id(&seed_hash) + .expect("registered upstream wallet id"); + let persister_path = source_dir + .path() + .join("spv") + .join("testnet") + .join("platform-wallet.sqlite"); + let connection = + rusqlite::Connection::open(&persister_path).expect("open persister fault injector"); + connection + .execute_batch( + "CREATE TRIGGER fail_wallet_delete \ + BEFORE DELETE ON wallets \ + BEGIN \ + SELECT RAISE(ABORT, 'forced wallet delete failure'); \ + END;", + ) + .expect("install wallet-delete failure trigger"); + crate::ui::components::MessageBanner::clear_all_global(ctx.egui_ctx()); + + ctx.remove_wallet(&seed_hash).expect("remove DET wallet"); + let _ = ctx.subtasks.shutdown_async().await; + + let viewing_key_count = connection + .query_row( + "SELECT COUNT(*) FROM shielded_viewing_keys WHERE wallet_id = ?1", + [wallet_id.as_slice()], + |row| row.get::<_, i64>(0), + ) + .expect("count retained viewing keys"); + assert_eq!( + viewing_key_count, 1, + "the failed wallet delete must leave its cascaded FVK row in place" + ); + assert!( + crate::ui::components::MessageBanner::has_global(ctx.egui_ctx()), + "the asynchronous deletion failure must raise a user-visible warning" + ); + + backend.shutdown().await; +} + /// The receive-address snapshot is empty until a bind publishes into it, so a /// wallet that is locked or not yet bound reports `None` and the Shielded tab /// falls back to its "not ready yet" copy instead of rendering a wrong address. @@ -3654,6 +3723,179 @@ async fn ensure_identity_funding_accounts_succeeds_on_cold_booted_watch_only_wal backend2.shutdown().await; } +/// A malformed Orchard viewing key must be isolated to its own wallet during +/// the real seedless cold-boot load. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cold_boot_skips_corrupt_fvk_for_one_wallet_and_restores_healthy_wallet() { + let _guard = backend_reopen_lock().await; + let source_dir = tempfile::tempdir().expect("source tempdir"); + let corrupt_seed = [0xD5u8; 64]; + let healthy_seed = [0xD6u8; 64]; + + let (corrupt_hash, corrupt_wallet_id, healthy_hash, healthy_wallet_id) = { + let password = Secret::new("cold-boot-fvk-password"); + let corrupt_wallet = crate::model::wallet::Wallet::new_from_seed( + corrupt_seed, + Network::Testnet, + Some("corrupt-fvk".to_string()), + Some(&password), + ) + .expect("build corrupt-fixture wallet"); + let corrupt_hash = corrupt_wallet.seed_hash(); + let healthy_wallet = crate::model::wallet::Wallet::new_from_seed( + healthy_seed, + Network::Testnet, + Some("healthy-fvk".to_string()), + Some(&password), + ) + .expect("build healthy wallet"); + let healthy_hash = healthy_wallet.seed_hash(); + let (ctx, sender) = offline_testnet_context_at(source_dir.path()); + + ctx.register_wallet(corrupt_wallet, &corrupt_seed, WalletOrigin::Fresh) + .expect("register corrupt-fixture DET wallet"); + ctx.register_wallet(healthy_wallet, &healthy_seed, WalletOrigin::Fresh) + .expect("register healthy DET wallet"); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire boot-1 backend"); + let backend = ctx.wallet_backend().expect("boot-1 backend"); + + backend + .register_wallet_from_seed(&corrupt_hash, &corrupt_seed, Some(0)) + .await + .expect("persist corrupt-fixture upstream wallet"); + backend + .register_wallet_from_seed(&healthy_hash, &healthy_seed, Some(0)) + .await + .expect("persist healthy upstream wallet"); + backend + .ensure_shielded_bound(&corrupt_hash, &corrupt_seed) + .await + .expect("persist corrupt-fixture viewing key"); + backend + .ensure_shielded_bound(&healthy_hash, &healthy_seed) + .await + .expect("persist healthy viewing key"); + assert!( + backend + .bind_shielded_from_persisted_for_test(&corrupt_hash) + .await + .expect("read corrupt-fixture viewing key before corruption"), + "precondition: the first wallet has a restorable FVK" + ); + assert!( + backend + .bind_shielded_from_persisted_for_test(&healthy_hash) + .await + .expect("read healthy viewing key"), + "precondition: the second wallet has a restorable FVK" + ); + let corrupt_wallet_id = backend + .registered_wallet_id(&corrupt_hash) + .expect("corrupt-fixture upstream wallet id"); + let healthy_wallet_id = backend + .registered_wallet_id(&healthy_hash) + .expect("healthy upstream wallet id"); + + let persister_path = source_dir + .path() + .join("spv") + .join("testnet") + .join("platform-wallet.sqlite"); + let connection = rusqlite::Connection::open_with_flags( + &persister_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, + ) + .expect("open boot-1 persister inspection connection"); + for wallet_id in [corrupt_wallet_id, healthy_wallet_id] { + let count = connection + .query_row( + "SELECT COUNT(*) FROM shielded_viewing_keys WHERE wallet_id = ?1", + [wallet_id.as_slice()], + |row| row.get::<_, i64>(0), + ) + .expect("count boot-1 viewing keys"); + assert_eq!(count, 1, "precondition: each wallet has one FVK row"); + } + drop(connection); + + backend.shutdown().await; + let _ = ctx.subtasks.shutdown_async().await; + ( + corrupt_hash, + corrupt_wallet_id, + healthy_hash, + healthy_wallet_id, + ) + }; + + let cold_dir = tempfile::tempdir().expect("cold tempdir"); + copy_dir_recursive(source_dir.path(), cold_dir.path()); + let persister_path = cold_dir + .path() + .join("spv") + .join("testnet") + .join("platform-wallet.sqlite"); + let connection = + rusqlite::Connection::open(&persister_path).expect("open cold-boot persister fixture"); + assert_eq!( + connection + .execute( + "UPDATE shielded_viewing_keys SET viewing_key = X'00' WHERE wallet_id = ?1", + [corrupt_wallet_id.as_slice()], + ) + .expect("corrupt one persisted viewing key"), + 1, + "the corruption fixture must update exactly one FVK row" + ); + drop(connection); + + let (ctx, sender) = offline_testnet_context_at(cold_dir.path()); + ctx.ensure_wallet_backend(sender) + .await + .expect("one corrupt FVK must not prevent cold boot"); + let backend = ctx.wallet_backend().expect("cold-boot backend"); + + assert!( + ctx.wallets.read_recover().contains_key(&corrupt_hash), + "the corrupt-FVK DET wallet must remain registered" + ); + assert!( + ctx.wallets.read_recover().contains_key(&healthy_hash), + "the healthy DET wallet must remain registered" + ); + assert!( + backend.is_wallet_registered(&corrupt_hash), + "the corrupt-FVK wallet must remain registered upstream" + ); + assert!( + backend.is_wallet_registered(&healthy_hash), + "the healthy wallet must remain registered upstream" + ); + assert!( + !backend + .bind_shielded_from_persisted_for_test(&corrupt_hash) + .await + .expect("probe skipped corrupt viewing key"), + "the corrupt wallet must not restore a shielded binding" + ); + assert!( + backend + .bind_shielded_from_persisted_for_test(&healthy_hash) + .await + .expect("restore healthy viewing key"), + "the healthy wallet must restore its shielded binding" + ); + assert_ne!( + corrupt_wallet_id, healthy_wallet_id, + "fixture wallets must have distinct upstream ids" + ); + + backend.shutdown().await; + let _ = ctx.subtasks.shutdown_async().await; +} + /// A corrupt persisted Core transaction must not hide the wallet or prevent /// valid history from populating its first seedless cold-boot snapshot. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/src/model/wallet/mod.rs b/src/model/wallet/mod.rs index 77940012b..a15eeed63 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -52,7 +52,7 @@ pub enum PaymentValidationError { #[derive(Debug, Error)] pub enum WalletCreationError { /// The password cannot satisfy the persistent secret store's minimum. - #[error("Wallet passwords must be at least {min} characters after trimming.")] + #[error("Wallet passwords must be at least {min} characters. Pick a longer one and try again.")] PasswordTooShort { min: u32 }, /// Encrypting the seed with the supplied password failed. #[error("Could not process encrypted data. Please check your keys and try again.")] @@ -436,7 +436,7 @@ impl Wallet { // Encrypt seed or store plaintext let (encrypted_seed, salt, nonce, uses_password) = match password { Some(pw) if !pw.is_empty() => { - if pw.expose_secret().trim().chars().count() < MIN_PASSPHRASE_LEN { + if pw.expose_secret().trim().len() < MIN_PASSPHRASE_LEN { return Err(WalletCreationError::PasswordTooShort { min: MIN_PASSPHRASE_LEN as u32, }); @@ -2646,6 +2646,30 @@ mod tests { )); } + #[test] + fn new_wallet_accepts_password_meeting_utf8_byte_storage_floor() { + let multibyte_password = Secret::new("öäüß"); + let result = Wallet::new_from_seed( + test_seed(), + Network::Testnet, + Some("multibyte-password".to_string()), + Some(&multibyte_password), + ); + + assert!( + result.is_ok(), + "four two-byte characters meet the eight-byte storage floor" + ); + } + + #[test] + fn password_too_short_display_is_actionable() { + assert_eq!( + WalletCreationError::PasswordTooShort { min: 8 }.to_string(), + "Wallet passwords must be at least 8 characters. Pick a longer one and try again." + ); + } + /// R3 capstone: an open wallet retains NO plaintext seed. /// /// `WalletSeed::Open` is the verify-not-park state — it carries only the diff --git a/src/wallet_backend/secret_access.rs b/src/wallet_backend/secret_access.rs index 7a6c52b7f..f0e721276 100644 --- a/src/wallet_backend/secret_access.rs +++ b/src/wallet_backend/secret_access.rs @@ -862,13 +862,9 @@ impl SecretAccess { let seed = decrypt_hd_seed(&envelope, passphrase)?; if envelope.uses_password { let pw = passphrase.ok_or(TaskError::HdPassphraseIncorrect)?; - if let Err(e) = view.set_protected(seed_hash, &seed, pw) { - tracing::warn!( - target = "wallet_backend::secret_access", - error = ?e, - "HD seed lazy Tier-2 re-wrap deferred (storage rejected write)", - ); - } + handle_lazy_tier2_rewrap_result( + view.set_protected(seed_hash, &seed, pw), + )?; } else { view.set_raw(seed_hash, &seed)?; } @@ -1137,6 +1133,22 @@ pub(crate) fn identity_key_from_bytes(bytes: &[u8]) -> Result<[u8; SINGLE_KEY_LE }) } +fn handle_lazy_tier2_rewrap_result(result: Result<(), TaskError>) -> Result<(), TaskError> { + match result { + Err(TaskError::SecretSeam { source }) + if matches!(source.as_ref(), SecretStoreError::BlankPassphrase) => + { + tracing::warn!( + target = "wallet_backend::secret_access", + error = ?source, + "HD seed lazy Tier-2 re-wrap deferred because the legacy password is below the storage floor", + ); + Ok(()) + } + other => other, + } +} + /// Whether `e` is the "wrong passphrase" condition that the re-ask loop /// catches and re-prompts on (rather than aborting). fn is_wrong_passphrase(e: &TaskError) -> bool { @@ -2696,6 +2708,33 @@ mod tests { ); } + #[test] + fn lazy_tier2_rewrap_defers_blank_passphrase() { + let result = handle_lazy_tier2_rewrap_result(Err(TaskError::SecretSeam { + source: Box::new(SecretStoreError::BlankPassphrase), + })); + + assert!(result.is_ok()); + } + + #[test] + fn lazy_tier2_rewrap_propagates_non_blank_storage_error() { + let result = handle_lazy_tier2_rewrap_result(Err(TaskError::SecretSeam { + source: Box::new(SecretStoreError::Corruption), + })); + + assert!(matches!( + result, + Err(TaskError::SecretSeam { source }) + if matches!(*source, SecretStoreError::Corruption) + )); + } + + #[test] + fn lazy_tier2_rewrap_accepts_success() { + assert!(handle_lazy_tier2_rewrap_result(Ok(())).is_ok()); + } + /// TS-T2-02 — a Tier-2 seed re-asks on a wrong object password (upstream /// `WrongPassword` ⇒ re-prompt, not abort) and then succeeds. #[tokio::test] diff --git a/src/wallet_backend/shielded.rs b/src/wallet_backend/shielded.rs index 909267688..e4b1383ca 100644 --- a/src/wallet_backend/shielded.rs +++ b/src/wallet_backend/shielded.rs @@ -20,18 +20,17 @@ use super::{ /// spend. Upstream re-derives the Orchard spend keyset from this seed on every /// `shielded_*_to` call. /// -/// A resolved secret that is not an HD seed — a single-key or identity-key -/// secret sitting at an HD scope, i.e. a misconfigured wallet — cannot yield a -/// shielded spend key, so it maps to [`TaskError::WalletLocked`]. This is the -/// ONLY `WalletLocked` source in the shielded transfer / unshield / withdraw -/// paths, and it is a defensive type guard — NOT the locked-protected-wallet -/// path. An actually-locked protected wallet is *prompted* by -/// `with_secret_session`; a dismissed or headless prompt surfaces as +/// A resolved secret that is not an HD seed indicates inconsistent wallet +/// structure and maps to [`TaskError::WalletStateInconsistent`]. This is +/// distinct from the protected-wallet path: `with_secret_session` prompts for +/// that wallet, and a dismissed or headless prompt surfaces as /// [`TaskError::SecretPromptCancelled`] / [`TaskError::SecretPromptUnavailable`]. fn hd_seed_for_shielded_spend<'a>( plaintext: &'a SecretPlaintext<'_>, ) -> Result<&'a [u8; 64], TaskError> { - plaintext.expose_hd_seed().ok_or(TaskError::WalletLocked) + plaintext + .expose_hd_seed() + .ok_or(TaskError::WalletStateInconsistent) } impl WalletBackend { @@ -238,9 +237,10 @@ impl WalletBackend { /// unprotected wallet resolves prompt-free; a protected one is prompted for /// its passphrase unless already cached, and a dismissed or headless prompt /// surfaces as [`TaskError::SecretPromptCancelled`] / - /// [`TaskError::SecretPromptUnavailable`]. [`TaskError::WalletLocked`] is - /// returned only if the HD scope resolves to a non-HD-seed secret (a - /// misconfigured wallet) — see `hd_seed_for_shielded_spend`. + /// [`TaskError::SecretPromptUnavailable`]. A non-HD secret at this HD scope + /// is a structural invariant failure and returns + /// [`TaskError::WalletStateInconsistent`] — see + /// `hd_seed_for_shielded_spend`. /// /// The Orchard prover is created internally via /// `CachedOrchardProver::new()` — callers do not supply a prover. @@ -425,18 +425,17 @@ mod tests { assert_eq!(got, &[0x11u8; 64]); } - /// A non-HD-seed secret at an HD scope (a single-key secret — a misconfigured - /// wallet) cannot yield a shielded spend key, so it maps to `WalletLocked`. - /// This pins the ONLY `WalletLocked` source in the shielded - /// transfer / unshield / withdraw paths, and guards against it being confused - /// with the locked-protected-wallet prompt path (which yields - /// `SecretPromptCancelled` / `SecretPromptUnavailable` instead). + /// A non-HD-seed secret at an HD scope is a structural wallet inconsistency, + /// distinct from the protected-wallet prompt path. #[test] - fn hd_seed_for_shielded_spend_rejects_non_hd_secret_as_wallet_locked() { + fn hd_seed_for_shielded_spend_rejects_non_hd_secret_as_wallet_state_inconsistent() { let key = Zeroizing::new([0x22u8; 32]); let plaintext = SecretPlaintext::SingleKey(&key); let err = hd_seed_for_shielded_spend(&plaintext) .expect_err("a non-HD-seed secret must not yield a shielded spend key"); - assert!(matches!(err, TaskError::WalletLocked), "got {err:?}"); + assert!( + matches!(err, TaskError::WalletStateInconsistent), + "got {err:?}" + ); } } From d99073aca7781d940e8a283bd09284f56a1cf3da Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:03:35 +0000 Subject: [PATCH 13/20] fix(wallet-backend): skip auto-backup on wallet delete, repaint removal warning, scope monitor-revision bump to new contacts Follow-up to the PR #919 comment-triage pass: - Wallet deletion now uses delete_wallet_skip_backup instead of delete_wallet, so removing a wallet no longer leaves an unencrypted copy of its shielded viewing key in an automatic pre-delete backup snapshot (BLOCKING privacy leak, thepastaclaw). Regression test extended to assert no auto-backup directory is created. - show_wallet_data_removal_warning now calls ctx.request_repaint(), so the async wallet-removal failure banner actually appears on an idle UI instead of waiting for an unrelated repaint (CodeRabbit). New unit test covers this directly. - register_contact_accounts_in_managed_wallet now bumps monitor_revision only on the specific newly-inserted DashpayReceivingFunds account, not every such account in the wallet, matching the function's own doc comment and avoiding needless bloom-filter/rescan churn on already -covered contacts (CodeRabbit nitpick). Regression test corrected and strengthened to assert per-account revisions individually. - CHANGELOG.md: password floor reworded to UTF-8 bytes (not characters, matching the actual fix), added the per-operation shielded passphrase prompt as its own user-facing bullet, and un-buried the Tier-2 lockout compatibility note into its own paragraph. Implemented by Codex Sol (gpt-5.6-sol, high effort), independently re-verified by the coordinator: fmt/clippy clean, wallet_backend (337 passed) and context::wallet_lifecycle (67 passed) test scopes re-run with the new/changed test names confirmed present and passing in the logs, not just an aggregate count. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 19 ++++--- src/context/wallet_lifecycle/removal.rs | 24 +++++++++ src/context/wallet_lifecycle/tests.rs | 10 ++++ src/wallet_backend/mod.rs | 72 +++++++++++++++++-------- 4 files changed, 97 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dae34566..f07d5d8c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,12 +70,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). plus DashPay address used-state updates. On Unix, DET now tightens app and per-network storage directories to owner-only before opening the hardened upstream database, so permissive system defaults do not prevent startup. New - wallet passwords must now be at least eight characters after trimming; - existing wallets with shorter passwords that are still in DET's legacy - encrypted format remain usable instead of failing during lazy migration. - Wallets already migrated to Tier-2 storage by a July 2026 weekly build with - a shorter password cannot be opened at this upstream tip; do not upgrade - those profiles until upstream provides a compatibility reader. + wallet passwords must now be at least eight UTF-8 bytes after trimming + (measured in bytes, not characters, so a 4-character non-ASCII password like + `öäüß` — 8 bytes — is accepted); existing wallets with shorter passwords + that are still in DET's legacy encrypted format remain usable instead of + failing during lazy migration. Protected (Tier-2) shielded wallets now + prompt for their passphrase on every shielded transfer, unshield, or + withdraw, rather than once per session as before. + + **Compatibility note:** wallets already migrated to Tier-2 storage by a + July 2026 weekly build with a shorter password cannot be opened at this + upstream tip; do not upgrade those profiles until upstream provides a + compatibility reader. + Development builds between `f7ca95f` and `69b7546` stored shielded viewing keys in an interim metadata row that this final pin does not migrate; those commits were never released, and unlocking the wallet safely re-derives and diff --git a/src/context/wallet_lifecycle/removal.rs b/src/context/wallet_lifecycle/removal.rs index 956e2c464..0e1b0d59c 100644 --- a/src/context/wallet_lifecycle/removal.rs +++ b/src/context/wallet_lifecycle/removal.rs @@ -10,6 +10,7 @@ fn show_wallet_data_removal_warning(ctx: &egui::Context, error: TaskError) { let handle = MessageBanner::set_global(ctx, WALLET_DATA_REMOVAL_WARNING, MessageType::Warning); handle.with_details(error); handle.disable_auto_dismiss(); + ctx.request_repaint(); } impl AppContext { @@ -84,3 +85,26 @@ impl AppContext { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[test] + fn wallet_data_removal_warning_requests_repaint() { + let ctx = egui::Context::default(); + let repaint_requests = Arc::new(AtomicUsize::new(0)); + let callback_requests = Arc::clone(&repaint_requests); + ctx.set_request_repaint_callback(move |_| { + callback_requests.fetch_add(1, Ordering::Relaxed); + }); + + show_wallet_data_removal_warning(&ctx, TaskError::TaskManagerShuttingDown); + + assert!( + repaint_requests.load(Ordering::Relaxed) > 0, + "showing an asynchronous warning must wake an idle UI" + ); + } +} diff --git a/src/context/wallet_lifecycle/tests.rs b/src/context/wallet_lifecycle/tests.rs index 65e282448..8d9b03658 100644 --- a/src/context/wallet_lifecycle/tests.rs +++ b/src/context/wallet_lifecycle/tests.rs @@ -1747,6 +1747,16 @@ async fn remove_wallet_reaps_persisted_shielded_viewing_keys() { 0, "upstream deletion must cascade to the native FVK row" ); + assert!( + !source_dir + .path() + .join("spv") + .join("testnet") + .join("backups") + .join("auto") + .exists(), + "explicit wallet removal must not create an automatic backup" + ); backend.shutdown().await; } diff --git a/src/wallet_backend/mod.rs b/src/wallet_backend/mod.rs index 6a1dd7271..aa93b7d53 100644 --- a/src/wallet_backend/mod.rs +++ b/src/wallet_backend/mod.rs @@ -1386,7 +1386,11 @@ impl WalletBackend { } } - match self.inner.wallet_persister.delete_wallet(*wallet_id) { + match self + .inner + .wallet_persister + .delete_wallet_skip_backup(*wallet_id) + { Ok(_) | Err(WalletStorageError::WalletNotFound { .. }) => Ok(()), Err(source) => Err(TaskError::WalletStorage { source }), } @@ -2638,7 +2642,7 @@ impl WalletBackend { } } -/// Registers contact accounts and bumps their aggregate monitor revision for real insertions. +/// Registers contact accounts and bumps each newly inserted account's monitor revision. fn register_contact_accounts_in_managed_wallet( info: &mut dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::ManagedWalletInfo, accounts: impl IntoIterator< @@ -2649,40 +2653,37 @@ fn register_contact_accounts_in_managed_wallet( >, ) -> usize { use dash_sdk::dpp::key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; - use dash_sdk::dpp::key_wallet::managed_account::managed_account_type::ManagedAccountType; let mut newly_inserted = 0; for (account_type, account_xpub) in accounts { match add_contact_receiving_account(info, account_type, account_xpub) { - Ok(true) => newly_inserted += 1, - Ok(false) => {} + Ok(Some(key)) => { + newly_inserted += 1; + if let Some(account) = info.accounts.dashpay_receival_accounts.get_mut(&key) { + account.bump_monitor_revision(); + } + } + Ok(None) => {} Err(error) => { tracing::debug!(%error, "Skipping contact account: managed insert failed"); } } } - if newly_inserted > 0 { - for account in info.accounts.all_funding_accounts_mut() { - if matches!( - account.managed_account_type(), - ManagedAccountType::DashpayReceivingFunds { .. } - ) { - account.bump_monitor_revision(); - } - } - } newly_inserted } /// Adds one `DashpayReceivingFunds` account to managed state. /// -/// Returns `true` for a new insertion and `false` when the contact key already exists. +/// Returns its key for a new insertion and `None` when the key already exists. /// Any other account variant returns `InvalidParameter`. fn add_contact_receiving_account( info: &mut dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::ManagedWalletInfo, account_type: dash_sdk::dpp::key_wallet::AccountType, account_xpub: dash_sdk::dpp::key_wallet::bip32::ExtendedPubKey, -) -> Result { +) -> Result< + Option, + dash_sdk::dpp::key_wallet::error::Error, +> { use dash_sdk::dpp::key_wallet::account::account_collection::DashpayAccountKey; use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::ManagedAccountOperations; @@ -2702,13 +2703,13 @@ fn add_contact_receiving_account( user_identity_id, friend_identity_id, }; - // Convert upstream's duplicate InvalidParameter into DET's idempotent false result. + // Convert upstream's duplicate InvalidParameter into DET's idempotent no-op result. if info.accounts.dashpay_receival_accounts.contains_key(&key) { - return Ok(false); + return Ok(None); } info.add_managed_account_from_xpub(account_type, account_xpub)?; - Ok(true) + Ok(Some(key)) } /// The BIP44 account-0 extended public key among `accounts`, or `None` when @@ -3060,6 +3061,7 @@ mod tests { #[test] fn contact_registration_bumps_monitor_revision_only_for_new_keys() { use dash_sdk::dpp::key_wallet::account::AccountType; + use dash_sdk::dpp::key_wallet::account::account_collection::DashpayAccountKey; use dash_sdk::dpp::key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; use dash_sdk::dpp::key_wallet::wallet::Wallet as UpstreamWallet; use dash_sdk::dpp::key_wallet::wallet::initialization::WalletAccountCreationOptions; @@ -3089,6 +3091,16 @@ mod tests { }; let first_contact = contact_account([2u8; 32]); let second_contact = contact_account([3u8; 32]); + let first_key = DashpayAccountKey { + index: 0, + user_identity_id: [1u8; 32], + friend_identity_id: [2u8; 32], + }; + let second_key = DashpayAccountKey { + index: 0, + user_identity_id: [1u8; 32], + friend_identity_id: [3u8; 32], + }; let max_monitor_revision = |info: &ManagedWalletInfo| { info.accounts .dashpay_receival_accounts @@ -3107,6 +3119,10 @@ mod tests { assert_eq!(info.account_generation(), 1); assert_eq!(info.synced_height(), 0); assert_eq!(max_monitor_revision(&info), 1); + assert_eq!( + info.accounts.dashpay_receival_accounts[&first_key].monitor_revision(), + 1 + ); assert_eq!( register_contact_accounts_in_managed_wallet(&mut info, [first_contact]), @@ -3115,13 +3131,25 @@ mod tests { assert_eq!(info.account_generation(), 1); assert_eq!(info.synced_height(), 0); assert_eq!(max_monitor_revision(&info), 1); + assert_eq!( + info.accounts.dashpay_receival_accounts[&first_key].monitor_revision(), + 1 + ); assert_eq!( register_contact_accounts_in_managed_wallet(&mut info, [first_contact, second_contact]), 1 ); assert_eq!(info.account_generation(), 2); - assert_eq!(max_monitor_revision(&info), 2); + assert_eq!(max_monitor_revision(&info), 1); + assert_eq!( + info.accounts.dashpay_receival_accounts[&first_key].monitor_revision(), + 1 + ); + assert_eq!( + info.accounts.dashpay_receival_accounts[&second_key].monitor_revision(), + 1 + ); let mut revisions = info .accounts .dashpay_receival_accounts @@ -3129,7 +3157,7 @@ mod tests { .map(ManagedAccountTrait::monitor_revision) .collect::>(); revisions.sort_unstable(); - assert_eq!(revisions, [1, 2]); + assert_eq!(revisions, [1, 1]); } #[cfg(unix)] From 0146af3393b04e9f009c274782dafea4e46627b8 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:19:48 +0000 Subject: [PATCH 14/20] chore: bump dashpay/platform pin to PR #3968 tip (4a1ba64c -> 288a6cae) Branch feat/platform-wallet-storage-rehydration advanced with mainline v4.1-dev merges (release/version bumps, kotlin-sdk fix, docker test-suite image, drive proof wire-format versioning) since the last pin. No changes to dash-sdk/platform-wallet/platform-wallet-storage/rs-sdk-trusted-context-provider themselves in this range. Lockfile regenerated scoped to the platform git package set only (59 packages moved 4.0.0 -> 4.1.0-rc.1); no crates.io registry drift. Formatting and lint gates (all-features, all-targets, warnings-as-errors) both clean at this pin. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 118 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 8 ++-- 2 files changed, 63 insertions(+), 63 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8c5f66ac3..13b516387 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1876,8 +1876,8 @@ dependencies = [ [[package]] name = "dapi-grpc" -version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" +version = "4.1.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=288a6cae4f9653d6085d2b3d6c7410210a0c95ba#288a6cae4f9653d6085d2b3d6c7410210a0c95ba" dependencies = [ "dash-platform-macros", "futures-core", @@ -1978,8 +1978,8 @@ dependencies = [ [[package]] name = "dash-async" -version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" +version = "4.1.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=288a6cae4f9653d6085d2b3d6c7410210a0c95ba#288a6cae4f9653d6085d2b3d6c7410210a0c95ba" dependencies = [ "thiserror 2.0.18", "tokio", @@ -1988,8 +1988,8 @@ dependencies = [ [[package]] name = "dash-context-provider" -version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" +version = "4.1.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=288a6cae4f9653d6085d2b3d6c7410210a0c95ba#288a6cae4f9653d6085d2b3d6c7410210a0c95ba" dependencies = [ "dash-async", "dpp", @@ -2099,8 +2099,8 @@ dependencies = [ [[package]] name = "dash-platform-macros" -version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" +version = "4.1.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=288a6cae4f9653d6085d2b3d6c7410210a0c95ba#288a6cae4f9653d6085d2b3d6c7410210a0c95ba" dependencies = [ "heck", "quote", @@ -2109,8 +2109,8 @@ dependencies = [ [[package]] name = "dash-sdk" -version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" +version = "4.1.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=288a6cae4f9653d6085d2b3d6c7410210a0c95ba#288a6cae4f9653d6085d2b3d6c7410210a0c95ba" dependencies = [ "arc-swap", "async-trait", @@ -2246,8 +2246,8 @@ dependencies = [ [[package]] name = "dashpay-contract" -version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" +version = "4.1.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=288a6cae4f9653d6085d2b3d6c7410210a0c95ba#288a6cae4f9653d6085d2b3d6c7410210a0c95ba" dependencies = [ "platform-value", "platform-version", @@ -2257,8 +2257,8 @@ dependencies = [ [[package]] name = "data-contracts" -version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" +version = "4.1.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=288a6cae4f9653d6085d2b3d6c7410210a0c95ba#288a6cae4f9653d6085d2b3d6c7410210a0c95ba" dependencies = [ "dashpay-contract", "document-history-contract", @@ -2526,8 +2526,8 @@ dependencies = [ [[package]] name = "document-history-contract" -version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" +version = "4.1.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=288a6cae4f9653d6085d2b3d6c7410210a0c95ba#288a6cae4f9653d6085d2b3d6c7410210a0c95ba" dependencies = [ "platform-value", "platform-version", @@ -2555,8 +2555,8 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "dpns-contract" -version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" +version = "4.1.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=288a6cae4f9653d6085d2b3d6c7410210a0c95ba#288a6cae4f9653d6085d2b3d6c7410210a0c95ba" dependencies = [ "platform-value", "platform-version", @@ -2566,8 +2566,8 @@ dependencies = [ [[package]] name = "dpp" -version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" +version = "4.1.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=288a6cae4f9653d6085d2b3d6c7410210a0c95ba#288a6cae4f9653d6085d2b3d6c7410210a0c95ba" dependencies = [ "anyhow", "async-trait", @@ -2616,8 +2616,8 @@ dependencies = [ [[package]] name = "dpp-json-convertible-derive" -version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" +version = "4.1.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=288a6cae4f9653d6085d2b3d6c7410210a0c95ba#288a6cae4f9653d6085d2b3d6c7410210a0c95ba" dependencies = [ "proc-macro2", "quote", @@ -2626,8 +2626,8 @@ dependencies = [ [[package]] name = "drive" -version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" +version = "4.1.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=288a6cae4f9653d6085d2b3d6c7410210a0c95ba#288a6cae4f9653d6085d2b3d6c7410210a0c95ba" dependencies = [ "bincode 2.0.1", "byteorder", @@ -2651,8 +2651,8 @@ dependencies = [ [[package]] name = "drive-proof-verifier" -version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" +version = "4.1.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=288a6cae4f9653d6085d2b3d6c7410210a0c95ba#288a6cae4f9653d6085d2b3d6c7410210a0c95ba" dependencies = [ "bincode 2.0.1", "dapi-grpc", @@ -5045,8 +5045,8 @@ dependencies = [ [[package]] name = "keyword-search-contract" -version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" +version = "4.1.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=288a6cae4f9653d6085d2b3d6c7410210a0c95ba#288a6cae4f9653d6085d2b3d6c7410210a0c95ba" dependencies = [ "platform-value", "platform-version", @@ -5258,8 +5258,8 @@ dependencies = [ [[package]] name = "masternode-reward-shares-contract" -version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" +version = "4.1.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=288a6cae4f9653d6085d2b3d6c7410210a0c95ba#288a6cae4f9653d6085d2b3d6c7410210a0c95ba" dependencies = [ "platform-value", "platform-version", @@ -6470,8 +6470,8 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "platform-encryption" -version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" +version = "4.1.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=288a6cae4f9653d6085d2b3d6c7410210a0c95ba#288a6cae4f9653d6085d2b3d6c7410210a0c95ba" dependencies = [ "aes", "cbc", @@ -6483,8 +6483,8 @@ dependencies = [ [[package]] name = "platform-serialization" -version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" +version = "4.1.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=288a6cae4f9653d6085d2b3d6c7410210a0c95ba#288a6cae4f9653d6085d2b3d6c7410210a0c95ba" dependencies = [ "bincode 2.0.1", "platform-version", @@ -6492,8 +6492,8 @@ dependencies = [ [[package]] name = "platform-serialization-derive" -version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" +version = "4.1.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=288a6cae4f9653d6085d2b3d6c7410210a0c95ba#288a6cae4f9653d6085d2b3d6c7410210a0c95ba" dependencies = [ "proc-macro2", "quote", @@ -6503,8 +6503,8 @@ dependencies = [ [[package]] name = "platform-value" -version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" +version = "4.1.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=288a6cae4f9653d6085d2b3d6c7410210a0c95ba#288a6cae4f9653d6085d2b3d6c7410210a0c95ba" dependencies = [ "base64 0.22.1", "bincode 2.0.1", @@ -6523,8 +6523,8 @@ dependencies = [ [[package]] name = "platform-version" -version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" +version = "4.1.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=288a6cae4f9653d6085d2b3d6c7410210a0c95ba#288a6cae4f9653d6085d2b3d6c7410210a0c95ba" dependencies = [ "bincode 2.0.1", "grovedb-version 5.0.1", @@ -6534,8 +6534,8 @@ dependencies = [ [[package]] name = "platform-versioning" -version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" +version = "4.1.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=288a6cae4f9653d6085d2b3d6c7410210a0c95ba#288a6cae4f9653d6085d2b3d6c7410210a0c95ba" dependencies = [ "proc-macro2", "quote", @@ -6544,8 +6544,8 @@ dependencies = [ [[package]] name = "platform-wallet" -version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" +version = "4.1.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=288a6cae4f9653d6085d2b3d6c7410210a0c95ba#288a6cae4f9653d6085d2b3d6c7410210a0c95ba" dependencies = [ "arc-swap", "async-trait", @@ -6576,8 +6576,8 @@ dependencies = [ [[package]] name = "platform-wallet-storage" -version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" +version = "4.1.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=288a6cae4f9653d6085d2b3d6c7410210a0c95ba#288a6cae4f9653d6085d2b3d6c7410210a0c95ba" dependencies = [ "apple-native-keyring-store", "argon2", @@ -6859,7 +6859,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ "heck", - "itertools 0.14.0", + "itertools 0.10.5", "log", "multimap", "petgraph", @@ -6880,7 +6880,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.117", @@ -7573,8 +7573,8 @@ dependencies = [ [[package]] name = "rs-dapi-client" -version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" +version = "4.1.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=288a6cae4f9653d6085d2b3d6c7410210a0c95ba#288a6cae4f9653d6085d2b3d6c7410210a0c95ba" dependencies = [ "backon", "chrono", @@ -7599,8 +7599,8 @@ dependencies = [ [[package]] name = "rs-sdk-trusted-context-provider" -version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" +version = "4.1.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=288a6cae4f9653d6085d2b3d6c7410210a0c95ba#288a6cae4f9653d6085d2b3d6c7410210a0c95ba" dependencies = [ "arc-swap", "dash-async", @@ -8841,8 +8841,8 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "token-history-contract" -version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" +version = "4.1.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=288a6cae4f9653d6085d2b3d6c7410210a0c95ba#288a6cae4f9653d6085d2b3d6c7410210a0c95ba" dependencies = [ "platform-value", "platform-version", @@ -9686,8 +9686,8 @@ dependencies = [ [[package]] name = "wallet-utils-contract" -version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" +version = "4.1.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=288a6cae4f9653d6085d2b3d6c7410210a0c95ba#288a6cae4f9653d6085d2b3d6c7410210a0c95ba" dependencies = [ "platform-value", "platform-version", @@ -10268,7 +10268,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -10942,8 +10942,8 @@ dependencies = [ [[package]] name = "withdrawals-contract" -version = "4.0.0" -source = "git+https://github.com/dashpay/platform?rev=4a1ba64cc6c5cab387e9ac848c6bf5e497548d60#4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" +version = "4.1.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=288a6cae4f9653d6085d2b3d6c7410210a0c95ba#288a6cae4f9653d6085d2b3d6c7410210a0c95ba" dependencies = [ "num_enum 0.5.11", "platform-value", diff --git a/Cargo.toml b/Cargo.toml index 4fda511b2..269fba25a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ qrcode = "0.14.1" nix = { version = "0.31.1", features = ["signal"] } eframe = { version = "0.35.0", features = ["persistence", "wgpu"] } base64 = "0.22.1" -dash-sdk = { git = "https://github.com/dashpay/platform", rev = "4a1ba64cc6c5cab387e9ac848c6bf5e497548d60", features = [ +dash-sdk = { git = "https://github.com/dashpay/platform", rev = "288a6cae4f9653d6085d2b3d6c7410210a0c95ba", features = [ "core_key_wallet", "core_key_wallet_manager", "core_bincode", @@ -28,12 +28,12 @@ dash-sdk = { git = "https://github.com/dashpay/platform", rev = "4a1ba64cc6c5cab "core_spv", "shielded", ] } -rs-sdk-trusted-context-provider = { git = "https://github.com/dashpay/platform", rev = "4a1ba64cc6c5cab387e9ac848c6bf5e497548d60" } -platform-wallet = { git = "https://github.com/dashpay/platform", rev = "4a1ba64cc6c5cab387e9ac848c6bf5e497548d60", features = [ +rs-sdk-trusted-context-provider = { git = "https://github.com/dashpay/platform", rev = "288a6cae4f9653d6085d2b3d6c7410210a0c95ba" } +platform-wallet = { git = "https://github.com/dashpay/platform", rev = "288a6cae4f9653d6085d2b3d6c7410210a0c95ba", features = [ "serde", "shielded", ] } -platform-wallet-storage = { git = "https://github.com/dashpay/platform", rev = "4a1ba64cc6c5cab387e9ac848c6bf5e497548d60", features = [ +platform-wallet-storage = { git = "https://github.com/dashpay/platform", rev = "288a6cae4f9653d6085d2b3d6c7410210a0c95ba", features = [ "shielded", ] } zip32 = "0.2.0" From 9632a4b0b652171c4b0abb542e5dce61a40cf22a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:09:23 +0000 Subject: [PATCH 15/20] fix(wallet): surface local secret-wipe failures on removal remove_wallet's local secret wipe (forget_wallet_local_state: seed vault, session cache, shielded rows) previously only logged a warning and still returned Ok(()) on failure, while the async upstream-removal step already surfaced its own failure via a persistent warning banner. Route the local-wipe failure through the same banner so a user is told when a secret-bearing wipe may not have fully completed, instead of being told removal succeeded. Adds a fault-injection regression test covering the new path. Addresses PR review findings CMT-001/CMT-002. Co-Authored-By: Codex Sol --- src/context/wallet_lifecycle/removal.rs | 1 + src/context/wallet_lifecycle/tests.rs | 29 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/context/wallet_lifecycle/removal.rs b/src/context/wallet_lifecycle/removal.rs index 0e1b0d59c..802ce9ec7 100644 --- a/src/context/wallet_lifecycle/removal.rs +++ b/src/context/wallet_lifecycle/removal.rs @@ -57,6 +57,7 @@ impl AppContext { error = ?e, "Failed to wipe local wallet secret state on removal" ); + show_wallet_data_removal_warning(self.egui_ctx(), e); } // The upstream (watch-only, seedless) persistor row removal is the diff --git a/src/context/wallet_lifecycle/tests.rs b/src/context/wallet_lifecycle/tests.rs index 8d9b03658..0bdb8e0d7 100644 --- a/src/context/wallet_lifecycle/tests.rs +++ b/src/context/wallet_lifecycle/tests.rs @@ -1691,6 +1691,35 @@ async fn remove_wallet_wipes_seed_envelope() { backend.shutdown().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remove_wallet_warns_when_local_secret_wipe_fails() { + let (ctx, sender, _tmp) = offline_testnet_context(); + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + + let seed = [0xA4u8; 64]; + let wallet = crate::model::wallet::Wallet::new_from_seed(seed, Network::Testnet, None, None) + .expect("build wallet"); + let seed_hash = wallet.seed_hash(); + ctx.register_wallet(wallet, &seed, WalletOrigin::Fresh) + .expect("register wallet"); + + let backend = ctx.wallet_backend().expect("backend wired"); + backend.set_forget_wallet_local_state_test_failure(true); + crate::ui::components::MessageBanner::clear_all_global(ctx.egui_ctx()); + + ctx.remove_wallet(&seed_hash).expect("remove wallet"); + + assert!( + crate::ui::components::MessageBanner::has_global(ctx.egui_ctx()), + "a local secret-wipe failure must raise a user-visible warning" + ); + + let _ = ctx.subtasks.shutdown_async().await; + backend.shutdown().await; +} + /// Removing a wallet drives upstream's native viewing-key cascade. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn remove_wallet_reaps_persisted_shielded_viewing_keys() { From 09b9e197ffc5fc1e8b978183f95c8e348dd04a45 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:09:23 +0000 Subject: [PATCH 16/20] fix(contracts): reject mismatched update responses, fix password wording Add validate_updated_contract_id: before caching the contract returned by a data-contract update broadcast, assert its id matches the contract that was updated, rejecting a mismatch via a new dedicated TaskError::UpdatedContractIdMismatch instead of trusting the response blindly. Covered by unit tests for both the accept and reject paths. Also reword TaskError::WalletPasswordTooShort and WalletCreationError::PasswordTooShort to say "UTF-8 bytes after trimming" instead of "characters" - the floor is enforced as a byte count, and the prior wording under-rejected multi-byte passwords by implying a stricter character-based rule than actually enforced. Updates the two display tests pinning this wording and the matching TODO comment in secret_access.rs. Addresses PR review findings CMT-004 and CMT-008. Co-Authored-By: Codex Sol --- src/backend_task/error.rs | 15 ++++++- src/backend_task/update_data_contract.rs | 50 +++++++++++++++++++++++- src/model/wallet/mod.rs | 6 ++- src/wallet_backend/secret_access.rs | 4 +- 4 files changed, 67 insertions(+), 8 deletions(-) diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 6f312effd..81d394d6a 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -1686,6 +1686,15 @@ pub enum TaskError { #[error("The contract structure is unexpected. Please update the application.")] ContractSchemaMismatch { detail: &'static str }, + /// Platform returned a different contract after accepting an update. + #[error( + "The platform returned contract {returned} instead of {expected}. Please try the update again." + )] + UpdatedContractIdMismatch { + expected: Identifier, + returned: Identifier, + }, + // ────────────────────────────────────────────────────────────────────────── // Withdrawal document parsing errors // ────────────────────────────────────────────────────────────────────────── @@ -1951,7 +1960,9 @@ pub enum TaskError { /// A new wallet password is shorter than the persistent secret store's /// minimum and therefore could not be migrated to Tier-2 protection. - #[error("Wallet passwords must be at least {min} characters. Pick a longer one and try again.")] + #[error( + "Wallet passwords must be at least {min} UTF-8 bytes after trimming. Pick a longer one and try again." + )] WalletPasswordTooShort { min: u32 }, /// Wallet key derivation failed during construction. @@ -3220,7 +3231,7 @@ mod tests { fn wallet_password_too_short_display_matches_model_guidance() { assert_eq!( TaskError::WalletPasswordTooShort { min: 8 }.to_string(), - "Wallet passwords must be at least 8 characters. Pick a longer one and try again." + "Wallet passwords must be at least 8 UTF-8 bytes after trimming. Pick a longer one and try again." ); } diff --git a/src/backend_task/update_data_contract.rs b/src/backend_task/update_data_contract.rs index 4d2655058..689671a32 100644 --- a/src/backend_task/update_data_contract.rs +++ b/src/backend_task/update_data_contract.rs @@ -20,6 +20,16 @@ use dash_sdk::{ }, }; +fn validate_updated_contract_id( + expected: Identifier, + returned: Identifier, +) -> Result<(), TaskError> { + if returned != expected { + return Err(TaskError::UpdatedContractIdMismatch { expected, returned }); + } + Ok(()) +} + impl AppContext { pub async fn update_data_contract( &self, @@ -75,11 +85,12 @@ impl AppContext { .map_err(|e| TaskError::from(dash_sdk::Error::Protocol(e)))?; match state_transition - .broadcast_and_wait_for_affected_state(sdk, None) + .broadcast_and_wait_for_affected_state::(sdk, None) .await { Ok(returned_contract) => { - self.replace_contract(data_contract.id(), &returned_contract)?; + validate_updated_contract_id(contract_id, returned_contract.id())?; + self.replace_contract(contract_id, &returned_contract)?; Ok(BackendTaskSuccessResult::UpdatedContract( FeeResult::estimated_only(estimated_fee), )) @@ -100,3 +111,38 @@ impl AppContext { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn returned_contract_id_mismatch_is_rejected_before_caching() { + let expected = Identifier::from([1; 32]); + let returned = Identifier::from([2; 32]); + + let error = validate_updated_contract_id(expected, returned) + .expect_err("a mismatched returned contract must be rejected"); + + assert!(matches!( + &error, + TaskError::UpdatedContractIdMismatch { + expected: error_expected, + returned: error_returned, + } if *error_expected == expected && *error_returned == returned + )); + assert_eq!( + error.to_string(), + format!( + "The platform returned contract {returned} instead of {expected}. Please try the update again." + ) + ); + } + + #[test] + fn matching_returned_contract_id_is_accepted() { + let contract_id = Identifier::from([3; 32]); + + assert!(validate_updated_contract_id(contract_id, contract_id).is_ok()); + } +} diff --git a/src/model/wallet/mod.rs b/src/model/wallet/mod.rs index 99a4afd2a..b7f9b94aa 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -61,7 +61,9 @@ pub enum PaymentValidationError { #[derive(Debug, Error)] pub enum WalletCreationError { /// The password cannot satisfy the persistent secret store's minimum. - #[error("Wallet passwords must be at least {min} characters. Pick a longer one and try again.")] + #[error( + "Wallet passwords must be at least {min} UTF-8 bytes after trimming. Pick a longer one and try again." + )] PasswordTooShort { min: u32 }, /// Encrypting the seed with the supplied password failed. #[error("Could not process encrypted data. Please check your keys and try again.")] @@ -2675,7 +2677,7 @@ mod tests { fn password_too_short_display_is_actionable() { assert_eq!( WalletCreationError::PasswordTooShort { min: 8 }.to_string(), - "Wallet passwords must be at least 8 characters. Pick a longer one and try again." + "Wallet passwords must be at least 8 UTF-8 bytes after trimming. Pick a longer one and try again." ); } diff --git a/src/wallet_backend/secret_access.rs b/src/wallet_backend/secret_access.rs index f0e721276..1b318fcc1 100644 --- a/src/wallet_backend/secret_access.rs +++ b/src/wallet_backend/secret_access.rs @@ -833,12 +833,12 @@ impl SecretAccess { // // TODO(v1.1): `get_protected` rejects any password below // `platform_wallet_storage::secrets::MIN_PASSPHRASE_LEN` - // (8 chars) on READ, not just write (see + // (8 UTF-8 bytes after trimming) on READ, not just write (see // `rs-platform-wallet-storage/src/secrets/wire/envelope.rs`, // `unwrap_password_payload`, comment `(a0)` — intentional, // mirrors the write floor so a backend-write attacker can't // plant a weakly sealed envelope). A wallet already migrated - // to Tier-2 with a <8-char password by the July 17/21 weekly + // to Tier-2 with a password below that floor by the July 17/21 weekly // builds (see CHANGELOG "Compatibility blocker" entry) has no // downstream escape hatch here and stays permanently // unreadable until upstream adds a scoped migration-read From 690e7cd53cb2af5849ae99a214ad6b96630f923d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:09:23 +0000 Subject: [PATCH 17/20] docs: cover all six shielded operations in protected-wallet caveats docs/MCP.md, docs/CLI.md, and CHANGELOG.md described the protected (Tier-2) wallet just-in-time seed resolution caveat as covering only shielded transfer/unshield/withdraw, but src/wallet_backend/shielded.rs routes six operations through the same with_secret_session mechanism: initialization, shield-from-Core, shield-from-Platform, transfer, unshield, and withdraw. Generalize all three artifacts to the full list so automation developers aren't surprised by an undocumented SecretPromptUnavailable on the other three. Addresses PR review findings CMT-005/CMT-006/CMT-007. Co-Authored-By: Codex Sol --- CHANGELOG.md | 15 +++++++++------ docs/CLI.md | 15 ++++++++------- docs/MCP.md | 15 ++++++++------- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ae0796f3..1cafc686d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,9 +66,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). (accounts, balances, identities, platform addresses) from the on-disk store without the HD seed, re-deriving spend authority just-in-time from the seed only when an operation actually signs — so private key material is never left resident - between operations. DET's shielded send / unshield / withdraw were updated to - match: each now resolves the HD seed through the secret-seam chokepoint for the - single spend and drops it on return. The update also adds persistence and + between operations. DET's shielded operations were updated to match: each now + resolves the HD seed through the secret-seam chokepoint for the single operation + and drops it on return. The update also adds persistence and rehydration for provider (masternode / evonode) platform-node key pools and for DashPay invitations. The later review tip also retries transient startup rehydration, selects platform-address transfer and withdrawal inputs from @@ -81,9 +81,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). (measured in bytes, not characters, so a 4-character non-ASCII password like `öäüß` — 8 bytes — is accepted); existing wallets with shorter passwords that are still in DET's legacy encrypted format remain usable instead of - failing during lazy migration. Protected (Tier-2) shielded wallets now - prompt for their passphrase on every shielded transfer, unshield, or - withdraw, rather than once per session as before. + failing during lazy migration. Protected (Tier-2) shielded wallets now resolve + their seed just in time for every operation that spends or binds their Orchard + keys (initialization, shield from Core, shield from Platform, transfer, + unshield, and withdraw). Each operation prompts for the passphrase unless the + user explicitly keeps the wallet unlocked for the session, replacing the + previous implicit once-per-session reuse. **Compatibility note:** wallets already migrated to Tier-2 storage by a July 2026 weekly build with a shorter password cannot be opened at this diff --git a/docs/CLI.md b/docs/CLI.md index c432e0270..5e53d7e31 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -52,18 +52,19 @@ det-cli --addr http://127.0.0.1:9000/mcp core-wallets-list Force standalone mode with `--standalone` even when an API key is present. -## Protected wallets and shielded spends +## Protected wallets and shielded operations -Shielded transfer, unshield, and withdraw operations resolve the wallet seed -just in time for each operation. A password-protected (Tier-2) wallet therefore -requires an interactive passphrase session. +Every shielded operation that spends or binds the wallet's Orchard keys +(initialization, shield from Core, shield from Platform, transfer, unshield, and +withdraw) resolves the wallet seed just in time. A password-protected (Tier-2) +wallet therefore requires an interactive passphrase session. Standalone `det-cli`, its stdio MCP server, and the headless HTTP server use a null secret prompt because no authorized GUI session is available. These modes return `SecretPromptUnavailable` when a protected wallet needs to authorize a -shielded spend. There is currently no environment-variable or CLI-passphrase -workaround, so unattended shielded spending from a protected wallet is not -supported. Use an unprotected wallet for that automation. +shielded operation. There is currently no environment-variable or +CLI-passphrase workaround, so unattended shielded operations from a protected +wallet are not supported. Use an unprotected wallet for that automation. This limitation does not apply in the same way when `det-cli` connects to MCP embedded in a running GUI. The embedded server can use the GUI's existing diff --git a/docs/MCP.md b/docs/MCP.md index 8dbebb5ae..3bb9a0446 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -54,18 +54,19 @@ Standalone/headless mode skips the GUI event loop and egui rendering, resulting The GUI-embedded HTTP mode shares the app's `AppContext` and follows network switches in real time, which is useful when you want to interactively switch networks from the GUI while MCP clients remain connected. -## Protected wallets and shielded spends +## Protected wallets and shielded operations -Shielded transfer, unshield, and withdraw tools resolve the wallet seed just in -time for each operation. A password-protected (Tier-2) wallet therefore -requires an interactive passphrase session. +Every shielded operation that spends or binds the wallet's Orchard keys +(initialization, shield from Core, shield from Platform, transfer, unshield, and +withdraw) resolves the wallet seed just in time. A password-protected (Tier-2) +wallet therefore requires an interactive passphrase session. Standalone stdio MCP and headless HTTP MCP use a null secret prompt because they have no authorized GUI session. These transports return `SecretPromptUnavailable` when a protected wallet needs to authorize a -shielded spend. There is currently no environment-variable or CLI-passphrase -workaround, so unattended shielded spending from a protected wallet is not -supported. Use an unprotected wallet for that automation. +shielded operation. There is currently no environment-variable or +CLI-passphrase workaround, so unattended shielded operations from a protected +wallet are not supported. Use an unprotected wallet for that automation. MCP embedded in a running GUI is not affected in the same way: it can use the GUI's existing interactive prompt and authorized secret session. This caveat From 9f3d2305bc4d1292dc8feb4e28f4737075065eee Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:09:41 +0000 Subject: [PATCH 18/20] test(wallet): add local secret-wipe fault-injection hook Follow-up to 958c32a8: adds the forget_wallet_local_state_test_failure test-only hook on WalletBackend (cfg(test)-gated, mirrors the existing registration_test_failure/clear_shielded_test_failure pattern in this file) that the remove_wallet_warns_when_local_secret_wipe_fails regression test forces to exercise the new warning-banner path. Co-Authored-By: Codex Sol --- src/wallet_backend/mod.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/wallet_backend/mod.rs b/src/wallet_backend/mod.rs index aa93b7d53..9e54d2cf3 100644 --- a/src/wallet_backend/mod.rs +++ b/src/wallet_backend/mod.rs @@ -339,6 +339,8 @@ struct Inner { registration_test_failure: std::sync::atomic::AtomicBool, #[cfg(test)] clear_shielded_test_failure: std::sync::atomic::AtomicBool, + #[cfg(test)] + forget_wallet_local_state_test_failure: std::sync::atomic::AtomicBool, /// Per-wallet shared-result flights for upstream registration. Every caller /// that joins an active flight awaits the same success or typed error. registration_flights: @@ -526,6 +528,8 @@ impl WalletBackend { registration_test_failure: std::sync::atomic::AtomicBool::new(false), #[cfg(test)] clear_shielded_test_failure: std::sync::atomic::AtomicBool::new(false), + #[cfg(test)] + forget_wallet_local_state_test_failure: std::sync::atomic::AtomicBool::new(false), registration_flights: std::sync::Mutex::new(std::collections::BTreeMap::new()), dashpay_request_action_locks: dashpay::ContactRequestActionLocks::default(), wallets: std::sync::RwLock::new(std::collections::BTreeMap::new()), @@ -1036,6 +1040,13 @@ impl WalletBackend { .store(fail, std::sync::atomic::Ordering::Relaxed); } + #[cfg(test)] + pub(crate) fn set_forget_wallet_local_state_test_failure(&self, fail: bool) { + self.inner + .forget_wallet_local_state_test_failure + .store(fail, std::sync::atomic::Ordering::Relaxed); + } + /// Resolve one just-registered upstream wallet into the DET-keyed maps via /// the account-xpub fund-routing gate, identical in spirit to the gate the /// seedless loader applies per wallet. @@ -1268,6 +1279,15 @@ impl WalletBackend { seed_hash: &WalletSeedHash, wallet_id: Option, ) -> Result<(), TaskError> { + #[cfg(test)] + if self + .inner + .forget_wallet_local_state_test_failure + .load(std::sync::atomic::Ordering::Relaxed) + { + return Err(TaskError::WalletDataClearUnavailable); + } + let mut first_error: Option = None; // Seed vault — delete BOTH the raw `seed.raw.v1` (the current form) and From b17e71ca4d272a860f1a95a32968e17d3df259e0 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:10:03 +0000 Subject: [PATCH 19/20] fix(wallet): surface fatal persisted-load corruption as a dedicated error The dashpay/platform pin bump (0146af33) picked up a stricter upstream persister: core_state::load_state in platform-wallet-storage now validates the core_transactions.txid column's fixed width on every row and fails the entire cold-boot load hard when it's malformed, where the prior pin never read that column at load time at all (a corrupt row was silently untouched). This is an intentional upstream tightening, accepted here rather than worked around. Add TaskError::WalletLocalDataLoadFailed with an accurate, actionable message ("Saved wallet data appears damaged and cannot be loaded. Restore the wallet from its recovery phrase to keep using it.") and map only PlatformWalletError::PersisterLoad(PersistenceError::Backend { kind: Fatal, .. }) to it in load_from_persistor_seedless, leaving Transient/Constraint and every other PersisterLoad-adjacent call site on its existing behavior. The prior generic "please retry" wording was actively misleading for this case: the corruption is permanent, so retrying fails identically every time. Renamed and rewrote the now-stale regression test cold_boot_keeps_wallet_visible_when_persisted_transaction_txid_is_corrupt (assumed the old lenient, skip-corrupt-row contract) to cold_boot_surfaces_typed_error_when_persisted_transaction_txid_is_corrupt, asserting the new typed error instead. Co-Authored-By: Codex Sol --- src/backend_task/error.rs | 10 +++++++ src/context/wallet_lifecycle/tests.rs | 43 ++++++--------------------- src/wallet_backend/mod.rs | 14 +++++++-- 3 files changed, 31 insertions(+), 36 deletions(-) diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 81d394d6a..474c83b6c 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -204,6 +204,16 @@ pub enum TaskError { )] WalletPaymentOptionUnsupported, + /// A fatal persisted-load failure prevented the wallet backend from + /// restoring locally saved wallet state during startup. + #[error( + "Saved wallet data appears damaged and cannot be loaded. Restore the wallet from its recovery phrase to keep using it." + )] + WalletLocalDataLoadFailed { + #[source] + source: std::sync::Arc, + }, + /// A wallet operation failed inside the upstream wallet runtime. #[error("The wallet service could not complete this operation. Please retry in a moment.")] WalletBackend { diff --git a/src/context/wallet_lifecycle/tests.rs b/src/context/wallet_lifecycle/tests.rs index 0bdb8e0d7..de0af38f5 100644 --- a/src/context/wallet_lifecycle/tests.rs +++ b/src/context/wallet_lifecycle/tests.rs @@ -3977,10 +3977,10 @@ async fn cold_boot_skips_corrupt_fvk_for_one_wallet_and_restores_healthy_wallet( let _ = ctx.subtasks.shutdown_async().await; } -/// A corrupt persisted Core transaction must not hide the wallet or prevent -/// valid history from populating its first seedless cold-boot snapshot. +/// A corrupt persisted Core transaction must surface the dedicated fatal +/// local-data load error during seedless cold boot. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn cold_boot_keeps_wallet_visible_when_persisted_transaction_txid_is_corrupt() { +async fn cold_boot_surfaces_typed_error_when_persisted_transaction_txid_is_corrupt() { use dash_sdk::dpp::dashcore::hashes::Hash; use dash_sdk::dpp::dashcore::{BlockHash, Transaction}; use dash_sdk::dpp::key_wallet::account::{AccountType, StandardAccountType}; @@ -3998,7 +3998,7 @@ async fn cold_boot_keeps_wallet_visible_when_persisted_transaction_txid_is_corru let source_dir = tempfile::tempdir().expect("source tempdir"); let seed = [0xD4u8; 64]; - let (seed_hash, wallet_id) = { + let (_seed_hash, wallet_id) = { let wallet = crate::model::wallet::Wallet::new_from_seed(seed, Network::Testnet, None, None) .expect("build wallet"); @@ -4046,8 +4046,6 @@ async fn cold_boot_keeps_wallet_visible_when_persisted_transaction_txid_is_corru Vec::new(), 250_000, ); - let expected_txid = record.txid; - let cold_dir = tempfile::tempdir().expect("cold tempdir"); copy_dir_recursive(source_dir.path(), cold_dir.path()); @@ -4089,37 +4087,14 @@ async fn cold_boot_keeps_wallet_visible_when_persisted_transaction_txid_is_corru drop(connection); let (ctx, sender) = offline_testnet_context_at(cold_dir.path()); - ctx.ensure_wallet_backend(sender) + let error = ctx + .ensure_wallet_backend(sender) .await - .expect("corrupt history must not prevent cold-boot registration"); - let backend = ctx.wallet_backend().expect("cold-boot backend"); - let history = backend.transaction_history(&seed_hash); - - assert!( - backend.is_wallet_registered(&seed_hash), - "the wallet must remain registered when one history row is corrupt" - ); + .expect_err("corrupt persisted transaction id must fail cold-boot loading"); assert!( - backend.has_snapshot(&seed_hash), - "the wallet must remain visible when one history row is corrupt" + matches!(error, TaskError::WalletLocalDataLoadFailed { .. }), + "fatal persisted-wallet corruption must use the dedicated error, got: {error:?}" ); - assert_eq!( - history.len(), - 1, - "the corrupt row must be skipped without dropping valid history" - ); - assert_eq!(history[0].txid, expected_txid); - assert_eq!(history[0].timestamp, u64::from(timestamp)); - assert_eq!(history[0].net_amount, 250_000); - assert!(matches!( - backend.transaction_history_status(&seed_hash), - crate::wallet_backend::TransactionHistoryStatus::Partial { - skipped_rows: 1, - .. - } - )); - - backend.shutdown().await; } /// Build a minimal basic identity for manager-reconcile tests — only its diff --git a/src/wallet_backend/mod.rs b/src/wallet_backend/mod.rs index 9e54d2cf3..66efe0213 100644 --- a/src/wallet_backend/mod.rs +++ b/src/wallet_backend/mod.rs @@ -691,12 +691,22 @@ impl WalletBackend { // already holds the wallets, so the upstream load (which rejects // duplicates) is skipped and only the resolution below runs. if self.inner.pwm.wallet_ids().await.is_empty() { + use platform_wallet::changeset::{PersistenceError, PersistenceErrorKind}; + self.inner .pwm .load_from_persistor() .await - .map_err(|e| TaskError::WalletBackend { - source: Arc::new(e), + .map_err(|source| match source { + source @ PlatformWalletError::PersisterLoad(PersistenceError::Backend { + kind: PersistenceErrorKind::Fatal, + .. + }) => TaskError::WalletLocalDataLoadFailed { + source: Arc::new(source), + }, + source => TaskError::WalletBackend { + source: Arc::new(source), + }, })?; } From 2b2da63017fcd6a1a42d9a28c05067444b18a5f6 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:00:59 +0000 Subject: [PATCH 20/20] docs: fix stale platform pin reference in CHANGELOG The pin-bump changelog entry still cited the d18020f -> 4a1ba64 range from an earlier round; the manifest has since moved on to 288a6cae. Update the cited commit so the changelog matches what is actually pinned in Cargo.toml/Cargo.lock. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cafc686d..6c33f9bd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,7 +61,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Upstream wallet backend updated (`platform-wallet` / `platform-wallet-storage`)**: the `dashpay/platform` dependency is bumped to the PR #3968 tip - (`d18020f` → `4a1ba64`), which lands an embeddable SQLite persistence backend with + (`d18020f` → `288a6ca`), which lands an embeddable SQLite persistence backend with *seedless rehydration*. The wallet manager now restores watch-only wallet state (accounts, balances, identities, platform addresses) from the on-disk store without the HD seed, re-deriving spend authority just-in-time from the seed only