From 1e83b53cfbf0ff0d5fbd4ec12bf92f158a57dfc7 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 6 May 2026 06:13:00 +0700 Subject: [PATCH 01/23] 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/23] =?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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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, + )) + } +}