From 1e83b53cfbf0ff0d5fbd4ec12bf92f158a57dfc7 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 6 May 2026 06:13:00 +0700 Subject: [PATCH 01/78] feat(swift-sdk,platform-wallet): wire shielded transfer/unshield/withdraw end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shielded send was stubbed out behind a "rebuilt in follow-up PR" placeholder for the four send flows even though `ShieldedWallet::transfer` / `unshield` / `withdraw` already exist on the Rust side and need only the bound shielded wallet's cached `SpendAuthorizingKey` (no host signer). This commit threads them through to the Swift Send sheet. platform-wallet - New `PlatformWalletError::ShieldedNotBound` so the wrapper can distinguish "wallet has no shielded sub-wallet" from a build / broadcast failure. - New `PlatformWallet` wrappers under the existing `shielded` feature: `shielded_transfer_to(recipient_raw_43, amount, prover)`, `shielded_unshield_to(to_platform_addr_bytes, amount, prover)`, `shielded_withdraw_to(to_core_address, amount, core_fee_per_byte, prover)`. Each takes the prover by value because `OrchardProver` is impl'd on `&CachedOrchardProver` (not the bare struct), and forwards `&prover` into the underlying `ShieldedWallet` op. Address parsing is inline — Orchard 43-byte raw → `PaymentAddress`, bincode `PlatformAddress::from_bytes`, `dashcore::Address` from string with network-match check. platform-wallet-ffi - New module `shielded_send` (feature-gated `shielded`): - `platform_wallet_shielded_warm_up_prover()` — fire-and-forget global, no manager handle. - `platform_wallet_shielded_prover_is_ready()` — bool getter for a UI affordance. - `platform_wallet_manager_shielded_transfer/unshield/withdraw` — manager-handle FFIs that resolve the wallet, instantiate a `CachedOrchardProver`, and forward to the wallet wrappers via `runtime().block_on(...)`. swift-sdk - New `PlatformWalletManager` async methods: `shieldedTransfer(walletId:recipientRaw43:amount:)`, `shieldedUnshield(walletId:toPlatformAddress:amount:)`, `shieldedWithdraw(walletId:toCoreAddress:amount:coreFeePerByte:)`. All run on a `Task.detached(priority: .userInitiated)` so the ~30 s first-call proof build doesn't block the main actor. - Static helpers `PlatformWalletManager.warmUpShieldedProver()` and `PlatformWalletManager.isShieldedProverReady`. swift-example-app - `SendViewModel.executeSend` gains a `walletManager` parameter and replaces three of the four shielded placeholder branches with the real FFI calls (Shielded → Shielded, Shielded → Platform, Shielded → Core). The Platform → Shielded branch retains a clearer placeholder because Type 15 still needs the per-input nonce fetch the Rust spend builder stubs to zero. - `SwiftExampleAppApp.bootstrap` kicks off `warmUpShieldedProver()` on a background task at app start so the first user-initiated shielded send doesn't pay the build cost inline. Verified: - `cargo fmt --all`, `cargo clippy --workspace --all-features --locked -- --no-deps -D warnings` clean. - `bash build_ios.sh --target sim --profile dev` green (** BUILD SUCCEEDED **). The end-to-end story is still missing Platform → Shielded (blocked on the spend builder's nonce TODO) and a host `Signer` adapter, plus the optional Type 18 `shield_from_asset_lock`. Wallets that already have shielded balance can now move it freely. --- packages/rs-platform-wallet-ffi/src/lib.rs | 4 + .../src/shielded_send.rs | 235 ++++++++++++++++++ packages/rs-platform-wallet/src/error.rs | 3 + .../src/wallet/platform_wallet.rs | 87 +++++++ .../PlatformWalletManagerShieldedSync.swift | 154 ++++++++++++ .../Core/ViewModels/SendViewModel.swift | 71 +++++- .../Core/Views/SendTransactionView.swift | 1 + .../SwiftExampleApp/SwiftExampleAppApp.swift | 8 + 8 files changed, 551 insertions(+), 12 deletions(-) create mode 100644 packages/rs-platform-wallet-ffi/src/shielded_send.rs diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index 0085d8c8547..764d7b89e39 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -52,6 +52,8 @@ pub mod platform_addresses; pub mod platform_wallet_info; mod runtime; #[cfg(feature = "shielded")] +pub mod shielded_send; +#[cfg(feature = "shielded")] pub mod shielded_sync; pub mod shielded_types; pub mod sign_with_mnemonic_resolver; @@ -107,6 +109,8 @@ pub use platform_address_types::*; pub use platform_addresses::*; pub use platform_wallet_info::*; #[cfg(feature = "shielded")] +pub use shielded_send::*; +#[cfg(feature = "shielded")] pub use shielded_sync::*; pub use shielded_types::*; pub use sign_with_mnemonic_resolver::*; diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs new file mode 100644 index 00000000000..5fb1341c594 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -0,0 +1,235 @@ +//! FFI bindings for the shielded spend pipeline (transitions +//! 16/17/19 — transfer, unshield, withdraw). +//! +//! These three transitions sign with the bound shielded wallet's +//! Orchard `SpendAuthorizingKey`, which lives on the +//! `OrchardKeySet` cached after [`platform_wallet_manager_bind_shielded`]. +//! No host-side `Signer` is required — the host +//! only supplies the recipient + amount (+ core fee rate for +//! withdrawal) and the resulting Halo 2 proof + state transition +//! is built and broadcast on the Rust side. +//! +//! The fourth transition (Type 15 `shield` — Platform→Shielded) +//! and Type 18 (`shield_from_asset_lock` — Core L1→Shielded) live +//! elsewhere in `platform-wallet`'s [`ShieldedWallet`] surface but +//! aren't wired here yet — they need a host-supplied +//! `Signer` (or asset-lock proof + private key) +//! plus per-input nonce fetching that the Rust spend builder +//! today stubs to zero. +//! +//! Feature-gated behind `shielded`. The accompanying +//! [`platform_wallet_shielded_warm_up_prover`] entry-point is +//! also defined here so hosts can pre-build the Halo 2 proving +//! key on a background thread at app startup. +//! +//! [`ShieldedWallet`]: platform_wallet::wallet::shielded::ShieldedWallet + +use std::ffi::CStr; +use std::os::raw::c_char; + +use platform_wallet::wallet::shielded::CachedOrchardProver; + +use crate::check_ptr; +use crate::error::*; +use crate::handle::*; +use crate::runtime::runtime; + +/// Build the Halo 2 proving key now if it hasn't been built yet. +/// +/// First-call latency is ~30 seconds; subsequent calls return +/// immediately. Hosts should fire this on a background thread at +/// app startup so the first shielded send doesn't block the user. +/// Safe to call repeatedly and from any thread. +/// +/// Independent of any manager — the cache is a process-global +/// `OnceLock`. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_shielded_warm_up_prover() { + CachedOrchardProver::new().warm_up(); +} + +/// Whether the Halo 2 proving key has already been built. +/// +/// Useful as a UI indicator ("preparing prover…") before the +/// first shielded send. `false` doesn't mean shielded sends will +/// fail — it just means the next one will pay the ~30s build +/// cost up front. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_shielded_prover_is_ready() -> bool { + CachedOrchardProver::new().is_ready() +} + +/// Send a shielded → shielded transfer. +/// +/// Spends notes from `wallet_id`'s shielded balance and creates a +/// new note for `recipient_raw_43`. `amount` is in credits +/// (1 DASH = 1e11 credits). Errors if the wallet has no bound +/// shielded sub-wallet, no spendable notes, or insufficient +/// shielded balance to cover `amount + estimated_fee`. +/// +/// # Safety +/// - `wallet_id_bytes` must point to 32 readable bytes. +/// - `recipient_raw_43` must point to 43 readable bytes (the +/// recipient's raw Orchard payment address — same shape +/// `platform_wallet_manager_shielded_default_address` returns). +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_shielded_transfer( + handle: Handle, + wallet_id_bytes: *const u8, + recipient_raw_43: *const u8, + amount: u64, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id_bytes); + check_ptr!(recipient_raw_43); + + let mut wallet_id = [0u8; 32]; + std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); + let mut recipient = [0u8; 43]; + std::ptr::copy_nonoverlapping(recipient_raw_43, recipient.as_mut_ptr(), 43); + + let wallet = match resolve_wallet(handle, &wallet_id) { + Ok(w) => w, + Err(result) => return result, + }; + let prover = CachedOrchardProver::new(); + let prover_ref: &CachedOrchardProver = &prover; + + if let Err(e) = runtime().block_on(wallet.shielded_transfer_to(&recipient, amount, prover_ref)) + { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("shielded transfer failed: {e}"), + ); + } + PlatformWalletFFIResult::ok() +} + +/// Unshield: spend shielded notes and send `amount` credits to a +/// platform address. +/// +/// `to_platform_addr_bytes` is the bincode-encoded +/// `PlatformAddress` — `0x00 ‖ 20-byte hash` for P2PKH, +/// `0x01 ‖ 20-byte hash` for P2SH. `to_platform_addr_len` is +/// typically 21. +/// +/// # Safety +/// - `wallet_id_bytes` must point to 32 readable bytes. +/// - `to_platform_addr_bytes` must point to `to_platform_addr_len` +/// readable bytes. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_shielded_unshield( + handle: Handle, + wallet_id_bytes: *const u8, + to_platform_addr_bytes: *const u8, + to_platform_addr_len: usize, + amount: u64, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id_bytes); + check_ptr!(to_platform_addr_bytes); + if to_platform_addr_len == 0 { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "to_platform_addr_len must be > 0", + ); + } + + let mut wallet_id = [0u8; 32]; + std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); + let to_addr = std::slice::from_raw_parts(to_platform_addr_bytes, to_platform_addr_len).to_vec(); + + let wallet = match resolve_wallet(handle, &wallet_id) { + Ok(w) => w, + Err(result) => return result, + }; + let prover = CachedOrchardProver::new(); + let prover_ref: &CachedOrchardProver = &prover; + + if let Err(e) = runtime().block_on(wallet.shielded_unshield_to(&to_addr, amount, prover_ref)) { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("shielded unshield failed: {e}"), + ); + } + PlatformWalletFFIResult::ok() +} + +/// Withdraw: spend shielded notes and send `amount` credits to a +/// Core L1 address. `to_core_address_cstr` is the address as a +/// Base58Check NUL-terminated UTF-8 string (e.g. +/// `"yL...."` on testnet); the Rust side parses it and verifies +/// the network matches the wallet's. `core_fee_per_byte` is the +/// L1 fee rate in duffs/byte (`1` is the dashmate default). +/// +/// # Safety +/// - `wallet_id_bytes` must point to 32 readable bytes. +/// - `to_core_address_cstr` must be a valid NUL-terminated UTF-8 +/// C string for the duration of the call. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_shielded_withdraw( + handle: Handle, + wallet_id_bytes: *const u8, + to_core_address_cstr: *const c_char, + amount: u64, + core_fee_per_byte: u32, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id_bytes); + check_ptr!(to_core_address_cstr); + + let mut wallet_id = [0u8; 32]; + std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); + let to_core = match CStr::from_ptr(to_core_address_cstr).to_str() { + Ok(s) => s.to_string(), + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorUtf8Conversion, + format!("to_core_address is not valid UTF-8: {e}"), + ); + } + }; + + let wallet = match resolve_wallet(handle, &wallet_id) { + Ok(w) => w, + Err(result) => return result, + }; + let prover = CachedOrchardProver::new(); + let prover_ref: &CachedOrchardProver = &prover; + + if let Err(e) = runtime().block_on(wallet.shielded_withdraw_to( + &to_core, + amount, + core_fee_per_byte, + prover_ref, + )) { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("shielded withdraw failed: {e}"), + ); + } + PlatformWalletFFIResult::ok() +} + +/// Resolve the wallet `Arc` for the given manager handle, or +/// produce a `PlatformWalletFFIResult` describing why we couldn't. +fn resolve_wallet( + handle: Handle, + wallet_id: &[u8; 32], +) -> Result, PlatformWalletFFIResult> { + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + runtime().block_on(manager.get_wallet(wallet_id)) + }); + let inner_option = match option { + Some(v) => v, + None => { + return Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + format!("invalid manager handle: {handle}"), + )); + } + }; + inner_option.ok_or_else(|| { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("wallet not found: {}", hex::encode(wallet_id)), + ) + }) +} diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 006e9b01331..2c5e94f833f 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -135,6 +135,9 @@ pub enum PlatformWalletError { #[error("Shielded key derivation failed: {0}")] ShieldedKeyDerivation(String), + + #[error("Shielded sub-wallet not bound: call bind_shielded first")] + ShieldedNotBound, } /// Check whether an SDK error indicates that an InstantSend lock proof was diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index dcd9486798e..45b6e509598 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -360,6 +360,93 @@ impl PlatformWallet { .as_ref() .map(|w| w.default_address().to_raw_address_bytes()) } + + /// Send a private shielded → shielded transfer. Spends notes + /// from this wallet's shielded balance and sends `amount` + /// credits to `recipient_raw_43` (the recipient's Orchard + /// payment address as the 43 raw bytes — same shape + /// [`shielded_default_address`](Self::shielded_default_address) + /// returns). + /// + /// The prover is consumed by value rather than borrowed because + /// `OrchardProver` is impl'd on `&CachedOrchardProver` (the + /// reference type), not on the bare struct. Callers pass + /// `&CachedOrchardProver::new()` and we forward it down to the + /// underlying `ShieldedWallet::transfer`'s `&P` parameter. + #[cfg(feature = "shielded")] + pub async fn shielded_transfer_to( + &self, + recipient_raw_43: &[u8; 43], + amount: u64, + prover: P, + ) -> Result<(), PlatformWalletError> { + let guard = self.shielded.read().await; + let shielded = guard + .as_ref() + .ok_or(PlatformWalletError::ShieldedNotBound)?; + let recipient = Option::::from( + grovedb_commitment_tree::PaymentAddress::from_raw_address_bytes(recipient_raw_43), + ) + .ok_or_else(|| { + PlatformWalletError::ShieldedBuildError( + "invalid Orchard payment address bytes".to_string(), + ) + })?; + shielded.transfer(&recipient, amount, &prover).await + } + + /// Unshield: spend shielded notes and send `amount` credits to + /// the platform address `to_platform_addr_bytes` (bincode- + /// encoded `PlatformAddress` — `0x00 ‖ 20-byte hash` for + /// P2PKH, `0x01 ‖ 20-byte hash` for P2SH). + #[cfg(feature = "shielded")] + pub async fn shielded_unshield_to( + &self, + to_platform_addr_bytes: &[u8], + amount: u64, + prover: P, + ) -> Result<(), PlatformWalletError> { + let guard = self.shielded.read().await; + let shielded = guard + .as_ref() + .ok_or(PlatformWalletError::ShieldedNotBound)?; + let to = dpp::address_funds::PlatformAddress::from_bytes(to_platform_addr_bytes).map_err( + |e| PlatformWalletError::ShieldedBuildError(format!("invalid platform address: {e}")), + )?; + shielded.unshield(&to, amount, &prover).await + } + + /// Withdraw: spend shielded notes and send `amount` credits to + /// the Core L1 address `to_core_address` (Base58Check string). + /// `core_fee_per_byte` is the L1 fee rate (duffs/byte). + #[cfg(feature = "shielded")] + pub async fn shielded_withdraw_to( + &self, + to_core_address: &str, + amount: u64, + core_fee_per_byte: u32, + prover: P, + ) -> Result<(), PlatformWalletError> { + let guard = self.shielded.read().await; + let shielded = guard + .as_ref() + .ok_or(PlatformWalletError::ShieldedNotBound)?; + let network = self.sdk.network; + let parsed = to_core_address + .parse::>() + .map_err(|e| { + PlatformWalletError::ShieldedBuildError(format!("invalid core address: {e}")) + })? + .require_network(network) + .map_err(|e| { + PlatformWalletError::ShieldedBuildError(format!( + "core address network mismatch: {e}" + )) + })?; + shielded + .withdraw(&parsed, amount, core_fee_per_byte, &prover) + .await + } } impl PlatformWallet { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index 1b739d1145c..d929d618dea 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -229,6 +229,160 @@ extension PlatformWalletManager { return present ? Data(bytes) : nil } + /// Build the Halo 2 proving key on a background thread so the + /// first shielded send doesn't pay the ~30 s build cost + /// inline. Idempotent and safe to call from any thread; later + /// calls return immediately. Independent of any wallet — the + /// cache is process-global on the Rust side. + public static func warmUpShieldedProver() async { + await Task.detached(priority: .background) { + platform_wallet_shielded_warm_up_prover() + }.value + } + + /// Whether the Halo 2 proving key has been built yet. Useful + /// for a "preparing prover…" UI affordance — `false` doesn't + /// mean shielded sends will fail, just that the next one + /// pays the build cost. + public static var isShieldedProverReady: Bool { + platform_wallet_shielded_prover_is_ready() + } + + /// Shielded → Shielded transfer. Spends notes from `walletId`'s + /// shielded balance and creates a new note for `recipientRaw43` + /// (the recipient's raw 43-byte Orchard payment address). Amount + /// is in credits (1 DASH = 1e11). Heavy CPU work runs on a + /// detached task so the caller's actor isn't blocked through + /// the proof build. + public func shieldedTransfer( + walletId: Data, + recipientRaw43: Data, + amount: UInt64 + ) async throws { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidHandle( + "PlatformWalletManager not configured" + ) + } + guard walletId.count == 32 else { + throw PlatformWalletError.invalidParameter( + "walletId must be exactly 32 bytes" + ) + } + guard recipientRaw43.count == 43 else { + throw PlatformWalletError.invalidParameter( + "recipient must be exactly 43 raw Orchard bytes" + ) + } + + let handle = self.handle + try await Task.detached(priority: .userInitiated) { + try walletId.withUnsafeBytes { widRaw in + guard let widPtr = widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self) + else { + throw PlatformWalletError.invalidParameter("walletId baseAddress is nil") + } + try recipientRaw43.withUnsafeBytes { recipientRaw in + guard let recipientPtr = recipientRaw.baseAddress? + .assumingMemoryBound(to: UInt8.self) + else { + throw PlatformWalletError.invalidParameter( + "recipient baseAddress is nil" + ) + } + try platform_wallet_manager_shielded_transfer( + handle, widPtr, recipientPtr, amount + ).check() + } + } + }.value + } + + /// Shielded → Platform unshield. Spends notes from `walletId`'s + /// shielded balance and credits the platform address + /// `toPlatformAddress` (bincode-encoded `PlatformAddress` — + /// `0x00 ‖ 20-byte hash` for P2PKH). + public func shieldedUnshield( + walletId: Data, + toPlatformAddress: Data, + amount: UInt64 + ) async throws { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidHandle( + "PlatformWalletManager not configured" + ) + } + guard walletId.count == 32 else { + throw PlatformWalletError.invalidParameter( + "walletId must be exactly 32 bytes" + ) + } + guard !toPlatformAddress.isEmpty else { + throw PlatformWalletError.invalidParameter( + "toPlatformAddress is empty" + ) + } + + let handle = self.handle + try await Task.detached(priority: .userInitiated) { + try walletId.withUnsafeBytes { widRaw in + guard let widPtr = widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self) + else { + throw PlatformWalletError.invalidParameter("walletId baseAddress is nil") + } + try toPlatformAddress.withUnsafeBytes { addrRaw in + guard let addrPtr = addrRaw.baseAddress? + .assumingMemoryBound(to: UInt8.self) + else { + throw PlatformWalletError.invalidParameter( + "toPlatformAddress baseAddress is nil" + ) + } + try platform_wallet_manager_shielded_unshield( + handle, widPtr, addrPtr, UInt(toPlatformAddress.count), amount + ).check() + } + } + }.value + } + + /// Shielded → Core L1 withdraw. Spends notes from `walletId`'s + /// shielded balance and creates an L1 withdrawal to + /// `toCoreAddress` (Base58Check string). `coreFeePerByte` is + /// the L1 fee rate in duffs/byte (`1` is the dashmate default). + public func shieldedWithdraw( + walletId: Data, + toCoreAddress: String, + amount: UInt64, + coreFeePerByte: UInt32 = 1 + ) async throws { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidHandle( + "PlatformWalletManager not configured" + ) + } + guard walletId.count == 32 else { + throw PlatformWalletError.invalidParameter( + "walletId must be exactly 32 bytes" + ) + } + + let handle = self.handle + try await Task.detached(priority: .userInitiated) { + try walletId.withUnsafeBytes { widRaw in + guard let widPtr = widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self) + else { + throw PlatformWalletError.invalidParameter("walletId baseAddress is nil") + } + try toCoreAddress.withCString { addrCStr in + try platform_wallet_manager_shielded_withdraw( + handle, widPtr, addrCStr, amount, coreFeePerByte + ).check() + } + } + }.value + } + public func syncShieldedWalletNow(walletId: Data) async throws { guard isConfigured, handle != NULL_HANDLE else { throw PlatformWalletError.invalidHandle( diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift index d07dc7a7d06..540a1c6a225 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -158,6 +158,7 @@ class SendViewModel: ObservableObject { func executeSend( sdk: SDK, + walletManager: PlatformWalletManager, shieldedService: ShieldedService, platformState: AppState, wallet: PersistentWallet, @@ -184,22 +185,68 @@ class SendViewModel: ObservableObject { ) successMessage = "Payment sent" - case .platformToShielded, - .shieldedToShielded, - .shieldedToPlatform, - .shieldedToCore: - // Shielded send paths are being moved to the Rust - // platform-wallet shielded coordinator. The previous - // SDK-side bundle/build/broadcast surface was deleted - // along with the duplicate `ShieldedPoolClient` FFI; - // wiring back up against the new manager-driven path - // happens in a follow-up PR. + case .shieldedToShielded: + // Shielded → Shielded: spend notes from this + // wallet's shielded balance, create a new note + // for the recipient. Recipient bytes come from + // the bech32m parser as raw 43-byte Orchard + // address; matches what the manager's transfer + // FFI expects. + let parsed = DashAddress.parse(recipientAddress, network: network) + guard case .orchard(let recipientRaw) = parsed.type else { + error = "Recipient is not a shielded address" + return + } + try await walletManager.shieldedTransfer( + walletId: wallet.walletId, + recipientRaw43: recipientRaw, + amount: amount + ) + successMessage = "Shielded transfer complete" + + case .shieldedToPlatform: + // Shielded → Platform: spend notes, credit the + // platform address. `addressBytes` is the 21-byte + // bincode-encoded `PlatformAddress` shape (type + // byte + 20-byte hash). + let parsed = DashAddress.parse(recipientAddress, network: network) + guard case .platform(let addressBytes) = parsed.type else { + error = "Recipient is not a platform address" + return + } + try await walletManager.shieldedUnshield( + walletId: wallet.walletId, + toPlatformAddress: addressBytes, + amount: amount + ) + successMessage = "Unshield complete" + + case .shieldedToCore: + // Shielded → Core L1: spend notes, create an L1 + // withdrawal. The manager parses the Base58Check + // address Rust-side; we just hand the trimmed + // string through. + let trimmed = recipientAddress.trimmingCharacters(in: .whitespacesAndNewlines) + try await walletManager.shieldedWithdraw( + walletId: wallet.walletId, + toCoreAddress: trimmed, + amount: amount, + coreFeePerByte: 1 + ) + successMessage = "Withdrawal submitted" + + case .platformToShielded: + // Platform → Shielded (Type 15) needs a + // `Signer` adapter and the + // per-input nonce fetch — the Rust spend builder + // currently stubs the nonce to 0. Tracked for a + // follow-up; surface a clear error so the UI + // doesn't pretend to handle this yet. _ = platformState _ = shieldedService - _ = wallet _ = modelContext _ = sdk - error = "Shielded sending is being rebuilt — see follow-up PR" + error = "Platform → Shielded is not wired yet — follow-up PR" return } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift index 80ffe41816a..2e4b0a7b71b 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift @@ -169,6 +169,7 @@ struct SendTransactionView: View { .coreWallet() await viewModel.executeSend( sdk: sdk, + walletManager: walletManager, shieldedService: shieldedService, platformState: platformState, wallet: wallet, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index 05c619dd754..bc506860ff2 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -239,6 +239,14 @@ struct SwiftExampleAppApp: App { do { LoggingPreferences.configure() + // Kick off Halo 2 proving-key build on a background + // thread so the first shielded send doesn't pay the + // ~30 s build cost inline. Idempotent — global + // OnceLock on the Rust side guards repeat calls. + Task.detached(priority: .background) { + await PlatformWalletManager.warmUpShieldedProver() + } + platformState.initializeSDK(modelContext: modelContainer.mainContext) // Give the Platform SDK a moment to finish its internal init. From 2180c68973cb902cdbfe61145fd4c6a4c05da03e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 6 May 2026 06:30:21 +0700 Subject: [PATCH 02/78] =?UTF-8?q?feat(swift-sdk,platform-wallet):=20wire?= =?UTF-8?q?=20shield=20(Platform=20=E2=86=92=20Shielded,=20Type=2015)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the four shielded send flows by lighting up Type 15. The Rust spend pipeline already had `ShieldedWallet::shield` but stubbed every input's nonce to 0, which drive-abci rejected on broadcast. This commit: platform-wallet - `ShieldedWallet::shield` now fetches per-input nonces from Platform via `AddressInfo::fetch_many` and increments them before handing to `build_shield_transition`. Removes the long-standing `nonce=0` placeholder + TODO. - New `PlatformWallet::shielded_shield_from_account` helper with auto input selection: walks the chosen Platform Payment account's addresses in ascending derivation order and picks enough to cover `amount + 0.01 DASH` fee buffer (the on-chain fee comes off input 0 via `DeductFromInput(0)`). Returns `ShieldedInsufficientBalance` if the account total can't cover the request. rs-platform-wallet-ffi - New `platform_wallet_manager_shielded_shield(handle, wallet_id, account_index, amount, signer_address_handle)` in `shielded_send.rs`. Takes a `*mut SignerHandle` (Swift's `KeychainSigner.handle`) and casts to `&VTableSigner` — same shape `platform_address_wallet_transfer` uses, since `VTableSigner` already implements `Signer`. swift-sdk - New async method `PlatformWalletManager.shieldedShield( walletId:accountIndex:amount:addressSigner:)`. Threads the `KeychainSigner` keepalive through the detached task the same way `topUpFromAddresses` does. swift-example-app - `SendViewModel.executeSend`'s `.platformToShielded` branch now constructs a `KeychainSigner` and calls `walletManager.shieldedShield(...)`. Replaces the last of the four shielded placeholder errors. The full Send Dash matrix is now real: | Source | Destination | Status | |------------|--------------|------------| | Core | Core | works | | Platform | Shielded | works (this PR) | | Shielded | Shielded | works | | Shielded | Platform | works | | Shielded | Core | works | Type 18 (`shield_from_asset_lock`) — direct Core L1 → Shielded without going through Platform first — is still unwired; tracked separately. --- .../src/shielded_send.rs | 81 ++++++++++++++-- .../src/wallet/platform_wallet.rs | 97 +++++++++++++++++++ .../src/wallet/shielded/operations.rs | 42 +++++--- .../PlatformWalletManagerShieldedSync.swift | 54 +++++++++++ .../Core/ViewModels/SendViewModel.swift | 24 +++-- 5 files changed, 269 insertions(+), 29 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 5fb1341c594..d8043d1309e 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -1,7 +1,7 @@ //! FFI bindings for the shielded spend pipeline (transitions -//! 16/17/19 — transfer, unshield, withdraw). +//! 15/16/17/19 — shield, transfer, unshield, withdraw). //! -//! These three transitions sign with the bound shielded wallet's +//! Transitions 16/17/19 sign with the bound shielded wallet's //! Orchard `SpendAuthorizingKey`, which lives on the //! `OrchardKeySet` cached after [`platform_wallet_manager_bind_shielded`]. //! No host-side `Signer` is required — the host @@ -9,13 +9,15 @@ //! withdrawal) and the resulting Halo 2 proof + state transition //! is built and broadcast on the Rust side. //! -//! The fourth transition (Type 15 `shield` — Platform→Shielded) -//! and Type 18 (`shield_from_asset_lock` — Core L1→Shielded) live -//! elsewhere in `platform-wallet`'s [`ShieldedWallet`] surface but -//! aren't wired here yet — they need a host-supplied -//! `Signer` (or asset-lock proof + private key) -//! plus per-input nonce fetching that the Rust spend builder -//! today stubs to zero. +//! Transition 15 (`shield` — Platform→Shielded) additionally +//! takes a host-supplied `Signer` because the +//! input addresses' ECDSA signatures live in the host keychain. +//! Per-input nonces are fetched from Platform inside +//! [`ShieldedWallet::shield`] before building. +//! +//! Type 18 (`shield_from_asset_lock` — Core L1→Shielded) lives on +//! [`ShieldedWallet`] but isn't wired here yet — it needs the +//! asset-lock proof + private key threaded through. //! //! Feature-gated behind `shielded`. The accompanying //! [`platform_wallet_shielded_warm_up_prover`] entry-point is @@ -23,11 +25,13 @@ //! key on a background thread at app startup. //! //! [`ShieldedWallet`]: platform_wallet::wallet::shielded::ShieldedWallet +//! [`ShieldedWallet::shield`]: platform_wallet::wallet::shielded::ShieldedWallet::shield use std::ffi::CStr; use std::os::raw::c_char; use platform_wallet::wallet::shielded::CachedOrchardProver; +use rs_sdk_ffi::{SignerHandle, VTableSigner}; use crate::check_ptr; use crate::error::*; @@ -208,6 +212,65 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_withdraw( PlatformWalletFFIResult::ok() } +/// Shield: spend credits from a Platform Payment account into +/// the bound shielded sub-wallet's pool. `account_index` selects +/// which Platform Payment account to draw from; the wallet +/// auto-selects input addresses in ascending derivation order +/// until the cumulative balance covers `amount + fee buffer`. +/// +/// `signer_address_handle` is a `*mut SignerHandle` produced by +/// `dash_sdk_signer_create_with_ctx` (typically Swift's +/// `KeychainSigner.handle`) — same shape +/// `platform_address_wallet_transfer` expects. The caller retains +/// ownership; this function does not destroy the handle. +/// +/// # Safety +/// - `wallet_id_bytes` must point to 32 readable bytes. +/// - `signer_address_handle` must be a valid, non-destroyed +/// `*mut SignerHandle` that outlives this call and points at a +/// `VTableSigner` with the callback variant (the native variant +/// doesn't satisfy `Signer`). +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_shielded_shield( + handle: Handle, + wallet_id_bytes: *const u8, + account_index: u32, + amount: u64, + signer_address_handle: *mut SignerHandle, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id_bytes); + check_ptr!(signer_address_handle); + + let mut wallet_id = [0u8; 32]; + std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); + + let wallet = match resolve_wallet(handle, &wallet_id) { + Ok(w) => w, + Err(result) => return result, + }; + + // SAFETY: caller guarantees `signer_address_handle` is a + // valid, non-destroyed handle that outlives this call. The + // `VTableSigner` is `Send + Sync` so dropping it back into a + // `block_on` future is safe. + let address_signer: &VTableSigner = &*(signer_address_handle as *const VTableSigner); + let prover = CachedOrchardProver::new(); + let prover_ref: &CachedOrchardProver = &prover; + + if let Err(e) = runtime().block_on(wallet.shielded_shield_from_account( + account_index, + amount, + address_signer, + prover_ref, + )) { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("shielded shield failed: {e}"), + ); + } + PlatformWalletFFIResult::ok() +} + /// Resolve the wallet `Arc` for the given manager handle, or /// produce a `PlatformWalletFFIResult` describing why we couldn't. fn resolve_wallet( diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 45b6e509598..d4a3b9cf574 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -447,6 +447,103 @@ impl PlatformWallet { .withdraw(&parsed, amount, core_fee_per_byte, &prover) .await } + + /// Shield credits from a Platform Payment account into this + /// wallet's shielded pool. Auto-selects input addresses from + /// the account in ascending derivation-index order until the + /// cumulative balance covers `amount` plus a conservative fee + /// buffer (the on-chain fee comes off input 0 via + /// `DeductFromInput(0)`; the buffer absorbs the discrepancy + /// without a more sophisticated estimator). + /// + /// The host supplies a `Signer` — typically + /// `&VTableSigner` from `KeychainSigner.handle` — which signs + /// each input's pubkey-hash binding to the Orchard bundle. + /// + /// Returns `ShieldedNotBound` if no shielded sub-wallet is + /// bound, `AddressOperation` if the platform-payment account + /// at `account_index` doesn't exist, or + /// `ShieldedInsufficientBalance` if the account's total + /// credits can't cover `amount + fee_buffer`. + #[cfg(feature = "shielded")] + pub async fn shielded_shield_from_account( + &self, + account_index: u32, + amount: u64, + signer: &S, + prover: P, + ) -> Result<(), PlatformWalletError> + where + S: dpp::identity::signer::Signer + Send + Sync, + P: dpp::shielded::builder::OrchardProver, + { + // Conservative fee buffer over `amount`. The shield + // transition's `DeductFromInput(0)` strategy lets the + // network deduct the actual fee from input 0; we just need + // to make sure the inputs cumulatively cover `amount + a + // bit`. Empty-mempool platform fees are well under + // 0.001 DASH (1e8 credits); 0.01 DASH absorbs a 10× spike. + const FEE_BUFFER_CREDITS: u64 = 1_000_000_000; + let needed = amount.saturating_add(FEE_BUFFER_CREDITS); + + // Build the inputs map under the wallet-manager read lock, + // then drop the lock before re-entering shielded so the + // guards don't nest unnecessarily. + let inputs: std::collections::BTreeMap< + dpp::address_funds::PlatformAddress, + dpp::fee::Credits, + > = { + let wm = self.wallet_manager.read().await; + let info = wm + .get_wallet_info(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + let account = info + .core_wallet + .platform_payment_managed_account_at_index(account_index) + .ok_or_else(|| { + PlatformWalletError::AddressOperation(format!( + "no platform payment account at index {account_index}" + )) + })?; + + let mut chosen: std::collections::BTreeMap< + dpp::address_funds::PlatformAddress, + dpp::fee::Credits, + > = std::collections::BTreeMap::new(); + let mut accumulated: u64 = 0; + for addr_info in account.addresses.addresses.values() { + let p2pkh = match key_wallet::PlatformP2PKHAddress::from_address(&addr_info.address) + { + Ok(p) => p, + Err(_) => continue, + }; + let balance = account.address_credit_balance(&p2pkh); + if balance == 0 { + continue; + } + let address = dpp::address_funds::PlatformAddress::P2pkh(p2pkh.to_bytes()); + chosen.insert(address, balance); + accumulated = accumulated.saturating_add(balance); + if accumulated >= needed { + break; + } + } + + if accumulated < needed { + return Err(PlatformWalletError::ShieldedInsufficientBalance { + available: accumulated, + required: needed, + }); + } + chosen + }; + + let guard = self.shielded.read().await; + let shielded = guard + .as_ref() + .ok_or(PlatformWalletError::ShieldedNotBound)?; + shielded.shield(inputs, amount, signer, &prover).await + } } impl PlatformWallet { diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index fb6d6ea41da..01d1a2bf3ea 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -70,17 +70,37 @@ impl ShieldedWallet { ) -> Result<(), PlatformWalletError> { let recipient_addr = self.default_orchard_address()?; - // Build nonce map: The DPP builder takes (AddressNonce, Credits) pairs. - // For now we use nonce=0 as a placeholder -- the actual nonce should be - // fetched from the platform. In production, callers may use the SDK's - // ShieldFunds trait directly which fetches nonces automatically. - // - // TODO: Add proper nonce fetching, either here or require callers to - // provide inputs_with_nonce directly. - let inputs_with_nonce: BTreeMap = inputs - .into_iter() - .map(|(addr, credits)| (addr, (0u32, credits))) - .collect(); + // Fetch the current address nonces from Platform. Each + // input address has a per-address nonce that the next + // state transition must use as `last_used + 1`. + // `AddressInfo::fetch_many` returns the last-used nonce + // (and current balance) per address; we increment it. + // Without this the broadcast was rejected by drive-abci + // because every shield transition tried to use nonce 0. + use dash_sdk::platform::FetchMany; + use dash_sdk::query_types::AddressInfo; + use std::collections::BTreeSet; + + let address_set: BTreeSet = inputs.keys().copied().collect(); + let infos = AddressInfo::fetch_many(&self.sdk, address_set) + .await + .map_err(|e| { + PlatformWalletError::ShieldedBuildError(format!("fetch input nonces: {e}")) + })?; + + let mut inputs_with_nonce: BTreeMap = BTreeMap::new(); + for (addr, credits) in inputs { + let info = infos + .get(&addr) + .and_then(|opt| opt.as_ref()) + .ok_or_else(|| { + PlatformWalletError::ShieldedBuildError(format!( + "input address not found on platform: {:?}", + addr + )) + })?; + inputs_with_nonce.insert(addr, (info.nonce + 1, credits)); + } let fee_strategy: AddressFundsFeeStrategy = vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]; diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index d929d618dea..c964b43e995 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -298,6 +298,60 @@ extension PlatformWalletManager { }.value } + /// Platform → Shielded. Spends credits from a Platform Payment + /// account on `walletId` into the bound shielded sub-wallet's + /// pool. Inputs are auto-selected from the account's addresses + /// in ascending derivation order until they cover `amount` plus + /// a conservative on-chain fee buffer; the actual fee is + /// deducted from input 0 by the network via the shield + /// transition's fee strategy. + /// + /// `addressSigner` is the host-side `KeychainSigner` whose + /// `.handle` produces ECDSA signatures over each input's + /// pubkey-hash binding to the Orchard bundle. Borrowed for the + /// duration of the call. + /// + /// Heavy CPU work (Halo 2 proof + per-input signing) runs on a + /// detached task so the caller's actor isn't blocked. + public func shieldedShield( + walletId: Data, + accountIndex: UInt32 = 0, + amount: UInt64, + addressSigner: KeychainSigner + ) async throws { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidHandle( + "PlatformWalletManager not configured" + ) + } + guard walletId.count == 32 else { + throw PlatformWalletError.invalidParameter( + "walletId must be exactly 32 bytes" + ) + } + + let handle = self.handle + let signerHandle = addressSigner.handle + + try await Task.detached(priority: .userInitiated) { + // Keepalive — same rationale as `topUpFromAddresses`. + // The trampoline ctx pointer inside the signer + // dangles unless the Swift owner outlives this + // detached work. + _ = addressSigner + + try walletId.withUnsafeBytes { widRaw in + guard let widPtr = widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self) + else { + throw PlatformWalletError.invalidParameter("walletId baseAddress is nil") + } + try platform_wallet_manager_shielded_shield( + handle, widPtr, accountIndex, amount, signerHandle + ).check() + } + }.value + } + /// Shielded → Platform unshield. Spends notes from `walletId`'s /// shielded balance and credits the platform address /// `toPlatformAddress` (bincode-encoded `PlatformAddress` — diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift index 540a1c6a225..a333d5e4e1b 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -236,18 +236,24 @@ class SendViewModel: ObservableObject { successMessage = "Withdrawal submitted" case .platformToShielded: - // Platform → Shielded (Type 15) needs a - // `Signer` adapter and the - // per-input nonce fetch — the Rust spend builder - // currently stubs the nonce to 0. Tracked for a - // follow-up; surface a clear error so the UI - // doesn't pretend to handle this yet. + // Platform → Shielded (Type 15): spend credits from + // the wallet's first Platform Payment account into + // the bound shielded pool. The KeychainSigner + // pulls the per-address ECDSA keys via the same + // mnemonic-resolver path identity-key signing uses; + // per-input nonces are fetched server-side from + // Platform inside `ShieldedWallet::shield`. _ = platformState _ = shieldedService - _ = modelContext _ = sdk - error = "Platform → Shielded is not wired yet — follow-up PR" - return + let signer = KeychainSigner(modelContainer: modelContext.container) + try await walletManager.shieldedShield( + walletId: wallet.walletId, + accountIndex: 0, + amount: amount, + addressSigner: signer + ) + successMessage = "Shielding complete" } } catch { From c15a1750f160c87a3ca20a01e7f85719a2a2c570 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 6 May 2026 06:40:14 +0700 Subject: [PATCH 03/78] fix(swift-sdk,platform-wallet): hydrate persisted address balances on restore + send credits at credits scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two adjacent bugs that surfaced together when sending Platform → Shielded immediately after a fresh app launch: **`shielded_shield_from_account` reported `available 0`** even though the wallet detail showed 1.005 DASH on the Platform Payment account. `PlatformAddressWallet::initialize_from_persisted` was only seeding the *provider*'s `found` map — the source it hands to the SDK's incremental sync — but never pushing those balances into the in-memory `ManagedPlatformAccount.address_balances` map. Spend paths that enumerate funded addresses (`shielded_shield_from_account`, `PlatformAddressWallet::addresses_with_balances`, `account.address_credit_balance`) all read from `address_balances`, so they returned 0 until the first BLAST sync finished and `provider::on_address_found` repopulated it. Walk `persisted.per_account` at restore time and call `set_address_credit_balance(addr, balance, None)` on the matching `ManagedPlatformAccount` for each entry, mirroring the same `apply_changeset` path the steady-state sync writes through. New public accessor `PerAccountPlatformAddressState::persisted_balances()` exposes the iteration without leaking the inner `found` map. **Send screen sent at duffs scale.** `SendViewModel.amount` unconditionally multiplied the typed DASH value by 1e8 (L1 duffs). Right for `coreToCore` but wrong for the four flows that touch the credits ledger (1 DASH = 1e11), which underpaid by 1000×. Typing 0.5 DASH for a Platform → Shielded shield turned into 50_000_000 credits (~0.0005 DASH) on the wire — error-message gave it away as `required 1050000000 = amount + fee_buffer`. Split into `amountDuffs` and `amountCredits`. `executeSend` picks `amountCredits` for `shieldedToShielded`, `shieldedToPlatform`, `shieldedToCore`, `platformToShielded`; `coreToCore` still uses `amountDuffs`. The legacy `amount` property aliases `amountDuffs` so any caller that hadn't been audited still gets Core-correct semantics. Verified: `cargo clippy --workspace --all-features --locked -- --no-deps -D warnings` clean, `bash build_ios.sh --target sim --profile dev` green. --- .../src/wallet/platform_addresses/provider.rs | 14 ++++ .../src/wallet/platform_addresses/wallet.rs | 27 +++++++ .../Core/ViewModels/SendViewModel.swift | 80 +++++++++++++------ 3 files changed, 97 insertions(+), 24 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs index d5836be9ff1..f9a612e4a52 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs @@ -115,6 +115,20 @@ impl PerAccountPlatformAddressState { self.addresses.insert(address_index, address); self.found.insert(address, funds); } + + /// Iterate the (address, funds) pairs currently held in `found` — + /// the persisted-or-synced address balance snapshot. Used by the + /// restore path on + /// [`PlatformAddressWallet::initialize_from_persisted`](crate::wallet::platform_addresses::PlatformAddressWallet::initialize_from_persisted) + /// to seed the in-memory `ManagedPlatformAccount.address_balances` + /// map at startup, so spend paths that enumerate funded + /// addresses don't read `0` while waiting for the first BLAST + /// sync to repopulate them. + pub fn persisted_balances( + &self, + ) -> impl Iterator { + self.found.iter() + } } /// Per-wallet account map — keys are DIP-17 account indexes (hardened diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs index 0c08fc8a425..63a0da338c5 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs @@ -91,6 +91,33 @@ impl PlatformAddressWallet { &self, persisted: crate::PlatformAddressSyncStartState, ) -> Result<(), PlatformWalletError> { + // Push the persisted address balances into the in-memory + // `ManagedPlatformAccount.address_balances` map so callers + // that read via `addresses_with_balances()` / + // `address_credit_balance()` see the same numbers the + // BLAST sync saved last session. Without this the + // in-memory map starts empty after a restart and stays + // that way until the first sync pass repopulates it — + // any spend that needs to enumerate funded addresses + // (e.g. `shielded_shield_from_account`) sees `available = + // 0` even though the wallet detail screen reports a real + // balance from SwiftData. + { + let mut wm = self.wallet_manager.write().await; + if let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) { + for (account_index, account_state) in &persisted.per_account { + if let Some(account) = info + .core_wallet + .platform_payment_managed_account_at_index_mut(*account_index) + { + for (p2pkh, funds) in account_state.persisted_balances() { + account.set_address_credit_balance(*p2pkh, funds.balance, None); + } + } + } + } + } + let mut per_wallet = std::collections::BTreeMap::new(); per_wallet.insert(self.wallet_id, persisted.per_account); let provider = PlatformPaymentAddressProvider::from_persisted( diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift index a333d5e4e1b..24970fc7d9f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -89,13 +89,32 @@ class SendViewModel: ObservableObject { self.network = network } - var amount: UInt64? { + /// Parsed amount expressed in **L1 duffs** (1 DASH = 1e8). Right + /// for Core sends; *wrong* for Platform / shielded sends, which + /// use the credits scale (1 DASH = 1e11) instead. Use [`amountCredits`] + /// for those paths — picking duffs underpays them by 1000×. + var amountDuffs: UInt64? { guard let double = Double(amountString), double > 0 else { return nil } return UInt64(double * 100_000_000) } + /// Parsed amount expressed in Platform / shielded **credits** + /// (1 DASH = 1e11). Used for any flow that touches the credits + /// ledger (`platformToShielded`, `shieldedToShielded`, + /// `shieldedToPlatform`, `shieldedToCore`). + var amountCredits: UInt64? { + guard let double = Double(amountString), double > 0 else { return nil } + return UInt64(double * 100_000_000_000) + } + + /// Backwards-compatibility shim — the original `amount` property + /// always returned duffs, so any leftover call site that hasn't + /// switched to the unit-explicit pair stays correct for Core + /// flows. + var amount: UInt64? { amountDuffs } + var canSend: Bool { - detectedFlow != nil && amount != nil && !isSending + detectedFlow != nil && amountDuffs != nil && !isSending } /// Determine which fund sources are available based on destination and balances. @@ -165,7 +184,7 @@ class SendViewModel: ObservableObject { coreWallet: ManagedCoreWallet?, modelContext: ModelContext ) async { - guard let flow = detectedFlow, let amount = amount else { return } + guard let flow = detectedFlow else { return } isSending = true error = nil @@ -175,23 +194,30 @@ class SendViewModel: ObservableObject { do { switch flow { case .coreToCore: + guard let amountDuffs else { + error = "Invalid amount" + return + } guard let core = coreWallet else { error = "Core wallet not available" return } let address = recipientAddress.trimmingCharacters(in: .whitespacesAndNewlines) let _ = try core.sendToAddresses( - recipients: [(address: address, amountDuffs: amount)] + recipients: [(address: address, amountDuffs: amountDuffs)] ) successMessage = "Payment sent" case .shieldedToShielded: // Shielded → Shielded: spend notes from this // wallet's shielded balance, create a new note - // for the recipient. Recipient bytes come from - // the bech32m parser as raw 43-byte Orchard - // address; matches what the manager's transfer - // FFI expects. + // for the recipient. Amount is in **credits** + // (1 DASH = 1e11) — the entire shielded ledger + // works on the credits scale. + guard let amountCredits else { + error = "Invalid amount" + return + } let parsed = DashAddress.parse(recipientAddress, network: network) guard case .orchard(let recipientRaw) = parsed.type else { error = "Recipient is not a shielded address" @@ -200,15 +226,17 @@ class SendViewModel: ObservableObject { try await walletManager.shieldedTransfer( walletId: wallet.walletId, recipientRaw43: recipientRaw, - amount: amount + amount: amountCredits ) successMessage = "Shielded transfer complete" case .shieldedToPlatform: // Shielded → Platform: spend notes, credit the - // platform address. `addressBytes` is the 21-byte - // bincode-encoded `PlatformAddress` shape (type - // byte + 20-byte hash). + // platform address (also credits scale). + guard let amountCredits else { + error = "Invalid amount" + return + } let parsed = DashAddress.parse(recipientAddress, network: network) guard case .platform(let addressBytes) = parsed.type else { error = "Recipient is not a platform address" @@ -217,20 +245,24 @@ class SendViewModel: ObservableObject { try await walletManager.shieldedUnshield( walletId: wallet.walletId, toPlatformAddress: addressBytes, - amount: amount + amount: amountCredits ) successMessage = "Unshield complete" case .shieldedToCore: - // Shielded → Core L1: spend notes, create an L1 - // withdrawal. The manager parses the Base58Check - // address Rust-side; we just hand the trimmed - // string through. + // Shielded → Core L1: spend notes (credits), create + // an L1 withdrawal. The shielded-side amount is in + // credits; the network converts to L1 duffs at the + // 1000:1 conversion rate. + guard let amountCredits else { + error = "Invalid amount" + return + } let trimmed = recipientAddress.trimmingCharacters(in: .whitespacesAndNewlines) try await walletManager.shieldedWithdraw( walletId: wallet.walletId, toCoreAddress: trimmed, - amount: amount, + amount: amountCredits, coreFeePerByte: 1 ) successMessage = "Withdrawal submitted" @@ -238,11 +270,11 @@ class SendViewModel: ObservableObject { case .platformToShielded: // Platform → Shielded (Type 15): spend credits from // the wallet's first Platform Payment account into - // the bound shielded pool. The KeychainSigner - // pulls the per-address ECDSA keys via the same - // mnemonic-resolver path identity-key signing uses; - // per-input nonces are fetched server-side from - // Platform inside `ShieldedWallet::shield`. + // the bound shielded pool. Credits scale. + guard let amountCredits else { + error = "Invalid amount" + return + } _ = platformState _ = shieldedService _ = sdk @@ -250,7 +282,7 @@ class SendViewModel: ObservableObject { try await walletManager.shieldedShield( walletId: wallet.walletId, accountIndex: 0, - amount: amount, + amount: amountCredits, addressSigner: signer ) successMessage = "Shielding complete" From a8d9b1421e62dfab58864b3a1433864d7619be65 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 6 May 2026 06:52:54 +0700 Subject: [PATCH 04/78] fix(platform-wallet-ffi): run shielded proof on worker thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Halo 2 circuit synthesis recurses past the ~512 KB iOS dispatch-thread stack and crashes with EXC_BAD_ACCESS on the first `synthesize(config.clone(), V1Pass::<_, CS>::measure(pass))?` call when the future is polled directly on the calling thread. Switch the four shielded spend FFI entry points (transfer/unshield/withdraw/shield) from `runtime().block_on(...)` to `block_on_worker(...)` so the proof runs on a tokio worker with the configured 8 MB stack — the exact case `runtime.rs` was set up for. For `shield`, transmute the borrowed `&VTableSigner` to `&'static` inside the FFI call: the caller retains ownership of the signer handle and we block until the worker future completes, so the painted lifetime never actually escapes the call. `VTableSigner` is `Send + Sync` per its `unsafe impl` in rs-sdk-ffi, so the resulting reference is `Send + 'static` — exactly what `block_on_worker` needs. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/shielded_send.rs | 77 ++++++++++++------- 1 file changed, 48 insertions(+), 29 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index d8043d1309e..57e8a6543f2 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -36,7 +36,7 @@ use rs_sdk_ffi::{SignerHandle, VTableSigner}; use crate::check_ptr; use crate::error::*; use crate::handle::*; -use crate::runtime::runtime; +use crate::runtime::{block_on_worker, runtime}; /// Build the Halo 2 proving key now if it hasn't been built yet. /// @@ -95,11 +95,19 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_transfer( Ok(w) => w, Err(result) => return result, }; - let prover = CachedOrchardProver::new(); - let prover_ref: &CachedOrchardProver = &prover; - if let Err(e) = runtime().block_on(wallet.shielded_transfer_to(&recipient, amount, prover_ref)) - { + // Run the proof on a worker thread (8 MB stack). Halo 2 circuit + // synthesis recurses past the ~512 KB iOS dispatch-thread stack + // and crashes with EXC_BAD_ACCESS at the first + // `synthesize(... measure(pass))` call when polled on the + // calling thread. + let result = block_on_worker(async move { + let prover = CachedOrchardProver::new(); + wallet + .shielded_transfer_to(&recipient, amount, &prover) + .await + }); + if let Err(e) = result { return PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorWalletOperation, format!("shielded transfer failed: {e}"), @@ -145,10 +153,12 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_unshield( Ok(w) => w, Err(result) => return result, }; - let prover = CachedOrchardProver::new(); - let prover_ref: &CachedOrchardProver = &prover; - if let Err(e) = runtime().block_on(wallet.shielded_unshield_to(&to_addr, amount, prover_ref)) { + let result = block_on_worker(async move { + let prover = CachedOrchardProver::new(); + wallet.shielded_unshield_to(&to_addr, amount, &prover).await + }); + if let Err(e) = result { return PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorWalletOperation, format!("shielded unshield failed: {e}"), @@ -195,15 +205,14 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_withdraw( Ok(w) => w, Err(result) => return result, }; - let prover = CachedOrchardProver::new(); - let prover_ref: &CachedOrchardProver = &prover; - if let Err(e) = runtime().block_on(wallet.shielded_withdraw_to( - &to_core, - amount, - core_fee_per_byte, - prover_ref, - )) { + let result = block_on_worker(async move { + let prover = CachedOrchardProver::new(); + wallet + .shielded_withdraw_to(&to_core, amount, core_fee_per_byte, &prover) + .await + }); + if let Err(e) = result { return PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorWalletOperation, format!("shielded withdraw failed: {e}"), @@ -249,20 +258,30 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_shield( Err(result) => return result, }; - // SAFETY: caller guarantees `signer_address_handle` is a - // valid, non-destroyed handle that outlives this call. The - // `VTableSigner` is `Send + Sync` so dropping it back into a - // `block_on` future is safe. - let address_signer: &VTableSigner = &*(signer_address_handle as *const VTableSigner); - let prover = CachedOrchardProver::new(); - let prover_ref: &CachedOrchardProver = &prover; + // SAFETY: the caller retains ownership of the signer handle + // and guarantees it outlives this call. We block until the + // worker future completes, so the `'static` lifetime we paint + // on the borrow does not actually outlive the host's handle. + // `VTableSigner` is `Send + Sync` per its `unsafe impl` in + // rs-sdk-ffi, so `&'static VTableSigner` is automatically + // `Send + 'static` — exactly what `block_on_worker` needs. + let address_signer: &'static VTableSigner = + std::mem::transmute::<&VTableSigner, &'static VTableSigner>( + &*(signer_address_handle as *const VTableSigner), + ); - if let Err(e) = runtime().block_on(wallet.shielded_shield_from_account( - account_index, - amount, - address_signer, - prover_ref, - )) { + // Run the proof on a worker thread (8 MB stack). Halo 2 circuit + // synthesis recurses past the ~512 KB iOS dispatch-thread stack + // and crashes with EXC_BAD_ACCESS at the first + // `synthesize(... measure(pass))` call when polled on the + // calling thread. + let result = block_on_worker(async move { + let prover = CachedOrchardProver::new(); + wallet + .shielded_shield_from_account(account_index, amount, address_signer, &prover) + .await + }); + if let Err(e) = result { return PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorWalletOperation, format!("shielded shield failed: {e}"), From dfbb2f86b6316d910c7b93415a60bd91d6201fae Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 6 May 2026 07:02:44 +0700 Subject: [PATCH 05/78] fix(platform-wallet): surface Platform's per-input view on shield broadcast failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AddressesNotEnoughFundsError` from drive-abci already carries `addresses_with_info: BTreeMap` — Platform's actual per-address nonce and remaining balance after the bundle's `DeductFromInput(0)` strategy deducts the shield amount. Stringifying with `e.to_string()` discarded everything but `required_balance` (the fee), leaving the host with no way to tell *which* input fell short or whether the local-cache balance disagreed with Platform. Pattern-match the broadcast `dash_sdk::Error` for the structured consensus error (via `Error::Protocol(ProtocolError::ConsensusError)` or `Error::StateTransitionBroadcastError { cause }`), then format both the local claim list and Platform's view side-by-side. Add a per-input `tracing::info!`/`warn!` before broadcast so the same data is visible in logs even on success — and hosts can spot local-cache drift by comparing claimed_credits vs platform_balance. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/wallet/shielded/operations.rs | 98 ++++++++++++++++++- 1 file changed, 96 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 01d1a2bf3ea..72dae202847 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -42,7 +42,51 @@ use dpp::shielded::builder::{ }; use dpp::withdrawal::Pooling; use grovedb_commitment_tree::{Anchor, PaymentAddress}; -use tracing::{info, trace}; +use tracing::{info, trace, warn}; + +/// Try to extract a structured `AddressesNotEnoughFundsError` from a +/// broadcast error so the shield path can format a diagnostic that +/// includes Platform's actual per-input view (nonce + balance) rather +/// than just the stringified message. +fn addresses_not_enough_funds( + e: &dash_sdk::Error, +) -> Option<&dpp::consensus::state::address_funds::AddressesNotEnoughFundsError> { + use dpp::consensus::state::state_error::StateError; + use dpp::consensus::ConsensusError; + use dpp::ProtocolError; + + let consensus: &ConsensusError = match e { + dash_sdk::Error::Protocol(ProtocolError::ConsensusError(boxed)) => boxed.as_ref(), + dash_sdk::Error::StateTransitionBroadcastError(s) => s.cause.as_ref()?, + _ => return None, + }; + match consensus { + ConsensusError::StateError(StateError::AddressesNotEnoughFundsError(err)) => Some(err), + _ => None, + } +} + +/// Format a one-line `addresses_with_info` summary for diagnostics — +/// each entry rendered as `=(nonce , credits)`. +fn format_addresses_with_info( + map: &std::collections::BTreeMap< + dpp::address_funds::PlatformAddress, + (dpp::prelude::AddressNonce, dpp::fee::Credits), + >, +) -> String { + map.iter() + .map(|(addr, (nonce, credits))| { + let hex_hash = match addr { + dpp::address_funds::PlatformAddress::P2pkh(h) => { + format!("p2pkh:{}", hex::encode(h)) + } + dpp::address_funds::PlatformAddress::P2sh(h) => format!("p2sh:{}", hex::encode(h)), + }; + format!("{hex_hash}=(nonce {nonce}, {credits} credits)") + }) + .collect::>() + .join(", ") +} impl ShieldedWallet { // ------------------------------------------------------------------------- @@ -99,6 +143,27 @@ impl ShieldedWallet { addr )) })?; + // Surface a per-input diagnostic so the host can see what + // we're claiming vs what Platform actually reports — + // mismatches are the typical root cause of + // `AddressesNotEnoughFundsError` on shield broadcast. + if info.balance < credits { + warn!( + address = ?addr, + claimed_credits = credits, + platform_balance = info.balance, + platform_nonce = info.nonce, + "Shield input claims more credits than Platform reports — broadcast will likely fail" + ); + } else { + info!( + address = ?addr, + claimed_credits = credits, + platform_balance = info.balance, + platform_nonce = info.nonce, + "Shield input" + ); + } inputs_with_nonce.insert(addr, (info.nonce + 1, credits)); } @@ -107,6 +172,12 @@ impl ShieldedWallet { info!("Shield credits: {} credits, building proof...", amount,); + // Snapshot what we're claiming so the diagnostic can show + // local-claim vs platform-view side by side when broadcast + // fails with `AddressesNotEnoughFundsError`. The map is + // moved into the builder below so we have to clone here. + let claimed_inputs = inputs_with_nonce.clone(); + // Build the state transition using the DPP builder. // `build_shield_transition` is async (cascade from the dpp // `Signer` trait being made async upstream); await before @@ -130,7 +201,30 @@ impl ShieldedWallet { state_transition .broadcast(&self.sdk, None) .await - .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; + .map_err(|e| { + if let Some(rich) = addresses_not_enough_funds(&e) { + let claimed = claimed_inputs + .iter() + .map(|(addr, (nonce, credits))| { + let h = match addr { + PlatformAddress::P2pkh(h) => format!("p2pkh:{}", hex::encode(h)), + PlatformAddress::P2sh(h) => format!("p2sh:{}", hex::encode(h)), + }; + format!("{h}=(nonce {nonce}, {credits} credits)") + }) + .collect::>() + .join(", "); + PlatformWalletError::ShieldedBroadcastFailed(format!( + "addresses not enough funds: required {} credits; \ + claimed inputs [{}]; platform sees [{}]", + rich.required_balance(), + claimed, + format_addresses_with_info(rich.addresses_with_info()), + )) + } else { + PlatformWalletError::ShieldedBroadcastFailed(e.to_string()) + } + })?; info!("Shield credits broadcast succeeded: {} credits", amount); Ok(()) From 6c72239ea5f6082689d7bded2ae0a93656c498b7 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 6 May 2026 07:21:06 +0700 Subject: [PATCH 06/78] fix(platform-wallet): reserve fee headroom on shield input 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shield transition uses `DeductFromInput(0)` as its fee strategy, which drive-abci interprets as "after each input has had its claim deducted, take the fee out of input 0's *remaining* balance" (see the doc comment on `deduct_fee_from_outputs_or_remaining_balance_of_inputs_v0` in rs-dpp). "Input 0" is the BTreeMap-smallest key. The previous selection code claimed the full balance of every picked input, so every input's remaining was 0, and `DeductFromInput(0)` had nothing to bite into. Platform rejected the broadcast with `AddressesNotEnoughFundsError` showing "total available is less than required ". Sort candidates by address bytes (BTreeMap order), skip leading dust addresses whose balance can't reserve the fee buffer (so the next funded address becomes the bundle's input 0), then claim only what's needed to cover `amount` — capping input 0's claim at `balance - FEE_RESERVE_CREDITS` so its post-claim remaining stays ≥ FEE_RESERVE for the network's fee deduction step. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/wallet/platform_wallet.rs | 116 ++++++++++++++---- 1 file changed, 90 insertions(+), 26 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index d4a3b9cf574..b2c38cf1528 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -477,14 +477,25 @@ impl PlatformWallet { S: dpp::identity::signer::Signer + Send + Sync, P: dpp::shielded::builder::OrchardProver, { - // Conservative fee buffer over `amount`. The shield - // transition's `DeductFromInput(0)` strategy lets the - // network deduct the actual fee from input 0; we just need - // to make sure the inputs cumulatively cover `amount + a - // bit`. Empty-mempool platform fees are well under - // 0.001 DASH (1e8 credits); 0.01 DASH absorbs a 10× spike. - const FEE_BUFFER_CREDITS: u64 = 1_000_000_000; - let needed = amount.saturating_add(FEE_BUFFER_CREDITS); + // The shield transition uses `DeductFromInput(0)` as its fee + // strategy. drive-abci interprets that as "after each input + // address has had its `claim` deducted, take the fee out of + // input 0's *remaining* balance" (see + // `deduct_fee_from_outputs_or_remaining_balance_of_inputs_v0` + // in rs-dpp). "Input 0" is the smallest-key entry of the + // BTreeMap we hand to the builder. Therefore: + // + // * we must NOT claim each input's full balance — claiming + // `balance` leaves `remaining = 0`, and the fee + // deduction has nothing to bite into. + // * we must reserve at least `FEE_RESERVE_CREDITS` of + // unclaimed balance specifically on input 0 (the + // BTreeMap-smallest address). + // + // Empty-mempool fees on Type 15 transitions land at ~20M + // credits (~0.0002 DASH). Reserve 1e9 credits (0.01 DASH) — + // 50× headroom, still trivial relative to typical balances. + const FEE_RESERVE_CREDITS: u64 = 1_000_000_000; // Build the inputs map under the wallet-manager read lock, // then drop the lock before re-entering shielded so the @@ -506,33 +517,86 @@ impl PlatformWallet { )) })?; + // Collect (address, balance) for every funded address, + // sorted by address bytes — that determines BTreeMap + // key order downstream and therefore which input ends + // up at index 0. + let mut candidates: Vec<(dpp::address_funds::PlatformAddress, u64)> = account + .addresses + .addresses + .values() + .filter_map(|addr_info| { + let p2pkh = + key_wallet::PlatformP2PKHAddress::from_address(&addr_info.address).ok()?; + let balance = account.address_credit_balance(&p2pkh); + if balance == 0 { + None + } else { + Some(( + dpp::address_funds::PlatformAddress::P2pkh(p2pkh.to_bytes()), + balance, + )) + } + }) + .collect(); + candidates.sort_by_key(|(addr, _)| *addr); + + // The address that will be the bundle's `input_0` must + // have balance > FEE_RESERVE so we can claim at least 1 + // credit while leaving the reserve untouched. Skip any + // leading dust address that can't satisfy that — the + // next address up will become input 0 instead. (If + // every funded address is below the reserve, fall back + // to the smallest one so we still produce a valid + // builder input map; the network will reject it cleanly + // if the fee can't be covered.) + let viable_input_0 = candidates + .iter() + .position(|(_, balance)| *balance > FEE_RESERVE_CREDITS) + .unwrap_or(0); + let usable: &[(dpp::address_funds::PlatformAddress, u64)] = + &candidates[viable_input_0..]; + + let total_usable: u64 = usable.iter().map(|(_, b)| b).sum(); + let needed = amount.saturating_add(FEE_RESERVE_CREDITS); + if total_usable < needed { + return Err(PlatformWalletError::ShieldedInsufficientBalance { + available: total_usable, + required: needed, + }); + } + + // Walk usable inputs in BTreeMap order, claiming only + // what's needed to cover `amount`. The fee reserve is + // taken off input 0's max claim so its post-claim + // remaining stays ≥ FEE_RESERVE_CREDITS for the + // network's `DeductFromInput(0)` step. let mut chosen: std::collections::BTreeMap< dpp::address_funds::PlatformAddress, dpp::fee::Credits, > = std::collections::BTreeMap::new(); - let mut accumulated: u64 = 0; - for addr_info in account.addresses.addresses.values() { - let p2pkh = match key_wallet::PlatformP2PKHAddress::from_address(&addr_info.address) - { - Ok(p) => p, - Err(_) => continue, - }; - let balance = account.address_credit_balance(&p2pkh); - if balance == 0 { - continue; - } - let address = dpp::address_funds::PlatformAddress::P2pkh(p2pkh.to_bytes()); - chosen.insert(address, balance); - accumulated = accumulated.saturating_add(balance); - if accumulated >= needed { + let mut accumulated_claim: u64 = 0; + for (i, (addr, balance)) in usable.iter().enumerate() { + if accumulated_claim >= amount { break; } + let max_claim = if i == 0 { + balance.saturating_sub(FEE_RESERVE_CREDITS) + } else { + *balance + }; + let still_need = amount - accumulated_claim; + let claim = max_claim.min(still_need); + if claim > 0 { + chosen.insert(*addr, claim); + accumulated_claim = accumulated_claim.saturating_add(claim); + } } - if accumulated < needed { + if accumulated_claim < amount { return Err(PlatformWalletError::ShieldedInsufficientBalance { - available: accumulated, - required: needed, + available: accumulated_claim, + required: amount, }); } chosen From 6e4931c4828164ebc7036c8a04484137fd8fc5d8 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 6 May 2026 14:11:54 +0700 Subject: [PATCH 07/78] fix(swift-sdk,platform-wallet): address PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - unshield FFI now takes the bech32m string and parses Rust-side via `PlatformAddress::from_bech32m_string`, with a network check. The previous byte-based path passed the 21-byte bech32m payload (type byte 0xb0/0x80) into bincode `from_bytes`, which expects the storage variant tag 0x00/0x01 and rejected real user-entered addresses (thepastaclaw c8873f6312ef). - shield: nonce increment now `checked_add(1)` so a u32 wrap surfaces as `ShieldedBuildError` instead of replaying with nonce 0 after a 30 s proof (cb50b774985e). - shield input selection: when no candidate clears FEE_RESERVE_CREDITS, fail fast with `ShieldedInsufficientBalance` instead of producing a known-boundary bundle (2b28ee4ac2f4). - SendViewModel: trim recipient in the shielded→shielded and shielded→platform branches (68c36dcd4fe0). Forward the trimmed bech32m string to `shieldedUnshield` directly — the Swift side no longer extracts payload bytes. - format_addresses_with_info now renders via `to_bech32m_string` and takes the wallet's network — diagnostics match what the UI shows so log greps line up (6b82603320bd). - platform_wallet_shielded_warm_up_prover dispatches the build via `runtime().spawn_blocking(...)` so it actually returns immediately as the doc claims (a575d0f7eb0f). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/shielded_send.rs | 56 +++++++++++-------- .../src/wallet/platform_wallet.rs | 45 ++++++++++----- .../src/wallet/shielded/operations.rs | 42 +++++++++----- .../PlatformWalletManagerShieldedSync.swift | 20 +++---- .../Core/ViewModels/SendViewModel.swift | 16 +++--- 5 files changed, 105 insertions(+), 74 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 57e8a6543f2..3ce99636013 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -38,18 +38,20 @@ use crate::error::*; use crate::handle::*; use crate::runtime::{block_on_worker, runtime}; -/// Build the Halo 2 proving key now if it hasn't been built yet. -/// -/// First-call latency is ~30 seconds; subsequent calls return -/// immediately. Hosts should fire this on a background thread at -/// app startup so the first shielded send doesn't block the user. -/// Safe to call repeatedly and from any thread. +/// Kick off the Halo 2 proving-key build on a background tokio +/// worker if it hasn't been built yet. Returns immediately — +/// hosts can call this at app startup without blocking the UI +/// thread. Subsequent calls are cheap no-ops once the key is +/// cached. The first shielded send still pays the ~30 s build +/// cost only if it fires before the warm-up worker finishes; +/// `platform_wallet_shielded_prover_is_ready` reports whether +/// that's the case. /// /// Independent of any manager — the cache is a process-global /// `OnceLock`. #[no_mangle] pub unsafe extern "C" fn platform_wallet_shielded_warm_up_prover() { - CachedOrchardProver::new().warm_up(); + runtime().spawn_blocking(|| CachedOrchardProver::new().warm_up()); } /// Whether the Halo 2 proving key has already been built. @@ -119,35 +121,39 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_transfer( /// Unshield: spend shielded notes and send `amount` credits to a /// platform address. /// -/// `to_platform_addr_bytes` is the bincode-encoded -/// `PlatformAddress` — `0x00 ‖ 20-byte hash` for P2PKH, -/// `0x01 ‖ 20-byte hash` for P2SH. `to_platform_addr_len` is -/// typically 21. +/// `to_platform_addr_cstr` is the recipient as a NUL-terminated +/// UTF-8 bech32m string (e.g. `"dash1..."` on mainnet, +/// `"tdash1..."` on testnet). The Rust side parses it via +/// `PlatformAddress::from_bech32m_string` so hosts don't have to +/// hand-roll the bincode storage variant tag (`0x00`/`0x01`), +/// which differs from the bech32m payload's type byte +/// (`0xb0`/`0x80`). /// /// # Safety /// - `wallet_id_bytes` must point to 32 readable bytes. -/// - `to_platform_addr_bytes` must point to `to_platform_addr_len` -/// readable bytes. +/// - `to_platform_addr_cstr` must be a valid NUL-terminated UTF-8 +/// C string for the duration of the call. #[no_mangle] pub unsafe extern "C" fn platform_wallet_manager_shielded_unshield( handle: Handle, wallet_id_bytes: *const u8, - to_platform_addr_bytes: *const u8, - to_platform_addr_len: usize, + to_platform_addr_cstr: *const c_char, amount: u64, ) -> PlatformWalletFFIResult { check_ptr!(wallet_id_bytes); - check_ptr!(to_platform_addr_bytes); - if to_platform_addr_len == 0 { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorInvalidParameter, - "to_platform_addr_len must be > 0", - ); - } + check_ptr!(to_platform_addr_cstr); let mut wallet_id = [0u8; 32]; std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); - let to_addr = std::slice::from_raw_parts(to_platform_addr_bytes, to_platform_addr_len).to_vec(); + let to_addr_str = match CStr::from_ptr(to_platform_addr_cstr).to_str() { + Ok(s) => s.to_string(), + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorUtf8Conversion, + format!("to_platform_addr is not valid UTF-8: {e}"), + ); + } + }; let wallet = match resolve_wallet(handle, &wallet_id) { Ok(w) => w, @@ -156,7 +162,9 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_unshield( let result = block_on_worker(async move { let prover = CachedOrchardProver::new(); - wallet.shielded_unshield_to(&to_addr, amount, &prover).await + wallet + .shielded_unshield_to(&to_addr_str, amount, &prover) + .await }); if let Err(e) = result { return PlatformWalletFFIResult::err( diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index b2c38cf1528..114188bc423 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -396,13 +396,14 @@ impl PlatformWallet { } /// Unshield: spend shielded notes and send `amount` credits to - /// the platform address `to_platform_addr_bytes` (bincode- - /// encoded `PlatformAddress` — `0x00 ‖ 20-byte hash` for - /// P2PKH, `0x01 ‖ 20-byte hash` for P2SH). + /// the platform address `to_platform_addr_bech32m` (a bech32m + /// string like `"dash1…"` / `"tdash1…"`). Parsed via + /// `PlatformAddress::from_bech32m_string` and verified against + /// the wallet's network. #[cfg(feature = "shielded")] pub async fn shielded_unshield_to( &self, - to_platform_addr_bytes: &[u8], + to_platform_addr_bech32m: &str, amount: u64, prover: P, ) -> Result<(), PlatformWalletError> { @@ -410,9 +411,19 @@ impl PlatformWallet { let shielded = guard .as_ref() .ok_or(PlatformWalletError::ShieldedNotBound)?; - let to = dpp::address_funds::PlatformAddress::from_bytes(to_platform_addr_bytes).map_err( - |e| PlatformWalletError::ShieldedBuildError(format!("invalid platform address: {e}")), - )?; + let (to, addr_network) = + dpp::address_funds::PlatformAddress::from_bech32m_string(to_platform_addr_bech32m) + .map_err(|e| { + PlatformWalletError::ShieldedBuildError(format!( + "invalid platform address: {e}" + )) + })?; + if addr_network != self.sdk.network { + return Err(PlatformWalletError::ShieldedBuildError(format!( + "platform address network mismatch: address {addr_network:?}, wallet {:?}", + self.sdk.network + ))); + } shielded.unshield(&to, amount, &prover).await } @@ -545,15 +556,21 @@ impl PlatformWallet { // have balance > FEE_RESERVE so we can claim at least 1 // credit while leaving the reserve untouched. Skip any // leading dust address that can't satisfy that — the - // next address up will become input 0 instead. (If - // every funded address is below the reserve, fall back - // to the smallest one so we still produce a valid - // builder input map; the network will reject it cleanly - // if the fee can't be covered.) - let viable_input_0 = candidates + // next address up will become input 0 instead. If + // every funded address is below the reserve, fail fast: + // the network would reject the broadcast on the + // boundary anyway, only after we've spent ~30 s + // building the Halo 2 proof. + let Some(viable_input_0) = candidates .iter() .position(|(_, balance)| *balance > FEE_RESERVE_CREDITS) - .unwrap_or(0); + else { + let total: u64 = candidates.iter().map(|(_, b)| b).sum(); + return Err(PlatformWalletError::ShieldedInsufficientBalance { + available: total, + required: amount.saturating_add(FEE_RESERVE_CREDITS), + }); + }; let usable: &[(dpp::address_funds::PlatformAddress, u64)] = &candidates[viable_input_0..]; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 72dae202847..281eef1c9ea 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -67,22 +67,22 @@ fn addresses_not_enough_funds( } /// Format a one-line `addresses_with_info` summary for diagnostics — -/// each entry rendered as `=(nonce , credits)`. +/// each entry rendered as `=(nonce , credits)`, +/// matching what the wallet UI shows so the same string can be used +/// to grep logs for a specific address. fn format_addresses_with_info( map: &std::collections::BTreeMap< dpp::address_funds::PlatformAddress, (dpp::prelude::AddressNonce, dpp::fee::Credits), >, + network: key_wallet::Network, ) -> String { map.iter() .map(|(addr, (nonce, credits))| { - let hex_hash = match addr { - dpp::address_funds::PlatformAddress::P2pkh(h) => { - format!("p2pkh:{}", hex::encode(h)) - } - dpp::address_funds::PlatformAddress::P2sh(h) => format!("p2sh:{}", hex::encode(h)), - }; - format!("{hex_hash}=(nonce {nonce}, {credits} credits)") + format!( + "{}=(nonce {nonce}, {credits} credits)", + addr.to_bech32m_string(network) + ) }) .collect::>() .join(", ") @@ -164,7 +164,19 @@ impl ShieldedWallet { "Shield input" ); } - inputs_with_nonce.insert(addr, (info.nonce + 1, credits)); + // `AddressNonce` is `u32`; `info.nonce + 1` would panic in + // debug and wrap in release once an address reaches the + // ceiling. drive-abci treats `u32::MAX` as exhausted, so a + // wrap submits nonce 0 and gets rejected as a replay + // *after* the wallet has already spent ~30 s building the + // Halo 2 proof. Bail loudly here instead. + let next_nonce = info.nonce.checked_add(1).ok_or_else(|| { + PlatformWalletError::ShieldedBuildError(format!( + "input address nonce exhausted on platform: {:?}", + addr + )) + })?; + inputs_with_nonce.insert(addr, (next_nonce, credits)); } let fee_strategy: AddressFundsFeeStrategy = @@ -198,6 +210,7 @@ impl ShieldedWallet { // Broadcast trace!("Shield credits: state transition built, broadcasting..."); + let network = self.sdk.network; state_transition .broadcast(&self.sdk, None) .await @@ -206,11 +219,10 @@ impl ShieldedWallet { let claimed = claimed_inputs .iter() .map(|(addr, (nonce, credits))| { - let h = match addr { - PlatformAddress::P2pkh(h) => format!("p2pkh:{}", hex::encode(h)), - PlatformAddress::P2sh(h) => format!("p2sh:{}", hex::encode(h)), - }; - format!("{h}=(nonce {nonce}, {credits} credits)") + format!( + "{}=(nonce {nonce}, {credits} credits)", + addr.to_bech32m_string(network) + ) }) .collect::>() .join(", "); @@ -219,7 +231,7 @@ impl ShieldedWallet { claimed inputs [{}]; platform sees [{}]", rich.required_balance(), claimed, - format_addresses_with_info(rich.addresses_with_info()), + format_addresses_with_info(rich.addresses_with_info(), network), )) } else { PlatformWalletError::ShieldedBroadcastFailed(e.to_string()) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index c964b43e995..7b3e16e9400 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -353,12 +353,13 @@ extension PlatformWalletManager { } /// Shielded → Platform unshield. Spends notes from `walletId`'s - /// shielded balance and credits the platform address - /// `toPlatformAddress` (bincode-encoded `PlatformAddress` — - /// `0x00 ‖ 20-byte hash` for P2PKH). + /// shielded balance and credits `toPlatformAddress`, a bech32m + /// string (`"dash1…"` on mainnet, `"tdash1…"` on testnet). Rust + /// parses and network-checks the address; hosts don't have to + /// hand-roll the bincode storage variant tag. public func shieldedUnshield( walletId: Data, - toPlatformAddress: Data, + toPlatformAddress: String, amount: UInt64 ) async throws { guard isConfigured, handle != NULL_HANDLE else { @@ -384,16 +385,9 @@ extension PlatformWalletManager { else { throw PlatformWalletError.invalidParameter("walletId baseAddress is nil") } - try toPlatformAddress.withUnsafeBytes { addrRaw in - guard let addrPtr = addrRaw.baseAddress? - .assumingMemoryBound(to: UInt8.self) - else { - throw PlatformWalletError.invalidParameter( - "toPlatformAddress baseAddress is nil" - ) - } + try toPlatformAddress.withCString { addrCStr in try platform_wallet_manager_shielded_unshield( - handle, widPtr, addrPtr, UInt(toPlatformAddress.count), amount + handle, widPtr, addrCStr, amount ).check() } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift index 24970fc7d9f..1403679f3e7 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -218,7 +218,8 @@ class SendViewModel: ObservableObject { error = "Invalid amount" return } - let parsed = DashAddress.parse(recipientAddress, network: network) + let trimmed = recipientAddress.trimmingCharacters(in: .whitespacesAndNewlines) + let parsed = DashAddress.parse(trimmed, network: network) guard case .orchard(let recipientRaw) = parsed.type else { error = "Recipient is not a shielded address" return @@ -232,19 +233,18 @@ class SendViewModel: ObservableObject { case .shieldedToPlatform: // Shielded → Platform: spend notes, credit the - // platform address (also credits scale). + // platform address (also credits scale). The + // bech32m string is forwarded as-is — Rust parses + // it via `PlatformAddress::from_bech32m_string` + // and verifies the network. guard let amountCredits else { error = "Invalid amount" return } - let parsed = DashAddress.parse(recipientAddress, network: network) - guard case .platform(let addressBytes) = parsed.type else { - error = "Recipient is not a platform address" - return - } + let trimmed = recipientAddress.trimmingCharacters(in: .whitespacesAndNewlines) try await walletManager.shieldedUnshield( walletId: wallet.walletId, - toPlatformAddress: addressBytes, + toPlatformAddress: trimmed, amount: amountCredits ) successMessage = "Unshield complete" From 3627d0f28f9c84f11708efa9e78ce2c01b1a2487 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 6 May 2026 14:33:42 +0700 Subject: [PATCH 08/78] fix(platform-wallet): wire shielded spend Merkle witnesses through ShieldedStore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `extract_spends_and_anchor` returned `ShieldedBuildError("Spending operations require a ShieldedStore that provides MerklePath witnesses. Not yet implemented.")` for every note, so shielded transfer / unshield / withdraw failed at runtime even when the store had a real commitment tree. The persistent tree's `ClientPersistentCommitmentTree::witness(position, depth) -> Option` was already available — the trait was just sitting on a `Vec` placeholder. Change `ShieldedStore::witness()` to return `Result, _>` directly, wire `FileBackedShieldedStore::witness` through `tree.witness(Position::from(position), 0)` (depth 0 matches the `tree_anchor()` that the same builder consumes), and have `extract_spends_and_anchor` build real `SpendableNote { note, merkle_path }` entries. Side effects (deliberate): - `InMemoryShieldedStore::witness` keeps its existing `Err`; that store has no tree state, only a flat `Vec<[u8; 32]>` of commitments. Spend paths require a real store. - Trait module-doc was updated: the "no orchard types" claim was already partially false (notes deserialize to `orchard::Note` at the call site) and is now plainly false. Tests: 11 existing shielded unit tests pass; clippy clean; iOS xcframework + SwiftExampleApp rebuild succeeds. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/wallet/shielded/file_store.rs | 21 ++++++---- .../src/wallet/shielded/operations.rs | 41 ++++++++----------- .../src/wallet/shielded/store.rs | 27 +++++++++--- 3 files changed, 52 insertions(+), 37 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index c217d0febe9..9a589bc3eba 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -160,14 +160,19 @@ impl ShieldedStore for FileBackedShieldedStore { .map_err(|e| FileShieldedStoreError(format!("read tree anchor: {e}"))) } - fn witness(&self, _position: u64) -> Result, Self::Error> { - // Witness path serialization lives with the spend signer; the - // sync path doesn't call this, and spend ops haven't been - // routed back through `ShieldedStore` yet. - let _ = Position::from(_position); // keep the import alive - Err(FileShieldedStoreError( - "witness generation deferred until spend signer lands".into(), - )) + fn witness( + &self, + position: u64, + ) -> Result, Self::Error> { + let tree = self + .tree + .lock() + .map_err(|e| FileShieldedStoreError(format!("tree mutex poisoned: {e}")))?; + // `checkpoint_depth = 0` = current tree state. The Halo 2 + // proof we're about to build uses `tree_anchor()` — also + // depth 0 — so the witness root must agree. + tree.witness(Position::from(position), 0) + .map_err(|e| FileShieldedStoreError(format!("witness({position}): {e}"))) } fn last_synced_note_index(&self) -> Result { diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 281eef1c9ea..9cfc3b530d4 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -542,7 +542,6 @@ impl ShieldedWallet { let mut spends = Vec::with_capacity(notes.len()); for note in notes { - // Deserialize the stored note back to an Orchard Note let orchard_note = deserialize_note(¬e.note_data).ok_or_else(|| { PlatformWalletError::ShieldedBuildError(format!( "Failed to deserialize note at position {}", @@ -550,29 +549,25 @@ impl ShieldedWallet { )) })?; - // Get Merkle witness for this note position. - // The ShieldedStore trait returns Vec to avoid coupling the trait - // to the MerklePath type. Production implementations should store the - // witness bytes from ClientPersistentCommitmentTree::witness(). - // - // TODO: MerklePath doesn't implement serde traits, so we can't - // deserialize from bytes generically. The real fix is to either: - // (a) Make ShieldedStore return MerklePath directly (couples to orchard), or - // (b) Add a witness_for_spend() method that returns SpendableNote directly. - // For now, spending operations require a store that provides valid witnesses. - let _witness_bytes = store.witness(note.position).map_err(|e| { - PlatformWalletError::ShieldedMerkleWitnessUnavailable(e.to_string()) - })?; + // The store returns the typed `MerklePath` (option (a) from + // the previous TODO — coupling the trait to the orchard + // types is the only sound path: `MerklePath` doesn't + // implement serde, so a bytes contract would force every + // caller through a serializer that doesn't exist). + let merkle_path = store + .witness(note.position) + .map_err(|e| PlatformWalletError::ShieldedMerkleWitnessUnavailable(e.to_string()))? + .ok_or_else(|| { + PlatformWalletError::ShieldedMerkleWitnessUnavailable(format!( + "no witness available for note at position {} (not marked, or pruned past this position)", + note.position + )) + })?; - // TODO: Convert witness bytes to MerklePath and build SpendableNote. - // MerklePath doesn't implement serde, so this requires either: - // (a) coupling ShieldedStore to MerklePath type, or - // (b) a higher-level method that returns SpendableNote directly. - // For now, spending operations are not yet functional. - let _note = orchard_note; - return Err(PlatformWalletError::ShieldedBuildError( - "Spending operations require a ShieldedStore that provides MerklePath witnesses. Not yet implemented.".to_string(), - )); + spends.push(SpendableNote { + note: orchard_note, + merkle_path, + }); } let anchor_bytes = store diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index 54e5bde9de7..78be58fe3ca 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -5,9 +5,11 @@ //! SQLite-backed for production) while tests can use `InMemoryShieldedStore`. //! //! Note data is stored as raw bytes (`note_data: Vec`) — a serialized -//! `orchard::Note` — so the trait itself does not depend on `orchard` types. -//! The serialization format is documented in -//! [`crate::wallet::shielded::keys`] (115 bytes: recipient || value || rho || rseed). +//! `orchard::Note`. The witness path, however, is returned as a typed +//! `grovedb_commitment_tree::MerklePath`: that type doesn't implement +//! serde, so a bytes contract would force every caller through a +//! serializer that doesn't exist. Anything spending a note already +//! depends on these types via the DPP shielded builder. use std::collections::BTreeMap; use std::error::Error as StdError; @@ -85,11 +87,21 @@ pub trait ShieldedStore: Send + Sync { fn tree_anchor(&self) -> Result<[u8; 32], Self::Error>; /// Generate a Merkle authentication path (witness) for the note at the - /// given global position. Returns the path as raw bytes. + /// given global position, against the current tree state. + /// + /// Returns `Ok(None)` if no witness is available (e.g. the position is + /// not marked or the tree state has been pruned past it). Returns the + /// typed `MerklePath` so callers can hand it directly to the Orchard + /// spend builder; `MerklePath` doesn't implement serde, so a bytes + /// variant would force every caller to round-trip through a + /// non-existent serializer. /// /// This is needed when spending a note — the ZK proof must demonstrate /// that the note commitment exists in the tree at `anchor`. - fn witness(&self, position: u64) -> Result, Self::Error>; + fn witness( + &self, + position: u64, + ) -> Result, Self::Error>; // ── Sync state ───────────────────────────────────────────────────── @@ -217,7 +229,10 @@ impl ShieldedStore for InMemoryShieldedStore { Ok(self.anchor) } - fn witness(&self, _position: u64) -> Result, Self::Error> { + fn witness( + &self, + _position: u64, + ) -> Result, Self::Error> { // In-memory store does not support real Merkle witness generation. // Production implementations use ClientPersistentCommitmentTree. Err(InMemoryStoreError( From 9211a0da3765a43934859049c3f058feef6c4a7f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 6 May 2026 15:08:56 +0700 Subject: [PATCH 09/78] fix(swift-example-app): per-wallet shielded commitment-tree DB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dbPath(for:)` was keyed only on network, so two wallets on the same network bound `bind_shielded` to the *same* SQLite file. `FileBackedShieldedStore`'s notes table has no `wallet_id` column, so `store.get_unspent_notes()` returned every wallet's notes — wallet B saw wallet A's shielded balance under its own name even though B's seed (and FVK) is unrelated. User reproduced this with two wallets on regtest, distinct mnemonics: a freshly created Wallet2 with empty Core/Platform balances reported the same 0.6 DASH shielded balance as the funded Reg wallet. Include the wallet id hex in the dbPath. Each wallet now has its own commitment-tree file and will re-sync from genesis on first bind. Per project memory ("pre-release: schema migrations aren't a concern; dev DBs rebuild"), the resulting one-time re-sync is acceptable. Long-term the right fix is to add a `wallet_id` column to the notes table inside `FileBackedShieldedStore` so wallets can share the tree but filter their own notes; that's a bigger change tracked separately. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Services/ShieldedService.swift | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift index d6a7ed66f81..55d73315ebb 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -118,7 +118,7 @@ class ShieldedService: ObservableObject { totalNewNotes = 0 totalNewlySpent = 0 - let dbPath = Self.dbPath(for: network) + let dbPath = Self.dbPath(for: network, walletId: walletId) do { try walletManager.bindShielded( walletId: walletId, @@ -241,14 +241,23 @@ class ShieldedService: ObservableObject { // MARK: - Private - /// One commitment tree per network (the Orchard tree is global per - /// network; only the per-wallet decrypted notes are wallet-scoped). - private static func dbPath(for network: Network) -> String { + /// Per-(network, wallet) commitment-tree DB. Conceptually the + /// Orchard tree is shared across wallets on the same network (the + /// tree itself is anchor-equivalent for everyone), but + /// `FileBackedShieldedStore` keeps decrypted notes in the same + /// SQLite file without a `wallet_id` column — so a single + /// per-network file would let wallet B read wallet A's notes + /// (and report A's balance under B's name). Until the store is + /// extended to scope notes by wallet, each wallet gets its own + /// file. Cost: re-syncing the tree from genesis per wallet on + /// first bind. Acceptable for now. + private static func dbPath(for network: Network, walletId: Data) -> String { let docs = FileManager.default .urls(for: .documentDirectory, in: .userDomainMask) .first! + let walletHex = walletId.map { String(format: "%02x", $0) }.joined() return docs - .appendingPathComponent("shielded_tree_\(network.networkName).sqlite") + .appendingPathComponent("shielded_tree_\(network.networkName)_\(walletHex).sqlite") .path } } From 3ffce1a34590e236434620106663d9d83463d2ae Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 6 May 2026 15:19:02 +0700 Subject: [PATCH 10/78] fix(swift-example-app): repoint ShieldedService when opening a wallet detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ShieldedService` is a singleton bound by `rebindWalletScopedServices()` to `walletManager.firstWallet`. The detail-view code path never re-bound it, so opening any wallet other than `firstWallet` showed `firstWallet`'s shielded balance under the wrong wallet's name. The previous per-wallet dbPath fix correctly isolated each wallet's notes in Rust, but the published `shieldedBalance` on the UI side stayed pinned to the first-bound wallet. `ShieldedService` now stashes `walletManager` / `resolver` / `network` on first `bind(...)` and exposes `switchTo(walletId:)` that reuses them — cheap and idempotent (the Rust-side `bind_shielded` already replaces its slot). `WalletDetailView` calls it from `.onAppear` and `.onChange(of: wallet.walletId)`, and grew the `@EnvironmentObject var shieldedService` it was missing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Services/ShieldedService.swift | 46 +++++++++++++++++++ .../Core/Views/WalletDetailView.swift | 14 +++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift index 55d73315ebb..a93f9e37ec0 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -70,6 +70,16 @@ class ShieldedService: ObservableObject { /// Wallet id we filter sync results by. private var walletId: Data? + /// Network of the currently-bound wallet. Stashed so + /// `switchTo(walletId:)` can reach the right per-network + /// dbPath without re-plumbing it from the call site. + private var network: Network? + + /// Mnemonic resolver stashed from the first `bind`. Reused by + /// `switchTo(walletId:)` so detail views can rebind without + /// pulling a fresh resolver out of the SwiftUI environment. + private var resolver: MnemonicResolver? + /// Subscription to `walletManager.$shieldedSyncIsSyncing`. private var syncStateCancellable: AnyCancellable? @@ -94,6 +104,8 @@ class ShieldedService: ObservableObject { ) { self.walletManager = walletManager self.walletId = walletId + self.network = network + self.resolver = resolver self.syncStateCancellable?.cancel() self.syncEventCancellable?.cancel() @@ -161,6 +173,40 @@ class ShieldedService: ObservableObject { } } + /// Re-bind the singleton service to a different wallet using the + /// `walletManager` / `resolver` / `network` stashed by the first + /// `bind(...)`. Per-detail-view code paths call this when the + /// user navigates into a wallet other than the one + /// `rebindWalletScopedServices()` initially selected — without + /// it, the published `shieldedBalance` stays pinned to the + /// first-bound wallet and every detail screen shows that + /// wallet's balance. + /// + /// No-op if the requested wallet is already bound. Logs and + /// returns early if `bind(...)` was never called yet. + func switchTo(walletId: Data) { + if self.walletId == walletId, isBound { + return + } + guard + let walletManager, + let resolver, + let network + else { + SDKLogger.log( + "ShieldedService.switchTo called before initial bind — ignoring", + minimumLevel: .medium + ) + return + } + bind( + walletManager: walletManager, + walletId: walletId, + network: network, + resolver: resolver + ) + } + /// Trigger a manual shielded sync pass. No-op if a pass is /// already in flight. /// diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index 1df76b683c0..f34dc79aaf4 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -22,6 +22,7 @@ struct WalletDetailView: View { @EnvironmentObject var walletManager: PlatformWalletManager @EnvironmentObject var platformState: AppState @EnvironmentObject var appUIState: AppUIState + @EnvironmentObject var shieldedService: ShieldedService @Environment(\.dismiss) private var dismiss let wallet: PersistentWallet @State private var showReceiveAddress = false @@ -176,7 +177,18 @@ struct WalletDetailView: View { dismiss() } } - .onAppear { appUIState.showWalletsSyncDetails = false } + .onAppear { + appUIState.showWalletsSyncDetails = false + // Repoint the singleton ShieldedService at THIS wallet — + // the app-level bind only attaches it to `firstWallet`, + // so without this every detail screen would show the + // first-bound wallet's shielded balance regardless of + // which wallet the user opened. + shieldedService.switchTo(walletId: wallet.walletId) + } + .onChange(of: wallet.walletId) { _, newId in + shieldedService.switchTo(walletId: newId) + } } } From 4ba2c4230980cfad3b6c967c711657f22742207f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 6 May 2026 16:16:46 +0700 Subject: [PATCH 11/78] feat(platform-wallet,swift-sdk): multi-account shielded wallet (Design B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor shielded internals so a single PlatformWallet can hold multiple ZIP-32 Orchard accounts that share the network's commitment tree but keep their decrypted notes / nullifiers / sync watermarks scoped per-(wallet_id, account_index). This replaces the per-wallet shielded SQLite path that was shipped earlier — that change isolated wallets at the cost of a duplicate tree per wallet, and didn't help with same-wallet multi-account at all. The on-chain commitment stream is chain-wide, so the tree should be too. ## What changes **`ShieldedStore` trait** (rs-platform-wallet): - New `SubwalletId { wallet_id: [u8; 32], account_index: u32 }`. - Note + sync-state methods (`save_note`, `get_unspent_notes`, `mark_spent`, `last_synced_note_index`, `nullifier_checkpoint`, …) take `id: SubwalletId`. Tree methods (`append_commitment`, `checkpoint_tree`, `tree_anchor`, `witness`) stay scope-free. - `InMemoryShieldedStore` and `FileBackedShieldedStore` now hold a `BTreeMap` and lazily allocate per-subwallet entries. **`ShieldedWallet`**: - Holds `accounts: BTreeMap` (per-account keyset). New constructors `from_keysets`, `from_seed_accounts`; `add_account_from_seed` for live add. New `account_indices`, `keys_for(account)`, `default_address(account)`, `balance(account)`, `balances`, `balance_total`. Per-wallet `wallet_id` field threaded through every store call as `SubwalletId`. **Sync** (`shielded/sync.rs`): - One sync pass covers every bound account: fetch raw chunks via `sync_shielded_notes` once with the lowest-keyed account's IVK, then locally trial-decrypt each chunk with every other account's IVK via `dash_sdk::platform::shielded:: try_decrypt_note`. Append each cmx to the shared tree once with `marked = (any account decrypted this position)`. - `SyncNotesResult` and `ShieldedSyncSummary` carry per-account maps; `total_new_notes`, `total_newly_spent`, `balance_total` helpers fold them for the flat FFI surface. **Operations** (`shielded/operations.rs`): - `transfer`, `unshield`, `withdraw`, `shield`, `shield_from_asset_lock` all take `account: u32` and route through the corresponding `OrchardKeySet` and per-subwallet note set. Spends never cross account boundaries. **`PlatformWallet`**: - `bind_shielded(seed, accounts: &[u32], db_path)` derives all listed accounts at once. New `shielded_add_account(seed, account)` for live add (with a docstring caveat that historical retroactive marking requires a tree wipe + resync). - `shielded_default_address(account)`, `shielded_balances()`, `shielded_account_indices()`, plus the four spend helpers (`shielded_transfer_to`, `shielded_unshield_to`, `shielded_withdraw_to`, `shielded_shield_from_account`) all take `account: u32`. - `shielded_shield_from_account` now takes both `shielded_account` and `payment_account` — they're distinct concepts (Orchard recipient account vs Platform Payment funding account) that previously shared one `account_index` parameter. **FFI** (`rs-platform-wallet-ffi`): - `platform_wallet_manager_bind_shielded` takes `accounts_ptr: *const u32, accounts_len: usize` (1..=64). - All four spend entry points + `shielded_default_address` take `account: u32`. `shielded_shield` takes both `shielded_account` and `payment_account`. - `ShieldedSyncWalletResultFFI::ok` flattens per-account sums. **Swift SDK + example app**: - `bindShielded` takes `accounts: [UInt32] = [0]`; passes the C buffer through. - All shielded send wrappers take `account: UInt32 = 0`. - `shieldedDefaultAddress(walletId:account:)` per-account. - `ShieldedService.dbPath(for:network:)` reverts to per-network (the per-(wallet,network) workaround is no longer needed — notes are scoped at the column level inside the store). ## Persistence (deferred) This commit ships the multi-account refactor with notes still held only in memory (`Vec` on `SubwalletState`). Cold start = re-sync from genesis, same as before. SwiftData persistence (`PersistentShieldedNote` keyed by `(walletId, accountIndex, position)` driven through the existing changeset model) is the planned next step but is its own substantial slice — splitting it out keeps this commit reviewable. ## Tests 11 existing shielded unit tests pass. New `test_save_and_retrieve_notes`, `test_mark_spent`, `test_sync_state_per_subwallet` cover SubwalletId scoping in the in-memory store. iOS xcframework + SwiftExampleApp rebuild green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/shielded_send.rs | 37 +- .../src/shielded_sync.rs | 71 ++- .../src/wallet/platform_wallet.rs | 178 +++++-- .../src/wallet/shielded/file_store.rs | 150 +++--- .../src/wallet/shielded/mod.rs | 241 ++++++--- .../src/wallet/shielded/operations.rs | 297 +++++------- .../src/wallet/shielded/store.rs | 382 +++++++++------ .../src/wallet/shielded/sync.rs | 458 +++++++++++------- .../PlatformWalletManagerShieldedSync.swift | 95 ++-- .../Core/Services/ShieldedService.swift | 34 +- .../Core/ViewModels/SendViewModel.swift | 6 +- 11 files changed, 1167 insertions(+), 782 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 3ce99636013..9bbe60fbecb 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -82,6 +82,7 @@ pub unsafe extern "C" fn platform_wallet_shielded_prover_is_ready() -> bool { pub unsafe extern "C" fn platform_wallet_manager_shielded_transfer( handle: Handle, wallet_id_bytes: *const u8, + account: u32, recipient_raw_43: *const u8, amount: u64, ) -> PlatformWalletFFIResult { @@ -106,7 +107,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_transfer( let result = block_on_worker(async move { let prover = CachedOrchardProver::new(); wallet - .shielded_transfer_to(&recipient, amount, &prover) + .shielded_transfer_to(account, &recipient, amount, &prover) .await }); if let Err(e) = result { @@ -137,6 +138,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_transfer( pub unsafe extern "C" fn platform_wallet_manager_shielded_unshield( handle: Handle, wallet_id_bytes: *const u8, + account: u32, to_platform_addr_cstr: *const c_char, amount: u64, ) -> PlatformWalletFFIResult { @@ -163,7 +165,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_unshield( let result = block_on_worker(async move { let prover = CachedOrchardProver::new(); wallet - .shielded_unshield_to(&to_addr_str, amount, &prover) + .shielded_unshield_to(account, &to_addr_str, amount, &prover) .await }); if let Err(e) = result { @@ -190,6 +192,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_unshield( pub unsafe extern "C" fn platform_wallet_manager_shielded_withdraw( handle: Handle, wallet_id_bytes: *const u8, + account: u32, to_core_address_cstr: *const c_char, amount: u64, core_fee_per_byte: u32, @@ -217,7 +220,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_withdraw( let result = block_on_worker(async move { let prover = CachedOrchardProver::new(); wallet - .shielded_withdraw_to(&to_core, amount, core_fee_per_byte, &prover) + .shielded_withdraw_to(account, &to_core, amount, core_fee_per_byte, &prover) .await }); if let Err(e) = result { @@ -230,16 +233,19 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_withdraw( } /// Shield: spend credits from a Platform Payment account into -/// the bound shielded sub-wallet's pool. `account_index` selects -/// which Platform Payment account to draw from; the wallet -/// auto-selects input addresses in ascending derivation order -/// until the cumulative balance covers `amount + fee buffer`. +/// the bound shielded sub-wallet's pool. +/// +/// `shielded_account` selects which ZIP-32 Orchard account on +/// the bound shielded sub-wallet receives the new note. +/// `payment_account` selects which Platform Payment account on +/// the transparent side funds the shield (auto-selects input +/// addresses in ascending derivation order until the cumulative +/// balance covers `amount + fee buffer`). /// /// `signer_address_handle` is a `*mut SignerHandle` produced by /// `dash_sdk_signer_create_with_ctx` (typically Swift's -/// `KeychainSigner.handle`) — same shape -/// `platform_address_wallet_transfer` expects. The caller retains -/// ownership; this function does not destroy the handle. +/// `KeychainSigner.handle`). The caller retains ownership; this +/// function does not destroy the handle. /// /// # Safety /// - `wallet_id_bytes` must point to 32 readable bytes. @@ -251,7 +257,8 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_withdraw( pub unsafe extern "C" fn platform_wallet_manager_shielded_shield( handle: Handle, wallet_id_bytes: *const u8, - account_index: u32, + shielded_account: u32, + payment_account: u32, amount: u64, signer_address_handle: *mut SignerHandle, ) -> PlatformWalletFFIResult { @@ -286,7 +293,13 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_shield( let result = block_on_worker(async move { let prover = CachedOrchardProver::new(); wallet - .shielded_shield_from_account(account_index, amount, address_signer, &prover) + .shielded_shield_from_account( + shielded_account, + payment_account, + amount, + address_signer, + &prover, + ) .await }); if let Err(e) = result { diff --git a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs index 31e9bd43140..db26d69c0f3 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs @@ -29,8 +29,11 @@ use crate::{check_ptr, unwrap_option_or_return}; impl ShieldedSyncWalletResultFFI { pub(crate) fn ok(wallet_id: [u8; 32], summary: &ShieldedSyncSummary) -> Self { - let new_notes = u32::try_from(summary.notes_result.new_notes).unwrap_or(u32::MAX); - let newly_spent = u32::try_from(summary.newly_spent).unwrap_or(u32::MAX); + // Multi-account on the Rust side; flattened to wallet-level + // sums here. Hosts that want per-account detail call + // `platform_wallet_manager_shielded_balance(account)`. + let new_notes = u32::try_from(summary.notes_result.total_new_notes()).unwrap_or(u32::MAX); + let newly_spent = u32::try_from(summary.total_newly_spent()).unwrap_or(u32::MAX); Self { wallet_id, success: true, @@ -38,7 +41,7 @@ impl ShieldedSyncWalletResultFFI { new_notes, total_scanned: summary.notes_result.total_scanned, newly_spent, - balance: summary.balance, + balance: summary.balance_total(), error_message: std::ptr::null(), } } @@ -156,38 +159,57 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_sync_sync_now( /// `db_path`, and bind the resulting [`ShieldedWallet`] to the /// `PlatformWallet`. /// +/// `accounts_ptr` / `accounts_len` describe the ZIP-32 account +/// indices to derive. The slice must be non-empty and at most +/// `64` entries; pass a one-element `[0]` array for the +/// single-account default. Each entry produces an independent +/// [`OrchardKeySet`] and bookkeeping `SubwalletId` inside the +/// store; the same commitment tree backs every account on the +/// network. +/// /// The resolver fires exactly once per call. The mnemonic and the -/// derived seed live in `Zeroizing` buffers and are scrubbed before -/// this function returns; only the FVK / IVK / OVK / default -/// payment address survive on the wallet. +/// derived seed live in `Zeroizing` buffers and are scrubbed +/// before this function returns; only the per-account FVK / IVK / +/// OVK / default payment addresses survive on the wallet. /// /// `db_path` is owned by the host (typically -/// `/shielded_tree_.sqlite`). The same path is fine -/// to share across wallets on the same network — the commitment -/// tree is global per network and per-wallet decrypted notes live -/// in memory. +/// `/shielded_tree_.sqlite`). The same path is +/// fine to share across wallets on the same network — the +/// commitment tree is global per network; decrypted notes are +/// scoped per `(wallet_id, account_index)` inside the store. /// -/// Idempotent: a second call with a different db path / account -/// replaces the previously-bound shielded wallet. +/// Idempotent: a second call replaces the previously-bound +/// shielded wallet. /// /// # Safety /// - `wallet_id_bytes` must point at 32 readable bytes. +/// - `accounts_ptr` must point at `accounts_len` readable `u32`s. /// - `mnemonic_resolver_handle` must come from /// [`crate::dash_sdk_mnemonic_resolver_create`]. /// - `db_path_cstr` must be a valid NUL-terminated UTF-8 C string. /// /// [`ShieldedWallet`]: platform_wallet::wallet::shielded::ShieldedWallet +/// [`OrchardKeySet`]: platform_wallet::wallet::shielded::OrchardKeySet #[no_mangle] pub unsafe extern "C" fn platform_wallet_manager_bind_shielded( handle: Handle, wallet_id_bytes: *const u8, mnemonic_resolver_handle: *mut MnemonicResolverHandle, - account: u32, + accounts_ptr: *const u32, + accounts_len: usize, db_path_cstr: *const c_char, ) -> PlatformWalletFFIResult { check_ptr!(wallet_id_bytes); check_ptr!(mnemonic_resolver_handle); check_ptr!(db_path_cstr); + check_ptr!(accounts_ptr); + if accounts_len == 0 || accounts_len > 64 { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("accounts_len must be in 1..=64, got {accounts_len}"), + ); + } + let accounts: Vec = std::slice::from_raw_parts(accounts_ptr, accounts_len).to_vec(); let mut wallet_id = [0u8; 32]; std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); @@ -285,7 +307,9 @@ pub unsafe extern "C" fn platform_wallet_manager_bind_shielded( } }; - if let Err(e) = runtime().block_on(wallet_arc.bind_shielded(seed.as_ref(), account, &db_path)) { + if let Err(e) = + runtime().block_on(wallet_arc.bind_shielded(seed.as_ref(), accounts.as_slice(), &db_path)) + { return PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorWalletOperation, format!("bind_shielded failed: {e}"), @@ -299,16 +323,16 @@ pub unsafe extern "C" fn platform_wallet_manager_bind_shielded( // Default Orchard payment address // --------------------------------------------------------------------------- -/// Read the default Orchard payment address for the bound shielded -/// sub-wallet on `wallet_id`. The host receives the 43 raw bytes -/// (recipient + diversifier) and applies its own bech32m encoding. +/// Read the default Orchard payment address for `account` on the +/// bound shielded sub-wallet of `wallet_id`. The host receives 43 +/// raw bytes (recipient + diversifier) and applies its own +/// bech32m encoding. /// /// `*out_present` is set to `true` and 43 bytes are written to -/// `out_bytes_43` when the wallet has been bound via -/// [`platform_wallet_manager_bind_shielded`]. When the wallet is -/// known but not bound, `*out_present` is set to `false` and -/// `out_bytes_43` is left untouched. An unknown wallet returns -/// `ErrorWalletOperation`. +/// `out_bytes_43` when `account` is bound. `*out_present` is set +/// to `false` when the wallet is known but the shielded +/// sub-wallet hasn't been bound, or `account` isn't bound on it. +/// An unknown wallet returns `ErrorWalletOperation`. /// /// # Safety /// - `wallet_id_bytes` must point at 32 readable bytes. @@ -318,6 +342,7 @@ pub unsafe extern "C" fn platform_wallet_manager_bind_shielded( pub unsafe extern "C" fn platform_wallet_manager_shielded_default_address( handle: Handle, wallet_id_bytes: *const u8, + account: u32, out_bytes_43: *mut u8, out_present: *mut bool, ) -> PlatformWalletFFIResult { @@ -338,7 +363,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_default_address( runtime().block_on(async { match manager.get_wallet(&wallet_id).await { None => Outcome::WalletMissing, - Some(w) => match w.shielded_default_address().await { + Some(w) => match w.shielded_default_address(account).await { Some(bytes) => Outcome::Bound(bytes), None => Outcome::Unbound, }, diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 114188bc423..33e85c3ca47 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -293,24 +293,29 @@ impl PlatformWallet { /// Bind a shielded (Orchard) sub-wallet to this `PlatformWallet`. /// - /// Derives ZIP-32 Orchard keys from `seed` (a 32-252 byte BIP-39 - /// seed; see [`SpendingKey::from_zip32_seed`]), opens or creates - /// the per-network commitment tree at `db_path`, and stores the - /// resulting [`ShieldedWallet`] on this handle. The caller is - /// responsible for sourcing the seed (e.g. via the host - /// `MnemonicResolverHandle`) and for zeroizing it once this call - /// returns. The seed is not retained — only the FVK / IVK / OVK - /// / default address derived from it survive on the wallet. + /// Derives ZIP-32 Orchard keys for every entry of `accounts` + /// from `seed` (a 32-252 byte BIP-39 seed; see + /// [`SpendingKey::from_zip32_seed`]), opens or creates the + /// per-network commitment tree at `db_path`, and stores the + /// resulting multi-account [`ShieldedWallet`] on this handle. + /// The caller is responsible for sourcing the seed (e.g. via + /// the host `MnemonicResolverHandle`) and for zeroizing it + /// once this call returns. The seed is not retained — only + /// the per-account FVK / IVK / OVK / default address derived + /// from it survive on the wallet. /// /// Idempotent: a second call replaces the previously-bound /// shielded wallet (e.g. after a network switch). /// + /// `accounts` must be non-empty; pass `&[0]` for the + /// single-account default. + /// /// [`SpendingKey::from_zip32_seed`]: grovedb_commitment_tree::SpendingKey::from_zip32_seed #[cfg(feature = "shielded")] pub async fn bind_shielded( &self, seed: &[u8], - account: u32, + accounts: &[u32], db_path: impl AsRef, ) -> Result<(), PlatformWalletError> { // Open / create the SQLite-backed commitment tree first so @@ -319,14 +324,41 @@ impl PlatformWallet { let store = FileBackedShieldedStore::open_path(db_path, 100) .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; let network = self.sdk.network; - let wallet = - ShieldedWallet::from_seed(Arc::clone(&self.sdk), seed, network, account, store)?; + let wallet = ShieldedWallet::from_seed_accounts( + Arc::clone(&self.sdk), + self.wallet_id, + seed, + network, + accounts, + store, + )?; let mut slot = self.shielded.write().await; *slot = Some(wallet); Ok(()) } + /// Add another ZIP-32 account to the already-bound shielded + /// sub-wallet. Returns `ShieldedNotBound` if `bind_shielded` + /// hasn't run yet. + /// + /// **Caveat**: notes belonging to `account` that already + /// landed on-chain before the bind call only become spendable + /// after a tree wipe + re-sync. Hosts that need to discover + /// historical funds for a freshly-added account should drop + /// the commitment-tree DB and call [`bind_shielded`] again + /// with the full account list. + #[cfg(feature = "shielded")] + pub async fn shielded_add_account( + &self, + seed: &[u8], + account: u32, + ) -> Result<(), PlatformWalletError> { + let mut slot = self.shielded.write().await; + let wallet = slot.as_mut().ok_or(PlatformWalletError::ShieldedNotBound)?; + wallet.add_account_from_seed(seed, self.sdk.network, account) + } + /// Whether the shielded sub-wallet has been bound via /// [`bind_shielded`](Self::bind_shielded). #[cfg(feature = "shielded")] @@ -334,7 +366,20 @@ impl PlatformWallet { self.shielded.read().await.is_some() } - /// Run one shielded sync pass on this wallet. + /// Bound ZIP-32 account indices on the shielded sub-wallet, + /// in ascending order. Empty if not bound. + #[cfg(feature = "shielded")] + pub async fn shielded_account_indices(&self) -> Vec { + self.shielded + .read() + .await + .as_ref() + .map(|w| w.account_indices()) + .unwrap_or_default() + } + + /// Run one shielded sync pass on this wallet (covers every + /// bound account in a single chain walk). /// /// Returns `Ok(None)` if the shielded sub-wallet hasn't been /// bound (the sync coordinator skips unbound wallets without @@ -349,24 +394,54 @@ impl PlatformWallet { } } - /// The default Orchard payment address for this wallet, as the - /// raw 43-byte representation. Returns `None` if the shielded - /// sub-wallet hasn't been bound. Hosts apply their own bech32m - /// encoding (HRP + 0x10 type byte) on top. + /// The default Orchard payment address for `account` on this + /// wallet, as the raw 43-byte representation. Returns `None` + /// if the shielded sub-wallet hasn't been bound or `account` + /// isn't bound on it. Hosts apply their own bech32m encoding + /// (HRP + 0x10 type byte) on top. #[cfg(feature = "shielded")] - pub async fn shielded_default_address(&self) -> Option<[u8; 43]> { + pub async fn shielded_default_address(&self, account: u32) -> Option<[u8; 43]> { let guard = self.shielded.read().await; guard .as_ref() - .map(|w| w.default_address().to_raw_address_bytes()) + .and_then(|w| w.default_address(account).ok()) + .map(|addr| addr.to_raw_address_bytes()) } - /// Send a private shielded → shielded transfer. Spends notes - /// from this wallet's shielded balance and sends `amount` - /// credits to `recipient_raw_43` (the recipient's Orchard - /// payment address as the 43 raw bytes — same shape - /// [`shielded_default_address`](Self::shielded_default_address) - /// returns). + /// Per-account default Orchard payment addresses (raw 43 bytes). + #[cfg(feature = "shielded")] + pub async fn shielded_default_addresses(&self) -> std::collections::BTreeMap { + let guard = self.shielded.read().await; + let Some(wallet) = guard.as_ref() else { + return std::collections::BTreeMap::new(); + }; + wallet + .account_indices() + .into_iter() + .filter_map(|account| { + wallet + .default_address(account) + .ok() + .map(|addr| (account, addr.to_raw_address_bytes())) + }) + .collect() + } + + /// Per-account unspent shielded balance. + #[cfg(feature = "shielded")] + pub async fn shielded_balances( + &self, + ) -> Result, PlatformWalletError> { + let guard = self.shielded.read().await; + match guard.as_ref() { + Some(wallet) => wallet.balances().await, + None => Ok(std::collections::BTreeMap::new()), + } + } + + /// Send a private shielded → shielded transfer from `account`'s + /// notes to `recipient_raw_43` (the recipient's Orchard payment + /// address as the 43 raw bytes). /// /// The prover is consumed by value rather than borrowed because /// `OrchardProver` is impl'd on `&CachedOrchardProver` (the @@ -376,6 +451,7 @@ impl PlatformWallet { #[cfg(feature = "shielded")] pub async fn shielded_transfer_to( &self, + account: u32, recipient_raw_43: &[u8; 43], amount: u64, prover: P, @@ -392,17 +468,19 @@ impl PlatformWallet { "invalid Orchard payment address bytes".to_string(), ) })?; - shielded.transfer(&recipient, amount, &prover).await + shielded + .transfer(account, &recipient, amount, &prover) + .await } - /// Unshield: spend shielded notes and send `amount` credits to - /// the platform address `to_platform_addr_bech32m` (a bech32m - /// string like `"dash1…"` / `"tdash1…"`). Parsed via + /// Unshield from `account`'s notes to a transparent platform + /// address (`"dash1…"` / `"tdash1…"`). Parsed via /// `PlatformAddress::from_bech32m_string` and verified against /// the wallet's network. #[cfg(feature = "shielded")] pub async fn shielded_unshield_to( &self, + account: u32, to_platform_addr_bech32m: &str, amount: u64, prover: P, @@ -424,15 +502,16 @@ impl PlatformWallet { self.sdk.network ))); } - shielded.unshield(&to, amount, &prover).await + shielded.unshield(account, &to, amount, &prover).await } - /// Withdraw: spend shielded notes and send `amount` credits to - /// the Core L1 address `to_core_address` (Base58Check string). - /// `core_fee_per_byte` is the L1 fee rate (duffs/byte). + /// Withdraw from `account`'s notes to a Core L1 address + /// (Base58Check string). `core_fee_per_byte` is the L1 fee + /// rate (duffs/byte). #[cfg(feature = "shielded")] pub async fn shielded_withdraw_to( &self, + account: u32, to_core_address: &str, amount: u64, core_fee_per_byte: u32, @@ -455,17 +534,23 @@ impl PlatformWallet { )) })?; shielded - .withdraw(&parsed, amount, core_fee_per_byte, &prover) + .withdraw(account, &parsed, amount, core_fee_per_byte, &prover) .await } - /// Shield credits from a Platform Payment account into this - /// wallet's shielded pool. Auto-selects input addresses from - /// the account in ascending derivation-index order until the - /// cumulative balance covers `amount` plus a conservative fee - /// buffer (the on-chain fee comes off input 0 via - /// `DeductFromInput(0)`; the buffer absorbs the discrepancy - /// without a more sophisticated estimator). + /// Shield credits from a Platform Payment account into the + /// wallet's shielded pool, with the resulting note assigned + /// to `shielded_account`'s default Orchard address. + /// + /// `payment_account` selects the source Platform Payment + /// account (different concept from `shielded_account` — this + /// is the BIP-44-style funding account on the transparent + /// side, not the ZIP-32 Orchard account). Auto-selects input + /// addresses from that account in ascending derivation-index + /// order until the cumulative balance covers `amount` plus a + /// conservative fee buffer (the on-chain fee comes off input + /// 0 via `DeductFromInput(0)`; the buffer absorbs the + /// discrepancy without a more sophisticated estimator). /// /// The host supplies a `Signer` — typically /// `&VTableSigner` from `KeychainSigner.handle` — which signs @@ -473,13 +558,14 @@ impl PlatformWallet { /// /// Returns `ShieldedNotBound` if no shielded sub-wallet is /// bound, `AddressOperation` if the platform-payment account - /// at `account_index` doesn't exist, or + /// at `payment_account` doesn't exist, or /// `ShieldedInsufficientBalance` if the account's total /// credits can't cover `amount + fee_buffer`. #[cfg(feature = "shielded")] pub async fn shielded_shield_from_account( &self, - account_index: u32, + shielded_account: u32, + payment_account: u32, amount: u64, signer: &S, prover: P, @@ -521,10 +607,10 @@ impl PlatformWallet { .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; let account = info .core_wallet - .platform_payment_managed_account_at_index(account_index) + .platform_payment_managed_account_at_index(payment_account) .ok_or_else(|| { PlatformWalletError::AddressOperation(format!( - "no platform payment account at index {account_index}" + "no platform payment account at index {payment_account}" )) })?; @@ -623,7 +709,9 @@ impl PlatformWallet { let shielded = guard .as_ref() .ok_or(PlatformWalletError::ShieldedNotBound)?; - shielded.shield(inputs, amount, signer, &prover).await + shielded + .shield(shielded_account, inputs, amount, signer, &prover) + .await } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index 9a589bc3eba..caf05f9df62 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -1,18 +1,15 @@ //! File-backed `ShieldedStore` impl. //! -//! The Orchard commitment tree is shared across every wallet that -//! decrypts notes against the same network — there is one global -//! tree of commitments and each wallet keeps its own decrypted-note -//! subset. This store therefore persists the tree to a SQLite file -//! (via [`ClientPersistentCommitmentTree`]) while keeping the -//! per-wallet decrypted notes and nullifier bookkeeping in memory. -//! Notes are rediscovered on cold start by re-running -//! [`ShieldedWallet::sync_notes`](super::ShieldedWallet::sync_notes) -//! against the cached tree. -//! -//! Witness generation (needed for spends) is intentionally not -//! implemented yet — the spend signer pipeline that drives it lands -//! in a follow-up. +//! The Orchard commitment tree is shared across every subwallet +//! that decrypts notes against the same network — the on-chain +//! commitment stream is identical for every consumer. This store +//! therefore persists the tree to a SQLite file (via +//! [`ClientPersistentCommitmentTree`]) and keeps per-subwallet +//! decrypted notes / nullifier bookkeeping in memory, scoped by +//! [`SubwalletId`]. Notes are rediscovered on cold start by +//! re-running [`ShieldedWallet::sync_notes`] against the cached +//! tree (or, when the host persister is wired up, restored from +//! SwiftData before sync runs). use std::collections::BTreeMap; use std::error::Error as StdError; @@ -22,7 +19,7 @@ use std::sync::Mutex; use grovedb_commitment_tree::{ClientPersistentCommitmentTree, Position, Retention}; -use super::store::{ShieldedNote, ShieldedStore}; +use super::store::{ShieldedNote, ShieldedStore, SubwalletId, SubwalletState}; /// Error type for [`FileBackedShieldedStore`]. #[derive(Debug)] @@ -36,39 +33,24 @@ impl fmt::Display for FileShieldedStoreError { impl StdError for FileShieldedStoreError {} -/// File-backed shielded store: SQLite-persisted commitment tree plus -/// in-memory decrypted notes / nullifier bookkeeping. -/// -/// The commitment tree is keyed per-network at the call site (the -/// path is supplied by [`Self::open_path`]). Decrypted notes are -/// kept in memory and rediscovered via trial decryption on every -/// cold start — same shape the previous `ShieldedPoolClient` had, -/// suitable for the MVP shielded sync path. Persisting notes via -/// the host's data store is a follow-up. +/// File-backed shielded store: SQLite-persisted commitment tree +/// plus in-memory per-subwallet decrypted notes / nullifier +/// bookkeeping. pub struct FileBackedShieldedStore { - /// SQLite-backed commitment tree. Wrapped in a `Mutex` rather than - /// relying on `&mut self` because the underlying SQLite store is - /// not `Sync` on its own and the [`ShieldedStore`] trait requires - /// `Send + Sync`. Outer concurrency is still serialized through - /// `ShieldedWallet`'s `RwLock`; this inner mutex is just a - /// `Sync`-restoring shim and is uncontended in practice. + /// SQLite-backed commitment tree. Wrapped in a `Mutex` because + /// the underlying SQLite store is not `Sync`; the + /// [`ShieldedStore`] trait requires `Send + Sync`. Outer + /// concurrency is still serialized through `ShieldedWallet`'s + /// `RwLock`; this inner mutex is just a `Sync`-restoring + /// shim and is uncontended in practice. tree: Mutex, - notes: Vec, - /// Nullifier → index into `notes`, for `mark_spent` lookups. - nullifier_index: BTreeMap<[u8; 32], usize>, - /// Last global note index synced from Platform. - last_synced_index: u64, - /// `(height, timestamp)` from the most recent nullifier sync. - nullifier_checkpoint: Option<(u64, u64)>, + /// Per-subwallet notes + sync state, keyed by `(wallet_id, + /// account_index)`. Lazily populated on first use of an id. + subwallets: BTreeMap, } impl FileBackedShieldedStore { /// Open or create a shielded store at `path`. - /// - /// `max_checkpoints` controls how many tree checkpoints the - /// underlying [`ClientPersistentCommitmentTree`] retains for - /// witness generation. A value of `100` matches what the previous - /// SDK-side client used. pub fn open_path( path: impl AsRef, max_checkpoints: usize, @@ -77,10 +59,7 @@ impl FileBackedShieldedStore { .map_err(|e| FileShieldedStoreError(format!("open commitment tree: {e}")))?; Ok(Self { tree: Mutex::new(tree), - notes: Vec::new(), - nullifier_index: BTreeMap::new(), - last_synced_index: 0, - nullifier_checkpoint: None, + subwallets: BTreeMap::new(), }) } } @@ -88,42 +67,33 @@ impl FileBackedShieldedStore { impl ShieldedStore for FileBackedShieldedStore { type Error = FileShieldedStoreError; - fn save_note(&mut self, note: &ShieldedNote) -> Result<(), Self::Error> { - // Re-saving an already-known note (e.g. a re-scan after a - // cold start trial-decrypts the same chunk) used to append - // a duplicate `ShieldedNote` while overwriting the - // nullifier index. The result was a double-counted balance - // (`get_unspent_notes` returned both copies) and a stuck - // unspent flag (`mark_spent` only marked the second copy). - // Orchard nullifiers are globally unique, so an existing - // entry for the same nullifier means we already have this - // note — overwrite-in-place rather than append. - if let Some(&existing_idx) = self.nullifier_index.get(¬e.nullifier) { - self.notes[existing_idx] = note.clone(); - return Ok(()); - } - let idx = self.notes.len(); - self.nullifier_index.insert(note.nullifier, idx); - self.notes.push(note.clone()); + fn save_note(&mut self, id: SubwalletId, note: &ShieldedNote) -> Result<(), Self::Error> { + self.subwallets.entry(id).or_default().save_note(note); Ok(()) } - fn get_unspent_notes(&self) -> Result, Self::Error> { - Ok(self.notes.iter().filter(|n| !n.is_spent).cloned().collect()) + fn get_unspent_notes(&self, id: SubwalletId) -> Result, Self::Error> { + Ok(self + .subwallets + .get(&id) + .map(SubwalletState::unspent_notes) + .unwrap_or_default()) } - fn get_all_notes(&self) -> Result, Self::Error> { - Ok(self.notes.clone()) + fn get_all_notes(&self, id: SubwalletId) -> Result, Self::Error> { + Ok(self + .subwallets + .get(&id) + .map(SubwalletState::all_notes) + .unwrap_or_default()) } - fn mark_spent(&mut self, nullifier: &[u8; 32]) -> Result { - if let Some(&idx) = self.nullifier_index.get(nullifier) { - if !self.notes[idx].is_spent { - self.notes[idx].is_spent = true; - return Ok(true); - } - } - Ok(false) + fn mark_spent(&mut self, id: SubwalletId, nullifier: &[u8; 32]) -> Result { + Ok(self + .subwallets + .get_mut(&id) + .map(|sw| sw.mark_spent(nullifier)) + .unwrap_or(false)) } fn append_commitment(&mut self, cmx: &[u8; 32], marked: bool) -> Result<(), Self::Error> { @@ -175,21 +145,37 @@ impl ShieldedStore for FileBackedShieldedStore { .map_err(|e| FileShieldedStoreError(format!("witness({position}): {e}"))) } - fn last_synced_note_index(&self) -> Result { - Ok(self.last_synced_index) + fn last_synced_note_index(&self, id: SubwalletId) -> Result { + Ok(self + .subwallets + .get(&id) + .map(|sw| sw.last_synced_index) + .unwrap_or(0)) } - fn set_last_synced_note_index(&mut self, index: u64) -> Result<(), Self::Error> { - self.last_synced_index = index; + fn set_last_synced_note_index( + &mut self, + id: SubwalletId, + index: u64, + ) -> Result<(), Self::Error> { + self.subwallets.entry(id).or_default().last_synced_index = index; Ok(()) } - fn nullifier_checkpoint(&self) -> Result, Self::Error> { - Ok(self.nullifier_checkpoint) + fn nullifier_checkpoint(&self, id: SubwalletId) -> Result, Self::Error> { + Ok(self + .subwallets + .get(&id) + .and_then(|sw| sw.nullifier_checkpoint)) } - fn set_nullifier_checkpoint(&mut self, height: u64, timestamp: u64) -> Result<(), Self::Error> { - self.nullifier_checkpoint = Some((height, timestamp)); + fn set_nullifier_checkpoint( + &mut self, + id: SubwalletId, + height: u64, + timestamp: u64, + ) -> Result<(), Self::Error> { + self.subwallets.entry(id).or_default().nullifier_checkpoint = Some((height, timestamp)); Ok(()) } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs index c68e0eb7507..5d2cd8327fa 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs @@ -1,19 +1,20 @@ -//! Feature-gated shielded (Orchard/Halo2) wallet support. +//! Feature-gated shielded (Orchard / Halo 2) wallet support. //! -//! This module provides ZK-private transactions on Dash Platform using the -//! Orchard circuit (Halo 2 proving system). It is gated behind the `shielded` -//! Cargo feature because it pulls in heavy cryptographic dependencies. +//! This module provides ZK-private transactions on Dash Platform +//! using the Orchard circuit (Halo 2 proving system). It is +//! gated behind the `shielded` Cargo feature because it pulls in +//! heavy cryptographic dependencies. //! //! # Architecture //! -//! - [`OrchardKeySet`] — ZIP-32 key derivation from wallet seed -//! - [`ShieldedStore`] / [`InMemoryShieldedStore`] — storage abstraction -//! - [`CachedOrchardProver`] — lazy-init proving key cache -//! - [`ShieldedWallet`] — top-level coordinator tying keys, store, and SDK together -//! -//! The `ShieldedWallet` is generic over `S: ShieldedStore` so consumers can -//! plug in their own persistence (SQLite, RocksDB, etc.) while tests use the -//! in-memory implementation. +//! - [`OrchardKeySet`] — ZIP-32 key derivation from a wallet seed. +//! - [`ShieldedStore`] / [`InMemoryShieldedStore`] — storage abstraction. +//! The shared commitment tree lives here too; per-subwallet +//! notes are scoped by [`SubwalletId`] inside the store. +//! - [`CachedOrchardProver`] — lazy-init proving key cache. +//! - [`ShieldedWallet`] — multi-account coordinator tying the +//! wallet's Orchard accounts (`BTreeMap`), +//! the shared store, and the SDK together. pub mod file_store; pub mod keys; @@ -26,87 +27,213 @@ pub mod sync; pub use file_store::{FileBackedShieldedStore, FileShieldedStoreError}; pub use keys::OrchardKeySet; pub use prover::CachedOrchardProver; -pub use store::{InMemoryShieldedStore, ShieldedNote, ShieldedStore}; +pub use store::{InMemoryShieldedStore, ShieldedNote, ShieldedStore, SubwalletId}; pub use sync::{ShieldedSyncSummary, SyncNotesResult}; +use std::collections::BTreeMap; use std::sync::Arc; use tokio::sync::RwLock; use crate::error::PlatformWalletError; +use crate::wallet::platform_wallet::WalletId; -/// Feature-gated shielded wallet. -/// -/// Coordinates Orchard key material, a pluggable storage backend, and the -/// Dash SDK for note sync, nullifier checks, and shielded state transitions. -/// -/// Generic over `S: ShieldedStore` — consumers provide their persistence -/// layer. For tests, use [`InMemoryShieldedStore`]. +/// Per-account state held inside a [`ShieldedWallet`]. /// -/// # Thread safety +/// Crate-private — callers go through `ShieldedWallet`'s +/// per-account helpers (`default_address(account)`, +/// `balance(account)`, etc.). Held by value (not behind a lock) +/// because the parent wallet's `RwLock` already serializes +/// access, and key material is read-only after derivation. +pub(super) struct AccountState { + pub(super) keys: OrchardKeySet, +} + +/// Feature-gated multi-account shielded wallet. /// -/// The store is wrapped in `Arc>` so the wallet can be shared -/// across async tasks. Read operations (balance, address queries) take a -/// read lock; mutating operations (sync, spend) take a write lock. +/// One [`ShieldedWallet`] lives inside one [`PlatformWallet`] and +/// holds every Orchard account that wallet has bound. Operations +/// take `account: u32` and route to the right keyset internally. +/// The shared `store: Arc>` is keyed per-account via +/// [`SubwalletId`] so multiple accounts on the same wallet (and +/// multiple wallets on the same network) cohabit the same store +/// without cross-talk. pub struct ShieldedWallet { /// Dash Platform SDK handle for network operations. - sdk: Arc, - /// ZIP-32 derived Orchard keys. - keys: OrchardKeySet, - /// Pluggable storage backend behind a shared async lock. - store: Arc>, + pub(super) sdk: Arc, + /// 32-byte wallet identifier — used to construct + /// [`SubwalletId`] for every store call. + pub(super) wallet_id: WalletId, + /// Bound Orchard accounts, keyed by ZIP-32 account index. + pub(super) accounts: BTreeMap, + /// Pluggable storage backend behind a shared async lock. The + /// commitment tree inside is global per network; notes are + /// scoped per-subwallet by the store's `SubwalletId` keying. + pub(super) store: Arc>, } impl ShieldedWallet { - /// Create a shielded wallet from pre-derived keys and a store. - pub fn new(sdk: Arc, keys: OrchardKeySet, store: S) -> Self { - Self { + /// Construct a [`ShieldedWallet`] from pre-derived keysets. + /// + /// `accounts` maps ZIP-32 account index → [`OrchardKeySet`]. + /// At least one account must be supplied. + pub fn from_keysets( + sdk: Arc, + wallet_id: WalletId, + accounts: BTreeMap, + store: S, + ) -> Result { + if accounts.is_empty() { + return Err(PlatformWalletError::ShieldedKeyDerivation( + "shielded wallet requires at least one account".to_string(), + )); + } + let accounts = accounts + .into_iter() + .map(|(idx, keys)| (idx, AccountState { keys })) + .collect(); + Ok(Self { sdk, - keys, + wallet_id, + accounts, store: Arc::new(RwLock::new(store)), - } + }) } - /// Derive Orchard keys from a wallet seed and create a shielded wallet. + /// Derive Orchard keys for every listed `account` from a + /// wallet seed and return a [`ShieldedWallet`]. /// - /// This is the primary constructor for production use. The `seed` should - /// be the BIP-39 seed bytes (typically 64 bytes). `network` selects the - /// ZIP-32 coin type used during key derivation; once derivation is done - /// the network is captured implicitly in the SDK handle. - /// - /// # Errors - /// - /// Returns an error if key derivation fails (invalid seed or account index). - pub fn from_seed( + /// `seed` is the BIP-39 seed bytes (32–252 bytes; typically + /// 64). `network` selects the ZIP-32 coin type. Each entry of + /// `accounts` becomes a separate ZIP-32 account + /// (`m / 32' / coin_type' / account'`); duplicates are + /// silently deduplicated. + pub fn from_seed_accounts( sdk: Arc, + wallet_id: WalletId, seed: &[u8], network: dashcore::Network, - account: u32, + accounts: &[u32], store: S, ) -> Result { - let keys = OrchardKeySet::from_seed(seed, network, account)?; - Ok(Self::new(sdk, keys, store)) + if accounts.is_empty() { + return Err(PlatformWalletError::ShieldedKeyDerivation( + "shielded wallet requires at least one account".to_string(), + )); + } + let mut keysets: BTreeMap = BTreeMap::new(); + for &account in accounts { + let keys = OrchardKeySet::from_seed(seed, network, account)?; + keysets.insert(account, keys); + } + Self::from_keysets(sdk, wallet_id, keysets, store) } - /// Total unspent shielded balance in credits. + /// Add another ZIP-32 account to this wallet by re-deriving + /// from the seed. No-op if `account` is already bound. /// + /// **Caveat**: the commitment tree only retains + /// authentication paths for positions `Retention::Marked` at + /// append time. Notes that reached the tree before this + /// account existed were marked `Ephemeral` and can never + /// produce witnesses for it without a tree wipe + full + /// re-sync. New accounts therefore only see notes from + /// future syncs. The host should drop the tree DB and + /// re-sync from genesis when the user adds an account they + /// expect to discover historical funds for. + pub fn add_account_from_seed( + &mut self, + seed: &[u8], + network: dashcore::Network, + account: u32, + ) -> Result<(), PlatformWalletError> { + if self.accounts.contains_key(&account) { + return Ok(()); + } + let keys = OrchardKeySet::from_seed(seed, network, account)?; + self.accounts.insert(account, AccountState { keys }); + Ok(()) + } + + /// All bound ZIP-32 account indices, in ascending order. + pub fn account_indices(&self) -> Vec { + self.accounts.keys().copied().collect() + } + + /// `true` iff `account` is bound on this wallet. + pub fn has_account(&self, account: u32) -> bool { + self.accounts.contains_key(&account) + } + + /// Borrow the keyset for `account`. + pub(super) fn keys_for(&self, account: u32) -> Result<&OrchardKeySet, PlatformWalletError> { + self.accounts.get(&account).map(|s| &s.keys).ok_or_else(|| { + PlatformWalletError::ShieldedKeyDerivation(format!( + "shielded account {account} not bound" + )) + }) + } + + /// Construct the [`SubwalletId`] for `account` on this wallet. + pub(super) fn subwallet_id(&self, account: u32) -> SubwalletId { + SubwalletId::new(self.wallet_id, account) + } + + /// Total unspent shielded balance for `account` in credits. /// Reads from the store — does not trigger a sync. - pub async fn balance(&self) -> Result { + pub async fn balance(&self, account: u32) -> Result { + self.keys_for(account)?; // existence check + let id = self.subwallet_id(account); let store = self.store.read().await; let notes = store - .get_unspent_notes() + .get_unspent_notes(id) .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; Ok(notes.iter().map(|n| n.value).sum()) } - /// The default payment address (diversifier index 0) for receiving - /// shielded funds. - pub fn default_address(&self) -> &grovedb_commitment_tree::PaymentAddress { - &self.keys.default_address + /// Sum of unspent shielded balance across every bound account. + pub async fn balance_total(&self) -> Result { + let store = self.store.read().await; + let mut total: u64 = 0; + for account in self.accounts.keys() { + let id = SubwalletId::new(self.wallet_id, *account); + let notes = store + .get_unspent_notes(id) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + total = total.saturating_add(notes.iter().map(|n| n.value).sum::()); + } + Ok(total) + } + + /// Per-account unspent shielded balance, in ascending account order. + pub async fn balances(&self) -> Result, PlatformWalletError> { + let store = self.store.read().await; + let mut out: BTreeMap = BTreeMap::new(); + for account in self.accounts.keys() { + let id = SubwalletId::new(self.wallet_id, *account); + let notes = store + .get_unspent_notes(id) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + out.insert(*account, notes.iter().map(|n| n.value).sum()); + } + Ok(out) } - /// Derive a payment address at the given diversifier index. - pub fn address_at(&self, index: u32) -> grovedb_commitment_tree::PaymentAddress { - self.keys.address_at(index) + /// The default payment address (diversifier index 0) for + /// `account`. Returns an error if `account` isn't bound. + pub fn default_address( + &self, + account: u32, + ) -> Result<&grovedb_commitment_tree::PaymentAddress, PlatformWalletError> { + self.keys_for(account).map(|k| &k.default_address) + } + + /// Derive a payment address at `index` under `account`. + pub fn address_at( + &self, + account: u32, + index: u32, + ) -> Result { + Ok(self.keys_for(account)?.address_at(index)) } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 9cfc3b530d4..366f496615f 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -1,27 +1,19 @@ -//! Shielded transaction operations (5 transition types). +//! Shielded transaction operations (5 transition types), multi-account. //! -//! Each operation follows a common pattern: -//! 1. Select spendable notes (if spending from the shielded pool) -//! 2. Get Merkle witnesses from the commitment tree -//! 3. Build Orchard bundle via DPP builder functions -//! 4. Broadcast the resulting state transition via SDK -//! 5. Mark spent notes (if any) in the store +//! Each operation now takes `account: u32` and routes through the +//! corresponding `OrchardKeySet` / `SubwalletId`. Spends never +//! cross account boundaries — note selection reads only the +//! given account's unspent notes. //! //! The five transition types are: -//! - **Shield** (Type 15): transparent platform addresses -> shielded pool -//! - **ShieldFromAssetLock** (Type 18): Core L1 asset lock -> shielded pool -//! - **Unshield** (Type 17): shielded pool -> transparent platform address -//! - **Transfer** (Type 16): shielded pool -> shielded pool (private) -//! - **Withdraw** (Type 19): shielded pool -> Core L1 address -//! -//! # Store requirements -//! -//! Spending operations (unshield, transfer, withdraw) require the store to -//! provide Merkle witness paths. The `ShieldedStore` trait needs a `witness()` -//! method for this -- see the TODO in `extract_spends_and_anchor()`. +//! - **Shield** (Type 15): transparent platform addresses → shielded pool +//! - **ShieldFromAssetLock** (Type 18): Core L1 asset lock → shielded pool +//! - **Unshield** (Type 17): shielded pool → transparent platform address +//! - **Transfer** (Type 16): shielded pool → shielded pool (private) +//! - **Withdraw** (Type 19): shielded pool → Core L1 address use super::note_selection::select_notes_with_fee; -use super::store::{ShieldedNote, ShieldedStore}; +use super::store::{ShieldedNote, ShieldedStore, SubwalletId}; use super::ShieldedWallet; use crate::error::PlatformWalletError; @@ -44,10 +36,10 @@ use dpp::withdrawal::Pooling; use grovedb_commitment_tree::{Anchor, PaymentAddress}; use tracing::{info, trace, warn}; -/// Try to extract a structured `AddressesNotEnoughFundsError` from a -/// broadcast error so the shield path can format a diagnostic that -/// includes Platform's actual per-input view (nonce + balance) rather -/// than just the stringified message. +/// Try to extract a structured `AddressesNotEnoughFundsError` from +/// a broadcast error so the shield path can format a diagnostic +/// that includes Platform's actual per-input view (nonce + balance) +/// rather than just the stringified message. fn addresses_not_enough_funds( e: &dash_sdk::Error, ) -> Option<&dpp::consensus::state::address_funds::AddressesNotEnoughFundsError> { @@ -68,8 +60,7 @@ fn addresses_not_enough_funds( /// Format a one-line `addresses_with_info` summary for diagnostics — /// each entry rendered as `=(nonce , credits)`, -/// matching what the wallet UI shows so the same string can be used -/// to grep logs for a specific address. +/// matching what the wallet UI shows. fn format_addresses_with_info( map: &std::collections::BTreeMap< dpp::address_funds::PlatformAddress, @@ -93,34 +84,22 @@ impl ShieldedWallet { // Shield: platform addresses -> shielded pool (Type 15) // ------------------------------------------------------------------------- - /// Shield funds from transparent platform addresses into the shielded pool. - /// - /// This is an output-only operation -- no notes are spent. Funds are deducted - /// from the transparent input addresses and a new shielded note is created for - /// this wallet's default payment address. - /// - /// # Parameters - /// - /// - `inputs` - Map of platform addresses to credits to spend from each - /// - `amount` - Total amount to shield (in credits) - /// - `signer` - Signs the transparent input witnesses (ECDSA) - /// - `prover` - Orchard prover for Halo 2 proof generation + /// Shield credits from transparent platform addresses into the + /// shielded pool, with the resulting note assigned to `account`'s + /// default Orchard payment address. pub async fn shield, P: OrchardProver>( &self, + account: u32, inputs: BTreeMap, amount: u64, signer: &Sig, prover: &P, ) -> Result<(), PlatformWalletError> { - let recipient_addr = self.default_orchard_address()?; + let recipient_addr = self.default_orchard_address(account)?; // Fetch the current address nonces from Platform. Each // input address has a per-address nonce that the next // state transition must use as `last_used + 1`. - // `AddressInfo::fetch_many` returns the last-used nonce - // (and current balance) per address; we increment it. - // Without this the broadcast was rejected by drive-abci - // because every shield transition tried to use nonce 0. use dash_sdk::platform::FetchMany; use dash_sdk::query_types::AddressInfo; use std::collections::BTreeSet; @@ -143,10 +122,6 @@ impl ShieldedWallet { addr )) })?; - // Surface a per-input diagnostic so the host can see what - // we're claiming vs what Platform actually reports — - // mismatches are the typical root cause of - // `AddressesNotEnoughFundsError` on shield broadcast. if info.balance < credits { warn!( address = ?addr, @@ -164,12 +139,11 @@ impl ShieldedWallet { "Shield input" ); } - // `AddressNonce` is `u32`; `info.nonce + 1` would panic in - // debug and wrap in release once an address reaches the - // ceiling. drive-abci treats `u32::MAX` as exhausted, so a - // wrap submits nonce 0 and gets rejected as a replay - // *after* the wallet has already spent ~30 s building the - // Halo 2 proof. Bail loudly here instead. + // `AddressNonce` is `u32`; `info.nonce + 1` would + // wrap silently in release once an address reaches + // u32::MAX. drive-abci treats wrap-to-0 as a replay + // and rejects it after the wallet has spent ~30 s on + // a Halo 2 proof. Bail loudly here instead. let next_nonce = info.nonce.checked_add(1).ok_or_else(|| { PlatformWalletError::ShieldedBuildError(format!( "input address nonce exhausted on platform: {:?}", @@ -182,18 +156,10 @@ impl ShieldedWallet { let fee_strategy: AddressFundsFeeStrategy = vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]; - info!("Shield credits: {} credits, building proof...", amount,); + info!(account, credits = amount, "Shield: building proof"); - // Snapshot what we're claiming so the diagnostic can show - // local-claim vs platform-view side by side when broadcast - // fails with `AddressesNotEnoughFundsError`. The map is - // moved into the builder below so we have to clone here. let claimed_inputs = inputs_with_nonce.clone(); - // Build the state transition using the DPP builder. - // `build_shield_transition` is async (cascade from the dpp - // `Signer` trait being made async upstream); await before - // mapping the error. let state_transition = build_shield_transition( &recipient_addr, amount, @@ -208,7 +174,6 @@ impl ShieldedWallet { .await .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; - // Broadcast trace!("Shield credits: state transition built, broadcasting..."); let network = self.sdk.network; state_transition @@ -238,7 +203,7 @@ impl ShieldedWallet { } })?; - info!("Shield credits broadcast succeeded: {} credits", amount); + info!(account, credits = amount, "Shield broadcast succeeded"); Ok(()) } @@ -246,29 +211,22 @@ impl ShieldedWallet { // ShieldFromAssetLock: Core L1 -> shielded pool (Type 18) // ------------------------------------------------------------------------- - /// Shield funds from a Core L1 asset lock directly into the shielded pool. - /// - /// The asset lock proof proves ownership of L1 funds. The ECDSA signature - /// from the private key binds those funds to the Orchard bundle. - /// - /// # Parameters - /// - /// - `asset_lock_proof` - Proof that funds are locked on the Core chain - /// - `private_key` - Private key for the asset lock (signs the transition) - /// - `amount` - Amount to shield (in credits) - /// - `prover` - Orchard prover for Halo 2 proof generation + /// Shield funds from a Core L1 asset lock directly into + /// `account`'s shielded pool entry. pub async fn shield_from_asset_lock( &self, + account: u32, asset_lock_proof: AssetLockProof, private_key: &[u8], amount: u64, prover: &P, ) -> Result<(), PlatformWalletError> { - let recipient_addr = self.default_orchard_address()?; + let recipient_addr = self.default_orchard_address(account)?; info!( - "Shield from asset lock: building state transition for {} credits", - amount, + account, + credits = amount, + "Shield from asset lock: building state transition" ); let state_transition = build_shield_from_asset_lock_transition( @@ -277,7 +235,7 @@ impl ShieldedWallet { asset_lock_proof, private_key, prover, - [0u8; 36], // empty memo + [0u8; 36], self.sdk.version(), ) .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; @@ -289,8 +247,9 @@ impl ShieldedWallet { .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; info!( - "Shield from asset lock broadcast succeeded: {} credits", - amount, + account, + credits = amount, + "Shield from asset lock broadcast succeeded" ); Ok(()) } @@ -299,42 +258,36 @@ impl ShieldedWallet { // Unshield: shielded pool -> platform address (Type 17) // ------------------------------------------------------------------------- - /// Unshield funds from the shielded pool to a transparent platform address. - /// - /// Selects notes to cover the requested amount plus fee, builds the Orchard - /// bundle with spend proofs, and broadcasts the state transition. - /// - /// # Parameters - /// - /// - `to_address` - Platform address to receive the unshielded funds - /// - `amount` - Amount to unshield (in credits) - /// - `prover` - Orchard prover for Halo 2 proof generation + /// Unshield funds from `account`'s shielded notes to a + /// transparent platform address. pub async fn unshield( &self, + account: u32, to_address: &PlatformAddress, amount: u64, prover: &P, ) -> Result<(), PlatformWalletError> { - let change_addr = self.default_orchard_address()?; + let keys = self.keys_for(account)?; + let change_addr = self.default_orchard_address(account)?; + let id = self.subwallet_id(account); - // Select notes with fee convergence (min 1 action for unshield change output) let (selected_notes, total_input, exact_fee) = { let store = self.store.read().await; let unspent = store - .get_unspent_notes() + .get_unspent_notes(id) .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; select_notes_with_fee(&unspent, amount, 1, self.sdk.version())?.into_owned() }; info!( - "Unshield: {} credits, fee {} credits, spending {} input note(s), total {} credits", - amount, - exact_fee, - selected_notes.len(), + account, + credits = amount, + fee = exact_fee, + inputs = selected_notes.len(), total_input, + "Unshield" ); - // Build SpendableNote structs with Merkle witnesses let (spends, anchor) = self.extract_spends_and_anchor(&selected_notes).await?; let state_transition = build_unshield_transition( @@ -342,11 +295,11 @@ impl ShieldedWallet { *to_address, amount, &change_addr, - &self.keys.full_viewing_key, - &self.keys.spend_auth_key, + &keys.full_viewing_key, + &keys.spend_auth_key, anchor, prover, - [0u8; 36], // empty memo + [0u8; 36], Some(exact_fee), self.sdk.version(), ) @@ -358,10 +311,9 @@ impl ShieldedWallet { .await .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; - // Mark spent notes in store - self.mark_notes_spent(&selected_notes).await?; + self.mark_notes_spent(id, &selected_notes).await?; - info!("Unshield broadcast succeeded: {} credits", amount); + info!(account, credits = amount, "Unshield broadcast succeeded"); Ok(()) } @@ -369,40 +321,35 @@ impl ShieldedWallet { // Transfer: shielded pool -> shielded pool (Type 16) // ------------------------------------------------------------------------- - /// Transfer funds privately within the shielded pool. - /// - /// Both input and output are shielded -- an observer learns nothing about - /// the sender, recipient, or amount. - /// - /// # Parameters - /// - /// - `to_address` - Recipient's Orchard payment address - /// - `amount` - Amount to transfer (in credits) - /// - `prover` - Orchard prover for Halo 2 proof generation + /// Transfer funds privately from `account`'s shielded notes + /// to another Orchard payment address. pub async fn transfer( &self, + account: u32, to_address: &PaymentAddress, amount: u64, prover: &P, ) -> Result<(), PlatformWalletError> { + let keys = self.keys_for(account)?; let recipient_addr = payment_address_to_orchard(to_address)?; - let change_addr = self.default_orchard_address()?; + let change_addr = self.default_orchard_address(account)?; + let id = self.subwallet_id(account); - // Select notes with fee convergence (min 2 actions: recipient + change) let (selected_notes, total_input, exact_fee) = { let store = self.store.read().await; let unspent = store - .get_unspent_notes() + .get_unspent_notes(id) .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; select_notes_with_fee(&unspent, amount, 2, self.sdk.version())?.into_owned() }; info!( - "Shielded transfer: {} credits, fee {} credits, spending {} input note(s), total {} credits", - amount, - exact_fee, - selected_notes.len(), + account, + credits = amount, + fee = exact_fee, + inputs = selected_notes.len(), total_input, + "Shielded transfer" ); let (spends, anchor) = self.extract_spends_and_anchor(&selected_notes).await?; @@ -412,11 +359,11 @@ impl ShieldedWallet { &recipient_addr, amount, &change_addr, - &self.keys.full_viewing_key, - &self.keys.spend_auth_key, + &keys.full_viewing_key, + &keys.spend_auth_key, anchor, prover, - [0u8; 36], // empty memo + [0u8; 36], Some(exact_fee), self.sdk.version(), ) @@ -428,9 +375,13 @@ impl ShieldedWallet { .await .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; - self.mark_notes_spent(&selected_notes).await?; + self.mark_notes_spent(id, &selected_notes).await?; - info!("Shielded transfer broadcast succeeded: {} credits", amount); + info!( + account, + credits = amount, + "Shielded transfer broadcast succeeded" + ); Ok(()) } @@ -438,42 +389,35 @@ impl ShieldedWallet { // Withdraw: shielded pool -> Core L1 address (Type 19) // ------------------------------------------------------------------------- - /// Withdraw funds from the shielded pool to a Core L1 address. - /// - /// Spends shielded notes and creates a withdrawal to the specified Core - /// chain address. The withdrawal uses standard pooling by default. - /// - /// # Parameters - /// - /// - `to_address` - Core chain address to receive the withdrawal - /// - `amount` - Amount to withdraw (in credits) - /// - `core_fee_per_byte` - Core chain fee rate (duffs per byte) - /// - `prover` - Orchard prover for Halo 2 proof generation + /// Withdraw funds from `account`'s shielded notes to a Core L1 address. pub async fn withdraw( &self, + account: u32, to_address: &dashcore::Address, amount: u64, core_fee_per_byte: u32, prover: &P, ) -> Result<(), PlatformWalletError> { - let change_addr = self.default_orchard_address()?; + let keys = self.keys_for(account)?; + let change_addr = self.default_orchard_address(account)?; + let id = self.subwallet_id(account); let output_script = CoreScript::from_bytes(to_address.script_pubkey().to_bytes()); - // Select notes with fee convergence (min 1 action for change output) let (selected_notes, total_input, exact_fee) = { let store = self.store.read().await; let unspent = store - .get_unspent_notes() + .get_unspent_notes(id) .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; select_notes_with_fee(&unspent, amount, 1, self.sdk.version())?.into_owned() }; info!( - "Shielded withdrawal: {} credits, fee {} credits, spending {} input note(s), total {} credits", - amount, - exact_fee, - selected_notes.len(), + account, + credits = amount, + fee = exact_fee, + inputs = selected_notes.len(), total_input, + "Shielded withdrawal" ); let (spends, anchor) = self.extract_spends_and_anchor(&selected_notes).await?; @@ -485,11 +429,11 @@ impl ShieldedWallet { core_fee_per_byte, Pooling::Standard, &change_addr, - &self.keys.full_viewing_key, - &self.keys.spend_auth_key, + &keys.full_viewing_key, + &keys.spend_auth_key, anchor, prover, - [0u8; 36], // empty memo + [0u8; 36], Some(exact_fee), self.sdk.version(), ) @@ -501,11 +445,12 @@ impl ShieldedWallet { .await .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; - self.mark_notes_spent(&selected_notes).await?; + self.mark_notes_spent(id, &selected_notes).await?; info!( - "Shielded withdrawal broadcast succeeded: {} credits", - amount + account, + credits = amount, + "Shielded withdrawal broadcast succeeded" ); Ok(()) } @@ -514,26 +459,15 @@ impl ShieldedWallet { // Internal helpers // ------------------------------------------------------------------------- - /// Convert this wallet's default PaymentAddress to an OrchardAddress. - fn default_orchard_address(&self) -> Result { - payment_address_to_orchard(&self.keys.default_address) + /// Convert `account`'s default `PaymentAddress` to an `OrchardAddress`. + fn default_orchard_address(&self, account: u32) -> Result { + let keys = self.keys_for(account)?; + payment_address_to_orchard(&keys.default_address) } - /// Extract SpendableNote structs with Merkle witnesses and the tree anchor. - /// - /// Reads the commitment tree from the store, computes a Merkle path for each - /// selected note, and returns them alongside the current tree anchor. - /// - /// # Note - /// - /// This method requires the `ShieldedStore` to support `witness()` for - /// generating Merkle paths. If the store trait does not yet include this - /// method, it needs to be added. The spec defines: - /// ```ignore - /// fn witness(&self, position: u64) -> Result; - /// ``` - /// Until that method is added, this will not compile. - #[allow(clippy::never_loop, unused_mut)] + /// Extract `SpendableNote` structs with Merkle witnesses and the + /// tree anchor. The tree is shared per-network; only note + /// selection is per-subwallet (already done by the caller). async fn extract_spends_and_anchor( &self, notes: &[ShieldedNote], @@ -549,11 +483,6 @@ impl ShieldedWallet { )) })?; - // The store returns the typed `MerklePath` (option (a) from - // the previous TODO — coupling the trait to the orchard - // types is the only sound path: `MerklePath` doesn't - // implement serde, so a bytes contract would force every - // caller through a serializer that doesn't exist). let merkle_path = store .witness(note.position) .map_err(|e| PlatformWalletError::ShieldedMerkleWitnessUnavailable(e.to_string()))? @@ -584,24 +513,23 @@ impl ShieldedWallet { Ok((spends, anchor)) } - /// Mark selected notes as spent in the store. - async fn mark_notes_spent(&self, notes: &[ShieldedNote]) -> Result<(), PlatformWalletError> { + /// Mark the selected notes as spent for `id`. + async fn mark_notes_spent( + &self, + id: SubwalletId, + notes: &[ShieldedNote], + ) -> Result<(), PlatformWalletError> { let mut store = self.store.write().await; - for note in notes { store - .mark_spent(¬e.nullifier) + .mark_spent(id, ¬e.nullifier) .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; } - Ok(()) } } -/// Helper trait extension for note selection results that need to own the data. -/// -/// When note selection is performed inside a store lock scope, we need to -/// clone the results so they can outlive the lock. +/// Helper to clone selection results out from under the store lock. trait SelectionResultOwned { fn into_owned(self) -> (Vec, u64, u64); } @@ -614,7 +542,7 @@ impl SelectionResultOwned for (Vec<&ShieldedNote>, u64, u64) { } } -/// Convert a PaymentAddress to an OrchardAddress for the DPP builder functions. +/// Convert a `PaymentAddress` to an `OrchardAddress` for the DPP builder. fn payment_address_to_orchard( addr: &PaymentAddress, ) -> Result { @@ -628,8 +556,7 @@ fn payment_address_to_orchard( /// Deserialize an Orchard Note from 115 bytes. /// -/// Format: `recipient(43) || value(8 LE) || rho(32) || rseed(32)` -/// +/// Format: `recipient(43) || value(8 LE) || rho(32) || rseed(32)`. /// Must be kept in sync with `serialize_note()` in sync.rs. fn deserialize_note(data: &[u8]) -> Option { use grovedb_commitment_tree::{Note, NoteValue, RandomSeed, Rho}; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index 78be58fe3ca..2a612fdefe1 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -1,26 +1,60 @@ //! Storage abstraction for shielded wallet state. //! -//! The `ShieldedStore` trait decouples `ShieldedWallet` from any particular -//! persistence backend. Consumers provide their own implementation (e.g. -//! SQLite-backed for production) while tests can use `InMemoryShieldedStore`. +//! The `ShieldedStore` trait decouples `ShieldedWallet` from any +//! particular persistence backend. Consumers provide their own +//! implementation (e.g. SwiftData via the host persister) while +//! tests can use [`InMemoryShieldedStore`]. //! -//! Note data is stored as raw bytes (`note_data: Vec`) — a serialized -//! `orchard::Note`. The witness path, however, is returned as a typed -//! `grovedb_commitment_tree::MerklePath`: that type doesn't implement -//! serde, so a bytes contract would force every caller through a -//! serializer that doesn't exist. Anything spending a note already -//! depends on these types via the DPP shielded builder. +//! # Multi-tenant scoping +//! +//! Decrypted notes, nullifier bookkeeping, and per-account sync +//! watermarks are scoped by [`SubwalletId`] (a `(wallet_id, +//! account_index)` tuple) so a single store can host every wallet +//! and every shielded account on the same network. The Orchard +//! commitment tree itself is **not** scoped — the on-chain +//! commitment stream is identical for every consumer on a given +//! network, so one tree backs them all. +//! +//! # Note format +//! +//! `ShieldedNote::note_data` is a serialized `orchard::Note` (115 +//! bytes). The witness path returned by [`ShieldedStore::witness`] +//! is the typed `grovedb_commitment_tree::MerklePath` because that +//! type doesn't implement serde — a bytes contract would force +//! every caller through a serializer that doesn't exist. use std::collections::BTreeMap; use std::error::Error as StdError; use std::fmt; -/// A note decrypted and owned by this wallet. +/// Identifies a single shielded "subwallet" — one Orchard account +/// within one wallet. Used to scope notes, nullifier indices, and +/// sync watermarks inside a [`ShieldedStore`] so a single store +/// can hold state for many wallets/accounts without leakage. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SubwalletId { + /// 32-byte wallet identifier (matches `PlatformWallet::wallet_id`). + pub wallet_id: [u8; 32], + /// ZIP-32 account index (`m / 32' / coin_type' / account'`). + pub account_index: u32, +} + +impl SubwalletId { + /// Construct a [`SubwalletId`] from its parts. + pub fn new(wallet_id: [u8; 32], account_index: u32) -> Self { + Self { + wallet_id, + account_index, + } + } +} + +/// A note decrypted and owned by a specific subwallet. /// -/// This struct carries all the bookkeeping fields needed by the shielded -/// wallet. The actual `orchard::Note` is stored as opaque bytes in -/// `note_data` so that the storage layer does not need to depend on the -/// Orchard crate. +/// Carries the bookkeeping the spend pipeline needs without +/// pulling the orchard crate into this trait. The actual +/// `orchard::Note` is in `note_data` as 115 bytes +/// (`recipient(43) || value(8 LE) || rho(32) || rseed(32)`). #[derive(Debug, Clone)] pub struct ShieldedNote { /// Global position in the commitment tree. @@ -36,88 +70,135 @@ pub struct ShieldedNote { /// Note value in credits. pub value: u64, /// Serialized `orchard::Note` bytes (115 bytes). - /// Format: `recipient(43) || value(8 LE) || rho(32) || rseed(32)`. pub note_data: Vec, } /// Storage abstraction for shielded wallet state. /// -/// Consumers implement this for their persistence layer. The trait is -/// object-safe (no generics in method signatures) so it can be stored -/// behind `Arc>` when needed. +/// Consumers implement this for their persistence layer. The +/// trait is object-safe (no generics on method signatures) so it +/// can be stored behind `Arc>`. /// -/// All mutating methods take `&mut self` to allow the implementation to -/// batch writes or hold open transactions without interior mutability. +/// All mutating methods take `&mut self` so implementations can +/// batch writes without interior mutability. pub trait ShieldedStore: Send + Sync { /// The error type returned by storage operations. type Error: StdError + Send + Sync + 'static; - // ── Notes ────────────────────────────────────────────────────────── + // ── Notes (per-subwallet) ────────────────────────────────────────── - /// Persist a newly decrypted note. - fn save_note(&mut self, note: &ShieldedNote) -> Result<(), Self::Error>; + /// Persist a newly decrypted note for `id`. + fn save_note(&mut self, id: SubwalletId, note: &ShieldedNote) -> Result<(), Self::Error>; - /// Return all unspent (not yet nullified) notes. - fn get_unspent_notes(&self) -> Result, Self::Error>; + /// Return all unspent notes for `id`. + fn get_unspent_notes(&self, id: SubwalletId) -> Result, Self::Error>; - /// Return all notes (both spent and unspent). - fn get_all_notes(&self) -> Result, Self::Error>; + /// Return all notes (spent and unspent) for `id`. + fn get_all_notes(&self, id: SubwalletId) -> Result, Self::Error>; - /// Mark the note identified by `nullifier` as spent. - /// - /// Returns `true` if a matching unspent note was found and marked, - /// `false` if no unspent note has that nullifier. - fn mark_spent(&mut self, nullifier: &[u8; 32]) -> Result; + /// Mark `id`'s note with `nullifier` as spent. Returns `true` + /// if a matching unspent note was found. + fn mark_spent(&mut self, id: SubwalletId, nullifier: &[u8; 32]) -> Result; - // ── Commitment tree ──────────────────────────────────────────────── + // ── Commitment tree (network-shared) ─────────────────────────────── - /// Append a note commitment to the commitment tree. + /// Append a note commitment to the shared tree. /// - /// `marked` indicates whether this position should be remembered for - /// future witness generation (i.e. it belongs to this wallet). + /// `marked` should be `true` if **any** tracked subwallet owns + /// this position. The tree only retains authentication paths + /// for marked positions; unmarked positions are pruned. fn append_commitment(&mut self, cmx: &[u8; 32], marked: bool) -> Result<(), Self::Error>; /// Create a tree checkpoint at the given identifier. - /// - /// Checkpoints allow the tree to be rewound to this point if a sync - /// batch needs to be rolled back. fn checkpoint_tree(&mut self, checkpoint_id: u32) -> Result<(), Self::Error>; /// Return the current tree root (Sinsemilla anchor, 32 bytes). fn tree_anchor(&self) -> Result<[u8; 32], Self::Error>; - /// Generate a Merkle authentication path (witness) for the note at the - /// given global position, against the current tree state. - /// - /// Returns `Ok(None)` if no witness is available (e.g. the position is - /// not marked or the tree state has been pruned past it). Returns the - /// typed `MerklePath` so callers can hand it directly to the Orchard - /// spend builder; `MerklePath` doesn't implement serde, so a bytes - /// variant would force every caller to round-trip through a - /// non-existent serializer. - /// - /// This is needed when spending a note — the ZK proof must demonstrate - /// that the note commitment exists in the tree at `anchor`. + /// Generate a Merkle authentication path for `position` + /// against the current tree state. Returns `Ok(None)` if no + /// witness is available (position not marked, or pruned). fn witness( &self, position: u64, ) -> Result, Self::Error>; - // ── Sync state ───────────────────────────────────────────────────── + // ── Sync state (per-subwallet) ───────────────────────────────────── - /// The last global note index that was synced from Platform. - fn last_synced_note_index(&self) -> Result; + /// The last global note index that was synced for `id`. + fn last_synced_note_index(&self, id: SubwalletId) -> Result; - /// Persist the last synced note index. - fn set_last_synced_note_index(&mut self, index: u64) -> Result<(), Self::Error>; + /// Persist the last synced note index for `id`. + fn set_last_synced_note_index( + &mut self, + id: SubwalletId, + index: u64, + ) -> Result<(), Self::Error>; - /// The last nullifier sync checkpoint, if any. + /// The last `(height, timestamp)` nullifier sync checkpoint for `id`, if any. + fn nullifier_checkpoint(&self, id: SubwalletId) -> Result, Self::Error>; + + /// Persist the nullifier sync checkpoint for `id`. + fn set_nullifier_checkpoint( + &mut self, + id: SubwalletId, + height: u64, + timestamp: u64, + ) -> Result<(), Self::Error>; +} + +// ── Per-subwallet bookkeeping ────────────────────────────────────────── + +/// Per-subwallet note + sync state used by both the in-memory and +/// file-backed stores. Kept in this module so both share the +/// exact same shape and the persister callback can serialize it +/// without re-defining the structure on the host side. +#[derive(Debug, Default, Clone)] +pub(super) struct SubwalletState { + /// All known notes (spent + unspent), in insertion order. + pub notes: Vec, + /// Nullifier → index into `notes`, for O(1) `mark_spent`. + pub nullifier_index: BTreeMap<[u8; 32], usize>, + /// Highest global note index ever scanned. + pub last_synced_index: u64, + /// `(height, timestamp)` from the most recent nullifier sync. + pub nullifier_checkpoint: Option<(u64, u64)>, +} + +impl SubwalletState { + /// Save (or overwrite-by-nullifier) a note. /// - /// Returns `(height, timestamp)` from the most recent nullifier sync. - fn nullifier_checkpoint(&self) -> Result, Self::Error>; + /// Re-saving a note with a known nullifier overwrites the + /// existing entry instead of appending a duplicate — Orchard + /// nullifiers are globally unique, so a re-scan of the same + /// chunk shouldn't double-count. + pub(super) fn save_note(&mut self, note: &ShieldedNote) { + if let Some(&existing_idx) = self.nullifier_index.get(¬e.nullifier) { + self.notes[existing_idx] = note.clone(); + return; + } + let idx = self.notes.len(); + self.nullifier_index.insert(note.nullifier, idx); + self.notes.push(note.clone()); + } + + pub(super) fn unspent_notes(&self) -> Vec { + self.notes.iter().filter(|n| !n.is_spent).cloned().collect() + } + + pub(super) fn all_notes(&self) -> Vec { + self.notes.clone() + } - /// Persist the nullifier sync checkpoint. - fn set_nullifier_checkpoint(&mut self, height: u64, timestamp: u64) -> Result<(), Self::Error>; + pub(super) fn mark_spent(&mut self, nullifier: &[u8; 32]) -> bool { + if let Some(&idx) = self.nullifier_index.get(nullifier) { + if !self.notes[idx].is_spent { + self.notes[idx].is_spent = true; + return true; + } + } + false + } } // ── InMemoryShieldedStore ────────────────────────────────────────────── @@ -134,82 +215,62 @@ impl fmt::Display for InMemoryStoreError { impl StdError for InMemoryStoreError {} -/// In-memory implementation of [`ShieldedStore`] for tests and short-lived -/// wallets. -/// -/// Notes are stored in a `Vec`; the commitment tree is represented as a flat -/// list of commitments (sufficient for anchor computation via the incremental -/// merkle tree crate, but witness generation is **not** implemented — use a -/// real store for operations that require Merkle paths). -#[derive(Debug)] +/// In-memory implementation of [`ShieldedStore`] for tests and +/// short-lived wallets. Notes are kept per [`SubwalletId`]; the +/// commitment tree is a flat list (anchor is a placeholder, so +/// real witness generation is **not** supported — use a real +/// store for spends). +#[derive(Debug, Default)] pub struct InMemoryShieldedStore { - /// All notes, keyed by nullifier for O(1) lookup during `mark_spent`. - notes: Vec, - /// Nullifier -> index into `notes` for fast spend marking. - nullifier_index: BTreeMap<[u8; 32], usize>, + /// Per-subwallet notes + sync state. + subwallets: BTreeMap, /// Flat list of commitments appended to the tree. commitments: Vec<[u8; 32]>, - /// Positions that are marked (belong to this wallet). + /// Mark flag per position. marked_positions: Vec, - /// Checkpoint IDs in order. + /// Checkpoint ids in order. checkpoints: Vec, - /// Current anchor (recomputed lazily — for the in-memory store we - /// store a dummy zero value; production stores compute from the real tree). + /// Placeholder anchor; production stores compute the real Sinsemilla root. anchor: [u8; 32], - /// Last synced note index. - last_synced_index: u64, - /// Nullifier sync checkpoint: `(height, timestamp)`. - nullifier_checkpoint: Option<(u64, u64)>, } impl InMemoryShieldedStore { /// Create a new empty in-memory store. pub fn new() -> Self { - Self { - notes: Vec::new(), - nullifier_index: BTreeMap::new(), - commitments: Vec::new(), - marked_positions: Vec::new(), - checkpoints: Vec::new(), - anchor: [0u8; 32], - last_synced_index: 0, - nullifier_checkpoint: None, - } - } -} - -impl Default for InMemoryShieldedStore { - fn default() -> Self { - Self::new() + Self::default() } } impl ShieldedStore for InMemoryShieldedStore { type Error = InMemoryStoreError; - fn save_note(&mut self, note: &ShieldedNote) -> Result<(), Self::Error> { - let idx = self.notes.len(); - self.nullifier_index.insert(note.nullifier, idx); - self.notes.push(note.clone()); + fn save_note(&mut self, id: SubwalletId, note: &ShieldedNote) -> Result<(), Self::Error> { + self.subwallets.entry(id).or_default().save_note(note); Ok(()) } - fn get_unspent_notes(&self) -> Result, Self::Error> { - Ok(self.notes.iter().filter(|n| !n.is_spent).cloned().collect()) + fn get_unspent_notes(&self, id: SubwalletId) -> Result, Self::Error> { + Ok(self + .subwallets + .get(&id) + .map(SubwalletState::unspent_notes) + .unwrap_or_default()) } - fn get_all_notes(&self) -> Result, Self::Error> { - Ok(self.notes.clone()) + fn get_all_notes(&self, id: SubwalletId) -> Result, Self::Error> { + Ok(self + .subwallets + .get(&id) + .map(SubwalletState::all_notes) + .unwrap_or_default()) } - fn mark_spent(&mut self, nullifier: &[u8; 32]) -> Result { - if let Some(&idx) = self.nullifier_index.get(nullifier) { - if !self.notes[idx].is_spent { - self.notes[idx].is_spent = true; - return Ok(true); - } - } - Ok(false) + fn mark_spent(&mut self, id: SubwalletId, nullifier: &[u8; 32]) -> Result { + Ok(self + .subwallets + .get_mut(&id) + .map(|sw| sw.mark_spent(nullifier)) + .unwrap_or(false)) } fn append_commitment(&mut self, cmx: &[u8; 32], marked: bool) -> Result<(), Self::Error> { @@ -224,8 +285,6 @@ impl ShieldedStore for InMemoryShieldedStore { } fn tree_anchor(&self) -> Result<[u8; 32], Self::Error> { - // The in-memory store returns a dummy anchor. - // Production implementations should compute the real Sinsemilla root. Ok(self.anchor) } @@ -233,28 +292,42 @@ impl ShieldedStore for InMemoryShieldedStore { &self, _position: u64, ) -> Result, Self::Error> { - // In-memory store does not support real Merkle witness generation. - // Production implementations use ClientPersistentCommitmentTree. Err(InMemoryStoreError( "Merkle witness not supported in in-memory store".into(), )) } - fn last_synced_note_index(&self) -> Result { - Ok(self.last_synced_index) + fn last_synced_note_index(&self, id: SubwalletId) -> Result { + Ok(self + .subwallets + .get(&id) + .map(|sw| sw.last_synced_index) + .unwrap_or(0)) } - fn set_last_synced_note_index(&mut self, index: u64) -> Result<(), Self::Error> { - self.last_synced_index = index; + fn set_last_synced_note_index( + &mut self, + id: SubwalletId, + index: u64, + ) -> Result<(), Self::Error> { + self.subwallets.entry(id).or_default().last_synced_index = index; Ok(()) } - fn nullifier_checkpoint(&self) -> Result, Self::Error> { - Ok(self.nullifier_checkpoint) + fn nullifier_checkpoint(&self, id: SubwalletId) -> Result, Self::Error> { + Ok(self + .subwallets + .get(&id) + .and_then(|sw| sw.nullifier_checkpoint)) } - fn set_nullifier_checkpoint(&mut self, height: u64, timestamp: u64) -> Result<(), Self::Error> { - self.nullifier_checkpoint = Some((height, timestamp)); + fn set_nullifier_checkpoint( + &mut self, + id: SubwalletId, + height: u64, + timestamp: u64, + ) -> Result<(), Self::Error> { + self.subwallets.entry(id).or_default().nullifier_checkpoint = Some((height, timestamp)); Ok(()) } } @@ -263,9 +336,14 @@ impl ShieldedStore for InMemoryShieldedStore { mod tests { use super::*; + fn test_id(account: u32) -> SubwalletId { + SubwalletId::new([0xAA; 32], account) + } + #[test] fn test_save_and_retrieve_notes() { let mut store = InMemoryShieldedStore::new(); + let id = test_id(0); let note = ShieldedNote { position: 42, cmx: [1u8; 32], @@ -275,17 +353,22 @@ mod tests { value: 1000, note_data: vec![0u8; 115], }; - store.save_note(¬e).unwrap(); + store.save_note(id, ¬e).unwrap(); - let unspent = store.get_unspent_notes().unwrap(); + let unspent = store.get_unspent_notes(id).unwrap(); assert_eq!(unspent.len(), 1); assert_eq!(unspent[0].value, 1000); assert_eq!(unspent[0].position, 42); + + // A different subwallet sees no notes. + let other = test_id(1); + assert!(store.get_unspent_notes(other).unwrap().is_empty()); } #[test] fn test_mark_spent() { let mut store = InMemoryShieldedStore::new(); + let id = test_id(0); let nullifier = [3u8; 32]; let note = ShieldedNote { position: 0, @@ -296,52 +379,43 @@ mod tests { value: 500, note_data: vec![0u8; 115], }; - store.save_note(¬e).unwrap(); - - // Mark spent - let found = store.mark_spent(&nullifier).unwrap(); - assert!(found); - - // Should no longer appear in unspent - let unspent = store.get_unspent_notes().unwrap(); - assert!(unspent.is_empty()); + store.save_note(id, ¬e).unwrap(); - // But should appear in all notes - let all = store.get_all_notes().unwrap(); + assert!(store.mark_spent(id, &nullifier).unwrap()); + assert!(store.get_unspent_notes(id).unwrap().is_empty()); + let all = store.get_all_notes(id).unwrap(); assert_eq!(all.len(), 1); assert!(all[0].is_spent); - - // Marking again returns false - let found_again = store.mark_spent(&nullifier).unwrap(); - assert!(!found_again); + // Marking again returns false (already spent). + assert!(!store.mark_spent(id, &nullifier).unwrap()); } #[test] - fn test_sync_state() { + fn test_sync_state_per_subwallet() { let mut store = InMemoryShieldedStore::new(); + let a = test_id(0); + let b = test_id(1); - assert_eq!(store.last_synced_note_index().unwrap(), 0); - store.set_last_synced_note_index(100).unwrap(); - assert_eq!(store.last_synced_note_index().unwrap(), 100); + assert_eq!(store.last_synced_note_index(a).unwrap(), 0); + store.set_last_synced_note_index(a, 100).unwrap(); + assert_eq!(store.last_synced_note_index(a).unwrap(), 100); + // Different subwallet still at 0. + assert_eq!(store.last_synced_note_index(b).unwrap(), 0); - assert!(store.nullifier_checkpoint().unwrap().is_none()); - store.set_nullifier_checkpoint(200, 1234567890).unwrap(); + store.set_nullifier_checkpoint(a, 200, 1234567890).unwrap(); assert_eq!( - store.nullifier_checkpoint().unwrap(), + store.nullifier_checkpoint(a).unwrap(), Some((200, 1234567890)) ); + assert!(store.nullifier_checkpoint(b).unwrap().is_none()); } #[test] fn test_commitment_tree_operations() { let mut store = InMemoryShieldedStore::new(); - store.append_commitment(&[1u8; 32], true).unwrap(); store.append_commitment(&[2u8; 32], false).unwrap(); store.checkpoint_tree(1).unwrap(); - - // Anchor is dummy for in-memory - let anchor = store.tree_anchor().unwrap(); - assert_eq!(anchor, [0u8; 32]); + assert_eq!(store.tree_anchor().unwrap(), [0u8; 32]); } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs index fdb3ed05471..b42f4a5d77a 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs @@ -1,119 +1,202 @@ -//! Shielded note and nullifier synchronization. +//! Shielded note + nullifier synchronization (multi-account). //! -//! Implements the sync methods on `ShieldedWallet`: -//! - `sync_notes()` -- fetch and trial-decrypt encrypted notes from Platform -//! - `check_nullifiers()` -- privacy-preserving nullifier status check -//! - `sync()` -- full sync (notes + nullifiers + balance) - -use super::store::ShieldedStore; -use super::ShieldedWallet; -use crate::error::PlatformWalletError; +//! Implements sync methods on `ShieldedWallet`: +//! - `sync_notes()` — fetch encrypted notes once, trial-decrypt +//! with every bound account's IVK, append commitments to the +//! shared tree once with `marked = any account decrypted the +//! position`, save decrypted notes per-subwallet. +//! - `check_nullifiers()` — privacy-preserving nullifier scan, +//! marks spent notes per-subwallet. +//! - `sync()` — full pass: notes + nullifiers + per-account +//! balance summary. + +use std::collections::{BTreeMap, BTreeSet}; use dash_sdk::platform::shielded::nullifier_sync::{NullifierSyncCheckpoint, NullifierSyncConfig}; -use dash_sdk::platform::shielded::sync_shielded_notes; +use dash_sdk::platform::shielded::{sync_shielded_notes, try_decrypt_note}; +use grovedb_commitment_tree::{Note as OrchardNote, PaymentAddress, PreparedIncomingViewingKey}; use tracing::{debug, info, warn}; -/// Server-enforced chunk size -- start_index must be a multiple of this. +use super::store::{ShieldedStore, SubwalletId}; +use super::ShieldedWallet; +use crate::error::PlatformWalletError; + +/// Server-enforced chunk size — start_index must be a multiple of this. const CHUNK_SIZE: u64 = 2048; -/// Result of a note sync operation. -#[derive(Debug, Clone)] +/// Result of one note-sync pass. +#[derive(Debug, Clone, Default)] pub struct SyncNotesResult { - /// Number of new notes found (decrypted for this wallet). - pub new_notes: usize, - /// Total encrypted notes scanned in this sync. + /// Per-account count of new notes discovered in this pass. + pub new_notes_per_account: BTreeMap, + /// Total encrypted notes scanned. pub total_scanned: u64, } -/// Summary of a full sync (notes + nullifiers + balance). -#[derive(Debug, Clone)] +impl SyncNotesResult { + /// Total new notes across every account. + pub fn total_new_notes(&self) -> usize { + self.new_notes_per_account.values().sum() + } +} + +/// Summary of a full sync (notes + nullifiers + balances). +#[derive(Debug, Clone, Default)] pub struct ShieldedSyncSummary { - /// Results from note sync. + /// Note-sync result. pub notes_result: SyncNotesResult, - /// Number of notes newly detected as spent. - pub newly_spent: usize, - /// Current unspent balance after sync. - pub balance: u64, + /// Per-account count of notes newly detected as spent. + pub newly_spent_per_account: BTreeMap, + /// Per-account unspent balance after sync. + pub balances: BTreeMap, +} + +impl ShieldedSyncSummary { + /// Sum of unspent balances across accounts. + pub fn balance_total(&self) -> u64 { + self.balances.values().copied().sum() + } + + /// Sum of newly-spent counts across accounts. + pub fn total_newly_spent(&self) -> usize { + self.newly_spent_per_account.values().sum() + } } impl ShieldedWallet { - /// Sync encrypted notes from Platform. + /// Sync encrypted notes from Platform across every bound account. /// - /// Performs the following steps: - /// 1. Read `last_synced_note_index` from store and align to chunk boundary - /// 2. Fetch and trial-decrypt all new encrypted notes via SDK - /// 3. Append each note's commitment to the store's tree (marked if decrypted) - /// 4. Checkpoint the commitment tree - /// 5. Save each decrypted note to store - /// 6. Update `last_synced_note_index` - /// - /// # Returns - /// - /// `SyncNotesResult` with the count of new notes found and total scanned. + /// Fetches raw chunks once via the SDK (using account 0's IVK + /// as the trial-decrypt key for the SDK call), then locally + /// trial-decrypts the same chunks against every other + /// account's IVK. Commitments are appended to the shared + /// tree exactly once per global position with `marked = + /// (any bound account owns this position)`. Decrypted notes + /// land in the store under the discovering account's + /// [`SubwalletId`]. pub async fn sync_notes(&self) -> Result { - let prepared_ivk = self.keys.prepared_ivk(); - - // Step 1: Get last synced index and align to chunk boundary + // Snapshot accounts + their prepared IVKs. The IVKs are + // owned `PreparedIncomingViewingKey` values so we can hold + // them across the await without borrowing `self`. + let account_indices: Vec = self.account_indices(); + if account_indices.is_empty() { + return Ok(SyncNotesResult::default()); + } + let prepared: Vec<(u32, PreparedIncomingViewingKey)> = account_indices + .iter() + .map(|&a| Ok((a, self.keys_for(a)?.prepared_ivk()))) + .collect::>()?; + + // Use the lowest per-account watermark as the canonical + // tree-fetch start. Today we wipe-and-re-sync when an + // account is added, so all accounts share the same + // watermark in practice — this `min` is just defensive. let already_have = { let store = self.store.read().await; - store - .last_synced_note_index() - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))? + let mut min_idx: Option = None; + for &account in &account_indices { + let id = self.subwallet_id(account); + let idx = store + .last_synced_note_index(id) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + min_idx = Some(min_idx.map_or(idx, |m| m.min(idx))); + } + min_idx.unwrap_or(0) }; let aligned_start = (already_have / CHUNK_SIZE) * CHUNK_SIZE; info!( - "Starting shielded note sync: last_synced={}, aligned_start={}", - already_have, aligned_start, + accounts = account_indices.len(), + already_have, aligned_start, "Starting shielded note sync" ); - // Step 2: Fetch and trial-decrypt via SDK - let result = sync_shielded_notes(&self.sdk, &prepared_ivk, aligned_start, None) + // Fetch + trial-decrypt with the FIRST bound account's + // IVK in one SDK call. We also reuse the returned + // `all_notes` for local trial-decryption with every other + // account's IVK below. + let (driver_account, driver_ivk) = &prepared[0]; + let result = sync_shielded_notes(&self.sdk, driver_ivk, aligned_start, None) .await .map_err(|e| PlatformWalletError::ShieldedSyncFailed(e.to_string()))?; info!( - "Sync complete: total_scanned={}, decrypted={}, next_start_index={}", - result.total_notes_scanned, - result.decrypted_notes.len(), - result.next_start_index, + total_scanned = result.total_notes_scanned, + decrypted_for_driver = result.decrypted_notes.len(), + next_start_index = result.next_start_index, + "SDK sync returned" ); if result.next_start_index == 0 && result.total_notes_scanned > 0 { warn!( - "Shielded sync: next_start_index is 0 after scanning {} notes -- \ - next sync will rescan everything from the beginning", + "Shielded sync: next_start_index is 0 after scanning {} notes — \ + next sync will rescan from the beginning", result.total_notes_scanned, ); } + // Index decryptions by `(account, position) → DecryptedNote`. + // The driver account's hits come from the SDK call; + // every other account's are produced by local + // trial-decryption against `result.all_notes`. + let mut decrypted_by_account: BTreeMap> = BTreeMap::new(); + for dn in &result.decrypted_notes { + decrypted_by_account + .entry(*driver_account) + .or_default() + .push(DiscoveredNote { + position: dn.position, + cmx: dn.cmx, + note: dn.note, + }); + } + + for (account, ivk) in prepared.iter().skip(1) { + for (i, raw_note) in result.all_notes.iter().enumerate() { + let position = aligned_start + i as u64; + if let Some((note, _addr)) = try_decrypt_note(ivk, raw_note) { + let cmx_bytes: [u8; 32] = match raw_note.cmx.as_slice().try_into() { + Ok(b) => b, + Err(_) => continue, + }; + decrypted_by_account + .entry(*account) + .or_default() + .push(DiscoveredNote { + position, + cmx: cmx_bytes, + note, + }); + } + } + } + + // Build the union of "owned" positions for tree marking. + let owned_positions: BTreeSet = decrypted_by_account + .values() + .flat_map(|v| v.iter().map(|n| n.position)) + .collect(); + let mut store = self.store.write().await; - // Step 3: Append commitments to the tree, skipping positions already present + // Append every commitment to the shared tree exactly + // once per position. Skip positions already in the tree + // (re-scan after a partial chunk advance). let mut appended = 0u32; for (i, raw_note) in result.all_notes.iter().enumerate() { let global_pos = aligned_start + i as u64; if global_pos < already_have { - continue; // already appended in a previous sync + continue; } - let cmx_bytes: [u8; 32] = raw_note.cmx.as_slice().try_into().map_err(|_| { PlatformWalletError::ShieldedSyncFailed("Invalid cmx length".into()) })?; - - let is_ours = result - .decrypted_notes - .iter() - .any(|dn| dn.position == global_pos); - + let is_ours = owned_positions.contains(&global_pos); store .append_commitment(&cmx_bytes, is_ours) .map_err(|e| PlatformWalletError::ShieldedTreeUpdateFailed(e.to_string()))?; - appended += 1; } - // Step 4: Checkpoint tree if appended > 0 { let checkpoint_id = result.next_start_index as u32; store @@ -121,152 +204,181 @@ impl ShieldedWallet { .map_err(|e| PlatformWalletError::ShieldedTreeUpdateFailed(e.to_string()))?; } - // Step 5: Save decrypted notes - let mut new_note_count = 0usize; - for dn in &result.decrypted_notes { - if dn.position < already_have { - continue; // already stored in a previous sync + // Save decrypted notes scoped per subwallet, and + // count new notes per account. + let mut new_notes_per_account: BTreeMap = BTreeMap::new(); + for (account, discovered) in &decrypted_by_account { + let fvk = &self.keys_for(*account)?.full_viewing_key; + let id = self.subwallet_id(*account); + for d in discovered { + if d.position < already_have { + continue; + } + let nullifier = d.note.nullifier(fvk); + let value = d.note.value().inner(); + debug!( + account = account, + position = d.position, + value, + "Note DECRYPTED" + ); + let note_data = serialize_note(&d.note); + let shielded_note = super::store::ShieldedNote { + note_data, + position: d.position, + cmx: d.cmx, + nullifier: nullifier.to_bytes(), + block_height: result.block_height, + is_spent: false, + value, + }; + store + .save_note(id, &shielded_note) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + *new_notes_per_account.entry(*account).or_default() += 1; } + } - // Compute the spending nullifier from our FVK. - // dn.nullifier is the rho/nf from the compact action, not the spending nullifier. - let nullifier = dn.note.nullifier(&self.keys.full_viewing_key); - let value = dn.note.value().inner(); - - debug!("Note[{}]: DECRYPTED, value={} credits", dn.position, value,); - - // Serialize the note for storage. - let note_data = serialize_note(&dn.note); - - let shielded_note = super::store::ShieldedNote { - note_data, - position: dn.position, - cmx: dn.cmx, - nullifier: nullifier.to_bytes(), - block_height: result.block_height, - is_spent: false, - value, - }; - + // Update every account's watermark to the same global + // tree position so the next sync resumes coherently. + let new_index = aligned_start + result.total_notes_scanned; + for &account in &account_indices { + let id = self.subwallet_id(account); store - .save_note(&shielded_note) + .set_last_synced_note_index(id, new_index) .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; - - new_note_count += 1; } - // Step 6: Update last synced index - let new_index = aligned_start + result.total_notes_scanned; - store - .set_last_synced_note_index(new_index) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; - info!( - "Shielded sync finished: {} new note(s), last_synced_index={}", - new_note_count, new_index, + new_notes_total = new_notes_per_account.values().sum::(), + new_index, "Shielded sync finished" ); Ok(SyncNotesResult { - new_notes: new_note_count, + new_notes_per_account, total_scanned: result.total_notes_scanned, }) } - /// Check nullifier status for unspent notes. - /// - /// Uses the SDK's privacy-preserving trunk/branch tree scan with incremental - /// catch-up. Marks spent notes in the store. - /// - /// # Returns - /// - /// The number of notes newly detected as spent. - pub async fn check_nullifiers(&self) -> Result { - // Step 1: Collect unspent nullifiers from store - let (unspent_nullifiers, last_checkpoint) = { - let store = self.store.read().await; - let unspent = store - .get_unspent_notes() - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; - let nullifiers: Vec<[u8; 32]> = unspent.iter().map(|n| n.nullifier).collect(); - let checkpoint = store - .nullifier_checkpoint() - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))? - .map(|(height, timestamp)| NullifierSyncCheckpoint { height, timestamp }); - (nullifiers, checkpoint) - }; - - if unspent_nullifiers.is_empty() { - return Ok(0); + /// Check nullifier status for unspent notes across every bound + /// account. Spent notes are marked per-subwallet. + pub async fn check_nullifiers(&self) -> Result, PlatformWalletError> { + let account_indices = self.account_indices(); + if account_indices.is_empty() { + return Ok(BTreeMap::new()); } - debug!( - "Checking {} nullifiers (checkpoint: {:?})", - unspent_nullifiers.len(), - last_checkpoint, - ); - - // Step 2: Call SDK sync_nullifiers - let result = self - .sdk - .sync_nullifiers( - &unspent_nullifiers, - None::, - last_checkpoint, - ) - .await - .map_err(|e| PlatformWalletError::ShieldedNullifierSyncFailed(e.to_string()))?; - - // Step 3: Mark found (spent) nullifiers in store - let mut store = self.store.write().await; + // Aggregate unspent nullifiers across accounts so we hit + // the SDK once, then route the `found` results back to + // the right subwallet via a position lookup. + struct AccountUnspent { + id: SubwalletId, + nullifiers: Vec<[u8; 32]>, + checkpoint: Option, + } - let mut spent_count = 0usize; - for nf_bytes in &result.found { - let was_unspent = store - .mark_spent(nf_bytes) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; - if was_unspent { - spent_count += 1; + let per_account: Vec<(u32, AccountUnspent)> = { + let store = self.store.read().await; + let mut out = Vec::with_capacity(account_indices.len()); + for &account in &account_indices { + let id = self.subwallet_id(account); + let unspent = store + .get_unspent_notes(id) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + let nullifiers: Vec<[u8; 32]> = unspent.iter().map(|n| n.nullifier).collect(); + let checkpoint = store + .nullifier_checkpoint(id) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))? + .map(|(height, timestamp)| NullifierSyncCheckpoint { height, timestamp }); + out.push(( + account, + AccountUnspent { + id, + nullifiers, + checkpoint, + }, + )); } - } + out + }; - // Step 4: Update nullifier checkpoint - store - .set_nullifier_checkpoint(result.new_sync_height, result.new_sync_timestamp) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + let mut newly_spent: BTreeMap = BTreeMap::new(); + for ( + account, + AccountUnspent { + id, + nullifiers, + checkpoint, + }, + ) in per_account + { + if nullifiers.is_empty() { + continue; + } + debug!( + account, + checking = nullifiers.len(), + ?checkpoint, + "Checking nullifiers" + ); + let result = self + .sdk + .sync_nullifiers(&nullifiers, None::, checkpoint) + .await + .map_err(|e| PlatformWalletError::ShieldedNullifierSyncFailed(e.to_string()))?; + + let mut store = self.store.write().await; + let mut spent_count = 0usize; + for nf_bytes in &result.found { + if store + .mark_spent(id, nf_bytes) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))? + { + spent_count += 1; + } + } + store + .set_nullifier_checkpoint(id, result.new_sync_height, result.new_sync_timestamp) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; - if spent_count > 0 { - info!("{} note(s) newly detected as spent", spent_count); + if spent_count > 0 { + newly_spent.insert(account, spent_count); + info!(account, spent_count, "Notes newly detected as spent"); + } } - Ok(spent_count) + Ok(newly_spent) } - /// Full sync: notes + nullifiers + balance. - /// - /// Performs note sync first to discover new notes, then checks nullifiers - /// to detect spent notes, and finally computes the current balance. + /// Full sync: notes + nullifiers + per-account balance summary. pub async fn sync(&self) -> Result { - // Sync notes first let notes_result = self.sync_notes().await?; - - // Then check nullifiers - let newly_spent = self.check_nullifiers().await?; - - // Compute balance - let balance = self.balance().await?; - + let newly_spent_per_account = self.check_nullifiers().await?; + let balances = self.balances().await?; Ok(ShieldedSyncSummary { notes_result, - newly_spent, - balance, + newly_spent_per_account, + balances, }) } } +/// One decrypted note discovered during a sync pass. +#[derive(Clone)] +struct DiscoveredNote { + position: u64, + cmx: [u8; 32], + note: OrchardNote, +} + +// Suppress dead_code on `address` field — kept for future use +// (e.g. surfacing diversifier index per discovered note). +#[allow(dead_code)] +fn _unused_payment_address(_pa: PaymentAddress) {} + /// Serialize an Orchard note to bytes for storage. /// /// Format: `recipient(43) || value(8 LE) || rho(32) || rseed(32)` = 115 bytes. -/// /// Must be kept in sync with `deserialize_note()` in operations.rs. fn serialize_note(note: &grovedb_commitment_tree::Note) -> Vec { let mut data = Vec::with_capacity(115); diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index 7b3e16e9400..a3694cbba67 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -54,20 +54,26 @@ extension PlatformWalletManager { /// Derive Orchard keys for `walletId` from the host-side mnemonic /// resolver, open or create the per-network commitment tree at - /// `dbPath`, and bind the resulting shielded sub-wallet to the - /// `PlatformWallet`. + /// `dbPath`, and bind the resulting multi-account shielded + /// sub-wallet to the `PlatformWallet`. + /// + /// `accounts` is the list of ZIP-32 account indices to derive. + /// Pass `[0]` for the single-account default; pass + /// `[0, 1, …]` to bind multiple accounts up front. Each entry + /// produces an independent FVK / IVK / OVK / default address; + /// notes are scoped per-`(walletId, accountIndex)` inside the + /// store. Must be non-empty and at most 64 entries. /// /// The resolver is fired exactly once. The mnemonic and the /// derived seed live in zeroized buffers on the Rust side and - /// are scrubbed before this call returns; only the FVK / IVK / - /// OVK / default address survive on the wallet handle. + /// are scrubbed before this call returns. /// - /// Idempotent: calling again with a different account or - /// `dbPath` replaces the previously-bound shielded wallet. + /// Idempotent: calling again replaces the previously-bound + /// shielded wallet. public func bindShielded( walletId: Data, resolver: MnemonicResolver, - account: UInt32 = 0, + accounts: [UInt32] = [0], dbPath: String ) throws { guard isConfigured, handle != NULL_HANDLE else { @@ -80,6 +86,11 @@ extension PlatformWalletManager { "walletId must be exactly 32 bytes" ) } + guard !accounts.isEmpty else { + throw PlatformWalletError.invalidParameter( + "accounts must be non-empty" + ) + } guard let resolverHandle = resolver.handle else { throw PlatformWalletError.invalidParameter( "MnemonicResolver has no handle" @@ -92,14 +103,22 @@ extension PlatformWalletManager { else { throw PlatformWalletError.invalidParameter("walletId baseAddress is nil") } - try dbPath.withCString { dbPathPtr in - try platform_wallet_manager_bind_shielded( - handle, - walletIdPtr, - resolverHandle, - account, - dbPathPtr - ).check() + try accounts.withUnsafeBufferPointer { accountsBuf in + guard let accountsPtr = accountsBuf.baseAddress else { + throw PlatformWalletError.invalidParameter( + "accounts baseAddress is nil" + ) + } + try dbPath.withCString { dbPathPtr in + try platform_wallet_manager_bind_shielded( + handle, + walletIdPtr, + resolverHandle, + accountsPtr, + UInt(accountsBuf.count), + dbPathPtr + ).check() + } } } } @@ -186,15 +205,18 @@ extension PlatformWalletManager { }.value } - /// Read the default Orchard payment address for `walletId` as - /// the 43 raw bytes. Returns `nil` when the wallet exists on - /// the manager but has no bound shielded sub-wallet (i.e. - /// [`bindShielded`] hasn't run, or it failed). Throws when the - /// wallet id isn't known to the manager. + /// Read the default Orchard payment address for `account` on + /// `walletId` as the 43 raw bytes. Returns `nil` when the + /// wallet exists on the manager but has no bound shielded + /// sub-wallet, or `account` isn't bound on it. Throws when + /// the wallet id isn't known to the manager. /// - /// The host is responsible for bech32m-encoding the result for - /// display (HRP `dash` / `tdash` + `0x10` type byte). - public func shieldedDefaultAddress(walletId: Data) throws -> Data? { + /// The host is responsible for bech32m-encoding the result + /// for display (HRP `dash` / `tdash` + `0x10` type byte). + public func shieldedDefaultAddress( + walletId: Data, + account: UInt32 = 0 + ) throws -> Data? { guard isConfigured, handle != NULL_HANDLE else { throw PlatformWalletError.invalidHandle( "PlatformWalletManager not configured" @@ -221,6 +243,7 @@ extension PlatformWalletManager { try platform_wallet_manager_shielded_default_address( handle, ptr, + account, outPtr, &present ).check() @@ -248,14 +271,15 @@ extension PlatformWalletManager { platform_wallet_shielded_prover_is_ready() } - /// Shielded → Shielded transfer. Spends notes from `walletId`'s - /// shielded balance and creates a new note for `recipientRaw43` - /// (the recipient's raw 43-byte Orchard payment address). Amount - /// is in credits (1 DASH = 1e11). Heavy CPU work runs on a - /// detached task so the caller's actor isn't blocked through - /// the proof build. + /// Shielded → Shielded transfer. Spends notes from `account` + /// on `walletId` and creates a new note for `recipientRaw43` + /// (the recipient's raw 43-byte Orchard payment address). + /// Amount is in credits (1 DASH = 1e11). Heavy CPU work runs + /// on a detached task so the caller's actor isn't blocked + /// through the proof build. public func shieldedTransfer( walletId: Data, + account: UInt32 = 0, recipientRaw43: Data, amount: UInt64 ) async throws { @@ -291,7 +315,7 @@ extension PlatformWalletManager { ) } try platform_wallet_manager_shielded_transfer( - handle, widPtr, recipientPtr, amount + handle, widPtr, account, recipientPtr, amount ).check() } } @@ -315,7 +339,8 @@ extension PlatformWalletManager { /// detached task so the caller's actor isn't blocked. public func shieldedShield( walletId: Data, - accountIndex: UInt32 = 0, + shieldedAccount: UInt32 = 0, + paymentAccount: UInt32 = 0, amount: UInt64, addressSigner: KeychainSigner ) async throws { @@ -346,7 +371,7 @@ extension PlatformWalletManager { throw PlatformWalletError.invalidParameter("walletId baseAddress is nil") } try platform_wallet_manager_shielded_shield( - handle, widPtr, accountIndex, amount, signerHandle + handle, widPtr, shieldedAccount, paymentAccount, amount, signerHandle ).check() } }.value @@ -359,6 +384,7 @@ extension PlatformWalletManager { /// hand-roll the bincode storage variant tag. public func shieldedUnshield( walletId: Data, + account: UInt32 = 0, toPlatformAddress: String, amount: UInt64 ) async throws { @@ -387,7 +413,7 @@ extension PlatformWalletManager { } try toPlatformAddress.withCString { addrCStr in try platform_wallet_manager_shielded_unshield( - handle, widPtr, addrCStr, amount + handle, widPtr, account, addrCStr, amount ).check() } } @@ -400,6 +426,7 @@ extension PlatformWalletManager { /// the L1 fee rate in duffs/byte (`1` is the dashmate default). public func shieldedWithdraw( walletId: Data, + account: UInt32 = 0, toCoreAddress: String, amount: UInt64, coreFeePerByte: UInt32 = 1 @@ -424,7 +451,7 @@ extension PlatformWalletManager { } try toCoreAddress.withCString { addrCStr in try platform_wallet_manager_shielded_withdraw( - handle, widPtr, addrCStr, amount, coreFeePerByte + handle, widPtr, account, addrCStr, amount, coreFeePerByte ).check() } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift index a93f9e37ec0..2cc3b85012a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -130,12 +130,12 @@ class ShieldedService: ObservableObject { totalNewNotes = 0 totalNewlySpent = 0 - let dbPath = Self.dbPath(for: network, walletId: walletId) + let dbPath = Self.dbPath(for: network) do { try walletManager.bindShielded( walletId: walletId, resolver: resolver, - account: 0, + accounts: [0], dbPath: dbPath ) isBound = true @@ -145,7 +145,10 @@ class ShieldedService: ObservableObject { // succeeded so the Receive sheet has something to render // before the first sync pass lands. Best-effort — // failures here don't unbind the wallet. - if let raw = try? walletManager.shieldedDefaultAddress(walletId: walletId) { + if let raw = try? walletManager.shieldedDefaultAddress( + walletId: walletId, + account: 0 + ) { orchardDisplayAddress = DashAddress.encodeOrchard( rawBytes: raw, network: network @@ -287,23 +290,22 @@ class ShieldedService: ObservableObject { // MARK: - Private - /// Per-(network, wallet) commitment-tree DB. Conceptually the - /// Orchard tree is shared across wallets on the same network (the - /// tree itself is anchor-equivalent for everyone), but - /// `FileBackedShieldedStore` keeps decrypted notes in the same - /// SQLite file without a `wallet_id` column — so a single - /// per-network file would let wallet B read wallet A's notes - /// (and report A's balance under B's name). Until the store is - /// extended to scope notes by wallet, each wallet gets its own - /// file. Cost: re-syncing the tree from genesis per wallet on - /// first bind. Acceptable for now. - private static func dbPath(for network: Network, walletId: Data) -> String { + /// Per-network commitment-tree DB. + /// + /// The Orchard tree is a chain-wide structure: every wallet + /// and every account on the same network sees the same `cmx` + /// stream in the same order, so they all back the same + /// frontier and share anchors. `FileBackedShieldedStore` now + /// scopes per-`(walletId, accountIndex)` notes inside the + /// store via `SubwalletId`, so multiple wallets cohabiting + /// the same SQLite file no longer leak notes across each + /// other. (See `wallet/shielded/store.rs` for the trait.) + private static func dbPath(for network: Network) -> String { let docs = FileManager.default .urls(for: .documentDirectory, in: .userDomainMask) .first! - let walletHex = walletId.map { String(format: "%02x", $0) }.joined() return docs - .appendingPathComponent("shielded_tree_\(network.networkName)_\(walletHex).sqlite") + .appendingPathComponent("shielded_tree_\(network.networkName).sqlite") .path } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift index 1403679f3e7..230772215fa 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -226,6 +226,7 @@ class SendViewModel: ObservableObject { } try await walletManager.shieldedTransfer( walletId: wallet.walletId, + account: 0, recipientRaw43: recipientRaw, amount: amountCredits ) @@ -244,6 +245,7 @@ class SendViewModel: ObservableObject { let trimmed = recipientAddress.trimmingCharacters(in: .whitespacesAndNewlines) try await walletManager.shieldedUnshield( walletId: wallet.walletId, + account: 0, toPlatformAddress: trimmed, amount: amountCredits ) @@ -261,6 +263,7 @@ class SendViewModel: ObservableObject { let trimmed = recipientAddress.trimmingCharacters(in: .whitespacesAndNewlines) try await walletManager.shieldedWithdraw( walletId: wallet.walletId, + account: 0, toCoreAddress: trimmed, amount: amountCredits, coreFeePerByte: 1 @@ -281,7 +284,8 @@ class SendViewModel: ObservableObject { let signer = KeychainSigner(modelContainer: modelContext.container) try await walletManager.shieldedShield( walletId: wallet.walletId, - accountIndex: 0, + shieldedAccount: 0, + paymentAccount: 0, amount: amountCredits, addressSigner: signer ) From 771b01c866b0f9020cc3abb29cbd96df324f2b0b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 6 May 2026 16:29:11 +0700 Subject: [PATCH 12/78] feat(platform-wallet): shielded changeset + per-subwallet restore-on-bind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Rust-side persistence wiring for the multi-account shielded refactor. Sync passes and spend operations now emit `ShieldedChangeSet` deltas to the wallet's persister, and `bind_shielded` rehydrates the in-memory `SubwalletState` from the persister's `ClientStartState` snapshot before kicking off the first sync. This is the Rust half of the deferred persistence slice; the FFI callback that surfaces these changesets to the host (SwiftData on iOS) and the matching Swift handler land in follow-up commits in this same PR. ## What changes **`changeset/`**: - `ShieldedChangeSet` — per-`SubwalletId` `notes_saved`, `nullifiers_spent`, `synced_indices`, `nullifier_checkpoints`. Implements `Merge` (LWW on watermarks; append on note vecs). Carried as a new `Option` field on `PlatformWalletChangeSet` (feature-gated `shielded`). - `ShieldedSyncStartState` — restore snapshot keyed by `SubwalletId`. Lives on `ClientStartState.shielded`. - Existing destructure sites in `apply.rs`, `manager/load.rs`, `manager/wallet_lifecycle.rs`, and `platform_wallet.rs` updated to drop the new field with a `#[cfg(feature = "shielded")]` arm. **`wallet/shielded/mod.rs`**: - `ShieldedWallet` grows an optional `WalletPersister` handle and a `set_persister(...)` setter. - New `queue_shielded_changeset(cs)` helper that wraps a `ShieldedChangeSet` in a `PlatformWalletChangeSet` and pushes it to the persister. No-op when no persister is attached. - New `restore_from_snapshot(&ShieldedSyncStartState)` consumes per-subwallet entries that match `(self.wallet_id, account)` for any bound account, save_note's their notes, marks spent ones, and replays the sync watermarks. **`wallet/shielded/sync.rs`**: - `sync_notes` accumulates a `ShieldedChangeSet` as it saves decrypted notes / advances watermarks, then queues it on the persister at the end of the pass (after dropping the store write lock so the persister callback isn't nested under it). - `check_nullifiers` does the same for spent marks + nullifier checkpoints. **`wallet/shielded/operations.rs`**: - `mark_notes_spent` queues a changeset for each freshly-marked nullifier so spend events propagate to durable storage immediately rather than waiting for the next nullifier-sync pass to rediscover them. **`wallet/platform_wallet.rs`**: - `bind_shielded` attaches the wallet's persister to the `ShieldedWallet`, then calls `restore_from_snapshot` against `self.persister.load()?.shielded` so the freshly-bound wallet starts pre-populated with whatever the host already has on disk for `(self.wallet_id, account)` for each requested account. ## Tests 11 existing shielded unit tests still pass. Clippy clean. The load-side end-to-end flow ("host writes → cold start → restore_from_snapshot → spend works") is exercised once the FFI + SwiftData sides land in the next commits. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/changeset/changeset.rs | 22 ++++- .../src/changeset/client_start_state.rs | 18 +++- .../rs-platform-wallet/src/changeset/mod.rs | 8 ++ .../src/changeset/shielded_changeset.rs | 98 +++++++++++++++++++ .../changeset/shielded_sync_start_state.rs | 51 ++++++++++ .../rs-platform-wallet/src/manager/load.rs | 4 + .../src/manager/wallet_lifecycle.rs | 2 + .../rs-platform-wallet/src/wallet/apply.rs | 6 ++ .../src/wallet/platform_wallet.rs | 34 ++++++- .../src/wallet/shielded/mod.rs | 83 ++++++++++++++++ .../src/wallet/shielded/operations.rs | 22 +++-- .../src/wallet/shielded/sync.rs | 22 ++++- 12 files changed, 358 insertions(+), 12 deletions(-) create mode 100644 packages/rs-platform-wallet/src/changeset/shielded_changeset.rs create mode 100644 packages/rs-platform-wallet/src/changeset/shielded_sync_start_state.rs diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 40af538a08f..c78e0a59e3d 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -891,6 +891,12 @@ pub struct PlatformWalletChangeSet { /// gap-limit population) and on any pool extension / "used" flip. /// See [`AccountAddressPoolEntry`] for the merge policy. pub account_address_pools: Vec, + /// Shielded sub-wallet deltas: per-subwallet decrypted notes, + /// spent marks, sync watermarks, nullifier checkpoints. The + /// commitment tree itself is **not** in here — it lives on + /// disk in `ClientPersistentCommitmentTree`'s SQLite file. + #[cfg(feature = "shielded")] + pub shielded: Option, } impl From for PlatformWalletChangeSet { @@ -987,10 +993,14 @@ impl Merge for PlatformWalletChangeSet { .extend(other.account_registrations); self.account_address_pools .extend(other.account_address_pools); + #[cfg(feature = "shielded")] + { + self.shielded.merge(other.shielded); + } } fn is_empty(&self) -> bool { - self.core.is_empty() + let core_empty = self.core.is_empty() && self.identities.is_empty() && self.identity_keys.is_empty() && self.contacts.is_empty() @@ -1004,7 +1014,15 @@ impl Merge for PlatformWalletChangeSet { .is_none_or(|m| m.is_empty()) && self.wallet_metadata.is_none() && self.account_registrations.is_empty() - && self.account_address_pools.is_empty() + && self.account_address_pools.is_empty(); + #[cfg(feature = "shielded")] + { + core_empty && self.shielded.as_ref().is_none_or(|s| s.is_empty()) + } + #[cfg(not(feature = "shielded"))] + { + core_empty + } } } diff --git a/packages/rs-platform-wallet/src/changeset/client_start_state.rs b/packages/rs-platform-wallet/src/changeset/client_start_state.rs index 3a85d0c9915..c63e5a262be 100644 --- a/packages/rs-platform-wallet/src/changeset/client_start_state.rs +++ b/packages/rs-platform-wallet/src/changeset/client_start_state.rs @@ -11,6 +11,8 @@ use std::collections::BTreeMap; use crate::changeset::client_wallet_start_state::ClientWalletStartState; use crate::changeset::platform_address_sync_start_state::PlatformAddressSyncStartState; +#[cfg(feature = "shielded")] +use crate::changeset::shielded_sync_start_state::ShieldedSyncStartState; use crate::wallet::platform_wallet::WalletId; /// Snapshot of everything a persister hands back on @@ -30,10 +32,24 @@ pub struct ClientStartState { /// Per-wallet startup slices (UTXOs and unused asset locks, each /// bucketed by account index). pub wallets: BTreeMap, + /// Restored shielded sub-wallet state — per-`SubwalletId` + /// notes + sync watermarks. Consumed at `bind_shielded` time + /// to rehydrate the in-memory `SubwalletState` so spending / + /// balance reads work without re-decrypting the chain. + #[cfg(feature = "shielded")] + pub shielded: ShieldedSyncStartState, } impl ClientStartState { pub fn is_empty(&self) -> bool { - self.platform_addresses.is_empty() && self.wallets.is_empty() + let core_empty = self.platform_addresses.is_empty() && self.wallets.is_empty(); + #[cfg(feature = "shielded")] + { + core_empty && self.shielded.is_empty() + } + #[cfg(not(feature = "shielded"))] + { + core_empty + } } } diff --git a/packages/rs-platform-wallet/src/changeset/mod.rs b/packages/rs-platform-wallet/src/changeset/mod.rs index bd6650431fe..1f669091c58 100644 --- a/packages/rs-platform-wallet/src/changeset/mod.rs +++ b/packages/rs-platform-wallet/src/changeset/mod.rs @@ -16,6 +16,10 @@ pub mod core_bridge; pub mod identity_manager_start_state; pub mod merge; pub mod platform_address_sync_start_state; +#[cfg(feature = "shielded")] +pub mod shielded_changeset; +#[cfg(feature = "shielded")] +pub mod shielded_sync_start_state; pub mod traits; pub use changeset::{ @@ -31,4 +35,8 @@ pub use core_bridge::spawn_wallet_event_adapter; pub use identity_manager_start_state::IdentityManagerStartState; pub use merge::Merge; pub use platform_address_sync_start_state::PlatformAddressSyncStartState; +#[cfg(feature = "shielded")] +pub use shielded_changeset::ShieldedChangeSet; +#[cfg(feature = "shielded")] +pub use shielded_sync_start_state::{ShieldedSubwalletStartState, ShieldedSyncStartState}; pub use traits::{PersistenceError, PlatformWalletPersistence}; diff --git a/packages/rs-platform-wallet/src/changeset/shielded_changeset.rs b/packages/rs-platform-wallet/src/changeset/shielded_changeset.rs new file mode 100644 index 00000000000..dc90afd5176 --- /dev/null +++ b/packages/rs-platform-wallet/src/changeset/shielded_changeset.rs @@ -0,0 +1,98 @@ +//! Delta of shielded-wallet state for the persister callback. +//! +//! Buffered into [`PlatformWalletChangeSet::shielded`] from the +//! `FileBackedShieldedStore` whenever a sync pass discovers a new +//! note, marks one spent, advances a per-subwallet sync watermark, +//! or records a nullifier-sync checkpoint. The host persister +//! flushes these to its durable store (SwiftData on iOS) so cold +//! starts can rehydrate the in-memory `SubwalletState` without +//! re-decrypting the chain from genesis. +//! +//! Scope: +//! - **In** this changeset: per-subwallet decrypted notes, spent +//! marks, sync watermarks, nullifier checkpoints. +//! - **Out** of this changeset: the commitment tree itself +//! (already persisted in `ClientPersistentCommitmentTree`'s +//! SQLite file at the host-supplied `db_path`). + +use std::collections::BTreeMap; + +use crate::changeset::merge::Merge; +use crate::wallet::shielded::{ShieldedNote, SubwalletId}; + +/// Aggregated delta of shielded state for one persister flush. +#[derive(Debug, Clone, Default)] +pub struct ShieldedChangeSet { + /// Notes discovered (or re-saved with updated state) per + /// subwallet. Keyed by `(wallet_id, account_index)`. Order + /// inside the `Vec` is insertion order — the persister can + /// upsert by `(SubwalletId, position)`. + pub notes_saved: BTreeMap>, + /// Nullifiers freshly observed as spent on-chain, keyed by + /// the subwallet that owns the corresponding note. The + /// persister flips that note's `is_spent` row to true. + pub nullifiers_spent: BTreeMap>, + /// Latest per-subwallet `last_synced_note_index`. Last write + /// wins on merge (sync only ever advances this monotonically). + pub synced_indices: BTreeMap, + /// Latest per-subwallet `(height, timestamp)` nullifier sync + /// checkpoint. Last write wins on merge. + pub nullifier_checkpoints: BTreeMap, +} + +impl ShieldedChangeSet { + /// `true` iff this changeset carries no shielded deltas. + pub fn is_empty(&self) -> bool { + self.notes_saved.is_empty() + && self.nullifiers_spent.is_empty() + && self.synced_indices.is_empty() + && self.nullifier_checkpoints.is_empty() + } + + /// Accumulator helper: record a saved note for `id`. + pub fn record_note(&mut self, id: SubwalletId, note: ShieldedNote) { + self.notes_saved.entry(id).or_default().push(note); + } + + /// Accumulator helper: record a nullifier seen as spent on `id`. + pub fn record_nullifier_spent(&mut self, id: SubwalletId, nullifier: [u8; 32]) { + self.nullifiers_spent.entry(id).or_default().push(nullifier); + } + + /// Accumulator helper: advance the per-subwallet sync watermark. + pub fn record_synced_index(&mut self, id: SubwalletId, index: u64) { + let entry = self.synced_indices.entry(id).or_insert(index); + if *entry < index { + *entry = index; + } + } + + /// Accumulator helper: record the latest nullifier sync checkpoint. + pub fn record_nullifier_checkpoint(&mut self, id: SubwalletId, height: u64, timestamp: u64) { + self.nullifier_checkpoints.insert(id, (height, timestamp)); + } +} + +impl Merge for ShieldedChangeSet { + fn merge(&mut self, other: Self) { + for (id, notes) in other.notes_saved { + self.notes_saved.entry(id).or_default().extend(notes); + } + for (id, nfs) in other.nullifiers_spent { + self.nullifiers_spent.entry(id).or_default().extend(nfs); + } + for (id, idx) in other.synced_indices { + let entry = self.synced_indices.entry(id).or_insert(idx); + if *entry < idx { + *entry = idx; + } + } + // Last write wins for nullifier checkpoints. + self.nullifier_checkpoints + .extend(other.nullifier_checkpoints); + } + + fn is_empty(&self) -> bool { + ShieldedChangeSet::is_empty(self) + } +} diff --git a/packages/rs-platform-wallet/src/changeset/shielded_sync_start_state.rs b/packages/rs-platform-wallet/src/changeset/shielded_sync_start_state.rs new file mode 100644 index 00000000000..6f55af36480 --- /dev/null +++ b/packages/rs-platform-wallet/src/changeset/shielded_sync_start_state.rs @@ -0,0 +1,51 @@ +//! Shielded sub-wallet state restored from storage. +//! +//! Returned as part of [`ClientStartState`] by +//! [`PlatformWalletPersistence::load`] so a freshly-bound +//! [`ShieldedWallet`] can rehydrate per-subwallet decrypted notes +//! and sync watermarks without re-decrypting the chain. +//! +//! Keyed by [`SubwalletId`] so a single `BTreeMap` covers every +//! `(wallet_id, account_index)` combination on the network. +//! +//! [`ClientStartState`]: crate::changeset::ClientStartState +//! [`PlatformWalletPersistence::load`]: crate::changeset::PlatformWalletPersistence::load +//! [`ShieldedWallet`]: crate::wallet::shielded::ShieldedWallet +//! [`SubwalletId`]: crate::wallet::shielded::SubwalletId + +use crate::wallet::shielded::{ShieldedNote, SubwalletId}; +use std::collections::BTreeMap; + +/// Per-subwallet snapshot — every note (spent + unspent) the +/// persister has on file plus the sync watermarks. +#[derive(Debug, Default, Clone)] +pub struct ShieldedSubwalletStartState { + /// All known notes for this subwallet, including spent ones. + /// `is_spent` is preserved from the persisted row so the + /// in-memory store reflects what nullifier sync has already + /// established. + pub notes: Vec, + /// Highest global note index that the subwallet has scanned. + pub last_synced_index: u64, + /// Last `(height, timestamp)` nullifier sync checkpoint. + pub nullifier_checkpoint: Option<(u64, u64)>, +} + +/// Whole-client shielded restore state, keyed by `SubwalletId`. +/// +/// Lives on [`ClientStartState`] alongside platform-address state. +/// On wallet bind, `PlatformWallet::bind_shielded` consumes the +/// entries that match `(self.wallet_id, account)` for each +/// requested account and hands them back to the in-memory store +/// before kicking off the first sync pass. +#[derive(Debug, Default)] +pub struct ShieldedSyncStartState { + pub per_subwallet: BTreeMap, +} + +impl ShieldedSyncStartState { + /// `true` iff no subwallet snapshot is restored. + pub fn is_empty(&self) -> bool { + self.per_subwallet.is_empty() + } +} diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 36ba66e89a8..8e7af9be1c7 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -33,6 +33,10 @@ impl PlatformWalletManager

{ let ClientStartState { mut platform_addresses, wallets, + // Shielded restore happens lazily on `bind_shielded`, + // not here — drop the snapshot at this entry point. + #[cfg(feature = "shielded")] + shielded: _, } = self.persister.load().map_err(|e| { PlatformWalletError::WalletCreation(format!( "Failed to load persisted client state: {}", diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index 1042feb440a..769afe3dd52 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -277,6 +277,8 @@ impl PlatformWalletManager

{ let crate::changeset::ClientStartState { mut platform_addresses, wallets: _, + #[cfg(feature = "shielded")] + shielded: _, } = match platform_wallet.load_persisted() { Ok(state) => state, Err(e) => { diff --git a/packages/rs-platform-wallet/src/wallet/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs index 1c0ea40654b..3f6d75a61b3 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -108,6 +108,12 @@ impl PlatformWalletInfo { wallet_metadata: _, account_registrations: _, account_address_pools: _, + // Shielded deltas are owned by `ShieldedWallet` (which + // mutates its store directly during sync / spend); the + // canonical in-memory state lives there and the + // changeset is persistence-side only. Drop here. + #[cfg(feature = "shielded")] + shielded: _, } = cs; // 1. Core wallet state. In the new event-bus model, a diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 33e85c3ca47..a1818c228d6 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -324,7 +324,7 @@ impl PlatformWallet { let store = FileBackedShieldedStore::open_path(db_path, 100) .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; let network = self.sdk.network; - let wallet = ShieldedWallet::from_seed_accounts( + let mut wallet = ShieldedWallet::from_seed_accounts( Arc::clone(&self.sdk), self.wallet_id, seed, @@ -333,6 +333,36 @@ impl PlatformWallet { store, )?; + // Attach the persister so future sync passes emit + // shielded changesets the host can mirror (SwiftData + // on iOS). + wallet.set_persister(self.persister.clone()); + + // Rehydrate per-subwallet notes / sync watermarks from + // the persister's start state if any are present for + // this wallet. The lookup is cheap: load() is the + // boot-time snapshot, indexed by SubwalletId. Errors are + // logged but not fatal — first-launch wallets simply + // see no persisted state. + match self.persister.load() { + Ok(start) => { + if let Err(e) = wallet.restore_from_snapshot(&start.shielded).await { + tracing::warn!( + wallet_id = %hex::encode(self.wallet_id), + error = %e, + "Failed to restore shielded snapshot at bind time" + ); + } + } + Err(e) => { + tracing::warn!( + wallet_id = %hex::encode(self.wallet_id), + error = %e, + "persister.load() failed at shielded bind time" + ); + } + } + let mut slot = self.shielded.write().await; *slot = Some(wallet); Ok(()) @@ -809,6 +839,8 @@ impl PlatformWallet { let ClientStartState { mut platform_addresses, wallets: _, + #[cfg(feature = "shielded")] + shielded: _, } = self.load_persisted()?; if let Some(persisted) = platform_addresses.remove(&self.wallet_id) { diff --git a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs index 5d2cd8327fa..2b0bc239313 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs @@ -35,7 +35,10 @@ use std::sync::Arc; use tokio::sync::RwLock; +use crate::changeset::ShieldedChangeSet; +use crate::changeset::{PlatformWalletChangeSet, ShieldedSyncStartState}; use crate::error::PlatformWalletError; +use crate::wallet::persister::WalletPersister; use crate::wallet::platform_wallet::WalletId; /// Per-account state held inside a [`ShieldedWallet`]. @@ -70,6 +73,13 @@ pub struct ShieldedWallet { /// commitment tree inside is global per network; notes are /// scoped per-subwallet by the store's `SubwalletId` keying. pub(super) store: Arc>, + /// Optional persister handle. When set, every state-changing + /// sync / spend pass emits a [`PlatformWalletChangeSet`] with + /// a populated `shielded` field so the host (typically + /// SwiftData on iOS) can mirror per-subwallet notes / sync + /// watermarks. `None` means in-memory only — useful for + /// tests and short-lived wallets. + pub(super) persister: Option, } impl ShieldedWallet { @@ -97,9 +107,82 @@ impl ShieldedWallet { wallet_id, accounts, store: Arc::new(RwLock::new(store)), + persister: None, }) } + /// Attach a [`WalletPersister`] so future sync / spend passes + /// emit shielded changesets to the host. + pub fn set_persister(&mut self, persister: WalletPersister) { + self.persister = Some(persister); + } + + /// Queue a shielded changeset on the persister if one is + /// attached. No-op otherwise. + pub(super) fn queue_shielded_changeset(&self, cs: ShieldedChangeSet) { + if cs.is_empty() { + return; + } + let Some(persister) = &self.persister else { + return; + }; + let full = PlatformWalletChangeSet { + shielded: Some(cs), + ..Default::default() + }; + if let Err(e) = persister.store(full) { + tracing::warn!( + wallet_id = %hex::encode(self.wallet_id), + error = %e, + "Failed to queue shielded changeset" + ); + } + } + + /// Rehydrate per-subwallet state from a persisted snapshot. + /// Should be called after `from_seed_accounts(...)` and before + /// the first sync pass so the in-memory store matches what + /// the host already has on disk. + pub async fn restore_from_snapshot( + &self, + snapshot: &ShieldedSyncStartState, + ) -> Result<(), PlatformWalletError> { + if snapshot.is_empty() { + return Ok(()); + } + let mut store = self.store.write().await; + for (id, sub) in &snapshot.per_subwallet { + // Only restore subwallets that belong to this wallet. + if id.wallet_id != self.wallet_id { + continue; + } + // Skip accounts that aren't bound on this wallet — + // they'd accumulate state we can never spend. + if !self.accounts.contains_key(&id.account_index) { + continue; + } + for note in &sub.notes { + store + .save_note(*id, note) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + if note.is_spent { + store + .mark_spent(*id, ¬e.nullifier) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + } + } + store + .set_last_synced_note_index(*id, sub.last_synced_index) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + if let Some((h, t)) = sub.nullifier_checkpoint { + store + .set_nullifier_checkpoint(*id, h, t) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + } + } + Ok(()) + } + /// Derive Orchard keys for every listed `account` from a /// wallet seed and return a [`ShieldedWallet`]. /// diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 366f496615f..9837d96e702 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -513,18 +513,28 @@ impl ShieldedWallet { Ok((spends, anchor)) } - /// Mark the selected notes as spent for `id`. + /// Mark the selected notes as spent for `id`. Also queues a + /// shielded changeset on the persister so the spent flag + /// reaches durable storage immediately rather than waiting for + /// the next nullifier-sync pass to rediscover the spend. async fn mark_notes_spent( &self, id: SubwalletId, notes: &[ShieldedNote], ) -> Result<(), PlatformWalletError> { - let mut store = self.store.write().await; - for note in notes { - store - .mark_spent(id, ¬e.nullifier) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + let mut changeset = crate::changeset::ShieldedChangeSet::default(); + { + let mut store = self.store.write().await; + for note in notes { + if store + .mark_spent(id, ¬e.nullifier) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))? + { + changeset.record_nullifier_spent(id, note.nullifier); + } + } } + self.queue_shielded_changeset(changeset); Ok(()) } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs index b42f4a5d77a..94850596af6 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs @@ -19,6 +19,7 @@ use tracing::{debug, info, warn}; use super::store::{ShieldedStore, SubwalletId}; use super::ShieldedWallet; +use crate::changeset::ShieldedChangeSet; use crate::error::PlatformWalletError; /// Server-enforced chunk size — start_index must be a multiple of this. @@ -204,9 +205,11 @@ impl ShieldedWallet { .map_err(|e| PlatformWalletError::ShieldedTreeUpdateFailed(e.to_string()))?; } - // Save decrypted notes scoped per subwallet, and - // count new notes per account. + // Save decrypted notes scoped per subwallet, count new + // notes per account, and accumulate a changeset to hand + // to the persister at the end. let mut new_notes_per_account: BTreeMap = BTreeMap::new(); + let mut changeset = ShieldedChangeSet::default(); for (account, discovered) in &decrypted_by_account { let fvk = &self.keys_for(*account)?.full_viewing_key; let id = self.subwallet_id(*account); @@ -235,6 +238,7 @@ impl ShieldedWallet { store .save_note(id, &shielded_note) .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + changeset.record_note(id, shielded_note); *new_notes_per_account.entry(*account).or_default() += 1; } } @@ -247,7 +251,13 @@ impl ShieldedWallet { store .set_last_synced_note_index(id, new_index) .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + changeset.record_synced_index(id, new_index); } + // Drop the write lock before queuing the changeset so + // the persister callback (which may take its own + // synchronous mutex) doesn't nest under our store lock. + drop(store); + self.queue_shielded_changeset(changeset); info!( new_notes_total = new_notes_per_account.values().sum::(), @@ -303,6 +313,7 @@ impl ShieldedWallet { }; let mut newly_spent: BTreeMap = BTreeMap::new(); + let mut changeset = ShieldedChangeSet::default(); for ( account, AccountUnspent { @@ -334,18 +345,25 @@ impl ShieldedWallet { .mark_spent(id, nf_bytes) .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))? { + changeset.record_nullifier_spent(id, *nf_bytes); spent_count += 1; } } store .set_nullifier_checkpoint(id, result.new_sync_height, result.new_sync_timestamp) .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + changeset.record_nullifier_checkpoint( + id, + result.new_sync_height, + result.new_sync_timestamp, + ); if spent_count > 0 { newly_spent.insert(account, spent_count); info!(account, spent_count, "Notes newly detected as spent"); } } + self.queue_shielded_changeset(changeset); Ok(newly_spent) } From 784ce0250ad135cf275dccb934cec67e9de6f5b8 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 6 May 2026 16:47:13 +0700 Subject: [PATCH 13/78] feat(swift-sdk,platform-wallet-ffi): SwiftData persistence for shielded notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the Rust-side `ShieldedChangeSet` persister hook from the previous commit through the FFI to SwiftData, so decrypted shielded notes / nullifier-spent flags / per-subwallet sync watermarks survive across app launches. Cold start re-loads the state into the in-memory `ShieldedWallet` so spending and balance reads work without re-decrypting the chain. ## What changes **rs-platform-wallet-ffi**: - `shielded_persistence.rs` — new C-ABI types `ShieldedNoteFFI` / `ShieldedNullifierSpentFFI` / `ShieldedSyncedIndexFFI` / `ShieldedNullifierCheckpointFFI` for the persist path, and `ShieldedNoteRestoreFFI` / `ShieldedSubwalletSyncStateFFI` for the load path. - `PersistenceCallbacks` grows four `on_persist_shielded_*_fn` fields and four `on_load_shielded_*_fn` / free pairs. Inlined function signatures (rather than `pub type` aliases) so cbindgen walks into the referenced struct definitions and emits their full field layout in the generated header. - `FFIPersister::store` fans `changeset.shielded` out across the four persist callbacks. `FFIPersister::load` calls the two load callbacks and folds the results into `ClientStartState.shielded` keyed by `SubwalletId`. **swift-sdk**: - `PersistentShieldedNote` / `PersistentShieldedSyncState` SwiftData models. Notes keyed by `nullifier` (globally unique); sync states uniquely keyed by `(walletId, accountIndex)`. Both registered in `DashModelContainer.modelTypes`. - `PlatformWalletPersistenceHandler` grows handler methods + trampolines for the four persist callbacks (upserts / spent-flag flips / watermark advances / nullifier-checkpoint upserts) and the two load callbacks (host-allocated arrays with deferred free under `ShieldedLoadAllocation` / `ShieldedSyncStateLoadAllocation`). - `makeCallbacks()` wires every new callback into the `PersistenceCallbacks` struct handed to Rust. ## End-to-end flow Per-spend / per-sync passes on the Rust side build a `ShieldedChangeSet` and queue it on the persister. The FFI flushes that into the four typed callback batches, and the Swift handler upserts SwiftData rows. On cold start `bind_shielded` calls `persister.load()` which fires the load callbacks; the host streams every persisted row back as flat FFI arrays, Rust assembles a `ShieldedSyncStartState`, and `ShieldedWallet::restore_from_snapshot` rehydrates the in-memory `SubwalletState` before the first sync runs. ## Tests Existing 11 shielded unit tests pass. iOS xcframework + the SwiftExampleApp build green. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/rs-platform-wallet-ffi/src/lib.rs | 2 + .../rs-platform-wallet-ffi/src/persistence.rs | 378 ++++++++++++ .../src/shielded_persistence.rs | 125 ++++ .../Persistence/DashModelContainer.swift | 4 +- .../Models/PersistentShieldedNote.swift | 73 +++ .../Models/PersistentShieldedSyncState.swift | 55 ++ .../PlatformWalletPersistenceHandler.swift | 544 ++++++++++++++++++ 7 files changed, 1180 insertions(+), 1 deletion(-) create mode 100644 packages/rs-platform-wallet-ffi/src/shielded_persistence.rs create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedNote.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedSyncState.swift diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index 764d7b89e39..e81074a6472 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -52,6 +52,8 @@ pub mod platform_addresses; pub mod platform_wallet_info; mod runtime; #[cfg(feature = "shielded")] +pub mod shielded_persistence; +#[cfg(feature = "shielded")] pub mod shielded_send; #[cfg(feature = "shielded")] pub mod shielded_sync; diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 82d29fb3b65..c80a22e03f0 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -287,6 +287,97 @@ pub struct PersistenceCallbacks { removed_incoming_count: usize, ) -> i32, >, + // ── Shielded (Orchard) persistence ───────────────────────────────── + // + // These four `on_persist_shielded_*` callbacks fire from + // `FFIPersister::store` whenever a `ShieldedChangeSet` arrives + // from `ShieldedWallet`. The matching `on_load_shielded_*` + // callbacks fire once on `FFIPersister::load` to rehydrate the + // in-memory `SubwalletState`s before the first sync pass. The + // `wallet_id` carried inside each entry scopes the row by + // wallet; the outer `wallet_id` argument on the `store` + // callback identifies the wallet the changeset originated from + // (always identical to every entry's nested `wallet_id`). + /// Per-subwallet decrypted notes upserts. + #[cfg(feature = "shielded")] + pub on_persist_shielded_notes_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + entries: *const crate::shielded_persistence::ShieldedNoteFFI, + count: usize, + ) -> i32, + >, + /// Per-subwallet nullifier-spent observations. + #[cfg(feature = "shielded")] + pub on_persist_shielded_nullifiers_spent_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + entries: *const crate::shielded_persistence::ShieldedNullifierSpentFFI, + count: usize, + ) -> i32, + >, + /// Per-subwallet sync watermark advances. + #[cfg(feature = "shielded")] + pub on_persist_shielded_synced_indices_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + entries: *const crate::shielded_persistence::ShieldedSyncedIndexFFI, + count: usize, + ) -> i32, + >, + /// Per-subwallet nullifier-sync checkpoint advances. + #[cfg(feature = "shielded")] + pub on_persist_shielded_nullifier_checkpoints_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + entries: *const crate::shielded_persistence::ShieldedNullifierCheckpointFFI, + count: usize, + ) -> i32, + >, + /// Restore-on-load: every persisted shielded note. Host + /// allocates the array; Rust calls the matching free + /// callback after copying. Same lifetime contract as + /// `on_load_wallet_list_fn`. Inlined here (rather than via + /// the `OnLoadShieldedNotesFn` type alias) so cbindgen sees + /// the full signature and emits the referenced struct + /// definitions in the generated header. + #[cfg(feature = "shielded")] + pub on_load_shielded_notes_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + out_entries: *mut *const crate::shielded_persistence::ShieldedNoteRestoreFFI, + out_count: *mut usize, + ) -> i32, + >, + #[cfg(feature = "shielded")] + pub on_load_shielded_notes_free_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + entries: *const crate::shielded_persistence::ShieldedNoteRestoreFFI, + count: usize, + ), + >, + /// Restore-on-load: every per-subwallet sync state. + #[cfg(feature = "shielded")] + pub on_load_shielded_sync_states_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + out_entries: *mut *const crate::shielded_persistence::ShieldedSubwalletSyncStateFFI, + out_count: *mut usize, + ) -> i32, + >, + #[cfg(feature = "shielded")] + pub on_load_shielded_sync_states_free_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + entries: *const crate::shielded_persistence::ShieldedSubwalletSyncStateFFI, + count: usize, + ), + >, } // SAFETY: The context pointer is managed by the FFI caller who must ensure @@ -800,6 +891,152 @@ impl PlatformWalletPersistence for FFIPersister { } } + // Shielded changeset (Orchard): four flat callback batches + // mirroring the four `ShieldedChangeSet` fields. Notes + // first so a follow-up `mark_spent` for the same nullifier + // upserts onto an existing row instead of falling on + // missing-row floor. + #[cfg(feature = "shielded")] + if let Some(ref shielded_cs) = changeset.shielded { + use crate::shielded_persistence::*; + + // 1) notes_saved + if !shielded_cs.notes_saved.is_empty() { + if let Some(cb) = self.callbacks.on_persist_shielded_notes_fn { + // Flatten the per-subwallet map into a single + // contiguous Vec so the callback gets one + // `entries: *const ShieldedNoteFFI` slice. The + // host copies `note_data` bytes during the call. + let entries: Vec = shielded_cs + .notes_saved + .iter() + .flat_map(|(id, notes)| { + notes.iter().map(|n| ShieldedNoteFFI { + wallet_id: id.wallet_id, + account_index: id.account_index, + position: n.position, + cmx: n.cmx, + nullifier: n.nullifier, + block_height: n.block_height, + is_spent: u8::from(n.is_spent), + value: n.value, + note_data_ptr: n.note_data.as_ptr(), + note_data_len: n.note_data.len(), + }) + }) + .collect(); + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + entries.as_ptr(), + entries.len(), + ) + }; + if result != 0 { + eprintln!( + "Shielded notes persistence callback returned error code {}", + result + ); + round_success = false; + } + } + } + + // 2) nullifiers_spent + if !shielded_cs.nullifiers_spent.is_empty() { + if let Some(cb) = self.callbacks.on_persist_shielded_nullifiers_spent_fn { + let entries: Vec = shielded_cs + .nullifiers_spent + .iter() + .flat_map(|(id, nfs)| { + nfs.iter().map(|nf| ShieldedNullifierSpentFFI { + wallet_id: id.wallet_id, + account_index: id.account_index, + nullifier: *nf, + }) + }) + .collect(); + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + entries.as_ptr(), + entries.len(), + ) + }; + if result != 0 { + eprintln!( + "Shielded nullifier-spent persistence callback returned error code {}", + result + ); + round_success = false; + } + } + } + + // 3) synced_indices + if !shielded_cs.synced_indices.is_empty() { + if let Some(cb) = self.callbacks.on_persist_shielded_synced_indices_fn { + let entries: Vec = shielded_cs + .synced_indices + .iter() + .map(|(id, &idx)| ShieldedSyncedIndexFFI { + wallet_id: id.wallet_id, + account_index: id.account_index, + last_synced_index: idx, + }) + .collect(); + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + entries.as_ptr(), + entries.len(), + ) + }; + if result != 0 { + eprintln!( + "Shielded synced-index persistence callback returned error code {}", + result + ); + round_success = false; + } + } + } + + // 4) nullifier_checkpoints + if !shielded_cs.nullifier_checkpoints.is_empty() { + if let Some(cb) = self.callbacks.on_persist_shielded_nullifier_checkpoints_fn { + let entries: Vec = shielded_cs + .nullifier_checkpoints + .iter() + .map(|(id, &(h, t))| ShieldedNullifierCheckpointFFI { + wallet_id: id.wallet_id, + account_index: id.account_index, + height: h, + timestamp: t, + }) + .collect(); + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + entries.as_ptr(), + entries.len(), + ) + }; + if result != 0 { + eprintln!( + "Shielded nullifier-checkpoint persistence callback returned error code {}", + result + ); + round_success = false; + } + } + } + } + // Close the round. Clients use this to commit (if // `round_success == true`) or roll back (otherwise) the // staged writes accumulated across the per-kind callbacks @@ -900,6 +1137,147 @@ impl PlatformWalletPersistence for FFIPersister { .insert(entry.wallet_id, platform_address_state); } } + + // Restore shielded sub-wallet state if the host has wired + // up the optional callbacks. Notes and per-subwallet sync + // states travel separately so the host can populate them + // from independent SwiftData fetch descriptors. Both arms + // walk the same `(wallet_id, account_index)` key space and + // funnel into a single `SubwalletId` map on + // `ClientStartState.shielded`. + #[cfg(feature = "shielded")] + { + use crate::shielded_persistence::*; + use platform_wallet::changeset::{ShieldedSubwalletStartState, ShieldedSyncStartState}; + use platform_wallet::wallet::shielded::{ShieldedNote, SubwalletId}; + + let mut shielded_state = ShieldedSyncStartState::default(); + + // 1) notes + if let Some(load_notes) = self.callbacks.on_load_shielded_notes_fn { + let mut notes_ptr: *const ShieldedNoteRestoreFFI = std::ptr::null(); + let mut notes_count: usize = 0; + let rc = + unsafe { load_notes(self.callbacks.context, &mut notes_ptr, &mut notes_count) }; + if rc != 0 { + return Err( + format!("on_load_shielded_notes_fn returned error code {}", rc).into(), + ); + } + struct NotesGuard { + context: *mut c_void, + free_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + entries: *const ShieldedNoteRestoreFFI, + count: usize, + ), + >, + entries: *const ShieldedNoteRestoreFFI, + count: usize, + } + impl Drop for NotesGuard { + fn drop(&mut self) { + if let Some(free_fn) = self.free_fn { + unsafe { free_fn(self.context, self.entries, self.count) }; + } + } + } + let _notes_guard = NotesGuard { + context: self.callbacks.context, + free_fn: self.callbacks.on_load_shielded_notes_free_fn, + entries: notes_ptr, + count: notes_count, + }; + if !notes_ptr.is_null() && notes_count > 0 { + let slice = unsafe { slice::from_raw_parts(notes_ptr, notes_count) }; + for ffi in slice { + if ffi.note_data_ptr.is_null() || ffi.note_data_len == 0 { + continue; + } + let note_data = unsafe { + std::slice::from_raw_parts(ffi.note_data_ptr, ffi.note_data_len) + .to_vec() + }; + let id = SubwalletId::new(ffi.wallet_id, ffi.account_index); + let entry = shielded_state + .per_subwallet + .entry(id) + .or_insert_with(ShieldedSubwalletStartState::default); + entry.notes.push(ShieldedNote { + position: ffi.position, + cmx: ffi.cmx, + nullifier: ffi.nullifier, + block_height: ffi.block_height, + is_spent: ffi.is_spent != 0, + value: ffi.value, + note_data, + }); + } + } + } + + // 2) per-subwallet sync states + if let Some(load_states) = self.callbacks.on_load_shielded_sync_states_fn { + let mut states_ptr: *const ShieldedSubwalletSyncStateFFI = std::ptr::null(); + let mut states_count: usize = 0; + let rc = unsafe { + load_states(self.callbacks.context, &mut states_ptr, &mut states_count) + }; + if rc != 0 { + return Err(format!( + "on_load_shielded_sync_states_fn returned error code {}", + rc + ) + .into()); + } + struct StatesGuard { + context: *mut c_void, + free_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + entries: *const ShieldedSubwalletSyncStateFFI, + count: usize, + ), + >, + entries: *const ShieldedSubwalletSyncStateFFI, + count: usize, + } + impl Drop for StatesGuard { + fn drop(&mut self) { + if let Some(free_fn) = self.free_fn { + unsafe { free_fn(self.context, self.entries, self.count) }; + } + } + } + let _states_guard = StatesGuard { + context: self.callbacks.context, + free_fn: self.callbacks.on_load_shielded_sync_states_free_fn, + entries: states_ptr, + count: states_count, + }; + if !states_ptr.is_null() && states_count > 0 { + let slice = unsafe { slice::from_raw_parts(states_ptr, states_count) }; + for ffi in slice { + let id = SubwalletId::new(ffi.wallet_id, ffi.account_index); + let entry = shielded_state + .per_subwallet + .entry(id) + .or_insert_with(ShieldedSubwalletStartState::default); + entry.last_synced_index = ffi.last_synced_index; + if ffi.has_nullifier_checkpoint != 0 { + entry.nullifier_checkpoint = Some(( + ffi.nullifier_checkpoint_height, + ffi.nullifier_checkpoint_timestamp, + )); + } + } + } + } + + out.shielded = shielded_state; + } + Ok(out) } } diff --git a/packages/rs-platform-wallet-ffi/src/shielded_persistence.rs b/packages/rs-platform-wallet-ffi/src/shielded_persistence.rs new file mode 100644 index 00000000000..1d58c9be86e --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/shielded_persistence.rs @@ -0,0 +1,125 @@ +//! C ABI types + callback signatures for shielded note persistence. +//! +//! Mirror of [`platform_wallet::changeset::ShieldedChangeSet`] for the +//! FFI boundary: per-subwallet decrypted notes, spent marks, sync +//! watermarks, nullifier checkpoints. Hosts implement the four +//! callbacks below in [`crate::persistence::PersistenceCallbacks`] +//! so changesets emitted by the Rust-side `ShieldedWallet` reach +//! durable storage (typically SwiftData on iOS). +//! +//! All pointers in these structs are valid for the duration of the +//! callback only — the host must copy any bytes it needs to retain +//! before the call returns. + +use std::ffi::c_void; + +/// One decrypted shielded note for the host to persist. +/// +/// The host writes one row keyed by +/// `(wallet_id, account_index, position)`. Re-saves with the same +/// `(wallet_id, account_index, nullifier)` overwrite the existing +/// row in place — Orchard nullifiers are globally unique, so a +/// rescan after a restart shouldn't produce duplicates. +#[repr(C)] +pub struct ShieldedNoteFFI { + /// 32-byte wallet identifier. + pub wallet_id: [u8; 32], + /// ZIP-32 account index. + pub account_index: u32, + /// Global commitment-tree position. + pub position: u64, + /// Note commitment (32 bytes). + pub cmx: [u8; 32], + /// Nullifier (32 bytes). + pub nullifier: [u8; 32], + /// Block height the note was first observed at. + pub block_height: u64, + /// `1` if this note has been observed as spent on-chain, `0` + /// otherwise. (`bool` would still take 1 byte but `u8` is + /// less surprising across the C ABI.) + pub is_spent: u8, + /// Note value in credits. + pub value: u64, + /// Pointer to the serialized `orchard::Note` payload. + /// `recipient(43) || value(8 LE) || rho(32) || rseed(32)` = + /// 115 bytes. Valid only for the callback window — the host + /// must copy. + pub note_data_ptr: *const u8, + /// Length of `note_data_ptr` in bytes (always 115 for valid notes). + pub note_data_len: usize, +} + +/// One nullifier observed as spent for `(wallet_id, account_index)`. +/// The host flips the matching `is_spent` flag on the existing +/// `ShieldedNoteFFI` row. +#[repr(C)] +pub struct ShieldedNullifierSpentFFI { + pub wallet_id: [u8; 32], + pub account_index: u32, + pub nullifier: [u8; 32], +} + +/// One per-subwallet sync-watermark advance. +#[repr(C)] +pub struct ShieldedSyncedIndexFFI { + pub wallet_id: [u8; 32], + pub account_index: u32, + /// Highest global commitment-tree index the subwallet has scanned. + pub last_synced_index: u64, +} + +/// One per-subwallet nullifier-sync checkpoint. +#[repr(C)] +pub struct ShieldedNullifierCheckpointFFI { + pub wallet_id: [u8; 32], + pub account_index: u32, + /// Block height of the most recent nullifier sync pass. + pub height: u64, + /// Block timestamp (Unix seconds) of the most recent pass. + pub timestamp: u64, +} + +// ── Restore (load) ────────────────────────────────────────────────────── + +/// One persisted note as the host hands it back at boot. Mirrors +/// [`ShieldedNoteFFI`] but lives in a Swift-allocated array, so +/// the buffer ownership / free contract differs (see +/// [`OnLoadShieldedNotesFreeFn`]). +#[repr(C)] +pub struct ShieldedNoteRestoreFFI { + pub wallet_id: [u8; 32], + pub account_index: u32, + pub position: u64, + pub cmx: [u8; 32], + pub nullifier: [u8; 32], + pub block_height: u64, + pub is_spent: u8, + pub value: u64, + pub note_data_ptr: *const u8, + pub note_data_len: usize, +} + +/// One per-subwallet sync-watermark + nullifier-checkpoint snapshot. +/// Restored alongside notes so the rehydrated `SubwalletState` +/// resumes incremental sync from the right place. +#[repr(C)] +pub struct ShieldedSubwalletSyncStateFFI { + pub wallet_id: [u8; 32], + pub account_index: u32, + pub last_synced_index: u64, + /// `1` iff the optional `nullifier_checkpoint` is populated. + pub has_nullifier_checkpoint: u8, + pub nullifier_checkpoint_height: u64, + pub nullifier_checkpoint_timestamp: u64, +} + +// The `on_load_shielded_*_fn` callback types are inlined inside +// [`PersistenceCallbacks`] (rather than declared as `pub type` +// aliases here) so cbindgen sees the full signature, walks into +// the referenced structs, and emits their full field layout in +// the generated header. Bare `pub type X = unsafe extern "C" fn` +// aliases are mangled into opaque structs by cbindgen and don't +// drag in their function-pointer arguments. + +#[allow(dead_code)] +fn _keep_c_void_in_scope(_x: *const c_void) {} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index 2623a53f3e1..cc44e0b53d5 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -28,7 +28,9 @@ public enum DashModelContainer { PersistentTransaction.self, PersistentTxo.self, PersistentPendingInput.self, - PersistentWalletManagerMetadata.self + PersistentWalletManagerMetadata.self, + PersistentShieldedNote.self, + PersistentShieldedSyncState.self ] } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedNote.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedNote.swift new file mode 100644 index 00000000000..527aebc084d --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedNote.swift @@ -0,0 +1,73 @@ +import Foundation +import SwiftData + +/// SwiftData row for one decrypted shielded (Orchard) note owned by +/// a specific subwallet. +/// +/// Mirrors `platform_wallet::changeset::ShieldedChangeSet::notes_saved` +/// from the Rust side. The persister callback writes one row per +/// `(walletId, accountIndex, position)` and re-saves with the same +/// nullifier overwrite the existing row in place — Orchard +/// nullifiers are globally unique, so repeated discovery of the +/// same note (e.g. after a re-sync) shouldn't double-count. +/// +/// On cold start the matching `loadShieldedNotes` callback streams +/// every row back to Rust so `ShieldedWallet::restore_from_snapshot` +/// can rehydrate `SubwalletState.notes` before the first sync runs. +@Model +public final class PersistentShieldedNote { + /// Index `(walletId, accountIndex)` so per-subwallet balance + /// scans hit an index instead of the full table. + #Index([\.walletId, \.accountIndex]) + + /// 32-byte wallet identifier (matches `PersistentWallet.walletId`). + public var walletId: Data + /// ZIP-32 account index inside the wallet. + public var accountIndex: UInt32 + /// Global commitment-tree position. + public var position: UInt64 + /// Note commitment (32 bytes). + public var cmx: Data + /// Spending nullifier (32 bytes). Unique across the table — + /// Orchard nullifiers are globally unique, so making this the + /// upsert key prevents double-counts on re-sync. + @Attribute(.unique) public var nullifier: Data + /// Block height this note was first observed at. + public var blockHeight: UInt64 + /// Whether the nullifier has been observed as spent on-chain. + public var isSpent: Bool + /// Note value in credits. + public var value: UInt64 + /// Serialized `orchard::Note` bytes (115 bytes: + /// `recipient(43) || value(8 LE) || rho(32) || rseed(32)`). + public var noteData: Data + + /// Insertion timestamps. + public var createdAt: Date + public var lastUpdated: Date + + public init( + walletId: Data, + accountIndex: UInt32, + position: UInt64, + cmx: Data, + nullifier: Data, + blockHeight: UInt64, + isSpent: Bool, + value: UInt64, + noteData: Data + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.position = position + self.cmx = cmx + self.nullifier = nullifier + self.blockHeight = blockHeight + self.isSpent = isSpent + self.value = value + self.noteData = noteData + let now = Date() + self.createdAt = now + self.lastUpdated = now + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedSyncState.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedSyncState.swift new file mode 100644 index 00000000000..0676fd7b996 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedSyncState.swift @@ -0,0 +1,55 @@ +import Foundation +import SwiftData + +/// SwiftData row for per-subwallet shielded sync watermarks. +/// +/// Mirrors `platform_wallet::changeset::ShieldedChangeSet::synced_indices` +/// + `nullifier_checkpoints` from the Rust side. One row per +/// `(walletId, accountIndex)`. Updated via the +/// `on_persist_shielded_synced_indices_fn` and +/// `on_persist_shielded_nullifier_checkpoints_fn` FFI callbacks; +/// streamed back to Rust on cold start via +/// `on_load_shielded_sync_states_fn` so the rehydrated +/// `SubwalletState` resumes incremental sync from where it left off. +@Model +public final class PersistentShieldedSyncState { + /// Composite uniqueness on `(walletId, accountIndex)` — at + /// most one watermark row per subwallet. + #Unique([\.walletId, \.accountIndex]) + #Index([\.walletId]) + + public var walletId: Data + public var accountIndex: UInt32 + /// Highest global commitment-tree index that the subwallet has scanned. + public var lastSyncedIndex: UInt64 + /// Whether the optional `(height, timestamp)` nullifier + /// checkpoint is populated. SwiftData predicate compilation is + /// finicky around chained optionals; an explicit `Bool` flag + /// keeps the watermark-restore query simple. + public var hasNullifierCheckpoint: Bool + /// Block height of the most recent nullifier sync pass. + /// Meaningful iff `hasNullifierCheckpoint == true`. + public var nullifierCheckpointHeight: UInt64 + /// Block timestamp (Unix seconds) of the most recent pass. + /// Meaningful iff `hasNullifierCheckpoint == true`. + public var nullifierCheckpointTimestamp: UInt64 + + public var lastUpdated: Date + + public init( + walletId: Data, + accountIndex: UInt32, + lastSyncedIndex: UInt64 = 0, + hasNullifierCheckpoint: Bool = false, + nullifierCheckpointHeight: UInt64 = 0, + nullifierCheckpointTimestamp: UInt64 = 0 + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.lastSyncedIndex = lastSyncedIndex + self.hasNullifierCheckpoint = hasNullifierCheckpoint + self.nullifierCheckpointHeight = nullifierCheckpointHeight + self.nullifierCheckpointTimestamp = nullifierCheckpointTimestamp + self.lastUpdated = Date() + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 345ff652779..e35d80ab337 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -781,6 +781,15 @@ public class PlatformWalletPersistenceHandler { cb.on_persist_identity_keys_fn = persistIdentityKeysCallback cb.on_persist_token_balances_fn = persistTokenBalancesCallback cb.on_persist_contacts_fn = persistContactsCallback + cb.on_persist_shielded_notes_fn = persistShieldedNotesCallback + cb.on_persist_shielded_nullifiers_spent_fn = persistShieldedNullifiersSpentCallback + cb.on_persist_shielded_synced_indices_fn = persistShieldedSyncedIndicesCallback + cb.on_persist_shielded_nullifier_checkpoints_fn = + persistShieldedNullifierCheckpointsCallback + cb.on_load_shielded_notes_fn = loadShieldedNotesCallback + cb.on_load_shielded_notes_free_fn = loadShieldedNotesFreeCallback + cb.on_load_shielded_sync_states_fn = loadShieldedSyncStatesCallback + cb.on_load_shielded_sync_states_free_fn = loadShieldedSyncStatesFreeCallback return cb } @@ -2002,6 +2011,311 @@ public class PlatformWalletPersistenceHandler { // MARK: - Watch-only Restore: Wallet Metadata + // MARK: - Shielded persistence (Orchard) + + /// One incoming shielded-note row from + /// `ShieldedChangeSet::notes_saved`. Decoupled from + /// `ShieldedNoteFFI` so the trampoline can copy bytes out + /// before this method runs on `onQueue`. + struct ShieldedNoteSnapshot { + let walletId: Data + let accountIndex: UInt32 + let position: UInt64 + let cmx: Data + let nullifier: Data + let blockHeight: UInt64 + let isSpent: Bool + let value: UInt64 + let noteData: Data + } + + /// Upsert a batch of decrypted shielded notes by `nullifier`. + /// Re-saves with the same nullifier overwrite the existing + /// row in place — Orchard nullifiers are globally unique. + func persistShieldedNotes(walletId: Data, snapshots: [ShieldedNoteSnapshot]) { + onQueue { + for snap in snapshots { + let nf = snap.nullifier + let predicate = #Predicate { $0.nullifier == nf } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + if let existing = try? backgroundContext.fetch(descriptor).first { + existing.walletId = snap.walletId + existing.accountIndex = snap.accountIndex + existing.position = snap.position + existing.cmx = snap.cmx + existing.blockHeight = snap.blockHeight + existing.isSpent = snap.isSpent + existing.value = snap.value + existing.noteData = snap.noteData + existing.lastUpdated = Date() + } else { + let row = PersistentShieldedNote( + walletId: snap.walletId, + accountIndex: snap.accountIndex, + position: snap.position, + cmx: snap.cmx, + nullifier: snap.nullifier, + blockHeight: snap.blockHeight, + isSpent: snap.isSpent, + value: snap.value, + noteData: snap.noteData + ) + backgroundContext.insert(row) + } + } + if !self.inChangeset { try? backgroundContext.save() } + } + } + + /// Mark notes as spent by nullifier. + func persistShieldedNullifiersSpent( + walletId: Data, + entries: [(walletId: Data, accountIndex: UInt32, nullifier: Data)] + ) { + onQueue { + for entry in entries { + let nf = entry.nullifier + let predicate = #Predicate { $0.nullifier == nf } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + if let row = try? backgroundContext.fetch(descriptor).first { + if !row.isSpent { + row.isSpent = true + row.lastUpdated = Date() + } + } + } + if !self.inChangeset { try? backgroundContext.save() } + } + } + + /// Upsert per-subwallet sync watermarks. + func persistShieldedSyncedIndices( + walletId: Data, + entries: [(walletId: Data, accountIndex: UInt32, lastSyncedIndex: UInt64)] + ) { + onQueue { + for entry in entries { + let row = ensureShieldedSyncStateRow( + walletId: entry.walletId, + accountIndex: entry.accountIndex + ) + if entry.lastSyncedIndex > row.lastSyncedIndex { + row.lastSyncedIndex = entry.lastSyncedIndex + } + row.lastUpdated = Date() + } + if !self.inChangeset { try? backgroundContext.save() } + } + } + + /// Upsert per-subwallet nullifier-sync checkpoints. + func persistShieldedNullifierCheckpoints( + walletId: Data, + entries: [(walletId: Data, accountIndex: UInt32, height: UInt64, timestamp: UInt64)] + ) { + onQueue { + for entry in entries { + let row = ensureShieldedSyncStateRow( + walletId: entry.walletId, + accountIndex: entry.accountIndex + ) + row.hasNullifierCheckpoint = true + row.nullifierCheckpointHeight = entry.height + row.nullifierCheckpointTimestamp = entry.timestamp + row.lastUpdated = Date() + } + if !self.inChangeset { try? backgroundContext.save() } + } + } + + /// Fetch-or-create a `PersistentShieldedSyncState` row for + /// `(walletId, accountIndex)`. Caller must be on `onQueue`. + private func ensureShieldedSyncStateRow( + walletId: Data, + accountIndex: UInt32 + ) -> PersistentShieldedSyncState { + let predicate = #Predicate { row in + row.walletId == walletId && row.accountIndex == accountIndex + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + if let row = try? backgroundContext.fetch(descriptor).first { + return row + } + let row = PersistentShieldedSyncState( + walletId: walletId, + accountIndex: accountIndex + ) + backgroundContext.insert(row) + return row + } + + /// Build the host-allocated `ShieldedNoteRestoreFFI` array Rust + /// reads at boot. The allocation is tracked in + /// `shieldedLoadAllocations` and freed by + /// `loadShieldedNotesFree` once Rust hands the pointer back. + func loadShieldedNotes() -> ( + entries: UnsafePointer?, + count: Int, + errored: Bool + ) { + var resultEntries: UnsafePointer? + var resultCount: Int = 0 + var resultErrored = false + onQueue { + let descriptor = FetchDescriptor() + let rows: [PersistentShieldedNote] + do { + rows = try backgroundContext.fetch(descriptor) + } catch { + resultErrored = true + return + } + if rows.isEmpty { + return + } + let allocation = ShieldedLoadAllocation() + // Allocate the entries buffer up front; populate slots + // one by one and track `entriesInitialized` so a + // mid-loop bail-out can deinit only the populated + // slots. (Today nothing fails in this loop, but + // matching the existing `LoadAllocation` pattern keeps + // future field additions safe.) + let buf = UnsafeMutablePointer.allocate(capacity: rows.count) + allocation.entries = buf + allocation.entriesCount = rows.count + for (idx, row) in rows.enumerated() { + guard row.walletId.count == 32 else { continue } + guard row.cmx.count == 32 else { continue } + guard row.nullifier.count == 32 else { continue } + let noteDataBuf = UnsafeMutablePointer.allocate(capacity: row.noteData.count) + row.noteData.copyBytes(to: noteDataBuf, count: row.noteData.count) + allocation.scalarBuffers.append((noteDataBuf, row.noteData.count)) + + var walletIdTuple: FFIByteTuple32 = (0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0) + row.walletId.withUnsafeBytes { src in + Swift.withUnsafeMutableBytes(of: &walletIdTuple) { dst in + dst.copyMemory(from: src) + } + } + var cmxTuple: FFIByteTuple32 = (0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0) + row.cmx.withUnsafeBytes { src in + Swift.withUnsafeMutableBytes(of: &cmxTuple) { dst in + dst.copyMemory(from: src) + } + } + var nullifierTuple: FFIByteTuple32 = (0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0) + row.nullifier.withUnsafeBytes { src in + Swift.withUnsafeMutableBytes(of: &nullifierTuple) { dst in + dst.copyMemory(from: src) + } + } + buf[idx] = ShieldedNoteRestoreFFI( + wallet_id: walletIdTuple, + account_index: row.accountIndex, + position: row.position, + cmx: cmxTuple, + nullifier: nullifierTuple, + block_height: row.blockHeight, + is_spent: row.isSpent ? 1 : 0, + value: row.value, + note_data_ptr: UnsafePointer(noteDataBuf), + note_data_len: UInt(row.noteData.count) + ) + allocation.entriesInitialized += 1 + } + let entriesPtr = UnsafePointer(buf) + shieldedLoadAllocations[UnsafeRawPointer(entriesPtr)] = allocation + resultEntries = entriesPtr + resultCount = allocation.entriesInitialized + } + return (resultEntries, resultCount, resultErrored) + } + + func loadShieldedNotesFree(entries: UnsafeRawPointer?) { + onQueue { + guard let entries = entries, + let allocation = shieldedLoadAllocations.removeValue(forKey: entries) else { + return + } + allocation.release() + } + } + + /// Build the host-allocated `ShieldedSubwalletSyncStateFFI` + /// array Rust reads at boot. Same allocation pattern as + /// `loadShieldedNotes`. + func loadShieldedSyncStates() -> ( + entries: UnsafePointer?, + count: Int, + errored: Bool + ) { + var resultEntries: UnsafePointer? + var resultCount: Int = 0 + var resultErrored = false + onQueue { + let descriptor = FetchDescriptor() + let rows: [PersistentShieldedSyncState] + do { + rows = try backgroundContext.fetch(descriptor) + } catch { + resultErrored = true + return + } + if rows.isEmpty { + return + } + let allocation = ShieldedSyncStateLoadAllocation() + let buf = UnsafeMutablePointer.allocate( + capacity: rows.count + ) + allocation.entries = buf + allocation.entriesCount = rows.count + for (idx, row) in rows.enumerated() { + guard row.walletId.count == 32 else { continue } + var walletIdTuple: FFIByteTuple32 = (0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0) + row.walletId.withUnsafeBytes { src in + Swift.withUnsafeMutableBytes(of: &walletIdTuple) { dst in + dst.copyMemory(from: src) + } + } + buf[idx] = ShieldedSubwalletSyncStateFFI( + wallet_id: walletIdTuple, + account_index: row.accountIndex, + last_synced_index: row.lastSyncedIndex, + has_nullifier_checkpoint: row.hasNullifierCheckpoint ? 1 : 0, + nullifier_checkpoint_height: row.nullifierCheckpointHeight, + nullifier_checkpoint_timestamp: row.nullifierCheckpointTimestamp + ) + allocation.entriesInitialized += 1 + } + let entriesPtr = UnsafePointer(buf) + shieldedSyncStateLoadAllocations[UnsafeRawPointer(entriesPtr)] = allocation + resultEntries = entriesPtr + resultCount = allocation.entriesInitialized + } + return (resultEntries, resultCount, resultErrored) + } + + func loadShieldedSyncStatesFree(entries: UnsafeRawPointer?) { + onQueue { + guard let entries = entries, + let allocation = shieldedSyncStateLoadAllocations.removeValue(forKey: entries) + else { + return + } + allocation.release() + } + } + + /// Outstanding shielded-load allocations keyed by the entries + /// pointer we handed Rust. Drained by `loadShieldedNotesFree`. + private var shieldedLoadAllocations: [UnsafeRawPointer: ShieldedLoadAllocation] = [:] + private var shieldedSyncStateLoadAllocations: + [UnsafeRawPointer: ShieldedSyncStateLoadAllocation] = [:] + /// Set network + birth height on the `PersistentWallet` row. Fires /// once at wallet registration with values the Rust side can /// contribute but Swift can't easily recompute (network is on the @@ -2878,6 +3192,47 @@ private final class LoadAllocation { } } +/// Allocation tracker for `loadShieldedNotes` — the entries +/// buffer plus per-row `note_data` byte buffers. +private final class ShieldedLoadAllocation { + var entries: UnsafeMutablePointer? + var entriesCount: Int = 0 + var entriesInitialized: Int = 0 + /// Per-row `note_data` byte buffers; each entry's + /// `note_data_ptr` references one of these. + var scalarBuffers: [(UnsafeMutablePointer, Int)] = [] + + func release() { + if let entries = entries { + if entriesInitialized > 0 { + entries.deinitialize(count: entriesInitialized) + } + entries.deallocate() + } + for (ptr, _) in scalarBuffers { + ptr.deallocate() + } + } +} + +/// Allocation tracker for `loadShieldedSyncStates`. No nested +/// buffers — every field is plain-data — so this is just the +/// entries buffer. +private final class ShieldedSyncStateLoadAllocation { + var entries: UnsafeMutablePointer? + var entriesCount: Int = 0 + var entriesInitialized: Int = 0 + + func release() { + if let entries = entries { + if entriesInitialized > 0 { + entries.deinitialize(count: entriesInitialized) + } + entries.deallocate() + } + } +} + /// Copy bytes from `src` into a fixed-size C-tuple field. Swift /// imports `u8[N]` as an N-tuple — identical memory layout, so /// `withUnsafeMutableBytes` gives us a contiguous write window of @@ -3555,3 +3910,192 @@ private func persistWalletMetadataCallback( ) return 0 } + +// MARK: - Shielded persistence (Orchard) +// +// Mirror of the four `on_persist_shielded_*_fn` callbacks declared +// in `rs-platform-wallet-ffi/src/persistence.rs` plus the matching +// load callbacks used at boot to rehydrate `SubwalletState`s. + +private func persistShieldedNotesCallback( + context: UnsafeMutableRawPointer?, + walletIdPtr: UnsafePointer?, + entriesPtr: UnsafePointer?, + count: UInt +) -> Int32 { + guard let context = context, let walletIdPtr = walletIdPtr else { return 0 } + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + let walletId = Data(bytes: walletIdPtr, count: 32) + + var snapshots: [PlatformWalletPersistenceHandler.ShieldedNoteSnapshot] = [] + if count > 0, let entriesPtr = entriesPtr { + snapshots.reserveCapacity(Int(count)) + for i in 0.. 0 { + noteData = Data(bytes: dataPtr, count: Int(e.note_data_len)) + } else { + noteData = Data() + } + snapshots.append(.init( + walletId: dataFromTuple32(e.wallet_id), + accountIndex: e.account_index, + position: e.position, + cmx: dataFromTuple32(e.cmx), + nullifier: dataFromTuple32(e.nullifier), + blockHeight: e.block_height, + isSpent: e.is_spent != 0, + value: e.value, + noteData: noteData + )) + } + } + handler.persistShieldedNotes(walletId: walletId, snapshots: snapshots) + return 0 +} + +private func persistShieldedNullifiersSpentCallback( + context: UnsafeMutableRawPointer?, + walletIdPtr: UnsafePointer?, + entriesPtr: UnsafePointer?, + count: UInt +) -> Int32 { + guard let context = context, let walletIdPtr = walletIdPtr else { return 0 } + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + let walletId = Data(bytes: walletIdPtr, count: 32) + + var entries: [(walletId: Data, accountIndex: UInt32, nullifier: Data)] = [] + if count > 0, let entriesPtr = entriesPtr { + entries.reserveCapacity(Int(count)) + for i in 0..?, + entriesPtr: UnsafePointer?, + count: UInt +) -> Int32 { + guard let context = context, let walletIdPtr = walletIdPtr else { return 0 } + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + let walletId = Data(bytes: walletIdPtr, count: 32) + + var entries: [(walletId: Data, accountIndex: UInt32, lastSyncedIndex: UInt64)] = [] + if count > 0, let entriesPtr = entriesPtr { + entries.reserveCapacity(Int(count)) + for i in 0..?, + entriesPtr: UnsafePointer?, + count: UInt +) -> Int32 { + guard let context = context, let walletIdPtr = walletIdPtr else { return 0 } + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + let walletId = Data(bytes: walletIdPtr, count: 32) + + var entries: [(walletId: Data, accountIndex: UInt32, height: UInt64, timestamp: UInt64)] = [] + if count > 0, let entriesPtr = entriesPtr { + entries.reserveCapacity(Int(count)) + for i in 0..?>?, + outCount: UnsafeMutablePointer? +) -> Int32 { + guard let context = context, let outEntries = outEntries, let outCount = outCount else { + return 1 + } + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + let (entries, count, errored) = handler.loadShieldedNotes() + outEntries.pointee = entries + outCount.pointee = UInt(count) + return errored ? 1 : 0 +} + +private func loadShieldedNotesFreeCallback( + context: UnsafeMutableRawPointer?, + entries: UnsafePointer?, + _ count: UInt +) { + guard let context = context else { return } + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + handler.loadShieldedNotesFree(entries: entries.map(UnsafeRawPointer.init)) +} + +private func loadShieldedSyncStatesCallback( + context: UnsafeMutableRawPointer?, + outEntries: UnsafeMutablePointer?>?, + outCount: UnsafeMutablePointer? +) -> Int32 { + guard let context = context, let outEntries = outEntries, let outCount = outCount else { + return 1 + } + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + let (entries, count, errored) = handler.loadShieldedSyncStates() + outEntries.pointee = entries + outCount.pointee = UInt(count) + return errored ? 1 : 0 +} + +private func loadShieldedSyncStatesFreeCallback( + context: UnsafeMutableRawPointer?, + entries: UnsafePointer?, + _ count: UInt +) { + guard let context = context else { return } + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + handler.loadShieldedSyncStatesFree(entries: entries.map(UnsafeRawPointer.init)) +} From 589ee680231f64f0b19790d1c80defa4c968d523 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 6 May 2026 16:51:39 +0700 Subject: [PATCH 14/78] feat(swift-example-app): storage explorer rows for shielded notes + sync state Adds two read-only browsers next to the existing "TXOs" / "Pending Inputs" / etc. rows in the Storage Explorer: "Shielded Notes" (per-(wallet, account) decrypted notes, spent/unspent filterable) and "Shielded Sync State" (per- subwallet `last_synced_index` + nullifier checkpoint). Both scoped to the active network via the `walletId` denorm on the row, matching the pattern `TxoStorageListView` uses. Also wires the matching count entries into `loadCounts()` so the row counts on the Storage Explorer index page reflect the new tables. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Views/StorageExplorerView.swift | 16 ++ .../Views/StorageModelListViews.swift | 179 ++++++++++++++++++ 2 files changed, 195 insertions(+) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift index 6297ebdf2bf..459e32186f0 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift @@ -113,6 +113,16 @@ struct StorageExplorerView: View { modelRow("Manager Metadata", icon: "gearshape.2", type: PersistentWalletManagerMetadata.self) { WalletManagerMetadataStorageListView(network: network) } + modelRow("Shielded Notes", icon: "lock.shield", type: PersistentShieldedNote.self) { + ShieldedNoteStorageListView(network: network) + } + modelRow( + "Shielded Sync State", + icon: "arrow.triangle.2.circlepath", + type: PersistentShieldedSyncState.self + ) { + ShieldedSyncStateStorageListView(network: network) + } } .navigationTitle("Storage Explorer") .toolbar { @@ -249,6 +259,12 @@ struct StorageExplorerView: View { filteredCount(PersistentPendingInput.self) { walletsOnNetwork.contains($0.walletId) } + filteredCount(PersistentShieldedNote.self) { + walletsOnNetwork.contains($0.walletId) + } + filteredCount(PersistentShieldedSyncState.self) { + walletsOnNetwork.contains($0.walletId) + } // Core / Platform addresses partition the same family of // tables by account type, so they need their own counts. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift index 08c4e4c2f27..2fee807e856 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift @@ -1631,3 +1631,182 @@ struct WalletManagerMetadataStorageListView: View { .overlay { if visible.isEmpty { ContentUnavailableView("No Records", systemImage: "gearshape.2") } } } } + +// MARK: - PersistentShieldedNote + +/// Filter enum local to this view — mirrors the private one +/// inside `TxoStorageListView`. Both views need the same +/// "all / unspent / spent" segmented control; duplicating two +/// lines beats hoisting the private type to file scope and +/// touching the existing TXO view. +private enum ShieldedSpentFilter: CaseIterable, Hashable { + case all, unspent, spent + + var title: String { + switch self { + case .all: return "All" + case .unspent: return "Unspent" + case .spent: return "Spent" + } + } +} + +/// Read-only browser for the per-(wallet, account) decrypted +/// shielded notes the persister mirrors out of +/// `ShieldedChangeSet`. Scoped by the active network via the +/// denormalized `walletId` column on each row — same trick +/// `TxoStorageListView` uses. +struct ShieldedNoteStorageListView: View { + let network: Network + + /// Sort by block height (newest first), then position so + /// rows from the same block stay deterministic. + @Query( + sort: [ + SortDescriptor(\PersistentShieldedNote.blockHeight, order: .reverse), + SortDescriptor(\PersistentShieldedNote.position), + ] + ) + private var records: [PersistentShieldedNote] + + @Query private var allWallets: [PersistentWallet] + + private var walletIdsOnNetwork: Set { + Set(allWallets.lazy + .filter { $0.networkRaw == network.rawValue } + .map(\.walletId)) + } + + private var scopedRecords: [PersistentShieldedNote] { + let ids = walletIdsOnNetwork + return records.filter { ids.contains($0.walletId) } + } + + @State private var filter: ShieldedSpentFilter = .all + + private var filteredRecords: [PersistentShieldedNote] { + let scoped = scopedRecords + switch filter { + case .all: return scoped + case .unspent: return scoped.filter { !$0.isSpent } + case .spent: return scoped.filter { $0.isSpent } + } + } + + var body: some View { + let scoped = scopedRecords + let visible = filteredRecords + List { + Section { + Picker("Filter", selection: $filter) { + ForEach(ShieldedSpentFilter.allCases, id: \.self) { f in + Text(f.title).tag(f) + } + } + .pickerStyle(.segmented) + } + if !scoped.isEmpty && visible.isEmpty { + Section { + ContentUnavailableView( + "No \(filter.title) Notes", + systemImage: "lock.shield" + ) + } + } + ForEach(visible) { record in + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text("acct \(record.accountIndex)") + .font(.caption2) + .foregroundColor(.secondary) + Text("pos \(record.position)") + .font(.caption2) + .foregroundColor(.secondary) + if record.blockHeight > 0 { + Text("h \(record.blockHeight)") + .font(.caption2) + .foregroundColor(.secondary) + } + Spacer() + if record.isSpent { + Text("spent") + .font(.caption2) + .foregroundColor(.red) + } + } + Text("\(record.value) credits") + .font(.caption) + Text(record.nullifier.prefix(8).map { String(format: "%02x", $0) }.joined()) + .font(.system(.caption2, design: .monospaced)) + .foregroundColor(.secondary) + } + } + } + .navigationTitle("Shielded Notes (\(visible.count))") + .overlay { + if visible.isEmpty { + ContentUnavailableView("No Notes", systemImage: "lock.shield") + } + } + } +} + +// MARK: - PersistentShieldedSyncState + +struct ShieldedSyncStateStorageListView: View { + let network: Network + + // SwiftData's `SortDescriptor` doesn't accept `Data` fields + // (Data isn't Comparable), so sort only by `accountIndex` + // and let the wallet-id grouping fall out of insertion + // order — there are at most a handful of rows per device. + @Query(sort: [SortDescriptor(\PersistentShieldedSyncState.accountIndex)]) + private var records: [PersistentShieldedSyncState] + + @Query private var allWallets: [PersistentWallet] + + private var walletIdsOnNetwork: Set { + Set(allWallets.lazy + .filter { $0.networkRaw == network.rawValue } + .map(\.walletId)) + } + + private var scopedRecords: [PersistentShieldedSyncState] { + let ids = walletIdsOnNetwork + return records.filter { ids.contains($0.walletId) } + } + + var body: some View { + let visible = scopedRecords + List { + ForEach(visible) { record in + VStack(alignment: .leading, spacing: 4) { + HStack { + Text( + record.walletId.prefix(4) + .map { String(format: "%02x", $0) }.joined() + ) + .font(.system(.caption2, design: .monospaced)) + Text("acct \(record.accountIndex)") + .font(.caption2) + .foregroundColor(.secondary) + Spacer() + } + Text("synced index: \(record.lastSyncedIndex)") + .font(.caption) + if record.hasNullifierCheckpoint { + Text("nf: h \(record.nullifierCheckpointHeight) · ts \(record.nullifierCheckpointTimestamp)") + .font(.caption2) + .foregroundColor(.secondary) + } + } + } + } + .navigationTitle("Shielded Sync State (\(visible.count))") + .overlay { + if visible.isEmpty { + ContentUnavailableView("No Sync States", systemImage: "arrow.triangle.2.circlepath") + } + } + } +} From 9ab48e3c8713cbd24673b2ac499ee4d15313e3f4 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 6 May 2026 16:55:49 +0700 Subject: [PATCH 15/78] feat(swift-example-app): multi-account shielded UI in WalletDetailView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ShieldedService.bind(...)` now takes `accounts: [UInt32]` (default `[0]`); after a successful Rust-side `bindShielded` it populates `boundAccounts` and `addressesByAccount` by calling `shieldedDefaultAddress` per bound account. The legacy `orchardDisplayAddress` is preserved as the lowest-bound account's address so the existing single-account Receive sheet keeps working. `AccountListView` grows a "Shielded" section that mirrors the existing Core / Platform Payment account rows. One row per bound ZIP-32 account showing `Shielded #N` plus the truncated bech32m address, driven by `shieldedService.boundAccounts` / `addressesByAccount`. The whole-wallet "Shielded Balance" row on the balance card stays as-is for now since the FFI sync event still flattens balance to the wallet level; per-account balance breakdown needs a follow-up FFI lookup (`platform_wallet_manager_shielded_balance(walletId, account)`). `reset()` clears the new published fields so wallet switches don't leak the prior wallet's accounts/addresses into the new detail view. This is the third leg of the multi-account refactor (Rust internals + persistence + UI); the "Add account" affordance itself is deferred — it needs a new `shielded_add_account` FFI that re-uses the bind path's mnemonic resolver. Hosts can already bind multiple accounts up front by passing `accounts: [0, 1, …]` to `bind`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Services/ShieldedService.swift | 75 ++++++++++----- .../Core/Views/AccountListView.swift | 92 ++++++++++++++++--- 2 files changed, 134 insertions(+), 33 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift index 2cc3b85012a..45bc2aef058 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -57,11 +57,22 @@ class ShieldedService: ObservableObject { /// pass. @Published var lastError: String? - /// Bech32m-encoded Orchard payment address. Currently a - /// placeholder — the manager doesn't expose the per-wallet - /// address yet (defer until bundle building lands). + /// Bech32m-encoded Orchard payment address for account 0. + /// Kept for the existing Receive sheet which is still + /// single-account; multi-account-aware UI uses + /// `addressesByAccount` instead. @Published var orchardDisplayAddress: String? + /// Bound shielded ZIP-32 accounts, in ascending order. Driven + /// by `bind` — every entry of `accounts:` becomes a row here. + @Published var boundAccounts: [UInt32] = [] + + /// Bech32m-encoded Orchard payment address per bound account. + /// Populated alongside `boundAccounts` from per-account + /// `shieldedDefaultAddress` calls. Empty for accounts that + /// failed to bind. + @Published var addressesByAccount: [UInt32: String] = [:] + // MARK: - Internals /// Wallet manager whose shielded sync events we mirror. @@ -90,8 +101,15 @@ class ShieldedService: ObservableObject { /// Bind the service to a wallet. Drives `bindShielded` on the /// Rust side first (resolver-driven mnemonic lookup, ZIP-32 - /// derivation, per-network commitment tree open) and then - /// subscribes to shielded sync events for `walletId`. + /// derivation per `accounts`, per-network commitment tree + /// open) and then subscribes to shielded sync events for + /// `walletId`. + /// + /// `accounts` is the list of ZIP-32 account indices to bind. + /// Defaults to `[0]` for the single-account default; pass + /// `[0, 1, …]` to bind multiple accounts up front. Each + /// gets its own subwallet bookkeeping inside the store; the + /// commitment tree is shared per network. /// /// Failure during the Rust-side bind sets `lastError`; the /// service continues to subscribe to events so a successful @@ -100,7 +118,8 @@ class ShieldedService: ObservableObject { walletManager: PlatformWalletManager, walletId: Data, network: Network, - resolver: MnemonicResolver + resolver: MnemonicResolver, + accounts: [UInt32] = [0] ) { self.walletManager = walletManager self.walletId = walletId @@ -125,38 +144,50 @@ class ShieldedService: ObservableObject { lastSyncTime = nil lastError = nil orchardDisplayAddress = nil + boundAccounts = [] + addressesByAccount = [:] syncCountSinceLaunch = 0 totalScanned = 0 totalNewNotes = 0 totalNewlySpent = 0 let dbPath = Self.dbPath(for: network) + let sortedAccounts = Array(Set(accounts)).sorted() do { try walletManager.bindShielded( walletId: walletId, resolver: resolver, - accounts: [0], + accounts: sortedAccounts, dbPath: dbPath ) isBound = true lastError = nil - - // Pull the default Orchard payment address now that bind - // succeeded so the Receive sheet has something to render - // before the first sync pass lands. Best-effort — - // failures here don't unbind the wallet. - if let raw = try? walletManager.shieldedDefaultAddress( - walletId: walletId, - account: 0 - ) { - orchardDisplayAddress = DashAddress.encodeOrchard( - rawBytes: raw, - network: network - ) + boundAccounts = sortedAccounts + + // Populate per-account default addresses. Best-effort — + // a failure on any one account leaves that entry + // missing from `addressesByAccount` (the row in the UI + // shows blank) but doesn't unbind the wallet. + for account in sortedAccounts { + if let raw = try? walletManager.shieldedDefaultAddress( + walletId: walletId, + account: account + ) { + addressesByAccount[account] = DashAddress.encodeOrchard( + rawBytes: raw, + network: network + ) + } } + // Backwards-compat: `orchardDisplayAddress` still drives + // the existing Receive sheet which only renders one + // address. Use account 0 if bound, else the lowest + // bound account. + let primary = sortedAccounts.contains(0) ? 0 : (sortedAccounts.first ?? 0) + orchardDisplayAddress = addressesByAccount[primary] SDKLogger.log( - "Shielded bound: walletId=\(walletId.prefix(4).map { String(format: "%02x", $0) }.joined())… network=\(network.networkName) tree=\(dbPath)", + "Shielded bound: walletId=\(walletId.prefix(4).map { String(format: "%02x", $0) }.joined())… network=\(network.networkName) accounts=\(sortedAccounts) tree=\(dbPath)", minimumLevel: .medium ) } catch { @@ -254,6 +285,8 @@ class ShieldedService: ObservableObject { lastSyncTime = nil lastError = nil orchardDisplayAddress = nil + boundAccounts = [] + addressesByAccount = [:] syncCountSinceLaunch = 0 totalScanned = 0 totalNewNotes = 0 diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift index 8732d91423d..27a2a1ab014 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift @@ -6,6 +6,7 @@ import SwiftData struct AccountListView: View { let wallet: PersistentWallet @EnvironmentObject var walletManager: PlatformWalletManager + @EnvironmentObject var shieldedService: ShieldedService @Query private var accounts: [PersistentAccount] @@ -53,9 +54,21 @@ struct AccountListView: View { return (group, account.accountType, account.standardTag, account.accountIndex) } + /// Bound shielded accounts to render in their own section + /// below the Core / Platform accounts. Empty until + /// `ShieldedService.bind` has populated the list — which + /// happens once per wallet detail open. + private var shieldedAccountsForThisWallet: [UInt32] { + // Filter by wallet id so navigating between wallet + // details doesn't briefly show the previous wallet's + // accounts before the singleton service rebinds. + guard shieldedService.boundAccounts.isEmpty == false else { return [] } + return shieldedService.boundAccounts + } + var body: some View { ZStack { - if accounts.isEmpty { + if accounts.isEmpty && shieldedAccountsForThisWallet.isEmpty { ContentUnavailableView( "No Accounts", systemImage: "folder", @@ -63,18 +76,36 @@ struct AccountListView: View { ) } else { let balances = walletManager.accountBalances(for: wallet.walletId) - List(orderedAccounts) { account in - NavigationLink(destination: AccountDetailView(wallet: wallet, account: account)) { - let match = balances.first { b in - UInt32(b.typeTag) == account.accountType && - b.standardTag == account.standardTag && - b.index == account.accountIndex + List { + if !accounts.isEmpty { + Section { + ForEach(orderedAccounts) { account in + NavigationLink( + destination: AccountDetailView(wallet: wallet, account: account) + ) { + let match = balances.first { b in + UInt32(b.typeTag) == account.accountType && + b.standardTag == account.standardTag && + b.index == account.accountIndex + } + AccountRowView( + account: account, + coreConfirmedBalance: match?.confirmed ?? 0, + coreUnconfirmedBalance: match?.unconfirmed ?? 0 + ) + } + } + } + } + if !shieldedAccountsForThisWallet.isEmpty { + Section("Shielded") { + ForEach(shieldedAccountsForThisWallet, id: \.self) { account in + ShieldedAccountRowView( + accountIndex: account, + address: shieldedService.addressesByAccount[account] + ) + } } - AccountRowView( - account: account, - coreConfirmedBalance: match?.confirmed ?? 0, - coreUnconfirmedBalance: match?.unconfirmed ?? 0 - ) } } .listStyle(.plain) @@ -83,6 +114,43 @@ struct AccountListView: View { } } +// MARK: - Shielded Account Row + +/// Compact row that mirrors `AccountRowView` for shielded ZIP-32 +/// accounts. There's no `PersistentShieldedAccount` SwiftData +/// model — bound accounts live on `ShieldedService.boundAccounts` +/// — so the row is purely a display projection of `(index, +/// address)`. +private struct ShieldedAccountRowView: View { + let accountIndex: UInt32 + let address: String? + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "lock.shield.fill") + .foregroundColor(.purple) + .font(.title3) + VStack(alignment: .leading, spacing: 2) { + Text("Shielded #\(accountIndex)") + .font(.subheadline) + .fontWeight(.medium) + if let address { + Text(address) + .font(.system(.caption2, design: .monospaced)) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } else { + Text("address not available") + .font(.caption2) + .foregroundColor(.secondary) + .italic() + } + } + } + } +} + // MARK: - Account Row View struct AccountRowView: View { let account: PersistentAccount From 2daf3330eeeb170471485433aac50ef38c31176b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 6 May 2026 17:13:17 +0700 Subject: [PATCH 16/78] fix(platform-wallet): derive shielded spend anchor from witness paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Orchard spend builder rejected proofs with `AnchorMismatch: failed to add spend` because the anchor we passed in (read via `store.tree_anchor()` → `ClientPersistentCommitmentTree::anchor()` → `root_at_checkpoint_depth(None)`) reflected the latest tree state, while each witness was generated by `witness_at_checkpoint_depth(0)` — the root of the most recent checkpoint. Whenever the two depths diverged (e.g. commitments appended after the last checkpoint, or any sequencing where "latest" got ahead of "depth 0") the builder rejected the bundle. Derive the anchor from the witness paths themselves via `MerklePath::root(extracted_cmx)`. By construction that's the root the witness will verify against inside the Halo 2 proof, so it can't disagree with the bundle. Also catches the case where multiple selected notes' witnesses came from different checkpoints (returns `ShieldedBuildError` immediately instead of letting the spend builder surface `AnchorMismatch` after the ~30 s proof generation). `store.tree_anchor()` is no longer called from the spend pipeline; the trait method stays in place for diagnostics. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/wallet/shielded/operations.rs | 63 +++++++++++++++---- 1 file changed, 50 insertions(+), 13 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 9837d96e702..65f5b016cec 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -465,16 +465,31 @@ impl ShieldedWallet { payment_address_to_orchard(&keys.default_address) } - /// Extract `SpendableNote` structs with Merkle witnesses and the - /// tree anchor. The tree is shared per-network; only note - /// selection is per-subwallet (already done by the caller). + /// Extract `SpendableNote` structs with Merkle witnesses and + /// the tree anchor. + /// + /// The anchor is derived from the witness paths themselves + /// (via `MerklePath::root(cmx)`) rather than from + /// `store.tree_anchor()`. The store's witness call is + /// `witness_at_checkpoint_depth(0)` (root of the most recent + /// checkpoint) while `tree_anchor()` is + /// `root_at_checkpoint_depth(None)` (latest tree state) — + /// any commitments appended after the last checkpoint move + /// the latter ahead of the former, and the resulting + /// `AnchorMismatch` from the Orchard spend builder is what + /// you'd see at proof time. Using the witness's own + /// computed root keeps the anchor consistent with the + /// authentication paths the proof actually verifies. async fn extract_spends_and_anchor( &self, notes: &[ShieldedNote], ) -> Result<(Vec, Anchor), PlatformWalletError> { + use grovedb_commitment_tree::ExtractedNoteCommitment; + let store = self.store.read().await; let mut spends = Vec::with_capacity(notes.len()); + let mut anchor: Option = None; for note in notes { let orchard_note = deserialize_note(¬e.note_data).ok_or_else(|| { PlatformWalletError::ShieldedBuildError(format!( @@ -493,22 +508,44 @@ impl ShieldedWallet { )) })?; + // Compute the anchor this witness was generated + // against. All selected notes must share the same + // anchor — if not, the store handed us witnesses + // from different checkpoints, which the spend + // builder would reject downstream with + // `AnchorMismatch`. Surface the mismatch here so the + // host doesn't pay the ~30 s proof cost first. + let cmx = ExtractedNoteCommitment::from_bytes(¬e.cmx) + .into_option() + .ok_or_else(|| { + PlatformWalletError::ShieldedBuildError(format!( + "invalid stored cmx for note at position {}", + note.position + )) + })?; + let witness_anchor = merkle_path.root(cmx); + match &anchor { + None => anchor = Some(witness_anchor), + Some(prev) if prev.to_bytes() != witness_anchor.to_bytes() => { + return Err(PlatformWalletError::ShieldedBuildError(format!( + "witness anchor mismatch across selected notes (position {})", + note.position + ))); + } + _ => {} + } + spends.push(SpendableNote { note: orchard_note, merkle_path, }); } - let anchor_bytes = store - .tree_anchor() - .map_err(|e| PlatformWalletError::ShieldedMerkleWitnessUnavailable(e.to_string()))?; - let anchor = Anchor::from_bytes(anchor_bytes) - .into_option() - .ok_or_else(|| { - PlatformWalletError::ShieldedBuildError( - "Invalid anchor bytes from commitment tree".to_string(), - ) - })?; + let anchor = anchor.ok_or_else(|| { + PlatformWalletError::ShieldedBuildError( + "no spendable notes selected — anchor undefined".to_string(), + ) + })?; Ok((spends, anchor)) } From 44f9f57c42df43db9320ae06e06e68f2101f96e0 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 6 May 2026 17:38:47 +0700 Subject: [PATCH 17/78] fix(platform-wallet): use monotonic checkpoint id in shielded sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shardtree's `checkpoint(id)` silently dedups duplicate ids — a second `checkpoint(N)` call when checkpoint `N` already exists returns false (no-op) and the depth-0 view of the tree stays pinned at the first call's state. Sync was passing `result.next_start_index as u32` as the id, which the SDK rewinds to the last partial chunk's start so it can re-fetch that chunk on the next sync. Consecutive syncs that all ended on a partial chunk passed the SAME id; only the first checkpoint took, every subsequent one was a no-op even though each sync DID append fresh commitments. The witness computed at depth 0 then reflected an old tree state — its root was a snapshot Platform never recorded as a block-end anchor, and broadcast failed with `Anchor not found in the recorded anchors tree`. Switch to the high-water position (`aligned_start + total_notes_scanned` — one past the last appended) as the checkpoint id. Each sync that appends gets a strictly-greater id than the previous, depth 0 advances to the latest tree state, the witness's root tracks Platform's most recent recorded anchor, and broadcast validates. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/wallet/shielded/sync.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs index 94850596af6..b04410a87fe 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs @@ -199,7 +199,21 @@ impl ShieldedWallet { } if appended > 0 { - let checkpoint_id = result.next_start_index as u32; + // Use the high-water position (`aligned_start + + // total_notes_scanned` — i.e. one past the last + // appended position) as the checkpoint id rather than + // `result.next_start_index`, which rewinds to the last + // partial chunk's start and can therefore be the same + // value across consecutive syncs. shardtree's + // `checkpoint(id)` silently dedups duplicate ids, so + // a non-monotonic id leaves depth-0 pinned at the + // first checkpoint while later appends extend the + // tree past it. The witness at depth 0 then reflects + // an old state whose root Platform never recorded, + // and the bundle's anchor fails the + // `validate_anchor_exists` check on broadcast. + let new_index = aligned_start + result.total_notes_scanned; + let checkpoint_id: u32 = new_index.try_into().unwrap_or(u32::MAX); store .checkpoint_tree(checkpoint_id) .map_err(|e| PlatformWalletError::ShieldedTreeUpdateFailed(e.to_string()))?; From 515a69448a74bc41ae1723393e9d46dbf89312b2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 6 May 2026 18:22:03 +0700 Subject: [PATCH 18/78] fix(swift-example-app): route orphan recovery to the wallet's intended network MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `recoverWallet` was calling `walletManager.createWallet(network:)` on the env-injected active manager — bound to whatever network the user happened to be viewing (typically testnet). Even with the correct `network` parameter threaded into the FFI, the wallet ended up registered inside the active manager and its persister callback fired through that manager's `PlatformWalletPersistenceHandler`, pinning the SwiftData row's `networkRaw` to the active network instead of the wallet's actual one. Result: every recovered orphan landed on whichever network was visible at recovery time. Add `WalletManagerStore.getOrCreateManager(network:sdk:)` that lazily spins up the manager for any network — same configure + load-from-persistor side effects as `activate`, but doesn't change `activeManager` so a multi-network recovery doesn't flicker the user's UI between networks. Inject the store as an environment object so `ContentView` can reach it. `recoverWallet` now builds an SDK for `restoredNetwork`, asks the store for the matching manager, and routes the createWallet call through it. The wallet ends up registered in the right manager, the persister callback fires through that manager's handler, and the SwiftData row gets the correct `networkRaw`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SwiftExampleApp/ContentView.swift | 29 ++++++++++++++++- .../SwiftExampleApp/SwiftExampleAppApp.swift | 8 +++++ .../SwiftExampleApp/WalletManagerStore.swift | 31 +++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift index 9528791342d..84c21b02f5f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift @@ -13,6 +13,7 @@ struct ContentView: View { let onRetry: () -> Void @EnvironmentObject var walletManager: PlatformWalletManager + @EnvironmentObject var walletManagerStore: WalletManagerStore @EnvironmentObject var appUIState: AppUIState @EnvironmentObject var platformState: AppState @Environment(\.modelContext) private var modelContext @@ -427,8 +428,34 @@ struct ContentView: View { entry.network ?? metadata?.resolvedNetworks.first ?? platformState.currentNetwork let restoredBirthHeight = metadata?.birthHeight + // Route the create call through the wallet's + // intended-network manager, NOT the user's currently-active + // one. `walletManager` (the env-injected active manager) is + // bound to whatever network the user happens to be looking + // at; calling `createWallet(network: restoredNetwork)` on it + // when those don't match registers the wallet inside the + // wrong manager, and the wallet's persister callback fires + // through that manager's persistence handler — pinning the + // SwiftData row to the active manager's network instead of + // the wallet's actual one. Result before this fix: every + // recovered wallet landed on whichever network the user + // was looking at (typically testnet), regardless of what + // the keychain metadata recorded. + let recoveryManager: PlatformWalletManager do { - let managed = try walletManager.createWallet( + let networkSdk = try SDK(network: restoredNetwork) + recoveryManager = try walletManagerStore.getOrCreateManager( + network: restoredNetwork, + sdk: networkSdk + ) + } catch { + recoveryError = "Failed to prepare \(restoredNetwork.displayName) wallet manager: " + + error.localizedDescription + return false + } + + do { + let managed = try recoveryManager.createWallet( mnemonic: mnemonic, network: restoredNetwork, name: restoredName diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index bc506860ff2..cc95f7666eb 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -107,6 +107,14 @@ struct SwiftExampleAppApp: App { // PlatformWalletManager` consumers see the right // network's manager without any view changes. .environmentObject(walletManager) + // Inject the store itself so flows that need to + // operate on a non-active network's manager + // (orphan-mnemonic recovery — wallets restored + // from keychain may belong to networks the user + // isn't currently looking at) can route through + // `getOrCreateManager(network:sdk:)` without + // flipping the user's active view. + .environmentObject(walletManagerStore) .environmentObject(shieldedService) .environmentObject(platformBalanceSyncService) .environmentObject(transitionState) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift index 28d8d32626a..5ca994ea072 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift @@ -112,4 +112,35 @@ final class WalletManagerStore: ObservableObject { func manager(for network: Network) -> PlatformWalletManager? { managers[network] } + + /// Get or create the manager for `network` **without** changing + /// `activeManager`. Used by flows that need to operate on a + /// specific network's manager outside the user's current view — + /// e.g. orphan-mnemonic recovery, where wallets restored from + /// keychain metadata may belong to networks the user isn't + /// currently looking at and switching the active network for + /// each one would flicker the UI. + /// + /// Same configure / load-from-persistor side effects as + /// [`activate`]: a fresh manager comes up via + /// `manager.configure(sdk:modelContainer:)` and then + /// `manager.loadFromPersistor()`. Failures propagate to the + /// caller and the cache is left untouched. + func getOrCreateManager(network: Network, sdk: SDK) throws -> PlatformWalletManager { + if let existing = managers[network] { + return existing + } + let manager = PlatformWalletManager() + try manager.configure(sdk: sdk, modelContainer: modelContainer) + do { + _ = try manager.loadFromPersistor() + } catch { + SDKLogger.error( + "WalletManagerStore: load-from-persistor failed for " + + "\(network.displayName): \(error.localizedDescription)" + ) + } + managers[network] = manager + return manager + } } From a3f4edd177a1618e199cde5eb8fa5fa922d81c9c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 6 May 2026 19:22:56 +0700 Subject: [PATCH 19/78] fix(swift-example-app): aggregate orphan-recovery failures with actionable messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recovery surfaced only "Failed to recover wallet" with no detail when an SDK spin-up failed for a local-only network (regtest / devnet) — the user couldn't tell whether their local stack was down, the manager couldn't configure, or createWallet itself rejected the mnemonic. `recoverWallet` now returns `String?` (nil on success, message on failure) and splits the failure surface into three distinct cases: SDK-init error (with a "is your local stack running?" hint when the network is regtest or devnet), manager get-or-create error, and createWallet error. `authorizeAndRecover` aggregates per-wallet failures into the existing `perWalletFailures` array — moved up so the shared-prompt loop can append to it too — and joins them into one combined alert at the end of the run, matching the auth-failure aggregation pattern that was already there. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SwiftExampleApp/ContentView.swift | 84 +++++++++++-------- 1 file changed, 51 insertions(+), 33 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift index 84c21b02f5f..95ad0919643 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift @@ -273,6 +273,13 @@ struct ContentView: View { let separate = choices.filter { !$0.samePinCode } var recovered: Set = [] + // Both auth failures (per-wallet biometric prompt errors) and + // recovery failures (SDK init / manager prep / createWallet + // errors returned from `recoverWallet`) accumulate here so the + // user sees every problem at the end rather than the last one + // overwriting earlier messages. + var perWalletFailures: [String] = [] + if !shared.isEmpty { let reason = shared.count == 1 ? "Re-derive your wallet from the stored recovery phrase." @@ -280,7 +287,9 @@ struct ContentView: View { switch await runAuthPrompt(reason: reason) { case .authorized: for entry in shared { - if await recoverWallet(entry: entry) { + if let failure = await recoverWallet(entry: entry) { + perWalletFailures.append(failure) + } else { recovered.insert(entry.walletId) } } @@ -301,17 +310,13 @@ struct ContentView: View { } } - // Per-wallet auth failures accumulate so the user sees every - // one when the loop ends rather than the last one clobbering - // earlier messages. Mirrors the same `[String]` pattern - // `deleteStoredMnemonics` uses for cross-wallet error - // aggregation. - var perWalletFailures: [String] = [] for entry in separate { let reason = "Re-derive \"\(entry.displayName)\" from its stored recovery phrase." switch await runAuthPrompt(reason: reason) { case .authorized: - if await recoverWallet(entry: entry) { + if let failure = await recoverWallet(entry: entry) { + perWalletFailures.append(failure) + } else { recovered.insert(entry.walletId) } case .denied: @@ -336,8 +341,8 @@ struct ContentView: View { // One combined prompt at the end, joining every wallet's // failure into one message so none get lost. let prefix = perWalletFailures.count == 1 - ? "Authorization failed: " - : "Authorization failed for \(perWalletFailures.count) wallets:\n" + ? "Recovery failed: " + : "Recovery failed for \(perWalletFailures.count) wallets:\n" recoveryError = prefix + perWalletFailures.joined(separator: "\n") } @@ -390,17 +395,20 @@ struct ContentView: View { } /// Read the keychain mnemonic + metadata for `entry`, then - /// re-create the wallet. Returns `true` on success so the caller - /// can drop the entry from the orphan set. + /// re-create the wallet. Returns `nil` on success or the + /// failure message on failure. The caller aggregates failures + /// across multiple recoveries — relying on `recoveryError` + /// (a single `String?`) loses every error but the last when a + /// multi-wallet recovery has more than one failure. @MainActor - private func recoverWallet(entry: OrphanWalletEntry) async -> Bool { + private func recoverWallet(entry: OrphanWalletEntry) async -> String? { let storage = WalletStorage() let mnemonic: String do { mnemonic = try storage.retrieveMnemonic(for: entry.walletId) } catch { - recoveryError = "Failed to read stored mnemonic: \(error.localizedDescription)" - return false + return "\"\(entry.displayName)\": failed to read stored mnemonic — " + + error.localizedDescription } // Re-fetch metadata at recovery time rather than relying on @@ -430,28 +438,38 @@ struct ContentView: View { // Route the create call through the wallet's // intended-network manager, NOT the user's currently-active - // one. `walletManager` (the env-injected active manager) is - // bound to whatever network the user happens to be looking - // at; calling `createWallet(network: restoredNetwork)` on it - // when those don't match registers the wallet inside the - // wrong manager, and the wallet's persister callback fires - // through that manager's persistence handler — pinning the - // SwiftData row to the active manager's network instead of - // the wallet's actual one. Result before this fix: every - // recovered wallet landed on whichever network the user - // was looking at (typically testnet), regardless of what - // the keychain metadata recorded. + // one. See the prior fix's commit message for the full + // rationale; the short version is that the active manager's + // persistence handler pins SwiftData rows to its own + // network, so a regtest wallet recovered while the user is + // looking at testnet would land on testnet without this. + // + // SDK init splits out from the manager get-or-create so + // local-only networks (regtest / devnet) surface a clear + // "is your local stack running?" hint when SDK creation + // fails — those networks talk to a local quorum sidecar + // (typically `localhost:22444`) and reject SDK creation + // when it isn't reachable, while public networks + // (testnet / mainnet) hit always-on remote endpoints. + let networkSdk: SDK + do { + networkSdk = try SDK(network: restoredNetwork) + } catch { + let hint = (restoredNetwork == .regtest || restoredNetwork == .devnet) + ? " — is your local \(restoredNetwork.displayName) stack running?" + : "" + return "\"\(entry.displayName)\" (\(restoredNetwork.displayName)): " + + "failed to spin up SDK — \(error.localizedDescription)\(hint)" + } let recoveryManager: PlatformWalletManager do { - let networkSdk = try SDK(network: restoredNetwork) recoveryManager = try walletManagerStore.getOrCreateManager( network: restoredNetwork, sdk: networkSdk ) } catch { - recoveryError = "Failed to prepare \(restoredNetwork.displayName) wallet manager: " - + error.localizedDescription - return false + return "\"\(entry.displayName)\" (\(restoredNetwork.displayName)): " + + "failed to prepare wallet manager — \(error.localizedDescription)" } do { @@ -479,10 +497,10 @@ struct ContentView: View { } try? modelContext.save() } - return true + return nil } catch { - recoveryError = "Failed to recreate \"\(entry.displayName)\": \(error.localizedDescription)" - return false + return "\"\(entry.displayName)\" (\(restoredNetwork.displayName)): " + + error.localizedDescription } } From 00624ad0a9a94192585452b9dfde03e79ec95db3 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 6 May 2026 19:29:12 +0700 Subject: [PATCH 20/78] fix(swift-example-app): log orphan-recovery errors via SDKLogger The aggregated alert is great for the user but vanishes once dismissed. Mirror each failure into `SDKLogger.error` (including the raw error for debugging) so the messages survive in the console for diagnosis after the dialog closes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SwiftExampleApp/ContentView.swift | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift index 95ad0919643..f51b849331a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift @@ -407,8 +407,10 @@ struct ContentView: View { do { mnemonic = try storage.retrieveMnemonic(for: entry.walletId) } catch { - return "\"\(entry.displayName)\": failed to read stored mnemonic — " + let message = "\"\(entry.displayName)\": failed to read stored mnemonic — " + error.localizedDescription + SDKLogger.error("Recovery: \(message)") + return message } // Re-fetch metadata at recovery time rather than relying on @@ -458,8 +460,10 @@ struct ContentView: View { let hint = (restoredNetwork == .regtest || restoredNetwork == .devnet) ? " — is your local \(restoredNetwork.displayName) stack running?" : "" - return "\"\(entry.displayName)\" (\(restoredNetwork.displayName)): " + let message = "\"\(entry.displayName)\" (\(restoredNetwork.displayName)): " + "failed to spin up SDK — \(error.localizedDescription)\(hint)" + SDKLogger.error("Recovery: \(message) (raw: \(error))") + return message } let recoveryManager: PlatformWalletManager do { @@ -468,8 +472,10 @@ struct ContentView: View { sdk: networkSdk ) } catch { - return "\"\(entry.displayName)\" (\(restoredNetwork.displayName)): " + let message = "\"\(entry.displayName)\" (\(restoredNetwork.displayName)): " + "failed to prepare wallet manager — \(error.localizedDescription)" + SDKLogger.error("Recovery: \(message) (raw: \(error))") + return message } do { @@ -499,8 +505,10 @@ struct ContentView: View { } return nil } catch { - return "\"\(entry.displayName)\" (\(restoredNetwork.displayName)): " + let message = "\"\(entry.displayName)\" (\(restoredNetwork.displayName)): " + error.localizedDescription + SDKLogger.error("Recovery: createWallet failed — \(message) (raw: \(error))") + return message } } From ed300771052e23c1601d9d3462914ba557dff920 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 6 May 2026 19:33:08 +0700 Subject: [PATCH 21/78] fix(swift-sdk,swift-example-app): always log orphan-recovery failures Previous attempt only logged the recoverWallet inner failure paths and relied on Swift.print, which is easy to miss if the user isn't watching stdout. This broadens coverage: * SDKLogger.error now also emits via NSLog so errors land in the unified log (Console.app, Xcode debug area, device console) without depending on stdout capture. * authorizeAndRecover logs every recoveryError-setting branch (shared-prompt unavailable/failed, per-wallet unavailable/failed, the aggregated final message) and a startup line announcing how many wallets are being recovered, so a silent failure is now impossible to confuse with "the function never ran". Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Services/SDKLogger.swift | 7 +++++ .../SwiftExampleApp/ContentView.swift | 30 +++++++++++++++---- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift index 05cf840783f..e57dc1e6b1e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift @@ -69,6 +69,13 @@ public enum SDKLogger { } public static func error(_ message: String) { + // Route through both `NSLog` (unified log — Console.app, device + // console, Xcode debug area without depending on stdout + // capture) and `Swift.print` (stdout — preserves the existing + // dev-loop behaviour where `print` output is what's visible). + // Errors are rare; double-emit is fine and makes them harder + // to miss when something does go wrong. + NSLog("%@", message) Swift.print(message) } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift index f51b849331a..b2869dd3c23 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift @@ -273,6 +273,12 @@ struct ContentView: View { let separate = choices.filter { !$0.samePinCode } var recovered: Set = [] + SDKLogger.log( + "Recovery: authorize+recover — \(choices.count) wallet(s) " + + "(\(shared.count) shared-prompt, \(separate.count) per-wallet)", + minimumLevel: .low + ) + // Both auth failures (per-wallet biometric prompt errors) and // recovery failures (SDK init / manager prep / createWallet // errors returned from `recoverWallet`) accumulate here so the @@ -300,11 +306,15 @@ struct ContentView: View { showDeletePrompt = true return case .unavailable(let detail): - recoveryError = "Authentication is unavailable on this device: \(detail)" + let message = "Authentication is unavailable on this device: \(detail)" + SDKLogger.error("Recovery: \(message)") + recoveryError = message showDeletePrompt = true return case .failed(let detail): - recoveryError = "Authorization failed: \(detail)" + let message = "Authorization failed: \(detail)" + SDKLogger.error("Recovery: \(message)") + recoveryError = message showDeletePrompt = true return } @@ -322,6 +332,10 @@ struct ContentView: View { case .denied: // Skip this one — user said no to this specific // wallet — but keep going through the rest. + SDKLogger.log( + "Recovery: user denied auth for \"\(entry.displayName)\"; skipping", + minimumLevel: .medium + ) continue case .unavailable(let detail): // Same shape as the shared-branch handler: surface @@ -329,11 +343,15 @@ struct ContentView: View { // so the user has a path forward instead of being // left with stale orphans queued internally with // no UI to act on them. - recoveryError = "Authentication is unavailable on this device: \(detail)" + let message = "Authentication is unavailable on this device: \(detail)" + SDKLogger.error("Recovery: \(message)") + recoveryError = message showDeletePrompt = true return case .failed(let detail): - perWalletFailures.append("\(entry.displayName): \(detail)") + let entryFailure = "\(entry.displayName): \(detail)" + SDKLogger.error("Recovery: auth failed — \(entryFailure)") + perWalletFailures.append(entryFailure) continue } } @@ -343,7 +361,9 @@ struct ContentView: View { let prefix = perWalletFailures.count == 1 ? "Recovery failed: " : "Recovery failed for \(perWalletFailures.count) wallets:\n" - recoveryError = prefix + perWalletFailures.joined(separator: "\n") + let combined = prefix + perWalletFailures.joined(separator: "\n") + SDKLogger.error("Recovery: aggregated failures — \(combined)") + recoveryError = combined } // Drop the entries we just recreated. If the user skipped From 83054cbcf5d2e41da571975963e67b1e21f71a5f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 6 May 2026 20:19:14 +0700 Subject: [PATCH 22/78] fix(platform-wallet): validate spend anchor against Platform's recorded set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Anchor not found in the recorded anchors tree" broadcast failure was the depth-0 root of the local tree not matching any of Platform's per-block recorded anchors. Two ways our local depth-0 root drifts off a Platform-recorded state: 1. Platform records anchors only at block boundaries (record_shielded_pool_anchor_if_changed). If a sync chunk ends mid-block, our depth-0 root reflects a state that never existed at any block-end and matches nothing. 2. Tree corruption (e.g. multi-account re-sync re-appending committed positions) puts the local tree into a state Platform never had. Both surface the same way at broadcast time, ~30 s after the proof was built — which is too late to recover. Switch the spend pre-flight to ask Platform what anchors are valid (getShieldedAnchors RPC, retention 1000 blocks) and walk the local checkpoint depths until we find one whose root is in that set. The first matching depth becomes the depth used for every selected note's witness, so the bundle's anchor is in the recorded set by construction. If no local depth matches any Platform anchor, the local tree has fundamentally drifted; surface that as ShieldedTreeDiverged with a count of anchors tried and depths walked, so the host can drive a re-sync instead of failing at broadcast. Trait change: ShieldedStore::witness now takes a checkpoint_depth. FileBackedShieldedStore passes it through to shardtree's witness_at_checkpoint_depth; the in-memory store ignores it (still unsupported). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/rs-platform-wallet/src/error.rs | 16 ++ .../src/wallet/shielded/file_store.rs | 16 +- .../src/wallet/shielded/operations.rs | 151 ++++++++++++++---- .../src/wallet/shielded/store.rs | 9 +- 4 files changed, 152 insertions(+), 40 deletions(-) diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 2c5e94f833f..e5583899ac6 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -138,6 +138,22 @@ pub enum PlatformWalletError { #[error("Shielded sub-wallet not bound: call bind_shielded first")] ShieldedNotBound, + + /// The local commitment tree has no checkpoint whose root is + /// in Platform's `recorded_anchors`. Spend can't proceed — + /// our tree has diverged from Platform's (mid-block sync, + /// dropped notes, double-append, etc.) and a re-sync is + /// required. + #[error( + "Shielded tree diverged from Platform: no local checkpoint matches any of {tried} \ + recorded anchor(s) over {depths_walked} checkpoint depth(s); a re-sync is required" + )] + ShieldedTreeDiverged { + /// Number of Platform-side anchors we checked against. + tried: usize, + /// Number of local checkpoint depths we walked. + depths_walked: usize, + }, } /// Check whether an SDK error indicates that an InstantSend lock proof was diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index caf05f9df62..240e79077eb 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -133,16 +133,22 @@ impl ShieldedStore for FileBackedShieldedStore { fn witness( &self, position: u64, + checkpoint_depth: usize, ) -> Result, Self::Error> { let tree = self .tree .lock() .map_err(|e| FileShieldedStoreError(format!("tree mutex poisoned: {e}")))?; - // `checkpoint_depth = 0` = current tree state. The Halo 2 - // proof we're about to build uses `tree_anchor()` — also - // depth 0 — so the witness root must agree. - tree.witness(Position::from(position), 0) - .map_err(|e| FileShieldedStoreError(format!("witness({position}): {e}"))) + // `checkpoint_depth` indexes our local checkpoints (0 = + // most recent, 1 = one back, ...). The spend path walks + // depths to find one whose root matches a Platform-recorded + // anchor — see `ShieldedWallet::find_anchor_depth`. + tree.witness(Position::from(position), checkpoint_depth) + .map_err(|e| { + FileShieldedStoreError(format!( + "witness(position={position}, depth={checkpoint_depth}): {e}" + )) + }) } fn last_synced_note_index(&self, id: SubwalletId) -> Result { diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 65f5b016cec..e4ded624c64 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -465,29 +465,129 @@ impl ShieldedWallet { payment_address_to_orchard(&keys.default_address) } - /// Extract `SpendableNote` structs with Merkle witnesses and - /// the tree anchor. + /// Build witnesses + anchor for `notes`, validated against + /// Platform's `recorded_anchors` set so the resulting bundle + /// is guaranteed to pass the broadcast-time + /// `validate_anchor_exists` check. /// - /// The anchor is derived from the witness paths themselves - /// (via `MerklePath::root(cmx)`) rather than from - /// `store.tree_anchor()`. The store's witness call is - /// `witness_at_checkpoint_depth(0)` (root of the most recent - /// checkpoint) while `tree_anchor()` is - /// `root_at_checkpoint_depth(None)` (latest tree state) — - /// any commitments appended after the last checkpoint move - /// the latter ahead of the former, and the resulting - /// `AnchorMismatch` from the Orchard spend builder is what - /// you'd see at proof time. Using the witness's own - /// computed root keeps the anchor consistent with the - /// authentication paths the proof actually verifies. + /// Why this isn't just "use depth 0": + /// + /// Platform records anchors only at block boundaries + /// ([`record_shielded_pool_anchor_if_changed`]) — the + /// depth-0 root of our local tree may reflect a mid-block / + /// partial-sync state that no Platform-side anchor matches. + /// Walking depths catches the common case of "synced one + /// block past where the anchor was last recorded" for free, + /// and surfaces a clean `ShieldedTreeDiverged` error if + /// every local checkpoint disagrees with every Platform + /// anchor (= our tree has fundamentally drifted and needs a + /// re-sync). + /// + /// The probe path: every note's witness at a given depth + /// derives the same root, so we walk depths using a single + /// note's `(position, cmx)` pair until the derived root is + /// in Platform's anchor set. Then we re-witness every + /// selected note at that depth and return the bundle. async fn extract_spends_and_anchor( &self, notes: &[ShieldedNote], ) -> Result<(Vec, Anchor), PlatformWalletError> { + use dash_sdk::platform::fetch_current_no_parameters::FetchCurrent; + use dash_sdk::query_types::ShieldedAnchors; use grovedb_commitment_tree::ExtractedNoteCommitment; + use std::collections::HashSet; + + if notes.is_empty() { + return Err(PlatformWalletError::ShieldedBuildError( + "no spendable notes selected — anchor undefined".into(), + )); + } + + // Pull Platform's current set of valid anchors. + // Retention is 1000 blocks per the drive-abci method, + // so this comfortably covers any recently-synced state. + let valid_anchors_vec = ShieldedAnchors::fetch_current(&self.sdk) + .await + .map_err(|e| { + PlatformWalletError::ShieldedBuildError(format!( + "fetch shielded anchors from Platform: {e}" + )) + })? + .0; + let valid_anchors: HashSet<[u8; 32]> = valid_anchors_vec.iter().copied().collect(); + let tried_anchors = valid_anchors.len(); + if tried_anchors == 0 { + return Err(PlatformWalletError::ShieldedBuildError( + "Platform returned an empty shielded-anchor set; pool may be empty or pruned" + .into(), + )); + } + + let probe = ¬es[0]; + let probe_cmx = ExtractedNoteCommitment::from_bytes(&probe.cmx) + .into_option() + .ok_or_else(|| { + PlatformWalletError::ShieldedBuildError(format!( + "invalid stored cmx for note at position {}", + probe.position + )) + })?; + // shardtree returns `Ok(None)` once we walk past the + // last available checkpoint, which terminates the loop + // cleanly. The `MAX_CHECKPOINT_DEPTHS` bound is + // intentionally generous — `FileBackedShieldedStore` + // pins `max_checkpoints = 100` today. + const MAX_CHECKPOINT_DEPTHS: usize = 128; let store = self.store.read().await; + let mut chosen_depth: Option = None; + let mut depths_walked = 0usize; + for depth in 0..MAX_CHECKPOINT_DEPTHS { + let probe_path = match store.witness(probe.position, depth) { + Ok(Some(path)) => path, + // No checkpoint at this depth — no point + // walking further; older depths can't exist. + Ok(None) => break, + Err(e) => { + // Position not contained at this depth + // (note appended after the older + // checkpoint) — keep walking to deeper + // checkpoints, but record the error so we + // can surface useful diagnostics. + trace!( + depth, + position = probe.position, + "witness unavailable at depth: {e}" + ); + depths_walked += 1; + continue; + } + }; + depths_walked += 1; + let root = probe_path.root(probe_cmx).to_bytes(); + if valid_anchors.contains(&root) { + chosen_depth = Some(depth); + break; + } + } + + let depth = chosen_depth.ok_or(PlatformWalletError::ShieldedTreeDiverged { + tried: tried_anchors, + depths_walked, + })?; + + info!( + depth, + platform_anchor_count = tried_anchors, + notes = notes.len(), + "Selected anchor depth for shielded spend" + ); + // Re-witness every selected note at the chosen depth. + // The probe-path's root above already proved one note + // works; the remaining notes must witness at the same + // depth (same checkpoint state) for the spend bundle's + // single-anchor invariant to hold. let mut spends = Vec::with_capacity(notes.len()); let mut anchor: Option = None; for note in notes { @@ -497,24 +597,15 @@ impl ShieldedWallet { note.position )) })?; - let merkle_path = store - .witness(note.position) + .witness(note.position, depth) .map_err(|e| PlatformWalletError::ShieldedMerkleWitnessUnavailable(e.to_string()))? .ok_or_else(|| { PlatformWalletError::ShieldedMerkleWitnessUnavailable(format!( - "no witness available for note at position {} (not marked, or pruned past this position)", + "no witness at depth {depth} for note at position {}", note.position )) })?; - - // Compute the anchor this witness was generated - // against. All selected notes must share the same - // anchor — if not, the store handed us witnesses - // from different checkpoints, which the spend - // builder would reject downstream with - // `AnchorMismatch`. Surface the mismatch here so the - // host doesn't pay the ~30 s proof cost first. let cmx = ExtractedNoteCommitment::from_bytes(¬e.cmx) .into_option() .ok_or_else(|| { @@ -528,25 +619,19 @@ impl ShieldedWallet { None => anchor = Some(witness_anchor), Some(prev) if prev.to_bytes() != witness_anchor.to_bytes() => { return Err(PlatformWalletError::ShieldedBuildError(format!( - "witness anchor mismatch across selected notes (position {})", + "witness anchor mismatch across selected notes at depth {depth} (position {})", note.position ))); } _ => {} } - spends.push(SpendableNote { note: orchard_note, merkle_path, }); } - let anchor = anchor.ok_or_else(|| { - PlatformWalletError::ShieldedBuildError( - "no spendable notes selected — anchor undefined".to_string(), - ) - })?; - + let anchor = anchor.expect("anchor set after non-empty loop"); Ok((spends, anchor)) } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index 2a612fdefe1..405f51c75f1 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -116,11 +116,15 @@ pub trait ShieldedStore: Send + Sync { fn tree_anchor(&self) -> Result<[u8; 32], Self::Error>; /// Generate a Merkle authentication path for `position` - /// against the current tree state. Returns `Ok(None)` if no - /// witness is available (position not marked, or pruned). + /// against the tree state at `checkpoint_depth` checkpoints + /// before the current state (0 = most recent checkpoint, 1 = + /// one before, etc.). Returns `Ok(None)` if no checkpoint + /// exists at the requested depth or if the position is not + /// marked / has been pruned. fn witness( &self, position: u64, + checkpoint_depth: usize, ) -> Result, Self::Error>; // ── Sync state (per-subwallet) ───────────────────────────────────── @@ -291,6 +295,7 @@ impl ShieldedStore for InMemoryShieldedStore { fn witness( &self, _position: u64, + _checkpoint_depth: usize, ) -> Result, Self::Error> { Err(InMemoryStoreError( "Merkle witness not supported in in-memory store".into(), From e532eefa8bf2cc973e9939e47b8258aee3662958 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 6 May 2026 21:14:26 +0700 Subject: [PATCH 23/78] fix(platform-wallet,sdk): fall back to most-recent shielded anchor when set is empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous attempt failed at the SDK boundary: getShieldedAnchors returns an empty list when the anchors tree has nothing recorded yet, the proof verifier maps empty → None, and FetchCurrent then turns that into a "shielded anchors not found" error. From the wallet's side that error was indistinguishable from a transport failure, so the spend bailed without trying the second source we have for valid anchors. Two changes: * rs-sdk: add FetchCurrent impl for MostRecentShieldedAnchor — same shape as ShieldedAnchors / ShieldedPoolState but for the live most-recent slot. * platform-wallet: in extract_spends_and_anchor, treat both fetches as best-effort, fold both results into a single anchor set, and only fail with ShieldedBuildError when *both* came back empty. The most-recent anchor is the one likeliest to match a freshly- synced wallet's depth-0 root, and on a chain where the record-anchor upgrade hasn't backfilled it's the only valid target we can spend against. When no local depth matches any Platform anchor, log our depth-0 root and a sample of the Platform anchor set so the divergence is debuggable from the trace alone. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/wallet/shielded/operations.rs | 92 ++++++++++++++++--- .../rs-sdk/src/platform/types/shielded.rs | 36 +++++++- 2 files changed, 112 insertions(+), 16 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index e4ded624c64..0bdf0abfdc5 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -493,7 +493,7 @@ impl ShieldedWallet { notes: &[ShieldedNote], ) -> Result<(Vec, Anchor), PlatformWalletError> { use dash_sdk::platform::fetch_current_no_parameters::FetchCurrent; - use dash_sdk::query_types::ShieldedAnchors; + use dash_sdk::query_types::{MostRecentShieldedAnchor, ShieldedAnchors}; use grovedb_commitment_tree::ExtractedNoteCommitment; use std::collections::HashSet; @@ -506,19 +506,56 @@ impl ShieldedWallet { // Pull Platform's current set of valid anchors. // Retention is 1000 blocks per the drive-abci method, // so this comfortably covers any recently-synced state. - let valid_anchors_vec = ShieldedAnchors::fetch_current(&self.sdk) - .await - .map_err(|e| { - PlatformWalletError::ShieldedBuildError(format!( - "fetch shielded anchors from Platform: {e}" - )) - })? - .0; - let valid_anchors: HashSet<[u8; 32]> = valid_anchors_vec.iter().copied().collect(); + // + // The proof verifier's `FromProof` impl maps an empty + // anchors result to `None` (rather than `Some(vec![])`) + // and `fetch_current_with_metadata` then turns that + // into a `Generic("shielded anchors not found")` error. + // That error is indistinguishable from a transport + // failure, so we treat it as a non-fatal "set is + // empty" signal here and fall through to the + // most-recent fallback below. + let mut valid_anchors: HashSet<[u8; 32]> = HashSet::new(); + match ShieldedAnchors::fetch_current(&self.sdk).await { + Ok(set) => { + for a in set.0 { + valid_anchors.insert(a); + } + } + Err(e) => { + trace!("fetch shielded anchors returned no result (treated as empty set): {e}"); + } + } + + // Always fold in `MostRecentShieldedAnchor` too. It's + // the canonical "live" anchor — Platform updates it on + // every block where the commitment tree changes — and + // it's the single anchor that's most likely to match a + // freshly-synced wallet's depth-0 root. On a regtest + // where the recorded-anchors tree was never populated + // (e.g. the chain was running on an older platform + // version when the notes were added, and the + // `record_shielded_pool_anchor_if_changed` upgrade + // hasn't backfilled), this is the only valid anchor we + // can spend against. + match MostRecentShieldedAnchor::fetch_current(&self.sdk).await { + Ok(latest) => { + valid_anchors.insert(latest.0); + } + Err(e) => { + trace!( + "fetch most-recent shielded anchor returned no result \ + (treated as none): {e}" + ); + } + } + let tried_anchors = valid_anchors.len(); if tried_anchors == 0 { return Err(PlatformWalletError::ShieldedBuildError( - "Platform returned an empty shielded-anchor set; pool may be empty or pruned" + "Platform returned no shielded anchors (neither the recorded set \ + nor the most-recent slot is populated); the pool may be empty \ + or the anchor-recording upgrade hasn't run yet on this network" .into(), )); } @@ -571,10 +608,35 @@ impl ShieldedWallet { } } - let depth = chosen_depth.ok_or(PlatformWalletError::ShieldedTreeDiverged { - tried: tried_anchors, - depths_walked, - })?; + let depth = match chosen_depth { + Some(d) => d, + None => { + // Best-effort diagnostics: log our local depth-0 + // root + a few Platform anchors so a divergence + // is debuggable from the trace alone. + let local_root = store + .witness(probe.position, 0) + .ok() + .flatten() + .map(|p| hex::encode(p.root(probe_cmx).to_bytes())) + .unwrap_or_else(|| "".to_string()); + let mut sample: Vec = + valid_anchors.iter().take(4).map(hex::encode).collect(); + if valid_anchors.len() > sample.len() { + sample.push(format!("…({} total)", valid_anchors.len())); + } + warn!( + local_depth_0_root = %local_root, + platform_anchors = %sample.join(","), + depths_walked, + "No local checkpoint matches any Platform anchor — tree diverged" + ); + return Err(PlatformWalletError::ShieldedTreeDiverged { + tried: tried_anchors, + depths_walked, + }); + } + }; info!( depth, diff --git a/packages/rs-sdk/src/platform/types/shielded.rs b/packages/rs-sdk/src/platform/types/shielded.rs index 38b41f0ab4b..64a12538127 100644 --- a/packages/rs-sdk/src/platform/types/shielded.rs +++ b/packages/rs-sdk/src/platform/types/shielded.rs @@ -3,7 +3,9 @@ use crate::platform::fetch_current_no_parameters::FetchCurrent; use crate::{platform::Fetch, Error, Sdk}; use async_trait::async_trait; use dapi_grpc::platform::v0::{Proof, ResponseMetadata}; -use drive_proof_verifier::types::{NoParamQuery, ShieldedAnchors, ShieldedPoolState}; +use drive_proof_verifier::types::{ + MostRecentShieldedAnchor, NoParamQuery, ShieldedAnchors, ShieldedPoolState, +}; #[async_trait] impl FetchCurrent for ShieldedPoolState { @@ -60,3 +62,35 @@ impl FetchCurrent for ShieldedAnchors { )) } } + +#[async_trait] +impl FetchCurrent for MostRecentShieldedAnchor { + async fn fetch_current(sdk: &Sdk) -> Result { + let (anchor, _) = Self::fetch_current_with_metadata(sdk).await?; + Ok(anchor) + } + + async fn fetch_current_with_metadata(sdk: &Sdk) -> Result<(Self, ResponseMetadata), Error> { + let (anchor, metadata) = Self::fetch_with_metadata(sdk, NoParamQuery {}, None).await?; + Ok(( + anchor.ok_or(Error::Generic( + "most recent shielded anchor not set".to_string(), + ))?, + metadata, + )) + } + + async fn fetch_current_with_metadata_and_proof( + sdk: &Sdk, + ) -> Result<(Self, ResponseMetadata, Proof), Error> { + let (anchor, metadata, proof) = + Self::fetch_with_metadata_and_proof(sdk, NoParamQuery {}, None).await?; + Ok(( + anchor.ok_or(Error::Generic( + "most recent shielded anchor not set".to_string(), + ))?, + metadata, + proof, + )) + } +} From 497b74fc181009a1b123ecf945df63a02808cf34 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 7 May 2026 18:46:42 +0700 Subject: [PATCH 24/78] docs(claude): fix iOS framework build path in project CLAUDE.md The script lives at packages/swift-sdk/build_ios.sh; the previous packages/rs-sdk-ffi/build_ios.sh path doesn't exist and was misleading every Claude session that read this file. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index f1c8eb8bad5..2c061944c78 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,7 +154,7 @@ For SwiftExampleApp-specific guidance including token querying and data models, Quick build commands: ```bash # Build unified iOS framework (includes Core + Platform) -cd packages/rs-sdk-ffi +cd packages/swift-sdk ./build_ios.sh # Build SwiftExampleApp From d6a890a9ea243d9dd5c2b5c8b8f0b6a6204f4c2d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 7 May 2026 21:59:06 +0700 Subject: [PATCH 25/78] fix(swift-example-app): only overlay shielded-notes empty state when store is fully empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list had two empty-state branches: an in-list Section that fires when the segmented filter excludes everything (`!scoped.isEmpty && visible.isEmpty`), and a full-list `.overlay` that fires whenever `visible.isEmpty`. Both fired together when the user picked a filter with no matches — duplicating the empty placeholder and visually covering the filter picker. Gate the overlay on `scoped.isEmpty` so it only shows when the store has no notes for any filter; the inline Section keeps handling the filtered-empty case. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SwiftExampleApp/Views/StorageModelListViews.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift index 2fee807e856..6569670b187 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift @@ -1744,7 +1744,7 @@ struct ShieldedNoteStorageListView: View { } .navigationTitle("Shielded Notes (\(visible.count))") .overlay { - if visible.isEmpty { + if scoped.isEmpty { ContentUnavailableView("No Notes", systemImage: "lock.shield") } } From 6cc4d9a80716764a5d01137232b794cca90fedb3 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 7 May 2026 22:01:13 +0700 Subject: [PATCH 26/78] fix(swift-example-app): add storage-explorer detail views for shielded models The Verify-explorer-covers-all-SwiftData-models CI check was failing because PersistentShieldedNote and PersistentShieldedSyncState had no detail views in StorageRecordDetailViews.swift. Add Form-based detail views for both, surfacing every persisted field, and wrap the existing list-row cells in NavigationLink so the rows are tappable. With these in place the explorer-coverage script reports all 25 model types covered. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Views/StorageModelListViews.swift | 80 ++++++++++--------- .../Views/StorageRecordDetailViews.swift | 66 +++++++++++++++ 2 files changed, 108 insertions(+), 38 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift index 6569670b187..2ab57b5a61a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift @@ -1714,31 +1714,33 @@ struct ShieldedNoteStorageListView: View { } } ForEach(visible) { record in - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 8) { - Text("acct \(record.accountIndex)") - .font(.caption2) - .foregroundColor(.secondary) - Text("pos \(record.position)") - .font(.caption2) - .foregroundColor(.secondary) - if record.blockHeight > 0 { - Text("h \(record.blockHeight)") + NavigationLink(destination: ShieldedNoteStorageDetailView(record: record)) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text("acct \(record.accountIndex)") .font(.caption2) .foregroundColor(.secondary) - } - Spacer() - if record.isSpent { - Text("spent") + Text("pos \(record.position)") .font(.caption2) - .foregroundColor(.red) + .foregroundColor(.secondary) + if record.blockHeight > 0 { + Text("h \(record.blockHeight)") + .font(.caption2) + .foregroundColor(.secondary) + } + Spacer() + if record.isSpent { + Text("spent") + .font(.caption2) + .foregroundColor(.red) + } } + Text("\(record.value) credits") + .font(.caption) + Text(record.nullifier.prefix(8).map { String(format: "%02x", $0) }.joined()) + .font(.system(.caption2, design: .monospaced)) + .foregroundColor(.secondary) } - Text("\(record.value) credits") - .font(.caption) - Text(record.nullifier.prefix(8).map { String(format: "%02x", $0) }.joined()) - .font(.system(.caption2, design: .monospaced)) - .foregroundColor(.secondary) } } } @@ -1780,24 +1782,26 @@ struct ShieldedSyncStateStorageListView: View { let visible = scopedRecords List { ForEach(visible) { record in - VStack(alignment: .leading, spacing: 4) { - HStack { - Text( - record.walletId.prefix(4) - .map { String(format: "%02x", $0) }.joined() - ) - .font(.system(.caption2, design: .monospaced)) - Text("acct \(record.accountIndex)") - .font(.caption2) - .foregroundColor(.secondary) - Spacer() - } - Text("synced index: \(record.lastSyncedIndex)") - .font(.caption) - if record.hasNullifierCheckpoint { - Text("nf: h \(record.nullifierCheckpointHeight) · ts \(record.nullifierCheckpointTimestamp)") - .font(.caption2) - .foregroundColor(.secondary) + NavigationLink(destination: ShieldedSyncStateStorageDetailView(record: record)) { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text( + record.walletId.prefix(4) + .map { String(format: "%02x", $0) }.joined() + ) + .font(.system(.caption2, design: .monospaced)) + Text("acct \(record.accountIndex)") + .font(.caption2) + .foregroundColor(.secondary) + Spacer() + } + Text("synced index: \(record.lastSyncedIndex)") + .font(.caption) + if record.hasNullifierCheckpoint { + Text("nf: h \(record.nullifierCheckpointHeight) · ts \(record.nullifierCheckpointTimestamp)") + .font(.caption2) + .foregroundColor(.secondary) + } } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index 77b6136663d..f85db8a0345 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -1776,3 +1776,69 @@ struct WalletManagerMetadataStorageDetailView: View { .navigationBarTitleDisplayMode(.inline) } } + +// MARK: - PersistentShieldedNote + +struct ShieldedNoteStorageDetailView: View { + let record: PersistentShieldedNote + + var body: some View { + Form { + Section("Identity") { + FieldRow(label: "Wallet ID", value: hexString(record.walletId)) + FieldRow(label: "Account Index", value: "\(record.accountIndex)") + FieldRow(label: "Position", value: "\(record.position)") + } + Section("Commitment") { + FieldRow(label: "cmx", value: hexString(record.cmx)) + FieldRow(label: "Nullifier", value: hexString(record.nullifier)) + } + Section("State") { + FieldRow(label: "Block Height", value: "\(record.blockHeight)") + FieldRow(label: "Spent", value: record.isSpent ? "Yes" : "No") + FieldRow(label: "Value", value: "\(record.value) credits") + } + Section("Note Bytes") { + Text(hexString(record.noteData)) + .font(.system(.caption2, design: .monospaced)) + .textSelection(.enabled) + } + Section("Timestamps") { + FieldRow(label: "Created", value: dateString(record.createdAt)) + FieldRow(label: "Updated", value: dateString(record.lastUpdated)) + } + } + .navigationTitle("Shielded Note") + .navigationBarTitleDisplayMode(.inline) + } +} + +// MARK: - PersistentShieldedSyncState + +struct ShieldedSyncStateStorageDetailView: View { + let record: PersistentShieldedSyncState + + var body: some View { + Form { + Section("Identity") { + FieldRow(label: "Wallet ID", value: hexString(record.walletId)) + FieldRow(label: "Account Index", value: "\(record.accountIndex)") + } + Section("Sync") { + FieldRow(label: "Last Synced Index", value: "\(record.lastSyncedIndex)") + } + Section("Nullifier Checkpoint") { + FieldRow(label: "Present", value: record.hasNullifierCheckpoint ? "Yes" : "No") + if record.hasNullifierCheckpoint { + FieldRow(label: "Height", value: "\(record.nullifierCheckpointHeight)") + FieldRow(label: "Timestamp", value: "\(record.nullifierCheckpointTimestamp)") + } + } + Section("Timestamps") { + FieldRow(label: "Updated", value: dateString(record.lastUpdated)) + } + } + .navigationTitle("Shielded Sync State") + .navigationBarTitleDisplayMode(.inline) + } +} From 1911d736aaead9fd08160a7ce610f0bcf5acec87 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 7 May 2026 22:18:41 +0700 Subject: [PATCH 27/78] revert(platform-wallet): drop spend-side anchor pre-flight, trust depth-0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts 83054cbcf5 (validate spend anchor against Platform's recorded set) and e532eefa8b (fall back to most-recent shielded anchor when set is empty). Both were defensive workarounds for behaviour that has since been corrected on the Platform side — most directly by 7b23bc75e6 ("retire SHIELDED_MOST_RECENT_ANCHOR_KEY; derive most-recent from [8] and never empty it") which makes the empty-set branch dead code, and the broader anchor-recording refactor in 6dfa0fbd96 / 08a0bbc185. Net effect on the spend pipeline: * Witnesses are taken at depth 0. * The bundle's anchor is derived from the witness path itself (still 2daf3330e — kept; that's Halo 2 builder math, not Platform compensation). * Build proof, broadcast. * If drive-abci rejects with "anchor not in recorded set", the actual rejection text surfaces to the host instead of being pre-empted by a 128-depth walk that obscures the real failure. Drops `ShieldedTreeDiverged` error variant, the `getShieldedAnchors` round-trip, the `MostRecentShieldedAnchor::fetch_current` round-trip, and the `checkpoint_depth` parameter on `ShieldedStore::witness`. Net 208 lines removed across operations.rs / file_store.rs / store.rs / error.rs / rs-sdk shielded.rs. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/rs-platform-wallet/src/error.rs | 16 -- .../src/wallet/shielded/file_store.rs | 16 +- .../src/wallet/shielded/operations.rs | 213 +++--------------- .../src/wallet/shielded/store.rs | 9 +- .../rs-sdk/src/platform/types/shielded.rs | 36 +-- 5 files changed, 41 insertions(+), 249 deletions(-) diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index e5583899ac6..2c5e94f833f 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -138,22 +138,6 @@ pub enum PlatformWalletError { #[error("Shielded sub-wallet not bound: call bind_shielded first")] ShieldedNotBound, - - /// The local commitment tree has no checkpoint whose root is - /// in Platform's `recorded_anchors`. Spend can't proceed — - /// our tree has diverged from Platform's (mid-block sync, - /// dropped notes, double-append, etc.) and a re-sync is - /// required. - #[error( - "Shielded tree diverged from Platform: no local checkpoint matches any of {tried} \ - recorded anchor(s) over {depths_walked} checkpoint depth(s); a re-sync is required" - )] - ShieldedTreeDiverged { - /// Number of Platform-side anchors we checked against. - tried: usize, - /// Number of local checkpoint depths we walked. - depths_walked: usize, - }, } /// Check whether an SDK error indicates that an InstantSend lock proof was diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index 240e79077eb..caf05f9df62 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -133,22 +133,16 @@ impl ShieldedStore for FileBackedShieldedStore { fn witness( &self, position: u64, - checkpoint_depth: usize, ) -> Result, Self::Error> { let tree = self .tree .lock() .map_err(|e| FileShieldedStoreError(format!("tree mutex poisoned: {e}")))?; - // `checkpoint_depth` indexes our local checkpoints (0 = - // most recent, 1 = one back, ...). The spend path walks - // depths to find one whose root matches a Platform-recorded - // anchor — see `ShieldedWallet::find_anchor_depth`. - tree.witness(Position::from(position), checkpoint_depth) - .map_err(|e| { - FileShieldedStoreError(format!( - "witness(position={position}, depth={checkpoint_depth}): {e}" - )) - }) + // `checkpoint_depth = 0` = current tree state. The Halo 2 + // proof we're about to build uses `tree_anchor()` — also + // depth 0 — so the witness root must agree. + tree.witness(Position::from(position), 0) + .map_err(|e| FileShieldedStoreError(format!("witness({position}): {e}"))) } fn last_synced_note_index(&self, id: SubwalletId) -> Result { diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 0bdf0abfdc5..65f5b016cec 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -465,191 +465,29 @@ impl ShieldedWallet { payment_address_to_orchard(&keys.default_address) } - /// Build witnesses + anchor for `notes`, validated against - /// Platform's `recorded_anchors` set so the resulting bundle - /// is guaranteed to pass the broadcast-time - /// `validate_anchor_exists` check. + /// Extract `SpendableNote` structs with Merkle witnesses and + /// the tree anchor. /// - /// Why this isn't just "use depth 0": - /// - /// Platform records anchors only at block boundaries - /// ([`record_shielded_pool_anchor_if_changed`]) — the - /// depth-0 root of our local tree may reflect a mid-block / - /// partial-sync state that no Platform-side anchor matches. - /// Walking depths catches the common case of "synced one - /// block past where the anchor was last recorded" for free, - /// and surfaces a clean `ShieldedTreeDiverged` error if - /// every local checkpoint disagrees with every Platform - /// anchor (= our tree has fundamentally drifted and needs a - /// re-sync). - /// - /// The probe path: every note's witness at a given depth - /// derives the same root, so we walk depths using a single - /// note's `(position, cmx)` pair until the derived root is - /// in Platform's anchor set. Then we re-witness every - /// selected note at that depth and return the bundle. + /// The anchor is derived from the witness paths themselves + /// (via `MerklePath::root(cmx)`) rather than from + /// `store.tree_anchor()`. The store's witness call is + /// `witness_at_checkpoint_depth(0)` (root of the most recent + /// checkpoint) while `tree_anchor()` is + /// `root_at_checkpoint_depth(None)` (latest tree state) — + /// any commitments appended after the last checkpoint move + /// the latter ahead of the former, and the resulting + /// `AnchorMismatch` from the Orchard spend builder is what + /// you'd see at proof time. Using the witness's own + /// computed root keeps the anchor consistent with the + /// authentication paths the proof actually verifies. async fn extract_spends_and_anchor( &self, notes: &[ShieldedNote], ) -> Result<(Vec, Anchor), PlatformWalletError> { - use dash_sdk::platform::fetch_current_no_parameters::FetchCurrent; - use dash_sdk::query_types::{MostRecentShieldedAnchor, ShieldedAnchors}; use grovedb_commitment_tree::ExtractedNoteCommitment; - use std::collections::HashSet; - - if notes.is_empty() { - return Err(PlatformWalletError::ShieldedBuildError( - "no spendable notes selected — anchor undefined".into(), - )); - } - - // Pull Platform's current set of valid anchors. - // Retention is 1000 blocks per the drive-abci method, - // so this comfortably covers any recently-synced state. - // - // The proof verifier's `FromProof` impl maps an empty - // anchors result to `None` (rather than `Some(vec![])`) - // and `fetch_current_with_metadata` then turns that - // into a `Generic("shielded anchors not found")` error. - // That error is indistinguishable from a transport - // failure, so we treat it as a non-fatal "set is - // empty" signal here and fall through to the - // most-recent fallback below. - let mut valid_anchors: HashSet<[u8; 32]> = HashSet::new(); - match ShieldedAnchors::fetch_current(&self.sdk).await { - Ok(set) => { - for a in set.0 { - valid_anchors.insert(a); - } - } - Err(e) => { - trace!("fetch shielded anchors returned no result (treated as empty set): {e}"); - } - } - // Always fold in `MostRecentShieldedAnchor` too. It's - // the canonical "live" anchor — Platform updates it on - // every block where the commitment tree changes — and - // it's the single anchor that's most likely to match a - // freshly-synced wallet's depth-0 root. On a regtest - // where the recorded-anchors tree was never populated - // (e.g. the chain was running on an older platform - // version when the notes were added, and the - // `record_shielded_pool_anchor_if_changed` upgrade - // hasn't backfilled), this is the only valid anchor we - // can spend against. - match MostRecentShieldedAnchor::fetch_current(&self.sdk).await { - Ok(latest) => { - valid_anchors.insert(latest.0); - } - Err(e) => { - trace!( - "fetch most-recent shielded anchor returned no result \ - (treated as none): {e}" - ); - } - } - - let tried_anchors = valid_anchors.len(); - if tried_anchors == 0 { - return Err(PlatformWalletError::ShieldedBuildError( - "Platform returned no shielded anchors (neither the recorded set \ - nor the most-recent slot is populated); the pool may be empty \ - or the anchor-recording upgrade hasn't run yet on this network" - .into(), - )); - } - - let probe = ¬es[0]; - let probe_cmx = ExtractedNoteCommitment::from_bytes(&probe.cmx) - .into_option() - .ok_or_else(|| { - PlatformWalletError::ShieldedBuildError(format!( - "invalid stored cmx for note at position {}", - probe.position - )) - })?; - - // shardtree returns `Ok(None)` once we walk past the - // last available checkpoint, which terminates the loop - // cleanly. The `MAX_CHECKPOINT_DEPTHS` bound is - // intentionally generous — `FileBackedShieldedStore` - // pins `max_checkpoints = 100` today. - const MAX_CHECKPOINT_DEPTHS: usize = 128; let store = self.store.read().await; - let mut chosen_depth: Option = None; - let mut depths_walked = 0usize; - for depth in 0..MAX_CHECKPOINT_DEPTHS { - let probe_path = match store.witness(probe.position, depth) { - Ok(Some(path)) => path, - // No checkpoint at this depth — no point - // walking further; older depths can't exist. - Ok(None) => break, - Err(e) => { - // Position not contained at this depth - // (note appended after the older - // checkpoint) — keep walking to deeper - // checkpoints, but record the error so we - // can surface useful diagnostics. - trace!( - depth, - position = probe.position, - "witness unavailable at depth: {e}" - ); - depths_walked += 1; - continue; - } - }; - depths_walked += 1; - let root = probe_path.root(probe_cmx).to_bytes(); - if valid_anchors.contains(&root) { - chosen_depth = Some(depth); - break; - } - } - - let depth = match chosen_depth { - Some(d) => d, - None => { - // Best-effort diagnostics: log our local depth-0 - // root + a few Platform anchors so a divergence - // is debuggable from the trace alone. - let local_root = store - .witness(probe.position, 0) - .ok() - .flatten() - .map(|p| hex::encode(p.root(probe_cmx).to_bytes())) - .unwrap_or_else(|| "".to_string()); - let mut sample: Vec = - valid_anchors.iter().take(4).map(hex::encode).collect(); - if valid_anchors.len() > sample.len() { - sample.push(format!("…({} total)", valid_anchors.len())); - } - warn!( - local_depth_0_root = %local_root, - platform_anchors = %sample.join(","), - depths_walked, - "No local checkpoint matches any Platform anchor — tree diverged" - ); - return Err(PlatformWalletError::ShieldedTreeDiverged { - tried: tried_anchors, - depths_walked, - }); - } - }; - info!( - depth, - platform_anchor_count = tried_anchors, - notes = notes.len(), - "Selected anchor depth for shielded spend" - ); - - // Re-witness every selected note at the chosen depth. - // The probe-path's root above already proved one note - // works; the remaining notes must witness at the same - // depth (same checkpoint state) for the spend bundle's - // single-anchor invariant to hold. let mut spends = Vec::with_capacity(notes.len()); let mut anchor: Option = None; for note in notes { @@ -659,15 +497,24 @@ impl ShieldedWallet { note.position )) })?; + let merkle_path = store - .witness(note.position, depth) + .witness(note.position) .map_err(|e| PlatformWalletError::ShieldedMerkleWitnessUnavailable(e.to_string()))? .ok_or_else(|| { PlatformWalletError::ShieldedMerkleWitnessUnavailable(format!( - "no witness at depth {depth} for note at position {}", + "no witness available for note at position {} (not marked, or pruned past this position)", note.position )) })?; + + // Compute the anchor this witness was generated + // against. All selected notes must share the same + // anchor — if not, the store handed us witnesses + // from different checkpoints, which the spend + // builder would reject downstream with + // `AnchorMismatch`. Surface the mismatch here so the + // host doesn't pay the ~30 s proof cost first. let cmx = ExtractedNoteCommitment::from_bytes(¬e.cmx) .into_option() .ok_or_else(|| { @@ -681,19 +528,25 @@ impl ShieldedWallet { None => anchor = Some(witness_anchor), Some(prev) if prev.to_bytes() != witness_anchor.to_bytes() => { return Err(PlatformWalletError::ShieldedBuildError(format!( - "witness anchor mismatch across selected notes at depth {depth} (position {})", + "witness anchor mismatch across selected notes (position {})", note.position ))); } _ => {} } + spends.push(SpendableNote { note: orchard_note, merkle_path, }); } - let anchor = anchor.expect("anchor set after non-empty loop"); + let anchor = anchor.ok_or_else(|| { + PlatformWalletError::ShieldedBuildError( + "no spendable notes selected — anchor undefined".to_string(), + ) + })?; + Ok((spends, anchor)) } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index 405f51c75f1..2a612fdefe1 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -116,15 +116,11 @@ pub trait ShieldedStore: Send + Sync { fn tree_anchor(&self) -> Result<[u8; 32], Self::Error>; /// Generate a Merkle authentication path for `position` - /// against the tree state at `checkpoint_depth` checkpoints - /// before the current state (0 = most recent checkpoint, 1 = - /// one before, etc.). Returns `Ok(None)` if no checkpoint - /// exists at the requested depth or if the position is not - /// marked / has been pruned. + /// against the current tree state. Returns `Ok(None)` if no + /// witness is available (position not marked, or pruned). fn witness( &self, position: u64, - checkpoint_depth: usize, ) -> Result, Self::Error>; // ── Sync state (per-subwallet) ───────────────────────────────────── @@ -295,7 +291,6 @@ impl ShieldedStore for InMemoryShieldedStore { fn witness( &self, _position: u64, - _checkpoint_depth: usize, ) -> Result, Self::Error> { Err(InMemoryStoreError( "Merkle witness not supported in in-memory store".into(), diff --git a/packages/rs-sdk/src/platform/types/shielded.rs b/packages/rs-sdk/src/platform/types/shielded.rs index 64a12538127..38b41f0ab4b 100644 --- a/packages/rs-sdk/src/platform/types/shielded.rs +++ b/packages/rs-sdk/src/platform/types/shielded.rs @@ -3,9 +3,7 @@ use crate::platform::fetch_current_no_parameters::FetchCurrent; use crate::{platform::Fetch, Error, Sdk}; use async_trait::async_trait; use dapi_grpc::platform::v0::{Proof, ResponseMetadata}; -use drive_proof_verifier::types::{ - MostRecentShieldedAnchor, NoParamQuery, ShieldedAnchors, ShieldedPoolState, -}; +use drive_proof_verifier::types::{NoParamQuery, ShieldedAnchors, ShieldedPoolState}; #[async_trait] impl FetchCurrent for ShieldedPoolState { @@ -62,35 +60,3 @@ impl FetchCurrent for ShieldedAnchors { )) } } - -#[async_trait] -impl FetchCurrent for MostRecentShieldedAnchor { - async fn fetch_current(sdk: &Sdk) -> Result { - let (anchor, _) = Self::fetch_current_with_metadata(sdk).await?; - Ok(anchor) - } - - async fn fetch_current_with_metadata(sdk: &Sdk) -> Result<(Self, ResponseMetadata), Error> { - let (anchor, metadata) = Self::fetch_with_metadata(sdk, NoParamQuery {}, None).await?; - Ok(( - anchor.ok_or(Error::Generic( - "most recent shielded anchor not set".to_string(), - ))?, - metadata, - )) - } - - async fn fetch_current_with_metadata_and_proof( - sdk: &Sdk, - ) -> Result<(Self, ResponseMetadata, Proof), Error> { - let (anchor, metadata, proof) = - Self::fetch_with_metadata_and_proof(sdk, NoParamQuery {}, None).await?; - Ok(( - anchor.ok_or(Error::Generic( - "most recent shielded anchor not set".to_string(), - ))?, - metadata, - proof, - )) - } -} From 9e4ecdf22ea82f1cbe6e434cf2d55311e230269b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 7 May 2026 22:33:56 +0700 Subject: [PATCH 28/78] feat(swift-example-app): show per-account synced index in shielded sync status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the local commitment-tree's depth-0 root doesn't match any Platform-recorded anchor, the wallet has no UI surface for "how far have we appended into the tree" — making it hard to tell whether a divergence is a sync gap, a watermark/checkpoint drift, or a Platform-side cadence question. Add a `ShieldedSyncIndexRows` subview to the Sync Status screen that renders one row per ZIP-32 account showing the persisted `PersistentShieldedSyncState.lastSyncedIndex` (and the nullifier checkpoint height if present). Reads straight from SwiftData rather than via `ShieldedService`, so the values reflect what's actually on disk for the next cold start. Promote `ShieldedService.walletId` to a published `boundWalletId` so the diagnostic subview can scope the per-account watermark query to the active shielded wallet without re-plumbing the id from the call site. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Services/ShieldedService.swift | 13 ++-- .../Core/Views/CoreContentView.swift | 66 +++++++++++++++++++ 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift index 45bc2aef058..a764796ad87 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -78,8 +78,11 @@ class ShieldedService: ObservableObject { /// Wallet manager whose shielded sync events we mirror. private weak var walletManager: PlatformWalletManager? - /// Wallet id we filter sync results by. - private var walletId: Data? + /// Wallet id we filter sync results by. Exposed read-only so + /// diagnostics views (e.g. Sync Status) can resolve persisted + /// per-account watermarks without each one knowing the active + /// wallet through a separate path. + @Published private(set) var boundWalletId: Data? /// Network of the currently-bound wallet. Stashed so /// `switchTo(walletId:)` can reach the right per-network @@ -122,7 +125,7 @@ class ShieldedService: ObservableObject { accounts: [UInt32] = [0] ) { self.walletManager = walletManager - self.walletId = walletId + self.boundWalletId = walletId self.network = network self.resolver = resolver self.syncStateCancellable?.cancel() @@ -219,7 +222,7 @@ class ShieldedService: ObservableObject { /// No-op if the requested wallet is already bound. Logs and /// returns early if `bind(...)` was never called yet. func switchTo(walletId: Data) { - if self.walletId == walletId, isBound { + if self.boundWalletId == walletId, isBound { return } guard @@ -276,7 +279,7 @@ class ShieldedService: ObservableObject { syncStateCancellable?.cancel() syncEventCancellable?.cancel() walletManager = nil - walletId = nil + boundWalletId = nil isSyncing = false shieldedBalance = 0 lastNewNotes = 0 diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index 846ca6181ee..73f8ff0fd53 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -436,6 +436,17 @@ var body: some View { } } + // Per-account commitment-tree watermarks read + // straight from the persisted ShieldedSyncState + // rows — the "global note index we have appended + // up to". Useful when diagnosing + // ShieldedTreeDiverged / anchor-mismatch + // failures, where the wallet's local tree state + // and Platform's recorded anchors disagree. + ShieldedSyncIndexRows( + boundWalletId: shieldedService.boundWalletId + ) + // Sync counters since launch — `total_scanned` // is the wire-level encrypted-note count (every // pass), while new + spent are the wallet-side @@ -1163,3 +1174,58 @@ extension CoreContentView { return String(format: "%.8f DASH", dash) } } + +// MARK: - ShieldedSyncIndexRows + +/// One row per ZIP-32 account showing the persisted commitment-tree +/// watermark for the currently-bound shielded wallet. +/// +/// The watermark is the global note index up to which that +/// subwallet has appended commitments to the local tree — i.e. +/// "we've seen Platform's pool through index N" — and is the +/// single most useful diagnostic when local and Platform anchors +/// disagree (anchor-mismatch / ShieldedTreeDiverged at spend +/// time). Rendered straight from `PersistentShieldedSyncState` +/// rather than via `ShieldedService` so the values reflect what's +/// actually on disk for the next cold start, not just the +/// in-memory mirror. +private struct ShieldedSyncIndexRows: View { + let boundWalletId: Data? + + @Query(sort: [SortDescriptor(\PersistentShieldedSyncState.accountIndex)]) + private var allRows: [PersistentShieldedSyncState] + + private var rows: [PersistentShieldedSyncState] { + guard let id = boundWalletId else { return [] } + return allRows.filter { $0.walletId == id } + } + + var body: some View { + if !rows.isEmpty { + VStack(spacing: 4) { + HStack { + Text("Synced Index") + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + } + ForEach(rows, id: \.accountIndex) { row in + HStack { + Text("acct \(row.accountIndex)") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Text("\(row.lastSyncedIndex)") + .font(.system(.caption, design: .monospaced)) + .fontWeight(.medium) + if row.hasNullifierCheckpoint { + Text("· nf h \(row.nullifierCheckpointHeight)") + .font(.caption2) + .foregroundColor(.secondary) + } + } + } + } + } + } +} From 532b886975627a406f05a720856ae389f71a81e9 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 7 May 2026 22:52:59 +0700 Subject: [PATCH 29/78] fix(swift-example-app): use renamed boundWalletId in shielded sync event handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Missed call site in the previous boundWalletId rename — the `handleShieldedSyncEvent` guard still referenced the old `walletId` shorthand binding, which the warnings-as-errors Swift SDK build surfaced as `cannot find 'walletId' in scope`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SwiftExampleApp/Core/Services/ShieldedService.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift index a764796ad87..8ad597129cb 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -299,7 +299,8 @@ class ShieldedService: ObservableObject { // MARK: - Sync event handling private func handleShieldedSyncEvent(_ event: ShieldedSyncEvent) { - guard let walletId, let result = event.result(for: walletId) else { + guard let walletId = boundWalletId, + let result = event.result(for: walletId) else { return } From 33267c5766d0e5ef8dd79dfad9d163430969999f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 7 May 2026 23:07:15 +0700 Subject: [PATCH 30/78] fix(swift-example-app): show persisted balance per shielded account, hide solo acct label Two diagnostics on the Sync Status screen for the shielded section: * Add a per-account "balance (persisted)" column read straight from `PersistentShieldedNote` (sum of value over unspent rows for the bound walletId/account). The existing `Shielded Balance` is mirrored from Rust sync events via `ShieldedService.shieldedBalance`; when the two disagree, the divergence is in the event path. When both agree at zero while notes are clearly present, the divergence is in the persister / cold-start restore. Either way, the user has a direct comparison without leaving the Sync Status screen. * Hide the `acct N` label when only one account is bound. In the single-account default `acct 0` is just noise; the row's synced-index + balance still carry the diagnostic without it. Re-shown automatically once a second account binds. Both read SwiftData (no FFI changes); the persistence-side query filters by the new `boundWalletId` published on `ShieldedService` so the values are scoped to the active shielded wallet. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Views/CoreContentView.swift | 93 ++++++++++++------- 1 file changed, 61 insertions(+), 32 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index 73f8ff0fd53..653df177288 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -436,13 +436,14 @@ var body: some View { } } - // Per-account commitment-tree watermarks read - // straight from the persisted ShieldedSyncState - // rows — the "global note index we have appended - // up to". Useful when diagnosing - // ShieldedTreeDiverged / anchor-mismatch - // failures, where the wallet's local tree state - // and Platform's recorded anchors disagree. + // Per-account synced-index + persisted balance, + // read straight from SwiftData. The persisted + // balance is a cross-check against + // `ShieldedService.shieldedBalance` (which is + // mirrored from Rust sync events): when the two + // disagree, the divergence is in the event path; + // when both agree at zero while notes exist, the + // divergence is on the persister / restore side. ShieldedSyncIndexRows( boundWalletId: shieldedService.boundWalletId ) @@ -1177,52 +1178,80 @@ extension CoreContentView { // MARK: - ShieldedSyncIndexRows -/// One row per ZIP-32 account showing the persisted commitment-tree -/// watermark for the currently-bound shielded wallet. +/// Per-account commitment-tree + persisted-balance summary for the +/// currently-bound shielded wallet. /// -/// The watermark is the global note index up to which that -/// subwallet has appended commitments to the local tree — i.e. -/// "we've seen Platform's pool through index N" — and is the -/// single most useful diagnostic when local and Platform anchors -/// disagree (anchor-mismatch / ShieldedTreeDiverged at spend -/// time). Rendered straight from `PersistentShieldedSyncState` -/// rather than via `ShieldedService` so the values reflect what's -/// actually on disk for the next cold start, not just the -/// in-memory mirror. +/// Two diagnostics that don't otherwise have a UI surface: +/// +/// * **Synced Index** — global note index up to which each +/// subwallet has appended commitments to the local tree. +/// Useful when local and Platform anchors disagree +/// (anchor-mismatch / ShieldedTreeDiverged at spend time). +/// +/// * **Balance (persisted)** — sum of unspent +/// `PersistentShieldedNote.value` for each bound subwallet, +/// read **directly from SwiftData** rather than via the +/// `ShieldedService.shieldedBalance` mirror that's updated +/// from sync events. When the two numbers disagree, the bug +/// is in the Rust→Swift event path; when they agree at zero +/// while notes exist, the bug is in the persister callback or +/// the cold-start restore. +/// +/// The account label is suppressed when only one account is +/// bound — the redundant `acct 0` is just visual noise in the +/// single-account default. private struct ShieldedSyncIndexRows: View { let boundWalletId: Data? @Query(sort: [SortDescriptor(\PersistentShieldedSyncState.accountIndex)]) - private var allRows: [PersistentShieldedSyncState] + private var syncStateRows: [PersistentShieldedSyncState] - private var rows: [PersistentShieldedSyncState] { + @Query private var allNotes: [PersistentShieldedNote] + + private var scopedSyncRows: [PersistentShieldedSyncState] { guard let id = boundWalletId else { return [] } - return allRows.filter { $0.walletId == id } + return syncStateRows.filter { $0.walletId == id } + } + + /// Sum of `value` over unspent notes for `(boundWalletId, + /// account)`. Reads SwiftData directly — independent of the + /// in-memory shielded wallet's `balance_total()`. + private func persistedBalance(account: UInt32) -> UInt64 { + guard let id = boundWalletId else { return 0 } + return allNotes + .lazy + .filter { $0.walletId == id && $0.accountIndex == account && !$0.isSpent } + .reduce(UInt64(0)) { $0 &+ $1.value } } var body: some View { - if !rows.isEmpty { + if !scopedSyncRows.isEmpty { VStack(spacing: 4) { HStack { - Text("Synced Index") + Text("Per-Account State") .font(.subheadline) .foregroundColor(.secondary) Spacer() } - ForEach(rows, id: \.accountIndex) { row in - HStack { - Text("acct \(row.accountIndex)") - .font(.caption) - .foregroundColor(.secondary) - Spacer() - Text("\(row.lastSyncedIndex)") + let showAccountLabel = scopedSyncRows.count > 1 + ForEach(scopedSyncRows, id: \.accountIndex) { row in + HStack(spacing: 8) { + if showAccountLabel { + Text("acct \(row.accountIndex)") + .font(.caption) + .foregroundColor(.secondary) + } + Text("idx \(row.lastSyncedIndex)") .font(.system(.caption, design: .monospaced)) - .fontWeight(.medium) if row.hasNullifierCheckpoint { - Text("· nf h \(row.nullifierCheckpointHeight)") + Text("nf h \(row.nullifierCheckpointHeight)") .font(.caption2) .foregroundColor(.secondary) } + Spacer() + Text("\(persistedBalance(account: row.accountIndex)) credits") + .font(.system(.caption, design: .monospaced)) + .fontWeight(.medium) } } } From aea6dd152694a396cae2542fdeb7cd5a582b7b6b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 8 May 2026 15:41:23 +0700 Subject: [PATCH 31/78] fix(platform-wallet,swift-sdk): pending-then-confirm shielded spends + sparse-array UB on restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two blocking issues from the latest review pass. 1) Shielded spend lifecycle (operations.rs) `unshield`, `transfer`, and `withdraw` previously called `state_transition.broadcast(...)` and then `mark_notes_spent` unconditionally. `broadcast()` is the submission step only; the actual execution result comes through `wait_for_response` / `broadcast_and_wait`. A submitted-but-rejected/dropped transition therefore left the local store with notes permanently marked spent and no rollback path — denial-of-funds on honest network failures, and exploitable by a DAPI peer that acknowledges relay without delivering execution. Replace with reserve → broadcast_and_wait → finalize-or-cancel: * `ShieldedStore::mark_pending` / `clear_pending` reserve a nullifier in-memory; `unspent_notes` filters out pending reservations so concurrent callers can't double-select the same notes (also closes the prior TOCTOU on note selection across overlapping spend calls). * Spend methods take a write lock for select+reserve, drop it for build/broadcast, and re-take it on the success or failure branch to either finalize (`mark_spent` + queue changeset) or cancel (`clear_pending`). * `broadcast_and_wait::` waits for the execution outcome before promoting; a dropped or rejected transition surfaces as `ShieldedBroadcastFailed` and the rollback path runs. Pending state is in-memory only; a process crash between reserve and finalize is reconciled by the next nullifier-sync pass (the only authoritative spent observation). 2) FFI restore sparse-array UB (PlatformWalletPersistenceHandler.swift) `loadShieldedNotes` / `loadShieldedSyncStates` allocated `rows.count` slots, wrote each accepted row into `buf[idx]` indexed by `rows.enumerated()`, and returned `entriesInitialized` as the count. When an early row was skipped by a length guard, the contiguous prefix Rust reads off `slice::from_raw_parts(ptr, count)` contained uninitialized slots — a single malformed persisted row could make Rust ingest uninitialized wallet IDs / nullifiers. Switch both methods to a `written` counter, write to `buf[written]`, increment only after a slot is fully populated. The free path was already keyed on `entriesInitialized`, which now equals `written`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/wallet/shielded/file_store.rs | 20 ++ .../src/wallet/shielded/operations.rs | 307 +++++++++++------- .../src/wallet/shielded/store.rs | 76 ++++- .../PlatformWalletPersistenceHandler.swift | 32 +- 4 files changed, 316 insertions(+), 119 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index caf05f9df62..8e5398d0da1 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -96,6 +96,26 @@ impl ShieldedStore for FileBackedShieldedStore { .unwrap_or(false)) } + fn mark_pending(&mut self, id: SubwalletId, nullifier: &[u8; 32]) -> Result { + Ok(self + .subwallets + .entry(id) + .or_default() + .mark_pending(nullifier)) + } + + fn clear_pending( + &mut self, + id: SubwalletId, + nullifier: &[u8; 32], + ) -> Result { + Ok(self + .subwallets + .get_mut(&id) + .map(|sw| sw.clear_pending(nullifier)) + .unwrap_or(false)) + } + fn append_commitment(&mut self, cmx: &[u8; 32], marked: bool) -> Result<(), Self::Error> { let retention: Retention = if marked { Retention::Marked diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 65f5b016cec..0aebf1a9291 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -32,6 +32,7 @@ use dpp::shielded::builder::{ build_shielded_transfer_transition, build_shielded_withdrawal_transition, build_unshield_transition, OrchardProver, SpendableNote, }; +use dpp::state_transition::proof_result::StateTransitionProofResult; use dpp::withdrawal::Pooling; use grovedb_commitment_tree::{Anchor, PaymentAddress}; use tracing::{info, trace, warn}; @@ -271,13 +272,8 @@ impl ShieldedWallet { let change_addr = self.default_orchard_address(account)?; let id = self.subwallet_id(account); - let (selected_notes, total_input, exact_fee) = { - let store = self.store.read().await; - let unspent = store - .get_unspent_notes(id) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; - select_notes_with_fee(&unspent, amount, 1, self.sdk.version())?.into_owned() - }; + let (selected_notes, total_input, exact_fee) = + self.reserve_unspent_notes(id, amount, 1).await?; info!( account, @@ -288,33 +284,46 @@ impl ShieldedWallet { "Unshield" ); - let (spends, anchor) = self.extract_spends_and_anchor(&selected_notes).await?; - - let state_transition = build_unshield_transition( - spends, - *to_address, - amount, - &change_addr, - &keys.full_viewing_key, - &keys.spend_auth_key, - anchor, - prover, - [0u8; 36], - Some(exact_fee), - self.sdk.version(), - ) - .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; - - trace!("Unshield: state transition built, broadcasting..."); - state_transition - .broadcast(&self.sdk, None) - .await - .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; - - self.mark_notes_spent(id, &selected_notes).await?; + // From here on every error path must release the + // reservation taken by `reserve_unspent_notes`. + let result = async { + let (spends, anchor) = self.extract_spends_and_anchor(&selected_notes).await?; + + let state_transition = build_unshield_transition( + spends, + *to_address, + amount, + &change_addr, + &keys.full_viewing_key, + &keys.spend_auth_key, + anchor, + prover, + [0u8; 36], + Some(exact_fee), + self.sdk.version(), + ) + .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; + + trace!("Unshield: state transition built, broadcasting..."); + state_transition + .broadcast_and_wait::(&self.sdk, None) + .await + .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; + Ok::<(), PlatformWalletError>(()) + } + .await; - info!(account, credits = amount, "Unshield broadcast succeeded"); - Ok(()) + match result { + Ok(()) => { + self.finalize_pending(id, &selected_notes).await?; + info!(account, credits = amount, "Unshield broadcast succeeded"); + Ok(()) + } + Err(e) => { + self.cancel_pending(id, &selected_notes).await; + Err(e) + } + } } // ------------------------------------------------------------------------- @@ -335,13 +344,8 @@ impl ShieldedWallet { let change_addr = self.default_orchard_address(account)?; let id = self.subwallet_id(account); - let (selected_notes, total_input, exact_fee) = { - let store = self.store.read().await; - let unspent = store - .get_unspent_notes(id) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; - select_notes_with_fee(&unspent, amount, 2, self.sdk.version())?.into_owned() - }; + let (selected_notes, total_input, exact_fee) = + self.reserve_unspent_notes(id, amount, 2).await?; info!( account, @@ -352,37 +356,48 @@ impl ShieldedWallet { "Shielded transfer" ); - let (spends, anchor) = self.extract_spends_and_anchor(&selected_notes).await?; - - let state_transition = build_shielded_transfer_transition( - spends, - &recipient_addr, - amount, - &change_addr, - &keys.full_viewing_key, - &keys.spend_auth_key, - anchor, - prover, - [0u8; 36], - Some(exact_fee), - self.sdk.version(), - ) - .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; - - trace!("Shielded transfer: state transition built, broadcasting..."); - state_transition - .broadcast(&self.sdk, None) - .await - .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; - - self.mark_notes_spent(id, &selected_notes).await?; + let result = async { + let (spends, anchor) = self.extract_spends_and_anchor(&selected_notes).await?; + + let state_transition = build_shielded_transfer_transition( + spends, + &recipient_addr, + amount, + &change_addr, + &keys.full_viewing_key, + &keys.spend_auth_key, + anchor, + prover, + [0u8; 36], + Some(exact_fee), + self.sdk.version(), + ) + .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; + + trace!("Shielded transfer: state transition built, broadcasting..."); + state_transition + .broadcast_and_wait::(&self.sdk, None) + .await + .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; + Ok::<(), PlatformWalletError>(()) + } + .await; - info!( - account, - credits = amount, - "Shielded transfer broadcast succeeded" - ); - Ok(()) + match result { + Ok(()) => { + self.finalize_pending(id, &selected_notes).await?; + info!( + account, + credits = amount, + "Shielded transfer broadcast succeeded" + ); + Ok(()) + } + Err(e) => { + self.cancel_pending(id, &selected_notes).await; + Err(e) + } + } } // ------------------------------------------------------------------------- @@ -403,13 +418,8 @@ impl ShieldedWallet { let id = self.subwallet_id(account); let output_script = CoreScript::from_bytes(to_address.script_pubkey().to_bytes()); - let (selected_notes, total_input, exact_fee) = { - let store = self.store.read().await; - let unspent = store - .get_unspent_notes(id) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; - select_notes_with_fee(&unspent, amount, 1, self.sdk.version())?.into_owned() - }; + let (selected_notes, total_input, exact_fee) = + self.reserve_unspent_notes(id, amount, 1).await?; info!( account, @@ -420,39 +430,50 @@ impl ShieldedWallet { "Shielded withdrawal" ); - let (spends, anchor) = self.extract_spends_and_anchor(&selected_notes).await?; - - let state_transition = build_shielded_withdrawal_transition( - spends, - amount, - output_script, - core_fee_per_byte, - Pooling::Standard, - &change_addr, - &keys.full_viewing_key, - &keys.spend_auth_key, - anchor, - prover, - [0u8; 36], - Some(exact_fee), - self.sdk.version(), - ) - .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; - - trace!("Shielded withdrawal: state transition built, broadcasting..."); - state_transition - .broadcast(&self.sdk, None) - .await - .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; - - self.mark_notes_spent(id, &selected_notes).await?; + let result = async { + let (spends, anchor) = self.extract_spends_and_anchor(&selected_notes).await?; + + let state_transition = build_shielded_withdrawal_transition( + spends, + amount, + output_script, + core_fee_per_byte, + Pooling::Standard, + &change_addr, + &keys.full_viewing_key, + &keys.spend_auth_key, + anchor, + prover, + [0u8; 36], + Some(exact_fee), + self.sdk.version(), + ) + .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; + + trace!("Shielded withdrawal: state transition built, broadcasting..."); + state_transition + .broadcast_and_wait::(&self.sdk, None) + .await + .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; + Ok::<(), PlatformWalletError>(()) + } + .await; - info!( - account, - credits = amount, - "Shielded withdrawal broadcast succeeded" - ); - Ok(()) + match result { + Ok(()) => { + self.finalize_pending(id, &selected_notes).await?; + info!( + account, + credits = amount, + "Shielded withdrawal broadcast succeeded" + ); + Ok(()) + } + Err(e) => { + self.cancel_pending(id, &selected_notes).await; + Err(e) + } + } } // ------------------------------------------------------------------------- @@ -554,6 +575,10 @@ impl ShieldedWallet { /// shielded changeset on the persister so the spent flag /// reaches durable storage immediately rather than waiting for /// the next nullifier-sync pass to rediscover the spend. + /// + /// Also drops any matching pending reservation so the + /// confirmed-spent state and the in-flight-spend state can't + /// disagree. async fn mark_notes_spent( &self, id: SubwalletId, @@ -574,6 +599,70 @@ impl ShieldedWallet { self.queue_shielded_changeset(changeset); Ok(()) } + + /// Select unspent notes and reserve them against an in-flight + /// spend in one write-locked critical section. + /// + /// Combining selection and reservation under a single write + /// lock is the only thing that prevents two overlapping spend + /// calls from picking the same notes: with separate + /// read-then-write phases, the second caller would observe + /// the same `unspent_notes()` between the first caller's + /// read and write and proceed to build a duplicate proof + /// that's only rejected ~30 s later at broadcast time. + /// + /// The reservation is in-memory only — see + /// [`ShieldedStore::mark_pending`] for the crash-recovery + /// note. Callers must pair this with [`Self::finalize_pending`] + /// (on broadcast success) or [`Self::cancel_pending`] (on + /// failure) so the reservation is always released. + #[allow(clippy::too_many_arguments)] + async fn reserve_unspent_notes( + &self, + id: SubwalletId, + amount: u64, + outputs: usize, + ) -> Result<(Vec, u64, u64), PlatformWalletError> { + let mut store = self.store.write().await; + let unspent = store + .get_unspent_notes(id) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + let (selected, total_input, exact_fee) = + select_notes_with_fee(&unspent, amount, outputs, self.sdk.version())?.into_owned(); + for note in &selected { + store + .mark_pending(id, ¬e.nullifier) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + } + Ok((selected, total_input, exact_fee)) + } + + /// Promote a successful broadcast: mark the notes spent (which + /// also clears any matching pending reservation, see + /// [`SubwalletState::mark_spent`]) and queue the changeset for + /// the host persister. + async fn finalize_pending( + &self, + id: SubwalletId, + notes: &[ShieldedNote], + ) -> Result<(), PlatformWalletError> { + self.mark_notes_spent(id, notes).await + } + + /// Roll back a reservation when the broadcast / wait fails. + /// Best-effort and doesn't surface its own errors — the + /// caller is already returning the broadcast error. + async fn cancel_pending(&self, id: SubwalletId, notes: &[ShieldedNote]) { + let mut store = self.store.write().await; + for note in notes { + if let Err(e) = store.clear_pending(id, ¬e.nullifier) { + tracing::warn!( + error = %e, + "cancel_pending: clear_pending failed; the next nullifier sync will reconcile" + ); + } + } + } } /// Helper to clone selection results out from under the store lock. diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index 2a612fdefe1..e4e2485d118 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -23,7 +23,7 @@ //! type doesn't implement serde — a bytes contract would force //! every caller through a serializer that doesn't exist. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::error::Error as StdError; use std::fmt; @@ -100,6 +100,31 @@ pub trait ShieldedStore: Send + Sync { /// if a matching unspent note was found. fn mark_spent(&mut self, id: SubwalletId, nullifier: &[u8; 32]) -> Result; + /// Reserve `id`'s note with `nullifier` against an in-flight + /// spend so concurrent callers can't pick the same note. + /// Returns `true` if the nullifier was newly added to the + /// pending set, `false` if it was already pending. + /// + /// Pending state is **in-memory only** — it does not survive + /// a process restart. The crash-during-broadcast case is + /// reconciled by the next nullifier-sync pass after the + /// transition lands (or, on rejection, leaves the notes + /// observable as unspent again on the next launch). + /// + /// `unspent_notes` skips notes whose nullifier is in the + /// pending set, so a successful `mark_pending` immediately + /// removes the note from selection candidates. + fn mark_pending(&mut self, id: SubwalletId, nullifier: &[u8; 32]) -> Result; + + /// Release the reservation taken by [`Self::mark_pending`]. + /// Returns `true` if the nullifier was actually pending and + /// got removed; `false` is a no-op (paired clear from the + /// rollback path on a transition that never marked pending, + /// or a stale clear after the spend already promoted to + /// `mark_spent`). + fn clear_pending(&mut self, id: SubwalletId, nullifier: &[u8; 32]) + -> Result; + // ── Commitment tree (network-shared) ─────────────────────────────── /// Append a note commitment to the shared tree. @@ -163,6 +188,11 @@ pub(super) struct SubwalletState { pub last_synced_index: u64, /// `(height, timestamp)` from the most recent nullifier sync. pub nullifier_checkpoint: Option<(u64, u64)>, + /// Nullifiers of notes currently being spent in an in-flight + /// transition. Excluded from `unspent_notes()` so concurrent + /// callers can't double-select. In-memory only — never + /// persisted; the next sync after a crash reconciles state. + pub pending_nullifiers: BTreeSet<[u8; 32]>, } impl SubwalletState { @@ -183,7 +213,11 @@ impl SubwalletState { } pub(super) fn unspent_notes(&self) -> Vec { - self.notes.iter().filter(|n| !n.is_spent).cloned().collect() + self.notes + .iter() + .filter(|n| !n.is_spent && !self.pending_nullifiers.contains(&n.nullifier)) + .cloned() + .collect() } pub(super) fn all_notes(&self) -> Vec { @@ -194,11 +228,29 @@ impl SubwalletState { if let Some(&idx) = self.nullifier_index.get(nullifier) { if !self.notes[idx].is_spent { self.notes[idx].is_spent = true; + // Promotion implies the spend confirmed; drop any + // matching pending reservation. Idempotent — the + // common path already cleared pending in the + // spend-flow finalizer. + self.pending_nullifiers.remove(nullifier); return true; } } false } + + /// Reserve `nullifier` against an in-flight spend. Returns + /// `true` if newly added. + pub(super) fn mark_pending(&mut self, nullifier: &[u8; 32]) -> bool { + self.pending_nullifiers.insert(*nullifier) + } + + /// Release a reservation previously taken via `mark_pending`. + /// Returns `true` if a matching reservation was actually + /// removed. + pub(super) fn clear_pending(&mut self, nullifier: &[u8; 32]) -> bool { + self.pending_nullifiers.remove(nullifier) + } } // ── InMemoryShieldedStore ────────────────────────────────────────────── @@ -273,6 +325,26 @@ impl ShieldedStore for InMemoryShieldedStore { .unwrap_or(false)) } + fn mark_pending(&mut self, id: SubwalletId, nullifier: &[u8; 32]) -> Result { + Ok(self + .subwallets + .entry(id) + .or_default() + .mark_pending(nullifier)) + } + + fn clear_pending( + &mut self, + id: SubwalletId, + nullifier: &[u8; 32], + ) -> Result { + Ok(self + .subwallets + .get_mut(&id) + .map(|sw| sw.clear_pending(nullifier)) + .unwrap_or(false)) + } + fn append_commitment(&mut self, cmx: &[u8; 32], marked: bool) -> Result<(), Self::Error> { self.commitments.push(*cmx); self.marked_positions.push(marked); diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 24c407db9e9..7c926493559 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -2206,7 +2206,17 @@ public class PlatformWalletPersistenceHandler { let buf = UnsafeMutablePointer.allocate(capacity: rows.count) allocation.entries = buf allocation.entriesCount = rows.count - for (idx, row) in rows.enumerated() { + // `written` is the next free slot in `buf`; we increment it + // only after a row's struct is fully populated, so the + // returned prefix `[0..written)` is contiguous initialized + // memory regardless of how many rows are skipped by the + // length guards below. Indexing by `rows.enumerated()`'s + // `idx` here would leave gaps when an early row is skipped + // and Rust would read uninitialized bytes off the + // `slice::from_raw_parts(ptr, count)` it builds in + // `FFIPersister::load`. + var written = 0 + for row in rows { guard row.walletId.count == 32 else { continue } guard row.cmx.count == 32 else { continue } guard row.nullifier.count == 32 else { continue } @@ -2232,7 +2242,7 @@ public class PlatformWalletPersistenceHandler { dst.copyMemory(from: src) } } - buf[idx] = ShieldedNoteRestoreFFI( + buf[written] = ShieldedNoteRestoreFFI( wallet_id: walletIdTuple, account_index: row.accountIndex, position: row.position, @@ -2244,12 +2254,13 @@ public class PlatformWalletPersistenceHandler { note_data_ptr: UnsafePointer(noteDataBuf), note_data_len: UInt(row.noteData.count) ) - allocation.entriesInitialized += 1 + written += 1 + allocation.entriesInitialized = written } let entriesPtr = UnsafePointer(buf) shieldedLoadAllocations[UnsafeRawPointer(entriesPtr)] = allocation resultEntries = entriesPtr - resultCount = allocation.entriesInitialized + resultCount = written } return (resultEntries, resultCount, resultErrored) } @@ -2293,7 +2304,11 @@ public class PlatformWalletPersistenceHandler { ) allocation.entries = buf allocation.entriesCount = rows.count - for (idx, row) in rows.enumerated() { + // Same `written`-counter pattern as `loadShieldedNotes`: + // skip malformed rows without leaving holes in the + // contiguous prefix Rust will read. + var written = 0 + for row in rows { guard row.walletId.count == 32 else { continue } var walletIdTuple: FFIByteTuple32 = (0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0) row.walletId.withUnsafeBytes { src in @@ -2301,7 +2316,7 @@ public class PlatformWalletPersistenceHandler { dst.copyMemory(from: src) } } - buf[idx] = ShieldedSubwalletSyncStateFFI( + buf[written] = ShieldedSubwalletSyncStateFFI( wallet_id: walletIdTuple, account_index: row.accountIndex, last_synced_index: row.lastSyncedIndex, @@ -2309,12 +2324,13 @@ public class PlatformWalletPersistenceHandler { nullifier_checkpoint_height: row.nullifierCheckpointHeight, nullifier_checkpoint_timestamp: row.nullifierCheckpointTimestamp ) - allocation.entriesInitialized += 1 + written += 1 + allocation.entriesInitialized = written } let entriesPtr = UnsafePointer(buf) shieldedSyncStateLoadAllocations[UnsafeRawPointer(entriesPtr)] = allocation resultEntries = entriesPtr - resultCount = allocation.entriesInitialized + resultCount = written } return (resultEntries, resultCount, resultErrored) } From 39b99171ee7051cd3757f531c1075bea5da80375 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 19 May 2026 11:18:39 +0700 Subject: [PATCH 32/78] fix(platform-wallet-ffi): include shielded callbacks in PersistenceCallbacks Default impl The v3.1-dev merge brought in additions to PersistenceCallbacks's Default impl from v3.1-dev's side, which doesn't carry the 8 `#[cfg(feature = "shielded")]` fields this branch added. With the `shielded` feature on, the resulting Default::default() body was missing those entries and the FFI crate failed to build on the warnings-as-errors Swift SDK build. Add each shielded field with a matching `#[cfg(feature = "shielded")]` gate so the Default impl mirrors the struct definition on both feature configurations. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rs-platform-wallet-ffi/src/persistence.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 8ff91e5b32f..bffe9f53d33 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -475,6 +475,22 @@ impl Default for PersistenceCallbacks { on_persist_contacts_fn: None, on_get_core_tx_record_fn: None, on_get_core_tx_record_free_fn: None, + #[cfg(feature = "shielded")] + on_persist_shielded_notes_fn: None, + #[cfg(feature = "shielded")] + on_persist_shielded_nullifiers_spent_fn: None, + #[cfg(feature = "shielded")] + on_persist_shielded_synced_indices_fn: None, + #[cfg(feature = "shielded")] + on_persist_shielded_nullifier_checkpoints_fn: None, + #[cfg(feature = "shielded")] + on_load_shielded_notes_fn: None, + #[cfg(feature = "shielded")] + on_load_shielded_notes_free_fn: None, + #[cfg(feature = "shielded")] + on_load_shielded_sync_states_fn: None, + #[cfg(feature = "shielded")] + on_load_shielded_sync_states_free_fn: None, } } } From e233bc5734da35ce2f7c8a195358dd7db58296e1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 07:28:28 +0700 Subject: [PATCH 33/78] fix(swift-example-app): drop leftover unused `amount` guard in coreToCore send path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge residue: the v3.1-dev side of the SendViewModel conflict captured `amount` for the `.coreToCore` case, while the PR-branch side used `amountDuffs`. The merge resolution kept `amountDuffs` as the active value but left a stale `guard let amount = amount` binding above the call site that was never read, which the warnings-as-errors Swift SDK build surfaced as `value 'amount' was defined but never used`. Remove the dead guard — `amountDuffs` itself is already validated on the line above, and the alias shim means they're equivalent. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SwiftExampleApp/Core/ViewModels/SendViewModel.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift index c2009509420..96931e34b5f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -212,7 +212,6 @@ class SendViewModel: ObservableObject { error = "Core wallet not available" return } - guard let amount = amount else { return } let address = recipientAddress.trimmingCharacters(in: .whitespacesAndNewlines) let _ = try core.sendToAddresses( recipients: [(address: address, amountDuffs: amountDuffs)] From 51da7311640d4687ac6058e5bf9ad98edfa40a34 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 08:58:02 +0700 Subject: [PATCH 34/78] fix(swift-example-app): include shielded balance in Wallets row total MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `WalletRowView.combinedDashAmount` summed Core + Platform but silently excluded the wallet's shielded balance. With a wallet showing Core 2 DASH + Platform 2.7 DASH + Shielded 0.3 DASH, the list row totalled to 4.70 DASH (= Core + Platform exactly) instead of 5.00 DASH — funds in the shielded pool didn't count. Add a per-wallet `@Query` filtered to unspent rows for this walletId, sum `value` (same credits scale as Platform, 1e11/DASH), and fold into the divisor block in `combinedDashAmount`. Reads SwiftData directly — same source the wallet-detail balance card draws from — so the list and detail agree without any FFI round-trip per row. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Views/CoreContentView.swift | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index 4ad68591649..43bda13577c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -795,12 +795,26 @@ struct WalletRowView: View { /// addresses (no identities) showed "Empty". @Query private var addressBalances: [PersistentPlatformAddress] + /// Per-wallet unspent shielded notes. Same persisted-truth + /// source as the Sync Status diagnostic — sum of `value` over + /// unspent rows for this wallet, in credits. Without this the + /// wallet row's combined DASH total under-reported the wallet's + /// real value by every shielded note: a wallet with funds in + /// the shielded pool would show Core + Platform on the list but + /// silently exclude Shielded. + @Query private var shieldedNotes: [PersistentShieldedNote] + init(wallet: PersistentWallet) { self.wallet = wallet let walletId = wallet.walletId _addressBalances = Query( filter: #Predicate { $0.walletId == walletId } ) + _shieldedNotes = Query( + filter: #Predicate { + $0.walletId == walletId && !$0.isSpent + } + ) } /// Identities on this wallet — via the SwiftData relationship. @@ -848,12 +862,20 @@ struct WalletRowView: View { totals.confirmed + totals.unconfirmed + totals.immature + totals.locked } + /// Sum of unspent shielded note values in credits. Same scale + /// as `platformBalance` (1e11 credits/DASH), so it folds into + /// the same divisor in [`combinedDashAmount(coreTotal:)`]. + private var shieldedBalance: UInt64 { + shieldedNotes.reduce(UInt64(0)) { $0 &+ $1.value } + } + /// Combined wallet balance expressed in DASH for a precomputed - /// totals tuple. Core uses 1e8 duffs/DASH; Platform uses 1e11 - /// credits/DASH. + /// totals tuple. Core uses 1e8 duffs/DASH; Platform and Shielded + /// both use 1e11 credits/DASH. private func combinedDashAmount(coreTotal: UInt64) -> Double { Double(coreTotal) / 100_000_000.0 + Double(platformBalance) / 100_000_000_000.0 + + Double(shieldedBalance) / 100_000_000_000.0 } private var walletIdShort: String { From b0fa943fcabf74b6f4d018c8e79f452741fa7f70 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 09:19:45 +0700 Subject: [PATCH 35/78] fix(swift-example-app): make shielded-sync Clear button actually wipe + rebind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Clear button in the Shielded Sync Status section called `ShieldedService.reset()`, which only nils the in-memory mirror and tears down the manager subscriptions. Two visible failures followed: 1. PersistentShieldedSyncState rows survived (the per-account synced-index list still showed entries after Clear, with the same `synced index: 4` and nullifier checkpoint). 2. The next Sync Now surfaced "Shielded service not configured" because `reset()` had nilled `walletManager` / `boundWalletId` / `network` / `resolver` so no rebind could happen until the user navigated away and back to retrigger `rebindWalletScopedServices`. Add `clearLocalState(modelContext:)`: * Stash the binding (walletManager, walletId, resolver, network, bound accounts) before wiping in-memory state. * Delete this wallet's `PersistentShieldedNote` and `PersistentShieldedSyncState` rows via predicate. Scoped to `walletId` so other wallets' state on the shared SwiftData store stays intact. * Call `reset()` to clear the published mirror. * Re-bind to the same wallet via the stashed binding so Sync Now works immediately afterwards. Deliberately does NOT touch the on-disk SQLite tree at `shielded_tree_.sqlite` — that file is per-network and shared across every wallet on the same network, so deleting it would corrupt other wallets' state. Rust's `sync_notes` already skips positions already in the tree, so re-using the existing leaves is the right behaviour. The Clear button now passes the SwiftUI `\.modelContext` through. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Services/ShieldedService.swift | 87 +++++++++++++++++++ .../Core/Views/CoreContentView.swift | 17 +++- 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift index 8ad597129cb..0bfb4c7b2e8 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -11,6 +11,7 @@ import Foundation import SwiftUI import Combine +import SwiftData import SwiftDashSDK /// Observable service mirroring Rust-owned shielded sync state. @@ -296,6 +297,92 @@ class ShieldedService: ObservableObject { totalNewlySpent = 0 } + /// Wipe this wallet's persisted shielded state and re-bind so + /// the next sync starts from scratch and the Sync Now button + /// keeps working. + /// + /// Bare [`reset`] just tears down subscriptions and nils the + /// in-memory mirror — the SwiftData rows + /// (`PersistentShieldedNote`, `PersistentShieldedSyncState`) + /// survive, and the service becomes unbound so the next Sync + /// Now fails with "Shielded service not configured". The user- + /// facing Clear button on the Sync Status screen really wants + /// "wipe and start over"; this does that. + /// + /// What it does NOT touch: + /// * The on-disk commitment-tree SQLite file at + /// `dbPath(for:)`. That tree is **per-network** — every + /// wallet on the same network shares the same `cmx` stream + /// and the same frontier, so deleting it would corrupt + /// other wallets' state. The next sync just re-uses the + /// existing tree leaves (Rust's `sync_notes` skips + /// positions already in the tree). + /// * The Rust-side shielded sub-wallet: `bindShielded` is + /// idempotent and a second call replaces the binding, so + /// we don't need a separate unbind path. + /// + /// No-op if the service hasn't been bound yet (nothing to + /// clear; nothing to rebind to). + func clearLocalState(modelContext: ModelContext) async { + guard + let walletManager, + let walletId = boundWalletId, + let resolver, + let network + else { + SDKLogger.log( + "ShieldedService.clearLocalState called before initial bind — ignoring", + minimumLevel: .medium + ) + return + } + let accounts = boundAccounts.isEmpty ? [0] : boundAccounts + + // Stash for after the in-memory reset. + let pinnedManager = walletManager + let pinnedResolver = resolver + let pinnedNetwork = network + + // 1) Delete this wallet's persisted shielded rows from + // SwiftData. Scoped to `walletId` so other wallets' + // state on the same SwiftData store stays intact. + do { + try modelContext.delete( + model: PersistentShieldedNote.self, + where: #Predicate { $0.walletId == walletId } + ) + try modelContext.delete( + model: PersistentShieldedSyncState.self, + where: #Predicate { $0.walletId == walletId } + ) + try modelContext.save() + } catch { + lastError = "Failed to wipe persisted shielded state: \(error.localizedDescription)" + SDKLogger.error(lastError ?? "") + return + } + + // 2) Reset the in-memory mirror so the UI shows zeros + // immediately rather than the stale snapshot from the + // last sync event. + reset() + + // 3) Re-bind so Sync Now works without the user having to + // navigate away and back to trigger + // `rebindWalletScopedServices`. The Rust manager's + // `bindShielded` is idempotent and replaces the + // previous binding; the persister callback's + // `loadShieldedNotes` will now see an empty table and + // Rust's `restore_from_snapshot` will start from zero. + bind( + walletManager: pinnedManager, + walletId: walletId, + network: pinnedNetwork, + resolver: pinnedResolver, + accounts: accounts + ) + } + // MARK: - Sync event handling private func handleShieldedSyncEvent(_ event: ShieldedSyncEvent) { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index 43bda13577c..3f7d03bac29 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -8,6 +8,10 @@ struct CoreContentView: View { @EnvironmentObject var appUIState: AppUIState @EnvironmentObject var platformBalanceSyncService: PlatformBalanceSyncService @EnvironmentObject var shieldedService: ShieldedService + /// Threaded into `ShieldedService.clearLocalState(modelContext:)` + /// so the Clear button can scope its delete-by-predicate to + /// the bound wallet's persisted rows. + @Environment(\.modelContext) private var modelContext @State private var showProofDetail = false @State private var masternodesEnabled: Bool = true @State private var platformSyncExpanded: Bool = false @@ -527,7 +531,18 @@ var body: some View { .disabled(shieldedService.isSyncing) Button { - shieldedService.reset() + // Wipe this wallet's persisted shielded + // rows (notes + sync state) AND re-bind + // so the next Sync Now starts from + // scratch — bare `reset()` left the + // service unbound, surfacing "Shielded + // service not configured" on the next + // press. + Task { + await shieldedService.clearLocalState( + modelContext: modelContext + ) + } } label: { Text("Clear") .font(.caption) From 15650d3a4bcc5545b3336aa177891e15cbaf8e33 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 09:25:35 +0700 Subject: [PATCH 36/78] fix(swift-example-app): shielded Clear should stop, not auto-rebind Previous Clear-then-rebind step caused the service to immediately start re-syncing as soon as the persisted rows were wiped, contrary to the user's "wipe and stop" expectation. Drop the rebind and let Clear leave the service unbound; the next navigation into a wallet detail screen retriggers `rebindWalletScopedServices` and starts a fresh sync from zero when (and only when) the user actually wants it. Rust-side binding stays put (no unbind FFI today), but no sync events flow without an active Swift-side subscription, so the Rust subwallet's in-memory state goes idle until the next bind replaces it. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Services/ShieldedService.swift | 73 +++++++------------ 1 file changed, 28 insertions(+), 45 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift index 0bfb4c7b2e8..6c9ac364e5a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -297,51 +297,47 @@ class ShieldedService: ObservableObject { totalNewlySpent = 0 } - /// Wipe this wallet's persisted shielded state and re-bind so - /// the next sync starts from scratch and the Sync Now button - /// keeps working. + /// Wipe this wallet's persisted shielded state and stop. The + /// service is left unbound — no auto-rebind, no auto-rescan. /// /// Bare [`reset`] just tears down subscriptions and nils the /// in-memory mirror — the SwiftData rows /// (`PersistentShieldedNote`, `PersistentShieldedSyncState`) - /// survive, and the service becomes unbound so the next Sync - /// Now fails with "Shielded service not configured". The user- - /// facing Clear button on the Sync Status screen really wants - /// "wipe and start over"; this does that. + /// survive, so the next bind hydrates the wallet right back to + /// the state the user just tried to clear. + /// + /// The user reaches this through the Clear button on the Sync + /// Status screen and wants "delete and walk away" semantics: + /// the persisted rows go, the published mirror zeroes out, and + /// the service stays inert until the next explicit bind. + /// Re-syncing means navigating back through the wallet detail + /// screen, which retriggers `rebindWalletScopedServices` and + /// rebinds from scratch (SwiftData is empty → Rust's + /// `restore_from_snapshot` starts at zero). /// /// What it does NOT touch: /// * The on-disk commitment-tree SQLite file at /// `dbPath(for:)`. That tree is **per-network** — every /// wallet on the same network shares the same `cmx` stream /// and the same frontier, so deleting it would corrupt - /// other wallets' state. The next sync just re-uses the - /// existing tree leaves (Rust's `sync_notes` skips - /// positions already in the tree). - /// * The Rust-side shielded sub-wallet: `bindShielded` is - /// idempotent and a second call replaces the binding, so - /// we don't need a separate unbind path. + /// other wallets' state. Rust's `sync_notes` already skips + /// positions already in the tree, so re-using the existing + /// leaves on the next bind is the right behaviour. + /// * The manager-wide shielded sync loop. Other wallets + /// bound on the same `PlatformWalletManager` keep syncing. + /// * The Rust-side shielded sub-wallet binding (there's no + /// unbind FFI today; the next `bindShielded` call replaces + /// the binding wholesale). /// - /// No-op if the service hasn't been bound yet (nothing to - /// clear; nothing to rebind to). + /// No-op if the service hasn't been bound yet. func clearLocalState(modelContext: ModelContext) async { - guard - let walletManager, - let walletId = boundWalletId, - let resolver, - let network - else { + guard let walletId = boundWalletId else { SDKLogger.log( "ShieldedService.clearLocalState called before initial bind — ignoring", minimumLevel: .medium ) return } - let accounts = boundAccounts.isEmpty ? [0] : boundAccounts - - // Stash for after the in-memory reset. - let pinnedManager = walletManager - let pinnedResolver = resolver - let pinnedNetwork = network // 1) Delete this wallet's persisted shielded rows from // SwiftData. Scoped to `walletId` so other wallets' @@ -362,25 +358,12 @@ class ShieldedService: ObservableObject { return } - // 2) Reset the in-memory mirror so the UI shows zeros - // immediately rather than the stale snapshot from the - // last sync event. + // 2) Tear down the in-memory mirror + subscriptions. The + // service is now unbound; no further sync events flow + // in and Sync Now will surface "Shielded service not + // configured" until something re-binds (typically the + // next navigation into a wallet detail screen). reset() - - // 3) Re-bind so Sync Now works without the user having to - // navigate away and back to trigger - // `rebindWalletScopedServices`. The Rust manager's - // `bindShielded` is idempotent and replaces the - // previous binding; the persister callback's - // `loadShieldedNotes` will now see an empty table and - // Rust's `restore_from_snapshot` will start from zero. - bind( - walletManager: pinnedManager, - walletId: walletId, - network: pinnedNetwork, - resolver: pinnedResolver, - accounts: accounts - ) } // MARK: - Sync event handling From 4d7d7ff713a2824fc6fd9d4ceff7b073df905dba Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 09:29:48 +0700 Subject: [PATCH 37/78] fix(swift-example-app): shielded-only wallets show their balance, not "Empty" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups on the prior `WalletRowView` change that added unspent shielded notes to `combinedDashAmount`: * `hasAny` was still gated on `coreTotal > 0 || platformBalance > 0`, so a wallet whose only value is unspent shielded notes still took the "Empty" branch even though `combinedDashAmount(...)` would now return a positive DASH amount — defeating the point of the prior commit. Add `shieldedBalance > 0` to the gate so the display matches the numeric aggregation. * Drop the lone `&+` wrapping add in `shieldedBalance`. The surrounding sums (`platformBalance`, `coreBalanceTotals`) all use `+`; one-off wrap suppresses overflow without a meaningful saturated value and produces a silently-tiny total if it ever fires. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SwiftExampleApp/Core/Views/CoreContentView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index 3f7d03bac29..7178ee2f939 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -881,7 +881,7 @@ struct WalletRowView: View { /// as `platformBalance` (1e11 credits/DASH), so it folds into /// the same divisor in [`combinedDashAmount(coreTotal:)`]. private var shieldedBalance: UInt64 { - shieldedNotes.reduce(UInt64(0)) { $0 &+ $1.value } + shieldedNotes.reduce(UInt64(0)) { $0 + $1.value } } /// Combined wallet balance expressed in DASH for a precomputed @@ -970,7 +970,7 @@ struct WalletRowView: View { // re-invoking the accessor. let core = coreBalanceTotals() let coreTotal = Self.sumCoreBalance(core) - let hasAny = coreTotal > 0 || platformBalance > 0 + let hasAny = coreTotal > 0 || platformBalance > 0 || shieldedBalance > 0 return VStack(alignment: .leading, spacing: 6) { // Header: label (+ status badges) and total Core balance. HStack(alignment: .firstTextBaseline) { From d4323c09691d5af522393e532331f1ec0eca5211 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 09:33:25 +0700 Subject: [PATCH 38/78] fix(swift-example-app): Clear wipes all shielded state, not just bound wallet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous version scoped both `PersistentShieldedNote` and `PersistentShieldedSyncState` deletes to `boundWalletId`. The Clear button lives on the global Sync Status surface and doesn't carry wallet context, so any wallet that wasn't currently bound kept its rows: after Clear, the user still saw a sync-state row for a second wallet (`d97b6777`, synced index 4). * Drop the walletId predicate. The full-table delete on both shielded models matches the "global Clear" framing of the button. * Also delete the per-network commitment-tree SQLite file (plus -wal / -shm / -journal sidecars). Previously kept on the theory that re-using existing tree leaves was cheaper, but the SwiftData / tree split is itself the most common source of the "Merkle witness unavailable" failures we're chasing — a fresh DB pairs cleanly with the wiped SwiftData snapshot, and the next bind walks the cmx stream from genesis. * No-op guard relaxed: Clear no longer requires `boundWalletId != nil`. SwiftData rows that survived from a prior session get wiped even when the service hasn't bound yet this session. The SQLite delete still requires `network != nil` because the path is per-network. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Services/ShieldedService.swift | 100 +++++++++++------- 1 file changed, 60 insertions(+), 40 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift index 6c9ac364e5a..4e70bfa8304 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -297,7 +297,7 @@ class ShieldedService: ObservableObject { totalNewlySpent = 0 } - /// Wipe this wallet's persisted shielded state and stop. The + /// Wipe every wallet's persisted shielded state and stop. The /// service is left unbound — no auto-rebind, no auto-rescan. /// /// Bare [`reset`] just tears down subscriptions and nils the @@ -306,51 +306,45 @@ class ShieldedService: ObservableObject { /// survive, so the next bind hydrates the wallet right back to /// the state the user just tried to clear. /// - /// The user reaches this through the Clear button on the Sync - /// Status screen and wants "delete and walk away" semantics: - /// the persisted rows go, the published mirror zeroes out, and - /// the service stays inert until the next explicit bind. - /// Re-syncing means navigating back through the wallet detail - /// screen, which retriggers `rebindWalletScopedServices` and - /// rebinds from scratch (SwiftData is empty → Rust's - /// `restore_from_snapshot` starts at zero). + /// The user reaches this through the Clear button on the + /// **global** Sync Status surface, not a per-wallet screen. + /// "Clear" therefore wipes every wallet's shielded rows + the + /// per-network commitment-tree SQLite file, so re-binding any + /// wallet on this network walks back through the cmx stream + /// from genesis. Re-syncing means navigating back into a + /// wallet detail (which retriggers + /// `rebindWalletScopedServices`). /// /// What it does NOT touch: - /// * The on-disk commitment-tree SQLite file at - /// `dbPath(for:)`. That tree is **per-network** — every - /// wallet on the same network shares the same `cmx` stream - /// and the same frontier, so deleting it would corrupt - /// other wallets' state. Rust's `sync_notes` already skips - /// positions already in the tree, so re-using the existing - /// leaves on the next bind is the right behaviour. - /// * The manager-wide shielded sync loop. Other wallets - /// bound on the same `PlatformWalletManager` keep syncing. + /// * The manager-wide shielded sync loop. The next bind + /// re-attaches a fresh subscription. /// * The Rust-side shielded sub-wallet binding (there's no /// unbind FFI today; the next `bindShielded` call replaces - /// the binding wholesale). + /// the binding wholesale, and the freshly-bound store + /// starts empty because both the SwiftData snapshot and + /// the SQLite tree are gone). /// - /// No-op if the service hasn't been bound yet. + /// Per-wallet scoping was tried first and rejected because the + /// Clear button doesn't carry wallet context — other wallets' + /// `PersistentShieldedSyncState` rows would silently survive + /// (the symptom the user reported when "Clear" left a row + /// behind for a non-active wallet). func clearLocalState(modelContext: ModelContext) async { - guard let walletId = boundWalletId else { - SDKLogger.log( - "ShieldedService.clearLocalState called before initial bind — ignoring", - minimumLevel: .medium - ) - return - } - - // 1) Delete this wallet's persisted shielded rows from - // SwiftData. Scoped to `walletId` so other wallets' - // state on the same SwiftData store stays intact. + // Capture the network BEFORE `reset()` nils it out so we + // can locate the per-network SQLite tree file. May still + // be nil if `bind` never ran this session; in that case + // there's no DB to delete and the SwiftData wipe is the + // whole job. + let networkForDB = network + + // 1) Delete every shielded SwiftData row across all + // wallets on this device. The Clear button is on the + // global Sync Status surface, so its semantics are + // "blow away shielded persistence", not "scope to one + // wallet". do { - try modelContext.delete( - model: PersistentShieldedNote.self, - where: #Predicate { $0.walletId == walletId } - ) - try modelContext.delete( - model: PersistentShieldedSyncState.self, - where: #Predicate { $0.walletId == walletId } - ) + try modelContext.delete(model: PersistentShieldedNote.self) + try modelContext.delete(model: PersistentShieldedSyncState.self) try modelContext.save() } catch { lastError = "Failed to wipe persisted shielded state: \(error.localizedDescription)" @@ -358,7 +352,33 @@ class ShieldedService: ObservableObject { return } - // 2) Tear down the in-memory mirror + subscriptions. The + // 2) Delete the per-network commitment-tree SQLite file + // (plus its WAL/SHM/journal sidecars) so the next + // `bind_shielded` on this network starts the tree at + // leaf 0. Without this the SwiftData snapshot says + // "fresh wallet" but the on-disk tree still carries + // every commitment from prior syncs — and the + // watermark/checkpoint asymmetry that produces is the + // most common source of the "Merkle witness + // unavailable" failures we're chasing. + if let networkForDB { + let dbPath = Self.dbPath(for: networkForDB) + let fm = FileManager.default + for suffix in ["", "-wal", "-shm", "-journal"] { + let path = dbPath + suffix + if fm.fileExists(atPath: path) { + do { + try fm.removeItem(atPath: path) + } catch { + SDKLogger.error( + "ShieldedService.clearLocalState: failed to delete \(path): \(error.localizedDescription)" + ) + } + } + } + } + + // 3) Tear down the in-memory mirror + subscriptions. The // service is now unbound; no further sync events flow // in and Sync Now will surface "Shielded service not // configured" until something re-binds (typically the From 9f664de6222b93b4e6568a2a1b6ca40960c09792 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 09:35:15 +0700 Subject: [PATCH 39/78] fix(swift-example-app): stop manager-wide shielded sync before wiping in Clear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the previous Clear-wipes-all change, the rows we deleted came right back: the row for the active wallet was rewritten (no nullifier checkpoint yet, since the SQLite tree was wiped), and a row for the second-bound wallet reappeared in parallel. Root cause: `ShieldedService` is per-wallet on the Swift side (only one `boundWalletId` at a time), but the Rust `PlatformWalletManager` keeps every wallet that ever called `bind_shielded` bound concurrently, and the background sync iterates all of them. Clearing SwiftData + SQLite while the loop was still running let the next sync pass fire for each bound wallet and the persister callback immediately re-derive the rows we'd just deleted. Add `walletManager.stopShieldedSync()` as the first step in `clearLocalState`, before any wipe. Best-effort — failure logs but doesn't abort the wipe. Navigating back into a wallet detail still works: the existing `rebindWalletScopedServices` path on `SwiftExampleAppApp` already calls `startShieldedSync()` after rebinding, so the sync resumes when (and only when) the user means it to. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Services/ShieldedService.swift | 45 +++++++++++++++---- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift index 4e70bfa8304..4f4b80397d4 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -330,14 +330,39 @@ class ShieldedService: ObservableObject { /// (the symptom the user reported when "Clear" left a row /// behind for a non-active wallet). func clearLocalState(modelContext: ModelContext) async { - // Capture the network BEFORE `reset()` nils it out so we - // can locate the per-network SQLite tree file. May still - // be nil if `bind` never ran this session; in that case - // there's no DB to delete and the SwiftData wipe is the - // whole job. + // Capture the network + manager BEFORE `reset()` nils + // them out. The network drives the per-network SQLite + // path; the manager is needed to stop the background + // shielded sync loop so Rust doesn't rewrite the rows we + // just wiped. let networkForDB = network + let managerForStop = walletManager + + // 1) Stop the manager-wide shielded sync loop BEFORE + // touching state on disk. The Swift `ShieldedService` + // is per-wallet-at-a-time, but the Rust + // `PlatformWalletManager` keeps **every** wallet that + // ever ran `bind_shielded` bound at the Rust level, + // and the background sync iterates all of them on its + // own cadence. If we don't stop the loop, the next + // pass fires per wallet and the persister callback + // immediately re-creates the `PersistentShieldedNote` + // / `PersistentShieldedSyncState` rows we're about to + // delete (this is exactly the "Clear left a row + // behind / re-derived a fresh row" symptom we saw on + // the prior attempt). Stopping is best-effort — + // failure logs but doesn't abort the wipe. + if let managerForStop { + do { + try managerForStop.stopShieldedSync() + } catch { + SDKLogger.error( + "ShieldedService.clearLocalState: stopShieldedSync failed: \(error.localizedDescription)" + ) + } + } - // 1) Delete every shielded SwiftData row across all + // 2) Delete every shielded SwiftData row across all // wallets on this device. The Clear button is on the // global Sync Status surface, so its semantics are // "blow away shielded persistence", not "scope to one @@ -352,7 +377,7 @@ class ShieldedService: ObservableObject { return } - // 2) Delete the per-network commitment-tree SQLite file + // 3) Delete the per-network commitment-tree SQLite file // (plus its WAL/SHM/journal sidecars) so the next // `bind_shielded` on this network starts the tree at // leaf 0. Without this the SwiftData snapshot says @@ -378,11 +403,13 @@ class ShieldedService: ObservableObject { } } - // 3) Tear down the in-memory mirror + subscriptions. The + // 4) Tear down the in-memory mirror + subscriptions. The // service is now unbound; no further sync events flow // in and Sync Now will surface "Shielded service not // configured" until something re-binds (typically the - // next navigation into a wallet detail screen). + // next navigation into a wallet detail screen, which + // also restarts the manager-wide sync via + // `rebindWalletScopedServices`). reset() } From 3da3fc0e274ca389da18d137b97e49056a567688 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 09:44:12 +0700 Subject: [PATCH 40/78] fix(swift-example-app): make post-Clear shielded state look intentional, not broken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing Sync Now after Clear set `lastError = "Shielded service not configured"`, painting the sync-status card with a red banner even though "unbound after Clear" is the explicit state the user asked for. Two changes make the post-Clear screen read as intentional: * Disable the Sync Now button when `!shieldedService.isBound`. Same shape as the existing `isSyncing` gate. Navigating into a wallet detail retriggers `rebindWalletScopedServices` and re-enables it. * `manualSync()` silently no-ops on missing `walletManager` instead of setting `lastError`. The button gate above makes this normally unreachable; the no-op is belt-and-braces so older shielded-sync entry points (or future ones) don't paint a misleading error. Also picks up the bot's belt-and-braces concern about Clear racing the persister callback: with the gate, Clear can't be tapped mid-sync (which would put SwiftData rewrites in the gap between delete and stop). `stopShieldedSync` is still called first inside `clearLocalState` regardless — the UI gate just prevents the human-paced double-tap that would expose the small remaining window. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Services/ShieldedService.swift | 11 +++++---- .../Core/Views/CoreContentView.swift | 24 ++++++++++++------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift index 4f4b80397d4..981f2a8290c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -256,10 +256,13 @@ class ShieldedService: ObservableObject { /// rely on the subscription alone to flip it back. func manualSync() async { guard !isSyncing else { return } - guard let walletManager else { - lastError = "Shielded service not configured" - return - } + // Unbound after Clear is an intentional state, not an + // error — surfacing "Shielded service not configured" in + // red made the post-Clear screen look broken. The Sync + // Now button is gated on `isBound` in the UI so this + // path is normally unreachable; the silent no-op is + // belt-and-braces. + guard let walletManager else { return } isSyncing = true lastError = nil diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index 7178ee2f939..b7e88ef8cf6 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -528,16 +528,24 @@ var body: some View { .buttonStyle(.borderedProminent) .tint(.purple) .controlSize(.mini) - .disabled(shieldedService.isSyncing) + // Disabled when not bound (post-Clear / + // pre-first-bind) so the button doesn't + // dangle in front of users with nothing + // to do — navigating into a wallet detail + // retriggers `rebindWalletScopedServices` + // and re-enables it. + .disabled(shieldedService.isSyncing || !shieldedService.isBound) Button { - // Wipe this wallet's persisted shielded - // rows (notes + sync state) AND re-bind - // so the next Sync Now starts from - // scratch — bare `reset()` left the - // service unbound, surfacing "Shielded - // service not configured" on the next - // press. + // Wipe every wallet's persisted shielded + // rows + the per-network commitment-tree + // SQLite, after stopping the manager-wide + // shielded sync loop so the persister + // callback can't immediately re-derive + // what we just deleted. Leaves the + // service unbound — to resume, navigate + // into a wallet detail which retriggers + // `rebindWalletScopedServices`. Task { await shieldedService.clearLocalState( modelContext: modelContext From 6673cc23a5bd62522dec6fceb10e24c260bf970b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 09:59:06 +0700 Subject: [PATCH 41/78] fix(platform-wallet): shielded sync stops re-counting + re-fetching the partial tail chunk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues observed on a sleepy 4-note network: 1. The host's cumulative "Scanned" counter climbed by +4 every minute even though nothing changed on Platform. 2. The wallet re-fetched + re-trial-decrypted those same 4 notes every cadence interval. Root cause is Platform's chunked-sync contract: when the returned chunk is partial (fewer than `chunk_size` notes), the SDK reports `next_start_index = chunk_start` so the caller comes back to the same chunk on the next pass — the buffer chunk is mutable until full. Combined with the chunk-aligned `aligned_start` that wallet/shielded/sync.rs computes, every pass re-fetches the whole partial chunk. Fix layered in two: * **Reporting** — `SyncNotesResult.total_scanned` now reports only **new positions** observed this pass (`max(0, (aligned_start + total_notes_scanned) - already_have)`) instead of the raw wire count. The host's cumulative counter plateaus at the actual number of distinct positions seen. * **Work-skip** — `ShieldedWallet` gains `last_caught_up_at: Mutex>`, stamped at the end of any sync pass that observed nothing new on either axis (no new positions, no newly-spent nullifiers). The background loop's `sync(force=false)` short-circuits if the stamp is within `CAUGHT_UP_COOLDOWN` (30s) — no SDK fetch, no trial-decrypt. Activity on either axis clears the stamp so the next pass runs immediately. `force: bool` is plumbed through `ShieldedWallet::sync`, `PlatformWallet::shielded_sync`, `ShieldedSyncManager::sync_now` and `ShieldedSyncManager::sync_wallet`. The background sync thread passes `false` (honors cooldown); both FFI sync entry points (`platform_wallet_manager_shielded_sync_sync_now` and `platform_wallet_manager_shielded_sync_wallet`) pass `true` — they're user-initiated taps, and a user who just sent a transaction should see the result on the next press rather than wait out the cooldown. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/shielded_sync.rs | 15 +++- .../src/manager/shielded_sync.rs | 23 +++++-- .../src/wallet/platform_wallet.rs | 7 +- .../src/wallet/shielded/mod.rs | 21 ++++++ .../src/wallet/shielded/sync.rs | 69 ++++++++++++++++++- 5 files changed, 124 insertions(+), 11 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs index b2aa3146904..271ff29fc95 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs @@ -139,12 +139,20 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_sync_set_interval( } /// Run one shielded sync pass across all registered wallets. +/// +/// This is the user-initiated entry point (the host's "Sync Now" +/// button), so `force=true` is passed through to bypass the +/// per-wallet caught-up cooldown: a user who just sent a +/// transaction and taps the button should see the resulting +/// note immediately, not wait out the cooldown. The background +/// loop in `ShieldedSyncManager::start()` uses `force=false` +/// and honors the cooldown. #[no_mangle] pub unsafe extern "C" fn platform_wallet_manager_shielded_sync_sync_now( handle: Handle, ) -> PlatformWalletFFIResult { let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { - runtime().block_on(manager.shielded_sync().sync_now()); + runtime().block_on(manager.shielded_sync().sync_now(true)); }); unwrap_option_or_return!(option); PlatformWalletFFIResult::ok() @@ -415,7 +423,10 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_sync_wallet( std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { - runtime().block_on(manager.shielded_sync().sync_wallet(&wallet_id)) + // Per-wallet sync_wallet is exclusively a user-initiated + // entry point — same `force=true` reasoning as + // `platform_wallet_manager_shielded_sync_sync_now`. + runtime().block_on(manager.shielded_sync().sync_wallet(&wallet_id, true)) }); let result = unwrap_option_or_return!(option); match result { diff --git a/packages/rs-platform-wallet/src/manager/shielded_sync.rs b/packages/rs-platform-wallet/src/manager/shielded_sync.rs index 167958bd8c8..a1e70ca545e 100644 --- a/packages/rs-platform-wallet/src/manager/shielded_sync.rs +++ b/packages/rs-platform-wallet/src/manager/shielded_sync.rs @@ -221,7 +221,14 @@ impl ShieldedSyncManager { break; } - this.sync_now().await; + // Background-loop cadence — honor the + // per-wallet caught-up cooldown so a + // sleepy network doesn't refetch + + // re-trial-decrypt the partial buffer + // chunk every interval. User-initiated + // syncs pass `force=true` to the FFI + // entry point below and bypass this. + this.sync_now(false).await; let interval = this.interval(); tokio::select! { @@ -260,9 +267,16 @@ impl ShieldedSyncManager { /// Run one sync pass across every registered wallet. /// + /// `force` is propagated to each wallet's + /// [`shielded_sync(force)`](crate::wallet::PlatformWallet::shielded_sync): + /// the background loop passes `false` to honor the per-wallet + /// caught-up cooldown; user-initiated paths (the manual + /// "Sync Now" FFI) pass `true` so a tap always re-checks + /// Platform. + /// /// If a pass is already in flight, returns an empty summary and /// skips — the caller can inspect [`is_syncing`] to distinguish. - pub async fn sync_now(&self) -> ShieldedSyncPassSummary { + pub async fn sync_now(&self, force: bool) -> ShieldedSyncPassSummary { if self .is_syncing .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) @@ -278,7 +292,7 @@ impl ShieldedSyncManager { let mut summary = ShieldedSyncPassSummary::default(); for (wallet_id, wallet) in snapshot { - let outcome = match wallet.shielded_sync().await { + let outcome = match wallet.shielded_sync(force).await { Ok(Some(result)) => WalletShieldedOutcome::Ok(result), Ok(None) => WalletShieldedOutcome::Skipped, Err(e) => { @@ -322,6 +336,7 @@ impl ShieldedSyncManager { pub async fn sync_wallet( &self, wallet_id: &WalletId, + force: bool, ) -> Result, crate::error::PlatformWalletError> { let wallet = { let wallets = self.wallets.read().await; @@ -346,7 +361,7 @@ impl ShieldedSyncManager { return Ok(None); } - let result = wallet.shielded_sync().await; + let result = wallet.shielded_sync(force).await; self.is_syncing.store(false, Ordering::Release); result diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index a1818c228d6..ace5ebb91a3 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -416,10 +416,13 @@ impl PlatformWallet { /// surfacing an error). Returns `Ok(Some(summary))` after a /// successful pass, or `Err(_)` if the underlying sync failed. #[cfg(feature = "shielded")] - pub async fn shielded_sync(&self) -> Result, PlatformWalletError> { + pub async fn shielded_sync( + &self, + force: bool, + ) -> Result, PlatformWalletError> { let guard = self.shielded.read().await; match guard.as_ref() { - Some(wallet) => Ok(Some(wallet.sync().await?)), + Some(wallet) => Ok(Some(wallet.sync(force).await?)), None => Ok(None), } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs index 2b0bc239313..e1d8b2a95fd 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs @@ -80,8 +80,28 @@ pub struct ShieldedWallet { /// watermarks. `None` means in-memory only — useful for /// tests and short-lived wallets. pub(super) persister: Option, + /// Timestamp of the last sync pass that observed no new + /// commitments or newly-spent nullifiers. Honored as a + /// cooldown by [`sync(force=false)`](Self::sync) so the + /// background loop doesn't re-fetch and re-trial-decrypt the + /// same partial chunk every cadence interval — a Platform + /// partial chunk's `next_start_index` is intentionally pinned + /// at its chunk-start (the buffer chunk is mutable until + /// full), and chunk-alignment in [`sync_notes`](Self::sync_notes) + /// then re-fetches it on every pass. Manual user-initiated + /// syncs pass `force=true` and ignore this. Cleared whenever + /// a sync observes new positions or new spends so the next + /// pass runs immediately. + pub(super) last_caught_up_at: std::sync::Mutex>, } +/// How long after a no-op sync the background loop should skip +/// further passes. Tuned against the wallet's typical 60s sync +/// cadence — halves wire calls in steady-state while keeping +/// "new notes" discovery latency bounded at one cooldown window +/// plus the next loop tick. Manual `force=true` syncs bypass. +pub(super) const CAUGHT_UP_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(30); + impl ShieldedWallet { /// Construct a [`ShieldedWallet`] from pre-derived keysets. /// @@ -108,6 +128,7 @@ impl ShieldedWallet { accounts, store: Arc::new(RwLock::new(store)), persister: None, + last_caught_up_at: std::sync::Mutex::new(None), }) } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs index b04410a87fe..567d8afcdde 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs @@ -30,7 +30,13 @@ const CHUNK_SIZE: u64 = 2048; pub struct SyncNotesResult { /// Per-account count of new notes discovered in this pass. pub new_notes_per_account: BTreeMap, - /// Total encrypted notes scanned. + /// Number of **new** positions scanned this pass — i.e. + /// commitments at positions `>= already_have` that the SDK + /// returned. Re-scanned positions (the partial-chunk + /// re-fetch that Platform's chunked sync semantics force on + /// every pass while the buffer chunk is mutable) are + /// excluded so the cumulative counter the host displays + /// reflects "new work seen", not wire-level fetch volume. pub total_scanned: u64, } @@ -278,9 +284,19 @@ impl ShieldedWallet { new_index, "Shielded sync finished" ); + // Report only the **new** positions observed this pass. + // The SDK's `total_notes_scanned` counts every commitment + // in the fetched chunks, but Platform's chunked-sync + // semantics re-fetch the partial chunk every cadence + // (the buffer chunk is mutable until full), so the raw + // wire count climbs by the chunk size every pass on a + // sleepy network even though nothing changed. Subtract + // `already_have` so the host counter reflects newly-seen + // positions instead of wire volume. + let scanned_new = (aligned_start + result.total_notes_scanned).saturating_sub(already_have); Ok(SyncNotesResult { new_notes_per_account, - total_scanned: result.total_notes_scanned, + total_scanned: scanned_new, }) } @@ -383,10 +399,57 @@ impl ShieldedWallet { } /// Full sync: notes + nullifiers + per-account balance summary. - pub async fn sync(&self) -> Result { + /// + /// `force` controls whether the [caught-up cooldown](super::CAUGHT_UP_COOLDOWN) + /// is honored. The background sync loop passes `force=false` + /// so a no-op pass (no new positions, no newly-spent + /// nullifiers) suppresses the next pass for the cooldown + /// window — without this the SDK's chunked sync forces a + /// fresh fetch + trial-decrypt of the (partial) tail chunk + /// on every cadence interval. User-initiated paths (the + /// "Sync Now" button) pass `force=true` and always run, so + /// a user who just sent a transaction sees the new note on + /// the next tap rather than waiting out the cooldown. + pub async fn sync(&self, force: bool) -> Result { + if !force { + if let Ok(guard) = self.last_caught_up_at.lock() { + if let Some(when) = *guard { + let elapsed = when.elapsed(); + if elapsed < super::CAUGHT_UP_COOLDOWN { + debug!( + elapsed_secs = elapsed.as_secs(), + cooldown_secs = super::CAUGHT_UP_COOLDOWN.as_secs(), + "Shielded sync skipped — within caught-up cooldown" + ); + return Ok(ShieldedSyncSummary { + notes_result: SyncNotesResult::default(), + newly_spent_per_account: BTreeMap::new(), + balances: self.balances().await?, + }); + } + } + } + } + let notes_result = self.sync_notes().await?; let newly_spent_per_account = self.check_nullifiers().await?; let balances = self.balances().await?; + + // Mark caught-up only when this pass observed nothing + // new on either axis. Any activity (new positions or new + // spends) clears the timestamp so the next pass runs + // immediately rather than back-pressuring fresh work + // behind the cooldown. + let was_no_op = + notes_result.total_scanned == 0 && newly_spent_per_account.values().all(|&n| n == 0); + if let Ok(mut guard) = self.last_caught_up_at.lock() { + if was_no_op { + *guard = Some(std::time::Instant::now()); + } else { + *guard = None; + } + } + Ok(ShieldedSyncSummary { notes_result, newly_spent_per_account, From 2e7d392733c7034468530c0e8ea9fd5b11aef8af Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 10:04:04 +0700 Subject: [PATCH 42/78] fix(swift-example-app): gate shielded Clear on isSyncing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the Sync Now button's gate so the user can't tap Clear mid-sync. `clearLocalState` already calls `stopShieldedSync()` first, but stop is best-effort and the persister callback can drain rows into SwiftData in the window between our delete saves and the Rust loop actually quiescing — which re-introduces rows that defeat the wipe. The UI gate eliminates the human-paced double-tap path entirely. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SwiftExampleApp/Core/Views/CoreContentView.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index b7e88ef8cf6..d8aa4bc4df9 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -559,6 +559,16 @@ var body: some View { .buttonStyle(.borderedProminent) .tint(.red) .controlSize(.mini) + // Gated on `isSyncing` to close the + // double-tap window where the user could + // hit Clear *while* a sync is in flight. + // `clearLocalState` calls + // `stopShieldedSync()` first, but stop is + // best-effort and the persister callback + // can still drain rows into SwiftData + // between our delete and the loop + // actually quiescing. + .disabled(shieldedService.isSyncing) } } .padding(.vertical, 4) From 81addc994d0debf1f2a275d070cbdd9c51f29ab5 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 10:10:55 +0700 Subject: [PATCH 43/78] fix(platform-wallet): drop MutexGuard before await in shielded sync cooldown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clippy's `await_holding_lock` lint (CI's `-D warnings`) tripped on `ShieldedWallet::sync`: the previous cooldown check held the `std::sync::Mutex` guard on `last_caught_up_at` across the `self.balances().await?` inside the early-return branch. The guard is `!Send`, so holding it across an await is a real bug (deadlock-prone, non-portable across executors), not just a lint. Snapshot the elapsed time into a `cooldown_remaining: Option` local outside the lock scope, then act on that local. The lock is held for one nanosecond's worth of work — read the timestamp, saturating-subtract — and released before the early-return path reaches the balances `await`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/wallet/shielded/sync.rs | 47 ++++++++++++------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs index 567d8afcdde..b3f1d6348ae 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs @@ -411,24 +411,35 @@ impl ShieldedWallet { /// a user who just sent a transaction sees the new note on /// the next tap rather than waiting out the cooldown. pub async fn sync(&self, force: bool) -> Result { - if !force { - if let Ok(guard) = self.last_caught_up_at.lock() { - if let Some(when) = *guard { - let elapsed = when.elapsed(); - if elapsed < super::CAUGHT_UP_COOLDOWN { - debug!( - elapsed_secs = elapsed.as_secs(), - cooldown_secs = super::CAUGHT_UP_COOLDOWN.as_secs(), - "Shielded sync skipped — within caught-up cooldown" - ); - return Ok(ShieldedSyncSummary { - notes_result: SyncNotesResult::default(), - newly_spent_per_account: BTreeMap::new(), - balances: self.balances().await?, - }); - } - } - } + // Snapshot the timestamp into a local so the `MutexGuard` + // is dropped before any `.await` below — `std::sync::Mutex` + // is `!Send` across await points (clippy's + // `await_holding_lock` lint flags this) and the rest of + // `sync()` is heavily async. + let cooldown_remaining: Option = if force { + None + } else { + self.last_caught_up_at + .lock() + .ok() + .and_then(|guard| *guard) + .map(|when| { + super::CAUGHT_UP_COOLDOWN.saturating_sub(when.elapsed()) + }) + .filter(|remaining| !remaining.is_zero()) + }; + + if let Some(remaining) = cooldown_remaining { + debug!( + cooldown_remaining_secs = remaining.as_secs(), + cooldown_total_secs = super::CAUGHT_UP_COOLDOWN.as_secs(), + "Shielded sync skipped — within caught-up cooldown" + ); + return Ok(ShieldedSyncSummary { + notes_result: SyncNotesResult::default(), + newly_spent_per_account: BTreeMap::new(), + balances: self.balances().await?, + }); } let notes_result = self.sync_notes().await?; From c3faf57f493a83915efea3adda5bde2e7bcfeb86 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 10:14:39 +0700 Subject: [PATCH 44/78] style(platform-wallet): apply rustfmt to sync.rs cooldown closure Single-expression closures collapse to one line; `cargo fmt --check` (run as part of the macOS workspace-tests job) blocked on the multi-line `.map(|when| { ... })` form left over from the prior commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/rs-platform-wallet/src/wallet/shielded/sync.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs index b3f1d6348ae..bf6f5216160 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs @@ -423,9 +423,7 @@ impl ShieldedWallet { .lock() .ok() .and_then(|guard| *guard) - .map(|when| { - super::CAUGHT_UP_COOLDOWN.saturating_sub(when.elapsed()) - }) + .map(|when| super::CAUGHT_UP_COOLDOWN.saturating_sub(when.elapsed())) .filter(|remaining| !remaining.is_zero()) }; From d3673f291f1a114e6f4a7e79f424436ff80402c2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 10:20:35 +0700 Subject: [PATCH 45/78] fix(swift-example-app): post-Clear Sync Now self-binds + syncs from the same screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior Clear flow nilled the service's bind credentials via `reset()`, leaving the Sync Now button greyed forever — to resume the user had to navigate to a wallet detail to retrigger `rebindWalletScopedServices`. No path back from the Sync Status screen, which is where the Clear button lives. Two changes: * `clearLocalState` no longer calls `reset()`. Inline soft- cleanup zeroes the published mirror + cancels subscriptions but keeps `walletManager` / `boundWalletId` / `network` / `resolver` / `boundAccounts` so a re-bind is one tap away. * Add `canResume: Bool` (true iff credentials are stashed). `manualSync` checks `!isBound && canResume` and re-binds before kicking off the sync. The Sync Now button gates on `canResume` instead of `isBound`, so it stays enabled immediately after Clear. Post-Clear UX is now: "Not bound" + Shielded Balance 0 + Sync Now enabled. Tap Sync Now → self-binds → manager-wide sync resumes → persister callback hydrates SwiftData from genesis (the SQLite tree was wiped, so it really is from genesis). No navigation detour required. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Services/ShieldedService.swift | 111 +++++++++++++----- .../Core/Views/CoreContentView.swift | 16 +-- 2 files changed, 92 insertions(+), 35 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift index 981f2a8290c..61d4412c10f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -256,14 +256,36 @@ class ShieldedService: ObservableObject { /// rely on the subscription alone to flip it back. func manualSync() async { guard !isSyncing else { return } - // Unbound after Clear is an intentional state, not an - // error — surfacing "Shielded service not configured" in - // red made the post-Clear screen look broken. The Sync - // Now button is gated on `isBound` in the UI so this - // path is normally unreachable; the silent no-op is - // belt-and-braces. guard let walletManager else { return } + // If we're unbound (typically because the user pressed + // Clear earlier) but still have the bind credentials, + // re-bind first so the next `syncShieldedNow()` call + // has a Rust-side shielded sub-wallet to walk. Without + // this, post-Clear Sync Now would no-op forever and the + // user has no path back to a synced state from this + // screen. + if !isBound, canResume { + guard + let walletId = boundWalletId, + let resolver, + let network + else { return } + let accounts = boundAccounts.isEmpty ? [0] : boundAccounts + bind( + walletManager: walletManager, + walletId: walletId, + network: network, + resolver: resolver, + accounts: accounts + ) + // `bind` is best-effort; if it failed (e.g. the + // mnemonic resolver was declined), `isBound` stays + // false and `lastError` is populated. Bail rather + // than chain a sync that will fail the same way. + guard isBound else { return } + } + isSyncing = true lastError = nil defer { isSyncing = false } @@ -275,6 +297,20 @@ class ShieldedService: ObservableObject { } } + /// Whether the service has enough stashed state to perform a + /// `bind` on demand from a Clear → Sync Now flow. Distinct + /// from [`isBound`]: after Clear we are not currently bound, + /// but the credentials live on so [`manualSync`] can rebind + /// without the user navigating away from the Sync Status + /// screen. False on a fresh session (no bind has ever run) + /// or after [`reset`]. + var canResume: Bool { + walletManager != nil + && boundWalletId != nil + && resolver != nil + && network != nil + } + /// Reset display state. Cancels the manager subscriptions but /// does not stop the manager-wide background loop — that's the /// caller's responsibility (see @@ -301,31 +337,37 @@ class ShieldedService: ObservableObject { } /// Wipe every wallet's persisted shielded state and stop. The - /// service is left unbound — no auto-rebind, no auto-rescan. - /// - /// Bare [`reset`] just tears down subscriptions and nils the - /// in-memory mirror — the SwiftData rows - /// (`PersistentShieldedNote`, `PersistentShieldedSyncState`) - /// survive, so the next bind hydrates the wallet right back to - /// the state the user just tried to clear. + /// service is left unbound, but the stashed bind credentials + /// (`walletManager` / `boundWalletId` / `network` / `resolver` + /// / `boundAccounts`) survive so [`manualSync`] can rebind on + /// demand without the user navigating away. The "Sync Now" + /// button on the Sync Status screen is the path back — + /// pressing it self-binds + syncs from a clean SQLite tree + /// and an empty SwiftData snapshot. /// /// The user reaches this through the Clear button on the /// **global** Sync Status surface, not a per-wallet screen. /// "Clear" therefore wipes every wallet's shielded rows + the - /// per-network commitment-tree SQLite file, so re-binding any - /// wallet on this network walks back through the cmx stream - /// from genesis. Re-syncing means navigating back into a - /// wallet detail (which retriggers - /// `rebindWalletScopedServices`). + /// per-network commitment-tree SQLite file, so the rebind on + /// the next Sync Now walks the cmx stream from genesis. /// /// What it does NOT touch: - /// * The manager-wide shielded sync loop. The next bind - /// re-attaches a fresh subscription. + /// * The manager-wide shielded sync loop is `stopShieldedSync`'d + /// first so the persister callback can't re-derive the + /// rows we're deleting; it restarts when something binds + /// (either `manualSync` self-binding or + /// `rebindWalletScopedServices` firing on a navigation). /// * The Rust-side shielded sub-wallet binding (there's no /// unbind FFI today; the next `bindShielded` call replaces /// the binding wholesale, and the freshly-bound store /// starts empty because both the SwiftData snapshot and /// the SQLite tree are gone). + /// * The stashed credentials on the service itself — bare + /// [`reset`] would nil them, leaving the user with no + /// path back to a synced state from this screen. The + /// inline soft-cleanup below only zeroes the published + /// mirror; [`canResume`] therefore stays `true` and the + /// Sync Now button stays usable. /// /// Per-wallet scoping was tried first and rejected because the /// Clear button doesn't carry wallet context — other wallets' @@ -406,14 +448,27 @@ class ShieldedService: ObservableObject { } } - // 4) Tear down the in-memory mirror + subscriptions. The - // service is now unbound; no further sync events flow - // in and Sync Now will surface "Shielded service not - // configured" until something re-binds (typically the - // next navigation into a wallet detail screen, which - // also restarts the manager-wide sync via - // `rebindWalletScopedServices`). - reset() + // 4) Soft cleanup: zero the published mirror + cancel + // subscriptions, but KEEP the bind credentials + // (walletManager / boundWalletId / network / resolver + // / boundAccounts) so [`manualSync`] can re-bind on + // the next Sync Now tap. Bare [`reset`] would nil + // them and leave the user stranded on this screen. + syncStateCancellable?.cancel() + syncEventCancellable?.cancel() + isBound = false + isSyncing = false + shieldedBalance = 0 + lastNewNotes = 0 + lastNewlySpent = 0 + lastSyncTime = nil + lastError = nil + orchardDisplayAddress = nil + addressesByAccount = [:] + syncCountSinceLaunch = 0 + totalScanned = 0 + totalNewNotes = 0 + totalNewlySpent = 0 } // MARK: - Sync event handling diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index d8aa4bc4df9..c8a8566792c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -528,13 +528,15 @@ var body: some View { .buttonStyle(.borderedProminent) .tint(.purple) .controlSize(.mini) - // Disabled when not bound (post-Clear / - // pre-first-bind) so the button doesn't - // dangle in front of users with nothing - // to do — navigating into a wallet detail - // retriggers `rebindWalletScopedServices` - // and re-enables it. - .disabled(shieldedService.isSyncing || !shieldedService.isBound) + // Disabled while a sync is in flight, and + // pre-first-bind when there are no stashed + // credentials to resume from. Post-Clear + // is *not* disabled — `manualSync` rebinds + // on demand from the credentials kept by + // `clearLocalState`, so the user has a + // path back to a synced state without + // having to navigate away. + .disabled(shieldedService.isSyncing || !shieldedService.canResume) Button { // Wipe every wallet's persisted shielded From 524a722ad9d4a46925e4a9c65c5b45d78ba2f338 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 10:29:24 +0700 Subject: [PATCH 46/78] fix(swift-example-app): stop deleting per-network SQLite tree on shielded Clear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unlink ran while Rust still held the SQLite handle open through `FileBackedShieldedStore` (no unbind FFI exists to drop it). Yanking `shielded_tree_.sqlite` plus its `-wal`/`-shm`/`-journal` sidecars out from under a live connection is a SQLite-documented corruption pattern — and the corruption it causes is exactly the "Merkle witness unavailable" class of failures the wipe was meant to defuse. Also fixes the mismatched-scope issue the same review surfaced: the SwiftData delete is unscoped (every wallet across every network), but the SQLite delete was per-network — wallets on other networks ended up with empty SwiftData snapshots paired against full on-disk trees, which is the same asymmetry the unlink was supposed to prevent in the first place. Dropping the unlink eliminates the mismatch. Why this is safe: the tree's existing leaves are still chain history (no per-wallet data); the marked-position auth paths survive across rebind; re-sync from an empty SwiftData snapshot re-decrypts every note while `append_commitment` skips already-appended positions, so the tree and the wallet state stay consistent. Also updates the Clear-button inline comment to match — it was stale through several iterations of `clearLocalState` and was actively misleading about the rebind path. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Services/ShieldedService.swift | 58 +++++++------------ .../Core/Views/CoreContentView.swift | 22 ++++--- 2 files changed, 35 insertions(+), 45 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift index 61d4412c10f..9a1c1895263 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -357,11 +357,24 @@ class ShieldedService: ObservableObject { /// rows we're deleting; it restarts when something binds /// (either `manualSync` self-binding or /// `rebindWalletScopedServices` firing on a navigation). + /// * The per-network commitment-tree SQLite file at + /// `dbPath(for:)`. Earlier revisions of this helper + /// unlinked it for a "true clean slate", but with no + /// unbind FFI the Rust-side `FileBackedShieldedStore` + /// keeps the SQLite handle open across the wipe — yanking + /// the file (plus -wal / -shm / -journal sidecars) out + /// from under a live connection is a SQLite-documented + /// corruption pattern, and the corruption it causes is + /// precisely the "Merkle witness unavailable" class of + /// failures the wipe was meant to defuse. The tree's + /// existing leaves are still correct (just chain history); + /// the marked-position auth paths survive; and re-sync + /// with an empty SwiftData snapshot re-decrypts every + /// note while `append_commitment` skips already-appended + /// positions, so the tree stays consistent across rebind. /// * The Rust-side shielded sub-wallet binding (there's no /// unbind FFI today; the next `bindShielded` call replaces - /// the binding wholesale, and the freshly-bound store - /// starts empty because both the SwiftData snapshot and - /// the SQLite tree are gone). + /// the binding wholesale). /// * The stashed credentials on the service itself — bare /// [`reset`] would nil them, leaving the user with no /// path back to a synced state from this screen. The @@ -375,12 +388,11 @@ class ShieldedService: ObservableObject { /// (the symptom the user reported when "Clear" left a row /// behind for a non-active wallet). func clearLocalState(modelContext: ModelContext) async { - // Capture the network + manager BEFORE `reset()` nils - // them out. The network drives the per-network SQLite - // path; the manager is needed to stop the background - // shielded sync loop so Rust doesn't rewrite the rows we - // just wiped. - let networkForDB = network + // Capture the manager before the soft-cleanup below + // touches anything, so we can stop the background loop + // first. (We used to capture `network` here too for the + // per-network SQLite delete; that step is gone — see + // doc above for why.) let managerForStop = walletManager // 1) Stop the manager-wide shielded sync loop BEFORE @@ -422,33 +434,7 @@ class ShieldedService: ObservableObject { return } - // 3) Delete the per-network commitment-tree SQLite file - // (plus its WAL/SHM/journal sidecars) so the next - // `bind_shielded` on this network starts the tree at - // leaf 0. Without this the SwiftData snapshot says - // "fresh wallet" but the on-disk tree still carries - // every commitment from prior syncs — and the - // watermark/checkpoint asymmetry that produces is the - // most common source of the "Merkle witness - // unavailable" failures we're chasing. - if let networkForDB { - let dbPath = Self.dbPath(for: networkForDB) - let fm = FileManager.default - for suffix in ["", "-wal", "-shm", "-journal"] { - let path = dbPath + suffix - if fm.fileExists(atPath: path) { - do { - try fm.removeItem(atPath: path) - } catch { - SDKLogger.error( - "ShieldedService.clearLocalState: failed to delete \(path): \(error.localizedDescription)" - ) - } - } - } - } - - // 4) Soft cleanup: zero the published mirror + cancel + // 3) Soft cleanup: zero the published mirror + cancel // subscriptions, but KEEP the bind credentials // (walletManager / boundWalletId / network / resolver // / boundAccounts) so [`manualSync`] can re-bind on diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index c8a8566792c..5e40905c3c8 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -539,15 +539,19 @@ var body: some View { .disabled(shieldedService.isSyncing || !shieldedService.canResume) Button { - // Wipe every wallet's persisted shielded - // rows + the per-network commitment-tree - // SQLite, after stopping the manager-wide - // shielded sync loop so the persister - // callback can't immediately re-derive - // what we just deleted. Leaves the - // service unbound — to resume, navigate - // into a wallet detail which retriggers - // `rebindWalletScopedServices`. + // Stop the manager-wide shielded sync + // loop, then wipe every wallet's + // persisted shielded rows (notes + + // sync state). The Swift mirror zeros + // out and the service goes unbound, + // but the bind credentials are kept + // so the user can tap Sync Now to + // self-rebind from this screen — no + // navigation detour required. The + // on-disk SQLite tree is intentionally + // NOT deleted (Rust still holds its + // handle open via FileBackedShieldedStore; + // see clearLocalState's doc). Task { await shieldedService.clearLocalState( modelContext: modelContext From bf16bc8e43603e286bc1b8c1b3014475c47f2c84 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 11:20:27 +0700 Subject: [PATCH 47/78] fix(swift-example-app): restart manager-wide shielded sync after Clear self-rebind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `clearLocalState` stops the manager-wide loop on the way in (needed so the persister callback can't re-derive the rows we just wiped). The recovery path in `manualSync` then re-binds via `bind(...)` and runs one manual `syncShieldedNow()` — but `bind()` itself doesn't start the manager loop, so the user got exactly one sync pass after Clear and then silently lost all background shielded updates until they navigated into a wallet detail (which is what re-fires `rebindWalletScopedServices`, where the start currently lives). `clearLocalState`'s own doc claimed the loop "restarts when something binds (either `manualSync` self-binding or `rebindWalletScopedServices` firing on a navigation)"; only the latter actually started it. Add a guarded `startShieldedSync()` call after the self-rebind succeeds, mirroring the same `isShieldedSyncRunning()` guard `rebindWalletScopedServices` uses to avoid double-starting. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Services/ShieldedService.swift | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift index 9a1c1895263..c1f81357210 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -284,6 +284,24 @@ class ShieldedService: ObservableObject { // false and `lastError` is populated. Bail rather // than chain a sync that will fail the same way. guard isBound else { return } + + // Restart the manager-wide shielded sync loop that + // `clearLocalState` stopped on the way in. Without + // this, the user would get exactly one manual sync + // pass here and then no further background updates + // until they navigated into a wallet detail (which + // is what re-triggers `rebindWalletScopedServices`). + // Guarded against double-start to mirror the same + // start path in `SwiftExampleAppApp.rebindWalletScopedServices`. + do { + if try !walletManager.isShieldedSyncRunning() { + try walletManager.startShieldedSync() + } + } catch { + SDKLogger.error( + "ShieldedService.manualSync: failed to restart shielded sync loop: \(error.localizedDescription)" + ) + } } isSyncing = true @@ -354,9 +372,10 @@ class ShieldedService: ObservableObject { /// What it does NOT touch: /// * The manager-wide shielded sync loop is `stopShieldedSync`'d /// first so the persister callback can't re-derive the - /// rows we're deleting; it restarts when something binds - /// (either `manualSync` self-binding or - /// `rebindWalletScopedServices` firing on a navigation). + /// rows we're deleting. It restarts on either of the two + /// bind paths: [`manualSync`] self-binding (which calls + /// `startShieldedSync()` after a successful self-rebind), + /// or `rebindWalletScopedServices` firing on a navigation. /// * The per-network commitment-tree SQLite file at /// `dbPath(for:)`. Earlier revisions of this helper /// unlinked it for a "true clean slate", but with no From 848c90573db39e551de1c4d575a46319759f2e69 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 11:54:05 +0700 Subject: [PATCH 48/78] fix(swift-example-app): run manual shielded sync before restarting background loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prior commit `bf16bc8e43` started the manager-wide loop *before* the manual `syncShieldedNow()` call. `start()` spawns a thread whose first iteration calls `sync_now(false)` immediately, and the manager's `is_syncing` CAS in `sync_now` lets only one caller win per pass — the other silently returns an empty summary. Net effect: a freshly-spawned loop iteration could race ahead and swallow the user-initiated sync the Sync Now tap was supposed to perform, exactly the failure mode the previous fix was meant to close. Move the `startShieldedSync()` call past the awaited `syncShieldedNow()`. The manual pass runs uncontested, releases `is_syncing` on completion, and the background loop's first tick fires on its own cadence afterward. Loop-start moves out of the `if !isBound` block since the same race shape applies to any post-Clear-or-not Sync Now where the loop happens to be stopped — `try !isShieldedSyncRunning()` keeps it a no-op when the loop is already going. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Services/ShieldedService.swift | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift index c1f81357210..50f2e6ea7c9 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -284,24 +284,6 @@ class ShieldedService: ObservableObject { // false and `lastError` is populated. Bail rather // than chain a sync that will fail the same way. guard isBound else { return } - - // Restart the manager-wide shielded sync loop that - // `clearLocalState` stopped on the way in. Without - // this, the user would get exactly one manual sync - // pass here and then no further background updates - // until they navigated into a wallet detail (which - // is what re-triggers `rebindWalletScopedServices`). - // Guarded against double-start to mirror the same - // start path in `SwiftExampleAppApp.rebindWalletScopedServices`. - do { - if try !walletManager.isShieldedSyncRunning() { - try walletManager.startShieldedSync() - } - } catch { - SDKLogger.error( - "ShieldedService.manualSync: failed to restart shielded sync loop: \(error.localizedDescription)" - ) - } } isSyncing = true @@ -313,6 +295,27 @@ class ShieldedService: ObservableObject { lastError = "Shielded sync error: \(error.localizedDescription)" SDKLogger.log(lastError ?? "", minimumLevel: .medium) } + + // Restart the manager-wide shielded sync loop AFTER the + // manual `syncShieldedNow()` call completes. `start()` + // spawns a background thread whose first iteration calls + // `sync_now(false)` immediately, and the manager's + // `is_syncing` CAS in `sync_now` means whichever caller + // gets there first wins — the other silently no-ops with + // an empty summary. Starting *after* the manual pass + // returns lets the user-initiated tap run uncontested, + // and the loop's first tick happens on its own cadence. + // The guard against double-start mirrors the equivalent + // call in `SwiftExampleAppApp.rebindWalletScopedServices`. + do { + if try !walletManager.isShieldedSyncRunning() { + try walletManager.startShieldedSync() + } + } catch { + SDKLogger.error( + "ShieldedService.manualSync: failed to (re)start shielded sync loop: \(error.localizedDescription)" + ) + } } /// Whether the service has enough stashed state to perform a From b398e1f2e2826b0c8e62083054630bd6f02b55be Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 12:14:13 +0700 Subject: [PATCH 49/78] fix(swift-example-app): don't tick shielded sync counter/timestamp on cooldown no-op passes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the prior commit reordered `manualSync` so the background loop starts *after* the awaited manual `syncShieldedNow()`, every Sync Now tap produced two success events back-to-back: the real manual pass, then the loop's immediate-first-tick — which landed inside the just-stamped `last_caught_up_at` cooldown window and returned an empty `ShieldedSyncSummary` that Swift nonetheless saw as `result.success == true`. The cooldown suppresses the network + trial-decrypt work on the Rust side but doesn't surface that distinction across the FFI yet, so `syncCountSinceLaunch` was double-ticking and `lastSyncTime` was resetting to "now" on every cooldown skip. Gate the counter / timestamp updates on a `wasNoOp` check — true when the pass returned zero scanned positions, zero new notes, and zero newly-spent nullifiers. Same gate covers the steady-state case where the wallet was already caught up (the user's original "+4 per minute" symptom from before the cooldown landed). `shieldedBalance` still updates on every success — the Rust cooldown path returns the current balance, so doing the assignment unconditionally keeps the displayed value accurate without leaking a stale cached number. A cleaner long-term fix would be to plumb an `is_cooldown_skip` flag through `ShieldedSyncSummary` → `ShieldedSyncWalletResultFFI` → Swift and route to a dedicated event, but the Swift-side gate is sufficient for the user-visible symptoms and avoids the FFI shape change. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Services/ShieldedService.swift | 39 +++++++++++++++---- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift index 50f2e6ea7c9..0790dd9af78 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -490,14 +490,39 @@ class ShieldedService: ObservableObject { if result.success { lastError = nil isBound = true + // Balance stays current — the Rust cooldown-skip path + // still fetches `balances()` and returns it on the + // summary, so updating here on every success keeps the + // displayed shielded balance accurate even when the + // pass itself was a no-op. shieldedBalance = result.balance - lastNewNotes = result.newNotes - lastNewlySpent = result.newlySpent - lastSyncTime = Date(timeIntervalSince1970: TimeInterval(event.syncUnixSeconds)) - syncCountSinceLaunch += 1 - totalScanned += result.totalScanned - totalNewNotes += UInt64(result.newNotes) - totalNewlySpent += UInt64(result.newlySpent) + + // Distinguish a real sync pass from a cooldown-suppressed + // one. The Rust side now skips the network + decrypt + // work after a recent no-op, but still returns + // `Ok(summary)` with zeros — without this gate the + // background loop's first tick after Clear → Sync Now + // double-ticks `syncCountSinceLaunch` and resets + // `lastSyncTime` to "1s ago" for what was actually an + // in-memory check. Treating any pass with no scanned + // positions, no new notes, and no newly-spent + // nullifiers as a no-op also covers the steady-state + // case where the wallet was already caught up. + let wasNoOp = + result.totalScanned == 0 + && result.newNotes == 0 + && result.newlySpent == 0 + if !wasNoOp { + lastNewNotes = result.newNotes + lastNewlySpent = result.newlySpent + lastSyncTime = Date( + timeIntervalSince1970: TimeInterval(event.syncUnixSeconds) + ) + syncCountSinceLaunch += 1 + totalScanned += result.totalScanned + totalNewNotes += UInt64(result.newNotes) + totalNewlySpent += UInt64(result.newlySpent) + } } else if result.skipped { // Skipped means the wallet hasn't been bound yet on the // Rust side. The UI can prompt the user to retry the From 7897f2d2bdbb0bb8f0bd25ba2baf7a5291d6b55e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 12:40:05 +0700 Subject: [PATCH 50/78] fix(platform-wallet,swift-sdk): plumb cooldown-skip flag through FFI instead of inferring it in Swift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior `wasNoOp` heuristic in `ShieldedService.handleShieldedSyncEvent` suppressed counter / timestamp updates whenever a successful sync returned all-zero `(totalScanned, newNotes, newlySpent)` fields. That fixed the post-Clear double-tick symptom but also froze `lastSyncTime` on healthy idle wallets — a real steady-state caught-up pass looks identical to a cooldown skip from Swift's side, so users on a quiet chain saw their last-sync timestamp get stuck minutes or hours behind. It also violated the "marshalling vs deciding" rule in `swift-sdk/CLAUDE.md`: the Rust side has the actual context (was the cooldown lock hit? did `sync_notes` walk anything?) and is the right layer for that distinction. Plumb the distinction explicitly: * Add `is_cooldown_skip: bool` to `ShieldedSyncSummary` in `rs-platform-wallet`. The cooldown fast-path in `ShieldedWallet::sync` sets it `true`; the normal path sets it `false`. * Add `cooldown_skip: bool` to `ShieldedSyncWalletResultFFI` in `rs-platform-wallet-ffi` and populate from the summary's flag in `ShieldedSyncWalletResultFFI::ok(...)`. * Expose `cooldownSkip: Bool` on Swift's `ShieldedWalletSyncResult`, marshalled from the FFI struct. * Replace `ShieldedService`'s `wasNoOp` heuristic with a direct `!result.cooldownSkip` check. Steady-state caught-up passes (`success && !cooldownSkip` with all-zero counters) now advance `lastSyncTime` as users expect; only deliberate cooldown skips suppress the counter / timestamp. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/shielded_sync.rs | 1 + .../src/shielded_types.rs | 9 +++++ .../src/wallet/shielded/sync.rs | 11 ++++++ .../PlatformWalletManagerShieldedSync.swift | 10 +++++ .../Core/Services/ShieldedService.swift | 37 +++++++++---------- 5 files changed, 48 insertions(+), 20 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs index 271ff29fc95..c5e145bbdd4 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs @@ -38,6 +38,7 @@ impl ShieldedSyncWalletResultFFI { wallet_id, success: true, skipped: false, + cooldown_skip: summary.is_cooldown_skip, new_notes, total_scanned: summary.notes_result.total_scanned, newly_spent, diff --git a/packages/rs-platform-wallet-ffi/src/shielded_types.rs b/packages/rs-platform-wallet-ffi/src/shielded_types.rs index 3cf10d60c78..ae1cb462157 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_types.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_types.rs @@ -32,6 +32,14 @@ pub struct ShieldedSyncWalletResultFFI { /// `true` if the wallet had no bound shielded sub-wallet (so the /// pass simply skipped it). Mutually exclusive with `success`. pub skipped: bool, + /// `true` when `success` is true but the pass was short-circuited + /// by the caught-up cooldown — no SDK fetch / trial-decrypt / + /// nullifier scan ran. `balance` is still populated from local + /// state so the host can keep its balance display current, but + /// any counters / timestamps that track *real sync activity* + /// should ignore this event. `false` for every pass that + /// actually walked Platform. + pub cooldown_skip: bool, /// New decrypted notes detected this pass. pub new_notes: u32, /// Total encrypted notes scanned (decrypted + skipped). @@ -51,6 +59,7 @@ impl Default for ShieldedSyncWalletResultFFI { wallet_id: [0; 32], success: false, skipped: false, + cooldown_skip: false, new_notes: 0, total_scanned: 0, newly_spent: 0, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs index bf6f5216160..c08b7721a2d 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs @@ -56,6 +56,15 @@ pub struct ShieldedSyncSummary { pub newly_spent_per_account: BTreeMap, /// Per-account unspent balance after sync. pub balances: BTreeMap, + /// True when the pass was short-circuited by the + /// caught-up cooldown (see [`super::CAUGHT_UP_COOLDOWN`]) — + /// no SDK fetch, no trial-decrypt, no nullifier scan. The + /// `balances` field is still populated from local state so + /// hosts can keep balance displays current, but counters / + /// last-sync timestamps should treat this as a no-op + /// distinct from a genuine "ran and found nothing" pass. + /// `false` for any pass that actually walked Platform. + pub is_cooldown_skip: bool, } impl ShieldedSyncSummary { @@ -437,6 +446,7 @@ impl ShieldedWallet { notes_result: SyncNotesResult::default(), newly_spent_per_account: BTreeMap::new(), balances: self.balances().await?, + is_cooldown_skip: true, }); } @@ -463,6 +473,7 @@ impl ShieldedWallet { notes_result, newly_spent_per_account, balances, + is_cooldown_skip: false, }) } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index a3694cbba67..b8f254347de 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -18,6 +18,15 @@ public struct ShieldedWalletSyncResult: Sendable { public let walletId: Data public let success: Bool public let skipped: Bool + /// `true` when `success` is true but the Rust pass was + /// short-circuited by the caught-up cooldown — no SDK fetch / + /// trial-decrypt / nullifier scan ran. `balance` is still + /// current (the Rust side reads it from local state on the + /// skip path) so hosts should still update balance displays, + /// but any counter / timestamp that tracks *real sync + /// activity* should ignore this event. `false` for any pass + /// that actually walked Platform. + public let cooldownSkip: Bool public let newNotes: UInt32 public let totalScanned: UInt64 public let newlySpent: UInt32 @@ -29,6 +38,7 @@ public struct ShieldedWalletSyncResult: Sendable { self.walletId = withUnsafeBytes(of: &walletId) { Data($0) } self.success = ffi.success self.skipped = ffi.skipped + self.cooldownSkip = ffi.cooldown_skip self.newNotes = ffi.new_notes self.totalScanned = ffi.total_scanned self.newlySpent = ffi.newly_spent diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift index 0790dd9af78..82baac7678f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -491,28 +491,25 @@ class ShieldedService: ObservableObject { lastError = nil isBound = true // Balance stays current — the Rust cooldown-skip path - // still fetches `balances()` and returns it on the - // summary, so updating here on every success keeps the - // displayed shielded balance accurate even when the - // pass itself was a no-op. + // still reads `balances()` from local state and + // returns it on the summary, so updating here on + // every success keeps the displayed shielded balance + // accurate without leaking a stale cached value. shieldedBalance = result.balance - // Distinguish a real sync pass from a cooldown-suppressed - // one. The Rust side now skips the network + decrypt - // work after a recent no-op, but still returns - // `Ok(summary)` with zeros — without this gate the - // background loop's first tick after Clear → Sync Now - // double-ticks `syncCountSinceLaunch` and resets - // `lastSyncTime` to "1s ago" for what was actually an - // in-memory check. Treating any pass with no scanned - // positions, no new notes, and no newly-spent - // nullifiers as a no-op also covers the steady-state - // case where the wallet was already caught up. - let wasNoOp = - result.totalScanned == 0 - && result.newNotes == 0 - && result.newlySpent == 0 - if !wasNoOp { + // Suppress counter / timestamp updates only on + // cooldown skips. Genuine steady-state caught-up + // passes (background loop ran, found nothing) still + // advance `lastSyncTime` so users have a live signal + // that the loop is running. The previous heuristic — + // gating on all-zero scanned/new/spent — froze the + // timestamp on healthy idle wallets and conflated the + // two skip semantics; the Rust side now distinguishes + // them explicitly via `is_cooldown_skip` on + // `ShieldedSyncSummary`, marshalled to + // `result.cooldownSkip` here. (Per swift-sdk/CLAUDE.md + // the policy decision belongs on the Rust side.) + if !result.cooldownSkip { lastNewNotes = result.newNotes lastNewlySpent = result.newlySpent lastSyncTime = Date( From c2db315f5971b2b4e044196e1d7461591235f9a9 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 13:17:19 +0700 Subject: [PATCH 51/78] =?UTF-8?q?fix(platform-wallet,swift-sdk):=20make=20?= =?UTF-8?q?shielded=20cooldown=20skip=20infallible=20=E2=80=94=20no=20bala?= =?UTF-8?q?nce=20read=20on=20skip=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cooldown fast-path in `ShieldedWallet::sync` previously called `self.balances().await?` inside the returned summary literal, so a transient store-level error (sqlite lock, GroveDB serialization hiccup) propagated up as `WalletShieldedOutcome::Err`, surfacing in Swift as a sync error banner for what was supposed to be a quiet no-op on a steady-state caught-up wallet. The cooldown is documented as doing "no SDK fetch / trial-decrypt / nullifier scan" — extend that contract to balances too. Three changes, one shape: * Rust skip path now returns `balances: BTreeMap::new()`. The skip is genuinely infallible; the host's prior cached balance is the right value to keep displayed (any spend or receive would have cleared `last_caught_up_at` and ended the cooldown, so balance can't have changed during the window). * Move `shieldedBalance = result.balance` inside the `!result.cooldownSkip` block in `ShieldedService.handleShieldedSyncEvent` so the empty balance payload on a skip event doesn't zero the cached display. * Update the FFI struct + Swift wrapper doc to make the contract explicit: when `cooldown_skip == true`, every numeric field is zero, hosts must preserve their cached values rather than apply the payload. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/shielded_types.rs | 8 ++--- .../src/wallet/shielded/sync.rs | 14 +++++++- .../PlatformWalletManagerShieldedSync.swift | 14 ++++---- .../Core/Services/ShieldedService.swift | 35 +++++++++---------- 4 files changed, 40 insertions(+), 31 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_types.rs b/packages/rs-platform-wallet-ffi/src/shielded_types.rs index ae1cb462157..152b546b931 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_types.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_types.rs @@ -34,10 +34,10 @@ pub struct ShieldedSyncWalletResultFFI { pub skipped: bool, /// `true` when `success` is true but the pass was short-circuited /// by the caught-up cooldown — no SDK fetch / trial-decrypt / - /// nullifier scan ran. `balance` is still populated from local - /// state so the host can keep its balance display current, but - /// any counters / timestamps that track *real sync activity* - /// should ignore this event. `false` for every pass that + /// nullifier scan / balance read ran. When this flag is set, + /// every numeric field on this struct is zero / default — the + /// host should preserve its prior cached balance and counters + /// rather than apply the payload. `false` for every pass that /// actually walked Platform. pub cooldown_skip: bool, /// New decrypted notes detected this pass. diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs index c08b7721a2d..96a2d425d8f 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs @@ -442,10 +442,22 @@ impl ShieldedWallet { cooldown_total_secs = super::CAUGHT_UP_COOLDOWN.as_secs(), "Shielded sync skipped — within caught-up cooldown" ); + // The cooldown skip is documented as a no-op (no SDK + // fetch / trial-decrypt / nullifier scan) and must + // be infallible — a transient `balances()` failure + // here would turn a deliberate skip into a sync + // error banner on the host even though no real work + // was attempted. Return an empty `balances` map and + // rely on the host to preserve its cached balance + // when it sees `is_cooldown_skip = true`. Balance + // can't have changed during the cooldown window + // anyway: any spend or receive would have cleared + // `last_caught_up_at` via the activity branch at + // the bottom of `sync()`, ending the cooldown. return Ok(ShieldedSyncSummary { notes_result: SyncNotesResult::default(), newly_spent_per_account: BTreeMap::new(), - balances: self.balances().await?, + balances: BTreeMap::new(), is_cooldown_skip: true, }); } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index b8f254347de..a12503212ba 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -19,13 +19,13 @@ public struct ShieldedWalletSyncResult: Sendable { public let success: Bool public let skipped: Bool /// `true` when `success` is true but the Rust pass was - /// short-circuited by the caught-up cooldown — no SDK fetch / - /// trial-decrypt / nullifier scan ran. `balance` is still - /// current (the Rust side reads it from local state on the - /// skip path) so hosts should still update balance displays, - /// but any counter / timestamp that tracks *real sync - /// activity* should ignore this event. `false` for any pass - /// that actually walked Platform. + /// short-circuited by the caught-up cooldown — no SDK fetch + /// / trial-decrypt / nullifier scan / balance read ran. + /// When this flag is set, every numeric field on this + /// struct (`newNotes`, `totalScanned`, `newlySpent`, + /// `balance`) is zero — hosts should preserve their cached + /// balance and counters rather than apply the payload. + /// `false` for any pass that actually walked Platform. public let cooldownSkip: Bool public let newNotes: UInt32 public let totalScanned: UInt64 diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift index 82baac7678f..f136c02d0d7 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -490,26 +490,23 @@ class ShieldedService: ObservableObject { if result.success { lastError = nil isBound = true - // Balance stays current — the Rust cooldown-skip path - // still reads `balances()` from local state and - // returns it on the summary, so updating here on - // every success keeps the displayed shielded balance - // accurate without leaking a stale cached value. - shieldedBalance = result.balance - - // Suppress counter / timestamp updates only on - // cooldown skips. Genuine steady-state caught-up - // passes (background loop ran, found nothing) still - // advance `lastSyncTime` so users have a live signal - // that the loop is running. The previous heuristic — - // gating on all-zero scanned/new/spent — froze the - // timestamp on healthy idle wallets and conflated the - // two skip semantics; the Rust side now distinguishes - // them explicitly via `is_cooldown_skip` on - // `ShieldedSyncSummary`, marshalled to - // `result.cooldownSkip` here. (Per swift-sdk/CLAUDE.md - // the policy decision belongs on the Rust side.) + + // Suppress counter / timestamp / balance updates on + // cooldown skips. The Rust side returns + // `result.cooldownSkip = true` with zeroed counters + // *and* an empty balances payload (`result.balance == + // 0`) because no work was attempted; updating the + // host's cached balance would clobber it to zero + // every cooldown tick on a wallet that actually has + // a balance. Genuine steady-state caught-up passes + // (background loop ran, found nothing, returned the + // real balance) still advance `lastSyncTime` so users + // have a live signal that the loop is running. Per + // swift-sdk/CLAUDE.md the policy decision (real sync + // vs. cooldown skip) lives on the Rust side; Swift + // just marshals the flag. if !result.cooldownSkip { + shieldedBalance = result.balance lastNewNotes = result.newNotes lastNewlySpent = result.newlySpent lastSyncTime = Date( From 6cfbdbf1e5e513d6ad21dd0f51ed6c35b18c72be Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 19:43:19 +0700 Subject: [PATCH 52/78] =?UTF-8?q?refactor(platform-wallet):=20Phase=200=20?= =?UTF-8?q?=E2=80=94=20shielded=20coordinator=20skeleton=20+=20viewing-key?= =?UTF-8?q?=20split?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First phase of the network-scoped shielded coordinator refactor. Zero behavior change: adds the new types but nothing consumes them yet. * New `AccountViewingKeys` in `wallet/shielded/keys.rs` — the viewing-grade subset of an `OrchardKeySet` (FVK, IVK, pre-computed prepared IVK, OVK, default address). No `SpendAuthorizingKey`. This is the type the coordinator will hold in its flat account registry; spend authorization stays on the per-wallet side and is passed in only at spend-call time. `OrchardKeySet::viewing_keys()` is the constructor that strips the ASK. * New `wallet/shielded/coordinator.rs` module with the `NetworkShieldedCoordinator` type — declared and constructable but unwired. Owns the single SQLite handle to `shielded_tree_.sqlite`, the `Map` registry, the persister handle, and the caught-up cooldown stamp. Fields marked `#[allow(dead_code)]` until Phase 1 / 2 wire them into the real sync + spend code paths. * Re-export `AccountViewingKeys` and `NetworkShieldedCoordinator` from `wallet/shielded` so the next phases can pull them in without further plumbing. `cargo check -p platform-wallet --features shielded` and `cargo clippy -p platform-wallet --features shielded --lib -- -D warnings` both clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/wallet/shielded/coordinator.rs | 146 ++++++++++++++++++ .../src/wallet/shielded/keys.rs | 41 +++++ .../src/wallet/shielded/mod.rs | 4 +- 3 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs new file mode 100644 index 00000000000..31e06bd3b32 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -0,0 +1,146 @@ +//! Network-scoped shielded coordinator. +//! +//! The Orchard commitment tree is chain-wide: every wallet and +//! every account on the same network sees the same `cmx` stream +//! in the same order, backs the same frontier, and shares the +//! same anchor set. The current per-`PlatformWallet` shielded +//! shape (each wallet owning its own [`ShieldedWallet`] and its +//! own [`FileBackedShieldedStore`] handle) duplicates the +//! fetch + trial-decrypt + tree-append work N times for N +//! wallets, and opens N concurrent SQLite handles into a single +//! `shielded_tree_.sqlite` file. +//! +//! [`NetworkShieldedCoordinator`] is the single object that owns +//! everything chain-wide about shielded sync — the SQLite-backed +//! commitment tree, the per-`SubwalletId` notes/sync state, the +//! flat registry of every bound account's **viewing keys** (no +//! spend authority), the caught-up cooldown stamp, and the +//! persister handle. One instance per [`PlatformWalletManager`]; +//! lazily constructed on the first `bind_shielded` call so +//! networks where no wallet uses shielded never open a SQLite +//! file. +//! +//! Privilege separation: the coordinator's account registry +//! holds [`AccountViewingKeys`] only — FVK / IVK / OVK / default +//! address. The `SpendAuthorizingKey` lives only on the +//! per-wallet side (in [`OrchardKeySet`]) and is passed into the +//! coordinator's spend methods at call time, never stored at +//! coordinator scope. +//! +//! # Phase 0 status +//! +//! This module currently contains the type skeleton only — the +//! coordinator is declared, its fields wired up, and its +//! lifecycle helpers documented. None of the existing sync / +//! spend code paths consume it yet; that wiring lands in +//! Phase 1+. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use tokio::sync::RwLock; + +use super::file_store::FileBackedShieldedStore; +use super::keys::AccountViewingKeys; +use super::store::SubwalletId; +use crate::wallet::persister::WalletPersister; + +/// Network-scoped shielded coordinator. +/// +/// See module docs for the architectural rationale. +/// +/// `#[allow(dead_code)]` on the fields is the Phase-0 marker — +/// the type compiles and is exported, but nothing reads it yet. +/// The annotations come off as each phase wires its respective +/// field into the real code path. +#[allow(dead_code)] +pub struct NetworkShieldedCoordinator { + /// Dash Platform SDK handle. The coordinator runs sync / + /// nullifier-scan / broadcast against this SDK on behalf of + /// every bound wallet. + sdk: Arc, + + /// Network this coordinator operates on. Pinned at + /// construction and never mutated — networks each get their + /// own coordinator instance. + network: dashcore::Network, + + /// The single SQLite handle into `shielded_tree_.sqlite`. + /// Both the commitment tree (frontier, checkpoints, marked + /// auth paths) and the per-[`SubwalletId`] notes / sync + /// watermarks / nullifier checkpoints live here. + /// + /// Every wallet and every account on this network shares + /// this `Arc>` — the single-handle property is + /// what closes the SQLite-WAL contention and the + /// delete-while-open race the prior architecture had. + store: Arc>, + + /// Flat registry of every bound `(walletId, accountIndex)` + /// pair's viewing keys, populated by + /// [`register_wallet`](Self::register_wallet). The sync loop + /// iterates this map once per pass to enumerate every IVK + /// across every wallet, then trial-decrypts each fetched + /// note against the union. + /// + /// Viewing-grade only: no [`SpendAuthorizingKey`] is ever + /// stored at coordinator scope. Spend operations re-attach + /// the ASK by accepting an [`OrchardKeySet`] parameter from + /// the per-wallet caller. + accounts: Arc>>, + + /// Persister handle attached when shielded support is first + /// configured on the manager. The coordinator emits a single + /// consolidated [`ShieldedChangeSet`] per sync pass — the + /// changeset is already `SubwalletId`-keyed so per-wallet + /// fan-out happens naturally on the host side. + persister: Option, + + /// Timestamp of the last sync pass that observed no new + /// commitments or newly-spent nullifiers — the caught-up + /// cooldown stamp moves from per-`ShieldedWallet` scope to + /// per-coordinator scope, so the cooldown applies once per + /// network instead of once per wallet. Cleared on any + /// activity; bypassed by `force` syncs. + last_caught_up_at: std::sync::Mutex>, +} + +#[allow(dead_code)] +impl NetworkShieldedCoordinator { + /// Build a new coordinator. Called by + /// `PlatformWalletManager::configure_shielded` (Phase 1) on + /// the first shielded use on this network manager. The + /// `db_path` is opened immediately; subsequent + /// `configure_shielded` calls on the same manager verify the + /// path matches and error otherwise (open question (a) from + /// the design doc). + pub fn new( + sdk: Arc, + network: dashcore::Network, + store: FileBackedShieldedStore, + persister: Option, + ) -> Self { + Self { + sdk, + network, + store: Arc::new(RwLock::new(store)), + accounts: Arc::new(RwLock::new(BTreeMap::new())), + persister, + last_caught_up_at: std::sync::Mutex::new(None), + } + } + + /// Network this coordinator is pinned to. Used by hosts that + /// need to assert their `PlatformWalletManager` and the + /// coordinator agree on the network. + pub fn network(&self) -> dashcore::Network { + self.network + } + + /// Reference to the shared store. The full sync / spend + /// surfaces land on the coordinator itself; this accessor + /// exists for tests and for migration scaffolding in Phase 1. + pub fn store(&self) -> &Arc> { + &self.store + } +} diff --git a/packages/rs-platform-wallet/src/wallet/shielded/keys.rs b/packages/rs-platform-wallet/src/wallet/shielded/keys.rs index 356a42c354f..ceeeddf185c 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/keys.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/keys.rs @@ -115,4 +115,45 @@ impl OrchardKeySet { pub fn prepared_ivk(&self) -> PreparedIncomingViewingKey { PreparedIncomingViewingKey::new(&self.incoming_viewing_key) } + + /// Strip the spend-authorizing key and return only the + /// viewing-grade material. Used to populate the + /// network-scoped shielded coordinator's account registry — + /// the coordinator runs sync (trial-decrypt + tree append + + /// nullifier scan), none of which needs spend authority, so + /// keeping the ASK on the per-wallet side preserves the + /// privilege separation. Spend operations re-attach the ASK + /// by passing the full [`OrchardKeySet`] back into the + /// coordinator's spend methods at call time. + pub fn viewing_keys(&self) -> AccountViewingKeys { + AccountViewingKeys { + full_viewing_key: self.full_viewing_key.clone(), + incoming_viewing_key: self.incoming_viewing_key.clone(), + prepared_ivk: self.prepared_ivk(), + outgoing_viewing_key: self.outgoing_viewing_key.clone(), + default_address: self.default_address, + } + } +} + +/// Viewing-grade subset of an [`OrchardKeySet`] — the material +/// needed to detect, decrypt, and recover Orchard notes, with no +/// ability to authorize a spend. +/// +/// The network-scoped shielded coordinator holds these for every +/// bound `(walletId, accountIndex)`; it never sees a +/// `SpendAuthorizingKey`. Spend operations are driven from the +/// per-wallet side, which holds the full [`OrchardKeySet`] (ASK +/// included) and passes it into the coordinator's spend methods +/// only for the duration of that call. +#[derive(Clone)] +pub struct AccountViewingKeys { + pub full_viewing_key: FullViewingKey, + pub incoming_viewing_key: IncomingViewingKey, + /// Pre-computed for fast trial-decrypt across many notes per + /// sync pass. Cached at registration time so the sync loop + /// doesn't pay [`PreparedIncomingViewingKey::new`] per pass. + pub prepared_ivk: PreparedIncomingViewingKey, + pub outgoing_viewing_key: OutgoingViewingKey, + pub default_address: PaymentAddress, } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs index e1d8b2a95fd..af528c891d8 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs @@ -16,6 +16,7 @@ //! wallet's Orchard accounts (`BTreeMap`), //! the shared store, and the SDK together. +pub mod coordinator; pub mod file_store; pub mod keys; pub mod note_selection; @@ -24,8 +25,9 @@ pub mod prover; pub mod store; pub mod sync; +pub use coordinator::NetworkShieldedCoordinator; pub use file_store::{FileBackedShieldedStore, FileShieldedStoreError}; -pub use keys::OrchardKeySet; +pub use keys::{AccountViewingKeys, OrchardKeySet}; pub use prover::CachedOrchardProver; pub use store::{InMemoryShieldedStore, ShieldedNote, ShieldedStore, SubwalletId}; pub use sync::{ShieldedSyncSummary, SyncNotesResult}; From 30bf42559bb0596b38a7ffb5a8307ca8d8f6da11 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 20:13:09 +0700 Subject: [PATCH 53/78] =?UTF-8?q?refactor(platform-wallet,ffi,swift-sdk):?= =?UTF-8?q?=20Phase=201=20=E2=80=94=20share=20network=20SQLite=20handle=20?= =?UTF-8?q?across=20wallets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wallets on the same network now share a single SQLite handle into `shielded_tree_.sqlite` instead of each opening its own. Rust - `NetworkShieldedCoordinator` now stores its `db_path: PathBuf` and exposes `db_path()` for the manager's idempotent / mismatch check. - `ShieldedWallet::{from_keysets, from_seed_accounts}` take `store: Arc>` instead of an owned `S` so the same store can be shared. - `PlatformWallet::bind_shielded` no longer takes a `db_path`; the caller passes the shared store directly. - `PlatformWalletManager` gains a `shielded_coordinator: Arc>>>` slot, plus `configure_shielded(db_path)` (idempotent same-path, errors on mismatch) and a `shielded_coordinator()` accessor. FFI - `platform_wallet_manager_bind_shielded` drops its `db_path_cstr` parameter and now requires the host to have called `platform_wallet_manager_configure_shielded` first; it errors with `ErrorWalletOperation` otherwise. - New `platform_wallet_manager_configure_shielded(handle, db_path_cstr)` opens the per-network store and installs the coordinator on the manager. Swift SDK - `PlatformWalletManager.bindShielded(...)` loses its `dbPath` parameter; added `configureShielded(dbPath:)` wrapper. - `ShieldedService.bind` calls `configureShielded` before `bindShielded` so N-wallet binds share one SQLite handle. Phase 2 will move the sync loop into the coordinator so the single store is also walked once per tick (today's sync loop still iterates per-wallet — same store, same work N times). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/shielded_sync.rs | 117 +++++++++++++----- .../rs-platform-wallet/src/manager/mod.rs | 90 ++++++++++++++ .../src/wallet/platform_wallet.rs | 13 +- .../src/wallet/shielded/coordinator.rs | 17 +++ .../src/wallet/shielded/mod.rs | 13 +- .../PlatformWalletManagerShieldedSync.swift | 46 +++++-- .../Core/Services/ShieldedService.swift | 11 +- 7 files changed, 249 insertions(+), 58 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs index c5e145bbdd4..ca21c43ba9e 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs @@ -164,9 +164,8 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_sync_sync_now( // --------------------------------------------------------------------------- /// Derive Orchard keys for the given wallet from the host-supplied -/// mnemonic resolver, open the per-network commitment tree at -/// `db_path`, and bind the resulting [`ShieldedWallet`] to the -/// `PlatformWallet`. +/// mnemonic resolver and register the resulting accounts on the +/// network-scoped shielded coordinator. /// /// `accounts_ptr` / `accounts_len` describe the ZIP-32 account /// indices to derive. The slice must be non-empty and at most @@ -181,23 +180,22 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_sync_sync_now( /// before this function returns; only the per-account FVK / IVK / /// OVK / default payment addresses survive on the wallet. /// -/// `db_path` is owned by the host (typically -/// `/shielded_tree_.sqlite`). The same path is -/// fine to share across wallets on the same network — the -/// commitment tree is global per network; decrypted notes are -/// scoped per `(wallet_id, account_index)` inside the store. +/// **Prerequisite**: the host must have already called +/// [`platform_wallet_manager_configure_shielded`] with the +/// per-network SQLite path before invoking this function — the +/// shared commitment-tree handle is opened there, not here. +/// Calling `bind_shielded` before `configure_shielded` returns +/// `ErrorWalletOperation`. /// /// Idempotent: a second call replaces the previously-bound -/// shielded wallet. +/// shielded wallet on the same `wallet_id`. /// /// # Safety /// - `wallet_id_bytes` must point at 32 readable bytes. /// - `accounts_ptr` must point at `accounts_len` readable `u32`s. /// - `mnemonic_resolver_handle` must come from /// [`crate::dash_sdk_mnemonic_resolver_create`]. -/// - `db_path_cstr` must be a valid NUL-terminated UTF-8 C string. /// -/// [`ShieldedWallet`]: platform_wallet::wallet::shielded::ShieldedWallet /// [`OrchardKeySet`]: platform_wallet::wallet::shielded::OrchardKeySet #[no_mangle] pub unsafe extern "C" fn platform_wallet_manager_bind_shielded( @@ -206,11 +204,9 @@ pub unsafe extern "C" fn platform_wallet_manager_bind_shielded( mnemonic_resolver_handle: *mut MnemonicResolverHandle, accounts_ptr: *const u32, accounts_len: usize, - db_path_cstr: *const c_char, ) -> PlatformWalletFFIResult { check_ptr!(wallet_id_bytes); check_ptr!(mnemonic_resolver_handle); - check_ptr!(db_path_cstr); check_ptr!(accounts_ptr); if accounts_len == 0 || accounts_len > 64 { return PlatformWalletFFIResult::err( @@ -223,16 +219,6 @@ pub unsafe extern "C" fn platform_wallet_manager_bind_shielded( let mut wallet_id = [0u8; 32]; std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); - let db_path = match CStr::from_ptr(db_path_cstr).to_str() { - Ok(s) => PathBuf::from(s), - Err(e) => { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorUtf8Conversion, - format!("db_path is not valid UTF-8: {e}"), - ); - } - }; - // Resolve mnemonic via the host callback. let mut mnemonic_buf: Zeroizing<[u8; MNEMONIC_RESOLVER_BUFFER_CAPACITY]> = Zeroizing::new([0u8; MNEMONIC_RESOLVER_BUFFER_CAPACITY]); @@ -299,13 +285,18 @@ pub unsafe extern "C" fn platform_wallet_manager_bind_shielded( let seed: Zeroizing<[u8; 64]> = Zeroizing::new(mnemonic.to_seed("")); drop(mnemonic); - // Look up the wallet on the manager and bind shielded. - let wallet_arc = { - let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { - runtime().block_on(manager.get_wallet(&wallet_id)) - }); - unwrap_option_or_return!(option) - }; + // Look up the wallet + the network-scoped shielded coordinator + // on the manager. The coordinator owns the single SQLite handle; + // we hand its store down to `bind_shielded` so this wallet's + // `ShieldedWallet` reuses it instead of opening its own. + let lookup = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + runtime().block_on(async { + let wallet = manager.get_wallet(&wallet_id).await; + let coordinator = manager.shielded_coordinator().await; + (wallet, coordinator) + }) + }); + let (wallet_arc, coordinator) = unwrap_option_or_return!(lookup); let wallet_arc = match wallet_arc { Some(w) => w, None => { @@ -315,10 +306,22 @@ pub unsafe extern "C" fn platform_wallet_manager_bind_shielded( ); } }; + let coordinator = match coordinator { + Some(c) => c, + None => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "shielded support not configured — call platform_wallet_manager_configure_shielded first", + ); + } + }; + let shared_store = std::sync::Arc::clone(coordinator.store()); - if let Err(e) = - runtime().block_on(wallet_arc.bind_shielded(seed.as_ref(), accounts.as_slice(), &db_path)) - { + if let Err(e) = runtime().block_on(wallet_arc.bind_shielded( + seed.as_ref(), + accounts.as_slice(), + shared_store, + )) { return PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorWalletOperation, format!("bind_shielded failed: {e}"), @@ -328,6 +331,54 @@ pub unsafe extern "C" fn platform_wallet_manager_bind_shielded( PlatformWalletFFIResult::ok() } +// --------------------------------------------------------------------------- +// Configure shielded (network-scoped) +// --------------------------------------------------------------------------- + +/// Configure the network-scoped shielded coordinator for this +/// manager. Opens (or creates) the per-network commitment-tree +/// SQLite file at `db_path_cstr` and installs a coordinator that +/// every subsequent `platform_wallet_manager_bind_shielded` call +/// reuses — one SQLite handle per network manager, regardless of +/// how many wallets bind shielded. +/// +/// Must be called **before** any `bind_shielded` on this manager. +/// Calling it again with the same path is a no-op (idempotent). +/// Calling it again with a different path returns +/// `ErrorWalletOperation`: the SQLite handle is opened once and +/// can't be repointed mid-flight. +/// +/// # Safety +/// - `db_path_cstr` must be a valid NUL-terminated UTF-8 C string. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_configure_shielded( + handle: Handle, + db_path_cstr: *const c_char, +) -> PlatformWalletFFIResult { + check_ptr!(db_path_cstr); + let db_path = match CStr::from_ptr(db_path_cstr).to_str() { + Ok(s) => PathBuf::from(s), + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorUtf8Conversion, + format!("db_path is not valid UTF-8: {e}"), + ); + } + }; + + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + runtime().block_on(manager.configure_shielded(&db_path)) + }); + let result = unwrap_option_or_return!(option); + if let Err(e) = result { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("configure_shielded failed: {e}"), + ); + } + PlatformWalletFFIResult::ok() +} + // --------------------------------------------------------------------------- // Default Orchard payment address // --------------------------------------------------------------------------- diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index ac44658e8f3..9f207a616a5 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -59,6 +59,18 @@ pub struct PlatformWalletManager { /// `start` after wallets are registered. #[cfg(feature = "shielded")] pub(super) shielded_sync_manager: Arc, + /// Network-scoped shielded coordinator. `None` until + /// `configure_shielded` opens the per-network SQLite tree; + /// once `Some`, every wallet's `bind_shielded` reuses the + /// same `Arc>` (held inside + /// the coordinator) so there's exactly one SQLite handle per + /// network manager. Phase 2 will move the sync loop here + /// from the per-wallet `ShieldedSyncManager` iteration; Phase + /// 4 deletes `ShieldedWallet` outright and the coordinator + /// owns the spend surface too. + #[cfg(feature = "shielded")] + pub(super) shielded_coordinator: + Arc>>>, pub(super) persister: Arc

, /// Cancellation token + join handle for the wallet-event adapter /// task. Held so [`shutdown`] can stop it cleanly when the manager @@ -133,12 +145,90 @@ impl PlatformWalletManager

{ identity_sync_manager: identity_sync, #[cfg(feature = "shielded")] shielded_sync_manager: shielded_sync, + #[cfg(feature = "shielded")] + shielded_coordinator: Arc::new(RwLock::new(None)), persister, event_adapter_cancel, event_adapter_join: tokio::sync::Mutex::new(Some(event_adapter_join)), } } + /// Configure network-scoped shielded support. Opens the + /// per-network commitment-tree SQLite file at `db_path` and + /// installs a [`NetworkShieldedCoordinator`] that every + /// subsequent `PlatformWallet::bind_shielded` will share. + /// + /// Must be called before any wallet's `bind_shielded` — + /// per-wallet bind looks up the coordinator from the manager + /// and errors out if it hasn't been configured. + /// + /// Subsequent calls with the **same** `db_path` are a no-op + /// (configuration is idempotent at the path level). A second + /// call with a **different** path returns + /// `ShieldedNotConfigured` — the SQLite handle is opened + /// once per manager and can't be repointed at a different + /// file mid-flight. (Design-doc choice (c): the path is a + /// manager-level concern, not per-wallet.) + #[cfg(feature = "shielded")] + pub async fn configure_shielded( + &self, + db_path: impl AsRef, + ) -> Result<(), crate::error::PlatformWalletError> { + use crate::wallet::shielded::{FileBackedShieldedStore, NetworkShieldedCoordinator}; + let db_path: std::path::PathBuf = db_path.as_ref().to_path_buf(); + + let mut slot = self.shielded_coordinator.write().await; + if let Some(existing) = slot.as_ref() { + if existing.db_path() == db_path.as_path() { + return Ok(()); + } + return Err(crate::error::PlatformWalletError::ShieldedStoreError( + format!( + "shielded already configured at {} — refusing to repoint at {}", + existing.db_path().display(), + db_path.display(), + ), + )); + } + + // The store opens (and creates if missing) the SQLite + // file synchronously. 100 = shardtree's max retained + // checkpoints; matches the prior per-wallet default at + // `PlatformWallet::bind_shielded`. + let store = FileBackedShieldedStore::open_path(&db_path, 100) + .map_err(|e| crate::error::PlatformWalletError::ShieldedStoreError(e.to_string()))?; + + let coordinator = Arc::new(NetworkShieldedCoordinator::new( + Arc::clone(&self.sdk), + self.sdk.network, + db_path, + store, + // Persister attaches in Phase 2 when the coordinator + // owns the sync loop and emits the consolidated + // changeset. For Phase 1 the per-wallet `ShieldedWallet` + // still holds its own persister handle and queues + // changesets exactly as before. + None, + )); + *slot = Some(coordinator); + Ok(()) + } + + /// Snapshot of the currently-installed shielded coordinator, + /// or `None` if `configure_shielded` hasn't run yet on this + /// manager. Cloned `Arc` so callers can hold the coordinator + /// past the read-lock guard's drop. + #[cfg(feature = "shielded")] + pub async fn shielded_coordinator( + &self, + ) -> Option> { + self.shielded_coordinator + .read() + .await + .as_ref() + .map(Arc::clone) + } + /// Stop all background tasks and wait for them to exit. /// /// Stops the periodic coordinators (`PlatformAddressSyncManager`, diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index ace5ebb91a3..bc401a579f1 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -24,8 +24,6 @@ use crate::changeset::{ }; #[cfg(feature = "shielded")] use crate::error::PlatformWalletError; -#[cfg(feature = "shielded")] -use std::path::Path; /// Unique identifier for a wallet (32-byte hash). pub type WalletId = [u8; 32]; @@ -316,13 +314,12 @@ impl PlatformWallet { &self, seed: &[u8], accounts: &[u32], - db_path: impl AsRef, + store: Arc>, ) -> Result<(), PlatformWalletError> { - // Open / create the SQLite-backed commitment tree first so - // any I/O failure surfaces before we touch the wallet's - // existing shielded slot. - let store = FileBackedShieldedStore::open_path(db_path, 100) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + // The store is sourced by the caller from the + // manager-level `NetworkShieldedCoordinator` — every + // wallet on the same network shares one SQLite handle. + // See `PlatformWalletManager::configure_shielded`. let network = self.sdk.network; let mut wallet = ShieldedWallet::from_seed_accounts( Arc::clone(&self.sdk), diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index 31e06bd3b32..ef70d476c3c 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -36,6 +36,7 @@ //! Phase 1+. use std::collections::BTreeMap; +use std::path::PathBuf; use std::sync::Arc; use tokio::sync::RwLock; @@ -65,6 +66,13 @@ pub struct NetworkShieldedCoordinator { /// own coordinator instance. network: dashcore::Network, + /// On-disk path to `shielded_tree_.sqlite`. Stored so + /// subsequent `configure_shielded` calls on the same manager + /// can fail loudly if a caller passes a mismatched path + /// (design-doc choice (c): explicit error rather than + /// silently honor first or silently honor second). + db_path: PathBuf, + /// The single SQLite handle into `shielded_tree_.sqlite`. /// Both the commitment tree (frontier, checkpoints, marked /// auth paths) and the per-[`SubwalletId`] notes / sync @@ -117,12 +125,14 @@ impl NetworkShieldedCoordinator { pub fn new( sdk: Arc, network: dashcore::Network, + db_path: PathBuf, store: FileBackedShieldedStore, persister: Option, ) -> Self { Self { sdk, network, + db_path, store: Arc::new(RwLock::new(store)), accounts: Arc::new(RwLock::new(BTreeMap::new())), persister, @@ -137,6 +147,13 @@ impl NetworkShieldedCoordinator { self.network } + /// The on-disk SQLite path the coordinator opened. Used by + /// `PlatformWalletManager::configure_shielded` to verify + /// subsequent calls pass the same path. + pub fn db_path(&self) -> &std::path::Path { + &self.db_path + } + /// Reference to the shared store. The full sync / spend /// surfaces land on the coordinator itself; this accessor /// exists for tests and for migration scaffolding in Phase 1. diff --git a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs index af528c891d8..e7c3bef9915 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs @@ -109,11 +109,18 @@ impl ShieldedWallet { /// /// `accounts` maps ZIP-32 account index → [`OrchardKeySet`]. /// At least one account must be supplied. + /// + /// `store` is the shared, network-scoped commitment-tree + /// store handed in by the caller. Every wallet on the same + /// network manager should pass the same `Arc` clone — the + /// chain-wide Orchard tree only needs one SQLite handle per + /// network. See `NetworkShieldedCoordinator` for the layer + /// that owns that single handle on Phase 1+. pub fn from_keysets( sdk: Arc, wallet_id: WalletId, accounts: BTreeMap, - store: S, + store: Arc>, ) -> Result { if accounts.is_empty() { return Err(PlatformWalletError::ShieldedKeyDerivation( @@ -128,7 +135,7 @@ impl ShieldedWallet { sdk, wallet_id, accounts, - store: Arc::new(RwLock::new(store)), + store, persister: None, last_caught_up_at: std::sync::Mutex::new(None), }) @@ -220,7 +227,7 @@ impl ShieldedWallet { seed: &[u8], network: dashcore::Network, accounts: &[u32], - store: S, + store: Arc>, ) -> Result { if accounts.is_empty() { return Err(PlatformWalletError::ShieldedKeyDerivation( diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index a12503212ba..cbf9c40697e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -80,11 +80,15 @@ extension PlatformWalletManager { /// /// Idempotent: calling again replaces the previously-bound /// shielded wallet. + /// + /// **Prerequisite**: [`configureShielded(dbPath:)`] must have + /// been called on this manager first — the per-network + /// SQLite handle is opened there. `bindShielded` reuses it + /// across every wallet. public func bindShielded( walletId: Data, resolver: MnemonicResolver, - accounts: [UInt32] = [0], - dbPath: String + accounts: [UInt32] = [0] ) throws { guard isConfigured, handle != NULL_HANDLE else { throw PlatformWalletError.invalidHandle( @@ -119,20 +123,38 @@ extension PlatformWalletManager { "accounts baseAddress is nil" ) } - try dbPath.withCString { dbPathPtr in - try platform_wallet_manager_bind_shielded( - handle, - walletIdPtr, - resolverHandle, - accountsPtr, - UInt(accountsBuf.count), - dbPathPtr - ).check() - } + try platform_wallet_manager_bind_shielded( + handle, + walletIdPtr, + resolverHandle, + accountsPtr, + UInt(accountsBuf.count) + ).check() } } } + /// Configure the network-scoped shielded coordinator. Opens + /// the per-network commitment-tree SQLite file at `dbPath` + /// and installs a single shared handle every subsequent + /// [`bindShielded`] call reuses. + /// + /// Must be called once before any `bindShielded` on this + /// manager. Idempotent: a second call with the same `dbPath` + /// is a no-op; a second call with a different `dbPath` + /// throws — the SQLite handle is opened once per manager and + /// can't be repointed mid-flight. + public func configureShielded(dbPath: String) throws { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidHandle( + "PlatformWalletManager not configured" + ) + } + try dbPath.withCString { dbPathPtr in + try platform_wallet_manager_configure_shielded(handle, dbPathPtr).check() + } + } + /// Start the shielded sync coordinator's background loop. /// /// Wallets that have not yet been bound via [`bindShielded`] are diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift index f136c02d0d7..752346eaf3e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -158,11 +158,18 @@ class ShieldedService: ObservableObject { let dbPath = Self.dbPath(for: network) let sortedAccounts = Array(Set(accounts)).sorted() do { + // The per-network SQLite tree handle now lives on the + // manager (one shared `NetworkShieldedCoordinator`), + // not per-wallet. `configureShielded` is idempotent at + // the path level — first call opens the file, every + // subsequent same-path call no-ops. Has to run before + // `bindShielded`; doing it inline keeps the call shape + // simple and avoids a separate bootstrap step. + try walletManager.configureShielded(dbPath: dbPath) try walletManager.bindShielded( walletId: walletId, resolver: resolver, - accounts: sortedAccounts, - dbPath: dbPath + accounts: sortedAccounts ) isBound = true lastError = nil From 000882cc2d3788baecbef7f56b5f88c2198848f1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 20:25:34 +0700 Subject: [PATCH 54/78] =?UTF-8?q?refactor(platform-wallet):=20Phase=202a?= =?UTF-8?q?=20=E2=80=94=20coordinator=20owns=20network=20cooldown=20+=20sy?= =?UTF-8?q?nc=20entry=20point?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shielded caught-up cooldown moves from per-`ShieldedWallet` scope to network-wide scope on the `NetworkShieldedCoordinator`. `ShieldedSyncManager::sync_now` now routes through `coordinator.sync(force)` when shielded support has been configured, so a no-op pass suppresses the next pass once per network instead of once per wallet. This is the safe incremental step — per-wallet iteration still delegates to `PlatformWallet::shielded_sync(force=true)` under the coordinator's cooldown gate (the per-wallet `last_caught_up_at` is now dead state, removed in Phase 4). Phase 2b collapses the N per-wallet SDK fetches into a single network-wide fetch + multi-IVK trial-decrypt pass against the union of every registered subwallet's `AccountViewingKeys`. Coordinator additions - `register_wallet(wallet_id, account_views)` — populates the per-`SubwalletId` viewing-key registry. Idempotent: re-bind replaces prior account list for the same wallet_id. - `unregister_wallet(wallet_id)` — purges registry on shielded clear / wallet remove. - `registered_subwallets()` — snapshot accessor. - `sync(force)` — network-wide cooldown gate; iterates registered wallets and delegates to `PlatformWallet::shielded_sync(true)` (the `true` is intentional — the coordinator already gated on cooldown above, so double-gating per-wallet would miss new chunks during the cooldown window). Emits a `ShieldedSyncPassSummary` ready for `PlatformEventManager::on_shielded_sync_completed`. Privilege separation - `PlatformWallet::bind_shielded` now takes the coordinator (was: bare `Arc>`). The bind extracts the store from the coordinator, builds the per-wallet `ShieldedWallet` as before, then snapshots only the `AccountViewingKeys` subset (FVK / IVK / OVK / default address; no `SpendAuthorizingKey`) and registers them on the coordinator via `register_wallet`. The ASK stays on the per-wallet side inside `OrchardKeySet` and is re-attached at spend-call time. ShieldedSyncManager wiring - New `coordinator_slot: Arc>>>` field, shared with the owning manager. - `sync_now(force)` snapshots the coordinator Arc and routes through `coordinator.sync(force)` when present; falls back to legacy per-wallet iteration when no `configure_shielded` call has run (preserves test / non-host paths). FFI - `platform_wallet_manager_bind_shielded` now hands the coordinator (not the bare store) to `bind_shielded`. Signature on the Swift side is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/shielded_sync.rs | 11 +- .../rs-platform-wallet/src/manager/mod.rs | 20 +- .../src/manager/shielded_sync.rs | 79 +++-- .../src/wallet/platform_wallet.rs | 33 +- .../src/wallet/shielded/coordinator.rs | 285 +++++++++++++++++- 5 files changed, 377 insertions(+), 51 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs index ca21c43ba9e..4d7bad75b6e 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs @@ -286,9 +286,11 @@ pub unsafe extern "C" fn platform_wallet_manager_bind_shielded( drop(mnemonic); // Look up the wallet + the network-scoped shielded coordinator - // on the manager. The coordinator owns the single SQLite handle; - // we hand its store down to `bind_shielded` so this wallet's - // `ShieldedWallet` reuses it instead of opening its own. + // on the manager. The coordinator owns the single SQLite handle + // *and* the per-network sync-coordination registry; we hand it + // to `bind_shielded` so the wallet reuses the shared store and + // self-registers its viewing keys for the coordinator-driven + // sync loop. let lookup = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { runtime().block_on(async { let wallet = manager.get_wallet(&wallet_id).await; @@ -315,12 +317,11 @@ pub unsafe extern "C" fn platform_wallet_manager_bind_shielded( ); } }; - let shared_store = std::sync::Arc::clone(coordinator.store()); if let Err(e) = runtime().block_on(wallet_arc.bind_shielded( seed.as_ref(), accounts.as_slice(), - shared_store, + &coordinator, )) { return PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorWalletOperation, diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 9f207a616a5..55236d9b4eb 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -131,9 +131,14 @@ impl PlatformWalletManager

{ Arc::clone(&persister), )); #[cfg(feature = "shielded")] + let shielded_coordinator: Arc< + RwLock>>, + > = Arc::new(RwLock::new(None)); + #[cfg(feature = "shielded")] let shielded_sync = Arc::new(ShieldedSyncManager::new( Arc::clone(&wallets), Arc::clone(&event_manager), + Arc::clone(&shielded_coordinator), )); Self { sdk, @@ -146,7 +151,7 @@ impl PlatformWalletManager

{ #[cfg(feature = "shielded")] shielded_sync_manager: shielded_sync, #[cfg(feature = "shielded")] - shielded_coordinator: Arc::new(RwLock::new(None)), + shielded_coordinator, persister, event_adapter_cancel, event_adapter_join: tokio::sync::Mutex::new(Some(event_adapter_join)), @@ -203,12 +208,15 @@ impl PlatformWalletManager

{ self.sdk.network, db_path, store, - // Persister attaches in Phase 2 when the coordinator - // owns the sync loop and emits the consolidated - // changeset. For Phase 1 the per-wallet `ShieldedWallet` - // still holds its own persister handle and queues - // changesets exactly as before. + // Persister attaches in Phase 2b when the coordinator + // owns the actual fetch / decrypt loop and emits the + // consolidated changeset. In Phase 2a the per-wallet + // `ShieldedWallet` still holds its own persister + // handle and queues changesets exactly as before; + // the coordinator only owns the cooldown gate and + // the per-wallet iteration. None, + Arc::clone(&self.wallets), )); *slot = Some(coordinator); Ok(()) diff --git a/packages/rs-platform-wallet/src/manager/shielded_sync.rs b/packages/rs-platform-wallet/src/manager/shielded_sync.rs index a1e70ca545e..065e258b204 100644 --- a/packages/rs-platform-wallet/src/manager/shielded_sync.rs +++ b/packages/rs-platform-wallet/src/manager/shielded_sync.rs @@ -29,7 +29,7 @@ use tokio_util::sync::CancellationToken; use crate::events::PlatformEventManager; use crate::wallet::platform_wallet::WalletId; -use crate::wallet::shielded::ShieldedSyncSummary; +use crate::wallet::shielded::{NetworkShieldedCoordinator, ShieldedSyncSummary}; use crate::wallet::PlatformWallet; /// Default cadence — 60s. Shielded sync is heavier than address sync @@ -123,6 +123,14 @@ impl ShieldedSyncPassSummary { pub struct ShieldedSyncManager { wallets: Arc>>>, event_manager: Arc, + /// Network-scoped shielded coordinator slot, shared with the + /// owning `PlatformWalletManager`. When `Some`, `sync_now` + /// routes through `coordinator.sync(force)` so the + /// network-wide caught-up cooldown applies and a future + /// Phase-2b lift can collapse the per-wallet SDK fetches. + /// When `None` (shielded support hasn't been configured), + /// `sync_now` falls back to the legacy per-wallet iteration. + coordinator_slot: Arc>>>, /// Cancel token for the background loop, if running. background_cancel: StdMutex>, /// Monotonically increasing generation counter. Bumped on every @@ -143,10 +151,12 @@ impl ShieldedSyncManager { pub fn new( wallets: Arc>>>, event_manager: Arc, + coordinator_slot: Arc>>>, ) -> Self { Self { wallets, event_manager, + coordinator_slot, background_cancel: StdMutex::new(None), background_generation: AtomicU64::new(0), interval_secs: AtomicU64::new(DEFAULT_SYNC_INTERVAL_SECS), @@ -285,34 +295,61 @@ impl ShieldedSyncManager { return ShieldedSyncPassSummary::default(); } - let snapshot: Vec<(WalletId, Arc)> = { - let wallets = self.wallets.read().await; - wallets.iter().map(|(id, w)| (*id, Arc::clone(w))).collect() + // Phase 2a: if shielded support has been configured on the + // owning manager, route through the coordinator so the + // network-wide caught-up cooldown applies and a future + // Phase-2b lift can collapse the per-wallet SDK fetches. + // Snapshot the coordinator Arc and release the slot lock + // before awaiting so a concurrent `configure_shielded` + // can't deadlock against our pass. + let coordinator_snapshot: Option> = { + let slot = self.coordinator_slot.read().await; + slot.as_ref().map(Arc::clone) }; - let mut summary = ShieldedSyncPassSummary::default(); - for (wallet_id, wallet) in snapshot { - let outcome = match wallet.shielded_sync(force).await { - Ok(Some(result)) => WalletShieldedOutcome::Ok(result), - Ok(None) => WalletShieldedOutcome::Skipped, - Err(e) => { - tracing::warn!( - "Shielded sync failed for wallet {}: {}", - hex::encode(wallet_id), - e - ); - WalletShieldedOutcome::Err(e.to_string()) - } + let mut summary = if let Some(coordinator) = coordinator_snapshot { + coordinator.sync(force).await + } else { + // Pre-configure fallback: legacy per-wallet iteration. + // Used when no wallet on this manager has ever called + // `configure_shielded` (e.g. in tests / non-shielded + // builds with the feature on but not exercised). + let snapshot: Vec<(WalletId, Arc)> = { + let wallets = self.wallets.read().await; + wallets.iter().map(|(id, w)| (*id, Arc::clone(w))).collect() }; - summary.wallet_results.insert(wallet_id, outcome); - } + + let mut s = ShieldedSyncPassSummary::default(); + for (wallet_id, wallet) in snapshot { + let outcome = match wallet.shielded_sync(force).await { + Ok(Some(result)) => WalletShieldedOutcome::Ok(result), + Ok(None) => WalletShieldedOutcome::Skipped, + Err(e) => { + tracing::warn!( + "Shielded sync failed for wallet {}: {}", + hex::encode(wallet_id), + e + ); + WalletShieldedOutcome::Err(e.to_string()) + } + }; + s.wallet_results.insert(wallet_id, outcome); + } + s + }; let now = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); - summary.sync_unix_seconds = now; - self.last_sync_unix.store(now, Ordering::Release); + // Honor a non-zero timestamp from the coordinator (it + // stamps `sync_unix_seconds` itself), and stamp our own + // for the fallback path. + if summary.sync_unix_seconds == 0 { + summary.sync_unix_seconds = now; + } + self.last_sync_unix + .store(summary.sync_unix_seconds, Ordering::Release); self.is_syncing.store(false, Ordering::Release); self.event_manager.on_shielded_sync_completed(&summary); diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index bc401a579f1..022796adc30 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -314,12 +314,15 @@ impl PlatformWallet { &self, seed: &[u8], accounts: &[u32], - store: Arc>, + coordinator: &Arc, ) -> Result<(), PlatformWalletError> { - // The store is sourced by the caller from the - // manager-level `NetworkShieldedCoordinator` — every - // wallet on the same network shares one SQLite handle. + // The store comes from the network-scoped coordinator — + // every wallet on the same network shares one SQLite + // handle. The bind also self-registers the wallet's + // viewing-key set on the coordinator so future sync + // passes (driven by the coordinator) iterate it. // See `PlatformWalletManager::configure_shielded`. + let store = Arc::clone(coordinator.store()); let network = self.sdk.network; let mut wallet = ShieldedWallet::from_seed_accounts( Arc::clone(&self.sdk), @@ -360,8 +363,30 @@ impl PlatformWallet { } } + // Snapshot the viewing-key subset for coordinator + // registration. Privilege separation: only FVK / IVK / + // OVK / default address cross to the coordinator; the + // `SpendAuthorizingKey` stays here on the per-wallet + // side inside `OrchardKeySet`. + let account_views: std::collections::BTreeMap = + wallet + .account_indices() + .into_iter() + .filter_map(|account| { + wallet + .keys_for(account) + .ok() + .map(|ks| (account, ks.viewing_keys())) + }) + .collect(); + let mut slot = self.shielded.write().await; *slot = Some(wallet); + drop(slot); + + coordinator + .register_wallet(self.wallet_id, account_views) + .await; Ok(()) } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index ef70d476c3c..0a691239eb6 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -27,38 +27,62 @@ //! coordinator's spend methods at call time, never stored at //! coordinator scope. //! -//! # Phase 0 status +//! # Status (Phase 2a) //! -//! This module currently contains the type skeleton only — the -//! coordinator is declared, its fields wired up, and its -//! lifecycle helpers documented. None of the existing sync / -//! spend code paths consume it yet; that wiring lands in -//! Phase 1+. +//! - **Phase 0** (landed): type skeleton + viewing-key split. +//! - **Phase 1** (landed): the coordinator owns the single +//! `Arc>` for the network; +//! every `PlatformWallet::bind_shielded` reuses it. +//! - **Phase 2a** (this module): the coordinator now owns the +//! network-wide caught-up cooldown and the +//! [`sync`](NetworkShieldedCoordinator::sync) entry point that +//! `ShieldedSyncManager::sync_now` routes through. Per-wallet +//! iteration still calls into +//! [`PlatformWallet::shielded_sync(force=true)`] under the +//! coordinator's cooldown gate. +//! - **Phase 2b** (next): replace the per-wallet iteration with a +//! single network-wide fetch + multi-IVK trial-decrypt against +//! the union of every registered subwallet — collapses N SDK +//! calls per pass to 1. +//! - **Phase 4** (later): delete `ShieldedWallet`, flatten +//! `PlatformWallet`'s shielded surface, and have the coordinator +//! own the spend path too (accepting an `OrchardKeySet` at +//! call time for the ASK). use std::collections::BTreeMap; use std::path::PathBuf; use std::sync::Arc; +use std::time::{Duration, Instant}; use tokio::sync::RwLock; use super::file_store::FileBackedShieldedStore; use super::keys::AccountViewingKeys; use super::store::SubwalletId; +use super::CAUGHT_UP_COOLDOWN; +use crate::manager::shielded_sync::{ShieldedSyncPassSummary, WalletShieldedOutcome}; use crate::wallet::persister::WalletPersister; +use crate::wallet::platform_wallet::WalletId; +use crate::wallet::PlatformWallet; /// Network-scoped shielded coordinator. /// /// See module docs for the architectural rationale. /// -/// `#[allow(dead_code)]` on the fields is the Phase-0 marker — -/// the type compiles and is exported, but nothing reads it yet. -/// The annotations come off as each phase wires its respective -/// field into the real code path. -#[allow(dead_code)] +/// As of Phase 2a, the coordinator owns the network-wide +/// caught-up cooldown and the sync entry point that +/// [`ShieldedSyncManager`](crate::manager::shielded_sync::ShieldedSyncManager) +/// drives — wallet iteration still delegates to +/// [`PlatformWallet::shielded_sync`] under the hood until Phase 2b +/// collapses the N per-wallet SDK fetches into one +/// network-wide fetch + multi-IVK trial-decrypt pass. pub struct NetworkShieldedCoordinator { /// Dash Platform SDK handle. The coordinator runs sync / /// nullifier-scan / broadcast against this SDK on behalf of - /// every bound wallet. + /// every bound wallet. Held but unused in Phase 2a — the + /// per-wallet `ShieldedWallet` still owns the SDK call in + /// its own `sync_notes`. Phase 2b lifts that call up here. + #[allow(dead_code)] sdk: Arc, /// Network this coordinator operates on. Pinned at @@ -101,7 +125,11 @@ pub struct NetworkShieldedCoordinator { /// configured on the manager. The coordinator emits a single /// consolidated [`ShieldedChangeSet`] per sync pass — the /// changeset is already `SubwalletId`-keyed so per-wallet - /// fan-out happens naturally on the host side. + /// fan-out happens naturally on the host side. Held but + /// unused in Phase 2a — per-wallet `ShieldedWallet`s still + /// queue their own changesets through their own persister + /// clones. Phase 2b moves the queueing here. + #[allow(dead_code)] persister: Option, /// Timestamp of the last sync pass that observed no new @@ -110,10 +138,18 @@ pub struct NetworkShieldedCoordinator { /// per-coordinator scope, so the cooldown applies once per /// network instead of once per wallet. Cleared on any /// activity; bypassed by `force` syncs. - last_caught_up_at: std::sync::Mutex>, + last_caught_up_at: std::sync::Mutex>, + + /// Shared handle to the manager's wallets map. The coordinator + /// looks up `Arc` by [`WalletId`] when its + /// [`sync`](Self::sync) iterates registered subwallets. + /// Held as a cloned `Arc` of the same `RwLock` the manager + /// owns, so wallets added after [`configure_shielded`] are + /// visible to the coordinator on the next sync pass without + /// any explicit re-registration. + wallets: Arc>>>, } -#[allow(dead_code)] impl NetworkShieldedCoordinator { /// Build a new coordinator. Called by /// `PlatformWalletManager::configure_shielded` (Phase 1) on @@ -128,6 +164,7 @@ impl NetworkShieldedCoordinator { db_path: PathBuf, store: FileBackedShieldedStore, persister: Option, + wallets: Arc>>>, ) -> Self { Self { sdk, @@ -137,6 +174,7 @@ impl NetworkShieldedCoordinator { accounts: Arc::new(RwLock::new(BTreeMap::new())), persister, last_caught_up_at: std::sync::Mutex::new(None), + wallets, } } @@ -160,4 +198,221 @@ impl NetworkShieldedCoordinator { pub fn store(&self) -> &Arc> { &self.store } + + /// Register every account of a newly-bound shielded wallet so + /// future [`sync`](Self::sync) passes iterate it. Called by + /// [`PlatformWallet::bind_shielded`] after the per-wallet + /// [`ShieldedWallet`] has been constructed. + /// + /// Privilege boundary: only the viewing-key subset + /// ([`AccountViewingKeys`]) is handed to the coordinator. The + /// `SpendAuthorizingKey` stays on the per-wallet side + /// (`OrchardKeySet`) and is re-attached at spend-call time. + /// + /// Idempotent: a second call with the same `wallet_id` + /// replaces every previously-registered account for that + /// wallet (so a re-bind after a clear is consistent). + /// + /// [`ShieldedWallet`]: super::ShieldedWallet + /// [`PlatformWallet::bind_shielded`]: crate::wallet::PlatformWallet::bind_shielded + pub async fn register_wallet( + &self, + wallet_id: WalletId, + account_views: BTreeMap, + ) { + let mut accounts = self.accounts.write().await; + // Drop any prior subwallets for this wallet_id before + // installing the new set so a re-bind with a different + // account list doesn't leave orphan entries. + accounts.retain(|id, _| id.wallet_id != wallet_id); + for (account_index, views) in account_views { + accounts.insert(SubwalletId::new(wallet_id, account_index), views); + } + } + + /// Remove every account belonging to `wallet_id` from the + /// coordinator's registry. No-op if the wallet wasn't + /// registered. Called when a wallet is unregistered from the + /// manager or when its shielded binding is cleared. + pub async fn unregister_wallet(&self, wallet_id: WalletId) { + let mut accounts = self.accounts.write().await; + accounts.retain(|id, _| id.wallet_id != wallet_id); + } + + /// Currently-registered subwallet ids (snapshot, ascending + /// `(wallet_id, account_index)` order). Exposed for tests and + /// for the sync coordinator's pass enumeration. + pub async fn registered_subwallets(&self) -> Vec { + self.accounts.read().await.keys().copied().collect() + } + + /// Run one shielded sync pass for every registered wallet on + /// this coordinator's network. Returns a per-wallet outcome + /// summary suitable for emission to UI / persistence layers + /// via [`PlatformEventManager::on_shielded_sync_completed`]. + /// + /// `force=false` honors the coordinator-scoped caught-up + /// cooldown — a sync pass that observed nothing new on any + /// registered subwallet suppresses subsequent background + /// passes for [`CAUGHT_UP_COOLDOWN`]. `force=true` (the + /// user-initiated "Sync Now" path) bypasses the cooldown and + /// always walks Platform. + /// + /// Phase-2a shape: this method iterates the registered + /// wallets and delegates each one to + /// [`PlatformWallet::shielded_sync(true)`] under the + /// coordinator's cooldown gate. Phase 2b collapses the N + /// per-wallet SDK fetches into a single network-wide fetch + + /// multi-IVK trial-decrypt pass against the union of every + /// subwallet's [`AccountViewingKeys`]. + /// + /// [`PlatformEventManager::on_shielded_sync_completed`]: + /// crate::events::PlatformEventManager::on_shielded_sync_completed + pub async fn sync(&self, force: bool) -> ShieldedSyncPassSummary { + // Network-wide cooldown gate. Snapshot the remaining + // window into a local before any await — `std::sync::Mutex` + // is `!Send` across await points (clippy's + // `await_holding_lock` lint flags this). + let cooldown_remaining: Option = if force { + None + } else { + self.last_caught_up_at + .lock() + .ok() + .and_then(|guard| *guard) + .map(|when| CAUGHT_UP_COOLDOWN.saturating_sub(when.elapsed())) + .filter(|remaining| !remaining.is_zero()) + }; + + if let Some(remaining) = cooldown_remaining { + tracing::debug!( + cooldown_remaining_secs = remaining.as_secs(), + cooldown_total_secs = CAUGHT_UP_COOLDOWN.as_secs(), + "Coordinator sync skipped — within caught-up cooldown" + ); + return Self::cooldown_skip_summary(self).await; + } + + // Snapshot the registered wallet ids first, then look up + // their `Arc` clones from the shared + // wallets map. The coordinator iterates by `WalletId` + // (not by `SubwalletId`) because `PlatformWallet::shielded_sync` + // already runs every bound account in a single chain walk. + let registered_wallet_ids: Vec = { + let accounts = self.accounts.read().await; + let mut ids: Vec = accounts.keys().map(|id| id.wallet_id).collect(); + ids.sort_unstable(); + ids.dedup(); + ids + }; + + let mut summary = ShieldedSyncPassSummary::default(); + if registered_wallet_ids.is_empty() { + return summary; + } + + let wallets_snapshot: Vec<(WalletId, Option>)> = { + let wallets = self.wallets.read().await; + registered_wallet_ids + .iter() + .map(|id| (*id, wallets.get(id).cloned())) + .collect() + }; + + let mut any_activity = false; + for (wallet_id, wallet) in wallets_snapshot { + let Some(wallet) = wallet else { + // Registered in coordinator but missing from the + // wallets map — host inconsistency. Skip with a + // warn so this surfaces in logs but a single + // missing wallet doesn't poison the whole pass. + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + "Shielded sync skipped: wallet registered on coordinator but not in wallets map" + ); + summary + .wallet_results + .insert(wallet_id, WalletShieldedOutcome::Skipped); + continue; + }; + + // Always force the per-wallet path — the coordinator + // already gated on the network-wide cooldown above, + // so the per-wallet `last_caught_up_at` would + // double-gate and miss new chunks during the + // cooldown window. + let outcome = match wallet.shielded_sync(true).await { + Ok(Some(result)) => { + if !result.is_cooldown_skip + && (result.notes_result.total_scanned > 0 || result.total_newly_spent() > 0) + { + any_activity = true; + } + WalletShieldedOutcome::Ok(result) + } + Ok(None) => WalletShieldedOutcome::Skipped, + Err(e) => { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "Shielded sync failed via coordinator" + ); + WalletShieldedOutcome::Err(e.to_string()) + } + }; + summary.wallet_results.insert(wallet_id, outcome); + } + + // Update the network-wide cooldown stamp based on + // aggregate activity. Any new commitment or newly-spent + // nullifier anywhere in the network clears the stamp so + // the next pass runs immediately. + if let Ok(mut guard) = self.last_caught_up_at.lock() { + if any_activity { + *guard = None; + } else { + *guard = Some(Instant::now()); + } + } + + summary.sync_unix_seconds = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + summary + } + + /// Build a pass summary in which every registered wallet is + /// reported as a cooldown skip. Used by [`sync`](Self::sync) + /// when the network-wide cooldown is in effect. + async fn cooldown_skip_summary(&self) -> ShieldedSyncPassSummary { + use super::sync::{ShieldedSyncSummary, SyncNotesResult}; + + let registered_wallet_ids: Vec = { + let accounts = self.accounts.read().await; + let mut ids: Vec = accounts.keys().map(|id| id.wallet_id).collect(); + ids.sort_unstable(); + ids.dedup(); + ids + }; + + let mut summary = ShieldedSyncPassSummary::default(); + for wallet_id in registered_wallet_ids { + summary.wallet_results.insert( + wallet_id, + WalletShieldedOutcome::Ok(ShieldedSyncSummary { + notes_result: SyncNotesResult::default(), + newly_spent_per_account: BTreeMap::new(), + balances: BTreeMap::new(), + is_cooldown_skip: true, + }), + ); + } + summary.sync_unix_seconds = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + summary + } } From aeaa861948b3c1b109b7a2ab3901fdc34494a957 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 20:43:51 +0700 Subject: [PATCH 55/78] =?UTF-8?q?refactor(platform-wallet):=20Phase=202b?= =?UTF-8?q?=20=E2=80=94=20collapse=20N=E2=86=921=20SDK=20calls=20per=20shi?= =?UTF-8?q?elded=20sync=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The actual architectural win: with N wallets registered on the same network, a sync pass now makes **one** SDK call instead of N. The first registered subwallet's IVK drives `sync_shielded_notes`; the SDK's returned `all_notes` is then locally trial-decrypted against every other subwallet's IVK in the same pass. Commitments are appended to the shared tree exactly once per global position with `marked = (any subwallet decrypted it)`, so accounts that belong to different wallets but share the network all retain their authentication paths. Free functions The sync internals lift from `&self` methods on `ShieldedWallet` to three free functions accepting `&[(SubwalletId, AccountViewingKeys)]`: - `sync_notes_across` returns `MultiSyncNotesResult` (per-subwallet new-note counts, network `total_scanned`, accumulated changeset). - `check_nullifiers_across` returns per-subwallet newly-spent counts + accumulated changeset. (The SDK's nullifier-scan API is per-checkpoint so it stays per-subwallet; still avoids the per-`ShieldedWallet` hop.) - `balances_across` returns a per-subwallet unspent-balance snapshot. Privilege boundary: only the viewing-key half ([`AccountViewingKeys`]) is passed in — IVK for trial decryption, FVK for nullifier derivation. No `SpendAuthorizingKey` is needed by sync. ShieldedWallet methods are now thin delegators `ShieldedWallet::sync_notes` / `check_nullifiers` / `balances` build their own one-wallet slice and delegate to the free functions, then fold per-`SubwalletId` results back into the legacy per-account shape for backward-compat with tests / the fallback path in `ShieldedSyncManager` when no coordinator is configured. Phase 4 removes these along with `ShieldedWallet`. Coordinator drives sync end-to-end `NetworkShieldedCoordinator::sync(force)` now: 1. Network-wide cooldown gate (unchanged from Phase 2a). 2. Snapshots flat `Vec<(SubwalletId, AccountViewingKeys)>` from the registry. 3. Calls `sync_notes_across` / `check_nullifiers_across` / `balances_across` directly — no more per-wallet `shielded_sync(true)` iteration. 4. Merges the two changeset streams, splits per-`WalletId` via `ShieldedChangeSet::split_by_wallet_id`, and queues each slice through the corresponding `WalletPersister` (registered at `register_wallet` time alongside the viewing keys). 5. Demuxes multi-subwallet results into per-wallet `ShieldedSyncSummary` for the existing `PlatformEventManager::on_shielded_sync_completed` event shape. ChangeSet plumbing - `ShieldedChangeSet::split_by_wallet_id` fans a consolidated multi-wallet changeset out into per-`WalletId` slices so each per-wallet `WalletPersister.store(...)` only sees its own wallet's deltas. Required because `WalletPersister` is wallet-scoped — `inner.store(wallet_id, ...)` always passes its bound wallet_id to the durable layer. `PlatformWallet::bind_shielded` now passes the persister to `coordinator.register_wallet` alongside the account-views map so the coordinator has the persister handle ready for sync-pass queueing. The `wallets` field is dropped from the coordinator — Phase 2b drives sync from the flat subwallet registry directly, no `Arc` lookup needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/changeset/shielded_changeset.rs | 54 ++ .../rs-platform-wallet/src/manager/mod.rs | 9 - .../src/wallet/platform_wallet.rs | 2 +- .../src/wallet/shielded/coordinator.rs | 344 +++++--- .../src/wallet/shielded/sync.rs | 744 +++++++++++------- 5 files changed, 717 insertions(+), 436 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/shielded_changeset.rs b/packages/rs-platform-wallet/src/changeset/shielded_changeset.rs index dc90afd5176..f6839ae16f1 100644 --- a/packages/rs-platform-wallet/src/changeset/shielded_changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/shielded_changeset.rs @@ -71,6 +71,60 @@ impl ShieldedChangeSet { pub fn record_nullifier_checkpoint(&mut self, id: SubwalletId, height: u64, timestamp: u64) { self.nullifier_checkpoints.insert(id, (height, timestamp)); } + + /// Split a consolidated shielded changeset into one + /// `ShieldedChangeSet` per `WalletId`. Used by the + /// network-scoped coordinator's sync path: the free + /// functions (`sync_notes_across`, `check_nullifiers_across`) + /// build a single `ShieldedChangeSet` spanning every + /// touched subwallet; the caller splits it here so each + /// per-wallet `WalletPersister.store(...)` only sees its + /// own `wallet_id`'s deltas. Empty per-wallet entries are + /// skipped so callers don't queue no-op changesets. + pub fn split_by_wallet_id( + self, + ) -> BTreeMap { + let ShieldedChangeSet { + notes_saved, + nullifiers_spent, + synced_indices, + nullifier_checkpoints, + } = self; + let mut out: BTreeMap = + BTreeMap::new(); + for (id, notes) in notes_saved { + out.entry(id.wallet_id) + .or_default() + .notes_saved + .insert(id, notes); + } + for (id, nfs) in nullifiers_spent { + out.entry(id.wallet_id) + .or_default() + .nullifiers_spent + .insert(id, nfs); + } + for (id, idx) in synced_indices { + out.entry(id.wallet_id) + .or_default() + .synced_indices + .insert(id, idx); + } + for (id, cp) in nullifier_checkpoints { + out.entry(id.wallet_id) + .or_default() + .nullifier_checkpoints + .insert(id, cp); + } + // Defensive: drop empty entries so the persister doesn't + // see noise. `split_by_wallet_id` is called on the result + // of a sync pass where at least one map is non-empty + // (otherwise the caller would have short-circuited), but + // a future caller could legitimately split an + // already-empty changeset. + out.retain(|_, cs| !cs.is_empty()); + out + } } impl Merge for ShieldedChangeSet { diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 55236d9b4eb..6d79d4ac86c 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -208,15 +208,6 @@ impl PlatformWalletManager

{ self.sdk.network, db_path, store, - // Persister attaches in Phase 2b when the coordinator - // owns the actual fetch / decrypt loop and emits the - // consolidated changeset. In Phase 2a the per-wallet - // `ShieldedWallet` still holds its own persister - // handle and queues changesets exactly as before; - // the coordinator only owns the cooldown gate and - // the per-wallet iteration. - None, - Arc::clone(&self.wallets), )); *slot = Some(coordinator); Ok(()) diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 022796adc30..606a081376a 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -385,7 +385,7 @@ impl PlatformWallet { drop(slot); coordinator - .register_wallet(self.wallet_id, account_views) + .register_wallet(self.wallet_id, account_views, self.persister.clone()) .await; Ok(()) } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index 0a691239eb6..919aa732d34 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -27,27 +27,29 @@ //! coordinator's spend methods at call time, never stored at //! coordinator scope. //! -//! # Status (Phase 2a) +//! # Status (Phase 2b) //! //! - **Phase 0** (landed): type skeleton + viewing-key split. //! - **Phase 1** (landed): the coordinator owns the single //! `Arc>` for the network; //! every `PlatformWallet::bind_shielded` reuses it. -//! - **Phase 2a** (this module): the coordinator now owns the +//! - **Phase 2a** (landed): the coordinator owns the //! network-wide caught-up cooldown and the //! [`sync`](NetworkShieldedCoordinator::sync) entry point that -//! `ShieldedSyncManager::sync_now` routes through. Per-wallet -//! iteration still calls into -//! [`PlatformWallet::shielded_sync(force=true)`] under the -//! coordinator's cooldown gate. -//! - **Phase 2b** (next): replace the per-wallet iteration with a -//! single network-wide fetch + multi-IVK trial-decrypt against -//! the union of every registered subwallet — collapses N SDK -//! calls per pass to 1. +//! `ShieldedSyncManager::sync_now` routes through. +//! - **Phase 2b** (this module): the coordinator drives sync +//! itself via [`sync_notes_across`] — a single network-wide +//! SDK fetch + multi-IVK trial-decrypt against the union of +//! every registered subwallet, collapsing N per-wallet SDK +//! calls per pass to 1. The consolidated changeset is split +//! per-`WalletId` and queued through each registered +//! [`WalletPersister`]. //! - **Phase 4** (later): delete `ShieldedWallet`, flatten -//! `PlatformWallet`'s shielded surface, and have the coordinator -//! own the spend path too (accepting an `OrchardKeySet` at -//! call time for the ASK). +//! `PlatformWallet`'s shielded surface, and have the +//! coordinator own the spend path too (accepting an +//! `OrchardKeySet` at call time for the ASK). +//! +//! [`sync_notes_across`]: super::sync::sync_notes_across use std::collections::BTreeMap; use std::path::PathBuf; @@ -63,26 +65,20 @@ use super::CAUGHT_UP_COOLDOWN; use crate::manager::shielded_sync::{ShieldedSyncPassSummary, WalletShieldedOutcome}; use crate::wallet::persister::WalletPersister; use crate::wallet::platform_wallet::WalletId; -use crate::wallet::PlatformWallet; /// Network-scoped shielded coordinator. /// /// See module docs for the architectural rationale. /// -/// As of Phase 2a, the coordinator owns the network-wide -/// caught-up cooldown and the sync entry point that -/// [`ShieldedSyncManager`](crate::manager::shielded_sync::ShieldedSyncManager) -/// drives — wallet iteration still delegates to -/// [`PlatformWallet::shielded_sync`] under the hood until Phase 2b -/// collapses the N per-wallet SDK fetches into one -/// network-wide fetch + multi-IVK trial-decrypt pass. +/// As of Phase 2b, the coordinator owns the entire sync path +/// for the network: a single SDK fetch + multi-IVK +/// trial-decrypt against the union of every registered +/// subwallet, with the consolidated changeset split per-`WalletId` +/// and queued through each registered persister. pub struct NetworkShieldedCoordinator { /// Dash Platform SDK handle. The coordinator runs sync / /// nullifier-scan / broadcast against this SDK on behalf of - /// every bound wallet. Held but unused in Phase 2a — the - /// per-wallet `ShieldedWallet` still owns the SDK call in - /// its own `sync_notes`. Phase 2b lifts that call up here. - #[allow(dead_code)] + /// every bound wallet. sdk: Arc, /// Network this coordinator operates on. Pinned at @@ -121,16 +117,18 @@ pub struct NetworkShieldedCoordinator { /// the per-wallet caller. accounts: Arc>>, - /// Persister handle attached when shielded support is first - /// configured on the manager. The coordinator emits a single - /// consolidated [`ShieldedChangeSet`] per sync pass — the - /// changeset is already `SubwalletId`-keyed so per-wallet - /// fan-out happens naturally on the host side. Held but - /// unused in Phase 2a — per-wallet `ShieldedWallet`s still - /// queue their own changesets through their own persister - /// clones. Phase 2b moves the queueing here. - #[allow(dead_code)] - persister: Option, + /// Per-wallet persister handles, populated by + /// [`register_wallet`](Self::register_wallet) alongside the + /// account registry. The Phase-2b sync builds a single + /// consolidated [`ShieldedChangeSet`] spanning every + /// touched subwallet, then + /// [`ShieldedChangeSet::split_by_wallet_id`] fans it back + /// out so each per-wallet `WalletPersister.store(...)` only + /// sees its own `wallet_id`'s deltas. (The wire format + /// requires this — `WalletPersister` is wallet-scoped and + /// `inner.store(wallet_id, ...)` always passes its bound + /// wallet_id to the durable layer.) + persisters: Arc>>, /// Timestamp of the last sync pass that observed no new /// commitments or newly-spent nullifiers — the caught-up @@ -139,15 +137,6 @@ pub struct NetworkShieldedCoordinator { /// network instead of once per wallet. Cleared on any /// activity; bypassed by `force` syncs. last_caught_up_at: std::sync::Mutex>, - - /// Shared handle to the manager's wallets map. The coordinator - /// looks up `Arc` by [`WalletId`] when its - /// [`sync`](Self::sync) iterates registered subwallets. - /// Held as a cloned `Arc` of the same `RwLock` the manager - /// owns, so wallets added after [`configure_shielded`] are - /// visible to the coordinator on the next sync pass without - /// any explicit re-registration. - wallets: Arc>>>, } impl NetworkShieldedCoordinator { @@ -163,8 +152,6 @@ impl NetworkShieldedCoordinator { network: dashcore::Network, db_path: PathBuf, store: FileBackedShieldedStore, - persister: Option, - wallets: Arc>>>, ) -> Self { Self { sdk, @@ -172,9 +159,8 @@ impl NetworkShieldedCoordinator { db_path, store: Arc::new(RwLock::new(store)), accounts: Arc::new(RwLock::new(BTreeMap::new())), - persister, + persisters: Arc::new(RwLock::new(BTreeMap::new())), last_caught_up_at: std::sync::Mutex::new(None), - wallets, } } @@ -200,9 +186,11 @@ impl NetworkShieldedCoordinator { } /// Register every account of a newly-bound shielded wallet so - /// future [`sync`](Self::sync) passes iterate it. Called by - /// [`PlatformWallet::bind_shielded`] after the per-wallet - /// [`ShieldedWallet`] has been constructed. + /// future [`sync`](Self::sync) passes iterate it, and attach + /// the wallet's [`WalletPersister`] so the coordinator can + /// queue per-wallet slices of the consolidated changeset. + /// Called by [`PlatformWallet::bind_shielded`] after the + /// per-wallet [`ShieldedWallet`] has been constructed. /// /// Privilege boundary: only the viewing-key subset /// ([`AccountViewingKeys`]) is handed to the coordinator. The @@ -210,8 +198,9 @@ impl NetworkShieldedCoordinator { /// (`OrchardKeySet`) and is re-attached at spend-call time. /// /// Idempotent: a second call with the same `wallet_id` - /// replaces every previously-registered account for that - /// wallet (so a re-bind after a clear is consistent). + /// replaces every previously-registered account and the + /// persister handle for that wallet (so a re-bind after a + /// clear is consistent). /// /// [`ShieldedWallet`]: super::ShieldedWallet /// [`PlatformWallet::bind_shielded`]: crate::wallet::PlatformWallet::bind_shielded @@ -219,6 +208,7 @@ impl NetworkShieldedCoordinator { &self, wallet_id: WalletId, account_views: BTreeMap, + persister: WalletPersister, ) { let mut accounts = self.accounts.write().await; // Drop any prior subwallets for this wallet_id before @@ -228,15 +218,21 @@ impl NetworkShieldedCoordinator { for (account_index, views) in account_views { accounts.insert(SubwalletId::new(wallet_id, account_index), views); } + drop(accounts); + self.persisters.write().await.insert(wallet_id, persister); } /// Remove every account belonging to `wallet_id` from the - /// coordinator's registry. No-op if the wallet wasn't - /// registered. Called when a wallet is unregistered from the - /// manager or when its shielded binding is cleared. + /// coordinator's registry and drop the persister handle. + /// No-op if the wallet wasn't registered. Called when a + /// wallet is unregistered from the manager or when its + /// shielded binding is cleared. pub async fn unregister_wallet(&self, wallet_id: WalletId) { - let mut accounts = self.accounts.write().await; - accounts.retain(|id, _| id.wallet_id != wallet_id); + self.accounts + .write() + .await + .retain(|id, _| id.wallet_id != wallet_id); + self.persisters.write().await.remove(&wallet_id); } /// Currently-registered subwallet ids (snapshot, ascending @@ -258,14 +254,15 @@ impl NetworkShieldedCoordinator { /// user-initiated "Sync Now" path) bypasses the cooldown and /// always walks Platform. /// - /// Phase-2a shape: this method iterates the registered - /// wallets and delegates each one to - /// [`PlatformWallet::shielded_sync(true)`] under the - /// coordinator's cooldown gate. Phase 2b collapses the N - /// per-wallet SDK fetches into a single network-wide fetch + - /// multi-IVK trial-decrypt pass against the union of every - /// subwallet's [`AccountViewingKeys`]. + /// Phase-2b shape: the union of every registered subwallet's + /// [`AccountViewingKeys`] drives a **single** SDK fetch via + /// [`sync_notes_across`]; the SDK's `all_notes` is locally + /// trial-decrypted against every other subwallet's IVK in + /// the same pass. The consolidated changeset is then split + /// per-[`WalletId`] and queued through each registered + /// [`WalletPersister`]. /// + /// [`sync_notes_across`]: super::sync::sync_notes_across /// [`PlatformEventManager::on_shielded_sync_completed`]: /// crate::events::PlatformEventManager::on_shielded_sync_completed pub async fn sync(&self, force: bool) -> ShieldedSyncPassSummary { @@ -293,80 +290,73 @@ impl NetworkShieldedCoordinator { return Self::cooldown_skip_summary(self).await; } - // Snapshot the registered wallet ids first, then look up - // their `Arc` clones from the shared - // wallets map. The coordinator iterates by `WalletId` - // (not by `SubwalletId`) because `PlatformWallet::shielded_sync` - // already runs every bound account in a single chain walk. - let registered_wallet_ids: Vec = { + // Snapshot the flat subwallet registry. This Vec is both + // the IVK fan-out for sync_notes_across and the + // identity-map for per-wallet summary demux below. + let subwallets: Vec<(SubwalletId, AccountViewingKeys)> = { let accounts = self.accounts.read().await; - let mut ids: Vec = accounts.keys().map(|id| id.wallet_id).collect(); - ids.sort_unstable(); - ids.dedup(); - ids + accounts.iter().map(|(id, v)| (*id, v.clone())).collect() }; let mut summary = ShieldedSyncPassSummary::default(); - if registered_wallet_ids.is_empty() { + if subwallets.is_empty() { + summary.sync_unix_seconds = Self::now_unix(); return summary; } - let wallets_snapshot: Vec<(WalletId, Option>)> = { - let wallets = self.wallets.read().await; - registered_wallet_ids - .iter() - .map(|id| (*id, wallets.get(id).cloned())) - .collect() + // ONE SDK call covers every registered IVK on the network. + let notes = match super::sync::sync_notes_across(&self.sdk, &self.store, &subwallets).await + { + Ok(r) => r, + Err(e) => return self.fail_all_wallets(&subwallets, &e), }; - - let mut any_activity = false; - for (wallet_id, wallet) in wallets_snapshot { - let Some(wallet) = wallet else { - // Registered in coordinator but missing from the - // wallets map — host inconsistency. Skip with a - // warn so this surfaces in logs but a single - // missing wallet doesn't poison the whole pass. - tracing::warn!( - wallet_id = %hex::encode(wallet_id), - "Shielded sync skipped: wallet registered on coordinator but not in wallets map" - ); - summary - .wallet_results - .insert(wallet_id, WalletShieldedOutcome::Skipped); - continue; + let (newly_spent_per_sub, nf_changeset) = + match super::sync::check_nullifiers_across(&self.sdk, &self.store, &subwallets).await { + Ok(r) => r, + Err(e) => return self.fail_all_wallets(&subwallets, &e), }; + let balances_per_sub = match super::sync::balances_across(&self.store, &subwallets).await { + Ok(r) => r, + Err(e) => return self.fail_all_wallets(&subwallets, &e), + }; - // Always force the per-wallet path — the coordinator - // already gated on the network-wide cooldown above, - // so the per-wallet `last_caught_up_at` would - // double-gate and miss new chunks during the - // cooldown window. - let outcome = match wallet.shielded_sync(true).await { - Ok(Some(result)) => { - if !result.is_cooldown_skip - && (result.notes_result.total_scanned > 0 || result.total_newly_spent() > 0) - { - any_activity = true; - } - WalletShieldedOutcome::Ok(result) - } - Ok(None) => WalletShieldedOutcome::Skipped, - Err(e) => { + // Merge the note-side changeset (saves + synced_index) + // with the nullifier-side changeset (spends + + // checkpoints) into one consolidated stream, then split + // per WalletId so each per-wallet `WalletPersister.store` + // only sees its own wallet's deltas. + let mut consolidated = notes.changeset.clone(); + crate::changeset::merge::Merge::merge(&mut consolidated, nf_changeset); + if !crate::changeset::merge::Merge::is_empty(&consolidated) { + let per_wallet = consolidated.split_by_wallet_id(); + let persisters = self.persisters.read().await; + for (wallet_id, cs) in per_wallet { + let Some(persister) = persisters.get(&wallet_id) else { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + "Shielded sync changeset dropped: no persister registered (host inconsistency)" + ); + continue; + }; + let full = crate::changeset::PlatformWalletChangeSet { + shielded: Some(cs), + ..Default::default() + }; + if let Err(e) = persister.store(full) { tracing::warn!( wallet_id = %hex::encode(wallet_id), error = %e, - "Shielded sync failed via coordinator" + "Failed to queue shielded changeset from coordinator" ); - WalletShieldedOutcome::Err(e.to_string()) } - }; - summary.wallet_results.insert(wallet_id, outcome); + } } - // Update the network-wide cooldown stamp based on - // aggregate activity. Any new commitment or newly-spent - // nullifier anywhere in the network clears the stamp so - // the next pass runs immediately. + // Cooldown decision based on aggregate activity across + // every subwallet — any new commitment scanned or + // newly-spent nullifier anywhere on the network clears + // the stamp so the next pass runs immediately. + let any_activity = notes.total_scanned > 0 || newly_spent_per_sub.values().any(|&n| n > 0); if let Ok(mut guard) = self.last_caught_up_at.lock() { if any_activity { *guard = None; @@ -375,14 +365,51 @@ impl NetworkShieldedCoordinator { } } - summary.sync_unix_seconds = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); + // Demux multi-subwallet results into the per-wallet + // `ShieldedSyncSummary` shape that + // `PlatformEventManager::on_shielded_sync_completed` + // already speaks. Same emission shape as Phase 2a. + summary = + build_per_wallet_summary(&subwallets, ¬es, &newly_spent_per_sub, &balances_per_sub); + summary.sync_unix_seconds = Self::now_unix(); + summary + } + /// Build a `ShieldedSyncPassSummary` where every registered + /// wallet's outcome is the supplied error string. Used when + /// a network-wide SDK call (sync_notes_across / + /// check_nullifiers_across) errors before any per-wallet + /// result can be produced. + fn fail_all_wallets( + &self, + subwallets: &[(SubwalletId, AccountViewingKeys)], + e: &crate::error::PlatformWalletError, + ) -> ShieldedSyncPassSummary { + let mut wallet_ids: Vec = subwallets.iter().map(|(id, _)| id.wallet_id).collect(); + wallet_ids.sort_unstable(); + wallet_ids.dedup(); + let mut summary = ShieldedSyncPassSummary::default(); + for wallet_id in wallet_ids { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "Network-wide shielded sync failed" + ); + summary + .wallet_results + .insert(wallet_id, WalletShieldedOutcome::Err(e.to_string())); + } + summary.sync_unix_seconds = Self::now_unix(); summary } + fn now_unix() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) + } + /// Build a pass summary in which every registered wallet is /// reported as a cooldown skip. Used by [`sync`](Self::sync) /// when the network-wide cooldown is in effect. @@ -416,3 +443,68 @@ impl NetworkShieldedCoordinator { summary } } + +/// Demux the multi-subwallet sync result into the per-wallet +/// [`ShieldedSyncSummary`] shape that +/// [`PlatformEventManager::on_shielded_sync_completed`] already +/// speaks. The coordinator drives sync at SubwalletId-flat +/// granularity but the host event stream is still per-wallet, +/// so this helper folds per-(wallet_id) slices back into the +/// `BTreeMap` per-account shape consumers expect. +/// +/// [`PlatformEventManager::on_shielded_sync_completed`]: +/// crate::events::PlatformEventManager::on_shielded_sync_completed +fn build_per_wallet_summary( + subwallets: &[(SubwalletId, AccountViewingKeys)], + notes: &super::sync::MultiSyncNotesResult, + newly_spent_per_sub: &BTreeMap, + balances_per_sub: &BTreeMap, +) -> ShieldedSyncPassSummary { + use super::sync::{ShieldedSyncSummary, SyncNotesResult}; + + // Enumerate distinct wallet_ids in ascending order so the + // BTreeMap iteration in the consumer is deterministic. + let mut wallet_ids: Vec = subwallets.iter().map(|(id, _)| id.wallet_id).collect(); + wallet_ids.sort_unstable(); + wallet_ids.dedup(); + + let mut summary = ShieldedSyncPassSummary::default(); + for wallet_id in wallet_ids { + let new_notes_per_account: BTreeMap = notes + .per_subwallet_new_notes + .iter() + .filter(|(id, _)| id.wallet_id == wallet_id) + .map(|(id, &c)| (id.account_index, c)) + .collect(); + let newly_spent_per_account: BTreeMap = newly_spent_per_sub + .iter() + .filter(|(id, _)| id.wallet_id == wallet_id) + .map(|(id, &c)| (id.account_index, c)) + .collect(); + let balances: BTreeMap = balances_per_sub + .iter() + .filter(|(id, _)| id.wallet_id == wallet_id) + .map(|(id, &v)| (id.account_index, v)) + .collect(); + + summary.wallet_results.insert( + wallet_id, + WalletShieldedOutcome::Ok(ShieldedSyncSummary { + notes_result: SyncNotesResult { + new_notes_per_account, + // `total_scanned` is a network property — + // every wallet sees the same number of new + // positions in the same pass. Surface it on + // each per-wallet summary so the host UI can + // display it without having to look up the + // pass-level value. + total_scanned: notes.total_scanned, + }, + newly_spent_per_account, + balances, + is_cooldown_skip: false, + }), + ); + } + summary +} diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs index 96a2d425d8f..61f6f9eff16 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs @@ -1,22 +1,37 @@ -//! Shielded note + nullifier synchronization (multi-account). +//! Shielded note + nullifier synchronization. //! -//! Implements sync methods on `ShieldedWallet`: -//! - `sync_notes()` — fetch encrypted notes once, trial-decrypt -//! with every bound account's IVK, append commitments to the -//! shared tree once with `marked = any account decrypted the -//! position`, save decrypted notes per-subwallet. -//! - `check_nullifiers()` — privacy-preserving nullifier scan, -//! marks spent notes per-subwallet. -//! - `sync()` — full pass: notes + nullifiers + per-account -//! balance summary. +//! Phase-2b shape: the heavy lifting lives in three free +//! functions that take a flat `&[(SubwalletId, AccountViewingKeys)]` +//! slice and drive a single network-wide SDK fetch per pass: +//! - [`sync_notes_across`] — fetches encrypted notes once, +//! trial-decrypts against the union of every subwallet's IVK, +//! appends commitments to the shared tree exactly once per +//! position with `marked = any subwallet decrypted it`, and +//! saves decrypted notes scoped per-`SubwalletId`. +//! - [`check_nullifiers_across`] — privacy-preserving nullifier +//! scan per subwallet (the SDK's nullifier-scan API is +//! per-checkpoint, so it stays per-subwallet, but no longer +//! per-`ShieldedWallet`). +//! - [`balances_across`] — pure unspent-balance read against +//! the shared store. +//! +//! Per-wallet [`ShieldedWallet`] methods (`sync_notes`, +//! `check_nullifiers`, `balances`, `sync`) are now thin +//! delegators that build the one-wallet's slice and call the +//! free functions. Phase 4 removes them along with +//! `ShieldedWallet` itself; the coordinator becomes the only +//! sync entry point. use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; use dash_sdk::platform::shielded::nullifier_sync::{NullifierSyncCheckpoint, NullifierSyncConfig}; use dash_sdk::platform::shielded::{sync_shielded_notes, try_decrypt_note}; -use grovedb_commitment_tree::{Note as OrchardNote, PaymentAddress, PreparedIncomingViewingKey}; +use grovedb_commitment_tree::{Note as OrchardNote, PaymentAddress}; +use tokio::sync::RwLock; use tracing::{debug, info, warn}; +use super::keys::AccountViewingKeys; use super::store::{ShieldedStore, SubwalletId}; use super::ShieldedWallet; use crate::changeset::ShieldedChangeSet; @@ -79,332 +94,461 @@ impl ShieldedSyncSummary { } } -impl ShieldedWallet { - /// Sync encrypted notes from Platform across every bound account. - /// - /// Fetches raw chunks once via the SDK (using account 0's IVK - /// as the trial-decrypt key for the SDK call), then locally - /// trial-decrypts the same chunks against every other - /// account's IVK. Commitments are appended to the shared - /// tree exactly once per global position with `marked = - /// (any bound account owns this position)`. Decrypted notes - /// land in the store under the discovering account's - /// [`SubwalletId`]. - pub async fn sync_notes(&self) -> Result { - // Snapshot accounts + their prepared IVKs. The IVKs are - // owned `PreparedIncomingViewingKey` values so we can hold - // them across the await without borrowing `self`. - let account_indices: Vec = self.account_indices(); - if account_indices.is_empty() { - return Ok(SyncNotesResult::default()); - } - let prepared: Vec<(u32, PreparedIncomingViewingKey)> = account_indices - .iter() - .map(|&a| Ok((a, self.keys_for(a)?.prepared_ivk()))) - .collect::>()?; - - // Use the lowest per-account watermark as the canonical - // tree-fetch start. Today we wipe-and-re-sync when an - // account is added, so all accounts share the same - // watermark in practice — this `min` is just defensive. - let already_have = { - let store = self.store.read().await; - let mut min_idx: Option = None; - for &account in &account_indices { - let id = self.subwallet_id(account); - let idx = store - .last_synced_note_index(id) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; - min_idx = Some(min_idx.map_or(idx, |m| m.min(idx))); - } - min_idx.unwrap_or(0) - }; - let aligned_start = (already_have / CHUNK_SIZE) * CHUNK_SIZE; +/// Result of a multi-subwallet note-sync pass. +/// +/// Produced by [`sync_notes_across`] and consumed by the +/// coordinator's [`NetworkShieldedCoordinator::sync`] flow. +/// +/// `total_scanned` is a property of the **network fetch**, not of +/// any individual subwallet — every subwallet on the network +/// sees the same set of new positions in the same order, so +/// surfacing per-subwallet `total_scanned` would just duplicate +/// the same number `N` times. +/// +/// [`NetworkShieldedCoordinator::sync`]: +/// super::coordinator::NetworkShieldedCoordinator::sync +#[derive(Debug, Clone, Default)] +pub struct MultiSyncNotesResult { + /// Per-subwallet count of new notes discovered in this pass. + pub per_subwallet_new_notes: BTreeMap, + /// New positions observed this pass — `(aligned_start + + /// total_notes_scanned).saturating_sub(already_have)`. See + /// [`SyncNotesResult::total_scanned`] for the rationale. + pub total_scanned: u64, + /// Accumulated persistence changeset spanning every touched + /// subwallet. The caller decides whether to queue it on the + /// shared `WalletPersister`. + pub changeset: ShieldedChangeSet, +} - info!( - accounts = account_indices.len(), - already_have, aligned_start, "Starting shielded note sync" - ); +impl MultiSyncNotesResult { + /// Total new notes across every subwallet. + pub fn total_new_notes(&self) -> usize { + self.per_subwallet_new_notes.values().sum() + } - // Fetch + trial-decrypt with the FIRST bound account's - // IVK in one SDK call. We also reuse the returned - // `all_notes` for local trial-decryption with every other - // account's IVK below. - let (driver_account, driver_ivk) = &prepared[0]; - let result = sync_shielded_notes(&self.sdk, driver_ivk, aligned_start, None) - .await - .map_err(|e| PlatformWalletError::ShieldedSyncFailed(e.to_string()))?; + /// Split out the per-account map for `wallet_id`. Useful for + /// callers that want to feed a single wallet's slice back into + /// the legacy per-wallet [`SyncNotesResult`] shape. + pub fn per_account_for( + &self, + wallet_id: crate::wallet::platform_wallet::WalletId, + ) -> BTreeMap { + self.per_subwallet_new_notes + .iter() + .filter(|(id, _)| id.wallet_id == wallet_id) + .map(|(id, &c)| (id.account_index, c)) + .collect() + } +} - info!( - total_scanned = result.total_notes_scanned, - decrypted_for_driver = result.decrypted_notes.len(), - next_start_index = result.next_start_index, - "SDK sync returned" - ); +/// Single-fetch, multi-IVK trial-decrypt across an arbitrary set +/// of registered subwallets — the Phase 2b primitive that +/// collapses N per-wallet SDK calls into one. +/// +/// The first subwallet's IVK drives the SDK call; the SDK's +/// `result.all_notes` is then locally trial-decrypted against +/// every other subwallet's IVK. Commitments are appended to the +/// shared tree exactly once per global position with `marked = +/// (any subwallet decrypted this position)` — so accounts that +/// belong to different wallets but share the same network all +/// see their notes' authentication paths retained. +/// +/// `subwallets` must be non-empty; otherwise the function +/// returns an empty result without contacting Platform. +/// +/// Privilege boundary: only the viewing-key half is required +/// (FVK for nullifier derivation, IVK for trial decryption). +/// No `SpendAuthorizingKey` is needed by sync — the spend +/// surface re-attaches it at call time. +pub(super) async fn sync_notes_across( + sdk: &Arc, + store: &Arc>, + subwallets: &[(SubwalletId, AccountViewingKeys)], +) -> Result { + if subwallets.is_empty() { + return Ok(MultiSyncNotesResult::default()); + } - if result.next_start_index == 0 && result.total_notes_scanned > 0 { - warn!( - "Shielded sync: next_start_index is 0 after scanning {} notes — \ - next sync will rescan from the beginning", - result.total_notes_scanned, - ); + // Snapshot the lowest per-subwallet watermark — the canonical + // tree-fetch start. Defensive `min` across subwallets: in + // practice we wipe-and-re-sync on account add so every + // subwallet shares the same watermark. + let already_have = { + let store = store.read().await; + let mut min_idx: Option = None; + for (id, _) in subwallets { + let idx = store + .last_synced_note_index(*id) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + min_idx = Some(min_idx.map_or(idx, |m| m.min(idx))); } + min_idx.unwrap_or(0) + }; + let aligned_start = (already_have / CHUNK_SIZE) * CHUNK_SIZE; + + info!( + subwallets = subwallets.len(), + already_have, aligned_start, "Starting multi-subwallet shielded note sync" + ); + + // Fetch + trial-decrypt with the FIRST subwallet's IVK in + // one SDK call. The driver's hits come back as + // `result.decrypted_notes`; every other subwallet's are + // produced by local trial-decryption against + // `result.all_notes` below. + let (driver_id, driver_views) = &subwallets[0]; + let driver_ivk = driver_views.prepared_ivk.clone(); + let result = sync_shielded_notes(sdk, &driver_ivk, aligned_start, None) + .await + .map_err(|e| PlatformWalletError::ShieldedSyncFailed(e.to_string()))?; + + info!( + total_scanned = result.total_notes_scanned, + decrypted_for_driver = result.decrypted_notes.len(), + next_start_index = result.next_start_index, + "SDK sync returned" + ); + + if result.next_start_index == 0 && result.total_notes_scanned > 0 { + warn!( + "Shielded sync: next_start_index is 0 after scanning {} notes — \ + next sync will rescan from the beginning", + result.total_notes_scanned, + ); + } - // Index decryptions by `(account, position) → DecryptedNote`. - // The driver account's hits come from the SDK call; - // every other account's are produced by local - // trial-decryption against `result.all_notes`. - let mut decrypted_by_account: BTreeMap> = BTreeMap::new(); - for dn in &result.decrypted_notes { - decrypted_by_account - .entry(*driver_account) - .or_default() - .push(DiscoveredNote { - position: dn.position, - cmx: dn.cmx, - note: dn.note, - }); - } + // Route decryptions to the subwallet that owns the IVK. + let mut decrypted_by_subwallet: BTreeMap> = BTreeMap::new(); + for dn in &result.decrypted_notes { + decrypted_by_subwallet + .entry(*driver_id) + .or_default() + .push(DiscoveredNote { + position: dn.position, + cmx: dn.cmx, + note: dn.note, + }); + } - for (account, ivk) in prepared.iter().skip(1) { - for (i, raw_note) in result.all_notes.iter().enumerate() { - let position = aligned_start + i as u64; - if let Some((note, _addr)) = try_decrypt_note(ivk, raw_note) { - let cmx_bytes: [u8; 32] = match raw_note.cmx.as_slice().try_into() { - Ok(b) => b, - Err(_) => continue, - }; - decrypted_by_account - .entry(*account) - .or_default() - .push(DiscoveredNote { - position, - cmx: cmx_bytes, - note, - }); - } + for (id, views) in subwallets.iter().skip(1) { + for (i, raw_note) in result.all_notes.iter().enumerate() { + let position = aligned_start + i as u64; + if let Some((note, _addr)) = try_decrypt_note(&views.prepared_ivk, raw_note) { + let cmx_bytes: [u8; 32] = match raw_note.cmx.as_slice().try_into() { + Ok(b) => b, + Err(_) => continue, + }; + decrypted_by_subwallet + .entry(*id) + .or_default() + .push(DiscoveredNote { + position, + cmx: cmx_bytes, + note, + }); } } + } - // Build the union of "owned" positions for tree marking. - let owned_positions: BTreeSet = decrypted_by_account - .values() - .flat_map(|v| v.iter().map(|n| n.position)) - .collect(); + // Build the union of "owned" positions for tree marking. + let owned_positions: BTreeSet = decrypted_by_subwallet + .values() + .flat_map(|v| v.iter().map(|n| n.position)) + .collect(); + + let mut store = store.write().await; + + // Append every commitment to the shared tree exactly once + // per position. Skip positions already in the tree (re-scan + // after a partial chunk advance). + let mut appended = 0u32; + for (i, raw_note) in result.all_notes.iter().enumerate() { + let global_pos = aligned_start + i as u64; + if global_pos < already_have { + continue; + } + let cmx_bytes: [u8; 32] = + raw_note.cmx.as_slice().try_into().map_err(|_| { + PlatformWalletError::ShieldedSyncFailed("Invalid cmx length".into()) + })?; + let is_ours = owned_positions.contains(&global_pos); + store + .append_commitment(&cmx_bytes, is_ours) + .map_err(|e| PlatformWalletError::ShieldedTreeUpdateFailed(e.to_string()))?; + appended += 1; + } - let mut store = self.store.write().await; + if appended > 0 { + // Use the high-water position as the checkpoint id (not + // `result.next_start_index`, which rewinds to the last + // partial chunk's start and can therefore be the same + // value across consecutive syncs). shardtree's + // `checkpoint(id)` silently dedups duplicate ids; a + // non-monotonic id leaves depth-0 pinned at the first + // checkpoint while later appends extend the tree past + // it, and the witness at depth 0 then reflects a state + // Platform never recorded. + let new_index = aligned_start + result.total_notes_scanned; + let checkpoint_id: u32 = new_index.try_into().unwrap_or(u32::MAX); + store + .checkpoint_tree(checkpoint_id) + .map_err(|e| PlatformWalletError::ShieldedTreeUpdateFailed(e.to_string()))?; + } - // Append every commitment to the shared tree exactly - // once per position. Skip positions already in the tree - // (re-scan after a partial chunk advance). - let mut appended = 0u32; - for (i, raw_note) in result.all_notes.iter().enumerate() { - let global_pos = aligned_start + i as u64; - if global_pos < already_have { + // Save decrypted notes per subwallet; count new notes per + // subwallet; build a single consolidated changeset. + let mut per_subwallet_new_notes: BTreeMap = BTreeMap::new(); + let mut changeset = ShieldedChangeSet::default(); + for (id, discovered) in &decrypted_by_subwallet { + // Look up the FVK for nullifier derivation. The id is + // guaranteed to come from `subwallets` (we keyed off the + // same set above), so the find is infallible — but be + // defensive in case caller passed a malformed slice. + let Some((_, views)) = subwallets.iter().find(|(s, _)| s == id) else { + continue; + }; + for d in discovered { + if d.position < already_have { continue; } - let cmx_bytes: [u8; 32] = raw_note.cmx.as_slice().try_into().map_err(|_| { - PlatformWalletError::ShieldedSyncFailed("Invalid cmx length".into()) - })?; - let is_ours = owned_positions.contains(&global_pos); + let nullifier = d.note.nullifier(&views.full_viewing_key); + let value = d.note.value().inner(); + debug!( + wallet_id = %hex::encode(id.wallet_id), + account = id.account_index, + position = d.position, + value, + "Note DECRYPTED" + ); + let note_data = serialize_note(&d.note); + let shielded_note = super::store::ShieldedNote { + note_data, + position: d.position, + cmx: d.cmx, + nullifier: nullifier.to_bytes(), + block_height: result.block_height, + is_spent: false, + value, + }; store - .append_commitment(&cmx_bytes, is_ours) - .map_err(|e| PlatformWalletError::ShieldedTreeUpdateFailed(e.to_string()))?; - appended += 1; + .save_note(*id, &shielded_note) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + changeset.record_note(*id, shielded_note); + *per_subwallet_new_notes.entry(*id).or_default() += 1; } + } - if appended > 0 { - // Use the high-water position (`aligned_start + - // total_notes_scanned` — i.e. one past the last - // appended position) as the checkpoint id rather than - // `result.next_start_index`, which rewinds to the last - // partial chunk's start and can therefore be the same - // value across consecutive syncs. shardtree's - // `checkpoint(id)` silently dedups duplicate ids, so - // a non-monotonic id leaves depth-0 pinned at the - // first checkpoint while later appends extend the - // tree past it. The witness at depth 0 then reflects - // an old state whose root Platform never recorded, - // and the bundle's anchor fails the - // `validate_anchor_exists` check on broadcast. - let new_index = aligned_start + result.total_notes_scanned; - let checkpoint_id: u32 = new_index.try_into().unwrap_or(u32::MAX); - store - .checkpoint_tree(checkpoint_id) - .map_err(|e| PlatformWalletError::ShieldedTreeUpdateFailed(e.to_string()))?; - } + // Advance every subwallet's watermark to the same global + // tree position so the next sync resumes coherently across + // the union. + let new_index = aligned_start + result.total_notes_scanned; + for (id, _) in subwallets { + store + .set_last_synced_note_index(*id, new_index) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + changeset.record_synced_index(*id, new_index); + } + // Drop the write lock before returning so the caller's + // persister queue (which may take its own synchronous + // mutex) doesn't nest under our store lock. + drop(store); + + info!( + new_notes_total = per_subwallet_new_notes.values().sum::(), + new_index, "Multi-subwallet shielded sync finished" + ); + + let scanned_new = (aligned_start + result.total_notes_scanned).saturating_sub(already_have); + Ok(MultiSyncNotesResult { + per_subwallet_new_notes, + total_scanned: scanned_new, + changeset, + }) +} - // Save decrypted notes scoped per subwallet, count new - // notes per account, and accumulate a changeset to hand - // to the persister at the end. - let mut new_notes_per_account: BTreeMap = BTreeMap::new(); - let mut changeset = ShieldedChangeSet::default(); - for (account, discovered) in &decrypted_by_account { - let fvk = &self.keys_for(*account)?.full_viewing_key; - let id = self.subwallet_id(*account); - for d in discovered { - if d.position < already_have { - continue; - } - let nullifier = d.note.nullifier(fvk); - let value = d.note.value().inner(); - debug!( - account = account, - position = d.position, - value, - "Note DECRYPTED" - ); - let note_data = serialize_note(&d.note); - let shielded_note = super::store::ShieldedNote { - note_data, - position: d.position, - cmx: d.cmx, - nullifier: nullifier.to_bytes(), - block_height: result.block_height, - is_spent: false, - value, - }; - store - .save_note(id, &shielded_note) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; - changeset.record_note(id, shielded_note); - *new_notes_per_account.entry(*account).or_default() += 1; +/// Multi-subwallet nullifier sync. One SDK call per subwallet +/// that has unspent notes — the SDK's nullifier-scan API is +/// keyed by a checkpoint per subwallet and can't be coalesced +/// the same way `sync_shielded_notes` can. Still drops the +/// per-wallet `last_caught_up_at` and persister hop, leaving +/// the caller in charge of changeset queueing. +pub(super) async fn check_nullifiers_across( + sdk: &Arc, + store: &Arc>, + subwallets: &[(SubwalletId, AccountViewingKeys)], +) -> Result<(BTreeMap, ShieldedChangeSet), PlatformWalletError> { + if subwallets.is_empty() { + return Ok((BTreeMap::new(), ShieldedChangeSet::default())); + } + + // Aggregate unspent nullifiers per subwallet so we hit the + // SDK once per subwallet, then route the `found` results + // back via a position lookup. + struct PerSub { + nullifiers: Vec<[u8; 32]>, + checkpoint: Option, + } + + let per_sub: Vec<(SubwalletId, PerSub)> = { + let store = store.read().await; + let mut out = Vec::with_capacity(subwallets.len()); + for (id, _) in subwallets { + let unspent = store + .get_unspent_notes(*id) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + let nullifiers: Vec<[u8; 32]> = unspent.iter().map(|n| n.nullifier).collect(); + let checkpoint = store + .nullifier_checkpoint(*id) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))? + .map(|(height, timestamp)| NullifierSyncCheckpoint { height, timestamp }); + out.push(( + *id, + PerSub { + nullifiers, + checkpoint, + }, + )); + } + out + }; + + let mut newly_spent: BTreeMap = BTreeMap::new(); + let mut changeset = ShieldedChangeSet::default(); + for (id, sub) in per_sub { + if sub.nullifiers.is_empty() { + continue; + } + debug!( + wallet_id = %hex::encode(id.wallet_id), + account = id.account_index, + checking = sub.nullifiers.len(), + ?sub.checkpoint, + "Checking nullifiers" + ); + let result = sdk + .sync_nullifiers(&sub.nullifiers, None::, sub.checkpoint) + .await + .map_err(|e| PlatformWalletError::ShieldedNullifierSyncFailed(e.to_string()))?; + + let mut store = store.write().await; + let mut spent_count = 0usize; + for nf_bytes in &result.found { + if store + .mark_spent(id, nf_bytes) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))? + { + changeset.record_nullifier_spent(id, *nf_bytes); + spent_count += 1; } } + store + .set_nullifier_checkpoint(id, result.new_sync_height, result.new_sync_timestamp) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + changeset.record_nullifier_checkpoint( + id, + result.new_sync_height, + result.new_sync_timestamp, + ); - // Update every account's watermark to the same global - // tree position so the next sync resumes coherently. - let new_index = aligned_start + result.total_notes_scanned; - for &account in &account_indices { - let id = self.subwallet_id(account); - store - .set_last_synced_note_index(id, new_index) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; - changeset.record_synced_index(id, new_index); + if spent_count > 0 { + newly_spent.insert(id, spent_count); + info!( + wallet_id = %hex::encode(id.wallet_id), + account = id.account_index, + spent_count, + "Notes newly detected as spent" + ); } - // Drop the write lock before queuing the changeset so - // the persister callback (which may take its own - // synchronous mutex) doesn't nest under our store lock. - drop(store); - self.queue_shielded_changeset(changeset); + } + Ok((newly_spent, changeset)) +} - info!( - new_notes_total = new_notes_per_account.values().sum::(), - new_index, "Shielded sync finished" - ); +/// Multi-subwallet unspent-balance snapshot. Pure read against +/// the shared store — does not trigger a sync. +pub(super) async fn balances_across( + store: &Arc>, + subwallets: &[(SubwalletId, AccountViewingKeys)], +) -> Result, PlatformWalletError> { + let store = store.read().await; + let mut out: BTreeMap = BTreeMap::new(); + for (id, _) in subwallets { + let notes = store + .get_unspent_notes(*id) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + out.insert(*id, notes.iter().map(|n| n.value).sum()); + } + Ok(out) +} - // Report only the **new** positions observed this pass. - // The SDK's `total_notes_scanned` counts every commitment - // in the fetched chunks, but Platform's chunked-sync - // semantics re-fetch the partial chunk every cadence - // (the buffer chunk is mutable until full), so the raw - // wire count climbs by the chunk size every pass on a - // sleepy network even though nothing changed. Subtract - // `already_have` so the host counter reflects newly-seen - // positions instead of wire volume. - let scanned_new = (aligned_start + result.total_notes_scanned).saturating_sub(already_have); +impl ShieldedWallet { + /// Sync encrypted notes from Platform across every bound + /// account of this single wallet. + /// + /// Delegates to [`sync_notes_across`] under the hood with + /// the wallet's own subwallets — Phase 2b kept this surface + /// for backward-compat with existing per-wallet callers + /// (tests, the fallback path in `ShieldedSyncManager` when no + /// coordinator has been configured). Phase 4 removes this + /// method along with `ShieldedWallet` itself; the coordinator + /// becomes the only sync entry point. + pub async fn sync_notes(&self) -> Result { + let account_indices = self.account_indices(); + if account_indices.is_empty() { + return Ok(SyncNotesResult::default()); + } + let subwallets: Vec<(SubwalletId, AccountViewingKeys)> = account_indices + .iter() + .map(|&account| { + let id = self.subwallet_id(account); + let views = self.keys_for(account)?.viewing_keys(); + Ok::<_, PlatformWalletError>((id, views)) + }) + .collect::>()?; + + let result = sync_notes_across(&self.sdk, &self.store, &subwallets).await?; + // Fold per-subwallet counts back into per-account for + // this wallet, and queue the consolidated changeset on + // the wallet's own persister. + let new_notes_per_account = result.per_account_for(self.wallet_id); + self.queue_shielded_changeset(result.changeset); Ok(SyncNotesResult { new_notes_per_account, - total_scanned: scanned_new, + total_scanned: result.total_scanned, }) } - /// Check nullifier status for unspent notes across every bound - /// account. Spent notes are marked per-subwallet. + /// Check nullifier status for unspent notes across every + /// bound account on this single wallet. Spent notes are + /// marked per-subwallet. + /// + /// Delegates to [`check_nullifiers_across`]; see + /// [`sync_notes`](Self::sync_notes) for the Phase 4 deletion + /// plan that removes this method along with `ShieldedWallet`. pub async fn check_nullifiers(&self) -> Result, PlatformWalletError> { let account_indices = self.account_indices(); if account_indices.is_empty() { return Ok(BTreeMap::new()); } - - // Aggregate unspent nullifiers across accounts so we hit - // the SDK once, then route the `found` results back to - // the right subwallet via a position lookup. - struct AccountUnspent { - id: SubwalletId, - nullifiers: Vec<[u8; 32]>, - checkpoint: Option, - } - - let per_account: Vec<(u32, AccountUnspent)> = { - let store = self.store.read().await; - let mut out = Vec::with_capacity(account_indices.len()); - for &account in &account_indices { + let subwallets: Vec<(SubwalletId, AccountViewingKeys)> = account_indices + .iter() + .map(|&account| { let id = self.subwallet_id(account); - let unspent = store - .get_unspent_notes(id) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; - let nullifiers: Vec<[u8; 32]> = unspent.iter().map(|n| n.nullifier).collect(); - let checkpoint = store - .nullifier_checkpoint(id) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))? - .map(|(height, timestamp)| NullifierSyncCheckpoint { height, timestamp }); - out.push(( - account, - AccountUnspent { - id, - nullifiers, - checkpoint, - }, - )); - } - out - }; + let views = self.keys_for(account)?.viewing_keys(); + Ok::<_, PlatformWalletError>((id, views)) + }) + .collect::>()?; - let mut newly_spent: BTreeMap = BTreeMap::new(); - let mut changeset = ShieldedChangeSet::default(); - for ( - account, - AccountUnspent { - id, - nullifiers, - checkpoint, - }, - ) in per_account - { - if nullifiers.is_empty() { - continue; - } - debug!( - account, - checking = nullifiers.len(), - ?checkpoint, - "Checking nullifiers" - ); - let result = self - .sdk - .sync_nullifiers(&nullifiers, None::, checkpoint) - .await - .map_err(|e| PlatformWalletError::ShieldedNullifierSyncFailed(e.to_string()))?; - - let mut store = self.store.write().await; - let mut spent_count = 0usize; - for nf_bytes in &result.found { - if store - .mark_spent(id, nf_bytes) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))? - { - changeset.record_nullifier_spent(id, *nf_bytes); - spent_count += 1; - } - } - store - .set_nullifier_checkpoint(id, result.new_sync_height, result.new_sync_timestamp) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; - changeset.record_nullifier_checkpoint( - id, - result.new_sync_height, - result.new_sync_timestamp, - ); - - if spent_count > 0 { - newly_spent.insert(account, spent_count); - info!(account, spent_count, "Notes newly detected as spent"); - } - } + let (per_sub, changeset) = + check_nullifiers_across(&self.sdk, &self.store, &subwallets).await?; self.queue_shielded_changeset(changeset); - - Ok(newly_spent) + // Fold per-subwallet counts back into per-account for + // this wallet's caller shape. + Ok(per_sub + .into_iter() + .filter(|(id, _)| id.wallet_id == self.wallet_id) + .map(|(id, c)| (id.account_index, c)) + .collect()) } /// Full sync: notes + nullifiers + per-account balance summary. From b9086516261c14417e1cd5907f45cdf4361def00 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 20:48:22 +0700 Subject: [PATCH 56/78] =?UTF-8?q?feat(platform-wallet,ffi,swift-sdk):=20Ph?= =?UTF-8?q?ase=203=20=E2=80=94=20single=20platform=5Fwallet=5Fmanager=5Fsh?= =?UTF-8?q?ielded=5Fclear=20FFI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `platform_wallet_manager_shielded_clear(handle)` so the host's Clear button is one FFI call on the Rust side instead of a coordinate-the-stop-and-hope sequence in Swift. Rust - `NetworkShieldedCoordinator::clear()` drops every wallet registration (`accounts` + `persisters` maps) and resets the network-wide caught-up cooldown stamp. The single SQLite commitment-tree file stays open — Clear semantics are "wipe my host-side persistence and re-sync from index 0 on the shared tree", **not** "blow away the chain-wide cache". Host can't reach into iOS / Android persistence anyway, so the SwiftData / SharedPreferences wipe stays on the host side. - New FFI `platform_wallet_manager_shielded_clear(handle)`: stops the sync loop first (so the next pass can't race the registry clear and observe half-emptied state), then calls `coord.clear()`. Idempotent — works whether or not shielded has been configured. Swift - New `PlatformWalletManager.clearShielded()` wrapper. - `ShieldedService.clearLocalState` now calls `clearShielded()` in place of `stopShieldedSync()` — one Rust round-trip covers stop + unregister + cooldown reset. - Doc-comment updated to match the new contract. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/shielded_sync.rs | 41 +++++++++++++++++++ .../src/wallet/shielded/coordinator.rs | 26 ++++++++++++ .../PlatformWalletManagerShieldedSync.swift | 23 +++++++++++ .../Core/Services/ShieldedService.swift | 38 ++++++++++------- 4 files changed, 113 insertions(+), 15 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs index 4d7bad75b6e..de4678d1eb2 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs @@ -380,6 +380,47 @@ pub unsafe extern "C" fn platform_wallet_manager_configure_shielded( PlatformWalletFFIResult::ok() } +// --------------------------------------------------------------------------- +// Clear shielded state (Rust side) +// --------------------------------------------------------------------------- + +/// Reset the Rust-side shielded state on this manager: stop the +/// background sync loop, drop every wallet registration on the +/// network-scoped coordinator, and reset the caught-up cooldown +/// stamp. +/// +/// The single SQLite commitment-tree file stays open — Clear +/// semantics are "wipe my host-side persistence and start +/// re-syncing from index 0 on the shared tree", **not** "blow +/// away the chain-wide cache". The host is responsible for +/// wiping its own per-wallet persistence layer (e.g. SwiftData +/// rows) since Rust can't reach into iOS / Android persistence; +/// after that, the next [`platform_wallet_manager_bind_shielded`] +/// call repopulates the coordinator's registries and the next +/// sync pass re-saves notes via the changeset path. +/// +/// Idempotent: calling Clear when shielded support has never +/// been configured (no coordinator installed) is still a +/// successful no-op on the coordinator side. The sync-loop stop +/// is unconditional. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_shielded_clear( + handle: Handle, +) -> PlatformWalletFFIResult { + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + // Stop the loop first so the next pass can't race the + // registry clear and observe a half-emptied state. + manager.shielded_sync().stop(); + runtime().block_on(async { + if let Some(coord) = manager.shielded_coordinator().await { + coord.clear().await; + } + }); + }); + unwrap_option_or_return!(option); + PlatformWalletFFIResult::ok() +} + // --------------------------------------------------------------------------- // Default Orchard payment address // --------------------------------------------------------------------------- diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index 919aa732d34..13fc0819d42 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -242,6 +242,32 @@ impl NetworkShieldedCoordinator { self.accounts.read().await.keys().copied().collect() } + /// Drop every wallet registration and reset the cooldown + /// stamp. The single SQLite handle (commitment tree) stays + /// open — Clear semantics on the host side are "wipe my + /// persistence and start re-syncing from index 0 on the + /// shared tree", not "blow away the chain-wide cache". + /// + /// Used by [`platform_wallet_manager_shielded_clear`] (the + /// host's Clear button). The host then wipes its own + /// per-wallet persistence (e.g. SwiftData rows) — Rust can't + /// reach that layer — and the next `bind_shielded` call + /// repopulates the registries. + /// + /// Resets the cooldown to `None` so the first post-clear + /// background sync pass runs immediately rather than honoring + /// a stale "caught up" stamp from before the wipe. + /// + /// [`platform_wallet_manager_shielded_clear`]: + /// rs-platform-wallet-ffi's FFI entry point + pub async fn clear(&self) { + self.accounts.write().await.clear(); + self.persisters.write().await.clear(); + if let Ok(mut g) = self.last_caught_up_at.lock() { + *g = None; + } + } + /// Run one shielded sync pass for every registered wallet on /// this coordinator's network. Returns a per-wallet outcome /// summary suitable for emission to UI / persistence layers diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index cbf9c40697e..54477142548 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -183,6 +183,29 @@ extension PlatformWalletManager { try platform_wallet_manager_shielded_sync_stop(handle).check() } + /// Reset the Rust-side shielded state on this manager: + /// stops the background sync loop, drops every wallet + /// registration on the network-scoped coordinator, and + /// resets the caught-up cooldown stamp. + /// + /// Use this from the host's "Clear" flow before wiping + /// host-side persistence (e.g. SwiftData rows). The single + /// per-network SQLite commitment-tree file stays open — + /// Clear semantics are "wipe my host persistence and + /// re-sync from index 0 on the shared tree", not "blow + /// away the chain-wide cache". After Clear, the next + /// [`bindShielded`] call repopulates the coordinator's + /// registries and the next sync pass re-saves notes via + /// the changeset path. + public func clearShielded() throws { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidHandle( + "PlatformWalletManager not configured" + ) + } + try platform_wallet_manager_shielded_clear(handle).check() + } + public func isShieldedSyncRunning() throws -> Bool { guard isConfigured, handle != NULL_HANDLE else { throw PlatformWalletError.invalidHandle( diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift index 752346eaf3e..2cf8aefb8f9 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -424,26 +424,34 @@ class ShieldedService: ObservableObject { // doc above for why.) let managerForStop = walletManager - // 1) Stop the manager-wide shielded sync loop BEFORE - // touching state on disk. The Swift `ShieldedService` - // is per-wallet-at-a-time, but the Rust + // 1) Reset the Rust-side shielded state BEFORE touching + // state on disk. The Swift `ShieldedService` is + // per-wallet-at-a-time, but the Rust // `PlatformWalletManager` keeps **every** wallet that - // ever ran `bind_shielded` bound at the Rust level, - // and the background sync iterates all of them on its - // own cadence. If we don't stop the loop, the next - // pass fires per wallet and the persister callback - // immediately re-creates the `PersistentShieldedNote` - // / `PersistentShieldedSyncState` rows we're about to - // delete (this is exactly the "Clear left a row - // behind / re-derived a fresh row" symptom we saw on - // the prior attempt). Stopping is best-effort — - // failure logs but doesn't abort the wipe. + // ever ran `bind_shielded` registered on the + // network-scoped coordinator. Without this call the + // coordinator's next sync iterates the still-registered + // wallets and the persister callback immediately + // re-creates the `PersistentShieldedNote` / + // `PersistentShieldedSyncState` rows we're about to + // delete (the "Clear left a row behind / re-derived a + // fresh row" symptom from before). `clearShielded` + // does three things on the Rust side in one call: + // - stops the background sync loop + // - drops every wallet registration from the + // coordinator (`accounts` + `persisters` maps) + // - resets the network-wide caught-up cooldown + // The single SQLite commitment-tree file stays open; + // the next `bindShielded` call repopulates the + // registries and the next sync re-saves notes via + // the changeset path. Best-effort — failure logs but + // doesn't abort the wipe. if let managerForStop { do { - try managerForStop.stopShieldedSync() + try managerForStop.clearShielded() } catch { SDKLogger.error( - "ShieldedService.clearLocalState: stopShieldedSync failed: \(error.localizedDescription)" + "ShieldedService.clearLocalState: clearShielded failed: \(error.localizedDescription)" ) } } From 1c56d31cb43fe620ea8db2e3e1676e1c67634600 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 20:50:58 +0700 Subject: [PATCH 57/78] =?UTF-8?q?refactor(platform-wallet):=20Phase=204a?= =?UTF-8?q?=20=E2=80=94=20remove=20dead=20per-wallet=20shielded=20cooldown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2a moved the caught-up cooldown from per-`ShieldedWallet` scope to per-`NetworkShieldedCoordinator` scope; the per-wallet `last_caught_up_at` field hasn't been read on the live path since then (the coordinator's `sync(force)` always passes `force=true` to the per-wallet shim, so the per-wallet cooldown gate could never fire). Phase 2b removed the gate's only other caller path. This commit deletes the orphan. - `ShieldedWallet::last_caught_up_at` field removed. - `ShieldedWallet::sync(force)` is now an always-walks-Platform delegator (`force` retained as a no-op for API stability until Phase 4d deletes `ShieldedWallet` itself). - ~80 lines of stale cooldown commentary in `sync.rs` removed. Single source of truth for the cooldown stamp: `NetworkShieldedCoordinator::last_caught_up_at`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/wallet/shielded/mod.rs | 14 ---- .../src/wallet/shielded/sync.rs | 79 +++---------------- 2 files changed, 11 insertions(+), 82 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs index e7c3bef9915..da65b339c87 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs @@ -82,19 +82,6 @@ pub struct ShieldedWallet { /// watermarks. `None` means in-memory only — useful for /// tests and short-lived wallets. pub(super) persister: Option, - /// Timestamp of the last sync pass that observed no new - /// commitments or newly-spent nullifiers. Honored as a - /// cooldown by [`sync(force=false)`](Self::sync) so the - /// background loop doesn't re-fetch and re-trial-decrypt the - /// same partial chunk every cadence interval — a Platform - /// partial chunk's `next_start_index` is intentionally pinned - /// at its chunk-start (the buffer chunk is mutable until - /// full), and chunk-alignment in [`sync_notes`](Self::sync_notes) - /// then re-fetches it on every pass. Manual user-initiated - /// syncs pass `force=true` and ignore this. Cleared whenever - /// a sync observes new positions or new spends so the next - /// pass runs immediately. - pub(super) last_caught_up_at: std::sync::Mutex>, } /// How long after a no-op sync the background loop should skip @@ -137,7 +124,6 @@ impl ShieldedWallet { accounts, store, persister: None, - last_caught_up_at: std::sync::Mutex::new(None), }) } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs index 61f6f9eff16..7560ba23c8b 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs @@ -553,78 +553,21 @@ impl ShieldedWallet { /// Full sync: notes + nullifiers + per-account balance summary. /// - /// `force` controls whether the [caught-up cooldown](super::CAUGHT_UP_COOLDOWN) - /// is honored. The background sync loop passes `force=false` - /// so a no-op pass (no new positions, no newly-spent - /// nullifiers) suppresses the next pass for the cooldown - /// window — without this the SDK's chunked sync forces a - /// fresh fetch + trial-decrypt of the (partial) tail chunk - /// on every cadence interval. User-initiated paths (the - /// "Sync Now" button) pass `force=true` and always run, so - /// a user who just sent a transaction sees the new note on - /// the next tap rather than waiting out the cooldown. - pub async fn sync(&self, force: bool) -> Result { - // Snapshot the timestamp into a local so the `MutexGuard` - // is dropped before any `.await` below — `std::sync::Mutex` - // is `!Send` across await points (clippy's - // `await_holding_lock` lint flags this) and the rest of - // `sync()` is heavily async. - let cooldown_remaining: Option = if force { - None - } else { - self.last_caught_up_at - .lock() - .ok() - .and_then(|guard| *guard) - .map(|when| super::CAUGHT_UP_COOLDOWN.saturating_sub(when.elapsed())) - .filter(|remaining| !remaining.is_zero()) - }; - - if let Some(remaining) = cooldown_remaining { - debug!( - cooldown_remaining_secs = remaining.as_secs(), - cooldown_total_secs = super::CAUGHT_UP_COOLDOWN.as_secs(), - "Shielded sync skipped — within caught-up cooldown" - ); - // The cooldown skip is documented as a no-op (no SDK - // fetch / trial-decrypt / nullifier scan) and must - // be infallible — a transient `balances()` failure - // here would turn a deliberate skip into a sync - // error banner on the host even though no real work - // was attempted. Return an empty `balances` map and - // rely on the host to preserve its cached balance - // when it sees `is_cooldown_skip = true`. Balance - // can't have changed during the cooldown window - // anyway: any spend or receive would have cleared - // `last_caught_up_at` via the activity branch at - // the bottom of `sync()`, ending the cooldown. - return Ok(ShieldedSyncSummary { - notes_result: SyncNotesResult::default(), - newly_spent_per_account: BTreeMap::new(), - balances: BTreeMap::new(), - is_cooldown_skip: true, - }); - } - + /// Per-wallet full-sync. Cooldown handling lives on the + /// network-scoped coordinator (see + /// [`NetworkShieldedCoordinator::sync`]) — this method + /// always walks Platform. `force` is kept for API + /// compatibility with pre-Phase-2a callers; the parameter + /// is now a no-op and will be removed in Phase 4d alongside + /// `ShieldedWallet` itself. + /// + /// [`NetworkShieldedCoordinator::sync`]: + /// super::coordinator::NetworkShieldedCoordinator::sync + pub async fn sync(&self, _force: bool) -> Result { let notes_result = self.sync_notes().await?; let newly_spent_per_account = self.check_nullifiers().await?; let balances = self.balances().await?; - // Mark caught-up only when this pass observed nothing - // new on either axis. Any activity (new positions or new - // spends) clears the timestamp so the next pass runs - // immediately rather than back-pressuring fresh work - // behind the cooldown. - let was_no_op = - notes_result.total_scanned == 0 && newly_spent_per_account.values().all(|&n| n == 0); - if let Ok(mut guard) = self.last_caught_up_at.lock() { - if was_no_op { - *guard = Some(std::time::Instant::now()); - } else { - *guard = None; - } - } - Ok(ShieldedSyncSummary { notes_result, newly_spent_per_account, From 6043c3537fb3c8472023016896d6c3f1cef853ba Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 20:56:08 +0700 Subject: [PATCH 58/78] =?UTF-8?q?refactor(platform-wallet):=20Phase=204c?= =?UTF-8?q?=20=E2=80=94=20collapse=20AccountState=20wrapper=20into=20bare?= =?UTF-8?q?=20OrchardKeySet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `AccountState { keys: OrchardKeySet }` newtype on `ShieldedWallet` never carried anything besides the keyset itself, and the indirection blocked nothing. Replace `BTreeMap` with `BTreeMap` directly. Pure rename — no behaviour change. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/wallet/shielded/mod.rs | 21 +++---------------- 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs index da65b339c87..90e88cd59fb 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs @@ -43,17 +43,6 @@ use crate::error::PlatformWalletError; use crate::wallet::persister::WalletPersister; use crate::wallet::platform_wallet::WalletId; -/// Per-account state held inside a [`ShieldedWallet`]. -/// -/// Crate-private — callers go through `ShieldedWallet`'s -/// per-account helpers (`default_address(account)`, -/// `balance(account)`, etc.). Held by value (not behind a lock) -/// because the parent wallet's `RwLock` already serializes -/// access, and key material is read-only after derivation. -pub(super) struct AccountState { - pub(super) keys: OrchardKeySet, -} - /// Feature-gated multi-account shielded wallet. /// /// One [`ShieldedWallet`] lives inside one [`PlatformWallet`] and @@ -70,7 +59,7 @@ pub struct ShieldedWallet { /// [`SubwalletId`] for every store call. pub(super) wallet_id: WalletId, /// Bound Orchard accounts, keyed by ZIP-32 account index. - pub(super) accounts: BTreeMap, + pub(super) accounts: BTreeMap, /// Pluggable storage backend behind a shared async lock. The /// commitment tree inside is global per network; notes are /// scoped per-subwallet by the store's `SubwalletId` keying. @@ -114,10 +103,6 @@ impl ShieldedWallet { "shielded wallet requires at least one account".to_string(), )); } - let accounts = accounts - .into_iter() - .map(|(idx, keys)| (idx, AccountState { keys })) - .collect(); Ok(Self { sdk, wallet_id, @@ -250,7 +235,7 @@ impl ShieldedWallet { return Ok(()); } let keys = OrchardKeySet::from_seed(seed, network, account)?; - self.accounts.insert(account, AccountState { keys }); + self.accounts.insert(account, keys); Ok(()) } @@ -266,7 +251,7 @@ impl ShieldedWallet { /// Borrow the keyset for `account`. pub(super) fn keys_for(&self, account: u32) -> Result<&OrchardKeySet, PlatformWalletError> { - self.accounts.get(&account).map(|s| &s.keys).ok_or_else(|| { + self.accounts.get(&account).ok_or_else(|| { PlatformWalletError::ShieldedKeyDerivation(format!( "shielded account {account} not bound" )) From a7dee49c4bd4c9d246d1c8a2e2821e15c9ef48d7 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 21:01:10 +0700 Subject: [PATCH 59/78] =?UTF-8?q?refactor(platform-wallet):=20Phase=204d.1?= =?UTF-8?q?=20=E2=80=94=20delete=20dead=20fallback=20paths=20in=20Shielded?= =?UTF-8?q?SyncManager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After Phase 1 the FFI's `platform_wallet_manager_bind_shielded` requires `configure_shielded` to have run first, so no wallet on a manager can be shielded-bound without the coordinator slot being populated. That makes two paths dead code: 1. The pre-configure fallback in `ShieldedSyncManager::sync_now` that iterated `self.wallets` and called per-wallet `shielded_sync` — every wallet would have returned `Ok(None)` in this branch since none could be shielded-bound. 2. The per-wallet `shielded_sync` route in `ShieldedSyncManager::sync_wallet` — same reasoning. Changes - `sync_now` collapses to: snapshot coordinator slot, call `coordinator.sync(force)` if populated, else return an empty summary. Drops the per-wallet iteration and the legacy fallback comment block. - `sync_wallet` runs a full network-wide coordinator pass (since post-Phase-2b the SDK fetch is network-wide regardless of which wallet was named) and returns the requested wallet's slice of the result map. "Per-wallet sync" is now a selection over a network-wide pass instead of a separate fetch. - `ShieldedSyncManager.wallets` field removed (no longer iterated). Constructor signature drops the `wallets` arg. - `crate::wallet::PlatformWallet` import removed. - Module + type docstrings updated to reflect the coordinator- only routing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rs-platform-wallet/src/manager/mod.rs | 1 - .../src/manager/shielded_sync.rs | 172 +++++++++--------- 2 files changed, 83 insertions(+), 90 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 6d79d4ac86c..c0f472cede0 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -136,7 +136,6 @@ impl PlatformWalletManager

{ > = Arc::new(RwLock::new(None)); #[cfg(feature = "shielded")] let shielded_sync = Arc::new(ShieldedSyncManager::new( - Arc::clone(&wallets), Arc::clone(&event_manager), Arc::clone(&shielded_coordinator), )); diff --git a/packages/rs-platform-wallet/src/manager/shielded_sync.rs b/packages/rs-platform-wallet/src/manager/shielded_sync.rs index 065e258b204..32116bfa351 100644 --- a/packages/rs-platform-wallet/src/manager/shielded_sync.rs +++ b/packages/rs-platform-wallet/src/manager/shielded_sync.rs @@ -1,21 +1,27 @@ //! Periodic shielded (Orchard) note + nullifier sync coordinator. //! //! Mirrors [`PlatformAddressSyncManager`](super::platform_address_sync::PlatformAddressSyncManager): -//! runs [`PlatformWallet::shielded_sync`] for every wallet that has a -//! bound [`ShieldedWallet`] on a fixed cadence, and emits a summary -//! event so UI and persistence layers can react. +//! drives a single +//! [`NetworkShieldedCoordinator::sync`](crate::wallet::shielded::NetworkShieldedCoordinator::sync) +//! pass on a fixed cadence and emits a summary event so UI and +//! persistence layers can react. The coordinator pass itself +//! covers every wallet registered on the network in a single +//! SDK fetch (see the coordinator's module docs). //! -//! Wallets without a bound shielded sub-wallet are silently skipped -//! — `bind_shielded` is the host's responsibility (it requires -//! mnemonic access via the keychain resolver), so the manager -//! shouldn't error out passes just because some wallets aren't yet -//! shielded-aware. +//! Empty-coordinator handling: if shielded support hasn't been +//! configured (no [`configure_shielded`] call has run yet), sync +//! passes return an empty summary — no wallet on this manager +//! can have shielded state until the coordinator exists, so +//! iterating wallets here would just produce noise. //! -//! Not auto-started. Call [`ShieldedSyncManager::start`] once the -//! shielded sub-wallets are bound. +//! Not auto-started. Call [`ShieldedSyncManager::start`] once +//! shielded support has been configured and at least one wallet +//! has bound. //! -//! Feature-gated behind `shielded` — when the feature is off, the -//! whole module is omitted from the build. +//! Feature-gated behind `shielded` — when the feature is off, +//! the whole module is omitted from the build. +//! +//! [`configure_shielded`]: crate::manager::PlatformWalletManager::configure_shielded use std::collections::BTreeMap; use std::sync::{ @@ -30,7 +36,6 @@ use tokio_util::sync::CancellationToken; use crate::events::PlatformEventManager; use crate::wallet::platform_wallet::WalletId; use crate::wallet::shielded::{NetworkShieldedCoordinator, ShieldedSyncSummary}; -use crate::wallet::PlatformWallet; /// Default cadence — 60s. Shielded sync is heavier than address sync /// (chunked at 2048 entries with trial decryption per entry), so this @@ -103,33 +108,34 @@ impl ShieldedSyncPassSummary { /// Periodic shielded sync coordinator. /// -/// Holds a handle to the same `wallets` map owned by +/// Holds a handle to the same `coordinator_slot` owned by /// [`PlatformWalletManager`](super::PlatformWalletManager) (via -/// `Arc`), so wallets bound after `start` are picked up on the next -/// tick without any re-registration. +/// `Arc`), so wallets bound after `start` are picked up on the +/// next tick without any re-registration (the network-scoped +/// coordinator iterates its own registry). /// /// Each pass: -/// 1. Snapshots the wallet map (short read lock, no await while -/// held). -/// 2. Calls [`PlatformWallet::shielded_sync`] on each wallet -/// sequentially. Returns -/// [`WalletShieldedOutcome::Skipped`] for unbound wallets. +/// 1. Snapshots the coordinator `Arc` (short read lock, no +/// `.await` while held). +/// 2. Calls [`NetworkShieldedCoordinator::sync`] once — the +/// coordinator handles the union of every registered +/// subwallet's IVK in a single SDK fetch. /// 3. Stores the pass timestamp. /// 4. Dispatches /// [`PlatformEventManager::on_shielded_sync_completed`]. /// /// `sync_now` is re-entrant-safe: if a pass is already running, -/// calling `sync_now` again returns an empty summary immediately. +/// calling `sync_now` again returns an empty summary +/// immediately. pub struct ShieldedSyncManager { - wallets: Arc>>>, event_manager: Arc, /// Network-scoped shielded coordinator slot, shared with the - /// owning `PlatformWalletManager`. When `Some`, `sync_now` - /// routes through `coordinator.sync(force)` so the - /// network-wide caught-up cooldown applies and a future - /// Phase-2b lift can collapse the per-wallet SDK fetches. - /// When `None` (shielded support hasn't been configured), - /// `sync_now` falls back to the legacy per-wallet iteration. + /// owning `PlatformWalletManager`. Sync passes route through + /// `coordinator.sync(force)` whenever the slot is populated; + /// an empty slot returns an empty pass summary (no wallets + /// can be shielded-bound without `configure_shielded` having + /// run first, so an empty slot guarantees no shielded state + /// exists). coordinator_slot: Arc>>>, /// Cancel token for the background loop, if running. background_cancel: StdMutex>, @@ -149,12 +155,10 @@ pub struct ShieldedSyncManager { impl ShieldedSyncManager { pub fn new( - wallets: Arc>>>, event_manager: Arc, coordinator_slot: Arc>>>, ) -> Self { Self { - wallets, event_manager, coordinator_slot, background_cancel: StdMutex::new(None), @@ -295,13 +299,16 @@ impl ShieldedSyncManager { return ShieldedSyncPassSummary::default(); } - // Phase 2a: if shielded support has been configured on the - // owning manager, route through the coordinator so the - // network-wide caught-up cooldown applies and a future - // Phase-2b lift can collapse the per-wallet SDK fetches. // Snapshot the coordinator Arc and release the slot lock // before awaiting so a concurrent `configure_shielded` // can't deadlock against our pass. + // + // Empty-coordinator handling: if shielded support hasn't + // been configured yet, return an empty pass summary — + // `bind_shielded` requires `configure_shielded` to run + // first (the FFI enforces this), so no wallet on this + // manager can possibly have shielded state until the + // coordinator exists. let coordinator_snapshot: Option> = { let slot = self.coordinator_slot.read().await; slot.as_ref().map(Arc::clone) @@ -310,41 +317,15 @@ impl ShieldedSyncManager { let mut summary = if let Some(coordinator) = coordinator_snapshot { coordinator.sync(force).await } else { - // Pre-configure fallback: legacy per-wallet iteration. - // Used when no wallet on this manager has ever called - // `configure_shielded` (e.g. in tests / non-shielded - // builds with the feature on but not exercised). - let snapshot: Vec<(WalletId, Arc)> = { - let wallets = self.wallets.read().await; - wallets.iter().map(|(id, w)| (*id, Arc::clone(w))).collect() - }; - - let mut s = ShieldedSyncPassSummary::default(); - for (wallet_id, wallet) in snapshot { - let outcome = match wallet.shielded_sync(force).await { - Ok(Some(result)) => WalletShieldedOutcome::Ok(result), - Ok(None) => WalletShieldedOutcome::Skipped, - Err(e) => { - tracing::warn!( - "Shielded sync failed for wallet {}: {}", - hex::encode(wallet_id), - e - ); - WalletShieldedOutcome::Err(e.to_string()) - } - }; - s.wallet_results.insert(wallet_id, outcome); - } - s + ShieldedSyncPassSummary::default() }; let now = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); - // Honor a non-zero timestamp from the coordinator (it - // stamps `sync_unix_seconds` itself), and stamp our own - // for the fallback path. + // Honor the coordinator's own `sync_unix_seconds` stamp + // when it set one; supply our own otherwise (empty pass). if summary.sync_unix_seconds == 0 { summary.sync_unix_seconds = now; } @@ -359,37 +340,36 @@ impl ShieldedSyncManager { /// Sync a single wallet on demand. /// - /// Acquires the manager's `is_syncing` exclusion before - /// touching the wallet's shielded sub-wallet, mirroring - /// [`sync_now`]. If a pass is already in flight this returns - /// `Ok(None)` immediately rather than serializing — the caller - /// got told "no" without their request also blocking the - /// running periodic pass. Inspect [`is_syncing`] beforehand if - /// you need to distinguish "wallet has no shielded sub-wallet" - /// from "another pass was running". + /// Post-Phase-2b shape: since the coordinator's sync pass is + /// already network-wide (one SDK fetch covers every + /// registered IVK), "sync this wallet" is implemented as a + /// full coordinator pass that returns this wallet's slice of + /// the result. The result map is keyed by `WalletId`; this + /// method extracts the requested wallet's + /// [`ShieldedSyncSummary`] before returning. /// - /// Returns `Ok(None)` if the wallet has no bound shielded - /// sub-wallet, or if another sync pass was already in flight. + /// Returns `Ok(None)` if `wallet_id` isn't registered on the + /// coordinator (e.g. shielded support hasn't been configured, + /// or the wallet has never called `bind_shielded`), or if + /// another sync pass was already in flight. pub async fn sync_wallet( &self, wallet_id: &WalletId, force: bool, ) -> Result, crate::error::PlatformWalletError> { - let wallet = { - let wallets = self.wallets.read().await; - wallets.get(wallet_id).cloned() + let coordinator_snapshot: Option> = { + let slot = self.coordinator_slot.read().await; + slot.as_ref().map(Arc::clone) + }; + let Some(coordinator) = coordinator_snapshot else { + return Ok(None); }; - let wallet = wallet.ok_or_else(|| { - crate::error::PlatformWalletError::WalletNotFound(hex::encode(wallet_id)) - })?; // Reuse the manager-wide `is_syncing` flag so a per-wallet - // sync_wallet() can't race the periodic sync_now() against - // the same `ShieldedWallet` / store. PlatformWallet's - // `shielded_sync` only takes a read lock on the optional - // shielded slot, so without this gate two passes can step - // on each other's commitment-tree appends and - // last-synced-index updates. + // `sync_wallet()` can't race the periodic `sync_now()` + // against the same store — both go through + // `coordinator.sync()`, which serializes per-coordinator + // but the manager flag is what the host UI watches. if self .is_syncing .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) @@ -398,10 +378,24 @@ impl ShieldedSyncManager { return Ok(None); } - let result = wallet.shielded_sync(force).await; - + let pass = coordinator.sync(force).await; self.is_syncing.store(false, Ordering::Release); - result + + // Extract this wallet's slice from the network-wide pass + // summary. If the wallet is registered, we'll get back an + // outcome; otherwise `None`. + match pass + .wallet_results + .into_iter() + .find(|(id, _)| id == wallet_id) + { + Some((_, WalletShieldedOutcome::Ok(summary))) => Ok(Some(summary)), + Some((_, WalletShieldedOutcome::Skipped)) => Ok(None), + Some((_, WalletShieldedOutcome::Err(e))) => { + Err(crate::error::PlatformWalletError::ShieldedSyncFailed(e)) + } + None => Ok(None), + } } } From 18448c8573ed57305a0cd3345bf858e2e5c7cca3 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 21:04:02 +0700 Subject: [PATCH 60/78] =?UTF-8?q?refactor(platform-wallet):=20Phase=204d.2?= =?UTF-8?q?=20=E2=80=94=20drop=20now-orphaned=20per-wallet=20sync=20method?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4d.1 removed the only `ShieldedSyncManager` paths that called `PlatformWallet::shielded_sync` (the pre-configure fallback and `sync_wallet`'s per-wallet route). With no caller left, both `PlatformWallet::shielded_sync` and the underlying `ShieldedWallet::sync` orchestrator are dead. - `PlatformWallet::shielded_sync` removed. - `ShieldedWallet::sync` removed (the per-wallet `sync_notes` / `check_nullifiers` / `balances` delegators stay until Phase 4d.3 deletes `ShieldedWallet` itself). - Unused `ShieldedSyncSummary` import dropped from `platform_wallet.rs`. - `sync.rs` module docstring updated to match the new shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/wallet/platform_wallet.rs | 21 +---------- .../src/wallet/shielded/sync.rs | 37 ++++--------------- 2 files changed, 8 insertions(+), 50 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 606a081376a..bf5e29252eb 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -17,7 +17,7 @@ use super::identity::{IdentityManager, IdentityWallet}; use super::persister::WalletPersister; use super::platform_addresses::PlatformAddressWallet; #[cfg(feature = "shielded")] -use super::shielded::{FileBackedShieldedStore, ShieldedSyncSummary, ShieldedWallet}; +use super::shielded::{FileBackedShieldedStore, ShieldedWallet}; use crate::broadcaster::SpvBroadcaster; use crate::changeset::{ ClientStartState, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, @@ -430,25 +430,6 @@ impl PlatformWallet { .unwrap_or_default() } - /// Run one shielded sync pass on this wallet (covers every - /// bound account in a single chain walk). - /// - /// Returns `Ok(None)` if the shielded sub-wallet hasn't been - /// bound (the sync coordinator skips unbound wallets without - /// surfacing an error). Returns `Ok(Some(summary))` after a - /// successful pass, or `Err(_)` if the underlying sync failed. - #[cfg(feature = "shielded")] - pub async fn shielded_sync( - &self, - force: bool, - ) -> Result, PlatformWalletError> { - let guard = self.shielded.read().await; - match guard.as_ref() { - Some(wallet) => Ok(Some(wallet.sync(force).await?)), - None => Ok(None), - } - } - /// The default Orchard payment address for `account` on this /// wallet, as the raw 43-byte representation. Returns `None` /// if the shielded sub-wallet hasn't been bound or `account` diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs index 7560ba23c8b..f89392a38a8 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs @@ -16,11 +16,13 @@ //! the shared store. //! //! Per-wallet [`ShieldedWallet`] methods (`sync_notes`, -//! `check_nullifiers`, `balances`, `sync`) are now thin -//! delegators that build the one-wallet's slice and call the -//! free functions. Phase 4 removes them along with -//! `ShieldedWallet` itself; the coordinator becomes the only -//! sync entry point. +//! `check_nullifiers`, `balances`) remain as thin delegators +//! that build the one-wallet's slice and call the free +//! functions; the full-wallet `sync` orchestrator was removed +//! in Phase 4d.2 (the coordinator's `sync` is now the only +//! entry point that runs all three in sequence). Phase 4d.3 +//! removes the remaining delegators along with `ShieldedWallet` +//! itself. use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; @@ -550,31 +552,6 @@ impl ShieldedWallet { .map(|(id, c)| (id.account_index, c)) .collect()) } - - /// Full sync: notes + nullifiers + per-account balance summary. - /// - /// Per-wallet full-sync. Cooldown handling lives on the - /// network-scoped coordinator (see - /// [`NetworkShieldedCoordinator::sync`]) — this method - /// always walks Platform. `force` is kept for API - /// compatibility with pre-Phase-2a callers; the parameter - /// is now a no-op and will be removed in Phase 4d alongside - /// `ShieldedWallet` itself. - /// - /// [`NetworkShieldedCoordinator::sync`]: - /// super::coordinator::NetworkShieldedCoordinator::sync - pub async fn sync(&self, _force: bool) -> Result { - let notes_result = self.sync_notes().await?; - let newly_spent_per_account = self.check_nullifiers().await?; - let balances = self.balances().await?; - - Ok(ShieldedSyncSummary { - notes_result, - newly_spent_per_account, - balances, - is_cooldown_skip: false, - }) - } } /// One decrypted note discovered during a sync pass. From 27ad86da67620df06642d15bce37b87467f6a067 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 21:44:48 +0700 Subject: [PATCH 61/78] =?UTF-8?q?refactor(platform-wallet):=20Phase=204b?= =?UTF-8?q?=20=E2=80=94=20lift=20restore=5Ffrom=5Fsnapshot=20to=20coordina?= =?UTF-8?q?tor=20scope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-Phase-4b, `ShieldedWallet::restore_from_snapshot` walked the host snapshot per-wallet and filtered by `self.wallet_id` + self.accounts membership before writing into the shared store. The membership check is now on the coordinator (Phase 2a moved the account registry there), so the per-wallet method had to borrow accounts indirectly — clean architectural fit for hoisting the whole restore to coordinator scope. - New `NetworkShieldedCoordinator::restore_for_wallet(wallet_id, snapshot)`. Filters by both: * `wallet_id` (the global `ShieldedSyncStartState` is keyed by `SubwalletId`; callers loop one wallet at a time) * registered-on-coordinator (subwallets without a current viewing-key registration are skipped — they'd accumulate state we can never spend, no `OrchardKeySet` for them on the per-wallet side) - `ShieldedWallet::restore_from_snapshot` removed. - `PlatformWallet::bind_shielded` re-ordered so `register_wallet` runs BEFORE `restore_for_wallet` (the restore's "is this account registered?" gate requires the registry to already contain this wallet's subwallets). - Unused `ShieldedSyncStartState` import dropped from `shielded/mod.rs`. - `ShieldedStore` trait imported into `coordinator.rs` so the inline `store.save_note`, `mark_spent`, etc. calls compile. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/wallet/platform_wallet.rs | 56 +++++++------ .../src/wallet/shielded/coordinator.rs | 79 ++++++++++++++++++- .../src/wallet/shielded/mod.rs | 46 +---------- 3 files changed, 110 insertions(+), 71 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index bf5e29252eb..c47f4ecc233 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -338,31 +338,6 @@ impl PlatformWallet { // on iOS). wallet.set_persister(self.persister.clone()); - // Rehydrate per-subwallet notes / sync watermarks from - // the persister's start state if any are present for - // this wallet. The lookup is cheap: load() is the - // boot-time snapshot, indexed by SubwalletId. Errors are - // logged but not fatal — first-launch wallets simply - // see no persisted state. - match self.persister.load() { - Ok(start) => { - if let Err(e) = wallet.restore_from_snapshot(&start.shielded).await { - tracing::warn!( - wallet_id = %hex::encode(self.wallet_id), - error = %e, - "Failed to restore shielded snapshot at bind time" - ); - } - } - Err(e) => { - tracing::warn!( - wallet_id = %hex::encode(self.wallet_id), - error = %e, - "persister.load() failed at shielded bind time" - ); - } - } - // Snapshot the viewing-key subset for coordinator // registration. Privilege separation: only FVK / IVK / // OVK / default address cross to the coordinator; the @@ -384,9 +359,40 @@ impl PlatformWallet { *slot = Some(wallet); drop(slot); + // Register on the coordinator BEFORE restoring so the + // restore path's "is this account registered?" gate + // sees this wallet's subwallets. coordinator .register_wallet(self.wallet_id, account_views, self.persister.clone()) .await; + + // Rehydrate per-subwallet notes / sync watermarks from + // the persister's start state if any are present for + // this wallet. The lookup is cheap: load() is the + // boot-time snapshot, indexed by SubwalletId. Errors are + // logged but not fatal — first-launch wallets simply + // see no persisted state. + match self.persister.load() { + Ok(start) => { + if let Err(e) = coordinator + .restore_for_wallet(self.wallet_id, &start.shielded) + .await + { + tracing::warn!( + wallet_id = %hex::encode(self.wallet_id), + error = %e, + "Failed to restore shielded snapshot at bind time" + ); + } + } + Err(e) => { + tracing::warn!( + wallet_id = %hex::encode(self.wallet_id), + error = %e, + "persister.load() failed at shielded bind time" + ); + } + } Ok(()) } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index 13fc0819d42..3fead91f76e 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -60,7 +60,7 @@ use tokio::sync::RwLock; use super::file_store::FileBackedShieldedStore; use super::keys::AccountViewingKeys; -use super::store::SubwalletId; +use super::store::{ShieldedStore, SubwalletId}; use super::CAUGHT_UP_COOLDOWN; use crate::manager::shielded_sync::{ShieldedSyncPassSummary, WalletShieldedOutcome}; use crate::wallet::persister::WalletPersister; @@ -242,6 +242,83 @@ impl NetworkShieldedCoordinator { self.accounts.read().await.keys().copied().collect() } + /// Rehydrate per-subwallet state from a host-persisted + /// snapshot for the wallet identified by `wallet_id`. Should + /// be called after [`register_wallet`](Self::register_wallet) + /// and before the first sync pass so the in-memory store + /// matches what the host already has on disk (notes, spent + /// marks, sync watermarks, nullifier checkpoints). + /// + /// Filters the supplied [`ShieldedSyncStartState`] in two + /// ways: + /// - **By `wallet_id`**: only entries whose `SubwalletId` + /// belongs to `wallet_id` are restored. The startup + /// snapshot is keyed globally by `SubwalletId` but a single + /// `restore_for_wallet` call only owns one wallet's slice; + /// the host typically loops over registered wallets and + /// calls this once per wallet so each per-wallet `bind` + /// flow drops in its own state. + /// - **By registered account**: subwallets whose + /// `account_index` isn't currently registered on this + /// coordinator are skipped — they'd accumulate state we + /// can never spend (no `OrchardKeySet` for them on the + /// per-wallet side). + /// + /// No-op on empty snapshots. + pub async fn restore_for_wallet( + &self, + wallet_id: WalletId, + snapshot: &crate::changeset::ShieldedSyncStartState, + ) -> Result<(), crate::error::PlatformWalletError> { + if snapshot.is_empty() { + return Ok(()); + } + // Snapshot of registered subwallets for the membership + // check. Cheaper than holding the accounts read lock + // across the store write below. + let registered: std::collections::BTreeSet = { + let accounts = self.accounts.read().await; + accounts + .keys() + .copied() + .filter(|id| id.wallet_id == wallet_id) + .collect() + }; + if registered.is_empty() { + return Ok(()); + } + + let mut store = self.store.write().await; + for (id, sub) in &snapshot.per_subwallet { + // Only restore subwallets that belong to `wallet_id` + // and are registered on this coordinator. + if id.wallet_id != wallet_id || !registered.contains(id) { + continue; + } + for note in &sub.notes { + store.save_note(*id, note).map_err(|e| { + crate::error::PlatformWalletError::ShieldedStoreError(e.to_string()) + })?; + if note.is_spent { + store.mark_spent(*id, ¬e.nullifier).map_err(|e| { + crate::error::PlatformWalletError::ShieldedStoreError(e.to_string()) + })?; + } + } + store + .set_last_synced_note_index(*id, sub.last_synced_index) + .map_err(|e| { + crate::error::PlatformWalletError::ShieldedStoreError(e.to_string()) + })?; + if let Some((h, t)) = sub.nullifier_checkpoint { + store.set_nullifier_checkpoint(*id, h, t).map_err(|e| { + crate::error::PlatformWalletError::ShieldedStoreError(e.to_string()) + })?; + } + } + Ok(()) + } + /// Drop every wallet registration and reset the cooldown /// stamp. The single SQLite handle (commitment tree) stays /// open — Clear semantics on the host side are "wipe my diff --git a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs index 90e88cd59fb..e9988c4d2b2 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs @@ -37,8 +37,8 @@ use std::sync::Arc; use tokio::sync::RwLock; +use crate::changeset::PlatformWalletChangeSet; use crate::changeset::ShieldedChangeSet; -use crate::changeset::{PlatformWalletChangeSet, ShieldedSyncStartState}; use crate::error::PlatformWalletError; use crate::wallet::persister::WalletPersister; use crate::wallet::platform_wallet::WalletId; @@ -140,50 +140,6 @@ impl ShieldedWallet { } } - /// Rehydrate per-subwallet state from a persisted snapshot. - /// Should be called after `from_seed_accounts(...)` and before - /// the first sync pass so the in-memory store matches what - /// the host already has on disk. - pub async fn restore_from_snapshot( - &self, - snapshot: &ShieldedSyncStartState, - ) -> Result<(), PlatformWalletError> { - if snapshot.is_empty() { - return Ok(()); - } - let mut store = self.store.write().await; - for (id, sub) in &snapshot.per_subwallet { - // Only restore subwallets that belong to this wallet. - if id.wallet_id != self.wallet_id { - continue; - } - // Skip accounts that aren't bound on this wallet — - // they'd accumulate state we can never spend. - if !self.accounts.contains_key(&id.account_index) { - continue; - } - for note in &sub.notes { - store - .save_note(*id, note) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; - if note.is_spent { - store - .mark_spent(*id, ¬e.nullifier) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; - } - } - store - .set_last_synced_note_index(*id, sub.last_synced_index) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; - if let Some((h, t)) = sub.nullifier_checkpoint { - store - .set_nullifier_checkpoint(*id, h, t) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; - } - } - Ok(()) - } - /// Derive Orchard keys for every listed `account` from a /// wallet seed and return a [`ShieldedWallet`]. /// From 2eaffb556747a95520baa69b1f6219a32b1d3b94 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 20 May 2026 22:00:22 +0700 Subject: [PATCH 62/78] =?UTF-8?q?refactor(platform-wallet):=20Phase=204d.3?= =?UTF-8?q?=20=E2=80=94=20delete=20ShieldedWallet;=20lift=20spend=20ops=20?= =?UTF-8?q?to=20free=20fns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `ShieldedWallet` wrapper held five fields, four of which were duplicates of state the rest of the system already owned: - `sdk` — same `Arc` PlatformWallet holds - `wallet_id` — same WalletId PlatformWallet holds - `store` — pre-Phase-1 it was per-wallet; coordinator owns it now - `persister` — same WalletPersister PlatformWallet holds - `accounts: BTreeMap` — the only unique state Phase 4d.3 lifts the `accounts` map directly onto PlatformWallet as `shielded_keys` and deletes the wrapper. Spend operations operations.rs converts every `pub async fn xxx(&self, ...)` on `ShieldedWallet` to a free function `pub async fn xxx(sdk, store, persister, wallet_id, keys, account, ...)`. Each external entry (shield / unshield / transfer / withdraw) takes the (now shared) commitment-tree store and the per-call `OrchardKeySet` (with the `SpendAuthorizingKey`) explicitly — no implicit `&self` shielded state. Internal helpers (extract_spends_and_anchor, mark_notes_spent, reserve_unspent_notes, finalize_pending, cancel_pending, default_orchard_address) lifted too. PlatformWallet - `shielded: Arc>>>` → `shielded_keys: Arc>>>`. - `bind_shielded` builds the keys map directly (no `from_seed_accounts` wrapper) and registers viewing keys + persister on the coordinator. - `shielded_add_account` updates the keys map directly. - `is_shielded_bound` / `shielded_account_indices` / `shielded_default_address` / `shielded_default_addresses` read the keys map directly. - `shielded_balances` now takes the coordinator (to source the shared store) and routes through `sync::balances_across`. - `shielded_transfer_to` / `shielded_unshield_to` / `shielded_withdraw_to` take the coordinator + look up the per-account keyset in shielded_keys, then call the free fns. FFI - New `resolve_wallet_and_coordinator` helper that fetches both the wallet Arc and the coordinator Arc from the manager in one short-locked block; the three spend FFIs use it. - platform_wallet_manager_shielded_transfer / unshield / withdraw thread the coordinator down to PlatformWallet. sync.rs - Per-wallet `ShieldedWallet::sync_notes` / `check_nullifiers` delegators removed (Phase 4d.2 left them as a stub when the fallback path went away; now there's no caller). - `balances_across` widened to `pub(crate)` so PlatformWallet's new `shielded_balances` body can call it directly. mod.rs - `ShieldedWallet` struct removed. - Impl block removed. - Module docstring rewritten to describe the post-deletion architecture (per-wallet keys, coordinator-owned store, free functions for sync / spend). - `CAUGHT_UP_COOLDOWN` kept (single caller: coordinator). Net code reduction: ~1,800 lines moved + ~150 lines deleted (duplicate state, dead wrapper machinery). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/shielded_send.rs | 70 +- .../src/wallet/platform_wallet.rs | 286 +++-- .../src/wallet/shielded/mod.rs | 272 +--- .../src/wallet/shielded/operations.rs | 1100 +++++++++-------- .../src/wallet/shielded/sync.rs | 100 +- 5 files changed, 871 insertions(+), 957 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 9bbe60fbecb..868bb88b86c 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -94,8 +94,8 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_transfer( let mut recipient = [0u8; 43]; std::ptr::copy_nonoverlapping(recipient_raw_43, recipient.as_mut_ptr(), 43); - let wallet = match resolve_wallet(handle, &wallet_id) { - Ok(w) => w, + let (wallet, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) { + Ok(p) => p, Err(result) => return result, }; @@ -107,7 +107,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_transfer( let result = block_on_worker(async move { let prover = CachedOrchardProver::new(); wallet - .shielded_transfer_to(account, &recipient, amount, &prover) + .shielded_transfer_to(&coordinator, account, &recipient, amount, &prover) .await }); if let Err(e) = result { @@ -157,15 +157,15 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_unshield( } }; - let wallet = match resolve_wallet(handle, &wallet_id) { - Ok(w) => w, + let (wallet, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) { + Ok(p) => p, Err(result) => return result, }; let result = block_on_worker(async move { let prover = CachedOrchardProver::new(); wallet - .shielded_unshield_to(account, &to_addr_str, amount, &prover) + .shielded_unshield_to(&coordinator, account, &to_addr_str, amount, &prover) .await }); if let Err(e) = result { @@ -212,15 +212,22 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_withdraw( } }; - let wallet = match resolve_wallet(handle, &wallet_id) { - Ok(w) => w, + let (wallet, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) { + Ok(p) => p, Err(result) => return result, }; let result = block_on_worker(async move { let prover = CachedOrchardProver::new(); wallet - .shielded_withdraw_to(account, &to_core, amount, core_fee_per_byte, &prover) + .shielded_withdraw_to( + &coordinator, + account, + &to_core, + amount, + core_fee_per_byte, + &prover, + ) .await }); if let Err(e) = result { @@ -336,3 +343,48 @@ fn resolve_wallet( ) }) } + +/// Resolve both the wallet `Arc` and the network-scoped shielded +/// coordinator `Arc` for the given manager handle. Shielded +/// spend operations need the coordinator's shared store, so this +/// is the right resolver for transfer/unshield/withdraw FFIs. +fn resolve_wallet_and_coordinator( + handle: Handle, + wallet_id: &[u8; 32], +) -> Result< + ( + std::sync::Arc, + std::sync::Arc, + ), + PlatformWalletFFIResult, +> { + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + runtime().block_on(async { + let wallet = manager.get_wallet(wallet_id).await; + let coordinator = manager.shielded_coordinator().await; + (wallet, coordinator) + }) + }); + let (wallet_opt, coord_opt) = match option { + Some(v) => v, + None => { + return Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + format!("invalid manager handle: {handle}"), + )); + } + }; + let wallet = wallet_opt.ok_or_else(|| { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("wallet not found: {}", hex::encode(wallet_id)), + ) + })?; + let coordinator = coord_opt.ok_or_else(|| { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "shielded support not configured — call platform_wallet_manager_configure_shielded first", + ) + })?; + Ok((wallet, coordinator)) +} diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index c47f4ecc233..129d72e357f 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -16,8 +16,10 @@ use super::core::{CoreWallet, WalletBalance}; use super::identity::{IdentityManager, IdentityWallet}; use super::persister::WalletPersister; use super::platform_addresses::PlatformAddressWallet; -#[cfg(feature = "shielded")] -use super::shielded::{FileBackedShieldedStore, ShieldedWallet}; +// Phase 4d.3 deleted the `ShieldedWallet` wrapper; per-account +// keysets now live in `self.shielded_keys` directly. Spend +// operations source the shared commitment-tree store from +// `NetworkShieldedCoordinator` at call time. use crate::broadcaster::SpvBroadcaster; use crate::changeset::{ ClientStartState, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, @@ -74,15 +76,27 @@ pub struct PlatformWallet { persister: WalletPersister, /// Lock-free balance for UI reads, cloned from `PlatformWalletInfo.balance`. pub(crate) balance: Arc, - /// Shielded (Orchard / ZK) sub-wallet. `None` until [`bind_shielded`] - /// has run; remains `None` for `WatchOnly` / `ExternalSignable` - /// wallets that have never had a resolver-driven bind. The - /// `RwLock` lets the shielded sync coordinator read the bound - /// state without serializing against unrelated wallet writes. + /// Per-account Orchard keysets, populated by [`bind_shielded`]. + /// `None` until bind has run; remains `None` for `WatchOnly` + /// / `ExternalSignable` wallets that have never had a + /// resolver-driven bind. The `RwLock` lets read paths (the + /// shielded sync coordinator, balance/address accessors) + /// observe the bound state without serializing against + /// unrelated wallet writes. + /// + /// Sync / spend operations source the shared + /// commitment-tree store from + /// [`NetworkShieldedCoordinator`] (one SQLite handle per + /// network) rather than per-wallet, so all this slot needs + /// to hold is the spend-authority keysets — the + /// `SpendAuthorizingKey` lives here, the viewing-key half + /// is mirrored on the coordinator's account registry. /// /// [`bind_shielded`]: Self::bind_shielded + /// [`NetworkShieldedCoordinator`]: crate::wallet::shielded::NetworkShieldedCoordinator #[cfg(feature = "shielded")] - pub(crate) shielded: Arc>>>, + pub(crate) shielded_keys: + Arc>>>, } impl PlatformWallet { @@ -285,7 +299,7 @@ impl PlatformWallet { persister: wallet_persister, balance, #[cfg(feature = "shielded")] - shielded: Arc::new(RwLock::new(None)), + shielded_keys: Arc::new(RwLock::new(None)), } } @@ -316,47 +330,41 @@ impl PlatformWallet { accounts: &[u32], coordinator: &Arc, ) -> Result<(), PlatformWalletError> { - // The store comes from the network-scoped coordinator — - // every wallet on the same network shares one SQLite - // handle. The bind also self-registers the wallet's - // viewing-key set on the coordinator so future sync - // passes (driven by the coordinator) iterate it. - // See `PlatformWalletManager::configure_shielded`. - let store = Arc::clone(coordinator.store()); + // Phase 4d.3: derive the per-account `OrchardKeySet` map + // directly — no more `ShieldedWallet` wrapper. The shared + // commitment-tree store lives on the coordinator (one + // SQLite handle per network); the spend methods source it + // there at call time. The per-wallet side just needs the + // keysets (with the `SpendAuthorizingKey`) for spend + // authorization. + use super::shielded::{AccountViewingKeys, OrchardKeySet}; + if accounts.is_empty() { + return Err(PlatformWalletError::ShieldedKeyDerivation( + "shielded wallet requires at least one account".to_string(), + )); + } let network = self.sdk.network; - let mut wallet = ShieldedWallet::from_seed_accounts( - Arc::clone(&self.sdk), - self.wallet_id, - seed, - network, - accounts, - store, - )?; - - // Attach the persister so future sync passes emit - // shielded changesets the host can mirror (SwiftData - // on iOS). - wallet.set_persister(self.persister.clone()); + let mut keys: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for &account in accounts { + // `accounts` may contain duplicates; the BTreeMap + // dedups by definition. + let ks = OrchardKeySet::from_seed(seed, network, account)?; + keys.insert(account, ks); + } // Snapshot the viewing-key subset for coordinator // registration. Privilege separation: only FVK / IVK / // OVK / default address cross to the coordinator; the // `SpendAuthorizingKey` stays here on the per-wallet // side inside `OrchardKeySet`. - let account_views: std::collections::BTreeMap = - wallet - .account_indices() - .into_iter() - .filter_map(|account| { - wallet - .keys_for(account) - .ok() - .map(|ks| (account, ks.viewing_keys())) - }) - .collect(); + let account_views: std::collections::BTreeMap = keys + .iter() + .map(|(account, ks)| (*account, ks.viewing_keys())) + .collect(); - let mut slot = self.shielded.write().await; - *slot = Some(wallet); + let mut slot = self.shielded_keys.write().await; + *slot = Some(keys); drop(slot); // Register on the coordinator BEFORE restoring so the @@ -412,27 +420,38 @@ impl PlatformWallet { seed: &[u8], account: u32, ) -> Result<(), PlatformWalletError> { - let mut slot = self.shielded.write().await; - let wallet = slot.as_mut().ok_or(PlatformWalletError::ShieldedNotBound)?; - wallet.add_account_from_seed(seed, self.sdk.network, account) + use super::shielded::OrchardKeySet; + let mut slot = self.shielded_keys.write().await; + let keys = slot.as_mut().ok_or(PlatformWalletError::ShieldedNotBound)?; + if keys.contains_key(&account) { + return Ok(()); + } + let ks = OrchardKeySet::from_seed(seed, self.sdk.network, account)?; + keys.insert(account, ks); + // NOTE: this only updates the per-wallet keys slot — the + // coordinator's `accounts` registry isn't refreshed here. + // Hosts that add accounts after bind should re-call + // `bind_shielded` with the full account list so the + // coordinator's viewing-key registry stays in sync. + Ok(()) } /// Whether the shielded sub-wallet has been bound via /// [`bind_shielded`](Self::bind_shielded). #[cfg(feature = "shielded")] pub async fn is_shielded_bound(&self) -> bool { - self.shielded.read().await.is_some() + self.shielded_keys.read().await.is_some() } /// Bound ZIP-32 account indices on the shielded sub-wallet, /// in ascending order. Empty if not bound. #[cfg(feature = "shielded")] pub async fn shielded_account_indices(&self) -> Vec { - self.shielded + self.shielded_keys .read() .await .as_ref() - .map(|w| w.account_indices()) + .map(|keys| keys.keys().copied().collect()) .unwrap_or_default() } @@ -443,65 +462,96 @@ impl PlatformWallet { /// (HRP + 0x10 type byte) on top. #[cfg(feature = "shielded")] pub async fn shielded_default_address(&self, account: u32) -> Option<[u8; 43]> { - let guard = self.shielded.read().await; + let guard = self.shielded_keys.read().await; guard .as_ref() - .and_then(|w| w.default_address(account).ok()) - .map(|addr| addr.to_raw_address_bytes()) + .and_then(|keys| keys.get(&account)) + .map(|ks| ks.default_address.to_raw_address_bytes()) } /// Per-account default Orchard payment addresses (raw 43 bytes). #[cfg(feature = "shielded")] pub async fn shielded_default_addresses(&self) -> std::collections::BTreeMap { - let guard = self.shielded.read().await; - let Some(wallet) = guard.as_ref() else { + let guard = self.shielded_keys.read().await; + let Some(keys) = guard.as_ref() else { return std::collections::BTreeMap::new(); }; - wallet - .account_indices() - .into_iter() - .filter_map(|account| { - wallet - .default_address(account) - .ok() - .map(|addr| (account, addr.to_raw_address_bytes())) - }) + keys.iter() + .map(|(account, ks)| (*account, ks.default_address.to_raw_address_bytes())) .collect() } /// Per-account unspent shielded balance. + /// + /// Reads against the coordinator's shared store (one SQLite + /// handle per network); returns an empty map if shielded + /// support hasn't been configured or this wallet isn't + /// bound. Folds the network-wide + /// [`balances_across`](super::shielded::sync::balances_across) + /// result down to this wallet's per-account slice. #[cfg(feature = "shielded")] pub async fn shielded_balances( &self, + coordinator: &Arc, ) -> Result, PlatformWalletError> { - let guard = self.shielded.read().await; - match guard.as_ref() { - Some(wallet) => wallet.balances().await, - None => Ok(std::collections::BTreeMap::new()), - } + use super::shielded::{AccountViewingKeys, SubwalletId}; + let guard = self.shielded_keys.read().await; + let Some(keys) = guard.as_ref() else { + return Ok(std::collections::BTreeMap::new()); + }; + let subwallets: Vec<(SubwalletId, AccountViewingKeys)> = keys + .iter() + .map(|(account, ks)| { + ( + SubwalletId::new(self.wallet_id, *account), + ks.viewing_keys(), + ) + }) + .collect(); + let per_sub = + super::shielded::sync::balances_across(coordinator.store(), &subwallets).await?; + Ok(per_sub + .into_iter() + .filter(|(id, _)| id.wallet_id == self.wallet_id) + .map(|(id, v)| (id.account_index, v)) + .collect()) } /// Send a private shielded → shielded transfer from `account`'s /// notes to `recipient_raw_43` (the recipient's Orchard payment /// address as the 43 raw bytes). /// - /// The prover is consumed by value rather than borrowed because - /// `OrchardProver` is impl'd on `&CachedOrchardProver` (the - /// reference type), not on the bare struct. Callers pass - /// `&CachedOrchardProver::new()` and we forward it down to the - /// underlying `ShieldedWallet::transfer`'s `&P` parameter. + /// `coordinator` supplies the shared, network-scoped + /// commitment-tree store; the wallet supplies the + /// `OrchardKeySet` (with the `SpendAuthorizingKey`) by + /// account. Privilege separation: the ASK never crosses to + /// the coordinator — the spend free function takes the + /// keyset by reference at call time. + /// + /// The prover is consumed by value rather than borrowed + /// because `OrchardProver` is impl'd on + /// `&CachedOrchardProver` (the reference type), not on the + /// bare struct. Callers pass `&CachedOrchardProver::new()` + /// and we forward it down to the spend free function's + /// `&P` parameter. #[cfg(feature = "shielded")] pub async fn shielded_transfer_to( &self, + coordinator: &Arc, account: u32, recipient_raw_43: &[u8; 43], amount: u64, prover: P, ) -> Result<(), PlatformWalletError> { - let guard = self.shielded.read().await; - let shielded = guard + let guard = self.shielded_keys.read().await; + let keys = guard .as_ref() .ok_or(PlatformWalletError::ShieldedNotBound)?; + let keyset = keys.get(&account).ok_or_else(|| { + PlatformWalletError::ShieldedKeyDerivation(format!( + "shielded account {account} not bound" + )) + })?; let recipient = Option::::from( grovedb_commitment_tree::PaymentAddress::from_raw_address_bytes(recipient_raw_43), ) @@ -510,9 +560,18 @@ impl PlatformWallet { "invalid Orchard payment address bytes".to_string(), ) })?; - shielded - .transfer(account, &recipient, amount, &prover) - .await + super::shielded::operations::transfer( + &self.sdk, + coordinator.store(), + Some(&self.persister), + self.wallet_id, + keyset, + account, + &recipient, + amount, + &prover, + ) + .await } /// Unshield from `account`'s notes to a transparent platform @@ -522,15 +581,21 @@ impl PlatformWallet { #[cfg(feature = "shielded")] pub async fn shielded_unshield_to( &self, + coordinator: &Arc, account: u32, to_platform_addr_bech32m: &str, amount: u64, prover: P, ) -> Result<(), PlatformWalletError> { - let guard = self.shielded.read().await; - let shielded = guard + let guard = self.shielded_keys.read().await; + let keys = guard .as_ref() .ok_or(PlatformWalletError::ShieldedNotBound)?; + let keyset = keys.get(&account).ok_or_else(|| { + PlatformWalletError::ShieldedKeyDerivation(format!( + "shielded account {account} not bound" + )) + })?; let (to, addr_network) = dpp::address_funds::PlatformAddress::from_bech32m_string(to_platform_addr_bech32m) .map_err(|e| { @@ -544,7 +609,18 @@ impl PlatformWallet { self.sdk.network ))); } - shielded.unshield(account, &to, amount, &prover).await + super::shielded::operations::unshield( + &self.sdk, + coordinator.store(), + Some(&self.persister), + self.wallet_id, + keyset, + account, + &to, + amount, + &prover, + ) + .await } /// Withdraw from `account`'s notes to a Core L1 address @@ -553,16 +629,22 @@ impl PlatformWallet { #[cfg(feature = "shielded")] pub async fn shielded_withdraw_to( &self, + coordinator: &Arc, account: u32, to_core_address: &str, amount: u64, core_fee_per_byte: u32, prover: P, ) -> Result<(), PlatformWalletError> { - let guard = self.shielded.read().await; - let shielded = guard + let guard = self.shielded_keys.read().await; + let keys = guard .as_ref() .ok_or(PlatformWalletError::ShieldedNotBound)?; + let keyset = keys.get(&account).ok_or_else(|| { + PlatformWalletError::ShieldedKeyDerivation(format!( + "shielded account {account} not bound" + )) + })?; let network = self.sdk.network; let parsed = to_core_address .parse::>() @@ -575,9 +657,19 @@ impl PlatformWallet { "core address network mismatch: {e}" )) })?; - shielded - .withdraw(account, &parsed, amount, core_fee_per_byte, &prover) - .await + super::shielded::operations::withdraw( + &self.sdk, + coordinator.store(), + Some(&self.persister), + self.wallet_id, + keyset, + account, + &parsed, + amount, + core_fee_per_byte, + &prover, + ) + .await } /// Shield credits from a Platform Payment account into the @@ -747,13 +839,25 @@ impl PlatformWallet { chosen }; - let guard = self.shielded.read().await; - let shielded = guard + let guard = self.shielded_keys.read().await; + let keys = guard .as_ref() .ok_or(PlatformWalletError::ShieldedNotBound)?; - shielded - .shield(shielded_account, inputs, amount, signer, &prover) - .await + let keyset = keys.get(&shielded_account).ok_or_else(|| { + PlatformWalletError::ShieldedKeyDerivation(format!( + "shielded account {shielded_account} not bound" + )) + })?; + super::shielded::operations::shield( + &self.sdk, + keyset, + shielded_account, + inputs, + amount, + signer, + &prover, + ) + .await } } @@ -879,7 +983,7 @@ impl Clone for PlatformWallet { persister: self.persister.clone(), balance: self.balance.clone(), #[cfg(feature = "shielded")] - shielded: self.shielded.clone(), + shielded_keys: self.shielded_keys.clone(), } } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs index e9988c4d2b2..24fcb86c95c 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs @@ -7,14 +7,29 @@ //! //! # Architecture //! -//! - [`OrchardKeySet`] — ZIP-32 key derivation from a wallet seed. -//! - [`ShieldedStore`] / [`InMemoryShieldedStore`] — storage abstraction. -//! The shared commitment tree lives here too; per-subwallet -//! notes are scoped by [`SubwalletId`] inside the store. +//! - [`OrchardKeySet`] — ZIP-32 key derivation from a wallet +//! seed (FVK / IVK / OVK / `SpendAuthorizingKey` / default +//! payment address). +//! - [`AccountViewingKeys`] — viewing-grade subset of +//! `OrchardKeySet` (no ASK); the only key material that +//! crosses to coordinator scope. +//! - [`NetworkShieldedCoordinator`] — network-scoped owner of +//! the shared commitment-tree store, the per-`SubwalletId` +//! account registry, the caught-up cooldown stamp, and the +//! sync entry point. One coordinator per +//! `PlatformWalletManager`. +//! - [`ShieldedStore`] / [`InMemoryShieldedStore`] / +//! [`FileBackedShieldedStore`] — storage abstraction; the +//! shared commitment tree lives here. Per-subwallet notes are +//! scoped by [`SubwalletId`] inside the store. //! - [`CachedOrchardProver`] — lazy-init proving key cache. -//! - [`ShieldedWallet`] — multi-account coordinator tying the -//! wallet's Orchard accounts (`BTreeMap`), -//! the shared store, and the SDK together. +//! - Sync / spend operations live as free functions in the +//! [`sync`] and [`operations`] submodules and take +//! `(sdk, store, persister, wallet_id, keys, …)` explicitly. +//! +//! Per-wallet shielded state on `PlatformWallet` is just the +//! `BTreeMap` (with the spend authority); +//! `PlatformWallet` doesn't need a wrapper struct around it. pub mod coordinator; pub mod file_store; @@ -32,248 +47,13 @@ pub use prover::CachedOrchardProver; pub use store::{InMemoryShieldedStore, ShieldedNote, ShieldedStore, SubwalletId}; pub use sync::{ShieldedSyncSummary, SyncNotesResult}; -use std::collections::BTreeMap; -use std::sync::Arc; - -use tokio::sync::RwLock; - -use crate::changeset::PlatformWalletChangeSet; -use crate::changeset::ShieldedChangeSet; -use crate::error::PlatformWalletError; -use crate::wallet::persister::WalletPersister; -use crate::wallet::platform_wallet::WalletId; - -/// Feature-gated multi-account shielded wallet. -/// -/// One [`ShieldedWallet`] lives inside one [`PlatformWallet`] and -/// holds every Orchard account that wallet has bound. Operations -/// take `account: u32` and route to the right keyset internally. -/// The shared `store: Arc>` is keyed per-account via -/// [`SubwalletId`] so multiple accounts on the same wallet (and -/// multiple wallets on the same network) cohabit the same store -/// without cross-talk. -pub struct ShieldedWallet { - /// Dash Platform SDK handle for network operations. - pub(super) sdk: Arc, - /// 32-byte wallet identifier — used to construct - /// [`SubwalletId`] for every store call. - pub(super) wallet_id: WalletId, - /// Bound Orchard accounts, keyed by ZIP-32 account index. - pub(super) accounts: BTreeMap, - /// Pluggable storage backend behind a shared async lock. The - /// commitment tree inside is global per network; notes are - /// scoped per-subwallet by the store's `SubwalletId` keying. - pub(super) store: Arc>, - /// Optional persister handle. When set, every state-changing - /// sync / spend pass emits a [`PlatformWalletChangeSet`] with - /// a populated `shielded` field so the host (typically - /// SwiftData on iOS) can mirror per-subwallet notes / sync - /// watermarks. `None` means in-memory only — useful for - /// tests and short-lived wallets. - pub(super) persister: Option, -} - /// How long after a no-op sync the background loop should skip /// further passes. Tuned against the wallet's typical 60s sync /// cadence — halves wire calls in steady-state while keeping /// "new notes" discovery latency bounded at one cooldown window /// plus the next loop tick. Manual `force=true` syncs bypass. +/// +/// Held in the coordinator (`NetworkShieldedCoordinator::sync` +/// is the only caller); the per-`PlatformWallet` cooldown stamp +/// was removed in Phase 4a. pub(super) const CAUGHT_UP_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(30); - -impl ShieldedWallet { - /// Construct a [`ShieldedWallet`] from pre-derived keysets. - /// - /// `accounts` maps ZIP-32 account index → [`OrchardKeySet`]. - /// At least one account must be supplied. - /// - /// `store` is the shared, network-scoped commitment-tree - /// store handed in by the caller. Every wallet on the same - /// network manager should pass the same `Arc` clone — the - /// chain-wide Orchard tree only needs one SQLite handle per - /// network. See `NetworkShieldedCoordinator` for the layer - /// that owns that single handle on Phase 1+. - pub fn from_keysets( - sdk: Arc, - wallet_id: WalletId, - accounts: BTreeMap, - store: Arc>, - ) -> Result { - if accounts.is_empty() { - return Err(PlatformWalletError::ShieldedKeyDerivation( - "shielded wallet requires at least one account".to_string(), - )); - } - Ok(Self { - sdk, - wallet_id, - accounts, - store, - persister: None, - }) - } - - /// Attach a [`WalletPersister`] so future sync / spend passes - /// emit shielded changesets to the host. - pub fn set_persister(&mut self, persister: WalletPersister) { - self.persister = Some(persister); - } - - /// Queue a shielded changeset on the persister if one is - /// attached. No-op otherwise. - pub(super) fn queue_shielded_changeset(&self, cs: ShieldedChangeSet) { - if cs.is_empty() { - return; - } - let Some(persister) = &self.persister else { - return; - }; - let full = PlatformWalletChangeSet { - shielded: Some(cs), - ..Default::default() - }; - if let Err(e) = persister.store(full) { - tracing::warn!( - wallet_id = %hex::encode(self.wallet_id), - error = %e, - "Failed to queue shielded changeset" - ); - } - } - - /// Derive Orchard keys for every listed `account` from a - /// wallet seed and return a [`ShieldedWallet`]. - /// - /// `seed` is the BIP-39 seed bytes (32–252 bytes; typically - /// 64). `network` selects the ZIP-32 coin type. Each entry of - /// `accounts` becomes a separate ZIP-32 account - /// (`m / 32' / coin_type' / account'`); duplicates are - /// silently deduplicated. - pub fn from_seed_accounts( - sdk: Arc, - wallet_id: WalletId, - seed: &[u8], - network: dashcore::Network, - accounts: &[u32], - store: Arc>, - ) -> Result { - if accounts.is_empty() { - return Err(PlatformWalletError::ShieldedKeyDerivation( - "shielded wallet requires at least one account".to_string(), - )); - } - let mut keysets: BTreeMap = BTreeMap::new(); - for &account in accounts { - let keys = OrchardKeySet::from_seed(seed, network, account)?; - keysets.insert(account, keys); - } - Self::from_keysets(sdk, wallet_id, keysets, store) - } - - /// Add another ZIP-32 account to this wallet by re-deriving - /// from the seed. No-op if `account` is already bound. - /// - /// **Caveat**: the commitment tree only retains - /// authentication paths for positions `Retention::Marked` at - /// append time. Notes that reached the tree before this - /// account existed were marked `Ephemeral` and can never - /// produce witnesses for it without a tree wipe + full - /// re-sync. New accounts therefore only see notes from - /// future syncs. The host should drop the tree DB and - /// re-sync from genesis when the user adds an account they - /// expect to discover historical funds for. - pub fn add_account_from_seed( - &mut self, - seed: &[u8], - network: dashcore::Network, - account: u32, - ) -> Result<(), PlatformWalletError> { - if self.accounts.contains_key(&account) { - return Ok(()); - } - let keys = OrchardKeySet::from_seed(seed, network, account)?; - self.accounts.insert(account, keys); - Ok(()) - } - - /// All bound ZIP-32 account indices, in ascending order. - pub fn account_indices(&self) -> Vec { - self.accounts.keys().copied().collect() - } - - /// `true` iff `account` is bound on this wallet. - pub fn has_account(&self, account: u32) -> bool { - self.accounts.contains_key(&account) - } - - /// Borrow the keyset for `account`. - pub(super) fn keys_for(&self, account: u32) -> Result<&OrchardKeySet, PlatformWalletError> { - self.accounts.get(&account).ok_or_else(|| { - PlatformWalletError::ShieldedKeyDerivation(format!( - "shielded account {account} not bound" - )) - }) - } - - /// Construct the [`SubwalletId`] for `account` on this wallet. - pub(super) fn subwallet_id(&self, account: u32) -> SubwalletId { - SubwalletId::new(self.wallet_id, account) - } - - /// Total unspent shielded balance for `account` in credits. - /// Reads from the store — does not trigger a sync. - pub async fn balance(&self, account: u32) -> Result { - self.keys_for(account)?; // existence check - let id = self.subwallet_id(account); - let store = self.store.read().await; - let notes = store - .get_unspent_notes(id) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; - Ok(notes.iter().map(|n| n.value).sum()) - } - - /// Sum of unspent shielded balance across every bound account. - pub async fn balance_total(&self) -> Result { - let store = self.store.read().await; - let mut total: u64 = 0; - for account in self.accounts.keys() { - let id = SubwalletId::new(self.wallet_id, *account); - let notes = store - .get_unspent_notes(id) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; - total = total.saturating_add(notes.iter().map(|n| n.value).sum::()); - } - Ok(total) - } - - /// Per-account unspent shielded balance, in ascending account order. - pub async fn balances(&self) -> Result, PlatformWalletError> { - let store = self.store.read().await; - let mut out: BTreeMap = BTreeMap::new(); - for account in self.accounts.keys() { - let id = SubwalletId::new(self.wallet_id, *account); - let notes = store - .get_unspent_notes(id) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; - out.insert(*account, notes.iter().map(|n| n.value).sum()); - } - Ok(out) - } - - /// The default payment address (diversifier index 0) for - /// `account`. Returns an error if `account` isn't bound. - pub fn default_address( - &self, - account: u32, - ) -> Result<&grovedb_commitment_tree::PaymentAddress, PlatformWalletError> { - self.keys_for(account).map(|k| &k.default_address) - } - - /// Derive a payment address at `index` under `account`. - pub fn address_at( - &self, - account: u32, - index: u32, - ) -> Result { - Ok(self.keys_for(account)?.address_at(index)) - } -} diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 0aebf1a9291..1d7c920a745 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -1,9 +1,14 @@ //! Shielded transaction operations (5 transition types), multi-account. //! -//! Each operation now takes `account: u32` and routes through the -//! corresponding `OrchardKeySet` / `SubwalletId`. Spends never -//! cross account boundaries — note selection reads only the -//! given account's unspent notes. +//! Each operation is a free function taking the +//! (sdk, store, persister, wallet_id, keys, account, …) tuple +//! it needs explicitly. Phase 4d.3 lifted these out of +//! `impl ShieldedWallet` so per-wallet shielded state on +//! `PlatformWallet` can be just the keys map without a wrapper +//! struct. +//! +//! Spends never cross account boundaries — note selection reads +//! only the given account's unspent notes. //! //! The five transition types are: //! - **Shield** (Type 15): transparent platform addresses → shielded pool @@ -12,12 +17,16 @@ //! - **Transfer** (Type 16): shielded pool → shielded pool (private) //! - **Withdraw** (Type 19): shielded pool → Core L1 address +use super::keys::OrchardKeySet; use super::note_selection::select_notes_with_fee; use super::store::{ShieldedNote, ShieldedStore, SubwalletId}; -use super::ShieldedWallet; +use crate::changeset::{PlatformWalletChangeSet, ShieldedChangeSet}; use crate::error::PlatformWalletError; +use crate::wallet::persister::WalletPersister; +use crate::wallet::platform_wallet::WalletId; use std::collections::BTreeMap; +use std::sync::Arc; use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; use dpp::address_funds::{ @@ -35,6 +44,7 @@ use dpp::shielded::builder::{ use dpp::state_transition::proof_result::StateTransitionProofResult; use dpp::withdrawal::Pooling; use grovedb_commitment_tree::{Anchor, PaymentAddress}; +use tokio::sync::RwLock; use tracing::{info, trace, warn}; /// Try to extract a structured `AddressesNotEnoughFundsError` from @@ -80,587 +90,627 @@ fn format_addresses_with_info( .join(", ") } -impl ShieldedWallet { - // ------------------------------------------------------------------------- - // Shield: platform addresses -> shielded pool (Type 15) - // ------------------------------------------------------------------------- - - /// Shield credits from transparent platform addresses into the - /// shielded pool, with the resulting note assigned to `account`'s - /// default Orchard payment address. - pub async fn shield, P: OrchardProver>( - &self, - account: u32, - inputs: BTreeMap, - amount: u64, - signer: &Sig, - prover: &P, - ) -> Result<(), PlatformWalletError> { - let recipient_addr = self.default_orchard_address(account)?; - - // Fetch the current address nonces from Platform. Each - // input address has a per-address nonce that the next - // state transition must use as `last_used + 1`. - use dash_sdk::platform::FetchMany; - use dash_sdk::query_types::AddressInfo; - use std::collections::BTreeSet; - - let address_set: BTreeSet = inputs.keys().copied().collect(); - let infos = AddressInfo::fetch_many(&self.sdk, address_set) - .await - .map_err(|e| { - PlatformWalletError::ShieldedBuildError(format!("fetch input nonces: {e}")) - })?; +/// Queue a shielded changeset on the persister if one is +/// attached. No-op if the changeset is empty or no persister +/// was supplied. +fn queue_shielded_changeset( + persister: Option<&WalletPersister>, + wallet_id: WalletId, + cs: ShieldedChangeSet, +) { + if cs.is_empty() { + return; + } + let Some(persister) = persister else { + return; + }; + let full = PlatformWalletChangeSet { + shielded: Some(cs), + ..Default::default() + }; + if let Err(e) = persister.store(full) { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "Failed to queue shielded changeset" + ); + } +} - let mut inputs_with_nonce: BTreeMap = BTreeMap::new(); - for (addr, credits) in inputs { - let info = infos - .get(&addr) - .and_then(|opt| opt.as_ref()) - .ok_or_else(|| { - PlatformWalletError::ShieldedBuildError(format!( - "input address not found on platform: {:?}", - addr - )) - })?; - if info.balance < credits { - warn!( - address = ?addr, - claimed_credits = credits, - platform_balance = info.balance, - platform_nonce = info.nonce, - "Shield input claims more credits than Platform reports — broadcast will likely fail" - ); - } else { - info!( - address = ?addr, - claimed_credits = credits, - platform_balance = info.balance, - platform_nonce = info.nonce, - "Shield input" - ); - } - // `AddressNonce` is `u32`; `info.nonce + 1` would - // wrap silently in release once an address reaches - // u32::MAX. drive-abci treats wrap-to-0 as a replay - // and rejects it after the wallet has spent ~30 s on - // a Halo 2 proof. Bail loudly here instead. - let next_nonce = info.nonce.checked_add(1).ok_or_else(|| { +// ------------------------------------------------------------------------- +// Shield: platform addresses -> shielded pool (Type 15) +// ------------------------------------------------------------------------- + +/// Shield credits from transparent platform addresses into the +/// shielded pool, with the resulting note assigned to `account`'s +/// default Orchard payment address derived from `keys`. +#[allow(clippy::too_many_arguments)] +pub async fn shield, P: OrchardProver>( + sdk: &Arc, + keys: &OrchardKeySet, + account: u32, + inputs: BTreeMap, + amount: u64, + signer: &Sig, + prover: &P, +) -> Result<(), PlatformWalletError> { + let recipient_addr = default_orchard_address(keys)?; + + // Fetch the current address nonces from Platform. Each input + // address has a per-address nonce that the next state + // transition must use as `last_used + 1`. + use dash_sdk::platform::FetchMany; + use dash_sdk::query_types::AddressInfo; + use std::collections::BTreeSet; + + let address_set: BTreeSet = inputs.keys().copied().collect(); + let infos = AddressInfo::fetch_many(sdk, address_set) + .await + .map_err(|e| PlatformWalletError::ShieldedBuildError(format!("fetch input nonces: {e}")))?; + + let mut inputs_with_nonce: BTreeMap = BTreeMap::new(); + for (addr, credits) in inputs { + let info = infos + .get(&addr) + .and_then(|opt| opt.as_ref()) + .ok_or_else(|| { PlatformWalletError::ShieldedBuildError(format!( - "input address nonce exhausted on platform: {:?}", + "input address not found on platform: {:?}", addr )) })?; - inputs_with_nonce.insert(addr, (next_nonce, credits)); + if info.balance < credits { + warn!( + address = ?addr, + claimed_credits = credits, + platform_balance = info.balance, + platform_nonce = info.nonce, + "Shield input claims more credits than Platform reports — broadcast will likely fail" + ); + } else { + info!( + address = ?addr, + claimed_credits = credits, + platform_balance = info.balance, + platform_nonce = info.nonce, + "Shield input" + ); } + // `AddressNonce` is `u32`; `info.nonce + 1` would wrap + // silently in release once an address reaches u32::MAX. + // drive-abci treats wrap-to-0 as a replay and rejects it + // after the wallet has spent ~30 s on a Halo 2 proof. + // Bail loudly here instead. + let next_nonce = info.nonce.checked_add(1).ok_or_else(|| { + PlatformWalletError::ShieldedBuildError(format!( + "input address nonce exhausted on platform: {:?}", + addr + )) + })?; + inputs_with_nonce.insert(addr, (next_nonce, credits)); + } - let fee_strategy: AddressFundsFeeStrategy = - vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]; + let fee_strategy: AddressFundsFeeStrategy = + vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]; + + info!(account, credits = amount, "Shield: building proof"); + + let claimed_inputs = inputs_with_nonce.clone(); + + let state_transition = build_shield_transition( + &recipient_addr, + amount, + inputs_with_nonce, + fee_strategy, + signer, + 0, // user_fee_increase + prover, + [0u8; 36], // empty memo + sdk.version(), + ) + .await + .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; + + trace!("Shield credits: state transition built, broadcasting..."); + let network = sdk.network; + state_transition.broadcast(sdk, None).await.map_err(|e| { + if let Some(rich) = addresses_not_enough_funds(&e) { + let claimed = claimed_inputs + .iter() + .map(|(addr, (nonce, credits))| { + format!( + "{}=(nonce {nonce}, {credits} credits)", + addr.to_bech32m_string(network) + ) + }) + .collect::>() + .join(", "); + PlatformWalletError::ShieldedBroadcastFailed(format!( + "addresses not enough funds: required {} credits; \ + claimed inputs [{}]; platform sees [{}]", + rich.required_balance(), + claimed, + format_addresses_with_info(rich.addresses_with_info(), network), + )) + } else { + PlatformWalletError::ShieldedBroadcastFailed(e.to_string()) + } + })?; - info!(account, credits = amount, "Shield: building proof"); + info!(account, credits = amount, "Shield broadcast succeeded"); + Ok(()) +} - let claimed_inputs = inputs_with_nonce.clone(); +// ------------------------------------------------------------------------- +// ShieldFromAssetLock: Core L1 asset lock -> shielded pool (Type 18) +// ------------------------------------------------------------------------- + +/// Shield credits from a Core L1 asset lock into the shielded +/// pool, with the resulting note assigned to `account`'s default +/// Orchard payment address derived from `keys`. +pub async fn shield_from_asset_lock( + sdk: &Arc, + keys: &OrchardKeySet, + account: u32, + asset_lock_proof: AssetLockProof, + private_key: &[u8], + amount: u64, + prover: &P, +) -> Result<(), PlatformWalletError> { + let recipient_addr = default_orchard_address(keys)?; + + info!( + account, + credits = amount, + "Shield from asset lock: building state transition" + ); + + let state_transition = build_shield_from_asset_lock_transition( + &recipient_addr, + amount, + asset_lock_proof, + private_key, + prover, + [0u8; 36], + sdk.version(), + ) + .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; + + trace!("Shield from asset lock: state transition built, broadcasting..."); + state_transition + .broadcast(sdk, None) + .await + .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; + + info!( + account, + credits = amount, + "Shield from asset lock broadcast succeeded" + ); + Ok(()) +} - let state_transition = build_shield_transition( - &recipient_addr, +// ------------------------------------------------------------------------- +// Unshield: shielded pool -> platform address (Type 17) +// ------------------------------------------------------------------------- + +/// Unshield funds from `account`'s shielded notes to a +/// transparent platform address. +#[allow(clippy::too_many_arguments)] +pub async fn unshield( + sdk: &Arc, + store: &Arc>, + persister: Option<&WalletPersister>, + wallet_id: WalletId, + keys: &OrchardKeySet, + account: u32, + to_address: &PlatformAddress, + amount: u64, + prover: &P, +) -> Result<(), PlatformWalletError> { + let change_addr = default_orchard_address(keys)?; + let id = SubwalletId::new(wallet_id, account); + + let (selected_notes, total_input, exact_fee) = + reserve_unspent_notes(sdk, store, id, amount, 1).await?; + + info!( + account, + credits = amount, + fee = exact_fee, + inputs = selected_notes.len(), + total_input, + "Unshield" + ); + + // From here on every error path must release the reservation + // taken by `reserve_unspent_notes`. + let result = async { + let (spends, anchor) = extract_spends_and_anchor(store, &selected_notes).await?; + + let state_transition = build_unshield_transition( + spends, + *to_address, amount, - inputs_with_nonce, - fee_strategy, - signer, - 0, // user_fee_increase + &change_addr, + &keys.full_viewing_key, + &keys.spend_auth_key, + anchor, prover, - [0u8; 36], // empty memo - self.sdk.version(), + [0u8; 36], + Some(exact_fee), + sdk.version(), ) - .await .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; - trace!("Shield credits: state transition built, broadcasting..."); - let network = self.sdk.network; + trace!("Unshield: state transition built, broadcasting..."); state_transition - .broadcast(&self.sdk, None) + .broadcast_and_wait::(sdk, None) .await - .map_err(|e| { - if let Some(rich) = addresses_not_enough_funds(&e) { - let claimed = claimed_inputs - .iter() - .map(|(addr, (nonce, credits))| { - format!( - "{}=(nonce {nonce}, {credits} credits)", - addr.to_bech32m_string(network) - ) - }) - .collect::>() - .join(", "); - PlatformWalletError::ShieldedBroadcastFailed(format!( - "addresses not enough funds: required {} credits; \ - claimed inputs [{}]; platform sees [{}]", - rich.required_balance(), - claimed, - format_addresses_with_info(rich.addresses_with_info(), network), - )) - } else { - PlatformWalletError::ShieldedBroadcastFailed(e.to_string()) - } - })?; - - info!(account, credits = amount, "Shield broadcast succeeded"); - Ok(()) + .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; + Ok::<(), PlatformWalletError>(()) } + .await; - // ------------------------------------------------------------------------- - // ShieldFromAssetLock: Core L1 -> shielded pool (Type 18) - // ------------------------------------------------------------------------- - - /// Shield funds from a Core L1 asset lock directly into - /// `account`'s shielded pool entry. - pub async fn shield_from_asset_lock( - &self, - account: u32, - asset_lock_proof: AssetLockProof, - private_key: &[u8], - amount: u64, - prover: &P, - ) -> Result<(), PlatformWalletError> { - let recipient_addr = self.default_orchard_address(account)?; - - info!( - account, - credits = amount, - "Shield from asset lock: building state transition" - ); + match result { + Ok(()) => { + finalize_pending(store, persister, wallet_id, id, &selected_notes).await?; + info!(account, credits = amount, "Unshield broadcast succeeded"); + Ok(()) + } + Err(e) => { + cancel_pending(store, id, &selected_notes).await; + Err(e) + } + } +} - let state_transition = build_shield_from_asset_lock_transition( +// ------------------------------------------------------------------------- +// Transfer: shielded pool -> shielded pool (Type 16) +// ------------------------------------------------------------------------- + +/// Transfer funds privately from `account`'s shielded notes to +/// another Orchard payment address. +#[allow(clippy::too_many_arguments)] +pub async fn transfer( + sdk: &Arc, + store: &Arc>, + persister: Option<&WalletPersister>, + wallet_id: WalletId, + keys: &OrchardKeySet, + account: u32, + to_address: &PaymentAddress, + amount: u64, + prover: &P, +) -> Result<(), PlatformWalletError> { + let recipient_addr = payment_address_to_orchard(to_address)?; + let change_addr = default_orchard_address(keys)?; + let id = SubwalletId::new(wallet_id, account); + + let (selected_notes, total_input, exact_fee) = + reserve_unspent_notes(sdk, store, id, amount, 2).await?; + + info!( + account, + credits = amount, + fee = exact_fee, + inputs = selected_notes.len(), + total_input, + "Shielded transfer" + ); + + let result = async { + let (spends, anchor) = extract_spends_and_anchor(store, &selected_notes).await?; + + let state_transition = build_shielded_transfer_transition( + spends, &recipient_addr, amount, - asset_lock_proof, - private_key, + &change_addr, + &keys.full_viewing_key, + &keys.spend_auth_key, + anchor, prover, [0u8; 36], - self.sdk.version(), + Some(exact_fee), + sdk.version(), ) .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; - trace!("Shield from asset lock: state transition built, broadcasting..."); + trace!("Shielded transfer: state transition built, broadcasting..."); state_transition - .broadcast(&self.sdk, None) + .broadcast_and_wait::(sdk, None) .await .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; - - info!( - account, - credits = amount, - "Shield from asset lock broadcast succeeded" - ); - Ok(()) + Ok::<(), PlatformWalletError>(()) } - - // ------------------------------------------------------------------------- - // Unshield: shielded pool -> platform address (Type 17) - // ------------------------------------------------------------------------- - - /// Unshield funds from `account`'s shielded notes to a - /// transparent platform address. - pub async fn unshield( - &self, - account: u32, - to_address: &PlatformAddress, - amount: u64, - prover: &P, - ) -> Result<(), PlatformWalletError> { - let keys = self.keys_for(account)?; - let change_addr = self.default_orchard_address(account)?; - let id = self.subwallet_id(account); - - let (selected_notes, total_input, exact_fee) = - self.reserve_unspent_notes(id, amount, 1).await?; - - info!( - account, - credits = amount, - fee = exact_fee, - inputs = selected_notes.len(), - total_input, - "Unshield" - ); - - // From here on every error path must release the - // reservation taken by `reserve_unspent_notes`. - let result = async { - let (spends, anchor) = self.extract_spends_and_anchor(&selected_notes).await?; - - let state_transition = build_unshield_transition( - spends, - *to_address, - amount, - &change_addr, - &keys.full_viewing_key, - &keys.spend_auth_key, - anchor, - prover, - [0u8; 36], - Some(exact_fee), - self.sdk.version(), - ) - .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; - - trace!("Unshield: state transition built, broadcasting..."); - state_transition - .broadcast_and_wait::(&self.sdk, None) - .await - .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; - Ok::<(), PlatformWalletError>(()) + .await; + + match result { + Ok(()) => { + finalize_pending(store, persister, wallet_id, id, &selected_notes).await?; + info!( + account, + credits = amount, + "Shielded transfer broadcast succeeded" + ); + Ok(()) } - .await; - - match result { - Ok(()) => { - self.finalize_pending(id, &selected_notes).await?; - info!(account, credits = amount, "Unshield broadcast succeeded"); - Ok(()) - } - Err(e) => { - self.cancel_pending(id, &selected_notes).await; - Err(e) - } + Err(e) => { + cancel_pending(store, id, &selected_notes).await; + Err(e) } } +} - // ------------------------------------------------------------------------- - // Transfer: shielded pool -> shielded pool (Type 16) - // ------------------------------------------------------------------------- - - /// Transfer funds privately from `account`'s shielded notes - /// to another Orchard payment address. - pub async fn transfer( - &self, - account: u32, - to_address: &PaymentAddress, - amount: u64, - prover: &P, - ) -> Result<(), PlatformWalletError> { - let keys = self.keys_for(account)?; - let recipient_addr = payment_address_to_orchard(to_address)?; - let change_addr = self.default_orchard_address(account)?; - let id = self.subwallet_id(account); - - let (selected_notes, total_input, exact_fee) = - self.reserve_unspent_notes(id, amount, 2).await?; - - info!( - account, - credits = amount, - fee = exact_fee, - inputs = selected_notes.len(), - total_input, - "Shielded transfer" - ); +// ------------------------------------------------------------------------- +// Withdraw: shielded pool -> Core L1 address (Type 19) +// ------------------------------------------------------------------------- + +/// Withdraw funds from `account`'s shielded notes to a Core L1 address. +#[allow(clippy::too_many_arguments)] +pub async fn withdraw( + sdk: &Arc, + store: &Arc>, + persister: Option<&WalletPersister>, + wallet_id: WalletId, + keys: &OrchardKeySet, + account: u32, + to_address: &dashcore::Address, + amount: u64, + core_fee_per_byte: u32, + prover: &P, +) -> Result<(), PlatformWalletError> { + let change_addr = default_orchard_address(keys)?; + let id = SubwalletId::new(wallet_id, account); + let output_script = CoreScript::from_bytes(to_address.script_pubkey().to_bytes()); + + let (selected_notes, total_input, exact_fee) = + reserve_unspent_notes(sdk, store, id, amount, 1).await?; + + info!( + account, + credits = amount, + fee = exact_fee, + inputs = selected_notes.len(), + total_input, + "Shielded withdrawal" + ); + + let result = async { + let (spends, anchor) = extract_spends_and_anchor(store, &selected_notes).await?; + + let state_transition = build_shielded_withdrawal_transition( + spends, + amount, + output_script, + core_fee_per_byte, + Pooling::Standard, + &change_addr, + &keys.full_viewing_key, + &keys.spend_auth_key, + anchor, + prover, + [0u8; 36], + Some(exact_fee), + sdk.version(), + ) + .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; - let result = async { - let (spends, anchor) = self.extract_spends_and_anchor(&selected_notes).await?; - - let state_transition = build_shielded_transfer_transition( - spends, - &recipient_addr, - amount, - &change_addr, - &keys.full_viewing_key, - &keys.spend_auth_key, - anchor, - prover, - [0u8; 36], - Some(exact_fee), - self.sdk.version(), - ) - .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; - - trace!("Shielded transfer: state transition built, broadcasting..."); - state_transition - .broadcast_and_wait::(&self.sdk, None) - .await - .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; - Ok::<(), PlatformWalletError>(()) + trace!("Shielded withdrawal: state transition built, broadcasting..."); + state_transition + .broadcast_and_wait::(sdk, None) + .await + .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; + Ok::<(), PlatformWalletError>(()) + } + .await; + + match result { + Ok(()) => { + finalize_pending(store, persister, wallet_id, id, &selected_notes).await?; + info!( + account, + credits = amount, + "Shielded withdrawal broadcast succeeded" + ); + Ok(()) } - .await; - - match result { - Ok(()) => { - self.finalize_pending(id, &selected_notes).await?; - info!( - account, - credits = amount, - "Shielded transfer broadcast succeeded" - ); - Ok(()) - } - Err(e) => { - self.cancel_pending(id, &selected_notes).await; - Err(e) - } + Err(e) => { + cancel_pending(store, id, &selected_notes).await; + Err(e) } } +} - // ------------------------------------------------------------------------- - // Withdraw: shielded pool -> Core L1 address (Type 19) - // ------------------------------------------------------------------------- - - /// Withdraw funds from `account`'s shielded notes to a Core L1 address. - pub async fn withdraw( - &self, - account: u32, - to_address: &dashcore::Address, - amount: u64, - core_fee_per_byte: u32, - prover: &P, - ) -> Result<(), PlatformWalletError> { - let keys = self.keys_for(account)?; - let change_addr = self.default_orchard_address(account)?; - let id = self.subwallet_id(account); - let output_script = CoreScript::from_bytes(to_address.script_pubkey().to_bytes()); - - let (selected_notes, total_input, exact_fee) = - self.reserve_unspent_notes(id, amount, 1).await?; - - info!( - account, - credits = amount, - fee = exact_fee, - inputs = selected_notes.len(), - total_input, - "Shielded withdrawal" - ); +// ------------------------------------------------------------------------- +// Internal helpers (free fns) +// ------------------------------------------------------------------------- - let result = async { - let (spends, anchor) = self.extract_spends_and_anchor(&selected_notes).await?; - - let state_transition = build_shielded_withdrawal_transition( - spends, - amount, - output_script, - core_fee_per_byte, - Pooling::Standard, - &change_addr, - &keys.full_viewing_key, - &keys.spend_auth_key, - anchor, - prover, - [0u8; 36], - Some(exact_fee), - self.sdk.version(), - ) - .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; - - trace!("Shielded withdrawal: state transition built, broadcasting..."); - state_transition - .broadcast_and_wait::(&self.sdk, None) - .await - .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; - Ok::<(), PlatformWalletError>(()) - } - .await; - - match result { - Ok(()) => { - self.finalize_pending(id, &selected_notes).await?; - info!( - account, - credits = amount, - "Shielded withdrawal broadcast succeeded" - ); - Ok(()) - } - Err(e) => { - self.cancel_pending(id, &selected_notes).await; - Err(e) - } - } - } +/// Convert `keys`'s default `PaymentAddress` to an `OrchardAddress`. +fn default_orchard_address(keys: &OrchardKeySet) -> Result { + payment_address_to_orchard(&keys.default_address) +} - // ------------------------------------------------------------------------- - // Internal helpers - // ------------------------------------------------------------------------- +/// Extract `SpendableNote` structs with Merkle witnesses and the +/// tree anchor. +/// +/// The anchor is derived from the witness paths themselves (via +/// `MerklePath::root(cmx)`) rather than from `store.tree_anchor()`. +/// The store's witness call is `witness_at_checkpoint_depth(0)` +/// (root of the most recent checkpoint) while `tree_anchor()` is +/// `root_at_checkpoint_depth(None)` (latest tree state) — any +/// commitments appended after the last checkpoint move the latter +/// ahead of the former, and the resulting `AnchorMismatch` from +/// the Orchard spend builder is what you'd see at proof time. +/// Using the witness's own computed root keeps the anchor +/// consistent with the authentication paths the proof actually +/// verifies. +async fn extract_spends_and_anchor( + store: &Arc>, + notes: &[ShieldedNote], +) -> Result<(Vec, Anchor), PlatformWalletError> { + use grovedb_commitment_tree::ExtractedNoteCommitment; + + let store = store.read().await; + + let mut spends = Vec::with_capacity(notes.len()); + let mut anchor: Option = None; + for note in notes { + let orchard_note = deserialize_note(¬e.note_data).ok_or_else(|| { + PlatformWalletError::ShieldedBuildError(format!( + "Failed to deserialize note at position {}", + note.position + )) + })?; - /// Convert `account`'s default `PaymentAddress` to an `OrchardAddress`. - fn default_orchard_address(&self, account: u32) -> Result { - let keys = self.keys_for(account)?; - payment_address_to_orchard(&keys.default_address) - } + let merkle_path = store + .witness(note.position) + .map_err(|e| PlatformWalletError::ShieldedMerkleWitnessUnavailable(e.to_string()))? + .ok_or_else(|| { + PlatformWalletError::ShieldedMerkleWitnessUnavailable(format!( + "no witness available for note at position {} (not marked, or pruned past this position)", + note.position + )) + })?; - /// Extract `SpendableNote` structs with Merkle witnesses and - /// the tree anchor. - /// - /// The anchor is derived from the witness paths themselves - /// (via `MerklePath::root(cmx)`) rather than from - /// `store.tree_anchor()`. The store's witness call is - /// `witness_at_checkpoint_depth(0)` (root of the most recent - /// checkpoint) while `tree_anchor()` is - /// `root_at_checkpoint_depth(None)` (latest tree state) — - /// any commitments appended after the last checkpoint move - /// the latter ahead of the former, and the resulting - /// `AnchorMismatch` from the Orchard spend builder is what - /// you'd see at proof time. Using the witness's own - /// computed root keeps the anchor consistent with the - /// authentication paths the proof actually verifies. - async fn extract_spends_and_anchor( - &self, - notes: &[ShieldedNote], - ) -> Result<(Vec, Anchor), PlatformWalletError> { - use grovedb_commitment_tree::ExtractedNoteCommitment; - - let store = self.store.read().await; - - let mut spends = Vec::with_capacity(notes.len()); - let mut anchor: Option = None; - for note in notes { - let orchard_note = deserialize_note(¬e.note_data).ok_or_else(|| { + // Compute the anchor this witness was generated against. + // All selected notes must share the same anchor — if not, + // the store handed us witnesses from different + // checkpoints, which the spend builder would reject + // downstream with `AnchorMismatch`. Surface the mismatch + // here so the host doesn't pay the ~30 s proof cost + // first. + let cmx = ExtractedNoteCommitment::from_bytes(¬e.cmx) + .into_option() + .ok_or_else(|| { PlatformWalletError::ShieldedBuildError(format!( - "Failed to deserialize note at position {}", + "invalid stored cmx for note at position {}", note.position )) })?; - - let merkle_path = store - .witness(note.position) - .map_err(|e| PlatformWalletError::ShieldedMerkleWitnessUnavailable(e.to_string()))? - .ok_or_else(|| { - PlatformWalletError::ShieldedMerkleWitnessUnavailable(format!( - "no witness available for note at position {} (not marked, or pruned past this position)", - note.position - )) - })?; - - // Compute the anchor this witness was generated - // against. All selected notes must share the same - // anchor — if not, the store handed us witnesses - // from different checkpoints, which the spend - // builder would reject downstream with - // `AnchorMismatch`. Surface the mismatch here so the - // host doesn't pay the ~30 s proof cost first. - let cmx = ExtractedNoteCommitment::from_bytes(¬e.cmx) - .into_option() - .ok_or_else(|| { - PlatformWalletError::ShieldedBuildError(format!( - "invalid stored cmx for note at position {}", - note.position - )) - })?; - let witness_anchor = merkle_path.root(cmx); - match &anchor { - None => anchor = Some(witness_anchor), - Some(prev) if prev.to_bytes() != witness_anchor.to_bytes() => { - return Err(PlatformWalletError::ShieldedBuildError(format!( - "witness anchor mismatch across selected notes (position {})", - note.position - ))); - } - _ => {} + let witness_anchor = merkle_path.root(cmx); + match &anchor { + None => anchor = Some(witness_anchor), + Some(prev) if prev.to_bytes() != witness_anchor.to_bytes() => { + return Err(PlatformWalletError::ShieldedBuildError(format!( + "witness anchor mismatch across selected notes (position {})", + note.position + ))); } - - spends.push(SpendableNote { - note: orchard_note, - merkle_path, - }); + _ => {} } - let anchor = anchor.ok_or_else(|| { - PlatformWalletError::ShieldedBuildError( - "no spendable notes selected — anchor undefined".to_string(), - ) - })?; - - Ok((spends, anchor)) + spends.push(SpendableNote { + note: orchard_note, + merkle_path, + }); } - /// Mark the selected notes as spent for `id`. Also queues a - /// shielded changeset on the persister so the spent flag - /// reaches durable storage immediately rather than waiting for - /// the next nullifier-sync pass to rediscover the spend. - /// - /// Also drops any matching pending reservation so the - /// confirmed-spent state and the in-flight-spend state can't - /// disagree. - async fn mark_notes_spent( - &self, - id: SubwalletId, - notes: &[ShieldedNote], - ) -> Result<(), PlatformWalletError> { - let mut changeset = crate::changeset::ShieldedChangeSet::default(); - { - let mut store = self.store.write().await; - for note in notes { - if store - .mark_spent(id, ¬e.nullifier) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))? - { - changeset.record_nullifier_spent(id, note.nullifier); - } + let anchor = anchor.ok_or_else(|| { + PlatformWalletError::ShieldedBuildError( + "no spendable notes selected — anchor undefined".to_string(), + ) + })?; + + Ok((spends, anchor)) +} + +/// Mark the selected notes as spent for `id`. Also queues a +/// shielded changeset on the persister so the spent flag reaches +/// durable storage immediately rather than waiting for the next +/// nullifier-sync pass to rediscover the spend. Also drops any +/// matching pending reservation so the confirmed-spent state +/// and the in-flight-spend state can't disagree. +async fn mark_notes_spent( + store: &Arc>, + persister: Option<&WalletPersister>, + wallet_id: WalletId, + id: SubwalletId, + notes: &[ShieldedNote], +) -> Result<(), PlatformWalletError> { + let mut changeset = ShieldedChangeSet::default(); + { + let mut store = store.write().await; + for note in notes { + if store + .mark_spent(id, ¬e.nullifier) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))? + { + changeset.record_nullifier_spent(id, note.nullifier); } } - self.queue_shielded_changeset(changeset); - Ok(()) } + queue_shielded_changeset(persister, wallet_id, changeset); + Ok(()) +} - /// Select unspent notes and reserve them against an in-flight - /// spend in one write-locked critical section. - /// - /// Combining selection and reservation under a single write - /// lock is the only thing that prevents two overlapping spend - /// calls from picking the same notes: with separate - /// read-then-write phases, the second caller would observe - /// the same `unspent_notes()` between the first caller's - /// read and write and proceed to build a duplicate proof - /// that's only rejected ~30 s later at broadcast time. - /// - /// The reservation is in-memory only — see - /// [`ShieldedStore::mark_pending`] for the crash-recovery - /// note. Callers must pair this with [`Self::finalize_pending`] - /// (on broadcast success) or [`Self::cancel_pending`] (on - /// failure) so the reservation is always released. - #[allow(clippy::too_many_arguments)] - async fn reserve_unspent_notes( - &self, - id: SubwalletId, - amount: u64, - outputs: usize, - ) -> Result<(Vec, u64, u64), PlatformWalletError> { - let mut store = self.store.write().await; - let unspent = store - .get_unspent_notes(id) +/// Select unspent notes and reserve them against an in-flight +/// spend in one write-locked critical section. +/// +/// Combining selection and reservation under a single write lock +/// is the only thing that prevents two overlapping spend calls +/// from picking the same notes: with separate read-then-write +/// phases, the second caller would observe the same +/// `unspent_notes()` between the first caller's read and write +/// and proceed to build a duplicate proof that's only rejected +/// ~30 s later at broadcast time. +/// +/// The reservation is in-memory only — see +/// [`ShieldedStore::mark_pending`] for the crash-recovery note. +/// Callers must pair this with [`finalize_pending`] (on +/// broadcast success) or [`cancel_pending`] (on failure) so the +/// reservation is always released. +async fn reserve_unspent_notes( + sdk: &Arc, + store: &Arc>, + id: SubwalletId, + amount: u64, + outputs: usize, +) -> Result<(Vec, u64, u64), PlatformWalletError> { + let mut store = store.write().await; + let unspent = store + .get_unspent_notes(id) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + let (selected, total_input, exact_fee) = + select_notes_with_fee(&unspent, amount, outputs, sdk.version())?.into_owned(); + for note in &selected { + store + .mark_pending(id, ¬e.nullifier) .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; - let (selected, total_input, exact_fee) = - select_notes_with_fee(&unspent, amount, outputs, self.sdk.version())?.into_owned(); - for note in &selected { - store - .mark_pending(id, ¬e.nullifier) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; - } - Ok((selected, total_input, exact_fee)) } + Ok((selected, total_input, exact_fee)) +} - /// Promote a successful broadcast: mark the notes spent (which - /// also clears any matching pending reservation, see - /// [`SubwalletState::mark_spent`]) and queue the changeset for - /// the host persister. - async fn finalize_pending( - &self, - id: SubwalletId, - notes: &[ShieldedNote], - ) -> Result<(), PlatformWalletError> { - self.mark_notes_spent(id, notes).await - } +/// Promote a successful broadcast: mark the notes spent (which +/// also clears any matching pending reservation, see +/// [`SubwalletState::mark_spent`]) and queue the changeset for +/// the host persister. +async fn finalize_pending( + store: &Arc>, + persister: Option<&WalletPersister>, + wallet_id: WalletId, + id: SubwalletId, + notes: &[ShieldedNote], +) -> Result<(), PlatformWalletError> { + mark_notes_spent(store, persister, wallet_id, id, notes).await +} - /// Roll back a reservation when the broadcast / wait fails. - /// Best-effort and doesn't surface its own errors — the - /// caller is already returning the broadcast error. - async fn cancel_pending(&self, id: SubwalletId, notes: &[ShieldedNote]) { - let mut store = self.store.write().await; - for note in notes { - if let Err(e) = store.clear_pending(id, ¬e.nullifier) { - tracing::warn!( - error = %e, - "cancel_pending: clear_pending failed; the next nullifier sync will reconcile" - ); - } +/// Roll back a reservation when the broadcast / wait fails. +/// Best-effort and doesn't surface its own errors — the caller +/// is already returning the broadcast error. +async fn cancel_pending( + store: &Arc>, + id: SubwalletId, + notes: &[ShieldedNote], +) { + let mut store = store.write().await; + for note in notes { + if let Err(e) = store.clear_pending(id, ¬e.nullifier) { + tracing::warn!( + error = %e, + "cancel_pending: clear_pending failed; the next nullifier sync will reconcile" + ); } } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs index f89392a38a8..870713a8dd1 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs @@ -1,8 +1,8 @@ //! Shielded note + nullifier synchronization. //! -//! Phase-2b shape: the heavy lifting lives in three free -//! functions that take a flat `&[(SubwalletId, AccountViewingKeys)]` -//! slice and drive a single network-wide SDK fetch per pass: +//! The heavy lifting lives in three free functions that take a +//! flat `&[(SubwalletId, AccountViewingKeys)]` slice and drive a +//! single network-wide SDK fetch per pass: //! - [`sync_notes_across`] — fetches encrypted notes once, //! trial-decrypts against the union of every subwallet's IVK, //! appends commitments to the shared tree exactly once per @@ -10,19 +10,19 @@ //! saves decrypted notes scoped per-`SubwalletId`. //! - [`check_nullifiers_across`] — privacy-preserving nullifier //! scan per subwallet (the SDK's nullifier-scan API is -//! per-checkpoint, so it stays per-subwallet, but no longer -//! per-`ShieldedWallet`). +//! per-checkpoint, so it stays per-subwallet). //! - [`balances_across`] — pure unspent-balance read against //! the shared store. //! -//! Per-wallet [`ShieldedWallet`] methods (`sync_notes`, -//! `check_nullifiers`, `balances`) remain as thin delegators -//! that build the one-wallet's slice and call the free -//! functions; the full-wallet `sync` orchestrator was removed -//! in Phase 4d.2 (the coordinator's `sync` is now the only -//! entry point that runs all three in sequence). Phase 4d.3 -//! removes the remaining delegators along with `ShieldedWallet` -//! itself. +//! [`NetworkShieldedCoordinator::sync`] drives all three in +//! sequence against the union of every registered subwallet. +//! Per-wallet `PlatformWallet` shielded methods read from the +//! same store via the coordinator handle they're handed at +//! call time (post-Phase-4d.3 — no more `ShieldedWallet` +//! wrapper). +//! +//! [`NetworkShieldedCoordinator::sync`]: +//! super::coordinator::NetworkShieldedCoordinator::sync use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; @@ -35,7 +35,6 @@ use tracing::{debug, info, warn}; use super::keys::AccountViewingKeys; use super::store::{ShieldedStore, SubwalletId}; -use super::ShieldedWallet; use crate::changeset::ShieldedChangeSet; use crate::error::PlatformWalletError; @@ -468,7 +467,7 @@ pub(super) async fn check_nullifiers_across( /// Multi-subwallet unspent-balance snapshot. Pure read against /// the shared store — does not trigger a sync. -pub(super) async fn balances_across( +pub(crate) async fn balances_across( store: &Arc>, subwallets: &[(SubwalletId, AccountViewingKeys)], ) -> Result, PlatformWalletError> { @@ -483,77 +482,6 @@ pub(super) async fn balances_across( Ok(out) } -impl ShieldedWallet { - /// Sync encrypted notes from Platform across every bound - /// account of this single wallet. - /// - /// Delegates to [`sync_notes_across`] under the hood with - /// the wallet's own subwallets — Phase 2b kept this surface - /// for backward-compat with existing per-wallet callers - /// (tests, the fallback path in `ShieldedSyncManager` when no - /// coordinator has been configured). Phase 4 removes this - /// method along with `ShieldedWallet` itself; the coordinator - /// becomes the only sync entry point. - pub async fn sync_notes(&self) -> Result { - let account_indices = self.account_indices(); - if account_indices.is_empty() { - return Ok(SyncNotesResult::default()); - } - let subwallets: Vec<(SubwalletId, AccountViewingKeys)> = account_indices - .iter() - .map(|&account| { - let id = self.subwallet_id(account); - let views = self.keys_for(account)?.viewing_keys(); - Ok::<_, PlatformWalletError>((id, views)) - }) - .collect::>()?; - - let result = sync_notes_across(&self.sdk, &self.store, &subwallets).await?; - // Fold per-subwallet counts back into per-account for - // this wallet, and queue the consolidated changeset on - // the wallet's own persister. - let new_notes_per_account = result.per_account_for(self.wallet_id); - self.queue_shielded_changeset(result.changeset); - Ok(SyncNotesResult { - new_notes_per_account, - total_scanned: result.total_scanned, - }) - } - - /// Check nullifier status for unspent notes across every - /// bound account on this single wallet. Spent notes are - /// marked per-subwallet. - /// - /// Delegates to [`check_nullifiers_across`]; see - /// [`sync_notes`](Self::sync_notes) for the Phase 4 deletion - /// plan that removes this method along with `ShieldedWallet`. - pub async fn check_nullifiers(&self) -> Result, PlatformWalletError> { - let account_indices = self.account_indices(); - if account_indices.is_empty() { - return Ok(BTreeMap::new()); - } - let subwallets: Vec<(SubwalletId, AccountViewingKeys)> = account_indices - .iter() - .map(|&account| { - let id = self.subwallet_id(account); - let views = self.keys_for(account)?.viewing_keys(); - Ok::<_, PlatformWalletError>((id, views)) - }) - .collect::>()?; - - let (per_sub, changeset) = - check_nullifiers_across(&self.sdk, &self.store, &subwallets).await?; - self.queue_shielded_changeset(changeset); - // Fold per-subwallet counts back into per-account for - // this wallet's caller shape. - Ok(per_sub - .into_iter() - .filter(|(id, _)| id.wallet_id == self.wallet_id) - .map(|(id, c)| (id.account_index, c)) - .collect()) - } -} - /// One decrypted note discovered during a sync pass. #[derive(Clone)] struct DiscoveredNote { From 6e66e65f39ab2da9487b5b691b164b934926e4ee Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 21 May 2026 00:05:10 +0700 Subject: [PATCH 63/78] fix(platform-wallet): shielded spend witness + lifecycle regressions from Phase 2b MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes to the shared-tree shielded coordinator, the first verified end-to-end against a live simulator wallet (a shielded transfer that previously failed with 'Merkle witness unavailable' now completes and debits the balance). 1. Witness bug — mark every commitment, not just owned ones The shared commitment tree appended a position as Marked only if the owning wallet's IVK recognized it in that very sync pass. With multiple wallets sharing one tree and binding at different times, a note appended before its owner bound stayed Ephemeral forever (shardtree has no retroactive marking), so the note showed as balance but couldn't be witnessed at spend time. Confirmed on the user's regtest wallet: two wallets ('Reg' owns nothing, 'Wallet2' owns the notes); 'Reg' synced first and appended every commitment Ephemeral. `sync_notes_across` now appends every position with `marked = true` — the tree is a chain-wide structure, so it must be witness-complete regardless of bind order. Per-wallet ownership is still tracked separately in the per-SubwalletId note store, so privacy/accounting is unchanged. Regression test reproduces the exact failing tree shape (6 commitments, single checkpoint, persist+reload) and asserts every position witnesses. 2. [BLOCKING] remove_wallet now detaches shielded state `PlatformWalletManager::remove_wallet` never called `coordinator.unregister_wallet`, so a deleted wallet's SubwalletId→AccountViewingKeys + cloned WalletPersister stayed registered and the next sync kept fetching / re-persisting its private notes. Now unregisters on remove. `unregister_wallet` also purges the per-subwallet store state (notes, watermarks, nullifier checkpoints) via the new `ShieldedStore::purge_wallet`. 3. [BLOCKING] clear() now purges per-subwallet store state `NetworkShieldedCoordinator::clear()` emptied only the registries, leaving `FileBackedShieldedStore::subwallets` (notes + `last_synced_note_index`) intact. A same-session re-bind after Clear resumed from the stale watermark, reported caught-up, and never re-emitted notes until process restart. `clear()` now calls the new `ShieldedStore::purge_all_subwallets`, delivering the documented 'resync from index 0' contract in-process. Also addressed from the review: - shielded_shield FFI no longer transmutes the host signer borrow to `&'static`; round-trips the pointer through `usize` and re-materializes the borrow inside the worker task (avoids a latent UAF / signing-oracle hazard if block_on_worker ever stops being synchronous). Param retyped `*mut`→`*const` to match read-only semantics. New trait methods `ShieldedStore::{purge_wallet, purge_all_subwallets}` implemented for both InMemoryShieldedStore and FileBackedShieldedStore; the shared SQLite commitment tree is deliberately left intact by both (chain-wide cache, not per-wallet). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/shielded_send.rs | 30 ++++--- .../src/manager/wallet_lifecycle.rs | 15 ++++ .../src/wallet/shielded/coordinator.rs | 49 +++++++--- .../src/wallet/shielded/file_store.rs | 89 +++++++++++++++++++ .../src/wallet/shielded/store.rs | 43 ++++++++- .../src/wallet/shielded/sync.rs | 38 +++++--- 6 files changed, 226 insertions(+), 38 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 868bb88b86c..12c15a0c7a1 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -257,7 +257,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_withdraw( /// # Safety /// - `wallet_id_bytes` must point to 32 readable bytes. /// - `signer_address_handle` must be a valid, non-destroyed -/// `*mut SignerHandle` that outlives this call and points at a +/// `*const SignerHandle` that outlives this call and points at a /// `VTableSigner` with the callback variant (the native variant /// doesn't satisfy `Signer`). #[no_mangle] @@ -267,7 +267,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_shield( shielded_account: u32, payment_account: u32, amount: u64, - signer_address_handle: *mut SignerHandle, + signer_address_handle: *const SignerHandle, ) -> PlatformWalletFFIResult { check_ptr!(wallet_id_bytes); check_ptr!(signer_address_handle); @@ -280,17 +280,17 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_shield( Err(result) => return result, }; - // SAFETY: the caller retains ownership of the signer handle - // and guarantees it outlives this call. We block until the - // worker future completes, so the `'static` lifetime we paint - // on the borrow does not actually outlive the host's handle. - // `VTableSigner` is `Send + Sync` per its `unsafe impl` in - // rs-sdk-ffi, so `&'static VTableSigner` is automatically - // `Send + 'static` — exactly what `block_on_worker` needs. - let address_signer: &'static VTableSigner = - std::mem::transmute::<&VTableSigner, &'static VTableSigner>( - &*(signer_address_handle as *const VTableSigner), - ); + // Round-trip the signer pointer through `usize` so the worker + // future captures only plain `Send + 'static` data and + // re-materializes the borrow INSIDE the task — never a + // fabricated `&'static` borrow of a host-owned vtable across + // the FFI boundary. The caller's documented contract is that + // the handle outlives this call, and `block_on_worker` blocks + // the calling frame until the task completes, so the borrow is + // valid for the task's whole lifetime. Avoids the latent UAF / + // signing-oracle hazard if `block_on_worker` ever stops being + // synchronous (cancellation, timeout, alternate executor). + let signer_addr = signer_address_handle as usize; // Run the proof on a worker thread (8 MB stack). Halo 2 circuit // synthesis recurses past the ~512 KB iOS dispatch-thread stack @@ -298,6 +298,10 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_shield( // `synthesize(... measure(pass))` call when polled on the // calling thread. let result = block_on_worker(async move { + // SAFETY: re-materialize the borrow under the caller's + // documented lifetime contract; valid for the duration of + // this synchronously-awaited task. + let address_signer: &VTableSigner = &*(signer_addr as *const VTableSigner); let prover = CachedOrchardProver::new(); wallet .shielded_shield_from_account( diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index a77d9d1fb28..ef158f1290d 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -370,6 +370,21 @@ impl PlatformWalletManager

{ .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(wallet_id)))? }; + // Detach the wallet's shielded state from the network + // coordinator. After the Phase-2b refactor the coordinator + // owns the per-`SubwalletId` viewing-key registry and the + // per-wallet `WalletPersister`; without this call a deleted + // wallet's shielded notes keep getting fetched, + // trial-decrypted, and re-persisted through the stale + // persister on the next `coordinator.sync()` pass — + // resurrecting private shielded history on disk after the + // host believed the wallet was gone. Drops the registry + + // persister entries and the per-subwallet store state. + #[cfg(feature = "shielded")] + if let Some(coordinator) = self.shielded_coordinator().await { + coordinator.unregister_wallet(*wallet_id).await; + } + for identity_id in &owned_identity_ids { self.identity_sync_manager .unregister_identity(identity_id) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index 3fead91f76e..98aec94dc71 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -223,16 +223,31 @@ impl NetworkShieldedCoordinator { } /// Remove every account belonging to `wallet_id` from the - /// coordinator's registry and drop the persister handle. - /// No-op if the wallet wasn't registered. Called when a - /// wallet is unregistered from the manager or when its - /// shielded binding is cleared. + /// coordinator's registry, drop the persister handle, and + /// purge the wallet's per-subwallet store state (decrypted + /// notes, spent marks, `last_synced_note_index`, nullifier + /// checkpoints). The shared commitment tree is left intact — + /// it's a chain-wide structure, not per-wallet. No-op for + /// parts that weren't present. Called when a wallet is + /// removed from the manager. + /// + /// Purging the store watermark matters: without it, a later + /// re-bind of the same wallet would resume from the stale + /// `last_synced_note_index` and silently skip re-emitting its + /// notes to the host. pub async fn unregister_wallet(&self, wallet_id: WalletId) { self.accounts .write() .await .retain(|id, _| id.wallet_id != wallet_id); self.persisters.write().await.remove(&wallet_id); + if let Err(e) = self.store.write().await.purge_wallet(wallet_id) { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "Failed to purge per-subwallet store state on unregister" + ); + } } /// Currently-registered subwallet ids (snapshot, ascending @@ -319,17 +334,28 @@ impl NetworkShieldedCoordinator { Ok(()) } - /// Drop every wallet registration and reset the cooldown - /// stamp. The single SQLite handle (commitment tree) stays - /// open — Clear semantics on the host side are "wipe my - /// persistence and start re-syncing from index 0 on the - /// shared tree", not "blow away the chain-wide cache". + /// Drop every wallet registration, purge all per-subwallet + /// store state (notes, spent marks, sync watermarks, + /// nullifier checkpoints), and reset the cooldown stamp. The + /// single SQLite handle (commitment tree) stays open — Clear + /// semantics on the host side are "wipe my persistence and + /// start re-syncing from index 0 on the shared tree", not + /// "blow away the chain-wide cache". + /// + /// Purging the in-memory `subwallets` store is what actually + /// delivers the "re-sync from index 0" contract: the sync + /// pass derives `already_have` from each subwallet's + /// `last_synced_note_index`, so leaving stale watermarks + /// behind would make a same-session re-bind report + /// caught-up and never re-emit notes to the host (it would + /// only work after a process restart that drops the + /// in-memory state). Clearing it here closes that gap. /// /// Used by [`platform_wallet_manager_shielded_clear`] (the /// host's Clear button). The host then wipes its own /// per-wallet persistence (e.g. SwiftData rows) — Rust can't /// reach that layer — and the next `bind_shielded` call - /// repopulates the registries. + /// repopulates the registries and resyncs from scratch. /// /// Resets the cooldown to `None` so the first post-clear /// background sync pass runs immediately rather than honoring @@ -340,6 +366,9 @@ impl NetworkShieldedCoordinator { pub async fn clear(&self) { self.accounts.write().await.clear(); self.persisters.write().await.clear(); + if let Err(e) = self.store.write().await.purge_all_subwallets() { + tracing::warn!(error = %e, "Failed to purge subwallet store state on clear"); + } if let Ok(mut g) = self.last_caught_up_at.lock() { *g = None; } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index 8e5398d0da1..808e77fba40 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -20,6 +20,7 @@ use std::sync::Mutex; use grovedb_commitment_tree::{ClientPersistentCommitmentTree, Position, Retention}; use super::store::{ShieldedNote, ShieldedStore, SubwalletId, SubwalletState}; +use crate::wallet::platform_wallet::WalletId; /// Error type for [`FileBackedShieldedStore`]. #[derive(Debug)] @@ -198,4 +199,92 @@ impl ShieldedStore for FileBackedShieldedStore { self.subwallets.entry(id).or_default().nullifier_checkpoint = Some((height, timestamp)); Ok(()) } + + fn purge_wallet(&mut self, wallet_id: WalletId) -> Result<(), Self::Error> { + // Per-subwallet note / watermark / checkpoint state is + // in-memory only (`subwallets`); the commitment tree in + // SQLite is chain-wide and intentionally left intact. + self.subwallets.retain(|id, _| id.wallet_id != wallet_id); + Ok(()) + } + + fn purge_all_subwallets(&mut self) -> Result<(), Self::Error> { + self.subwallets.clear(); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Unique temp path for a test tree (no `tempfile` dev-dep). + fn temp_tree_path(tag: &str) -> std::path::PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("shielded_tree_test_{tag}_{nanos}.sqlite")) + } + + /// Regression test for the "Shielded Merkle witness + /// unavailable" spend failure (multi-wallet shared-tree bug). + /// + /// Root cause: the shared commitment tree previously appended + /// commitments as `Ephemeral` unless the owning wallet's IVK + /// recognized them in that very sync pass. With multiple + /// wallets sharing one tree and binding at different times, a + /// note appended before its owner bound stayed Ephemeral + /// forever — shardtree has no retroactive marking — so the + /// balance showed but the spend failed to build a witness. + /// Observed on-disk symptom: every position un-witnessable + /// (missing internal nodes at `Level(2) index 0` / + /// `Level(1) index 2`). + /// + /// The fix: the shared tree marks EVERY position + /// (`append_commitment(.., true)`); per-wallet ownership is + /// tracked separately in the notes store. This test asserts + /// that a fully-marked tree witnesses every position — + /// including the rightmost (frontier) leaf whose sibling + /// doesn't exist yet — across a persist + reload cycle (the + /// cross-session round-trip a real wallet does between sync + /// and spend). + #[test] + fn all_marked_tree_witnesses_every_position_after_reload() { + let path = temp_tree_path("all_marked"); + let mut store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); + + // Mirror the real failing wallet's tree shape: 6 + // commitments, single checkpoint at the tip. The fix + // marks ALL of them regardless of ownership. + const N: u64 = 6; + for i in 0..N { + let mut cmx = [0u8; 32]; + cmx[0] = (i as u8) + 1; // distinct non-zero leaves + store.append_commitment(&cmx, true).unwrap(); + } + store.checkpoint_tree(N as u32).unwrap(); + + // Persist to SQLite and reopen — the wallet builds the + // tree in one app session and witnesses it (at spend + // time) in a later one. + drop(store); + let store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); + + let mut failures = Vec::new(); + for pos in 0..N { + match store.witness(pos) { + Ok(Some(_)) => {} + Ok(None) => failures.push(format!("position {pos}: witness returned None")), + Err(e) => failures.push(format!("position {pos}: {e}")), + } + } + + let _ = std::fs::remove_file(&path); + + assert!( + failures.is_empty(), + "every position in a fully-marked tree must be witnessable, but: {failures:?}" + ); + } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index e4e2485d118..874e155f8f4 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -27,6 +27,8 @@ use std::collections::{BTreeMap, BTreeSet}; use std::error::Error as StdError; use std::fmt; +use crate::wallet::platform_wallet::WalletId; + /// Identifies a single shielded "subwallet" — one Orchard account /// within one wallet. Used to scope notes, nullifier indices, and /// sync watermarks inside a [`ShieldedStore`] so a single store @@ -129,9 +131,17 @@ pub trait ShieldedStore: Send + Sync { /// Append a note commitment to the shared tree. /// - /// `marked` should be `true` if **any** tracked subwallet owns - /// this position. The tree only retains authentication paths - /// for marked positions; unmarked positions are pruned. + /// `marked` controls whether shardtree retains the + /// authentication path for this position (Marked) or prunes it + /// (Ephemeral). The sync path passes `true` for **every** + /// position: the tree is a chain-wide structure shared by all + /// wallets, and wallets bind at different times, so deciding + /// retention from "is this position owned right now" loses the + /// ability to witness a note whose owner binds later + /// (shardtree can't retroactively mark). Per-wallet ownership + /// is tracked separately in the per-`SubwalletId` note store. + /// The `marked` parameter is kept for store-level flexibility + /// and tests. fn append_commitment(&mut self, cmx: &[u8; 32], marked: bool) -> Result<(), Self::Error>; /// Create a tree checkpoint at the given identifier. @@ -170,6 +180,23 @@ pub trait ShieldedStore: Send + Sync { height: u64, timestamp: u64, ) -> Result<(), Self::Error>; + + // ── Per-subwallet lifecycle ──────────────────────────────────────── + + /// Drop ALL in-memory per-subwallet state (decrypted notes, + /// spent marks, `last_synced_note_index`, nullifier + /// checkpoints, pending reservations) for every subwallet + /// belonging to `wallet_id`. The shared commitment tree is + /// left untouched — it's a chain-wide structure, not + /// per-wallet. Used when a wallet is removed or its shielded + /// binding is cleared so a later re-bind resyncs from index 0 + /// rather than resuming behind the stale watermark. + fn purge_wallet(&mut self, wallet_id: WalletId) -> Result<(), Self::Error>; + + /// Drop ALL in-memory per-subwallet state for every subwallet + /// of every wallet. The shared commitment tree is left + /// untouched. Used by `NetworkShieldedCoordinator::clear()`. + fn purge_all_subwallets(&mut self) -> Result<(), Self::Error>; } // ── Per-subwallet bookkeeping ────────────────────────────────────────── @@ -402,6 +429,16 @@ impl ShieldedStore for InMemoryShieldedStore { self.subwallets.entry(id).or_default().nullifier_checkpoint = Some((height, timestamp)); Ok(()) } + + fn purge_wallet(&mut self, wallet_id: WalletId) -> Result<(), Self::Error> { + self.subwallets.retain(|id, _| id.wallet_id != wallet_id); + Ok(()) + } + + fn purge_all_subwallets(&mut self) -> Result<(), Self::Error> { + self.subwallets.clear(); + Ok(()) + } } #[cfg(test)] diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs index 870713a8dd1..08543ef5f70 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs @@ -24,7 +24,7 @@ //! [`NetworkShieldedCoordinator::sync`]: //! super::coordinator::NetworkShieldedCoordinator::sync -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeMap; use std::sync::Arc; use dash_sdk::platform::shielded::nullifier_sync::{NullifierSyncCheckpoint, NullifierSyncConfig}; @@ -252,17 +252,32 @@ pub(super) async fn sync_notes_across( } } - // Build the union of "owned" positions for tree marking. - let owned_positions: BTreeSet = decrypted_by_subwallet - .values() - .flat_map(|v| v.iter().map(|n| n.position)) - .collect(); - let mut store = store.write().await; - // Append every commitment to the shared tree exactly once - // per position. Skip positions already in the tree (re-scan - // after a partial chunk advance). + // Append every commitment to the shared tree exactly once per + // position, ALWAYS retained (`marked = true`). Skip positions + // already in the tree (re-scan after a partial chunk advance). + // + // Why mark every position rather than only owned ones: the + // commitment tree is a single chain-wide structure shared by + // every wallet on the network (the whole point of the + // coordinator refactor — one SQLite handle, not N). Ownership + // is decided per-pass by trial-decryption, but wallets bind at + // *different* times. If wallet A syncs first (driver IVK owns + // nothing) the positions get appended; when wallet B binds + // later and discovers its note at one of those positions, + // shardtree has no way to retroactively mark it — the auth + // path was already discarded as `Ephemeral`, and the note + // becomes permanently unwitnessable (balance shows, spend + // fails with "Merkle witness unavailable"). Marking every + // position makes the shared tree witness-complete regardless + // of bind ordering; per-wallet ownership is tracked + // separately in the per-`SubwalletId` notes store, so privacy + // / accounting is unaffected. The cost is retained auth paths + // for non-owned positions (O(commitments) storage); acceptable + // for correctness, and the shielded pool is small. A future + // optimization can prune auth paths for positions no live + // subwallet owns once all wallets have caught past them. let mut appended = 0u32; for (i, raw_note) in result.all_notes.iter().enumerate() { let global_pos = aligned_start + i as u64; @@ -273,9 +288,8 @@ pub(super) async fn sync_notes_across( raw_note.cmx.as_slice().try_into().map_err(|_| { PlatformWalletError::ShieldedSyncFailed("Invalid cmx length".into()) })?; - let is_ours = owned_positions.contains(&global_pos); store - .append_commitment(&cmx_bytes, is_ours) + .append_commitment(&cmx_bytes, true) .map_err(|e| PlatformWalletError::ShieldedTreeUpdateFailed(e.to_string()))?; appended += 1; } From fd934baa33d6efff320ebc55146747d1a06a2f61 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 21 May 2026 00:25:32 +0700 Subject: [PATCH 64/78] fix(platform-wallet): rebind is replace-not-merge; reject amount==0 in shield MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from the 6e66e65f review. bind_shielded rebind — replace, not merge (the doc contract) `register_wallet` replaces the coordinator's `accounts` entries for the wallet, but never purged the store's per-`SubwalletId` state. A same-process rebind therefore left stale `last_synced_note_index` watermarks, orphaned accounts dropped from the new bind set, and abandoned `pending_nullifiers` reservations behind — the last of which can make note selection skip otherwise-spendable notes. `bind_shielded` now calls `coordinator.unregister_wallet(self.wallet_id)` (which purges store state via `purge_wallet`) before `register_wallet`, so a rebind is a clean replace; no-op on first bind. `restore_for_wallet` then re-hydrates from the persisted snapshot as before. shielded_shield_from_account — reject amount == 0 With `amount == 0` the selection loop exits on iteration 0 (claim 0 >= 0) and the post-loop `0 < 0` check doesn't fire, so an empty inputs map flowed into the ~30 s Halo 2 proof build and failed deep + opaquely. Swift's UI guards this, but other FFI hosts didn't — also a minor prover-thread DoS surface. Guard at the boundary with ShieldedBuildError. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/wallet/platform_wallet.rs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 129d72e357f..2cc62d438db 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -367,6 +367,17 @@ impl PlatformWallet { *slot = Some(keys); drop(slot); + // Rebind is replace-not-merge (the doc contract above). + // `register_wallet` replaces the coordinator's `accounts` + // entries for this wallet, but it does NOT touch the + // store's per-`SubwalletId` state — so a same-process + // rebind would otherwise leave stale watermarks, orphaned + // accounts dropped from the new bind set, and abandoned + // `pending_nullifiers` reservations behind (the latter can + // make note selection skip spendable notes). Unregister + // first to purge that state; it's a no-op on first bind. + coordinator.unregister_wallet(self.wallet_id).await; + // Register on the coordinator BEFORE restoring so the // restore path's "is this account registered?" gate // sees this wallet's subwallets. @@ -708,6 +719,18 @@ impl PlatformWallet { S: dpp::identity::signer::Signer + Send + Sync, P: dpp::shielded::builder::OrchardProver, { + // Reject zero amount at the boundary. With `amount == 0` + // the selection loop exits immediately (claim 0 >= 0) and + // the post-loop insufficient-balance check (`0 < 0`) + // doesn't fire, so an empty inputs map would otherwise + // flow into the ~30 s Halo 2 proof build and fail deep and + // opaquely. Non-Swift FFI hosts don't have the UI guard. + if amount == 0 { + return Err(PlatformWalletError::ShieldedBuildError( + "amount must be > 0".to_string(), + )); + } + // The shield transition uses `DeductFromInput(0)` as its fee // strategy. drive-abci interprets that as "after each input // address has had its `claim` deducted, take the fee out of From 76ad3b5f7b56c26d2747cc59f92f1d6900b84d6d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 21 May 2026 06:23:08 +0700 Subject: [PATCH 65/78] fix(platform-wallet): gate shielded tree appends by tree size, not watermark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repeat shielded spends failed with "Anchor not found in the recorded anchors tree" (#3703). Root cause: sync_notes_across gated the commitment-tree append on the minimum per-subwallet watermark, so a re-fetch from a chunk boundary (or a lagging / late-bound subwallet) re-appended positions the tree already held — duplicating leaves, corrupting shardtree's internal nodes, and producing per-position witnesses that resolved against roots Platform never recorded. The checkpoint id (aligned_start + total_notes_scanned) could also collide across consecutive passes. Fix: add ShieldedStore::tree_size() (via max_leaf_position) and gate the append on the tree's own leaf count — append-once, global. Checkpoint at the true post-append tree size, which only grows so the id is strictly monotonic. Save decrypted notes per-subwallet watermark so a caught-up subwallet doesn't re-derive nullifiers and the new-note count stays honest. Add a tree_size persist/reload regression test. Verified on regtest: two consecutive shielded transfers now succeed and the rebuilt tree has consistent checkpoints (id == leaf count) with no double-append. --- .../src/wallet/shielded/file_store.rs | 54 +++++++++++ .../src/wallet/shielded/store.rs | 18 ++++ .../src/wallet/shielded/sync.rs | 94 ++++++++++++++----- 3 files changed, 140 insertions(+), 26 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index 808e77fba40..6eaf1803675 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -166,6 +166,19 @@ impl ShieldedStore for FileBackedShieldedStore { .map_err(|e| FileShieldedStoreError(format!("witness({position}): {e}"))) } + fn tree_size(&self) -> Result { + let tree = self + .tree + .lock() + .map_err(|e| FileShieldedStoreError(format!("tree mutex poisoned: {e}")))?; + let size = tree + .max_leaf_position() + .map_err(|e| FileShieldedStoreError(format!("read tree size: {e}")))? + .map(|p| u64::from(p) + 1) + .unwrap_or(0); + Ok(size) + } + fn last_synced_note_index(&self, id: SubwalletId) -> Result { Ok(self .subwallets @@ -287,4 +300,45 @@ mod tests { "every position in a fully-marked tree must be witnessable, but: {failures:?}" ); } + + /// `tree_size()` is the append gate the multi-subwallet sync + /// relies on to stay idempotent (it appends only positions + /// `>= tree_size`). If the count were wrong — or didn't survive + /// the persist + reload the wallet does between sessions — a + /// re-fetch from a chunk boundary would double-append and + /// corrupt the tree ("Anchor not found in the recorded anchors + /// tree" on the next spend). This asserts the count is exact + /// from empty, after appends, and across a reopen. + #[test] + fn tree_size_tracks_leaf_count_across_reload() { + let path = temp_tree_path("tree_size"); + let mut store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); + + assert_eq!(store.tree_size().unwrap(), 0, "empty tree has size 0"); + + const N: u64 = 6; + for i in 0..N { + let mut cmx = [0u8; 32]; + cmx[0] = (i as u8) + 1; + store.append_commitment(&cmx, true).unwrap(); + assert_eq!( + store.tree_size().unwrap(), + i + 1, + "size must equal leaves appended so far" + ); + } + store.checkpoint_tree(N as u32).unwrap(); + + drop(store); + let store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); + + let size = store.tree_size().unwrap(); + let _ = std::fs::remove_file(&path); + + assert_eq!( + size, N, + "tree size must survive persist + reload — the append gate \ + reads it on cold start to avoid re-appending existing leaves" + ); + } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index 874e155f8f4..2656f02f7d4 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -158,6 +158,20 @@ pub trait ShieldedStore: Send + Sync { position: u64, ) -> Result, Self::Error>; + /// Number of leaves currently in the shared commitment tree + /// (= highest appended position + 1, or 0 when empty). + /// + /// This is the append watermark for the tree itself, distinct + /// from any per-subwallet `last_synced_note_index`. The sync + /// path gates [`Self::append_commitment`] on this value — never + /// on a per-subwallet watermark — so a re-fetch from a chunk + /// boundary (forced when a lagging subwallet rewinds the fetch + /// start) re-appends nothing already in the tree. Double-append + /// corrupts the shardtree's internal nodes and makes per-position + /// witnesses resolve against inconsistent roots ("Anchor not + /// found in the recorded anchors tree" at spend time). + fn tree_size(&self) -> Result; + // ── Sync state (per-subwallet) ───────────────────────────────────── /// The last global note index that was synced for `id`. @@ -396,6 +410,10 @@ impl ShieldedStore for InMemoryShieldedStore { )) } + fn tree_size(&self) -> Result { + Ok(self.commitments.len() as u64) + } + fn last_synced_note_index(&self, id: SubwalletId) -> Result { Ok(self .subwallets diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs index 08543ef5f70..f123b58d397 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs @@ -112,9 +112,11 @@ impl ShieldedSyncSummary { pub struct MultiSyncNotesResult { /// Per-subwallet count of new notes discovered in this pass. pub per_subwallet_new_notes: BTreeMap, - /// New positions observed this pass — `(aligned_start + - /// total_notes_scanned).saturating_sub(already_have)`. See - /// [`SyncNotesResult::total_scanned`] for the rationale. + /// New positions observed this pass — the number of + /// commitments actually appended to the shared tree (positions + /// `>= tree_size`). Re-scanned positions the tree already held + /// are excluded. See [`SyncNotesResult::total_scanned`] for the + /// rationale. pub total_scanned: u64, /// Accumulated persistence changeset spanning every touched /// subwallet. The caller decides whether to queue it on the @@ -171,26 +173,43 @@ pub(super) async fn sync_notes_across( return Ok(MultiSyncNotesResult::default()); } - // Snapshot the lowest per-subwallet watermark — the canonical - // tree-fetch start. Defensive `min` across subwallets: in - // practice we wipe-and-re-sync on account add so every - // subwallet shares the same watermark. - let already_have = { + // Snapshot each subwallet's watermark and the shared tree's + // current leaf count. Two distinct quantities drive the rest + // of this pass: + // * `already_have` = the LOWEST per-subwallet watermark. + // Drives the SDK fetch start so a lagging (e.g. late-bound) + // subwallet's notes are re-scanned. A caught-up subwallet + // shares the same watermark, so in the common case this is + // just that one value. + // * `tree_size` = the number of commitments already in the + // shared tree. Drives the append gate (below) so the + // re-fetch from a chunk boundary doesn't double-append + // positions the tree already holds. + // The per-subwallet `watermarks` map gates note saving so a + // caught-up subwallet doesn't re-derive nullifiers for notes + // it already stored, while a lagging one still saves from its + // own start. + let (watermarks, already_have, tree_size) = { let store = store.read().await; + let mut watermarks: BTreeMap = BTreeMap::new(); let mut min_idx: Option = None; for (id, _) in subwallets { let idx = store .last_synced_note_index(*id) .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + watermarks.insert(*id, idx); min_idx = Some(min_idx.map_or(idx, |m| m.min(idx))); } - min_idx.unwrap_or(0) + let tree_size = store + .tree_size() + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + (watermarks, min_idx.unwrap_or(0), tree_size) }; let aligned_start = (already_have / CHUNK_SIZE) * CHUNK_SIZE; info!( subwallets = subwallets.len(), - already_have, aligned_start, "Starting multi-subwallet shielded note sync" + already_have, aligned_start, tree_size, "Starting multi-subwallet shielded note sync" ); // Fetch + trial-decrypt with the FIRST subwallet's IVK in @@ -256,7 +275,16 @@ pub(super) async fn sync_notes_across( // Append every commitment to the shared tree exactly once per // position, ALWAYS retained (`marked = true`). Skip positions - // already in the tree (re-scan after a partial chunk advance). + // already in the tree (`global_pos < tree_size`) — the SDK + // re-fetches from a chunk boundary every pass while the buffer + // chunk is mutable, and a lagging subwallet rewinds that start + // even further, so `all_notes` routinely overlaps positions the + // tree already holds. Gating on the tree's own leaf count (NOT + // a per-subwallet watermark) is what makes the append + // idempotent: re-appending an existing position duplicates a + // leaf, corrupts shardtree's internal nodes, and makes + // per-position witnesses resolve against roots Platform never + // recorded ("Anchor not found in the recorded anchors tree"). // // Why mark every position rather than only owned ones: the // commitment tree is a single chain-wide structure shared by @@ -281,7 +309,7 @@ pub(super) async fn sync_notes_across( let mut appended = 0u32; for (i, raw_note) in result.all_notes.iter().enumerate() { let global_pos = aligned_start + i as u64; - if global_pos < already_have { + if global_pos < tree_size { continue; } let cmx_bytes: [u8; 32] = @@ -295,17 +323,21 @@ pub(super) async fn sync_notes_across( } if appended > 0 { - // Use the high-water position as the checkpoint id (not - // `result.next_start_index`, which rewinds to the last - // partial chunk's start and can therefore be the same - // value across consecutive syncs). shardtree's - // `checkpoint(id)` silently dedups duplicate ids; a - // non-monotonic id leaves depth-0 pinned at the first - // checkpoint while later appends extend the tree past - // it, and the witness at depth 0 then reflects a state - // Platform never recorded. - let new_index = aligned_start + result.total_notes_scanned; - let checkpoint_id: u32 = new_index.try_into().unwrap_or(u32::MAX); + // Checkpoint at the tree's true post-append leaf count. The + // tree only ever grows, so this id is strictly monotonic + // and collision-free across consecutive syncs — unlike + // `result.next_start_index` (rewinds to the last partial + // chunk's start) or `aligned_start + total_notes_scanned` + // (can repeat when a lagging subwallet rewinds the fetch). + // shardtree's `checkpoint(id)` silently dedups duplicate + // ids; a non-monotonic id pins depth-0 at the first + // checkpoint while later appends extend the tree past it, + // so the depth-0 witness then reflects a state Platform + // never recorded. + let new_tree_size = store + .tree_size() + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + let checkpoint_id: u32 = new_tree_size.try_into().unwrap_or(u32::MAX); store .checkpoint_tree(checkpoint_id) .map_err(|e| PlatformWalletError::ShieldedTreeUpdateFailed(e.to_string()))?; @@ -323,8 +355,16 @@ pub(super) async fn sync_notes_across( let Some((_, views)) = subwallets.iter().find(|(s, _)| s == id) else { continue; }; + // Gate on THIS subwallet's own watermark (not the network + // min): a caught-up subwallet skips re-deriving nullifiers + // for notes it already stored, while a lagging one still + // saves everything from its own start. `save_note` is an + // idempotent overwrite-by-nullifier, so a stray re-save is + // harmless — but gating per-subwallet keeps the + // `per_subwallet_new_notes` count honest. + let sub_watermark = watermarks.get(id).copied().unwrap_or(0); for d in discovered { - if d.position < already_have { + if d.position < sub_watermark { continue; } let nullifier = d.note.nullifier(&views.full_viewing_key); @@ -374,10 +414,12 @@ pub(super) async fn sync_notes_across( new_index, "Multi-subwallet shielded sync finished" ); - let scanned_new = (aligned_start + result.total_notes_scanned).saturating_sub(already_have); + // Genuinely new positions this pass = what we actually added + // to the shared tree (positions `>= tree_size`). Re-scanned + // positions the tree already held are excluded. Ok(MultiSyncNotesResult { per_subwallet_new_notes, - total_scanned: scanned_new, + total_scanned: appended as u64, changeset, }) } From 407e0da9de29b09a64f64dd3155fef026250c983 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 21 May 2026 06:23:28 +0700 Subject: [PATCH 66/78] feat(swift-example-app): aggregate shielded balance + notes-synced on Sync Status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the Shielded Sync Status card to show the network-wide picture rather than a single bound wallet: Total Shielded Balance summed across every wallet's unspent notes, and a Notes Synced watermark (max last-synced index across subwallets) so large pools show sync progress. Drop the Orchard address row and the per-account state breakdown; sync status, counters, and actions are unchanged. Display-only via SwiftData @Query (network-wide, no wallet scoping) — no FFI. --- .../Core/Views/CoreContentView.swift | 175 +++++++----------- 1 file changed, 69 insertions(+), 106 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index 5e40905c3c8..51d806111ac 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -424,48 +424,13 @@ var body: some View { Spacer() } - // Shielded balance - HStack { - Text("Shielded Balance") - .font(.subheadline) - .foregroundColor(.secondary) - Spacer() - if shieldedService.shieldedBalance > 0 { - Text(formatCredits(shieldedService.shieldedBalance)) - .font(.subheadline) - .fontWeight(.medium) - } else { - Text("0") - .font(.subheadline) - .foregroundColor(.secondary) - } - } - - // Orchard address - if let address = shieldedService.orchardDisplayAddress { - HStack { - Text("Orchard Address") - .font(.subheadline) - .foregroundColor(.secondary) - Spacer() - Text(address.prefix(12) + "..." + address.suffix(8)) - .font(.caption) - .fontWeight(.medium) - .foregroundColor(.purple) - } - } - - // Per-account synced-index + persisted balance, - // read straight from SwiftData. The persisted - // balance is a cross-check against - // `ShieldedService.shieldedBalance` (which is - // mirrored from Rust sync events): when the two - // disagree, the divergence is in the event path; - // when both agree at zero while notes exist, the - // divergence is on the persister / restore side. - ShieldedSyncIndexRows( - boundWalletId: shieldedService.boundWalletId - ) + // Aggregate shielded balance + notes-synced + // watermark across every wallet/account on disk. + // Read straight from SwiftData (network-wide, no + // wallet scoping) so the figures survive restart + // and reflect the whole pool rather than a single + // bound wallet. + ShieldedNetworkSummaryRows() // Sync counters since launch — `total_scanned` // is the wire-level encrypted-note count (every @@ -1251,85 +1216,83 @@ extension CoreContentView { } } -// MARK: - ShieldedSyncIndexRows +// MARK: - ShieldedNetworkSummaryRows -/// Per-account commitment-tree + persisted-balance summary for the -/// currently-bound shielded wallet. -/// -/// Two diagnostics that don't otherwise have a UI surface: +/// Network-wide shielded summary: aggregate unspent balance and the +/// notes-synced watermark across **every** wallet/account on disk. /// -/// * **Synced Index** — global note index up to which each -/// subwallet has appended commitments to the local tree. -/// Useful when local and Platform anchors disagree -/// (anchor-mismatch / ShieldedTreeDiverged at spend time). +/// Both figures are read **directly from SwiftData** (no wallet +/// scoping) rather than from the `ShieldedService.shieldedBalance` +/// mirror that's updated per-bound-wallet from sync events, so they +/// survive restart and reflect the entire pool: /// -/// * **Balance (persisted)** — sum of unspent -/// `PersistentShieldedNote.value` for each bound subwallet, -/// read **directly from SwiftData** rather than via the -/// `ShieldedService.shieldedBalance` mirror that's updated -/// from sync events. When the two numbers disagree, the bug -/// is in the Rust→Swift event path; when they agree at zero -/// while notes exist, the bug is in the persister callback or -/// the cold-start restore. +/// * **Total Shielded Balance** — sum of `value` over every +/// unspent `PersistentShieldedNote`, in credits. /// -/// The account label is suppressed when only one account is -/// bound — the redundant `acct 0` is just visual noise in the -/// single-account default. -private struct ShieldedSyncIndexRows: View { - let boundWalletId: Data? - - @Query(sort: [SortDescriptor(\PersistentShieldedSyncState.accountIndex)]) - private var syncStateRows: [PersistentShieldedSyncState] - +/// * **Notes Synced** — the highest `lastSyncedIndex` across all +/// `PersistentShieldedSyncState` rows. The Orchard commitment +/// tree is chain-wide and shared by every wallet/account, so +/// each subwallet advances toward the same tip; the max is the +/// furthest-scanned position and climbs as sync progresses +/// across tens of thousands of notes. +private struct ShieldedNetworkSummaryRows: View { @Query private var allNotes: [PersistentShieldedNote] + @Query private var syncStates: [PersistentShieldedSyncState] + + /// Sum of `value` over every unspent note, in credits. + private var totalUnspentCredits: UInt64 { + allNotes.lazy.filter { !$0.isSpent }.reduce(UInt64(0)) { $0 &+ $1.value } + } - private var scopedSyncRows: [PersistentShieldedSyncState] { - guard let id = boundWalletId else { return [] } - return syncStateRows.filter { $0.walletId == id } + /// Furthest-scanned commitment-tree index across all subwallets. + private var notesSynced: UInt64 { + syncStates.map(\.lastSyncedIndex).max() ?? 0 } - /// Sum of `value` over unspent notes for `(boundWalletId, - /// account)`. Reads SwiftData directly — independent of the - /// in-memory shielded wallet's `balance_total()`. - private func persistedBalance(account: UInt32) -> UInt64 { - guard let id = boundWalletId else { return 0 } - return allNotes - .lazy - .filter { $0.walletId == id && $0.accountIndex == account && !$0.isSpent } - .reduce(UInt64(0)) { $0 &+ $1.value } + /// 1 DASH = 100,000,000,000 credits — matches `formatCredits`. + private func formatCredits(_ credits: UInt64) -> String { + let dash = Double(credits) / 100_000_000_000.0 + let formatter = NumberFormatter() + formatter.minimumFractionDigits = 0 + formatter.maximumFractionDigits = 8 + formatter.numberStyle = .decimal + formatter.groupingSeparator = "," + formatter.decimalSeparator = "." + if let formatted = formatter.string(from: NSNumber(value: dash)) { + return "\(formatted) DASH" + } + return String(format: "%.4f DASH", dash) } var body: some View { - if !scopedSyncRows.isEmpty { - VStack(spacing: 4) { - HStack { - Text("Per-Account State") + VStack(spacing: 8) { + // Aggregate unspent balance across all wallets. + HStack { + Text("Total Shielded Balance") + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + if totalUnspentCredits > 0 { + Text(formatCredits(totalUnspentCredits)) + .font(.subheadline) + .fontWeight(.medium) + } else { + Text("0") .font(.subheadline) .foregroundColor(.secondary) - Spacer() - } - let showAccountLabel = scopedSyncRows.count > 1 - ForEach(scopedSyncRows, id: \.accountIndex) { row in - HStack(spacing: 8) { - if showAccountLabel { - Text("acct \(row.accountIndex)") - .font(.caption) - .foregroundColor(.secondary) - } - Text("idx \(row.lastSyncedIndex)") - .font(.system(.caption, design: .monospaced)) - if row.hasNullifierCheckpoint { - Text("nf h \(row.nullifierCheckpointHeight)") - .font(.caption2) - .foregroundColor(.secondary) - } - Spacer() - Text("\(persistedBalance(account: row.accountIndex)) credits") - .font(.system(.caption, design: .monospaced)) - .fontWeight(.medium) - } } } + + // Notes-synced watermark — climbs as sync progresses. + HStack { + Text("Notes Synced") + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + Text(notesSynced, format: .number) + .font(.system(.caption, design: .monospaced)) + .fontWeight(.medium) + } } } } From e83524e1b44a081d8dcc90926c6a4c144244b262 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 21 May 2026 07:12:44 +0700 Subject: [PATCH 67/78] fix(platform-wallet): address shielded review feedback (Rust) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Spend flows: post-broadcast `mark_notes_spent` is now best-effort. A local spent-state write failure after a successful `broadcast_and_wait` no longer surfaces as a send failure (it warns; the next nullifier sync reconciles), so a successful spend can't be reported as failed and invite duplicate retries. - Sync: the tree checkpoint id hard-errors instead of saturating at `u32::MAX`, preserving the strict-monotonic-id guarantee the tree_size append gate depends on. - Sync: `MultiSyncNotesResult.total_scanned` keeps its documented wire-level scan-volume meaning (matching the exported FFI/Swift/UI "Scanned" contract) rather than the "positions appended" value the #3703 change had introduced. - FFI: validate the shielded load/free callback pairs before restore — a loader wired without its matching free callback leaked the host-allocated buffer on every load. - Extract `select_shield_inputs` (pure) + unit tests: dust-skip, exact-reserve, amount==total-reserve, multi-input accrual, and insufficient-balance. --- .../rs-platform-wallet-ffi/src/persistence.rs | 28 +++ .../src/wallet/platform_wallet.rs | 227 ++++++++++++------ .../src/wallet/shielded/operations.rs | 36 ++- .../src/wallet/shielded/sync.rs | 37 ++- 4 files changed, 247 insertions(+), 81 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 1ffb65dad73..072a0ea50ab 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -1336,6 +1336,34 @@ impl PlatformWalletPersistence for FFIPersister { let mut shielded_state = ShieldedSyncStartState::default(); + // Fail fast on a half-wired callback pair: a loader without + // its matching free callback leaks the host-allocated buffer + // on every successful load (the guard's `Drop` is a no-op + // when `free_fn` is `None`). + if self.callbacks.on_load_shielded_notes_fn.is_some() + != self.callbacks.on_load_shielded_notes_free_fn.is_some() + { + return Err( + "on_load_shielded_notes_fn and on_load_shielded_notes_free_fn must be \ + provided together" + .to_string() + .into(), + ); + } + if self.callbacks.on_load_shielded_sync_states_fn.is_some() + != self + .callbacks + .on_load_shielded_sync_states_free_fn + .is_some() + { + return Err( + "on_load_shielded_sync_states_fn and on_load_shielded_sync_states_free_fn \ + must be provided together" + .to_string() + .into(), + ); + } + // 1) notes if let Some(load_notes) = self.callbacks.on_load_shielded_notes_fn { let mut notes_ptr: *const ShieldedNoteRestoreFFI = std::ptr::null(); diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 2cc62d438db..086e83b1fc1 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -775,7 +775,7 @@ impl PlatformWallet { // sorted by address bytes — that determines BTreeMap // key order downstream and therefore which input ends // up at index 0. - let mut candidates: Vec<(dpp::address_funds::PlatformAddress, u64)> = account + let candidates: Vec<(dpp::address_funds::PlatformAddress, u64)> = account .addresses .addresses .values() @@ -793,73 +793,11 @@ impl PlatformWallet { } }) .collect(); - candidates.sort_by_key(|(addr, _)| *addr); - - // The address that will be the bundle's `input_0` must - // have balance > FEE_RESERVE so we can claim at least 1 - // credit while leaving the reserve untouched. Skip any - // leading dust address that can't satisfy that — the - // next address up will become input 0 instead. If - // every funded address is below the reserve, fail fast: - // the network would reject the broadcast on the - // boundary anyway, only after we've spent ~30 s - // building the Halo 2 proof. - let Some(viable_input_0) = candidates - .iter() - .position(|(_, balance)| *balance > FEE_RESERVE_CREDITS) - else { - let total: u64 = candidates.iter().map(|(_, b)| b).sum(); - return Err(PlatformWalletError::ShieldedInsufficientBalance { - available: total, - required: amount.saturating_add(FEE_RESERVE_CREDITS), - }); - }; - let usable: &[(dpp::address_funds::PlatformAddress, u64)] = - &candidates[viable_input_0..]; - - let total_usable: u64 = usable.iter().map(|(_, b)| b).sum(); - let needed = amount.saturating_add(FEE_RESERVE_CREDITS); - if total_usable < needed { - return Err(PlatformWalletError::ShieldedInsufficientBalance { - available: total_usable, - required: needed, - }); - } - - // Walk usable inputs in BTreeMap order, claiming only - // what's needed to cover `amount`. The fee reserve is - // taken off input 0's max claim so its post-claim - // remaining stays ≥ FEE_RESERVE_CREDITS for the - // network's `DeductFromInput(0)` step. - let mut chosen: std::collections::BTreeMap< - dpp::address_funds::PlatformAddress, - dpp::fee::Credits, - > = std::collections::BTreeMap::new(); - let mut accumulated_claim: u64 = 0; - for (i, (addr, balance)) in usable.iter().enumerate() { - if accumulated_claim >= amount { - break; - } - let max_claim = if i == 0 { - balance.saturating_sub(FEE_RESERVE_CREDITS) - } else { - *balance - }; - let still_need = amount - accumulated_claim; - let claim = max_claim.min(still_need); - if claim > 0 { - chosen.insert(*addr, claim); - accumulated_claim = accumulated_claim.saturating_add(claim); - } - } - - if accumulated_claim < amount { - return Err(PlatformWalletError::ShieldedInsufficientBalance { - available: accumulated_claim, - required: amount, - }); - } - chosen + // Selection rules live in `select_shield_inputs` (pure + + // unit-tested): sort by address, skip leading dust below the + // reserve, reserve fee headroom only on input 0, then claim + // in BTreeMap order up to `amount`. + select_shield_inputs(candidates, amount, FEE_RESERVE_CREDITS)? }; let guard = self.shielded_keys.read().await; @@ -1080,3 +1018,156 @@ impl DerefMut for WalletStateWriteGuard<'_> { .expect("wallet exists in guard") } } + +/// Select shield (Type 15) inputs from funded `(address, balance)` +/// candidates. +/// +/// Pure and deterministic so the selection rules are unit-testable +/// independent of the wallet manager — a future refactor can't silently +/// reintroduce the old `viable_input_0` dust/fee-reserve bug without +/// tripping a test. The rules: +/// * sort by address bytes — this fixes which input lands at index 0, +/// and the network deducts the transition fee from input 0 +/// (`DeductFromInput(0)`); +/// * skip any leading address with balance `<= fee_reserve` — input 0 +/// must keep at least `fee_reserve` unclaimed for the fee step; +/// * claim in BTreeMap order only up to `amount`, taking the reserve +/// headroom off input 0 alone. +/// +/// Errors with [`PlatformWalletError::ShieldedInsufficientBalance`] when +/// no viable input 0 exists, when usable balance can't cover +/// `amount + fee_reserve`, or when the walk can't accumulate `amount`. +#[cfg(feature = "shielded")] +fn select_shield_inputs( + mut candidates: Vec<(dpp::address_funds::PlatformAddress, u64)>, + amount: u64, + fee_reserve: u64, +) -> Result< + std::collections::BTreeMap, + PlatformWalletError, +> { + candidates.sort_by_key(|(addr, _)| *addr); + + let Some(viable_input_0) = candidates + .iter() + .position(|(_, balance)| *balance > fee_reserve) + else { + let total: u64 = candidates.iter().map(|(_, b)| b).sum(); + return Err(PlatformWalletError::ShieldedInsufficientBalance { + available: total, + required: amount.saturating_add(fee_reserve), + }); + }; + let usable = &candidates[viable_input_0..]; + + let total_usable: u64 = usable.iter().map(|(_, b)| b).sum(); + let needed = amount.saturating_add(fee_reserve); + if total_usable < needed { + return Err(PlatformWalletError::ShieldedInsufficientBalance { + available: total_usable, + required: needed, + }); + } + + let mut chosen: std::collections::BTreeMap< + dpp::address_funds::PlatformAddress, + dpp::fee::Credits, + > = std::collections::BTreeMap::new(); + let mut accumulated_claim: u64 = 0; + for (i, (addr, balance)) in usable.iter().enumerate() { + if accumulated_claim >= amount { + break; + } + let max_claim = if i == 0 { + balance.saturating_sub(fee_reserve) + } else { + *balance + }; + let still_need = amount - accumulated_claim; + let claim = max_claim.min(still_need); + if claim > 0 { + chosen.insert(*addr, claim); + accumulated_claim = accumulated_claim.saturating_add(claim); + } + } + + if accumulated_claim < amount { + return Err(PlatformWalletError::ShieldedInsufficientBalance { + available: accumulated_claim, + required: amount, + }); + } + Ok(chosen) +} + +#[cfg(all(test, feature = "shielded"))] +mod shield_input_selection_tests { + use super::*; + use dpp::address_funds::PlatformAddress; + + const RESERVE: u64 = 1_000_000_000; + + fn addr(b: u8) -> PlatformAddress { + PlatformAddress::P2pkh([b; 20]) + } + + #[test] + fn skips_leading_dust_address_below_reserve() { + // addr(1) sorts first but is dust (== reserve, not > reserve); + // addr(2) must become input 0. + let candidates = vec![(addr(1), RESERVE), (addr(2), 5 * RESERVE)]; + let chosen = select_shield_inputs(candidates, 2 * RESERVE, RESERVE).unwrap(); + assert!( + !chosen.contains_key(&addr(1)), + "dust leading address must be skipped" + ); + assert_eq!(chosen.get(&addr(2)), Some(&(2 * RESERVE))); + } + + #[test] + fn balance_exactly_at_reserve_is_not_viable_input_0() { + // Strict `> reserve`: a sole address holding exactly the reserve + // cannot be input 0. + let candidates = vec![(addr(1), RESERVE)]; + let err = select_shield_inputs(candidates, 1, RESERVE).unwrap_err(); + assert!(matches!( + err, + PlatformWalletError::ShieldedInsufficientBalance { available, required } + if available == RESERVE && required == 1 + RESERVE + )); + } + + #[test] + fn amount_equal_to_total_minus_reserve_claims_exactly_amount() { + // Single address holding exactly amount + reserve: claim == + // amount, leaving the full reserve for DeductFromInput(0). + let amount = 3 * RESERVE; + let candidates = vec![(addr(1), amount + RESERVE)]; + let chosen = select_shield_inputs(candidates, amount, RESERVE).unwrap(); + assert_eq!(chosen.len(), 1); + assert_eq!(chosen.get(&addr(1)), Some(&amount)); + } + + #[test] + fn accumulates_across_inputs_reserving_only_on_input_0() { + let amount = 5 * RESERVE; + // input 0 (addr 1) holds 2*reserve → contributes reserve after + // its headroom; addr 2 covers the rest. + let candidates = vec![(addr(1), 2 * RESERVE), (addr(2), 5 * RESERVE)]; + let chosen = select_shield_inputs(candidates, amount, RESERVE).unwrap(); + assert_eq!(chosen.get(&addr(1)), Some(&RESERVE)); + assert_eq!(chosen.get(&addr(2)), Some(&(4 * RESERVE))); + assert_eq!(chosen.values().sum::(), amount); + } + + #[test] + fn insufficient_usable_balance_errors() { + // Needs amount + reserve = 5*reserve, only 2*reserve available. + let candidates = vec![(addr(1), 2 * RESERVE)]; + let err = select_shield_inputs(candidates, 4 * RESERVE, RESERVE).unwrap_err(); + assert!(matches!( + err, + PlatformWalletError::ShieldedInsufficientBalance { .. } + )); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 1d7c920a745..746c70b5cf9 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -354,7 +354,19 @@ pub async fn unshield( match result { Ok(()) => { - finalize_pending(store, persister, wallet_id, id, &selected_notes).await?; + // Broadcast already succeeded; spent-state bookkeeping is + // best-effort. Surfacing a local write failure as a send + // failure here would invite duplicate retries — the next + // nullifier sync reconciles any drift. + if let Err(e) = finalize_pending(store, persister, wallet_id, id, &selected_notes).await + { + warn!( + account, + error = %e, + "Unshield broadcast succeeded but local spent-state update failed; \ + will heal on next sync" + ); + } info!(account, credits = amount, "Unshield broadcast succeeded"); Ok(()) } @@ -428,7 +440,16 @@ pub async fn transfer( match result { Ok(()) => { - finalize_pending(store, persister, wallet_id, id, &selected_notes).await?; + // Best-effort post-broadcast bookkeeping (see unshield). + if let Err(e) = finalize_pending(store, persister, wallet_id, id, &selected_notes).await + { + warn!( + account, + error = %e, + "Shielded transfer broadcast succeeded but local spent-state update \ + failed; will heal on next sync" + ); + } info!( account, credits = amount, @@ -508,7 +529,16 @@ pub async fn withdraw( match result { Ok(()) => { - finalize_pending(store, persister, wallet_id, id, &selected_notes).await?; + // Best-effort post-broadcast bookkeeping (see unshield). + if let Err(e) = finalize_pending(store, persister, wallet_id, id, &selected_notes).await + { + warn!( + account, + error = %e, + "Shielded withdrawal broadcast succeeded but local spent-state update \ + failed; will heal on next sync" + ); + } info!( account, credits = amount, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs index f123b58d397..7eb0e48d3b3 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs @@ -112,11 +112,13 @@ impl ShieldedSyncSummary { pub struct MultiSyncNotesResult { /// Per-subwallet count of new notes discovered in this pass. pub per_subwallet_new_notes: BTreeMap, - /// New positions observed this pass — the number of - /// commitments actually appended to the shared tree (positions - /// `>= tree_size`). Re-scanned positions the tree already held - /// are excluded. See [`SyncNotesResult::total_scanned`] for the - /// rationale. + /// Wire-level scan volume this pass — encrypted notes pulled from + /// Platform (decrypted + skipped), computed as `(aligned_start + + /// total_notes_scanned).saturating_sub(already_have)`. This is the + /// host-visible "Scanned" counter and is deliberately NOT the count + /// of newly-appended tree positions (the tree_size append gate makes + /// the two diverge on re-fetch). See + /// [`SyncNotesResult::total_scanned`] for the rationale. pub total_scanned: u64, /// Accumulated persistence changeset spanning every touched /// subwallet. The caller decides whether to queue it on the @@ -337,7 +339,16 @@ pub(super) async fn sync_notes_across( let new_tree_size = store .tree_size() .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; - let checkpoint_id: u32 = new_tree_size.try_into().unwrap_or(u32::MAX); + // Hard-fail rather than saturate at u32::MAX: a saturated id + // would reintroduce shardtree's silent-dedup (every checkpoint + // past the ceiling pins to the same id) — the exact corruption + // this monotonic-id scheme exists to avoid. Unreachable today + // (>4.29B notes scanned) but fail loudly before proving. + let checkpoint_id: u32 = new_tree_size.try_into().map_err(|_| { + PlatformWalletError::ShieldedTreeUpdateFailed(format!( + "commitment tree size {new_tree_size} exceeds u32 checkpoint-id range" + )) + })?; store .checkpoint_tree(checkpoint_id) .map_err(|e| PlatformWalletError::ShieldedTreeUpdateFailed(e.to_string()))?; @@ -414,12 +425,18 @@ pub(super) async fn sync_notes_across( new_index, "Multi-subwallet shielded sync finished" ); - // Genuinely new positions this pass = what we actually added - // to the shared tree (positions `>= tree_size`). Re-scanned - // positions the tree already held are excluded. + // `total_scanned` keeps its host-visible meaning: wire-level scan + // volume this pass (encrypted notes pulled — decrypted + skipped), + // matching the exported FFI/Swift/UI "Scanned" contract. This is + // intentionally NOT `appended`: the tree_size append gate makes the + // two diverge whenever the SDK re-fetches positions the tree + // already holds (chunk-boundary realignment, lagging-subwallet + // rewind), and the host counter is documented as scan throughput, + // not tree growth. + let scanned_volume = (aligned_start + result.total_notes_scanned).saturating_sub(already_have); Ok(MultiSyncNotesResult { per_subwallet_new_notes, - total_scanned: appended as u64, + total_scanned: scanned_volume, changeset, }) } From 7917a9f79d6b363fadc6b914663553b552a7478a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 21 May 2026 07:12:55 +0700 Subject: [PATCH 68/78] fix(swift-sdk): address shielded review feedback (Swift) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Send: validate the scaled integer for the active ledger (duffs for Core, credits for shielded/platform) is > 0 before enabling send, so sub-unit amounts can't slip through as 0. - Send: the Platform->Shielded shield always self-shields, so block the send when a non-empty entered recipient differs from the wallet's own Orchard address instead of silently reporting success. - bindShielded: enforce the documented <= 64 account cap before crossing the FFI boundary. - Persistence: scope shielded note / sync-state restore to the handler's network so a per-network manager can't rehydrate foreign-network rows. - Sync Status: `ShieldedNetworkSummaryRows` is scoped to the active network's wallet ids — no cross-network balance sum or max-across- networks watermark. - AccountListView: `shieldedAccountsForThisWallet` gates on the bound wallet id so it can't briefly show a previous wallet's accounts. --- .../PlatformWalletManagerShieldedSync.swift | 5 ++ .../PlatformWalletPersistenceHandler.swift | 33 +++++++++++- .../Core/ViewModels/SendViewModel.swift | 32 +++++++++++- .../Core/Views/AccountListView.swift | 9 ++-- .../Core/Views/CoreContentView.swift | 51 ++++++++++++------- 5 files changed, 104 insertions(+), 26 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index 54477142548..1143f8c0075 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -105,6 +105,11 @@ extension PlatformWalletManager { "accounts must be non-empty" ) } + guard accounts.count <= 64 else { + throw PlatformWalletError.invalidParameter( + "accounts must contain at most 64 entries" + ) + } guard let resolverHandle = resolver.handle else { throw PlatformWalletError.invalidParameter( "MnemonicResolver has no handle" diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index b6ae5d4653f..20a0b628161 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -2316,6 +2316,22 @@ public class PlatformWalletPersistenceHandler { return row } + /// Wallet ids belonging to the handler's bound network, used to + /// scope the shielded loaders the same way `loadWalletList()` scopes + /// its wallet fetch. Returns `nil` when the handler has no bound + /// network (legacy callers that haven't threaded `network` through), + /// signalling "don't filter" so those paths keep their pre-refactor + /// cross-network behavior. Caller must be on `onQueue`. + private func inNetworkWalletIds() -> Set? { + guard let network = self.network else { return nil } + let raw = network.rawValue + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.networkRaw == raw } + ) + let wallets = (try? backgroundContext.fetch(descriptor)) ?? [] + return Set(wallets.map { $0.walletId }) + } + /// Build the host-allocated `ShieldedNoteRestoreFFI` array Rust /// reads at boot. The allocation is tracked in /// `shieldedLoadAllocations` and freed by @@ -2330,13 +2346,20 @@ public class PlatformWalletPersistenceHandler { var resultErrored = false onQueue { let descriptor = FetchDescriptor() - let rows: [PersistentShieldedNote] + var rows: [PersistentShieldedNote] do { rows = try backgroundContext.fetch(descriptor) } catch { resultErrored = true return } + // Scope to the handler's bound network so a per-network + // manager never rehydrates another network's shielded notes + // (the commitment tree DB is network-scoped). `nil` ids => + // no in-network wallets => nothing to restore. + if let inNetworkIds = self.inNetworkWalletIds() { + rows = rows.filter { inNetworkIds.contains($0.walletId) } + } if rows.isEmpty { return } @@ -2432,13 +2455,19 @@ public class PlatformWalletPersistenceHandler { var resultErrored = false onQueue { let descriptor = FetchDescriptor() - let rows: [PersistentShieldedSyncState] + var rows: [PersistentShieldedSyncState] do { rows = try backgroundContext.fetch(descriptor) } catch { resultErrored = true return } + // Same network scoping as `loadShieldedNotes` — keep both + // loaders consistent so a per-network manager doesn't restore + // foreign-network sync watermarks. + if let inNetworkIds = self.inNetworkWalletIds() { + rows = rows.filter { inNetworkIds.contains($0.walletId) } + } if rows.isEmpty { return } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift index 96931e34b5f..af92136939e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -117,7 +117,19 @@ class SendViewModel: ObservableObject { var amountDuffs: UInt64? { amount } var canSend: Bool { - detectedFlow != nil && amountDuffs != nil && !isSending + guard let flow = detectedFlow, !isSending else { return false } + // Gate on the scaled integer for the *active* flow's unit, not + // just non-nil. A sub-unit amount (e.g. "0.000000001" in DASH) + // parses to 0 once scaled; sending that reaches the backend as a + // zero-value transfer. Core/L1 settles in duffs (1e8); every + // credits-ledger flow settles in credits (1e11). + switch flow { + case .coreToCore: + return (amountDuffs ?? 0) > 0 + case .platformToPlatform, .platformToShielded, + .shieldedToShielded, .shieldedToPlatform, .shieldedToCore: + return (amountCredits ?? 0) > 0 + } } /// Determine which fund sources are available based on destination and balances. @@ -390,8 +402,24 @@ class SendViewModel: ObservableObject { return } _ = platformState - _ = shieldedService _ = sdk + // `shieldedShield` has no recipient parameter — Rust + // always shields into this wallet's own default Orchard + // address (shieldedAccount 0). If the user typed a + // *different* Orchard address we'd report success while + // nothing reached that recipient, so constrain this path + // to self-shield only. + let enteredRecipient = recipientAddress + .trimmingCharacters(in: .whitespacesAndNewlines) + let ownShieldedAddress = + shieldedService.addressesByAccount[0] + ?? shieldedService.orchardDisplayAddress + if !enteredRecipient.isEmpty, + enteredRecipient != ownShieldedAddress { + error = "Shield always sends to your own shielded " + + "address; enter your own address or leave it blank" + return + } let signer = KeychainSigner(modelContainer: modelContext.container) try await walletManager.shieldedShield( walletId: wallet.walletId, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift index 27a2a1ab014..766ac154551 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift @@ -59,10 +59,11 @@ struct AccountListView: View { /// `ShieldedService.bind` has populated the list — which /// happens once per wallet detail open. private var shieldedAccountsForThisWallet: [UInt32] { - // Filter by wallet id so navigating between wallet - // details doesn't briefly show the previous wallet's - // accounts before the singleton service rebinds. - guard shieldedService.boundAccounts.isEmpty == false else { return [] } + // Gate on the service's currently-bound wallet id so navigating + // between wallet details doesn't briefly show the *previous* + // wallet's shielded accounts before the singleton service + // finishes rebinding to this wallet. + guard shieldedService.boundWalletId == wallet.walletId else { return [] } return shieldedService.boundAccounts } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index 51d806111ac..77371f56eb7 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -430,7 +430,7 @@ var body: some View { // wallet scoping) so the figures survive restart // and reflect the whole pool rather than a single // bound wallet. - ShieldedNetworkSummaryRows() + ShieldedNetworkSummaryRows(walletIds: walletIdsOnNetwork) // Sync counters since launch — `total_scanned` // is the wire-level encrypted-note count (every @@ -1219,34 +1219,49 @@ extension CoreContentView { // MARK: - ShieldedNetworkSummaryRows /// Network-wide shielded summary: aggregate unspent balance and the -/// notes-synced watermark across **every** wallet/account on disk. +/// notes-synced watermark across every wallet/account **on the active +/// network**. /// -/// Both figures are read **directly from SwiftData** (no wallet -/// scoping) rather than from the `ShieldedService.shieldedBalance` -/// mirror that's updated per-bound-wallet from sync events, so they -/// survive restart and reflect the entire pool: +/// Both figures are read **directly from SwiftData** (scoped to the +/// active network via `walletIds`) rather than from the +/// `ShieldedService.shieldedBalance` mirror that's updated +/// per-bound-wallet from sync events, so they survive restart and +/// reflect the whole on-network pool: /// -/// * **Total Shielded Balance** — sum of `value` over every -/// unspent `PersistentShieldedNote`, in credits. +/// * **Total Shielded Balance** — sum of `value` over every unspent +/// `PersistentShieldedNote` whose wallet is on this network. /// -/// * **Notes Synced** — the highest `lastSyncedIndex` across all -/// `PersistentShieldedSyncState` rows. The Orchard commitment -/// tree is chain-wide and shared by every wallet/account, so -/// each subwallet advances toward the same tip; the max is the -/// furthest-scanned position and climbs as sync progresses -/// across tens of thousands of notes. +/// * **Notes Synced** — the highest `lastSyncedIndex` across this +/// network's `PersistentShieldedSyncState` rows. The Orchard +/// commitment tree is chain-wide and shared by every wallet/account +/// **on a given network**, so each subwallet advances toward the +/// same tip; the max is the furthest-scanned position and climbs as +/// sync progresses. Scoping matters: trees are per-chain, so a +/// `max()` across networks would blend unrelated tip positions. private struct ShieldedNetworkSummaryRows: View { + /// Wallet ids on the active network. Both queries are filtered + /// against this so a multi-network install (e.g. regtest + testnet) + /// doesn't blend balances or take a watermark `max()` across + /// unrelated per-chain commitment trees — matching the Platform + /// Sync Status section's `walletIdsOnNetwork` scoping. + let walletIds: Set + @Query private var allNotes: [PersistentShieldedNote] @Query private var syncStates: [PersistentShieldedSyncState] - /// Sum of `value` over every unspent note, in credits. + /// Sum of `value` over this network's unspent notes, in credits. private var totalUnspentCredits: UInt64 { - allNotes.lazy.filter { !$0.isSpent }.reduce(UInt64(0)) { $0 &+ $1.value } + allNotes.lazy + .filter { !$0.isSpent && walletIds.contains($0.walletId) } + .reduce(UInt64(0)) { $0 &+ $1.value } } - /// Furthest-scanned commitment-tree index across all subwallets. + /// Furthest-scanned commitment-tree index across this network's subwallets. private var notesSynced: UInt64 { - syncStates.map(\.lastSyncedIndex).max() ?? 0 + syncStates.lazy + .filter { walletIds.contains($0.walletId) } + .map(\.lastSyncedIndex) + .max() ?? 0 } /// 1 DASH = 100,000,000,000 credits — matches `formatCredits`. From 82cb6e526131a126ebfcb02a85412c26daf419fe Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 21 May 2026 07:26:10 +0700 Subject: [PATCH 69/78] =?UTF-8?q?fix(platform-wallet):=20harden=20shield?= =?UTF-8?q?=20flow=20=E2=80=94=20reuse=20rs-sdk=20nonce=20fetch=20+=20sing?= =?UTF-8?q?le-flight?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reuse rs-sdk's canonical `fetch_inputs_with_nonce` (now `pub` + re-exported from `platform::transition`) in the shield path instead of re-implementing the fetch-and-validate dance. This swaps the old warn-and-proceed balance check for the SDK's hard `AddressNotEnoughFundsError`, so an underfunded shield fails before the ~30 s Halo 2 proof rather than getting rejected by drive-abci after it. The local `checked_add(1)` nonce increment is kept (the canonical `nonce_inc` uses an unchecked `+ 1` that would wrap a u32::MAX address into a replay nonce). - Add a per-wallet `shield_guard` (`Arc>`, shared across cloned wallet handles) held across fetch → build → broadcast in `shielded_shield_from_account`, serializing shield-class operations so two concurrent calls (double-tap / retry-while-proving) can't fetch and build with the same address nonce and have the second rejected as a replay. Addresses review threads on the shield-flow nonce handling (reuse rs-sdk helpers; concurrent-shield TOCTOU). --- .../src/wallet/platform_wallet.rs | 19 ++++++ .../src/wallet/shielded/operations.rs | 59 +++++-------------- packages/rs-sdk/src/platform/transition.rs | 4 ++ .../src/platform/transition/address_inputs.rs | 9 ++- 4 files changed, 46 insertions(+), 45 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 086e83b1fc1..14db85ec8bb 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -97,6 +97,15 @@ pub struct PlatformWallet { #[cfg(feature = "shielded")] pub(crate) shielded_keys: Arc>>>, + /// Per-wallet single-flight guard for shield-class operations + /// (Type 15). Two concurrent `shield` calls on one wallet would + /// each fetch the same address nonce and build with `nonce + 1`, so + /// the second to reach drive-abci is rejected as a replay after a + /// ~30 s proof. Holding this across fetch → build → broadcast + /// serializes the double-tap / retry-while-proving case. `Arc` so + /// cloned wallet handles share the one lock. + #[cfg(feature = "shielded")] + pub(crate) shield_guard: Arc>, } impl PlatformWallet { @@ -300,6 +309,8 @@ impl PlatformWallet { balance, #[cfg(feature = "shielded")] shielded_keys: Arc::new(RwLock::new(None)), + #[cfg(feature = "shielded")] + shield_guard: Arc::new(tokio::sync::Mutex::new(())), } } @@ -731,6 +742,12 @@ impl PlatformWallet { )); } + // Single-flight: serialize shield-class ops on this wallet so + // two concurrent calls can't fetch + build with the same + // address nonce (the second would be rejected as a replay after + // a ~30 s proof). Held across selection → build → broadcast. + let _shield_guard = self.shield_guard.lock().await; + // The shield transition uses `DeductFromInput(0)` as its fee // strategy. drive-abci interprets that as "after each input // address has had its `claim` deducted, take the fee out of @@ -945,6 +962,8 @@ impl Clone for PlatformWallet { balance: self.balance.clone(), #[cfg(feature = "shielded")] shielded_keys: self.shielded_keys.clone(), + #[cfg(feature = "shielded")] + shield_guard: self.shield_guard.clone(), } } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 746c70b5cf9..903b519d057 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -136,55 +136,26 @@ pub async fn shield, P: OrchardProver>( ) -> Result<(), PlatformWalletError> { let recipient_addr = default_orchard_address(keys)?; - // Fetch the current address nonces from Platform. Each input - // address has a per-address nonce that the next state - // transition must use as `last_used + 1`. - use dash_sdk::platform::FetchMany; - use dash_sdk::query_types::AddressInfo; - use std::collections::BTreeSet; - - let address_set: BTreeSet = inputs.keys().copied().collect(); - let infos = AddressInfo::fetch_many(sdk, address_set) + // Reuse rs-sdk's canonical fetch + hard balance check rather than + // re-implementing the fetch-and-validate dance. Unlike the old + // warn-and-proceed path, `fetch_inputs_with_nonce` errors with + // `AddressNotEnoughFundsError` when any input is short, so we fail + // before paying the ~30 s Halo 2 proof for a transition drive-abci + // would reject. It returns the *current* on-chain nonces; we apply + // a checked increment (the canonical `nonce_inc` uses an unchecked + // `+ 1`, which would wrap an address at u32::MAX into a replay + // nonce — bail loudly here instead). + use dash_sdk::platform::transition::fetch_inputs_with_nonce; + + let fetched = fetch_inputs_with_nonce(sdk, &inputs) .await .map_err(|e| PlatformWalletError::ShieldedBuildError(format!("fetch input nonces: {e}")))?; let mut inputs_with_nonce: BTreeMap = BTreeMap::new(); - for (addr, credits) in inputs { - let info = infos - .get(&addr) - .and_then(|opt| opt.as_ref()) - .ok_or_else(|| { - PlatformWalletError::ShieldedBuildError(format!( - "input address not found on platform: {:?}", - addr - )) - })?; - if info.balance < credits { - warn!( - address = ?addr, - claimed_credits = credits, - platform_balance = info.balance, - platform_nonce = info.nonce, - "Shield input claims more credits than Platform reports — broadcast will likely fail" - ); - } else { - info!( - address = ?addr, - claimed_credits = credits, - platform_balance = info.balance, - platform_nonce = info.nonce, - "Shield input" - ); - } - // `AddressNonce` is `u32`; `info.nonce + 1` would wrap - // silently in release once an address reaches u32::MAX. - // drive-abci treats wrap-to-0 as a replay and rejects it - // after the wallet has spent ~30 s on a Halo 2 proof. - // Bail loudly here instead. - let next_nonce = info.nonce.checked_add(1).ok_or_else(|| { + for (addr, (nonce, credits)) in fetched { + let next_nonce = nonce.checked_add(1).ok_or_else(|| { PlatformWalletError::ShieldedBuildError(format!( - "input address nonce exhausted on platform: {:?}", - addr + "input address nonce exhausted on platform: {addr:?}" )) })?; inputs_with_nonce.insert(addr, (next_nonce, credits)); diff --git a/packages/rs-sdk/src/platform/transition.rs b/packages/rs-sdk/src/platform/transition.rs index b5aa9aa0516..a1b58e7135d 100644 --- a/packages/rs-sdk/src/platform/transition.rs +++ b/packages/rs-sdk/src/platform/transition.rs @@ -1,6 +1,10 @@ //! State transitions used to put changed objects to the Dash Platform. pub mod address_credit_withdrawal; pub(crate) mod address_inputs; +/// Re-export the canonical address-input fetch + hard balance check so +/// downstream crates (e.g. platform-wallet's shield path) reuse it +/// instead of re-implementing the fetch-and-validate dance. +pub use address_inputs::fetch_inputs_with_nonce; pub mod broadcast; pub(crate) mod broadcast_identity; pub mod broadcast_request; diff --git a/packages/rs-sdk/src/platform/transition/address_inputs.rs b/packages/rs-sdk/src/platform/transition/address_inputs.rs index 38a5c4aecb3..fb34cde1033 100644 --- a/packages/rs-sdk/src/platform/transition/address_inputs.rs +++ b/packages/rs-sdk/src/platform/transition/address_inputs.rs @@ -9,7 +9,14 @@ use dpp::prelude::AddressNonce; use drive_proof_verifier::types::{AddressInfo, AddressInfos}; use std::collections::{BTreeMap, BTreeSet}; -pub(crate) async fn fetch_inputs_with_nonce( +/// Fetch each input address's current `(nonce, balance)` from Platform +/// and return `(nonce, amount)` per address, enforcing a hard balance +/// check — errors with `AddressNotEnoughFundsError` when any input is +/// short rather than letting an underfunded transition proceed. The +/// returned nonces are the *current* on-chain values; callers increment +/// them (see [`nonce_inc`], or apply their own checked increment) before +/// building a transition. +pub async fn fetch_inputs_with_nonce( sdk: &Sdk, amounts: &BTreeMap, ) -> Result, Error> { From 64096d997f022bf4e6409da81e3a0f4762a7694d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 21 May 2026 07:35:23 +0700 Subject: [PATCH 70/78] fix(swift-example-app): correct self-shield recipient hint; doc best-effort finalize invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The Platform→Shielded self-shield guard previously told the user they could "leave it blank", but a blank recipient clears `detectedFlow` upstream (detectAddressType → updateFlow) so `canSend` disables the button — the branch is only reachable with a non-empty address. Reword the message to match the working flow (enter your own shielded address). (Making blank-input self-shield reachable would need a send-flow redesign — source selection with an empty recipient — and is left as a follow-up.) - Document the no-double-spend invariant behind the best-effort post-broadcast `finalize_pending`: the authoritative no-reuse guarantee is the on-chain nullifier set, not the local mark, so a failed finalize worst-cases to a re-selected note rejected at broadcast (wasted proof, never fund loss); `pending_nullifiers` is in-memory only. --- .../src/wallet/shielded/operations.rs | 10 ++++++++++ .../Core/ViewModels/SendViewModel.swift | 8 +++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 903b519d057..cf4de09fffc 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -329,6 +329,16 @@ pub async fn unshield( // best-effort. Surfacing a local write failure as a send // failure here would invite duplicate retries — the next // nullifier sync reconciles any drift. + // + // No double-spend follows from this downgrade: the + // authoritative no-reuse guarantee is the on-chain nullifier + // set, not this local mark. Worst case, before the next + // nullifier sync runs the note is re-selected and a second + // spend is built + proven, then rejected at broadcast with a + // nullifier-already-used error — wasted ~30 s proof, never + // fund loss. (`pending_nullifiers` is in-memory only, so it + // does not protect across a process restart in this window; + // the on-chain set does.) if let Err(e) = finalize_pending(store, persister, wallet_id, id, &selected_notes).await { warn!( diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift index af92136939e..b727a7f2dfa 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -416,8 +416,14 @@ class SendViewModel: ObservableObject { ?? shieldedService.orchardDisplayAddress if !enteredRecipient.isEmpty, enteredRecipient != ownShieldedAddress { + // Don't advertise "leave it blank": a blank recipient + // clears `detectedFlow` upstream (detectAddressType → + // updateFlow), so `canSend` disables the button and + // this branch is only reachable with a non-empty + // address. Tell the user to enter their own. error = "Shield always sends to your own shielded " - + "address; enter your own address or leave it blank" + + "address; enter your own shielded address as " + + "the recipient" return } let signer = KeychainSigner(modelContainer: modelContext.container) From 0098206a79d4c7962b39edfcd64306ea7548de2e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 21 May 2026 18:37:21 +0700 Subject: [PATCH 71/78] fix(platform-wallet): surface structured balance error from shield pre-flight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shield pre-flight `fetch_inputs_with_nonce` hard balance check raises a structured `AddressNotEnoughFundsError`, but the call site collapsed every error variant to `format!("fetch input nonces: {e}")` before it crossed the Rust→Swift FFI, so the host lost the address/balance/required detail — making the now-common pre-broadcast failure less informative than the rarer post-broadcast one. Add an `address_not_enough_funds` extractor (mirroring the plural broadcast-side `addresses_not_enough_funds`) and, on the fetch path, format a structured "shield input has insufficient balance: requires X credits, has Y" message. FFI shape is unchanged (string body); other error variants still fall back to the flat string. --- .../src/wallet/shielded/operations.rs | 44 +++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index cf4de09fffc..0e795b7b5bc 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -69,6 +69,30 @@ fn addresses_not_enough_funds( } } +/// Try to extract the per-address `AddressNotEnoughFundsError` that the +/// pre-flight `fetch_inputs_with_nonce` hard balance check raises when a +/// single input is short. Mirrors [`addresses_not_enough_funds`] (the +/// plural broadcast-side variant) so the pre-broadcast failure can carry +/// the same structured `(address, balance, required)` info across the +/// FFI instead of collapsing to an opaque stringified error. +fn address_not_enough_funds( + e: &dash_sdk::Error, +) -> Option<&dpp::consensus::state::address_funds::AddressNotEnoughFundsError> { + use dpp::consensus::state::state_error::StateError; + use dpp::consensus::ConsensusError; + use dpp::ProtocolError; + + let consensus: &ConsensusError = match e { + dash_sdk::Error::Protocol(ProtocolError::ConsensusError(boxed)) => boxed.as_ref(), + dash_sdk::Error::StateTransitionBroadcastError(s) => s.cause.as_ref()?, + _ => return None, + }; + match consensus { + ConsensusError::StateError(StateError::AddressNotEnoughFundsError(err)) => Some(err), + _ => None, + } +} + /// Format a one-line `addresses_with_info` summary for diagnostics — /// each entry rendered as `=(nonce , credits)`, /// matching what the wallet UI shows. @@ -147,9 +171,23 @@ pub async fn shield, P: OrchardProver>( // nonce — bail loudly here instead). use dash_sdk::platform::transition::fetch_inputs_with_nonce; - let fetched = fetch_inputs_with_nonce(sdk, &inputs) - .await - .map_err(|e| PlatformWalletError::ShieldedBuildError(format!("fetch input nonces: {e}")))?; + let fetched = fetch_inputs_with_nonce(sdk, &inputs).await.map_err(|e| { + // The hard balance check is the common pre-broadcast failure; + // surface its structured (address, balance, required) info as a + // diagnostic string rather than the opaque `{e}` form, matching + // the richness of the broadcast-side handler below. The FFI + // shape is unchanged (the host still receives a string body). + if let Some(short) = address_not_enough_funds(&e) { + PlatformWalletError::ShieldedBuildError(format!( + "shield input {} has insufficient balance: requires {} credits, has {}", + short.address().to_bech32m_string(sdk.network), + short.required_balance(), + short.balance(), + )) + } else { + PlatformWalletError::ShieldedBuildError(format!("fetch input nonces: {e}")) + } + })?; let mut inputs_with_nonce: BTreeMap = BTreeMap::new(); for (addr, (nonce, credits)) in fetched { From 6f70314d5f5c6e076c4f2fb910501e0d768d2b95 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 21 May 2026 18:48:13 +0700 Subject: [PATCH 72/78] fix(platform-wallet): shield Type 15/18 wait for proven execution (#3704) `shield()` (Type 15) and `shield_from_asset_lock()` (Type 18) reported success on relay-ACK via `broadcast()`, while the spend-side flows (unshield/transfer/withdraw) wait for proven execution via `broadcast_and_wait::`. A faulty or hostile DAPI gateway could therefore ACK the bytes and surface a successful shield even when Platform later rejects or never includes the transition. Type 18 is the worst case: the asset-lock proof is single-use, so a false-positive strands the user's L1 outpoint with no in-app signal. Switch both shield flows to `broadcast_and_wait::` so all five shielded transitions share one proven-execution success contract. The proven result is discarded (callers only need the confirmation); the rich `addresses_not_enough_funds` diagnostic is retained for the now-rare post-pre-flight rejection. Closes #3704. --- .../src/wallet/shielded/operations.rs | 60 ++++++++++++------- 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 0e795b7b5bc..684e4ac11b1 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -222,29 +222,38 @@ pub async fn shield, P: OrchardProver>( trace!("Shield credits: state transition built, broadcasting..."); let network = sdk.network; - state_transition.broadcast(sdk, None).await.map_err(|e| { - if let Some(rich) = addresses_not_enough_funds(&e) { - let claimed = claimed_inputs - .iter() - .map(|(addr, (nonce, credits))| { - format!( - "{}=(nonce {nonce}, {credits} credits)", - addr.to_bech32m_string(network) - ) - }) - .collect::>() - .join(", "); - PlatformWalletError::ShieldedBroadcastFailed(format!( - "addresses not enough funds: required {} credits; \ + // Wait for proven execution (not just relay-ACK) so the host only + // sees success once Platform has actually included the transition — + // matching the spend-side flows (unshield/transfer/withdraw). A + // DAPI-level ACK alone could otherwise mask a later Platform + // rejection. The proven result is discarded; we only need the + // confirmation. + state_transition + .broadcast_and_wait::(sdk, None) + .await + .map_err(|e| { + if let Some(rich) = addresses_not_enough_funds(&e) { + let claimed = claimed_inputs + .iter() + .map(|(addr, (nonce, credits))| { + format!( + "{}=(nonce {nonce}, {credits} credits)", + addr.to_bech32m_string(network) + ) + }) + .collect::>() + .join(", "); + PlatformWalletError::ShieldedBroadcastFailed(format!( + "addresses not enough funds: required {} credits; \ claimed inputs [{}]; platform sees [{}]", - rich.required_balance(), - claimed, - format_addresses_with_info(rich.addresses_with_info(), network), - )) - } else { - PlatformWalletError::ShieldedBroadcastFailed(e.to_string()) - } - })?; + rich.required_balance(), + claimed, + format_addresses_with_info(rich.addresses_with_info(), network), + )) + } else { + PlatformWalletError::ShieldedBroadcastFailed(e.to_string()) + } + })?; info!(account, credits = amount, "Shield broadcast succeeded"); Ok(()) @@ -286,8 +295,13 @@ pub async fn shield_from_asset_lock( .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; trace!("Shield from asset lock: state transition built, broadcasting..."); + // Wait for proven execution rather than relay-ACK. This matters most + // for Type 18: the asset-lock proof is single-use, so a false- + // positive success on a transition Platform later rejects would + // strand the user's L1 outpoint with no in-app signal. The proven + // result is discarded; we only need the confirmation. state_transition - .broadcast(sdk, None) + .broadcast_and_wait::(sdk, None) .await .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; From 8c80874c8932066e51fbf2ebf31f6991d21d85d3 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 21 May 2026 19:00:11 +0700 Subject: [PATCH 73/78] docs(platform-wallet): clarify sync watermark is exclusive next-index, not last scanned The `last_synced_index` doc comments described it as the "highest/last global note index scanned" (inclusive phrasing), but the code uses it as an exclusive count / next-index-to-scan: it's advanced as `aligned_start + total_notes_scanned` and gated as `position < watermark`, so `0` unambiguously means "nothing scanned" and scanning through position N-1 sets it to N. Correct the wording across the trait method, SubwalletState, the changeset start-state, the FFI type, and the SwiftData model so readers don't infer a fresh-vs-scanned-index-0 ambiguity that the exclusive-count semantics don't actually have. No behavior change. --- .../src/shielded_persistence.rs | 4 +++- .../src/changeset/shielded_sync_start_state.rs | 3 ++- .../rs-platform-wallet/src/wallet/shielded/store.rs | 11 ++++++++--- .../Models/PersistentShieldedSyncState.swift | 4 +++- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_persistence.rs b/packages/rs-platform-wallet-ffi/src/shielded_persistence.rs index 1d58c9be86e..b37eac1e1d6 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_persistence.rs @@ -64,7 +64,9 @@ pub struct ShieldedNullifierSpentFFI { pub struct ShieldedSyncedIndexFFI { pub wallet_id: [u8; 32], pub account_index: u32, - /// Highest global commitment-tree index the subwallet has scanned. + /// Sync watermark: count of note positions scanned = the next + /// global commitment-tree index to scan (exclusive). `0` = nothing + /// scanned yet. pub last_synced_index: u64, } diff --git a/packages/rs-platform-wallet/src/changeset/shielded_sync_start_state.rs b/packages/rs-platform-wallet/src/changeset/shielded_sync_start_state.rs index 6f55af36480..ef5d0f16e03 100644 --- a/packages/rs-platform-wallet/src/changeset/shielded_sync_start_state.rs +++ b/packages/rs-platform-wallet/src/changeset/shielded_sync_start_state.rs @@ -25,7 +25,8 @@ pub struct ShieldedSubwalletStartState { /// in-memory store reflects what nullifier sync has already /// established. pub notes: Vec, - /// Highest global note index that the subwallet has scanned. + /// Sync watermark: count of note positions scanned = the next + /// global index to scan (exclusive). `0` = nothing scanned yet. pub last_synced_index: u64, /// Last `(height, timestamp)` nullifier sync checkpoint. pub nullifier_checkpoint: Option<(u64, u64)>, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index 2656f02f7d4..7268a4f0d0f 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -174,10 +174,14 @@ pub trait ShieldedStore: Send + Sync { // ── Sync state (per-subwallet) ───────────────────────────────────── - /// The last global note index that was synced for `id`. + /// Sync watermark for `id`: the count of note positions already + /// scanned, i.e. the next global commitment-tree index to scan. + /// `0` means nothing scanned yet; `N` means positions `0..N` are + /// done. Exclusive upper bound — *not* the last index scanned + /// (scanning through position `N-1` sets this to `N`). fn last_synced_note_index(&self, id: SubwalletId) -> Result; - /// Persist the last synced note index for `id`. + /// Persist the sync watermark (next index to scan) for `id`. fn set_last_synced_note_index( &mut self, id: SubwalletId, @@ -225,7 +229,8 @@ pub(super) struct SubwalletState { pub notes: Vec, /// Nullifier → index into `notes`, for O(1) `mark_spent`. pub nullifier_index: BTreeMap<[u8; 32], usize>, - /// Highest global note index ever scanned. + /// Sync watermark: count of note positions scanned = the next + /// global index to scan (exclusive). `0` = nothing scanned yet. pub last_synced_index: u64, /// `(height, timestamp)` from the most recent nullifier sync. pub nullifier_checkpoint: Option<(u64, u64)>, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedSyncState.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedSyncState.swift index 0676fd7b996..611d5152ffb 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedSyncState.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedSyncState.swift @@ -20,7 +20,9 @@ public final class PersistentShieldedSyncState { public var walletId: Data public var accountIndex: UInt32 - /// Highest global commitment-tree index that the subwallet has scanned. + /// Sync watermark: count of note positions scanned = the next global + /// commitment-tree index to scan (exclusive). `0` = nothing scanned + /// yet — *not* the last index scanned. public var lastSyncedIndex: UInt64 /// Whether the optional `(height, timestamp)` nullifier /// checkpoint is populated. SwiftData predicate compilation is From e768cdc6dedf2d368b1271e7f57276575f64759d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 21 May 2026 19:34:41 +0700 Subject: [PATCH 74/78] fix(platform-wallet): add shielded sync quiesce barrier for Clear/stop (#3706) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ShieldedSyncManager::stop()` was cancel-only: it cancelled the loop token but a pass already inside `sync_now`/`coordinator.sync()` ran to completion, including its persister-callback fan-out. So `clearShielded` could wipe the registries + SwiftData rows while an in-flight pass kept going and re-persisted `PersistentShieldedNote`/`PersistentShieldedSyncState` rows afterward — defeating the local-erasure boundary a user expects from Clear (especially when invoked in response to a perceived compromise). Add `ShieldedSyncManager::quiesce()`: raise a `quiescing` gate (so `sync_now`/`sync_wallet` bail after taking the `is_syncing` slot), cancel the loop, then wait for `is_syncing` to clear. `is_syncing` is held for the whole pass including the persister fan-out, so its falling edge with the gate up is a sound "fully drained" signal; the gate is reopened before returning. Route the Clear and stop FFIs through `quiesce()` so both block until the in-flight pass has actually drained. `stop()` stays as the cancel-only primitive for fast full-manager shutdown. Closes #3706. --- .../src/shielded_sync.rs | 21 +++++-- .../src/manager/shielded_sync.rs | 56 +++++++++++++++++++ 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs index de4678d1eb2..90df5b696f7 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs @@ -65,13 +65,19 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_sync_start( PlatformWalletFFIResult::ok() } -/// Stop the shielded sync manager if it is running. +/// Stop the shielded sync manager and wait for any in-flight pass to +/// drain before returning. No-op if not running. +/// +/// Uses [`quiesce`](crate-internal) rather than cancel-only stop so the +/// host-facing contract is honest: once this returns, no sync pass is +/// running and none will emit further completion events or persistence +/// callbacks. The call blocks until a pass already underway finishes. #[no_mangle] pub unsafe extern "C" fn platform_wallet_manager_shielded_sync_stop( handle: Handle, ) -> PlatformWalletFFIResult { let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { - manager.shielded_sync().stop(); + runtime().block_on(manager.shielded_sync().quiesce()); }); unwrap_option_or_return!(option); PlatformWalletFFIResult::ok() @@ -408,10 +414,15 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_clear( handle: Handle, ) -> PlatformWalletFFIResult { let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { - // Stop the loop first so the next pass can't race the - // registry clear and observe a half-emptied state. - manager.shielded_sync().stop(); + // Quiesce first — cancel the loop AND wait for any in-flight + // pass to fully drain — before wiping the registries. A + // cancel-only stop() would let a pass that already started + // finish and route stale results back through the persister + // after Clear returns, defeating the local-data-erasure + // boundary the user expects (especially when Clear is invoked + // in response to a perceived device compromise). runtime().block_on(async { + manager.shielded_sync().quiesce().await; if let Some(coord) = manager.shielded_coordinator().await { coord.clear().await; } diff --git a/packages/rs-platform-wallet/src/manager/shielded_sync.rs b/packages/rs-platform-wallet/src/manager/shielded_sync.rs index 32116bfa351..898183b0751 100644 --- a/packages/rs-platform-wallet/src/manager/shielded_sync.rs +++ b/packages/rs-platform-wallet/src/manager/shielded_sync.rs @@ -149,6 +149,13 @@ pub struct ShieldedSyncManager { background_generation: AtomicU64, interval_secs: AtomicU64, is_syncing: AtomicBool, + /// Set by [`quiesce`](Self::quiesce) to gate new passes while it + /// drains an in-flight one. `sync_now` / `sync_wallet` bail (after + /// taking the `is_syncing` slot) when this is set, so once `quiesce` + /// observes `is_syncing == false` no further pass can start — giving + /// Clear / stop a real "no more host-visible mutations" barrier that + /// cancel-only [`stop`](Self::stop) does not provide. + quiescing: AtomicBool, /// Unix seconds of the last completed pass. `0` = never. last_sync_unix: AtomicU64, } @@ -165,6 +172,7 @@ impl ShieldedSyncManager { background_generation: AtomicU64::new(0), interval_secs: AtomicU64::new(DEFAULT_SYNC_INTERVAL_SECS), is_syncing: AtomicBool::new(false), + quiescing: AtomicBool::new(false), last_sync_unix: AtomicU64::new(0), } } @@ -268,6 +276,13 @@ impl ShieldedSyncManager { } /// Stop the background sync loop. No-op if not running. + /// + /// **Cancel-only**: this requests cancellation and returns + /// immediately. A pass already inside `sync_now` / + /// `coordinator.sync()` keeps running to completion (including its + /// persister-callback fan-out). For a real "nothing is running and + /// nothing more will be persisted" barrier — required by Clear, + /// unregister, and rebind — use [`quiesce`](Self::quiesce). pub fn stop(&self) { if let Some(token) = self .background_cancel @@ -279,6 +294,32 @@ impl ShieldedSyncManager { } } + /// Cancel the background loop **and wait for any in-flight sync pass + /// to fully drain** before returning — a real quiescence barrier, + /// unlike cancel-only [`stop`](Self::stop). + /// + /// After this returns, no sync pass is running and none can start + /// until the next [`start`](Self::start) / `sync_now`, so a caller + /// that immediately mutates state a pass touches (Clear's registry + /// wipe + the host's SwiftData delete; wallet unregister; rebind) + /// cannot be raced by a pass that re-persists notes after the caller + /// believed sync had stopped. + /// + /// Mechanism: set the `quiescing` gate so any pass that hasn't yet + /// taken the `is_syncing` slot bails, cancel the loop, then wait for + /// `is_syncing` to clear. `is_syncing` is held for the whole pass + /// including the persister fan-out, so its falling edge (with the + /// gate up) is a sound "fully drained" signal. The gate is reopened + /// before returning so a later start/sync works normally. + pub async fn quiesce(&self) { + self.quiescing.store(true, Ordering::Release); + self.stop(); + while self.is_syncing.load(Ordering::Acquire) { + tokio::time::sleep(Duration::from_millis(20)).await; + } + self.quiescing.store(false, Ordering::Release); + } + /// Run one sync pass across every registered wallet. /// /// `force` is propagated to each wallet's @@ -299,6 +340,14 @@ impl ShieldedSyncManager { return ShieldedSyncPassSummary::default(); } + // A `quiesce()` may have raised the gate between our CAS and + // here; if so, release the slot and bail without running a pass + // so the drain can complete and Clear/stop get a true barrier. + if self.quiescing.load(Ordering::Acquire) { + self.is_syncing.store(false, Ordering::Release); + return ShieldedSyncPassSummary::default(); + } + // Snapshot the coordinator Arc and release the slot lock // before awaiting so a concurrent `configure_shielded` // can't deadlock against our pass. @@ -378,6 +427,13 @@ impl ShieldedSyncManager { return Ok(None); } + // Bail if a `quiesce()` raised the gate after our CAS (see + // `sync_now`) so the drain barrier holds. + if self.quiescing.load(Ordering::Acquire) { + self.is_syncing.store(false, Ordering::Release); + return Ok(None); + } + let pass = coordinator.sync(force).await; self.is_syncing.store(false, Ordering::Release); From 8acd81347a95b26d96c8554a46729c07b3961b3b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 21 May 2026 20:06:42 +0700 Subject: [PATCH 75/78] fix(platform-wallet): dispatch shielded completion event before clearing is_syncing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `quiesce()` drains on the falling edge of `is_syncing`, but `sync_now` cleared that flag *before* dispatching `on_shielded_sync_completed`, so a stop/clear caller could unblock while the completion event (FFI callback → Swift `handleShieldedSyncCompleted`) was still pending — surfacing a stale post-stop/post-clear event even though the persistence drain was already done. Move the event dispatch ahead of the `is_syncing` clear so the quiesce barrier covers the event too. (sync_wallet emits no post-flag event, so it's unaffected.) --- .../rs-platform-wallet/src/manager/shielded_sync.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet/src/manager/shielded_sync.rs b/packages/rs-platform-wallet/src/manager/shielded_sync.rs index 898183b0751..0e7ee5c729e 100644 --- a/packages/rs-platform-wallet/src/manager/shielded_sync.rs +++ b/packages/rs-platform-wallet/src/manager/shielded_sync.rs @@ -380,10 +380,18 @@ impl ShieldedSyncManager { } self.last_sync_unix .store(summary.sync_unix_seconds, Ordering::Release); - self.is_syncing.store(false, Ordering::Release); + // Dispatch the completion event BEFORE clearing `is_syncing`. + // `quiesce()` drains on the falling edge of `is_syncing`, so if + // we cleared the flag first a stop/clear caller could unblock + // while this completion event (FFI callback → Swift + // `handleShieldedSyncCompleted`) is still pending — surfacing a + // stale post-stop/post-clear event. Holding the flag across the + // dispatch makes quiesce's barrier cover the event too. self.event_manager.on_shielded_sync_completed(&summary); + self.is_syncing.store(false, Ordering::Release); + summary } From 47c3f527e0054545e5916aa5ef082401be5cb978 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 21 May 2026 20:32:12 +0700 Subject: [PATCH 76/78] refactor(platform-wallet): move shielded Clear quiesce+clear into clear_shielded() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Clear FFI was stitching two library calls inline (quiesce the sync manager, then clear the coordinator), which bends the FFI contract (resolve handle → call one library function → marshal result). Add `PlatformWalletManager::clear_shielded()` that owns the quiesce+clear sequencing in the library, and reduce `platform_wallet_manager_shielded_clear` to a single `block_on(manager.clear_shielded())` call. No behavior change. --- .../src/shielded_sync.rs | 21 +++++++------------ .../rs-platform-wallet/src/manager/mod.rs | 18 ++++++++++++++++ 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs index 90df5b696f7..bdccd9548db 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs @@ -414,19 +414,14 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_clear( handle: Handle, ) -> PlatformWalletFFIResult { let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { - // Quiesce first — cancel the loop AND wait for any in-flight - // pass to fully drain — before wiping the registries. A - // cancel-only stop() would let a pass that already started - // finish and route stale results back through the persister - // after Clear returns, defeating the local-data-erasure - // boundary the user expects (especially when Clear is invoked - // in response to a perceived device compromise). - runtime().block_on(async { - manager.shielded_sync().quiesce().await; - if let Some(coord) = manager.shielded_coordinator().await { - coord.clear().await; - } - }); + // Single library call: `clear_shielded` quiesces the sync + // manager (cancel + drain the in-flight pass, incl. persister + // fan-out, so nothing re-persists after Clear) and then clears + // the coordinator registries. Keeping the quiesce+clear + // sequencing in the library (not stitched here) follows the + // FFI's "resolve handle, call one function, marshal result" + // contract. + runtime().block_on(manager.clear_shielded()); }); unwrap_option_or_return!(option); PlatformWalletFFIResult::ok() diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index c0f472cede0..78fc7db3c55 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -227,6 +227,24 @@ impl PlatformWalletManager

{ .map(Arc::clone) } + /// Tear down shielded sync state for a Clear / wipe flow. + /// + /// Single library entry point so the FFI stays a one-call bridge: + /// first **quiesce** the sync manager (cancel the loop *and* drain + /// any in-flight pass, including its persister-callback fan-out, so + /// nothing can re-persist notes after this returns), then **clear** + /// the network coordinator's per-subwallet registries. Idempotent — + /// the coordinator step is a no-op when shielded support was never + /// configured. The per-network commitment-tree SQLite file is left + /// intact (chain-wide data; the next bind re-syncs against it). + #[cfg(feature = "shielded")] + pub async fn clear_shielded(&self) { + self.shielded_sync_manager.quiesce().await; + if let Some(coord) = self.shielded_coordinator().await { + coord.clear().await; + } + } + /// Stop all background tasks and wait for them to exit. /// /// Stops the periodic coordinators (`PlatformAddressSyncManager`, From 36608425f75aa1238216729f85c2bf0d2449b206 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 21 May 2026 20:54:46 +0700 Subject: [PATCH 77/78] fix(shielded): drop stale post-Clear completion event in host; correct stop doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Rust quiesce barrier guarantees no persistence after stop/clear returns, but the completion event is hopped onto the host's executor (Swift `@MainActor`), so a final already-dispatched event can land just after `stopShieldedSync()`/`clearShielded()` return and briefly repopulate the in-memory mirror Clear just zeroed (no SwiftData rows are rewritten — cosmetic only). - Correct the `platform_wallet_manager_shielded_sync_stop` doc to state the real guarantee (no further persistence, no new pass, Rust-side event dispatched) and not over-promise about when a host's run loop applies a trailing event. - Guard the example app's `handleShieldedSyncEvent` with `isBound` so an event arriving after Clear (which sets `isBound = false`) is dropped. `bind()` sets `isBound = true` before its sync events flow, so no legitimate post-bind event is dropped. --- .../src/shielded_sync.rs | 19 +++++++++++++++---- .../Core/Services/ShieldedService.swift | 11 +++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs index bdccd9548db..3f152059c87 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs @@ -68,10 +68,21 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_sync_start( /// Stop the shielded sync manager and wait for any in-flight pass to /// drain before returning. No-op if not running. /// -/// Uses [`quiesce`](crate-internal) rather than cancel-only stop so the -/// host-facing contract is honest: once this returns, no sync pass is -/// running and none will emit further completion events or persistence -/// callbacks. The call blocks until a pass already underway finishes. +/// Uses `quiesce` rather than cancel-only stop, so on return: the loop +/// is cancelled, no new pass will start, and any in-flight pass has +/// fully drained — its **persistence callbacks have completed** (no +/// note/sync-state row can be written after this returns) and its +/// completion-event *dispatch* on the Rust side has run. +/// +/// Caveat on host-observed events: a host that marshals the completion +/// callback onto its own executor (e.g. the Swift trampoline hops it to +/// the `@MainActor`) may still observe that final, already-dispatched +/// event land *after* this call returns — Rust controls when the event +/// is dispatched, not when the host's run loop applies it. The drain +/// guarantee above (no further persistence, no new pass) is the +/// load-bearing part; hosts that must ignore a trailing UI event should +/// gate their handler on their own post-stop/post-clear state (the +/// example app drops events while unbound). #[no_mangle] pub unsafe extern "C" fn platform_wallet_manager_shielded_sync_stop( handle: Handle, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift index 2cf8aefb8f9..8100283058c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -497,6 +497,17 @@ class ShieldedService: ObservableObject { // MARK: - Sync event handling private func handleShieldedSyncEvent(_ event: ShieldedSyncEvent) { + // Drop completion events that arrive while unbound. Clear + // (`clearLocalState`) sets `isBound = false` and cancels this + // subscription, but the Rust completion event is hopped onto the + // main actor and a final, already-dispatched one can land just + // after Clear returns — applying it would briefly repopulate the + // mirror Clear just zeroed. The Rust quiesce barrier already + // guarantees no *persistence* happens after Clear; this guards + // the in-memory display mirror. `bind()` sets `isBound = true` + // before its sync events flow, so a legitimate post-bind event + // is never dropped. + guard isBound else { return } guard let walletId = boundWalletId, let result = event.result(for: walletId) else { return From b97ff6dd4340e3d028054c6fd07c714bce8de2e5 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 21 May 2026 21:24:47 +0700 Subject: [PATCH 78/78] fix(swift-sdk): suppress trailing shielded completion event after stop/clear (SDK-level) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The example-app guard only protected that one consumer; the reusable SDK still published a trailing `lastShieldedSyncEvent` after `stopShieldedSync()` / `clearShielded()` returned, because the Rust completion callback is re-dispatched onto the manager's `@MainActor`. Gate it in the SDK: `PlatformWalletManager` gains an internal `suppressShieldedCompletionEvents` flag that `handleShieldedSyncCompleted` checks before publishing. stop/clear set it true after the Rust drain returns; startShieldedSync / syncShieldedNow clear it so a new run's events flow again. The manager is `@MainActor` and stop/clear are synchronous (they hold the main actor through the FFI while the trailing event only enqueues), so the flag is set before that event task runs — dropping it deterministically, with no risk of dropping a legitimate post-restart event. Now every SDK consumer gets the documented contract, not just the example app. --- .../PlatformWallet/PlatformWalletManager.swift | 16 ++++++++++++++++ .../PlatformWalletManagerShieldedSync.swift | 17 +++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index bb17801d399..1160ec47402 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -46,6 +46,22 @@ public class PlatformWalletManager: ObservableObject { /// Last completed shielded sync event emitted by Rust. @Published public internal(set) var lastShieldedSyncEvent: ShieldedSyncEvent? + /// When true, `handleShieldedSyncCompleted` drops incoming events + /// instead of publishing them. Set by `stopShieldedSync` / + /// `clearShielded` (after the Rust drain returns) and cleared by any + /// sync-start (`startShieldedSync` / `syncShieldedNow`). The Rust + /// quiesce barrier guarantees no persistence after stop/clear, but + /// the completion callback is re-dispatched onto this `@MainActor`, + /// so a final, already-dispatched event can land just after stop/ + /// clear returns; this gate keeps the published `lastShieldedSyncEvent` + /// honest for every SDK consumer (not just the example app). Both + /// stop/clear are synchronous on the main actor, so the flag is set + /// before the enqueued trailing-event task can run. + /// + /// `internal` (not `private`) because the shielded lifecycle methods + /// that read/write it live in an extension in a separate file. + var suppressShieldedCompletionEvents: Bool = false + /// All wallets currently held by the Rust-side /// `PlatformWalletManager`, keyed by the 32-byte wallet id. /// diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index 1143f8c0075..52d56bc60ad 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -59,6 +59,11 @@ public struct ShieldedSyncEvent: Sendable { extension PlatformWalletManager { func handleShieldedSyncCompleted(_ event: ShieldedSyncEvent) { + // Drop a trailing event that the Rust drain already dispatched + // but the main actor only delivers after stop/clear returned + // (see `suppressShieldedCompletionEvents`). Any sync-start clears + // the flag, so legitimate events are never suppressed. + guard !suppressShieldedCompletionEvents else { return } lastShieldedSyncEvent = event } @@ -176,6 +181,8 @@ extension PlatformWalletManager { if let intervalSeconds { try setShieldedSyncInterval(seconds: intervalSeconds) } + // A new sync run should publish its completion events again. + suppressShieldedCompletionEvents = false try platform_wallet_manager_shielded_sync_start(handle).check() } @@ -186,6 +193,9 @@ extension PlatformWalletManager { ) } try platform_wallet_manager_shielded_sync_stop(handle).check() + // The Rust drain returned; suppress any trailing completion + // event the main actor delivers after this point. + suppressShieldedCompletionEvents = true } /// Reset the Rust-side shielded state on this manager: @@ -209,6 +219,10 @@ extension PlatformWalletManager { ) } try platform_wallet_manager_shielded_clear(handle).check() + // The Rust drain returned; suppress any trailing completion + // event the main actor delivers after Clear (it would otherwise + // briefly repopulate the mirror the host is about to wipe). + suppressShieldedCompletionEvents = true } public func isShieldedSyncRunning() throws -> Bool { @@ -259,6 +273,9 @@ extension PlatformWalletManager { "PlatformWalletManager not configured" ) } + // A user-initiated sync should publish its completion event even + // if a prior stop/clear had suppressed events. + suppressShieldedCompletionEvents = false let handle = self.handle try await Task.detached(priority: .userInitiated) { try platform_wallet_manager_shielded_sync_sync_now(handle).check()