diff --git a/CLAUDE.md b/CLAUDE.md index f1c8eb8bad..2c061944c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,7 +154,7 @@ For SwiftExampleApp-specific guidance including token querying and data models, Quick build commands: ```bash # Build unified iOS framework (includes Core + Platform) -cd packages/rs-sdk-ffi +cd packages/swift-sdk ./build_ios.sh # Build SwiftExampleApp diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index 83a0e6006f..6f770ed142 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -53,6 +53,10 @@ 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; pub mod shielded_types; pub mod sign_with_mnemonic_resolver; @@ -110,6 +114,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/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 5becc5f38a..072a0ea50a 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -293,6 +293,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, + ), + >, /// Look up a single core transaction record by `txid` for the /// asset-lock proof flow's persister fallback. /// @@ -412,6 +503,22 @@ impl Default for PersistenceCallbacks { on_persist_contacts_fn: None, on_get_core_tx_record_fn: None, on_get_core_tx_record_free_fn: None, + #[cfg(feature = "shielded")] + on_persist_shielded_notes_fn: None, + #[cfg(feature = "shielded")] + on_persist_shielded_nullifiers_spent_fn: None, + #[cfg(feature = "shielded")] + on_persist_shielded_synced_indices_fn: None, + #[cfg(feature = "shielded")] + on_persist_shielded_nullifier_checkpoints_fn: None, + #[cfg(feature = "shielded")] + on_load_shielded_notes_fn: None, + #[cfg(feature = "shielded")] + on_load_shielded_notes_free_fn: None, + #[cfg(feature = "shielded")] + on_load_shielded_sync_states_fn: None, + #[cfg(feature = "shielded")] + on_load_shielded_sync_states_free_fn: None, } } } @@ -967,6 +1074,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 @@ -1067,6 +1320,175 @@ 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(); + + // Fail fast on a half-wired callback pair: a loader without + // its matching free callback leaks the host-allocated buffer + // on every successful load (the guard's `Drop` is a no-op + // when `free_fn` is `None`). + if self.callbacks.on_load_shielded_notes_fn.is_some() + != self.callbacks.on_load_shielded_notes_free_fn.is_some() + { + return Err( + "on_load_shielded_notes_fn and on_load_shielded_notes_free_fn must be \ + provided together" + .to_string() + .into(), + ); + } + if self.callbacks.on_load_shielded_sync_states_fn.is_some() + != self + .callbacks + .on_load_shielded_sync_states_free_fn + .is_some() + { + return Err( + "on_load_shielded_sync_states_fn and on_load_shielded_sync_states_free_fn \ + must be provided together" + .to_string() + .into(), + ); + } + + // 1) notes + if let Some(load_notes) = self.callbacks.on_load_shielded_notes_fn { + let mut notes_ptr: *const ShieldedNoteRestoreFFI = std::ptr::null(); + 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 0000000000..b37eac1e1d --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/shielded_persistence.rs @@ -0,0 +1,127 @@ +//! 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, + /// Sync watermark: count of note positions scanned = the next + /// global commitment-tree index to scan (exclusive). `0` = nothing + /// scanned yet. + pub last_synced_index: u64, +} + +/// 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/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs new file mode 100644 index 0000000000..12c15a0c7a --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -0,0 +1,394 @@ +//! FFI bindings for the shielded spend pipeline (transitions +//! 15/16/17/19 — shield, transfer, unshield, withdraw). +//! +//! 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 +//! 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. +//! +//! 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 +//! 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 +//! [`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::*; +use crate::handle::*; +use crate::runtime::{block_on_worker, runtime}; + +/// 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() { + runtime().spawn_blocking(|| 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, + account: u32, + 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, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) { + Ok(p) => p, + Err(result) => return result, + }; + + // 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(&coordinator, account, &recipient, amount, &prover) + .await + }); + if let Err(e) = result { + 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_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_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, + account: u32, + to_platform_addr_cstr: *const c_char, + amount: u64, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id_bytes); + 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_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, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) { + Ok(p) => p, + Err(result) => return result, + }; + + let result = block_on_worker(async move { + let prover = CachedOrchardProver::new(); + wallet + .shielded_unshield_to(&coordinator, account, &to_addr_str, amount, &prover) + .await + }); + if let Err(e) = result { + 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, + account: u32, + 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, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) { + Ok(p) => p, + Err(result) => return result, + }; + + let result = block_on_worker(async move { + let prover = CachedOrchardProver::new(); + wallet + .shielded_withdraw_to( + &coordinator, + account, + &to_core, + amount, + core_fee_per_byte, + &prover, + ) + .await + }); + if let Err(e) = result { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("shielded withdraw failed: {e}"), + ); + } + PlatformWalletFFIResult::ok() +} + +/// Shield: spend credits from a Platform Payment account into +/// 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`). 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 +/// `*const 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, + shielded_account: u32, + payment_account: u32, + amount: u64, + signer_address_handle: *const 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, + }; + + // Round-trip the signer pointer through `usize` so the worker + // future captures only plain `Send + 'static` data and + // re-materializes the borrow INSIDE the task — never a + // fabricated `&'static` borrow of a host-owned vtable across + // the FFI boundary. The caller's documented contract is that + // the handle outlives this call, and `block_on_worker` blocks + // the calling frame until the task completes, so the borrow is + // valid for the task's whole lifetime. Avoids the latent UAF / + // signing-oracle hazard if `block_on_worker` ever stops being + // synchronous (cancellation, timeout, alternate executor). + let signer_addr = signer_address_handle as usize; + + // Run the proof on a worker thread (8 MB stack). Halo 2 circuit + // synthesis recurses past the ~512 KB iOS dispatch-thread stack + // 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 { + // SAFETY: re-materialize the borrow under the caller's + // documented lifetime contract; valid for the duration of + // this synchronously-awaited task. + let address_signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + let prover = CachedOrchardProver::new(); + wallet + .shielded_shield_from_account( + shielded_account, + payment_account, + amount, + address_signer, + &prover, + ) + .await + }); + if let Err(e) = result { + 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( + 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)), + ) + }) +} + +/// Resolve both the wallet `Arc` and the network-scoped shielded +/// coordinator `Arc` for the given manager handle. Shielded +/// spend operations need the coordinator's shared store, so this +/// is the right resolver for transfer/unshield/withdraw FFIs. +fn resolve_wallet_and_coordinator( + handle: Handle, + wallet_id: &[u8; 32], +) -> Result< + ( + std::sync::Arc, + std::sync::Arc, + ), + PlatformWalletFFIResult, +> { + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + runtime().block_on(async { + let wallet = manager.get_wallet(wallet_id).await; + let coordinator = manager.shielded_coordinator().await; + (wallet, coordinator) + }) + }); + let (wallet_opt, coord_opt) = match option { + Some(v) => v, + None => { + return Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + format!("invalid manager handle: {handle}"), + )); + } + }; + let wallet = wallet_opt.ok_or_else(|| { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("wallet not found: {}", hex::encode(wallet_id)), + ) + })?; + let coordinator = coord_opt.ok_or_else(|| { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "shielded support not configured — call platform_wallet_manager_configure_shielded first", + ) + })?; + Ok((wallet, coordinator)) +} diff --git a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs index ffe22a2fb7..3f152059c8 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs @@ -29,16 +29,20 @@ use rs_sdk_ffi::{ 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, skipped: false, + cooldown_skip: summary.is_cooldown_skip, new_notes, total_scanned: summary.notes_result.total_scanned, newly_spent, - balance: summary.balance, + balance: summary.balance_total(), error_message: std::ptr::null(), } } @@ -61,13 +65,30 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_sync_start( PlatformWalletFFIResult::ok() } -/// Stop the shielded sync manager if it is running. +/// Stop the shielded sync manager and wait for any in-flight pass to +/// drain before returning. No-op if not running. +/// +/// Uses `quiesce` rather than cancel-only stop, so on return: the loop +/// is cancelled, no new pass will start, and any in-flight pass has +/// fully drained — its **persistence callbacks have completed** (no +/// note/sync-state row can be written after this returns) and its +/// completion-event *dispatch* on the Rust side has run. +/// +/// Caveat on host-observed events: a host that marshals the completion +/// callback onto its own executor (e.g. the Swift trampoline hops it to +/// the `@MainActor`) may still observe that final, already-dispatched +/// event land *after* this call returns — Rust controls when the event +/// is dispatched, not when the host's run loop applies it. The drain +/// guarantee above (no further persistence, no new pass) is the +/// load-bearing part; hosts that must ignore a trailing UI event should +/// gate their handler on their own post-stop/post-clear state (the +/// example app drops events while unbound). #[no_mangle] pub unsafe extern "C" fn platform_wallet_manager_shielded_sync_stop( handle: Handle, ) -> PlatformWalletFFIResult { let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { - manager.shielded_sync().stop(); + runtime().block_on(manager.shielded_sync().quiesce()); }); unwrap_option_or_return!(option); PlatformWalletFFIResult::ok() @@ -136,12 +157,20 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_sync_set_interval( } /// Run one shielded sync pass across all registered wallets. +/// +/// This is the user-initiated entry point (the host's "Sync Now" +/// button), so `force=true` is passed through to bypass the +/// per-wallet caught-up cooldown: a user who just sent a +/// transaction and taps the button should see the resulting +/// note immediately, not wait out the cooldown. The background +/// loop in `ShieldedSyncManager::start()` uses `force=false` +/// and honors the cooldown. #[no_mangle] pub unsafe extern "C" fn platform_wallet_manager_shielded_sync_sync_now( handle: Handle, ) -> PlatformWalletFFIResult { let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { - runtime().block_on(manager.shielded_sync().sync_now()); + runtime().block_on(manager.shielded_sync().sync_now(true)); }); unwrap_option_or_return!(option); PlatformWalletFFIResult::ok() @@ -152,56 +181,61 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_sync_sync_now( // --------------------------------------------------------------------------- /// Derive Orchard keys for the given wallet from the host-supplied -/// mnemonic resolver, open the per-network commitment tree at -/// `db_path`, and bind the resulting [`ShieldedWallet`] to the -/// `PlatformWallet`. +/// mnemonic resolver and register the resulting accounts on the +/// network-scoped shielded coordinator. +/// +/// `accounts_ptr` / `accounts_len` describe the ZIP-32 account +/// indices to derive. The slice must be non-empty and at most +/// `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. +/// **Prerequisite**: the host must have already called +/// [`platform_wallet_manager_configure_shielded`] with the +/// per-network SQLite path before invoking this function — the +/// shared commitment-tree handle is opened there, not here. +/// Calling `bind_shielded` before `configure_shielded` returns +/// `ErrorWalletOperation`. /// -/// Idempotent: a second call with a different db path / account -/// replaces the previously-bound shielded wallet. +/// Idempotent: a second call replaces the previously-bound +/// shielded wallet on the same `wallet_id`. /// /// # Safety /// - `wallet_id_bytes` must point at 32 readable bytes. +/// - `accounts_ptr` must point at `accounts_len` readable `u32`s. /// - `mnemonic_resolver_handle` must come from /// [`crate::dash_sdk_mnemonic_resolver_create`]. -/// - `db_path_cstr` must be a valid NUL-terminated UTF-8 C string. /// -/// [`ShieldedWallet`]: platform_wallet::wallet::shielded::ShieldedWallet +/// [`OrchardKeySet`]: platform_wallet::wallet::shielded::OrchardKeySet #[no_mangle] pub unsafe extern "C" fn platform_wallet_manager_bind_shielded( handle: Handle, wallet_id_bytes: *const u8, mnemonic_resolver_handle: *mut MnemonicResolverHandle, - account: u32, - db_path_cstr: *const c_char, + accounts_ptr: *const u32, + accounts_len: usize, ) -> 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); - let db_path = match CStr::from_ptr(db_path_cstr).to_str() { - Ok(s) => PathBuf::from(s), - Err(e) => { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorUtf8Conversion, - format!("db_path is not valid UTF-8: {e}"), - ); - } - }; - // Resolve mnemonic via the host callback. let mut mnemonic_buf: Zeroizing<[u8; MNEMONIC_RESOLVER_BUFFER_CAPACITY]> = Zeroizing::new([0u8; MNEMONIC_RESOLVER_BUFFER_CAPACITY]); @@ -268,13 +302,20 @@ pub unsafe extern "C" fn platform_wallet_manager_bind_shielded( let seed: Zeroizing<[u8; 64]> = Zeroizing::new(mnemonic.to_seed("")); drop(mnemonic); - // Look up the wallet on the manager and bind shielded. - let wallet_arc = { - let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { - runtime().block_on(manager.get_wallet(&wallet_id)) - }); - unwrap_option_or_return!(option) - }; + // Look up the wallet + the network-scoped shielded coordinator + // on the manager. The coordinator owns the single SQLite handle + // *and* the per-network sync-coordination registry; we hand it + // to `bind_shielded` so the wallet reuses the shared store and + // self-registers its viewing keys for the coordinator-driven + // sync loop. + let lookup = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + runtime().block_on(async { + let wallet = manager.get_wallet(&wallet_id).await; + let coordinator = manager.shielded_coordinator().await; + (wallet, coordinator) + }) + }); + let (wallet_arc, coordinator) = unwrap_option_or_return!(lookup); let wallet_arc = match wallet_arc { Some(w) => w, None => { @@ -284,8 +325,21 @@ pub unsafe extern "C" fn platform_wallet_manager_bind_shielded( ); } }; + let coordinator = match coordinator { + Some(c) => c, + None => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "shielded support not configured — call platform_wallet_manager_configure_shielded first", + ); + } + }; - 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(), + &coordinator, + )) { return PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorWalletOperation, format!("bind_shielded failed: {e}"), @@ -295,20 +349,109 @@ pub unsafe extern "C" fn platform_wallet_manager_bind_shielded( PlatformWalletFFIResult::ok() } +// --------------------------------------------------------------------------- +// Configure shielded (network-scoped) +// --------------------------------------------------------------------------- + +/// Configure the network-scoped shielded coordinator for this +/// manager. Opens (or creates) the per-network commitment-tree +/// SQLite file at `db_path_cstr` and installs a coordinator that +/// every subsequent `platform_wallet_manager_bind_shielded` call +/// reuses — one SQLite handle per network manager, regardless of +/// how many wallets bind shielded. +/// +/// Must be called **before** any `bind_shielded` on this manager. +/// Calling it again with the same path is a no-op (idempotent). +/// Calling it again with a different path returns +/// `ErrorWalletOperation`: the SQLite handle is opened once and +/// can't be repointed mid-flight. +/// +/// # Safety +/// - `db_path_cstr` must be a valid NUL-terminated UTF-8 C string. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_configure_shielded( + handle: Handle, + db_path_cstr: *const c_char, +) -> PlatformWalletFFIResult { + check_ptr!(db_path_cstr); + let db_path = match CStr::from_ptr(db_path_cstr).to_str() { + Ok(s) => PathBuf::from(s), + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorUtf8Conversion, + format!("db_path is not valid UTF-8: {e}"), + ); + } + }; + + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + runtime().block_on(manager.configure_shielded(&db_path)) + }); + let result = unwrap_option_or_return!(option); + if let Err(e) = result { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("configure_shielded failed: {e}"), + ); + } + PlatformWalletFFIResult::ok() +} + +// --------------------------------------------------------------------------- +// Clear shielded state (Rust side) +// --------------------------------------------------------------------------- + +/// Reset the Rust-side shielded state on this manager: stop the +/// background sync loop, drop every wallet registration on the +/// network-scoped coordinator, and reset the caught-up cooldown +/// stamp. +/// +/// The single SQLite commitment-tree file stays open — Clear +/// semantics are "wipe my host-side persistence and start +/// re-syncing from index 0 on the shared tree", **not** "blow +/// away the chain-wide cache". The host is responsible for +/// wiping its own per-wallet persistence layer (e.g. SwiftData +/// rows) since Rust can't reach into iOS / Android persistence; +/// after that, the next [`platform_wallet_manager_bind_shielded`] +/// call repopulates the coordinator's registries and the next +/// sync pass re-saves notes via the changeset path. +/// +/// Idempotent: calling Clear when shielded support has never +/// been configured (no coordinator installed) is still a +/// successful no-op on the coordinator side. The sync-loop stop +/// is unconditional. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_shielded_clear( + handle: Handle, +) -> PlatformWalletFFIResult { + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + // Single library call: `clear_shielded` quiesces the sync + // manager (cancel + drain the in-flight pass, incl. persister + // fan-out, so nothing re-persists after Clear) and then clears + // the coordinator registries. Keeping the quiesce+clear + // sequencing in the library (not stitched here) follows the + // FFI's "resolve handle, call one function, marshal result" + // contract. + runtime().block_on(manager.clear_shielded()); + }); + unwrap_option_or_return!(option); + PlatformWalletFFIResult::ok() +} + // --------------------------------------------------------------------------- // 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 +461,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 +482,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, }, @@ -390,7 +534,10 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_sync_wallet( std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { - runtime().block_on(manager.shielded_sync().sync_wallet(&wallet_id)) + // Per-wallet sync_wallet is exclusively a user-initiated + // entry point — same `force=true` reasoning as + // `platform_wallet_manager_shielded_sync_sync_now`. + runtime().block_on(manager.shielded_sync().sync_wallet(&wallet_id, true)) }); let result = unwrap_option_or_return!(option); match result { diff --git a/packages/rs-platform-wallet-ffi/src/shielded_types.rs b/packages/rs-platform-wallet-ffi/src/shielded_types.rs index 3cf10d60c7..152b546b93 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_types.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_types.rs @@ -32,6 +32,14 @@ pub struct ShieldedSyncWalletResultFFI { /// `true` if the wallet had no bound shielded sub-wallet (so the /// pass simply skipped it). Mutually exclusive with `success`. pub skipped: bool, + /// `true` when `success` is true but the pass was short-circuited + /// by the caught-up cooldown — no SDK fetch / trial-decrypt / + /// nullifier scan / balance read ran. When this flag is set, + /// every numeric field on this struct is zero / default — the + /// host should preserve its prior cached balance and counters + /// rather than apply the payload. `false` for every pass that + /// actually walked Platform. + pub cooldown_skip: bool, /// New decrypted notes detected this pass. pub new_notes: u32, /// Total encrypted notes scanned (decrypted + skipped). @@ -51,6 +59,7 @@ impl Default for ShieldedSyncWalletResultFFI { wallet_id: [0; 32], success: false, skipped: false, + cooldown_skip: false, new_notes: 0, total_scanned: 0, newly_spent: 0, diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index dc14ebad40..04f3ebe254 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -920,6 +920,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 { @@ -1016,10 +1022,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() @@ -1033,7 +1043,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 3a85d0c991..c63e5a262b 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 bd6650431f..1f669091c5 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 0000000000..f6839ae16f --- /dev/null +++ b/packages/rs-platform-wallet/src/changeset/shielded_changeset.rs @@ -0,0 +1,152 @@ +//! 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)); + } + + /// Split a consolidated shielded changeset into one + /// `ShieldedChangeSet` per `WalletId`. Used by the + /// network-scoped coordinator's sync path: the free + /// functions (`sync_notes_across`, `check_nullifiers_across`) + /// build a single `ShieldedChangeSet` spanning every + /// touched subwallet; the caller splits it here so each + /// per-wallet `WalletPersister.store(...)` only sees its + /// own `wallet_id`'s deltas. Empty per-wallet entries are + /// skipped so callers don't queue no-op changesets. + pub fn split_by_wallet_id( + self, + ) -> BTreeMap { + let ShieldedChangeSet { + notes_saved, + nullifiers_spent, + synced_indices, + nullifier_checkpoints, + } = self; + let mut out: BTreeMap = + BTreeMap::new(); + for (id, notes) in notes_saved { + out.entry(id.wallet_id) + .or_default() + .notes_saved + .insert(id, notes); + } + for (id, nfs) in nullifiers_spent { + out.entry(id.wallet_id) + .or_default() + .nullifiers_spent + .insert(id, nfs); + } + for (id, idx) in synced_indices { + out.entry(id.wallet_id) + .or_default() + .synced_indices + .insert(id, idx); + } + for (id, cp) in nullifier_checkpoints { + out.entry(id.wallet_id) + .or_default() + .nullifier_checkpoints + .insert(id, cp); + } + // Defensive: drop empty entries so the persister doesn't + // see noise. `split_by_wallet_id` is called on the result + // of a sync pass where at least one map is non-empty + // (otherwise the caller would have short-circuited), but + // a future caller could legitimately split an + // already-empty changeset. + out.retain(|_, cs| !cs.is_empty()); + out + } +} + +impl Merge for ShieldedChangeSet { + 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 0000000000..ef5d0f16e0 --- /dev/null +++ b/packages/rs-platform-wallet/src/changeset/shielded_sync_start_state.rs @@ -0,0 +1,52 @@ +//! 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, + /// Sync watermark: count of note positions scanned = the next + /// global index to scan (exclusive). `0` = nothing scanned yet. + pub last_synced_index: u64, + /// Last `(height, timestamp)` nullifier sync checkpoint. + pub nullifier_checkpoint: Option<(u64, u64)>, +} + +/// 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/error.rs b/packages/rs-platform-wallet/src/error.rs index 717462e065..c7eda7449e 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -141,6 +141,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/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 36ba66e89a..8e7af9be1c 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/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index ac44658e8f..78fc7db3c5 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -59,6 +59,18 @@ pub struct PlatformWalletManager { /// `start` after wallets are registered. #[cfg(feature = "shielded")] pub(super) shielded_sync_manager: Arc, + /// Network-scoped shielded coordinator. `None` until + /// `configure_shielded` opens the per-network SQLite tree; + /// once `Some`, every wallet's `bind_shielded` reuses the + /// same `Arc>` (held inside + /// the coordinator) so there's exactly one SQLite handle per + /// network manager. Phase 2 will move the sync loop here + /// from the per-wallet `ShieldedSyncManager` iteration; Phase + /// 4 deletes `ShieldedWallet` outright and the coordinator + /// owns the spend surface too. + #[cfg(feature = "shielded")] + pub(super) shielded_coordinator: + Arc>>>, pub(super) persister: Arc

, /// Cancellation token + join handle for the wallet-event adapter /// task. Held so [`shutdown`] can stop it cleanly when the manager @@ -119,9 +131,13 @@ impl PlatformWalletManager

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

{ identity_sync_manager: identity_sync, #[cfg(feature = "shielded")] shielded_sync_manager: shielded_sync, + #[cfg(feature = "shielded")] + shielded_coordinator, persister, event_adapter_cancel, event_adapter_join: tokio::sync::Mutex::new(Some(event_adapter_join)), } } + /// Configure network-scoped shielded support. Opens the + /// per-network commitment-tree SQLite file at `db_path` and + /// installs a [`NetworkShieldedCoordinator`] that every + /// subsequent `PlatformWallet::bind_shielded` will share. + /// + /// Must be called before any wallet's `bind_shielded` — + /// per-wallet bind looks up the coordinator from the manager + /// and errors out if it hasn't been configured. + /// + /// Subsequent calls with the **same** `db_path` are a no-op + /// (configuration is idempotent at the path level). A second + /// call with a **different** path returns + /// `ShieldedNotConfigured` — the SQLite handle is opened + /// once per manager and can't be repointed at a different + /// file mid-flight. (Design-doc choice (c): the path is a + /// manager-level concern, not per-wallet.) + #[cfg(feature = "shielded")] + pub async fn configure_shielded( + &self, + db_path: impl AsRef, + ) -> Result<(), crate::error::PlatformWalletError> { + use crate::wallet::shielded::{FileBackedShieldedStore, NetworkShieldedCoordinator}; + let db_path: std::path::PathBuf = db_path.as_ref().to_path_buf(); + + let mut slot = self.shielded_coordinator.write().await; + if let Some(existing) = slot.as_ref() { + if existing.db_path() == db_path.as_path() { + return Ok(()); + } + return Err(crate::error::PlatformWalletError::ShieldedStoreError( + format!( + "shielded already configured at {} — refusing to repoint at {}", + existing.db_path().display(), + db_path.display(), + ), + )); + } + + // The store opens (and creates if missing) the SQLite + // file synchronously. 100 = shardtree's max retained + // checkpoints; matches the prior per-wallet default at + // `PlatformWallet::bind_shielded`. + let store = FileBackedShieldedStore::open_path(&db_path, 100) + .map_err(|e| crate::error::PlatformWalletError::ShieldedStoreError(e.to_string()))?; + + let coordinator = Arc::new(NetworkShieldedCoordinator::new( + Arc::clone(&self.sdk), + self.sdk.network, + db_path, + store, + )); + *slot = Some(coordinator); + Ok(()) + } + + /// Snapshot of the currently-installed shielded coordinator, + /// or `None` if `configure_shielded` hasn't run yet on this + /// manager. Cloned `Arc` so callers can hold the coordinator + /// past the read-lock guard's drop. + #[cfg(feature = "shielded")] + pub async fn shielded_coordinator( + &self, + ) -> Option> { + self.shielded_coordinator + .read() + .await + .as_ref() + .map(Arc::clone) + } + + /// Tear down shielded sync state for a Clear / wipe flow. + /// + /// Single library entry point so the FFI stays a one-call bridge: + /// first **quiesce** the sync manager (cancel the loop *and* drain + /// any in-flight pass, including its persister-callback fan-out, so + /// nothing can re-persist notes after this returns), then **clear** + /// the network coordinator's per-subwallet registries. Idempotent — + /// the coordinator step is a no-op when shielded support was never + /// configured. The per-network commitment-tree SQLite file is left + /// intact (chain-wide data; the next bind re-syncs against it). + #[cfg(feature = "shielded")] + pub async fn clear_shielded(&self) { + self.shielded_sync_manager.quiesce().await; + if let Some(coord) = self.shielded_coordinator().await { + coord.clear().await; + } + } + /// Stop all background tasks and wait for them to exit. /// /// Stops the periodic coordinators (`PlatformAddressSyncManager`, diff --git a/packages/rs-platform-wallet/src/manager/shielded_sync.rs b/packages/rs-platform-wallet/src/manager/shielded_sync.rs index 167958bd8c..0e7ee5c729 100644 --- a/packages/rs-platform-wallet/src/manager/shielded_sync.rs +++ b/packages/rs-platform-wallet/src/manager/shielded_sync.rs @@ -1,21 +1,27 @@ //! Periodic shielded (Orchard) note + nullifier sync coordinator. //! //! Mirrors [`PlatformAddressSyncManager`](super::platform_address_sync::PlatformAddressSyncManager): -//! runs [`PlatformWallet::shielded_sync`] for every wallet that has a -//! bound [`ShieldedWallet`] on a fixed cadence, and emits a summary -//! event so UI and persistence layers can react. +//! drives a single +//! [`NetworkShieldedCoordinator::sync`](crate::wallet::shielded::NetworkShieldedCoordinator::sync) +//! pass on a fixed cadence and emits a summary event so UI and +//! persistence layers can react. The coordinator pass itself +//! covers every wallet registered on the network in a single +//! SDK fetch (see the coordinator's module docs). //! -//! Wallets without a bound shielded sub-wallet are silently skipped -//! — `bind_shielded` is the host's responsibility (it requires -//! mnemonic access via the keychain resolver), so the manager -//! shouldn't error out passes just because some wallets aren't yet -//! shielded-aware. +//! Empty-coordinator handling: if shielded support hasn't been +//! configured (no [`configure_shielded`] call has run yet), sync +//! passes return an empty summary — no wallet on this manager +//! can have shielded state until the coordinator exists, so +//! iterating wallets here would just produce noise. //! -//! Not auto-started. Call [`ShieldedSyncManager::start`] once the -//! shielded sub-wallets are bound. +//! Not auto-started. Call [`ShieldedSyncManager::start`] once +//! shielded support has been configured and at least one wallet +//! has bound. //! -//! Feature-gated behind `shielded` — when the feature is off, the -//! whole module is omitted from the build. +//! Feature-gated behind `shielded` — when the feature is off, +//! the whole module is omitted from the build. +//! +//! [`configure_shielded`]: crate::manager::PlatformWalletManager::configure_shielded use std::collections::BTreeMap; use std::sync::{ @@ -29,8 +35,7 @@ use tokio_util::sync::CancellationToken; use crate::events::PlatformEventManager; use crate::wallet::platform_wallet::WalletId; -use crate::wallet::shielded::ShieldedSyncSummary; -use crate::wallet::PlatformWallet; +use crate::wallet::shielded::{NetworkShieldedCoordinator, ShieldedSyncSummary}; /// Default cadence — 60s. Shielded sync is heavier than address sync /// (chunked at 2048 entries with trial decryption per entry), so this @@ -103,26 +108,35 @@ impl ShieldedSyncPassSummary { /// Periodic shielded sync coordinator. /// -/// Holds a handle to the same `wallets` map owned by +/// Holds a handle to the same `coordinator_slot` owned by /// [`PlatformWalletManager`](super::PlatformWalletManager) (via -/// `Arc`), so wallets bound after `start` are picked up on the next -/// tick without any re-registration. +/// `Arc`), so wallets bound after `start` are picked up on the +/// next tick without any re-registration (the network-scoped +/// coordinator iterates its own registry). /// /// Each pass: -/// 1. Snapshots the wallet map (short read lock, no await while -/// held). -/// 2. Calls [`PlatformWallet::shielded_sync`] on each wallet -/// sequentially. Returns -/// [`WalletShieldedOutcome::Skipped`] for unbound wallets. +/// 1. Snapshots the coordinator `Arc` (short read lock, no +/// `.await` while held). +/// 2. Calls [`NetworkShieldedCoordinator::sync`] once — the +/// coordinator handles the union of every registered +/// subwallet's IVK in a single SDK fetch. /// 3. Stores the pass timestamp. /// 4. Dispatches /// [`PlatformEventManager::on_shielded_sync_completed`]. /// /// `sync_now` is re-entrant-safe: if a pass is already running, -/// calling `sync_now` again returns an empty summary immediately. +/// calling `sync_now` again returns an empty summary +/// immediately. pub struct ShieldedSyncManager { - wallets: Arc>>>, event_manager: Arc, + /// Network-scoped shielded coordinator slot, shared with the + /// owning `PlatformWalletManager`. Sync passes route through + /// `coordinator.sync(force)` whenever the slot is populated; + /// an empty slot returns an empty pass summary (no wallets + /// can be shielded-bound without `configure_shielded` having + /// run first, so an empty slot guarantees no shielded state + /// exists). + coordinator_slot: Arc>>>, /// Cancel token for the background loop, if running. background_cancel: StdMutex>, /// Monotonically increasing generation counter. Bumped on every @@ -135,22 +149,30 @@ pub struct ShieldedSyncManager { background_generation: AtomicU64, interval_secs: AtomicU64, is_syncing: AtomicBool, + /// Set by [`quiesce`](Self::quiesce) to gate new passes while it + /// drains an in-flight one. `sync_now` / `sync_wallet` bail (after + /// taking the `is_syncing` slot) when this is set, so once `quiesce` + /// observes `is_syncing == false` no further pass can start — giving + /// Clear / stop a real "no more host-visible mutations" barrier that + /// cancel-only [`stop`](Self::stop) does not provide. + quiescing: AtomicBool, /// Unix seconds of the last completed pass. `0` = never. last_sync_unix: AtomicU64, } impl ShieldedSyncManager { pub fn new( - wallets: Arc>>>, event_manager: Arc, + coordinator_slot: Arc>>>, ) -> Self { Self { - wallets, event_manager, + coordinator_slot, background_cancel: StdMutex::new(None), background_generation: AtomicU64::new(0), interval_secs: AtomicU64::new(DEFAULT_SYNC_INTERVAL_SECS), is_syncing: AtomicBool::new(false), + quiescing: AtomicBool::new(false), last_sync_unix: AtomicU64::new(0), } } @@ -221,7 +243,14 @@ impl ShieldedSyncManager { break; } - this.sync_now().await; + // Background-loop cadence — honor the + // per-wallet caught-up cooldown so a + // sleepy network doesn't refetch + + // re-trial-decrypt the partial buffer + // chunk every interval. User-initiated + // syncs pass `force=true` to the FFI + // entry point below and bypass this. + this.sync_now(false).await; let interval = this.interval(); tokio::select! { @@ -247,6 +276,13 @@ impl ShieldedSyncManager { } /// Stop the background sync loop. No-op if not running. + /// + /// **Cancel-only**: this requests cancellation and returns + /// immediately. A pass already inside `sync_now` / + /// `coordinator.sync()` keeps running to completion (including its + /// persister-callback fan-out). For a real "nothing is running and + /// nothing more will be persisted" barrier — required by Clear, + /// unregister, and rebind — use [`quiesce`](Self::quiesce). pub fn stop(&self) { if let Some(token) = self .background_cancel @@ -258,11 +294,44 @@ impl ShieldedSyncManager { } } + /// Cancel the background loop **and wait for any in-flight sync pass + /// to fully drain** before returning — a real quiescence barrier, + /// unlike cancel-only [`stop`](Self::stop). + /// + /// After this returns, no sync pass is running and none can start + /// until the next [`start`](Self::start) / `sync_now`, so a caller + /// that immediately mutates state a pass touches (Clear's registry + /// wipe + the host's SwiftData delete; wallet unregister; rebind) + /// cannot be raced by a pass that re-persists notes after the caller + /// believed sync had stopped. + /// + /// Mechanism: set the `quiescing` gate so any pass that hasn't yet + /// taken the `is_syncing` slot bails, cancel the loop, then wait for + /// `is_syncing` to clear. `is_syncing` is held for the whole pass + /// including the persister fan-out, so its falling edge (with the + /// gate up) is a sound "fully drained" signal. The gate is reopened + /// before returning so a later start/sync works normally. + pub async fn quiesce(&self) { + self.quiescing.store(true, Ordering::Release); + self.stop(); + while self.is_syncing.load(Ordering::Acquire) { + tokio::time::sleep(Duration::from_millis(20)).await; + } + self.quiescing.store(false, Ordering::Release); + } + /// Run one sync pass across every registered wallet. /// + /// `force` is propagated to each wallet's + /// [`shielded_sync(force)`](crate::wallet::PlatformWallet::shielded_sync): + /// the background loop passes `false` to honor the per-wallet + /// caught-up cooldown; user-initiated paths (the manual + /// "Sync Now" FFI) pass `true` so a tap always re-checks + /// Platform. + /// /// If a pass is already in flight, returns an empty summary and /// skips — the caller can inspect [`is_syncing`] to distinguish. - pub async fn sync_now(&self) -> ShieldedSyncPassSummary { + pub async fn sync_now(&self, force: bool) -> ShieldedSyncPassSummary { if self .is_syncing .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) @@ -271,73 +340,93 @@ impl ShieldedSyncManager { return ShieldedSyncPassSummary::default(); } - let snapshot: Vec<(WalletId, Arc)> = { - let wallets = self.wallets.read().await; - wallets.iter().map(|(id, w)| (*id, Arc::clone(w))).collect() + // A `quiesce()` may have raised the gate between our CAS and + // here; if so, release the slot and bail without running a pass + // so the drain can complete and Clear/stop get a true barrier. + if self.quiescing.load(Ordering::Acquire) { + self.is_syncing.store(false, Ordering::Release); + return ShieldedSyncPassSummary::default(); + } + + // Snapshot the coordinator Arc and release the slot lock + // before awaiting so a concurrent `configure_shielded` + // can't deadlock against our pass. + // + // Empty-coordinator handling: if shielded support hasn't + // been configured yet, return an empty pass summary — + // `bind_shielded` requires `configure_shielded` to run + // first (the FFI enforces this), so no wallet on this + // manager can possibly have shielded state until the + // coordinator exists. + let coordinator_snapshot: Option> = { + let slot = self.coordinator_slot.read().await; + slot.as_ref().map(Arc::clone) }; - let mut summary = ShieldedSyncPassSummary::default(); - for (wallet_id, wallet) in snapshot { - let outcome = match wallet.shielded_sync().await { - Ok(Some(result)) => WalletShieldedOutcome::Ok(result), - Ok(None) => WalletShieldedOutcome::Skipped, - Err(e) => { - tracing::warn!( - "Shielded sync failed for wallet {}: {}", - hex::encode(wallet_id), - e - ); - WalletShieldedOutcome::Err(e.to_string()) - } - }; - summary.wallet_results.insert(wallet_id, outcome); - } + let mut summary = if let Some(coordinator) = coordinator_snapshot { + coordinator.sync(force).await + } else { + ShieldedSyncPassSummary::default() + }; let now = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); - summary.sync_unix_seconds = now; - self.last_sync_unix.store(now, Ordering::Release); - self.is_syncing.store(false, Ordering::Release); - + // Honor the coordinator's own `sync_unix_seconds` stamp + // when it set one; supply our own otherwise (empty pass). + if summary.sync_unix_seconds == 0 { + summary.sync_unix_seconds = now; + } + self.last_sync_unix + .store(summary.sync_unix_seconds, Ordering::Release); + + // Dispatch the completion event BEFORE clearing `is_syncing`. + // `quiesce()` drains on the falling edge of `is_syncing`, so if + // we cleared the flag first a stop/clear caller could unblock + // while this completion event (FFI callback → Swift + // `handleShieldedSyncCompleted`) is still pending — surfacing a + // stale post-stop/post-clear event. Holding the flag across the + // dispatch makes quiesce's barrier cover the event too. self.event_manager.on_shielded_sync_completed(&summary); + self.is_syncing.store(false, Ordering::Release); + summary } /// Sync a single wallet on demand. /// - /// Acquires the manager's `is_syncing` exclusion before - /// touching the wallet's shielded sub-wallet, mirroring - /// [`sync_now`]. If a pass is already in flight this returns - /// `Ok(None)` immediately rather than serializing — the caller - /// got told "no" without their request also blocking the - /// running periodic pass. Inspect [`is_syncing`] beforehand if - /// you need to distinguish "wallet has no shielded sub-wallet" - /// from "another pass was running". + /// Post-Phase-2b shape: since the coordinator's sync pass is + /// already network-wide (one SDK fetch covers every + /// registered IVK), "sync this wallet" is implemented as a + /// full coordinator pass that returns this wallet's slice of + /// the result. The result map is keyed by `WalletId`; this + /// method extracts the requested wallet's + /// [`ShieldedSyncSummary`] before returning. /// - /// Returns `Ok(None)` if the wallet has no bound shielded - /// sub-wallet, or if another sync pass was already in flight. + /// Returns `Ok(None)` if `wallet_id` isn't registered on the + /// coordinator (e.g. shielded support hasn't been configured, + /// or the wallet has never called `bind_shielded`), or if + /// another sync pass was already in flight. pub async fn sync_wallet( &self, wallet_id: &WalletId, + force: bool, ) -> Result, crate::error::PlatformWalletError> { - let wallet = { - let wallets = self.wallets.read().await; - wallets.get(wallet_id).cloned() + let coordinator_snapshot: Option> = { + let slot = self.coordinator_slot.read().await; + slot.as_ref().map(Arc::clone) + }; + let Some(coordinator) = coordinator_snapshot else { + return Ok(None); }; - let wallet = wallet.ok_or_else(|| { - crate::error::PlatformWalletError::WalletNotFound(hex::encode(wallet_id)) - })?; // Reuse the manager-wide `is_syncing` flag so a per-wallet - // sync_wallet() can't race the periodic sync_now() against - // the same `ShieldedWallet` / store. PlatformWallet's - // `shielded_sync` only takes a read lock on the optional - // shielded slot, so without this gate two passes can step - // on each other's commitment-tree appends and - // last-synced-index updates. + // `sync_wallet()` can't race the periodic `sync_now()` + // against the same store — both go through + // `coordinator.sync()`, which serializes per-coordinator + // but the manager flag is what the host UI watches. if self .is_syncing .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) @@ -346,10 +435,31 @@ impl ShieldedSyncManager { return Ok(None); } - let result = wallet.shielded_sync().await; + // Bail if a `quiesce()` raised the gate after our CAS (see + // `sync_now`) so the drain barrier holds. + if self.quiescing.load(Ordering::Acquire) { + self.is_syncing.store(false, Ordering::Release); + return Ok(None); + } + let pass = coordinator.sync(force).await; self.is_syncing.store(false, Ordering::Release); - result + + // Extract this wallet's slice from the network-wide pass + // summary. If the wallet is registered, we'll get back an + // outcome; otherwise `None`. + match pass + .wallet_results + .into_iter() + .find(|(id, _)| id == wallet_id) + { + Some((_, WalletShieldedOutcome::Ok(summary))) => Ok(Some(summary)), + Some((_, WalletShieldedOutcome::Skipped)) => Ok(None), + Some((_, WalletShieldedOutcome::Err(e))) => { + Err(crate::error::PlatformWalletError::ShieldedSyncFailed(e)) + } + None => Ok(None), + } } } diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index 5f16853202..51335dcca6 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -314,6 +314,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) => { @@ -405,6 +407,21 @@ impl PlatformWalletManager

{ .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(wallet_id)))? }; + // Detach the wallet's shielded state from the network + // coordinator. After the Phase-2b refactor the coordinator + // owns the per-`SubwalletId` viewing-key registry and the + // per-wallet `WalletPersister`; without this call a deleted + // wallet's shielded notes keep getting fetched, + // trial-decrypted, and re-persisted through the stale + // persister on the next `coordinator.sync()` pass — + // resurrecting private shielded history on disk after the + // host believed the wallet was gone. Drops the registry + + // persister entries and the per-subwallet store state. + #[cfg(feature = "shielded")] + if let Some(coordinator) = self.shielded_coordinator().await { + coordinator.unregister_wallet(*wallet_id).await; + } + for identity_id in &owned_identity_ids { self.identity_sync_manager .unregister_identity(identity_id) diff --git a/packages/rs-platform-wallet/src/wallet/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs index f683409433..df1b437116 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_addresses/provider.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs index 3dc9a67565..92e438ff81 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs @@ -120,7 +120,11 @@ impl PerAccountPlatformAddressState { /// /// Used by `PlatformAddressWallet::initialize_from_persisted` to /// push the persisted balances onto each `ManagedPlatformAccount` - /// before the provider takes over. + /// before the provider takes over — without this, spend paths + /// that enumerate funded addresses (e.g. + /// `shielded_shield_from_account`) read `available = 0` after a + /// restart until the first BLAST sync repopulates the in-memory + /// `address_balances` map. pub(crate) fn found(&self) -> &BTreeMap { &self.found } 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 aec6d5b4f9..7ce09f58a3 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs @@ -108,6 +108,12 @@ impl PlatformAddressWallet { // no read→write upgrade — doing the write-lock dance first // keeps both paths simple and avoids exposing a new public // accessor on the provider. + // + // Required by spend paths that enumerate funded addresses + // (e.g. `shielded_shield_from_account`): without this, after + // a restart they read `available = 0` until the first BLAST + // sync repopulates the in-memory map, even though SwiftData + // reports a real balance to the UI. { let mut wm = self.wallet_manager.write().await; if let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) { diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index dcd9486798..14db85ec8b 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -16,16 +16,16 @@ use super::core::{CoreWallet, WalletBalance}; use super::identity::{IdentityManager, IdentityWallet}; use super::persister::WalletPersister; use super::platform_addresses::PlatformAddressWallet; -#[cfg(feature = "shielded")] -use super::shielded::{FileBackedShieldedStore, ShieldedSyncSummary, ShieldedWallet}; +// Phase 4d.3 deleted the `ShieldedWallet` wrapper; per-account +// keysets now live in `self.shielded_keys` directly. Spend +// operations source the shared commitment-tree store from +// `NetworkShieldedCoordinator` at call time. use crate::broadcaster::SpvBroadcaster; use crate::changeset::{ ClientStartState, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, }; #[cfg(feature = "shielded")] use crate::error::PlatformWalletError; -#[cfg(feature = "shielded")] -use std::path::Path; /// Unique identifier for a wallet (32-byte hash). pub type WalletId = [u8; 32]; @@ -76,15 +76,36 @@ pub struct PlatformWallet { persister: WalletPersister, /// Lock-free balance for UI reads, cloned from `PlatformWalletInfo.balance`. pub(crate) balance: Arc, - /// Shielded (Orchard / ZK) sub-wallet. `None` until [`bind_shielded`] - /// has run; remains `None` for `WatchOnly` / `ExternalSignable` - /// wallets that have never had a resolver-driven bind. The - /// `RwLock` lets the shielded sync coordinator read the bound - /// state without serializing against unrelated wallet writes. + /// Per-account Orchard keysets, populated by [`bind_shielded`]. + /// `None` until bind has run; remains `None` for `WatchOnly` + /// / `ExternalSignable` wallets that have never had a + /// resolver-driven bind. The `RwLock` lets read paths (the + /// shielded sync coordinator, balance/address accessors) + /// observe the bound state without serializing against + /// unrelated wallet writes. + /// + /// Sync / spend operations source the shared + /// commitment-tree store from + /// [`NetworkShieldedCoordinator`] (one SQLite handle per + /// network) rather than per-wallet, so all this slot needs + /// to hold is the spend-authority keysets — the + /// `SpendAuthorizingKey` lives here, the viewing-key half + /// is mirrored on the coordinator's account registry. /// /// [`bind_shielded`]: Self::bind_shielded + /// [`NetworkShieldedCoordinator`]: crate::wallet::shielded::NetworkShieldedCoordinator + #[cfg(feature = "shielded")] + pub(crate) shielded_keys: + Arc>>>, + /// Per-wallet single-flight guard for shield-class operations + /// (Type 15). Two concurrent `shield` calls on one wallet would + /// each fetch the same address nonce and build with `nonce + 1`, so + /// the second to reach drive-abci is rejected as a replay after a + /// ~30 s proof. Holding this across fetch → build → broadcast + /// serializes the double-tap / retry-while-proving case. `Arc` so + /// cloned wallet handles share the one lock. #[cfg(feature = "shielded")] - pub(crate) shielded: Arc>>>, + pub(crate) shield_guard: Arc>, } impl PlatformWallet { @@ -287,43 +308,153 @@ impl PlatformWallet { persister: wallet_persister, balance, #[cfg(feature = "shielded")] - shielded: Arc::new(RwLock::new(None)), + shielded_keys: Arc::new(RwLock::new(None)), + #[cfg(feature = "shielded")] + shield_guard: Arc::new(tokio::sync::Mutex::new(())), } } /// 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, - db_path: impl AsRef, + accounts: &[u32], + coordinator: &Arc, ) -> Result<(), PlatformWalletError> { - // Open / create the SQLite-backed commitment tree first so - // any I/O failure surfaces before we touch the wallet's - // existing shielded slot. - let store = FileBackedShieldedStore::open_path(db_path, 100) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + // Phase 4d.3: derive the per-account `OrchardKeySet` map + // directly — no more `ShieldedWallet` wrapper. The shared + // commitment-tree store lives on the coordinator (one + // SQLite handle per network); the spend methods source it + // there at call time. The per-wallet side just needs the + // keysets (with the `SpendAuthorizingKey`) for spend + // authorization. + use super::shielded::{AccountViewingKeys, OrchardKeySet}; + if accounts.is_empty() { + return Err(PlatformWalletError::ShieldedKeyDerivation( + "shielded wallet requires at least one account".to_string(), + )); + } let network = self.sdk.network; - let wallet = - ShieldedWallet::from_seed(Arc::clone(&self.sdk), seed, network, account, store)?; + let mut keys: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for &account in accounts { + // `accounts` may contain duplicates; the BTreeMap + // dedups by definition. + let ks = OrchardKeySet::from_seed(seed, network, account)?; + keys.insert(account, ks); + } + + // Snapshot the viewing-key subset for coordinator + // registration. Privilege separation: only FVK / IVK / + // OVK / default address cross to the coordinator; the + // `SpendAuthorizingKey` stays here on the per-wallet + // side inside `OrchardKeySet`. + let account_views: std::collections::BTreeMap = keys + .iter() + .map(|(account, ks)| (*account, ks.viewing_keys())) + .collect(); + + let mut slot = self.shielded_keys.write().await; + *slot = Some(keys); + drop(slot); + + // Rebind is replace-not-merge (the doc contract above). + // `register_wallet` replaces the coordinator's `accounts` + // entries for this wallet, but it does NOT touch the + // store's per-`SubwalletId` state — so a same-process + // rebind would otherwise leave stale watermarks, orphaned + // accounts dropped from the new bind set, and abandoned + // `pending_nullifiers` reservations behind (the latter can + // make note selection skip spendable notes). Unregister + // first to purge that state; it's a no-op on first bind. + coordinator.unregister_wallet(self.wallet_id).await; - let mut slot = self.shielded.write().await; - *slot = Some(wallet); + // Register on the coordinator BEFORE restoring so the + // restore path's "is this account registered?" gate + // sees this wallet's subwallets. + coordinator + .register_wallet(self.wallet_id, account_views, self.persister.clone()) + .await; + + // Rehydrate per-subwallet notes / sync watermarks from + // the persister's start state if any are present for + // this wallet. The lookup is cheap: load() is the + // boot-time snapshot, indexed by SubwalletId. Errors are + // logged but not fatal — first-launch wallets simply + // see no persisted state. + match self.persister.load() { + Ok(start) => { + if let Err(e) = coordinator + .restore_for_wallet(self.wallet_id, &start.shielded) + .await + { + tracing::warn!( + wallet_id = %hex::encode(self.wallet_id), + error = %e, + "Failed to restore shielded snapshot at bind time" + ); + } + } + Err(e) => { + tracing::warn!( + wallet_id = %hex::encode(self.wallet_id), + error = %e, + "persister.load() failed at shielded bind time" + ); + } + } + Ok(()) + } + + /// 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> { + use super::shielded::OrchardKeySet; + let mut slot = self.shielded_keys.write().await; + let keys = slot.as_mut().ok_or(PlatformWalletError::ShieldedNotBound)?; + if keys.contains_key(&account) { + return Ok(()); + } + let ks = OrchardKeySet::from_seed(seed, self.sdk.network, account)?; + keys.insert(account, ks); + // NOTE: this only updates the per-wallet keys slot — the + // coordinator's `accounts` registry isn't refreshed here. + // Hosts that add accounts after bind should re-call + // `bind_shielded` with the full account list so the + // coordinator's viewing-key registry stays in sync. Ok(()) } @@ -331,34 +462,380 @@ impl PlatformWallet { /// [`bind_shielded`](Self::bind_shielded). #[cfg(feature = "shielded")] pub async fn is_shielded_bound(&self) -> bool { - self.shielded.read().await.is_some() + self.shielded_keys.read().await.is_some() + } + + /// Bound ZIP-32 account indices on the shielded sub-wallet, + /// in ascending order. Empty if not bound. + #[cfg(feature = "shielded")] + pub async fn shielded_account_indices(&self) -> Vec { + self.shielded_keys + .read() + .await + .as_ref() + .map(|keys| keys.keys().copied().collect()) + .unwrap_or_default() + } + + /// 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, account: u32) -> Option<[u8; 43]> { + let guard = self.shielded_keys.read().await; + guard + .as_ref() + .and_then(|keys| keys.get(&account)) + .map(|ks| ks.default_address.to_raw_address_bytes()) + } + + /// Per-account default Orchard payment addresses (raw 43 bytes). + #[cfg(feature = "shielded")] + pub async fn shielded_default_addresses(&self) -> std::collections::BTreeMap { + let guard = self.shielded_keys.read().await; + let Some(keys) = guard.as_ref() else { + return std::collections::BTreeMap::new(); + }; + keys.iter() + .map(|(account, ks)| (*account, ks.default_address.to_raw_address_bytes())) + .collect() } - /// Run one shielded sync pass on this wallet. + /// Per-account unspent shielded balance. /// - /// Returns `Ok(None)` if the shielded sub-wallet hasn't been - /// bound (the sync coordinator skips unbound wallets without - /// surfacing an error). Returns `Ok(Some(summary))` after a - /// successful pass, or `Err(_)` if the underlying sync failed. + /// Reads against the coordinator's shared store (one SQLite + /// handle per network); returns an empty map if shielded + /// support hasn't been configured or this wallet isn't + /// bound. Folds the network-wide + /// [`balances_across`](super::shielded::sync::balances_across) + /// result down to this wallet's per-account slice. #[cfg(feature = "shielded")] - pub async fn shielded_sync(&self) -> Result, PlatformWalletError> { - let guard = self.shielded.read().await; - match guard.as_ref() { - Some(wallet) => Ok(Some(wallet.sync().await?)), - None => Ok(None), + pub async fn shielded_balances( + &self, + coordinator: &Arc, + ) -> Result, PlatformWalletError> { + use super::shielded::{AccountViewingKeys, SubwalletId}; + let guard = self.shielded_keys.read().await; + let Some(keys) = guard.as_ref() else { + return Ok(std::collections::BTreeMap::new()); + }; + let subwallets: Vec<(SubwalletId, AccountViewingKeys)> = keys + .iter() + .map(|(account, ks)| { + ( + SubwalletId::new(self.wallet_id, *account), + ks.viewing_keys(), + ) + }) + .collect(); + let per_sub = + super::shielded::sync::balances_across(coordinator.store(), &subwallets).await?; + Ok(per_sub + .into_iter() + .filter(|(id, _)| id.wallet_id == self.wallet_id) + .map(|(id, v)| (id.account_index, v)) + .collect()) + } + + /// Send a private shielded → shielded transfer from `account`'s + /// notes to `recipient_raw_43` (the recipient's Orchard payment + /// address as the 43 raw bytes). + /// + /// `coordinator` supplies the shared, network-scoped + /// commitment-tree store; the wallet supplies the + /// `OrchardKeySet` (with the `SpendAuthorizingKey`) by + /// account. Privilege separation: the ASK never crosses to + /// the coordinator — the spend free function takes the + /// keyset by reference at call time. + /// + /// The prover is consumed by value rather than borrowed + /// because `OrchardProver` is impl'd on + /// `&CachedOrchardProver` (the reference type), not on the + /// bare struct. Callers pass `&CachedOrchardProver::new()` + /// and we forward it down to the spend free function's + /// `&P` parameter. + #[cfg(feature = "shielded")] + pub async fn shielded_transfer_to( + &self, + coordinator: &Arc, + account: u32, + recipient_raw_43: &[u8; 43], + amount: u64, + prover: P, + ) -> Result<(), PlatformWalletError> { + let guard = self.shielded_keys.read().await; + let keys = guard + .as_ref() + .ok_or(PlatformWalletError::ShieldedNotBound)?; + let keyset = keys.get(&account).ok_or_else(|| { + PlatformWalletError::ShieldedKeyDerivation(format!( + "shielded account {account} not bound" + )) + })?; + let recipient = Option::::from( + grovedb_commitment_tree::PaymentAddress::from_raw_address_bytes(recipient_raw_43), + ) + .ok_or_else(|| { + PlatformWalletError::ShieldedBuildError( + "invalid Orchard payment address bytes".to_string(), + ) + })?; + super::shielded::operations::transfer( + &self.sdk, + coordinator.store(), + Some(&self.persister), + self.wallet_id, + keyset, + account, + &recipient, + amount, + &prover, + ) + .await + } + + /// Unshield from `account`'s notes to a transparent platform + /// 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, + coordinator: &Arc, + account: u32, + to_platform_addr_bech32m: &str, + amount: u64, + prover: P, + ) -> Result<(), PlatformWalletError> { + let guard = self.shielded_keys.read().await; + let keys = guard + .as_ref() + .ok_or(PlatformWalletError::ShieldedNotBound)?; + let keyset = keys.get(&account).ok_or_else(|| { + PlatformWalletError::ShieldedKeyDerivation(format!( + "shielded account {account} not bound" + )) + })?; + let (to, addr_network) = + dpp::address_funds::PlatformAddress::from_bech32m_string(to_platform_addr_bech32m) + .map_err(|e| { + 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 + ))); } + super::shielded::operations::unshield( + &self.sdk, + coordinator.store(), + Some(&self.persister), + self.wallet_id, + keyset, + account, + &to, + amount, + &prover, + ) + .await } - /// 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. + /// 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_default_address(&self) -> Option<[u8; 43]> { - let guard = self.shielded.read().await; - guard + pub async fn shielded_withdraw_to( + &self, + coordinator: &Arc, + account: u32, + to_core_address: &str, + amount: u64, + core_fee_per_byte: u32, + prover: P, + ) -> Result<(), PlatformWalletError> { + let guard = self.shielded_keys.read().await; + let keys = guard .as_ref() - .map(|w| w.default_address().to_raw_address_bytes()) + .ok_or(PlatformWalletError::ShieldedNotBound)?; + let keyset = keys.get(&account).ok_or_else(|| { + PlatformWalletError::ShieldedKeyDerivation(format!( + "shielded account {account} not bound" + )) + })?; + let network = self.sdk.network; + let parsed = to_core_address + .parse::>() + .map_err(|e| { + PlatformWalletError::ShieldedBuildError(format!("invalid core address: {e}")) + })? + .require_network(network) + .map_err(|e| { + PlatformWalletError::ShieldedBuildError(format!( + "core address network mismatch: {e}" + )) + })?; + super::shielded::operations::withdraw( + &self.sdk, + coordinator.store(), + Some(&self.persister), + self.wallet_id, + keyset, + account, + &parsed, + amount, + core_fee_per_byte, + &prover, + ) + .await + } + + /// Shield credits from a Platform Payment account into the + /// 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 + /// 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 `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, + shielded_account: u32, + payment_account: u32, + amount: u64, + signer: &S, + prover: P, + ) -> Result<(), PlatformWalletError> + where + S: dpp::identity::signer::Signer + Send + Sync, + P: dpp::shielded::builder::OrchardProver, + { + // Reject zero amount at the boundary. With `amount == 0` + // the selection loop exits immediately (claim 0 >= 0) and + // the post-loop insufficient-balance check (`0 < 0`) + // doesn't fire, so an empty inputs map would otherwise + // flow into the ~30 s Halo 2 proof build and fail deep and + // opaquely. Non-Swift FFI hosts don't have the UI guard. + if amount == 0 { + return Err(PlatformWalletError::ShieldedBuildError( + "amount must be > 0".to_string(), + )); + } + + // Single-flight: serialize shield-class ops on this wallet so + // two concurrent calls can't fetch + build with the same + // address nonce (the second would be rejected as a replay after + // a ~30 s proof). Held across selection → build → broadcast. + let _shield_guard = self.shield_guard.lock().await; + + // The shield transition uses `DeductFromInput(0)` as its fee + // strategy. drive-abci interprets that as "after each input + // address has had its `claim` deducted, take the fee out of + // 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 + // 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(payment_account) + .ok_or_else(|| { + PlatformWalletError::AddressOperation(format!( + "no platform payment account at index {payment_account}" + )) + })?; + + // 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 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(); + // Selection rules live in `select_shield_inputs` (pure + + // unit-tested): sort by address, skip leading dust below the + // reserve, reserve fee headroom only on input 0, then claim + // in BTreeMap order up to `amount`. + select_shield_inputs(candidates, amount, FEE_RESERVE_CREDITS)? + }; + + let guard = self.shielded_keys.read().await; + let keys = guard + .as_ref() + .ok_or(PlatformWalletError::ShieldedNotBound)?; + let keyset = keys.get(&shielded_account).ok_or_else(|| { + PlatformWalletError::ShieldedKeyDerivation(format!( + "shielded account {shielded_account} not bound" + )) + })?; + super::shielded::operations::shield( + &self.sdk, + keyset, + shielded_account, + inputs, + amount, + signer, + &prover, + ) + .await } } @@ -456,6 +933,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) { @@ -482,7 +961,9 @@ impl Clone for PlatformWallet { persister: self.persister.clone(), balance: self.balance.clone(), #[cfg(feature = "shielded")] - shielded: self.shielded.clone(), + shielded_keys: self.shielded_keys.clone(), + #[cfg(feature = "shielded")] + shield_guard: self.shield_guard.clone(), } } } @@ -556,3 +1037,156 @@ impl DerefMut for WalletStateWriteGuard<'_> { .expect("wallet exists in guard") } } + +/// Select shield (Type 15) inputs from funded `(address, balance)` +/// candidates. +/// +/// Pure and deterministic so the selection rules are unit-testable +/// independent of the wallet manager — a future refactor can't silently +/// reintroduce the old `viable_input_0` dust/fee-reserve bug without +/// tripping a test. The rules: +/// * sort by address bytes — this fixes which input lands at index 0, +/// and the network deducts the transition fee from input 0 +/// (`DeductFromInput(0)`); +/// * skip any leading address with balance `<= fee_reserve` — input 0 +/// must keep at least `fee_reserve` unclaimed for the fee step; +/// * claim in BTreeMap order only up to `amount`, taking the reserve +/// headroom off input 0 alone. +/// +/// Errors with [`PlatformWalletError::ShieldedInsufficientBalance`] when +/// no viable input 0 exists, when usable balance can't cover +/// `amount + fee_reserve`, or when the walk can't accumulate `amount`. +#[cfg(feature = "shielded")] +fn select_shield_inputs( + mut candidates: Vec<(dpp::address_funds::PlatformAddress, u64)>, + amount: u64, + fee_reserve: u64, +) -> Result< + std::collections::BTreeMap, + PlatformWalletError, +> { + candidates.sort_by_key(|(addr, _)| *addr); + + let Some(viable_input_0) = candidates + .iter() + .position(|(_, balance)| *balance > fee_reserve) + else { + let total: u64 = candidates.iter().map(|(_, b)| b).sum(); + return Err(PlatformWalletError::ShieldedInsufficientBalance { + available: total, + required: amount.saturating_add(fee_reserve), + }); + }; + let usable = &candidates[viable_input_0..]; + + let total_usable: u64 = usable.iter().map(|(_, b)| b).sum(); + let needed = amount.saturating_add(fee_reserve); + if total_usable < needed { + return Err(PlatformWalletError::ShieldedInsufficientBalance { + available: total_usable, + required: needed, + }); + } + + let mut chosen: std::collections::BTreeMap< + dpp::address_funds::PlatformAddress, + dpp::fee::Credits, + > = std::collections::BTreeMap::new(); + let mut accumulated_claim: u64 = 0; + for (i, (addr, balance)) in usable.iter().enumerate() { + if accumulated_claim >= amount { + break; + } + let max_claim = if i == 0 { + balance.saturating_sub(fee_reserve) + } else { + *balance + }; + let still_need = amount - accumulated_claim; + let claim = max_claim.min(still_need); + if claim > 0 { + chosen.insert(*addr, claim); + accumulated_claim = accumulated_claim.saturating_add(claim); + } + } + + if accumulated_claim < amount { + return Err(PlatformWalletError::ShieldedInsufficientBalance { + available: accumulated_claim, + required: amount, + }); + } + Ok(chosen) +} + +#[cfg(all(test, feature = "shielded"))] +mod shield_input_selection_tests { + use super::*; + use dpp::address_funds::PlatformAddress; + + const RESERVE: u64 = 1_000_000_000; + + fn addr(b: u8) -> PlatformAddress { + PlatformAddress::P2pkh([b; 20]) + } + + #[test] + fn skips_leading_dust_address_below_reserve() { + // addr(1) sorts first but is dust (== reserve, not > reserve); + // addr(2) must become input 0. + let candidates = vec![(addr(1), RESERVE), (addr(2), 5 * RESERVE)]; + let chosen = select_shield_inputs(candidates, 2 * RESERVE, RESERVE).unwrap(); + assert!( + !chosen.contains_key(&addr(1)), + "dust leading address must be skipped" + ); + assert_eq!(chosen.get(&addr(2)), Some(&(2 * RESERVE))); + } + + #[test] + fn balance_exactly_at_reserve_is_not_viable_input_0() { + // Strict `> reserve`: a sole address holding exactly the reserve + // cannot be input 0. + let candidates = vec![(addr(1), RESERVE)]; + let err = select_shield_inputs(candidates, 1, RESERVE).unwrap_err(); + assert!(matches!( + err, + PlatformWalletError::ShieldedInsufficientBalance { available, required } + if available == RESERVE && required == 1 + RESERVE + )); + } + + #[test] + fn amount_equal_to_total_minus_reserve_claims_exactly_amount() { + // Single address holding exactly amount + reserve: claim == + // amount, leaving the full reserve for DeductFromInput(0). + let amount = 3 * RESERVE; + let candidates = vec![(addr(1), amount + RESERVE)]; + let chosen = select_shield_inputs(candidates, amount, RESERVE).unwrap(); + assert_eq!(chosen.len(), 1); + assert_eq!(chosen.get(&addr(1)), Some(&amount)); + } + + #[test] + fn accumulates_across_inputs_reserving_only_on_input_0() { + let amount = 5 * RESERVE; + // input 0 (addr 1) holds 2*reserve → contributes reserve after + // its headroom; addr 2 covers the rest. + let candidates = vec![(addr(1), 2 * RESERVE), (addr(2), 5 * RESERVE)]; + let chosen = select_shield_inputs(candidates, amount, RESERVE).unwrap(); + assert_eq!(chosen.get(&addr(1)), Some(&RESERVE)); + assert_eq!(chosen.get(&addr(2)), Some(&(4 * RESERVE))); + assert_eq!(chosen.values().sum::(), amount); + } + + #[test] + fn insufficient_usable_balance_errors() { + // Needs amount + reserve = 5*reserve, only 2*reserve available. + let candidates = vec![(addr(1), 2 * RESERVE)]; + let err = select_shield_inputs(candidates, 4 * RESERVE, RESERVE).unwrap_err(); + assert!(matches!( + err, + PlatformWalletError::ShieldedInsufficientBalance { .. } + )); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs new file mode 100644 index 0000000000..98aec94dc7 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -0,0 +1,642 @@ +//! Network-scoped shielded coordinator. +//! +//! The Orchard commitment tree is chain-wide: every wallet and +//! every account on the same network sees the same `cmx` stream +//! in the same order, backs the same frontier, and shares the +//! same anchor set. The current per-`PlatformWallet` shielded +//! shape (each wallet owning its own [`ShieldedWallet`] and its +//! own [`FileBackedShieldedStore`] handle) duplicates the +//! fetch + trial-decrypt + tree-append work N times for N +//! wallets, and opens N concurrent SQLite handles into a single +//! `shielded_tree_.sqlite` file. +//! +//! [`NetworkShieldedCoordinator`] is the single object that owns +//! everything chain-wide about shielded sync — the SQLite-backed +//! commitment tree, the per-`SubwalletId` notes/sync state, the +//! flat registry of every bound account's **viewing keys** (no +//! spend authority), the caught-up cooldown stamp, and the +//! persister handle. One instance per [`PlatformWalletManager`]; +//! lazily constructed on the first `bind_shielded` call so +//! networks where no wallet uses shielded never open a SQLite +//! file. +//! +//! Privilege separation: the coordinator's account registry +//! holds [`AccountViewingKeys`] only — FVK / IVK / OVK / default +//! address. The `SpendAuthorizingKey` lives only on the +//! per-wallet side (in [`OrchardKeySet`]) and is passed into the +//! coordinator's spend methods at call time, never stored at +//! coordinator scope. +//! +//! # Status (Phase 2b) +//! +//! - **Phase 0** (landed): type skeleton + viewing-key split. +//! - **Phase 1** (landed): the coordinator owns the single +//! `Arc>` for the network; +//! every `PlatformWallet::bind_shielded` reuses it. +//! - **Phase 2a** (landed): the coordinator owns the +//! network-wide caught-up cooldown and the +//! [`sync`](NetworkShieldedCoordinator::sync) entry point that +//! `ShieldedSyncManager::sync_now` routes through. +//! - **Phase 2b** (this module): the coordinator drives sync +//! itself via [`sync_notes_across`] — a single network-wide +//! SDK fetch + multi-IVK trial-decrypt against the union of +//! every registered subwallet, collapsing N per-wallet SDK +//! calls per pass to 1. The consolidated changeset is split +//! per-`WalletId` and queued through each registered +//! [`WalletPersister`]. +//! - **Phase 4** (later): delete `ShieldedWallet`, flatten +//! `PlatformWallet`'s shielded surface, and have the +//! coordinator own the spend path too (accepting an +//! `OrchardKeySet` at call time for the ASK). +//! +//! [`sync_notes_across`]: super::sync::sync_notes_across + +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use tokio::sync::RwLock; + +use super::file_store::FileBackedShieldedStore; +use super::keys::AccountViewingKeys; +use super::store::{ShieldedStore, SubwalletId}; +use super::CAUGHT_UP_COOLDOWN; +use crate::manager::shielded_sync::{ShieldedSyncPassSummary, WalletShieldedOutcome}; +use crate::wallet::persister::WalletPersister; +use crate::wallet::platform_wallet::WalletId; + +/// Network-scoped shielded coordinator. +/// +/// See module docs for the architectural rationale. +/// +/// As of Phase 2b, the coordinator owns the entire sync path +/// for the network: a single SDK fetch + multi-IVK +/// trial-decrypt against the union of every registered +/// subwallet, with the consolidated changeset split per-`WalletId` +/// and queued through each registered persister. +pub struct NetworkShieldedCoordinator { + /// Dash Platform SDK handle. The coordinator runs sync / + /// nullifier-scan / broadcast against this SDK on behalf of + /// every bound wallet. + sdk: Arc, + + /// Network this coordinator operates on. Pinned at + /// construction and never mutated — networks each get their + /// own coordinator instance. + network: dashcore::Network, + + /// On-disk path to `shielded_tree_.sqlite`. Stored so + /// subsequent `configure_shielded` calls on the same manager + /// can fail loudly if a caller passes a mismatched path + /// (design-doc choice (c): explicit error rather than + /// silently honor first or silently honor second). + db_path: PathBuf, + + /// The single SQLite handle into `shielded_tree_.sqlite`. + /// Both the commitment tree (frontier, checkpoints, marked + /// auth paths) and the per-[`SubwalletId`] notes / sync + /// watermarks / nullifier checkpoints live here. + /// + /// Every wallet and every account on this network shares + /// this `Arc>` — the single-handle property is + /// what closes the SQLite-WAL contention and the + /// delete-while-open race the prior architecture had. + store: Arc>, + + /// Flat registry of every bound `(walletId, accountIndex)` + /// pair's viewing keys, populated by + /// [`register_wallet`](Self::register_wallet). The sync loop + /// iterates this map once per pass to enumerate every IVK + /// across every wallet, then trial-decrypts each fetched + /// note against the union. + /// + /// Viewing-grade only: no [`SpendAuthorizingKey`] is ever + /// stored at coordinator scope. Spend operations re-attach + /// the ASK by accepting an [`OrchardKeySet`] parameter from + /// the per-wallet caller. + accounts: Arc>>, + + /// Per-wallet persister handles, populated by + /// [`register_wallet`](Self::register_wallet) alongside the + /// account registry. The Phase-2b sync builds a single + /// consolidated [`ShieldedChangeSet`] spanning every + /// touched subwallet, then + /// [`ShieldedChangeSet::split_by_wallet_id`] fans it back + /// out so each per-wallet `WalletPersister.store(...)` only + /// sees its own `wallet_id`'s deltas. (The wire format + /// requires this — `WalletPersister` is wallet-scoped and + /// `inner.store(wallet_id, ...)` always passes its bound + /// wallet_id to the durable layer.) + persisters: Arc>>, + + /// Timestamp of the last sync pass that observed no new + /// commitments or newly-spent nullifiers — the caught-up + /// cooldown stamp moves from per-`ShieldedWallet` scope to + /// per-coordinator scope, so the cooldown applies once per + /// network instead of once per wallet. Cleared on any + /// activity; bypassed by `force` syncs. + last_caught_up_at: std::sync::Mutex>, +} + +impl NetworkShieldedCoordinator { + /// Build a new coordinator. Called by + /// `PlatformWalletManager::configure_shielded` (Phase 1) on + /// the first shielded use on this network manager. The + /// `db_path` is opened immediately; subsequent + /// `configure_shielded` calls on the same manager verify the + /// path matches and error otherwise (open question (a) from + /// the design doc). + pub fn new( + sdk: Arc, + network: dashcore::Network, + db_path: PathBuf, + store: FileBackedShieldedStore, + ) -> Self { + Self { + sdk, + network, + db_path, + store: Arc::new(RwLock::new(store)), + accounts: Arc::new(RwLock::new(BTreeMap::new())), + persisters: Arc::new(RwLock::new(BTreeMap::new())), + last_caught_up_at: std::sync::Mutex::new(None), + } + } + + /// Network this coordinator is pinned to. Used by hosts that + /// need to assert their `PlatformWalletManager` and the + /// coordinator agree on the network. + pub fn network(&self) -> dashcore::Network { + self.network + } + + /// The on-disk SQLite path the coordinator opened. Used by + /// `PlatformWalletManager::configure_shielded` to verify + /// subsequent calls pass the same path. + pub fn db_path(&self) -> &std::path::Path { + &self.db_path + } + + /// Reference to the shared store. The full sync / spend + /// surfaces land on the coordinator itself; this accessor + /// exists for tests and for migration scaffolding in Phase 1. + pub fn store(&self) -> &Arc> { + &self.store + } + + /// Register every account of a newly-bound shielded wallet so + /// future [`sync`](Self::sync) passes iterate it, and attach + /// the wallet's [`WalletPersister`] so the coordinator can + /// queue per-wallet slices of the consolidated changeset. + /// Called by [`PlatformWallet::bind_shielded`] after the + /// per-wallet [`ShieldedWallet`] has been constructed. + /// + /// Privilege boundary: only the viewing-key subset + /// ([`AccountViewingKeys`]) is handed to the coordinator. The + /// `SpendAuthorizingKey` stays on the per-wallet side + /// (`OrchardKeySet`) and is re-attached at spend-call time. + /// + /// Idempotent: a second call with the same `wallet_id` + /// replaces every previously-registered account and the + /// persister handle for that wallet (so a re-bind after a + /// clear is consistent). + /// + /// [`ShieldedWallet`]: super::ShieldedWallet + /// [`PlatformWallet::bind_shielded`]: crate::wallet::PlatformWallet::bind_shielded + pub async fn register_wallet( + &self, + wallet_id: WalletId, + account_views: BTreeMap, + persister: WalletPersister, + ) { + let mut accounts = self.accounts.write().await; + // Drop any prior subwallets for this wallet_id before + // installing the new set so a re-bind with a different + // account list doesn't leave orphan entries. + accounts.retain(|id, _| id.wallet_id != wallet_id); + for (account_index, views) in account_views { + accounts.insert(SubwalletId::new(wallet_id, account_index), views); + } + drop(accounts); + self.persisters.write().await.insert(wallet_id, persister); + } + + /// Remove every account belonging to `wallet_id` from the + /// coordinator's registry, drop the persister handle, and + /// purge the wallet's per-subwallet store state (decrypted + /// notes, spent marks, `last_synced_note_index`, nullifier + /// checkpoints). The shared commitment tree is left intact — + /// it's a chain-wide structure, not per-wallet. No-op for + /// parts that weren't present. Called when a wallet is + /// removed from the manager. + /// + /// Purging the store watermark matters: without it, a later + /// re-bind of the same wallet would resume from the stale + /// `last_synced_note_index` and silently skip re-emitting its + /// notes to the host. + pub async fn unregister_wallet(&self, wallet_id: WalletId) { + self.accounts + .write() + .await + .retain(|id, _| id.wallet_id != wallet_id); + self.persisters.write().await.remove(&wallet_id); + if let Err(e) = self.store.write().await.purge_wallet(wallet_id) { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "Failed to purge per-subwallet store state on unregister" + ); + } + } + + /// Currently-registered subwallet ids (snapshot, ascending + /// `(wallet_id, account_index)` order). Exposed for tests and + /// for the sync coordinator's pass enumeration. + pub async fn registered_subwallets(&self) -> Vec { + self.accounts.read().await.keys().copied().collect() + } + + /// Rehydrate per-subwallet state from a host-persisted + /// snapshot for the wallet identified by `wallet_id`. Should + /// be called after [`register_wallet`](Self::register_wallet) + /// and before the first sync pass so the in-memory store + /// matches what the host already has on disk (notes, spent + /// marks, sync watermarks, nullifier checkpoints). + /// + /// Filters the supplied [`ShieldedSyncStartState`] in two + /// ways: + /// - **By `wallet_id`**: only entries whose `SubwalletId` + /// belongs to `wallet_id` are restored. The startup + /// snapshot is keyed globally by `SubwalletId` but a single + /// `restore_for_wallet` call only owns one wallet's slice; + /// the host typically loops over registered wallets and + /// calls this once per wallet so each per-wallet `bind` + /// flow drops in its own state. + /// - **By registered account**: subwallets whose + /// `account_index` isn't currently registered on this + /// coordinator are skipped — they'd accumulate state we + /// can never spend (no `OrchardKeySet` for them on the + /// per-wallet side). + /// + /// No-op on empty snapshots. + pub async fn restore_for_wallet( + &self, + wallet_id: WalletId, + snapshot: &crate::changeset::ShieldedSyncStartState, + ) -> Result<(), crate::error::PlatformWalletError> { + if snapshot.is_empty() { + return Ok(()); + } + // Snapshot of registered subwallets for the membership + // check. Cheaper than holding the accounts read lock + // across the store write below. + let registered: std::collections::BTreeSet = { + let accounts = self.accounts.read().await; + accounts + .keys() + .copied() + .filter(|id| id.wallet_id == wallet_id) + .collect() + }; + if registered.is_empty() { + return Ok(()); + } + + let mut store = self.store.write().await; + for (id, sub) in &snapshot.per_subwallet { + // Only restore subwallets that belong to `wallet_id` + // and are registered on this coordinator. + if id.wallet_id != wallet_id || !registered.contains(id) { + continue; + } + for note in &sub.notes { + store.save_note(*id, note).map_err(|e| { + crate::error::PlatformWalletError::ShieldedStoreError(e.to_string()) + })?; + if note.is_spent { + store.mark_spent(*id, ¬e.nullifier).map_err(|e| { + crate::error::PlatformWalletError::ShieldedStoreError(e.to_string()) + })?; + } + } + store + .set_last_synced_note_index(*id, sub.last_synced_index) + .map_err(|e| { + crate::error::PlatformWalletError::ShieldedStoreError(e.to_string()) + })?; + if let Some((h, t)) = sub.nullifier_checkpoint { + store.set_nullifier_checkpoint(*id, h, t).map_err(|e| { + crate::error::PlatformWalletError::ShieldedStoreError(e.to_string()) + })?; + } + } + Ok(()) + } + + /// Drop every wallet registration, purge all per-subwallet + /// store state (notes, spent marks, sync watermarks, + /// nullifier checkpoints), and reset the cooldown stamp. The + /// single SQLite handle (commitment tree) stays open — Clear + /// semantics on the host side are "wipe my persistence and + /// start re-syncing from index 0 on the shared tree", not + /// "blow away the chain-wide cache". + /// + /// Purging the in-memory `subwallets` store is what actually + /// delivers the "re-sync from index 0" contract: the sync + /// pass derives `already_have` from each subwallet's + /// `last_synced_note_index`, so leaving stale watermarks + /// behind would make a same-session re-bind report + /// caught-up and never re-emit notes to the host (it would + /// only work after a process restart that drops the + /// in-memory state). Clearing it here closes that gap. + /// + /// Used by [`platform_wallet_manager_shielded_clear`] (the + /// host's Clear button). The host then wipes its own + /// per-wallet persistence (e.g. SwiftData rows) — Rust can't + /// reach that layer — and the next `bind_shielded` call + /// repopulates the registries and resyncs from scratch. + /// + /// Resets the cooldown to `None` so the first post-clear + /// background sync pass runs immediately rather than honoring + /// a stale "caught up" stamp from before the wipe. + /// + /// [`platform_wallet_manager_shielded_clear`]: + /// rs-platform-wallet-ffi's FFI entry point + pub async fn clear(&self) { + self.accounts.write().await.clear(); + self.persisters.write().await.clear(); + if let Err(e) = self.store.write().await.purge_all_subwallets() { + tracing::warn!(error = %e, "Failed to purge subwallet store state on clear"); + } + if let Ok(mut g) = self.last_caught_up_at.lock() { + *g = None; + } + } + + /// Run one shielded sync pass for every registered wallet on + /// this coordinator's network. Returns a per-wallet outcome + /// summary suitable for emission to UI / persistence layers + /// via [`PlatformEventManager::on_shielded_sync_completed`]. + /// + /// `force=false` honors the coordinator-scoped caught-up + /// cooldown — a sync pass that observed nothing new on any + /// registered subwallet suppresses subsequent background + /// passes for [`CAUGHT_UP_COOLDOWN`]. `force=true` (the + /// user-initiated "Sync Now" path) bypasses the cooldown and + /// always walks Platform. + /// + /// Phase-2b shape: the union of every registered subwallet's + /// [`AccountViewingKeys`] drives a **single** SDK fetch via + /// [`sync_notes_across`]; the SDK's `all_notes` is locally + /// trial-decrypted against every other subwallet's IVK in + /// the same pass. The consolidated changeset is then split + /// per-[`WalletId`] and queued through each registered + /// [`WalletPersister`]. + /// + /// [`sync_notes_across`]: super::sync::sync_notes_across + /// [`PlatformEventManager::on_shielded_sync_completed`]: + /// crate::events::PlatformEventManager::on_shielded_sync_completed + pub async fn sync(&self, force: bool) -> ShieldedSyncPassSummary { + // Network-wide cooldown gate. Snapshot the remaining + // window into a local before any await — `std::sync::Mutex` + // is `!Send` across await points (clippy's + // `await_holding_lock` lint flags this). + let cooldown_remaining: Option = if force { + None + } else { + self.last_caught_up_at + .lock() + .ok() + .and_then(|guard| *guard) + .map(|when| CAUGHT_UP_COOLDOWN.saturating_sub(when.elapsed())) + .filter(|remaining| !remaining.is_zero()) + }; + + if let Some(remaining) = cooldown_remaining { + tracing::debug!( + cooldown_remaining_secs = remaining.as_secs(), + cooldown_total_secs = CAUGHT_UP_COOLDOWN.as_secs(), + "Coordinator sync skipped — within caught-up cooldown" + ); + return Self::cooldown_skip_summary(self).await; + } + + // Snapshot the flat subwallet registry. This Vec is both + // the IVK fan-out for sync_notes_across and the + // identity-map for per-wallet summary demux below. + let subwallets: Vec<(SubwalletId, AccountViewingKeys)> = { + let accounts = self.accounts.read().await; + accounts.iter().map(|(id, v)| (*id, v.clone())).collect() + }; + + let mut summary = ShieldedSyncPassSummary::default(); + if subwallets.is_empty() { + summary.sync_unix_seconds = Self::now_unix(); + return summary; + } + + // ONE SDK call covers every registered IVK on the network. + let notes = match super::sync::sync_notes_across(&self.sdk, &self.store, &subwallets).await + { + Ok(r) => r, + Err(e) => return self.fail_all_wallets(&subwallets, &e), + }; + let (newly_spent_per_sub, nf_changeset) = + match super::sync::check_nullifiers_across(&self.sdk, &self.store, &subwallets).await { + Ok(r) => r, + Err(e) => return self.fail_all_wallets(&subwallets, &e), + }; + let balances_per_sub = match super::sync::balances_across(&self.store, &subwallets).await { + Ok(r) => r, + Err(e) => return self.fail_all_wallets(&subwallets, &e), + }; + + // Merge the note-side changeset (saves + synced_index) + // with the nullifier-side changeset (spends + + // checkpoints) into one consolidated stream, then split + // per WalletId so each per-wallet `WalletPersister.store` + // only sees its own wallet's deltas. + let mut consolidated = notes.changeset.clone(); + crate::changeset::merge::Merge::merge(&mut consolidated, nf_changeset); + if !crate::changeset::merge::Merge::is_empty(&consolidated) { + let per_wallet = consolidated.split_by_wallet_id(); + let persisters = self.persisters.read().await; + for (wallet_id, cs) in per_wallet { + let Some(persister) = persisters.get(&wallet_id) else { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + "Shielded sync changeset dropped: no persister registered (host inconsistency)" + ); + continue; + }; + let full = crate::changeset::PlatformWalletChangeSet { + shielded: Some(cs), + ..Default::default() + }; + if let Err(e) = persister.store(full) { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "Failed to queue shielded changeset from coordinator" + ); + } + } + } + + // Cooldown decision based on aggregate activity across + // every subwallet — any new commitment scanned or + // newly-spent nullifier anywhere on the network clears + // the stamp so the next pass runs immediately. + let any_activity = notes.total_scanned > 0 || newly_spent_per_sub.values().any(|&n| n > 0); + if let Ok(mut guard) = self.last_caught_up_at.lock() { + if any_activity { + *guard = None; + } else { + *guard = Some(Instant::now()); + } + } + + // Demux multi-subwallet results into the per-wallet + // `ShieldedSyncSummary` shape that + // `PlatformEventManager::on_shielded_sync_completed` + // already speaks. Same emission shape as Phase 2a. + summary = + build_per_wallet_summary(&subwallets, ¬es, &newly_spent_per_sub, &balances_per_sub); + summary.sync_unix_seconds = Self::now_unix(); + summary + } + + /// Build a `ShieldedSyncPassSummary` where every registered + /// wallet's outcome is the supplied error string. Used when + /// a network-wide SDK call (sync_notes_across / + /// check_nullifiers_across) errors before any per-wallet + /// result can be produced. + fn fail_all_wallets( + &self, + subwallets: &[(SubwalletId, AccountViewingKeys)], + e: &crate::error::PlatformWalletError, + ) -> ShieldedSyncPassSummary { + let mut wallet_ids: Vec = subwallets.iter().map(|(id, _)| id.wallet_id).collect(); + wallet_ids.sort_unstable(); + wallet_ids.dedup(); + let mut summary = ShieldedSyncPassSummary::default(); + for wallet_id in wallet_ids { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "Network-wide shielded sync failed" + ); + summary + .wallet_results + .insert(wallet_id, WalletShieldedOutcome::Err(e.to_string())); + } + summary.sync_unix_seconds = Self::now_unix(); + summary + } + + fn now_unix() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) + } + + /// Build a pass summary in which every registered wallet is + /// reported as a cooldown skip. Used by [`sync`](Self::sync) + /// when the network-wide cooldown is in effect. + async fn cooldown_skip_summary(&self) -> ShieldedSyncPassSummary { + use super::sync::{ShieldedSyncSummary, SyncNotesResult}; + + let registered_wallet_ids: Vec = { + let accounts = self.accounts.read().await; + let mut ids: Vec = accounts.keys().map(|id| id.wallet_id).collect(); + ids.sort_unstable(); + ids.dedup(); + ids + }; + + let mut summary = ShieldedSyncPassSummary::default(); + for wallet_id in registered_wallet_ids { + summary.wallet_results.insert( + wallet_id, + WalletShieldedOutcome::Ok(ShieldedSyncSummary { + notes_result: SyncNotesResult::default(), + newly_spent_per_account: BTreeMap::new(), + balances: BTreeMap::new(), + is_cooldown_skip: true, + }), + ); + } + summary.sync_unix_seconds = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + summary + } +} + +/// Demux the multi-subwallet sync result into the per-wallet +/// [`ShieldedSyncSummary`] shape that +/// [`PlatformEventManager::on_shielded_sync_completed`] already +/// speaks. The coordinator drives sync at SubwalletId-flat +/// granularity but the host event stream is still per-wallet, +/// so this helper folds per-(wallet_id) slices back into the +/// `BTreeMap` per-account shape consumers expect. +/// +/// [`PlatformEventManager::on_shielded_sync_completed`]: +/// crate::events::PlatformEventManager::on_shielded_sync_completed +fn build_per_wallet_summary( + subwallets: &[(SubwalletId, AccountViewingKeys)], + notes: &super::sync::MultiSyncNotesResult, + newly_spent_per_sub: &BTreeMap, + balances_per_sub: &BTreeMap, +) -> ShieldedSyncPassSummary { + use super::sync::{ShieldedSyncSummary, SyncNotesResult}; + + // Enumerate distinct wallet_ids in ascending order so the + // BTreeMap iteration in the consumer is deterministic. + let mut wallet_ids: Vec = subwallets.iter().map(|(id, _)| id.wallet_id).collect(); + wallet_ids.sort_unstable(); + wallet_ids.dedup(); + + let mut summary = ShieldedSyncPassSummary::default(); + for wallet_id in wallet_ids { + let new_notes_per_account: BTreeMap = notes + .per_subwallet_new_notes + .iter() + .filter(|(id, _)| id.wallet_id == wallet_id) + .map(|(id, &c)| (id.account_index, c)) + .collect(); + let newly_spent_per_account: BTreeMap = newly_spent_per_sub + .iter() + .filter(|(id, _)| id.wallet_id == wallet_id) + .map(|(id, &c)| (id.account_index, c)) + .collect(); + let balances: BTreeMap = balances_per_sub + .iter() + .filter(|(id, _)| id.wallet_id == wallet_id) + .map(|(id, &v)| (id.account_index, v)) + .collect(); + + summary.wallet_results.insert( + wallet_id, + WalletShieldedOutcome::Ok(ShieldedSyncSummary { + notes_result: SyncNotesResult { + new_notes_per_account, + // `total_scanned` is a network property — + // every wallet sees the same number of new + // positions in the same pass. Surface it on + // each per-wallet summary so the host UI can + // display it without having to look up the + // pass-level value. + total_scanned: notes.total_scanned, + }, + newly_spent_per_account, + balances, + is_cooldown_skip: false, + }), + ); + } + summary +} diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index c217d0febe..6eaf180367 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,8 @@ use std::sync::Mutex; use grovedb_commitment_tree::{ClientPersistentCommitmentTree, Position, Retention}; -use super::store::{ShieldedNote, ShieldedStore}; +use super::store::{ShieldedNote, ShieldedStore, SubwalletId, SubwalletState}; +use crate::wallet::platform_wallet::WalletId; /// Error type for [`FileBackedShieldedStore`]. #[derive(Debug)] @@ -36,39 +34,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 +60,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 +68,53 @@ 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 mark_pending(&mut self, id: SubwalletId, nullifier: &[u8; 32]) -> Result { + Ok(self + .subwallets + .entry(id) + .or_default() + .mark_pending(nullifier)) + } + + fn clear_pending( + &mut self, + id: SubwalletId, + nullifier: &[u8; 32], + ) -> Result { + Ok(self + .subwallets + .get_mut(&id) + .map(|sw| sw.clear_pending(nullifier)) + .unwrap_or(false)) } fn append_commitment(&mut self, cmx: &[u8; 32], marked: bool) -> Result<(), Self::Error> { @@ -160,31 +151,194 @@ 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 { - Ok(self.last_synced_index) + fn tree_size(&self) -> Result { + let tree = self + .tree + .lock() + .map_err(|e| FileShieldedStoreError(format!("tree mutex poisoned: {e}")))?; + let size = tree + .max_leaf_position() + .map_err(|e| FileShieldedStoreError(format!("read tree size: {e}")))? + .map(|p| u64::from(p) + 1) + .unwrap_or(0); + Ok(size) + } + + fn last_synced_note_index(&self, id: SubwalletId) -> Result { + Ok(self + .subwallets + .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, + id: SubwalletId, + height: u64, + timestamp: u64, + ) -> Result<(), Self::Error> { + self.subwallets.entry(id).or_default().nullifier_checkpoint = Some((height, timestamp)); + Ok(()) + } + + fn purge_wallet(&mut self, wallet_id: WalletId) -> Result<(), Self::Error> { + // Per-subwallet note / watermark / checkpoint state is + // in-memory only (`subwallets`); the commitment tree in + // SQLite is chain-wide and intentionally left intact. + self.subwallets.retain(|id, _| id.wallet_id != wallet_id); + Ok(()) } - fn set_nullifier_checkpoint(&mut self, height: u64, timestamp: u64) -> Result<(), Self::Error> { - self.nullifier_checkpoint = Some((height, timestamp)); + fn purge_all_subwallets(&mut self) -> Result<(), Self::Error> { + self.subwallets.clear(); Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Unique temp path for a test tree (no `tempfile` dev-dep). + fn temp_tree_path(tag: &str) -> std::path::PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("shielded_tree_test_{tag}_{nanos}.sqlite")) + } + + /// Regression test for the "Shielded Merkle witness + /// unavailable" spend failure (multi-wallet shared-tree bug). + /// + /// Root cause: the shared commitment tree previously appended + /// commitments as `Ephemeral` unless the owning wallet's IVK + /// recognized them in that very sync pass. With multiple + /// wallets sharing one tree and binding at different times, a + /// note appended before its owner bound stayed Ephemeral + /// forever — shardtree has no retroactive marking — so the + /// balance showed but the spend failed to build a witness. + /// Observed on-disk symptom: every position un-witnessable + /// (missing internal nodes at `Level(2) index 0` / + /// `Level(1) index 2`). + /// + /// The fix: the shared tree marks EVERY position + /// (`append_commitment(.., true)`); per-wallet ownership is + /// tracked separately in the notes store. This test asserts + /// that a fully-marked tree witnesses every position — + /// including the rightmost (frontier) leaf whose sibling + /// doesn't exist yet — across a persist + reload cycle (the + /// cross-session round-trip a real wallet does between sync + /// and spend). + #[test] + fn all_marked_tree_witnesses_every_position_after_reload() { + let path = temp_tree_path("all_marked"); + let mut store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); + + // Mirror the real failing wallet's tree shape: 6 + // commitments, single checkpoint at the tip. The fix + // marks ALL of them regardless of ownership. + const N: u64 = 6; + for i in 0..N { + let mut cmx = [0u8; 32]; + cmx[0] = (i as u8) + 1; // distinct non-zero leaves + store.append_commitment(&cmx, true).unwrap(); + } + store.checkpoint_tree(N as u32).unwrap(); + + // Persist to SQLite and reopen — the wallet builds the + // tree in one app session and witnesses it (at spend + // time) in a later one. + drop(store); + let store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); + + let mut failures = Vec::new(); + for pos in 0..N { + match store.witness(pos) { + Ok(Some(_)) => {} + Ok(None) => failures.push(format!("position {pos}: witness returned None")), + Err(e) => failures.push(format!("position {pos}: {e}")), + } + } + + let _ = std::fs::remove_file(&path); + + assert!( + failures.is_empty(), + "every position in a fully-marked tree must be witnessable, but: {failures:?}" + ); + } + + /// `tree_size()` is the append gate the multi-subwallet sync + /// relies on to stay idempotent (it appends only positions + /// `>= tree_size`). If the count were wrong — or didn't survive + /// the persist + reload the wallet does between sessions — a + /// re-fetch from a chunk boundary would double-append and + /// corrupt the tree ("Anchor not found in the recorded anchors + /// tree" on the next spend). This asserts the count is exact + /// from empty, after appends, and across a reopen. + #[test] + fn tree_size_tracks_leaf_count_across_reload() { + let path = temp_tree_path("tree_size"); + let mut store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); + + assert_eq!(store.tree_size().unwrap(), 0, "empty tree has size 0"); + + const N: u64 = 6; + for i in 0..N { + let mut cmx = [0u8; 32]; + cmx[0] = (i as u8) + 1; + store.append_commitment(&cmx, true).unwrap(); + assert_eq!( + store.tree_size().unwrap(), + i + 1, + "size must equal leaves appended so far" + ); + } + store.checkpoint_tree(N as u32).unwrap(); + + drop(store); + let store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); + + let size = store.tree_size().unwrap(); + let _ = std::fs::remove_file(&path); + + assert_eq!( + size, N, + "tree size must survive persist + reload — the append gate \ + reads it on cold start to avoid re-appending existing leaves" + ); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/shielded/keys.rs b/packages/rs-platform-wallet/src/wallet/shielded/keys.rs index 356a42c354..ceeeddf185 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/keys.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/keys.rs @@ -115,4 +115,45 @@ impl OrchardKeySet { pub fn prepared_ivk(&self) -> PreparedIncomingViewingKey { PreparedIncomingViewingKey::new(&self.incoming_viewing_key) } + + /// Strip the spend-authorizing key and return only the + /// viewing-grade material. Used to populate the + /// network-scoped shielded coordinator's account registry — + /// the coordinator runs sync (trial-decrypt + tree append + + /// nullifier scan), none of which needs spend authority, so + /// keeping the ASK on the per-wallet side preserves the + /// privilege separation. Spend operations re-attach the ASK + /// by passing the full [`OrchardKeySet`] back into the + /// coordinator's spend methods at call time. + pub fn viewing_keys(&self) -> AccountViewingKeys { + AccountViewingKeys { + full_viewing_key: self.full_viewing_key.clone(), + incoming_viewing_key: self.incoming_viewing_key.clone(), + prepared_ivk: self.prepared_ivk(), + outgoing_viewing_key: self.outgoing_viewing_key.clone(), + default_address: self.default_address, + } + } +} + +/// Viewing-grade subset of an [`OrchardKeySet`] — the material +/// needed to detect, decrypt, and recover Orchard notes, with no +/// ability to authorize a spend. +/// +/// The network-scoped shielded coordinator holds these for every +/// bound `(walletId, accountIndex)`; it never sees a +/// `SpendAuthorizingKey`. Spend operations are driven from the +/// per-wallet side, which holds the full [`OrchardKeySet`] (ASK +/// included) and passes it into the coordinator's spend methods +/// only for the duration of that call. +#[derive(Clone)] +pub struct AccountViewingKeys { + pub full_viewing_key: FullViewingKey, + pub incoming_viewing_key: IncomingViewingKey, + /// Pre-computed for fast trial-decrypt across many notes per + /// sync pass. Cached at registration time so the sync loop + /// doesn't pay [`PreparedIncomingViewingKey::new`] per pass. + pub prepared_ivk: PreparedIncomingViewingKey, + pub outgoing_viewing_key: OutgoingViewingKey, + pub default_address: PaymentAddress, } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs index c68e0eb750..24fcb86c95 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs @@ -1,20 +1,37 @@ -//! 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 +//! - [`OrchardKeySet`] — ZIP-32 key derivation from a wallet +//! seed (FVK / IVK / OVK / `SpendAuthorizingKey` / default +//! payment address). +//! - [`AccountViewingKeys`] — viewing-grade subset of +//! `OrchardKeySet` (no ASK); the only key material that +//! crosses to coordinator scope. +//! - [`NetworkShieldedCoordinator`] — network-scoped owner of +//! the shared commitment-tree store, the per-`SubwalletId` +//! account registry, the caught-up cooldown stamp, and the +//! sync entry point. One coordinator per +//! `PlatformWalletManager`. +//! - [`ShieldedStore`] / [`InMemoryShieldedStore`] / +//! [`FileBackedShieldedStore`] — storage abstraction; the +//! shared commitment tree lives here. Per-subwallet notes are +//! scoped by [`SubwalletId`] inside the store. +//! - [`CachedOrchardProver`] — lazy-init proving key cache. +//! - Sync / spend operations live as free functions in the +//! [`sync`] and [`operations`] submodules and take +//! `(sdk, store, persister, wallet_id, keys, …)` explicitly. //! -//! 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. +//! Per-wallet shielded state on `PlatformWallet` is just the +//! `BTreeMap` (with the spend authority); +//! `PlatformWallet` doesn't need a wrapper struct around it. +pub mod coordinator; pub mod file_store; pub mod keys; pub mod note_selection; @@ -23,90 +40,20 @@ pub mod prover; pub mod store; pub mod sync; +pub use coordinator::NetworkShieldedCoordinator; pub use file_store::{FileBackedShieldedStore, FileShieldedStoreError}; -pub use keys::OrchardKeySet; +pub use keys::{AccountViewingKeys, OrchardKeySet}; pub use prover::CachedOrchardProver; -pub use store::{InMemoryShieldedStore, ShieldedNote, ShieldedStore}; +pub use store::{InMemoryShieldedStore, ShieldedNote, ShieldedStore, SubwalletId}; pub use sync::{ShieldedSyncSummary, SyncNotesResult}; -use std::sync::Arc; - -use tokio::sync::RwLock; - -use crate::error::PlatformWalletError; - -/// 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`]. -/// -/// # Thread safety +/// How long after a no-op sync the background loop should skip +/// further passes. Tuned against the wallet's typical 60s sync +/// cadence — halves wire calls in steady-state while keeping +/// "new notes" discovery latency bounded at one cooldown window +/// plus the next loop tick. Manual `force=true` syncs bypass. /// -/// 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. -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>, -} - -impl ShieldedWallet { - /// Create a shielded wallet from pre-derived keys and a store. - pub fn new(sdk: Arc, keys: OrchardKeySet, store: S) -> Self { - Self { - sdk, - keys, - store: Arc::new(RwLock::new(store)), - } - } - - /// Derive Orchard keys from a wallet seed and create a shielded wallet. - /// - /// 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( - sdk: Arc, - seed: &[u8], - network: dashcore::Network, - account: u32, - store: S, - ) -> Result { - let keys = OrchardKeySet::from_seed(seed, network, account)?; - Ok(Self::new(sdk, keys, store)) - } - - /// Total unspent shielded balance in credits. - /// - /// Reads from the store — does not trigger a sync. - pub async fn balance(&self) -> Result { - let store = self.store.read().await; - let notes = store - .get_unspent_notes() - .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 - } - - /// 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) - } -} +/// Held in the coordinator (`NetworkShieldedCoordinator::sync` +/// is the only caller); the per-`PlatformWallet` cooldown stamp +/// was removed in Phase 4a. +pub(super) const CAUGHT_UP_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(30); diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index fb6d6ea41d..684e4ac11b 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -1,31 +1,32 @@ -//! 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 is a free function taking the +//! (sdk, store, persister, wallet_id, keys, account, …) tuple +//! it needs explicitly. Phase 4d.3 lifted these out of +//! `impl ShieldedWallet` so per-wallet shielded state on +//! `PlatformWallet` can be just the keys map without a wrapper +//! struct. //! -//! 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 +//! Spends never cross account boundaries — note selection reads +//! only the given account's unspent notes. //! -//! 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()`. +//! 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 +use super::keys::OrchardKeySet; use super::note_selection::select_notes_with_fee; -use super::store::{ShieldedNote, ShieldedStore}; -use super::ShieldedWallet; +use super::store::{ShieldedNote, ShieldedStore, SubwalletId}; +use crate::changeset::{PlatformWalletChangeSet, ShieldedChangeSet}; use crate::error::PlatformWalletError; +use crate::wallet::persister::WalletPersister; +use crate::wallet::platform_wallet::WalletId; use std::collections::BTreeMap; +use std::sync::Arc; use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; use dpp::address_funds::{ @@ -40,317 +41,498 @@ use dpp::shielded::builder::{ build_shielded_transfer_transition, build_shielded_withdrawal_transition, build_unshield_transition, OrchardProver, SpendableNote, }; +use dpp::state_transition::proof_result::StateTransitionProofResult; use dpp::withdrawal::Pooling; use grovedb_commitment_tree::{Anchor, PaymentAddress}; -use tracing::{info, trace}; - -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 - pub async fn shield, P: OrchardProver>( - &self, - inputs: BTreeMap, - amount: u64, - signer: &Sig, - prover: &P, - ) -> 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(); - - let fee_strategy: AddressFundsFeeStrategy = - vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]; - - info!("Shield credits: {} credits, building proof...", amount,); - - // 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, - inputs_with_nonce, - fee_strategy, - signer, - 0, // user_fee_increase - prover, - [0u8; 36], // empty memo - self.sdk.version(), - ) - .await - .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; - - // Broadcast - trace!("Shield credits: state transition built, broadcasting..."); - state_transition - .broadcast(&self.sdk, None) - .await - .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; +use tokio::sync::RwLock; +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, + } +} - info!("Shield credits broadcast succeeded: {} credits", amount); - Ok(()) +/// Try to extract the per-address `AddressNotEnoughFundsError` that the +/// pre-flight `fetch_inputs_with_nonce` hard balance check raises when a +/// single input is short. Mirrors [`addresses_not_enough_funds`] (the +/// plural broadcast-side variant) so the pre-broadcast failure can carry +/// the same structured `(address, balance, required)` info across the +/// FFI instead of collapsing to an opaque stringified error. +fn address_not_enough_funds( + e: &dash_sdk::Error, +) -> Option<&dpp::consensus::state::address_funds::AddressNotEnoughFundsError> { + use dpp::consensus::state::state_error::StateError; + use dpp::consensus::ConsensusError; + use dpp::ProtocolError; + + let consensus: &ConsensusError = match e { + dash_sdk::Error::Protocol(ProtocolError::ConsensusError(boxed)) => boxed.as_ref(), + dash_sdk::Error::StateTransitionBroadcastError(s) => s.cause.as_ref()?, + _ => return None, + }; + match consensus { + ConsensusError::StateError(StateError::AddressNotEnoughFundsError(err)) => Some(err), + _ => None, } +} - // ------------------------------------------------------------------------- - // 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 - pub async fn shield_from_asset_lock( - &self, - asset_lock_proof: AssetLockProof, - private_key: &[u8], - amount: u64, - prover: &P, - ) -> Result<(), PlatformWalletError> { - let recipient_addr = self.default_orchard_address()?; - - info!( - "Shield from asset lock: building state transition for {} credits", - amount, +/// Format a one-line `addresses_with_info` summary for diagnostics — +/// each entry rendered as `=(nonce , credits)`, +/// matching what the wallet UI shows. +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))| { + format!( + "{}=(nonce {nonce}, {credits} credits)", + addr.to_bech32m_string(network) + ) + }) + .collect::>() + .join(", ") +} + +/// Queue a shielded changeset on the persister if one is +/// attached. No-op if the changeset is empty or no persister +/// was supplied. +fn queue_shielded_changeset( + persister: Option<&WalletPersister>, + wallet_id: WalletId, + cs: ShieldedChangeSet, +) { + if cs.is_empty() { + return; + } + let Some(persister) = persister else { + return; + }; + let full = PlatformWalletChangeSet { + shielded: Some(cs), + ..Default::default() + }; + if let Err(e) = persister.store(full) { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "Failed to queue shielded changeset" ); + } +} - let state_transition = build_shield_from_asset_lock_transition( - &recipient_addr, - amount, - asset_lock_proof, - private_key, - prover, - [0u8; 36], // empty memo - self.sdk.version(), - ) - .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; +// ------------------------------------------------------------------------- +// Shield: platform addresses -> shielded pool (Type 15) +// ------------------------------------------------------------------------- + +/// Shield credits from transparent platform addresses into the +/// shielded pool, with the resulting note assigned to `account`'s +/// default Orchard payment address derived from `keys`. +#[allow(clippy::too_many_arguments)] +pub async fn shield, P: OrchardProver>( + sdk: &Arc, + keys: &OrchardKeySet, + account: u32, + inputs: BTreeMap, + amount: u64, + signer: &Sig, + prover: &P, +) -> Result<(), PlatformWalletError> { + let recipient_addr = default_orchard_address(keys)?; + + // Reuse rs-sdk's canonical fetch + hard balance check rather than + // re-implementing the fetch-and-validate dance. Unlike the old + // warn-and-proceed path, `fetch_inputs_with_nonce` errors with + // `AddressNotEnoughFundsError` when any input is short, so we fail + // before paying the ~30 s Halo 2 proof for a transition drive-abci + // would reject. It returns the *current* on-chain nonces; we apply + // a checked increment (the canonical `nonce_inc` uses an unchecked + // `+ 1`, which would wrap an address at u32::MAX into a replay + // nonce — bail loudly here instead). + use dash_sdk::platform::transition::fetch_inputs_with_nonce; + + let fetched = fetch_inputs_with_nonce(sdk, &inputs).await.map_err(|e| { + // The hard balance check is the common pre-broadcast failure; + // surface its structured (address, balance, required) info as a + // diagnostic string rather than the opaque `{e}` form, matching + // the richness of the broadcast-side handler below. The FFI + // shape is unchanged (the host still receives a string body). + if let Some(short) = address_not_enough_funds(&e) { + PlatformWalletError::ShieldedBuildError(format!( + "shield input {} has insufficient balance: requires {} credits, has {}", + short.address().to_bech32m_string(sdk.network), + short.required_balance(), + short.balance(), + )) + } else { + PlatformWalletError::ShieldedBuildError(format!("fetch input nonces: {e}")) + } + })?; + + let mut inputs_with_nonce: BTreeMap = BTreeMap::new(); + for (addr, (nonce, credits)) in fetched { + let next_nonce = 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)); + } - trace!("Shield from asset lock: state transition built, broadcasting..."); - state_transition - .broadcast(&self.sdk, None) - .await - .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; + let fee_strategy: AddressFundsFeeStrategy = + vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]; + + info!(account, credits = amount, "Shield: building proof"); + + let claimed_inputs = inputs_with_nonce.clone(); + + let state_transition = build_shield_transition( + &recipient_addr, + amount, + inputs_with_nonce, + fee_strategy, + signer, + 0, // user_fee_increase + prover, + [0u8; 36], // empty memo + sdk.version(), + ) + .await + .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; + + trace!("Shield credits: state transition built, broadcasting..."); + let network = sdk.network; + // Wait for proven execution (not just relay-ACK) so the host only + // sees success once Platform has actually included the transition — + // matching the spend-side flows (unshield/transfer/withdraw). A + // DAPI-level ACK alone could otherwise mask a later Platform + // rejection. The proven result is discarded; we only need the + // confirmation. + state_transition + .broadcast_and_wait::(sdk, None) + .await + .map_err(|e| { + if let Some(rich) = addresses_not_enough_funds(&e) { + let claimed = claimed_inputs + .iter() + .map(|(addr, (nonce, credits))| { + format!( + "{}=(nonce {nonce}, {credits} credits)", + addr.to_bech32m_string(network) + ) + }) + .collect::>() + .join(", "); + PlatformWalletError::ShieldedBroadcastFailed(format!( + "addresses not enough funds: required {} credits; \ + claimed inputs [{}]; platform sees [{}]", + rich.required_balance(), + claimed, + format_addresses_with_info(rich.addresses_with_info(), network), + )) + } else { + PlatformWalletError::ShieldedBroadcastFailed(e.to_string()) + } + })?; - info!( - "Shield from asset lock broadcast succeeded: {} credits", - amount, - ); - Ok(()) - } + info!(account, credits = amount, "Shield broadcast succeeded"); + Ok(()) +} - // ------------------------------------------------------------------------- - // 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 - pub async fn unshield( - &self, - to_address: &PlatformAddress, - amount: u64, - prover: &P, - ) -> Result<(), PlatformWalletError> { - let change_addr = self.default_orchard_address()?; - - // 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() - .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(), - total_input, - ); +// ------------------------------------------------------------------------- +// ShieldFromAssetLock: Core L1 asset lock -> shielded pool (Type 18) +// ------------------------------------------------------------------------- + +/// Shield credits from a Core L1 asset lock into the shielded +/// pool, with the resulting note assigned to `account`'s default +/// Orchard payment address derived from `keys`. +pub async fn shield_from_asset_lock( + sdk: &Arc, + keys: &OrchardKeySet, + account: u32, + asset_lock_proof: AssetLockProof, + private_key: &[u8], + amount: u64, + prover: &P, +) -> Result<(), PlatformWalletError> { + let recipient_addr = default_orchard_address(keys)?; + + info!( + account, + credits = amount, + "Shield from asset lock: building state transition" + ); + + let state_transition = build_shield_from_asset_lock_transition( + &recipient_addr, + amount, + asset_lock_proof, + private_key, + prover, + [0u8; 36], + sdk.version(), + ) + .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; + + trace!("Shield from asset lock: state transition built, broadcasting..."); + // Wait for proven execution rather than relay-ACK. This matters most + // for Type 18: the asset-lock proof is single-use, so a false- + // positive success on a transition Platform later rejects would + // strand the user's L1 outpoint with no in-app signal. The proven + // result is discarded; we only need the confirmation. + state_transition + .broadcast_and_wait::(sdk, None) + .await + .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; + + info!( + account, + credits = amount, + "Shield from asset lock broadcast succeeded" + ); + Ok(()) +} - // Build SpendableNote structs with Merkle witnesses - let (spends, anchor) = self.extract_spends_and_anchor(&selected_notes).await?; +// ------------------------------------------------------------------------- +// Unshield: shielded pool -> platform address (Type 17) +// ------------------------------------------------------------------------- + +/// Unshield funds from `account`'s shielded notes to a +/// transparent platform address. +#[allow(clippy::too_many_arguments)] +pub async fn unshield( + sdk: &Arc, + store: &Arc>, + persister: Option<&WalletPersister>, + wallet_id: WalletId, + keys: &OrchardKeySet, + account: u32, + to_address: &PlatformAddress, + amount: u64, + prover: &P, +) -> Result<(), PlatformWalletError> { + let change_addr = default_orchard_address(keys)?; + let id = SubwalletId::new(wallet_id, account); + + let (selected_notes, total_input, exact_fee) = + reserve_unspent_notes(sdk, store, id, amount, 1).await?; + + info!( + account, + credits = amount, + fee = exact_fee, + inputs = selected_notes.len(), + total_input, + "Unshield" + ); + + // From here on every error path must release the reservation + // taken by `reserve_unspent_notes`. + let result = async { + let (spends, anchor) = extract_spends_and_anchor(store, &selected_notes).await?; let state_transition = build_unshield_transition( spends, *to_address, amount, &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(), + sdk.version(), ) .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; trace!("Unshield: state transition built, broadcasting..."); state_transition - .broadcast(&self.sdk, None) + .broadcast_and_wait::(sdk, None) .await .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; - - // Mark spent notes in store - self.mark_notes_spent(&selected_notes).await?; - - info!("Unshield broadcast succeeded: {} credits", amount); - Ok(()) + Ok::<(), PlatformWalletError>(()) } + .await; + + match result { + Ok(()) => { + // Broadcast already succeeded; spent-state bookkeeping is + // best-effort. Surfacing a local write failure as a send + // failure here would invite duplicate retries — the next + // nullifier sync reconciles any drift. + // + // No double-spend follows from this downgrade: the + // authoritative no-reuse guarantee is the on-chain nullifier + // set, not this local mark. Worst case, before the next + // nullifier sync runs the note is re-selected and a second + // spend is built + proven, then rejected at broadcast with a + // nullifier-already-used error — wasted ~30 s proof, never + // fund loss. (`pending_nullifiers` is in-memory only, so it + // does not protect across a process restart in this window; + // the on-chain set does.) + if let Err(e) = finalize_pending(store, persister, wallet_id, id, &selected_notes).await + { + warn!( + account, + error = %e, + "Unshield broadcast succeeded but local spent-state update failed; \ + will heal on next sync" + ); + } + info!(account, credits = amount, "Unshield broadcast succeeded"); + Ok(()) + } + Err(e) => { + cancel_pending(store, id, &selected_notes).await; + Err(e) + } + } +} - // ------------------------------------------------------------------------- - // 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 - pub async fn transfer( - &self, - to_address: &PaymentAddress, - amount: u64, - prover: &P, - ) -> Result<(), PlatformWalletError> { - let recipient_addr = payment_address_to_orchard(to_address)?; - let change_addr = self.default_orchard_address()?; - - // 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() - .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(), - total_input, - ); - - let (spends, anchor) = self.extract_spends_and_anchor(&selected_notes).await?; +// ------------------------------------------------------------------------- +// Transfer: shielded pool -> shielded pool (Type 16) +// ------------------------------------------------------------------------- + +/// Transfer funds privately from `account`'s shielded notes to +/// another Orchard payment address. +#[allow(clippy::too_many_arguments)] +pub async fn transfer( + sdk: &Arc, + store: &Arc>, + persister: Option<&WalletPersister>, + wallet_id: WalletId, + keys: &OrchardKeySet, + account: u32, + to_address: &PaymentAddress, + amount: u64, + prover: &P, +) -> Result<(), PlatformWalletError> { + let recipient_addr = payment_address_to_orchard(to_address)?; + let change_addr = default_orchard_address(keys)?; + let id = SubwalletId::new(wallet_id, account); + + let (selected_notes, total_input, exact_fee) = + reserve_unspent_notes(sdk, store, id, amount, 2).await?; + + info!( + account, + credits = amount, + fee = exact_fee, + inputs = selected_notes.len(), + total_input, + "Shielded transfer" + ); + + let result = async { + let (spends, anchor) = extract_spends_and_anchor(store, &selected_notes).await?; let state_transition = build_shielded_transfer_transition( spends, &recipient_addr, amount, &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(), + sdk.version(), ) .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; trace!("Shielded transfer: state transition built, broadcasting..."); state_transition - .broadcast(&self.sdk, None) + .broadcast_and_wait::(sdk, None) .await .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; - - self.mark_notes_spent(&selected_notes).await?; - - info!("Shielded transfer broadcast succeeded: {} credits", amount); - Ok(()) + Ok::<(), PlatformWalletError>(()) } + .await; + + match result { + Ok(()) => { + // Best-effort post-broadcast bookkeeping (see unshield). + if let Err(e) = finalize_pending(store, persister, wallet_id, id, &selected_notes).await + { + warn!( + account, + error = %e, + "Shielded transfer broadcast succeeded but local spent-state update \ + failed; will heal on next sync" + ); + } + info!( + account, + credits = amount, + "Shielded transfer broadcast succeeded" + ); + Ok(()) + } + Err(e) => { + cancel_pending(store, id, &selected_notes).await; + Err(e) + } + } +} - // ------------------------------------------------------------------------- - // 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 - pub async fn withdraw( - &self, - to_address: &dashcore::Address, - amount: u64, - core_fee_per_byte: u32, - prover: &P, - ) -> Result<(), PlatformWalletError> { - let change_addr = self.default_orchard_address()?; - 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() - .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(), - total_input, - ); - - let (spends, anchor) = self.extract_spends_and_anchor(&selected_notes).await?; +// ------------------------------------------------------------------------- +// Withdraw: shielded pool -> Core L1 address (Type 19) +// ------------------------------------------------------------------------- + +/// Withdraw funds from `account`'s shielded notes to a Core L1 address. +#[allow(clippy::too_many_arguments)] +pub async fn withdraw( + sdk: &Arc, + store: &Arc>, + persister: Option<&WalletPersister>, + wallet_id: WalletId, + keys: &OrchardKeySet, + account: u32, + to_address: &dashcore::Address, + amount: u64, + core_fee_per_byte: u32, + prover: &P, +) -> Result<(), PlatformWalletError> { + let change_addr = default_orchard_address(keys)?; + let id = SubwalletId::new(wallet_id, account); + let output_script = CoreScript::from_bytes(to_address.script_pubkey().to_bytes()); + + let (selected_notes, total_input, exact_fee) = + reserve_unspent_notes(sdk, store, id, amount, 1).await?; + + info!( + account, + credits = amount, + fee = exact_fee, + inputs = selected_notes.len(), + total_input, + "Shielded withdrawal" + ); + + let result = async { + let (spends, anchor) = extract_spends_and_anchor(store, &selected_notes).await?; let state_transition = build_shielded_withdrawal_transition( spends, @@ -359,128 +541,244 @@ 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(), + sdk.version(), ) .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; trace!("Shielded withdrawal: state transition built, broadcasting..."); state_transition - .broadcast(&self.sdk, None) + .broadcast_and_wait::(sdk, None) .await .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; - - self.mark_notes_spent(&selected_notes).await?; - - info!( - "Shielded withdrawal broadcast succeeded: {} credits", - amount - ); - Ok(()) + Ok::<(), PlatformWalletError>(()) + } + .await; + + match result { + Ok(()) => { + // Best-effort post-broadcast bookkeeping (see unshield). + if let Err(e) = finalize_pending(store, persister, wallet_id, id, &selected_notes).await + { + warn!( + account, + error = %e, + "Shielded withdrawal broadcast succeeded but local spent-state update \ + failed; will heal on next sync" + ); + } + info!( + account, + credits = amount, + "Shielded withdrawal broadcast succeeded" + ); + Ok(()) + } + Err(e) => { + cancel_pending(store, id, &selected_notes).await; + Err(e) + } } +} - // ------------------------------------------------------------------------- - // Internal helpers - // ------------------------------------------------------------------------- +// ------------------------------------------------------------------------- +// Internal helpers (free fns) +// ------------------------------------------------------------------------- - /// Convert this wallet's default PaymentAddress to an OrchardAddress. - fn default_orchard_address(&self) -> Result { - payment_address_to_orchard(&self.keys.default_address) - } +/// Convert `keys`'s default `PaymentAddress` to an `OrchardAddress`. +fn default_orchard_address(keys: &OrchardKeySet) -> Result { + 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)] - async fn extract_spends_and_anchor( - &self, - notes: &[ShieldedNote], - ) -> Result<(Vec, Anchor), PlatformWalletError> { - let store = self.store.read().await; - - 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 {}", +/// Extract `SpendableNote` structs with Merkle witnesses and the +/// tree anchor. +/// +/// The anchor is derived from the witness paths themselves (via +/// `MerklePath::root(cmx)`) rather than from `store.tree_anchor()`. +/// The store's witness call is `witness_at_checkpoint_depth(0)` +/// (root of the most recent checkpoint) while `tree_anchor()` is +/// `root_at_checkpoint_depth(None)` (latest tree state) — any +/// commitments appended after the last checkpoint move the latter +/// ahead of the former, and the resulting `AnchorMismatch` from +/// the Orchard spend builder is what you'd see at proof time. +/// Using the witness's own computed root keeps the anchor +/// consistent with the authentication paths the proof actually +/// verifies. +async fn extract_spends_and_anchor( + store: &Arc>, + notes: &[ShieldedNote], +) -> Result<(Vec, Anchor), PlatformWalletError> { + use grovedb_commitment_tree::ExtractedNoteCommitment; + + let store = store.read().await; + + let mut spends = Vec::with_capacity(notes.len()); + let mut anchor: Option = None; + for note in notes { + let orchard_note = deserialize_note(¬e.note_data).ok_or_else(|| { + PlatformWalletError::ShieldedBuildError(format!( + "Failed to deserialize note at position {}", + note.position + )) + })?; + + 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 )) })?; - // 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()) - })?; - - // 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(), - )); - } - - let anchor_bytes = store - .tree_anchor() - .map_err(|e| PlatformWalletError::ShieldedMerkleWitnessUnavailable(e.to_string()))?; - let anchor = Anchor::from_bytes(anchor_bytes) + // 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( - "Invalid anchor bytes from commitment tree".to_string(), - ) + 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 + ))); + } + _ => {} + } - Ok((spends, anchor)) + spends.push(SpendableNote { + note: orchard_note, + merkle_path, + }); } - /// Mark selected notes as spent in the store. - async fn mark_notes_spent(&self, notes: &[ShieldedNote]) -> Result<(), PlatformWalletError> { - let mut store = self.store.write().await; + let anchor = anchor.ok_or_else(|| { + PlatformWalletError::ShieldedBuildError( + "no spendable notes selected — anchor undefined".to_string(), + ) + })?; + + Ok((spends, anchor)) +} +/// Mark the selected notes as spent for `id`. Also queues a +/// shielded changeset on the persister so the spent flag reaches +/// durable storage immediately rather than waiting for the next +/// nullifier-sync pass to rediscover the spend. Also drops any +/// matching pending reservation so the confirmed-spent state +/// and the in-flight-spend state can't disagree. +async fn mark_notes_spent( + store: &Arc>, + persister: Option<&WalletPersister>, + wallet_id: WalletId, + id: SubwalletId, + notes: &[ShieldedNote], +) -> Result<(), PlatformWalletError> { + let mut changeset = ShieldedChangeSet::default(); + { + let mut store = store.write().await; for note in notes { - store - .mark_spent(¬e.nullifier) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + if store + .mark_spent(id, ¬e.nullifier) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))? + { + changeset.record_nullifier_spent(id, note.nullifier); + } } - - Ok(()) } + queue_shielded_changeset(persister, wallet_id, changeset); + Ok(()) } -/// Helper trait extension for note selection results that need to own the data. +/// Select unspent notes and reserve them against an in-flight +/// spend in one write-locked critical section. +/// +/// Combining selection and reservation under a single write lock +/// is the only thing that prevents two overlapping spend calls +/// from picking the same notes: with separate read-then-write +/// phases, the second caller would observe the same +/// `unspent_notes()` between the first caller's read and write +/// and proceed to build a duplicate proof that's only rejected +/// ~30 s later at broadcast time. /// -/// When note selection is performed inside a store lock scope, we need to -/// clone the results so they can outlive the lock. +/// The reservation is in-memory only — see +/// [`ShieldedStore::mark_pending`] for the crash-recovery note. +/// Callers must pair this with [`finalize_pending`] (on +/// broadcast success) or [`cancel_pending`] (on failure) so the +/// reservation is always released. +async fn reserve_unspent_notes( + sdk: &Arc, + store: &Arc>, + id: SubwalletId, + amount: u64, + outputs: usize, +) -> Result<(Vec, u64, u64), PlatformWalletError> { + let mut store = store.write().await; + let unspent = store + .get_unspent_notes(id) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + let (selected, total_input, exact_fee) = + select_notes_with_fee(&unspent, amount, outputs, sdk.version())?.into_owned(); + for note in &selected { + store + .mark_pending(id, ¬e.nullifier) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + } + Ok((selected, total_input, exact_fee)) +} + +/// Promote a successful broadcast: mark the notes spent (which +/// also clears any matching pending reservation, see +/// [`SubwalletState::mark_spent`]) and queue the changeset for +/// the host persister. +async fn finalize_pending( + store: &Arc>, + persister: Option<&WalletPersister>, + wallet_id: WalletId, + id: SubwalletId, + notes: &[ShieldedNote], +) -> Result<(), PlatformWalletError> { + mark_notes_spent(store, persister, wallet_id, id, notes).await +} + +/// Roll back a reservation when the broadcast / wait fails. +/// Best-effort and doesn't surface its own errors — the caller +/// is already returning the broadcast error. +async fn cancel_pending( + store: &Arc>, + id: SubwalletId, + notes: &[ShieldedNote], +) { + let mut store = store.write().await; + for note in notes { + if let Err(e) = store.clear_pending(id, ¬e.nullifier) { + tracing::warn!( + error = %e, + "cancel_pending: clear_pending failed; the next nullifier sync will reconcile" + ); + } + } +} + +/// Helper to clone selection results out from under the store lock. trait SelectionResultOwned { fn into_owned(self) -> (Vec, u64, u64); } @@ -493,7 +791,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 { @@ -507,8 +805,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 54e5bde9de..7268a4f0d0 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -1,24 +1,62 @@ //! 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` — 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). +//! # 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::collections::{BTreeMap, BTreeSet}; use std::error::Error as StdError; use std::fmt; -/// A note decrypted and owned by this wallet. +use crate::wallet::platform_wallet::WalletId; + +/// Identifies a single shielded "subwallet" — one Orchard account +/// within one wallet. Used to scope notes, nullifier indices, and +/// sync watermarks inside a [`ShieldedStore`] so a single store +/// 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. @@ -34,78 +72,231 @@ 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 ────────────────────────────────────────────────────────── - - /// Persist a newly decrypted note. - fn save_note(&mut self, note: &ShieldedNote) -> Result<(), Self::Error>; + // ── Notes (per-subwallet) ────────────────────────────────────────── - /// Return all unspent (not yet nullified) notes. - fn get_unspent_notes(&self) -> Result, Self::Error>; + /// Persist a newly decrypted note for `id`. + fn save_note(&mut self, id: SubwalletId, note: &ShieldedNote) -> Result<(), Self::Error>; - /// Return all notes (both spent and unspent). - fn get_all_notes(&self) -> Result, Self::Error>; + /// Return all unspent notes for `id`. + fn get_unspent_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; + /// Return all notes (spent and unspent) for `id`. + fn get_all_notes(&self, id: SubwalletId) -> Result, Self::Error>; - // ── Commitment tree ──────────────────────────────────────────────── + /// 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; - /// Append a note commitment to the commitment tree. + /// Reserve `id`'s note with `nullifier` against an in-flight + /// spend so concurrent callers can't pick the same note. + /// Returns `true` if the nullifier was newly added to the + /// pending set, `false` if it was already pending. + /// + /// Pending state is **in-memory only** — it does not survive + /// a process restart. The crash-during-broadcast case is + /// reconciled by the next nullifier-sync pass after the + /// transition lands (or, on rejection, leaves the notes + /// observable as unspent again on the next launch). + /// + /// `unspent_notes` skips notes whose nullifier is in the + /// pending set, so a successful `mark_pending` immediately + /// removes the note from selection candidates. + fn mark_pending(&mut self, id: SubwalletId, nullifier: &[u8; 32]) -> Result; + + /// Release the reservation taken by [`Self::mark_pending`]. + /// Returns `true` if the nullifier was actually pending and + /// got removed; `false` is a no-op (paired clear from the + /// rollback path on a transition that never marked pending, + /// or a stale clear after the spend already promoted to + /// `mark_spent`). + fn clear_pending(&mut self, id: SubwalletId, nullifier: &[u8; 32]) + -> Result; + + // ── Commitment tree (network-shared) ─────────────────────────────── + + /// Append a note commitment to the shared tree. /// - /// `marked` indicates whether this position should be remembered for - /// future witness generation (i.e. it belongs to this wallet). + /// `marked` controls whether shardtree retains the + /// authentication path for this position (Marked) or prunes it + /// (Ephemeral). The sync path passes `true` for **every** + /// position: the tree is a chain-wide structure shared by all + /// wallets, and wallets bind at different times, so deciding + /// retention from "is this position owned right now" loses the + /// ability to witness a note whose owner binds later + /// (shardtree can't retroactively mark). Per-wallet ownership + /// is tracked separately in the per-`SubwalletId` note store. + /// The `marked` parameter is kept for store-level flexibility + /// and tests. fn append_commitment(&mut self, cmx: &[u8; 32], marked: bool) -> Result<(), Self::Error>; /// Create a tree checkpoint at the given identifier. - /// - /// 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. Returns the path as raw bytes. + /// 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>; + + /// Number of leaves currently in the shared commitment tree + /// (= highest appended position + 1, or 0 when empty). + /// + /// This is the append watermark for the tree itself, distinct + /// from any per-subwallet `last_synced_note_index`. The sync + /// path gates [`Self::append_commitment`] on this value — never + /// on a per-subwallet watermark — so a re-fetch from a chunk + /// boundary (forced when a lagging subwallet rewinds the fetch + /// start) re-appends nothing already in the tree. Double-append + /// corrupts the shardtree's internal nodes and makes per-position + /// witnesses resolve against inconsistent roots ("Anchor not + /// found in the recorded anchors tree" at spend time). + fn tree_size(&self) -> Result; + + // ── Sync state (per-subwallet) ───────────────────────────────────── + + /// Sync watermark for `id`: the count of note positions already + /// scanned, i.e. the next global commitment-tree index to scan. + /// `0` means nothing scanned yet; `N` means positions `0..N` are + /// done. Exclusive upper bound — *not* the last index scanned + /// (scanning through position `N-1` sets this to `N`). + fn last_synced_note_index(&self, id: SubwalletId) -> Result; + + /// Persist the sync watermark (next index to scan) for `id`. + fn set_last_synced_note_index( + &mut self, + id: SubwalletId, + index: u64, + ) -> Result<(), Self::Error>; + + /// 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 lifecycle ──────────────────────────────────────── + + /// Drop ALL in-memory per-subwallet state (decrypted notes, + /// spent marks, `last_synced_note_index`, nullifier + /// checkpoints, pending reservations) for every subwallet + /// belonging to `wallet_id`. The shared commitment tree is + /// left untouched — it's a chain-wide structure, not + /// per-wallet. Used when a wallet is removed or its shielded + /// binding is cleared so a later re-bind resyncs from index 0 + /// rather than resuming behind the stale watermark. + fn purge_wallet(&mut self, wallet_id: WalletId) -> Result<(), Self::Error>; + + /// Drop ALL in-memory per-subwallet state for every subwallet + /// of every wallet. The shared commitment tree is left + /// untouched. Used by `NetworkShieldedCoordinator::clear()`. + fn purge_all_subwallets(&mut self) -> Result<(), Self::Error>; +} + +// ── Per-subwallet bookkeeping ────────────────────────────────────────── + +/// 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>, + /// Sync watermark: count of note positions scanned = the next + /// global index to scan (exclusive). `0` = nothing scanned yet. + pub last_synced_index: u64, + /// `(height, timestamp)` from the most recent nullifier sync. + pub nullifier_checkpoint: Option<(u64, u64)>, + /// Nullifiers of notes currently being spent in an in-flight + /// transition. Excluded from `unspent_notes()` so concurrent + /// callers can't double-select. In-memory only — never + /// persisted; the next sync after a crash reconciles state. + pub pending_nullifiers: BTreeSet<[u8; 32]>, +} + +impl SubwalletState { + /// Save (or overwrite-by-nullifier) a note. /// - /// 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>; + /// 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()); + } - // ── Sync state ───────────────────────────────────────────────────── + pub(super) fn unspent_notes(&self) -> Vec { + self.notes + .iter() + .filter(|n| !n.is_spent && !self.pending_nullifiers.contains(&n.nullifier)) + .cloned() + .collect() + } - /// The last global note index that was synced from Platform. - fn last_synced_note_index(&self) -> Result; + pub(super) fn all_notes(&self) -> Vec { + self.notes.clone() + } - /// Persist the last synced note index. - fn set_last_synced_note_index(&mut self, index: 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; + // Promotion implies the spend confirmed; drop any + // matching pending reservation. Idempotent — the + // common path already cleared pending in the + // spend-flow finalizer. + self.pending_nullifiers.remove(nullifier); + return true; + } + } + false + } - /// The last nullifier sync checkpoint, if any. - /// - /// Returns `(height, timestamp)` from the most recent nullifier sync. - fn nullifier_checkpoint(&self) -> Result, Self::Error>; + /// Reserve `nullifier` against an in-flight spend. Returns + /// `true` if newly added. + pub(super) fn mark_pending(&mut self, nullifier: &[u8; 32]) -> bool { + self.pending_nullifiers.insert(*nullifier) + } - /// Persist the nullifier sync checkpoint. - fn set_nullifier_checkpoint(&mut self, height: u64, timestamp: u64) -> Result<(), Self::Error>; + /// Release a reservation previously taken via `mark_pending`. + /// Returns `true` if a matching reservation was actually + /// removed. + pub(super) fn clear_pending(&mut self, nullifier: &[u8; 32]) -> bool { + self.pending_nullifiers.remove(nullifier) + } } // ── InMemoryShieldedStore ────────────────────────────────────────────── @@ -122,82 +313,82 @@ 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 mark_pending(&mut self, id: SubwalletId, nullifier: &[u8; 32]) -> Result { + Ok(self + .subwallets + .entry(id) + .or_default() + .mark_pending(nullifier)) + } + + fn clear_pending( + &mut self, + id: SubwalletId, + nullifier: &[u8; 32], + ) -> Result { + Ok(self + .subwallets + .get_mut(&id) + .map(|sw| sw.clear_pending(nullifier)) + .unwrap_or(false)) } fn append_commitment(&mut self, cmx: &[u8; 32], marked: bool) -> Result<(), Self::Error> { @@ -212,34 +403,63 @@ 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) } - fn witness(&self, _position: u64) -> Result, Self::Error> { - // In-memory store does not support real Merkle witness generation. - // Production implementations use ClientPersistentCommitmentTree. + fn witness( + &self, + _position: u64, + ) -> Result, Self::Error> { Err(InMemoryStoreError( "Merkle witness not supported in in-memory store".into(), )) } - fn last_synced_note_index(&self) -> Result { - Ok(self.last_synced_index) + fn tree_size(&self) -> Result { + Ok(self.commitments.len() as u64) + } + + 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, + id: SubwalletId, + height: u64, + timestamp: u64, + ) -> Result<(), Self::Error> { + self.subwallets.entry(id).or_default().nullifier_checkpoint = Some((height, timestamp)); + Ok(()) + } + + fn purge_wallet(&mut self, wallet_id: WalletId) -> Result<(), Self::Error> { + self.subwallets.retain(|id, _| id.wallet_id != wallet_id); + Ok(()) } - fn set_nullifier_checkpoint(&mut self, height: u64, timestamp: u64) -> Result<(), Self::Error> { - self.nullifier_checkpoint = Some((height, timestamp)); + fn purge_all_subwallets(&mut self) -> Result<(), Self::Error> { + self.subwallets.clear(); Ok(()) } } @@ -248,9 +468,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], @@ -260,17 +485,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, @@ -281,52 +511,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); + store.save_note(id, ¬e).unwrap(); - // Should no longer appear in unspent - let unspent = store.get_unspent_notes().unwrap(); - assert!(unspent.is_empty()); - - // 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 fdb3ed0547..7eb0e48d3b 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs @@ -1,272 +1,576 @@ -//! Shielded note and nullifier synchronization. +//! Shielded note + nullifier synchronization. //! -//! 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) +//! The heavy lifting lives in three free functions that take a +//! flat `&[(SubwalletId, AccountViewingKeys)]` slice and drive a +//! single network-wide SDK fetch per pass: +//! - [`sync_notes_across`] — fetches encrypted notes once, +//! trial-decrypts against the union of every subwallet's IVK, +//! appends commitments to the shared tree exactly once per +//! position with `marked = any subwallet decrypted it`, and +//! saves decrypted notes scoped per-`SubwalletId`. +//! - [`check_nullifiers_across`] — privacy-preserving nullifier +//! scan per subwallet (the SDK's nullifier-scan API is +//! per-checkpoint, so it stays per-subwallet). +//! - [`balances_across`] — pure unspent-balance read against +//! the shared store. +//! +//! [`NetworkShieldedCoordinator::sync`] drives all three in +//! sequence against the union of every registered subwallet. +//! Per-wallet `PlatformWallet` shielded methods read from the +//! same store via the coordinator handle they're handed at +//! call time (post-Phase-4d.3 — no more `ShieldedWallet` +//! wrapper). +//! +//! [`NetworkShieldedCoordinator::sync`]: +//! super::coordinator::NetworkShieldedCoordinator::sync -use super::store::ShieldedStore; -use super::ShieldedWallet; -use crate::error::PlatformWalletError; +use std::collections::BTreeMap; +use std::sync::Arc; 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}; +use tokio::sync::RwLock; use tracing::{debug, info, warn}; -/// Server-enforced chunk size -- start_index must be a multiple of this. +use super::keys::AccountViewingKeys; +use super::store::{ShieldedStore, SubwalletId}; +use crate::changeset::ShieldedChangeSet; +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, + /// Number of **new** positions scanned this pass — i.e. + /// commitments at positions `>= already_have` that the SDK + /// returned. Re-scanned positions (the partial-chunk + /// re-fetch that Platform's chunked sync semantics force on + /// every pass while the buffer chunk is mutable) are + /// excluded so the cumulative counter the host displays + /// reflects "new work seen", not wire-level fetch volume. pub total_scanned: u64, } -/// 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, + /// True when the pass was short-circuited by the + /// caught-up cooldown (see [`super::CAUGHT_UP_COOLDOWN`]) — + /// no SDK fetch, no trial-decrypt, no nullifier scan. The + /// `balances` field is still populated from local state so + /// hosts can keep balance displays current, but counters / + /// last-sync timestamps should treat this as a no-op + /// distinct from a genuine "ran and found nothing" pass. + /// `false` for any pass that actually walked Platform. + pub is_cooldown_skip: bool, } -impl ShieldedWallet { - /// Sync encrypted notes from Platform. - /// - /// 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. - 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 - let already_have = { - let store = self.store.read().await; - store - .last_synced_note_index() - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))? - }; - let aligned_start = (already_have / CHUNK_SIZE) * CHUNK_SIZE; +impl ShieldedSyncSummary { + /// Sum of unspent balances across accounts. + pub fn balance_total(&self) -> u64 { + self.balances.values().copied().sum() + } - info!( - "Starting shielded note sync: last_synced={}, aligned_start={}", - already_have, aligned_start, - ); + /// Sum of newly-spent counts across accounts. + pub fn total_newly_spent(&self) -> usize { + self.newly_spent_per_account.values().sum() + } +} - // Step 2: Fetch and trial-decrypt via SDK - let result = sync_shielded_notes(&self.sdk, &prepared_ivk, aligned_start, None) - .await - .map_err(|e| PlatformWalletError::ShieldedSyncFailed(e.to_string()))?; +/// Result of a multi-subwallet note-sync pass. +/// +/// Produced by [`sync_notes_across`] and consumed by the +/// coordinator's [`NetworkShieldedCoordinator::sync`] flow. +/// +/// `total_scanned` is a property of the **network fetch**, not of +/// any individual subwallet — every subwallet on the network +/// sees the same set of new positions in the same order, so +/// surfacing per-subwallet `total_scanned` would just duplicate +/// the same number `N` times. +/// +/// [`NetworkShieldedCoordinator::sync`]: +/// super::coordinator::NetworkShieldedCoordinator::sync +#[derive(Debug, Clone, Default)] +pub struct MultiSyncNotesResult { + /// Per-subwallet count of new notes discovered in this pass. + pub per_subwallet_new_notes: BTreeMap, + /// Wire-level scan volume this pass — encrypted notes pulled from + /// Platform (decrypted + skipped), computed as `(aligned_start + + /// total_notes_scanned).saturating_sub(already_have)`. This is the + /// host-visible "Scanned" counter and is deliberately NOT the count + /// of newly-appended tree positions (the tree_size append gate makes + /// the two diverge on re-fetch). See + /// [`SyncNotesResult::total_scanned`] for the rationale. + pub total_scanned: u64, + /// Accumulated persistence changeset spanning every touched + /// subwallet. The caller decides whether to queue it on the + /// shared `WalletPersister`. + pub changeset: ShieldedChangeSet, +} - info!( - "Sync complete: total_scanned={}, decrypted={}, next_start_index={}", - result.total_notes_scanned, - result.decrypted_notes.len(), - result.next_start_index, - ); +impl MultiSyncNotesResult { + /// Total new notes across every subwallet. + pub fn total_new_notes(&self) -> usize { + self.per_subwallet_new_notes.values().sum() + } - 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", - result.total_notes_scanned, - ); + /// Split out the per-account map for `wallet_id`. Useful for + /// callers that want to feed a single wallet's slice back into + /// the legacy per-wallet [`SyncNotesResult`] shape. + pub fn per_account_for( + &self, + wallet_id: crate::wallet::platform_wallet::WalletId, + ) -> BTreeMap { + self.per_subwallet_new_notes + .iter() + .filter(|(id, _)| id.wallet_id == wallet_id) + .map(|(id, &c)| (id.account_index, c)) + .collect() + } +} + +/// Single-fetch, multi-IVK trial-decrypt across an arbitrary set +/// of registered subwallets — the Phase 2b primitive that +/// collapses N per-wallet SDK calls into one. +/// +/// The first subwallet's IVK drives the SDK call; the SDK's +/// `result.all_notes` is then locally trial-decrypted against +/// every other subwallet's IVK. Commitments are appended to the +/// shared tree exactly once per global position with `marked = +/// (any subwallet decrypted this position)` — so accounts that +/// belong to different wallets but share the same network all +/// see their notes' authentication paths retained. +/// +/// `subwallets` must be non-empty; otherwise the function +/// returns an empty result without contacting Platform. +/// +/// Privilege boundary: only the viewing-key half is required +/// (FVK for nullifier derivation, IVK for trial decryption). +/// No `SpendAuthorizingKey` is needed by sync — the spend +/// surface re-attaches it at call time. +pub(super) async fn sync_notes_across( + sdk: &Arc, + store: &Arc>, + subwallets: &[(SubwalletId, AccountViewingKeys)], +) -> Result { + if subwallets.is_empty() { + return Ok(MultiSyncNotesResult::default()); + } + + // Snapshot each subwallet's watermark and the shared tree's + // current leaf count. Two distinct quantities drive the rest + // of this pass: + // * `already_have` = the LOWEST per-subwallet watermark. + // Drives the SDK fetch start so a lagging (e.g. late-bound) + // subwallet's notes are re-scanned. A caught-up subwallet + // shares the same watermark, so in the common case this is + // just that one value. + // * `tree_size` = the number of commitments already in the + // shared tree. Drives the append gate (below) so the + // re-fetch from a chunk boundary doesn't double-append + // positions the tree already holds. + // The per-subwallet `watermarks` map gates note saving so a + // caught-up subwallet doesn't re-derive nullifiers for notes + // it already stored, while a lagging one still saves from its + // own start. + let (watermarks, already_have, tree_size) = { + let store = store.read().await; + let mut watermarks: BTreeMap = BTreeMap::new(); + let mut min_idx: Option = None; + for (id, _) in subwallets { + let idx = store + .last_synced_note_index(*id) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + watermarks.insert(*id, idx); + min_idx = Some(min_idx.map_or(idx, |m| m.min(idx))); } + let tree_size = store + .tree_size() + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + (watermarks, min_idx.unwrap_or(0), tree_size) + }; + let aligned_start = (already_have / CHUNK_SIZE) * CHUNK_SIZE; + + info!( + subwallets = subwallets.len(), + already_have, aligned_start, tree_size, "Starting multi-subwallet shielded note sync" + ); + + // Fetch + trial-decrypt with the FIRST subwallet's IVK in + // one SDK call. The driver's hits come back as + // `result.decrypted_notes`; every other subwallet's are + // produced by local trial-decryption against + // `result.all_notes` below. + let (driver_id, driver_views) = &subwallets[0]; + let driver_ivk = driver_views.prepared_ivk.clone(); + let result = sync_shielded_notes(sdk, &driver_ivk, aligned_start, None) + .await + .map_err(|e| PlatformWalletError::ShieldedSyncFailed(e.to_string()))?; + + info!( + total_scanned = result.total_notes_scanned, + decrypted_for_driver = result.decrypted_notes.len(), + next_start_index = result.next_start_index, + "SDK sync returned" + ); + + if result.next_start_index == 0 && result.total_notes_scanned > 0 { + warn!( + "Shielded sync: next_start_index is 0 after scanning {} notes — \ + next sync will rescan from the beginning", + result.total_notes_scanned, + ); + } - let mut store = self.store.write().await; + // Route decryptions to the subwallet that owns the IVK. + let mut decrypted_by_subwallet: BTreeMap> = BTreeMap::new(); + for dn in &result.decrypted_notes { + decrypted_by_subwallet + .entry(*driver_id) + .or_default() + .push(DiscoveredNote { + position: dn.position, + cmx: dn.cmx, + note: dn.note, + }); + } - // Step 3: Append commitments to the tree, skipping positions already present - let mut appended = 0u32; + for (id, views) in subwallets.iter().skip(1) { 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 + let position = aligned_start + i as u64; + if let Some((note, _addr)) = try_decrypt_note(&views.prepared_ivk, raw_note) { + let cmx_bytes: [u8; 32] = match raw_note.cmx.as_slice().try_into() { + Ok(b) => b, + Err(_) => continue, + }; + decrypted_by_subwallet + .entry(*id) + .or_default() + .push(DiscoveredNote { + position, + cmx: cmx_bytes, + note, + }); } + } + } - let cmx_bytes: [u8; 32] = raw_note.cmx.as_slice().try_into().map_err(|_| { + let mut store = store.write().await; + + // Append every commitment to the shared tree exactly once per + // position, ALWAYS retained (`marked = true`). Skip positions + // already in the tree (`global_pos < tree_size`) — the SDK + // re-fetches from a chunk boundary every pass while the buffer + // chunk is mutable, and a lagging subwallet rewinds that start + // even further, so `all_notes` routinely overlaps positions the + // tree already holds. Gating on the tree's own leaf count (NOT + // a per-subwallet watermark) is what makes the append + // idempotent: re-appending an existing position duplicates a + // leaf, corrupts shardtree's internal nodes, and makes + // per-position witnesses resolve against roots Platform never + // recorded ("Anchor not found in the recorded anchors tree"). + // + // Why mark every position rather than only owned ones: the + // commitment tree is a single chain-wide structure shared by + // every wallet on the network (the whole point of the + // coordinator refactor — one SQLite handle, not N). Ownership + // is decided per-pass by trial-decryption, but wallets bind at + // *different* times. If wallet A syncs first (driver IVK owns + // nothing) the positions get appended; when wallet B binds + // later and discovers its note at one of those positions, + // shardtree has no way to retroactively mark it — the auth + // path was already discarded as `Ephemeral`, and the note + // becomes permanently unwitnessable (balance shows, spend + // fails with "Merkle witness unavailable"). Marking every + // position makes the shared tree witness-complete regardless + // of bind ordering; per-wallet ownership is tracked + // separately in the per-`SubwalletId` notes store, so privacy + // / accounting is unaffected. The cost is retained auth paths + // for non-owned positions (O(commitments) storage); acceptable + // for correctness, and the shielded pool is small. A future + // optimization can prune auth paths for positions no live + // subwallet owns once all wallets have caught past them. + let mut appended = 0u32; + for (i, raw_note) in result.all_notes.iter().enumerate() { + let global_pos = aligned_start + i as u64; + if global_pos < tree_size { + continue; + } + let cmx_bytes: [u8; 32] = + raw_note.cmx.as_slice().try_into().map_err(|_| { PlatformWalletError::ShieldedSyncFailed("Invalid cmx length".into()) })?; + store + .append_commitment(&cmx_bytes, true) + .map_err(|e| PlatformWalletError::ShieldedTreeUpdateFailed(e.to_string()))?; + appended += 1; + } - let is_ours = result - .decrypted_notes - .iter() - .any(|dn| dn.position == 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 - .checkpoint_tree(checkpoint_id) - .map_err(|e| PlatformWalletError::ShieldedTreeUpdateFailed(e.to_string()))?; - } + if appended > 0 { + // Checkpoint at the tree's true post-append leaf count. The + // tree only ever grows, so this id is strictly monotonic + // and collision-free across consecutive syncs — unlike + // `result.next_start_index` (rewinds to the last partial + // chunk's start) or `aligned_start + total_notes_scanned` + // (can repeat when a lagging subwallet rewinds the fetch). + // shardtree's `checkpoint(id)` silently dedups duplicate + // ids; a non-monotonic id pins depth-0 at the first + // checkpoint while later appends extend the tree past it, + // so the depth-0 witness then reflects a state Platform + // never recorded. + let new_tree_size = store + .tree_size() + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + // Hard-fail rather than saturate at u32::MAX: a saturated id + // would reintroduce shardtree's silent-dedup (every checkpoint + // past the ceiling pins to the same id) — the exact corruption + // this monotonic-id scheme exists to avoid. Unreachable today + // (>4.29B notes scanned) but fail loudly before proving. + let checkpoint_id: u32 = new_tree_size.try_into().map_err(|_| { + PlatformWalletError::ShieldedTreeUpdateFailed(format!( + "commitment tree size {new_tree_size} exceeds u32 checkpoint-id range" + )) + })?; + store + .checkpoint_tree(checkpoint_id) + .map_err(|e| PlatformWalletError::ShieldedTreeUpdateFailed(e.to_string()))?; + } - // 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 per subwallet; count new notes per + // subwallet; build a single consolidated changeset. + let mut per_subwallet_new_notes: BTreeMap = BTreeMap::new(); + let mut changeset = ShieldedChangeSet::default(); + for (id, discovered) in &decrypted_by_subwallet { + // Look up the FVK for nullifier derivation. The id is + // guaranteed to come from `subwallets` (we keyed off the + // same set above), so the find is infallible — but be + // defensive in case caller passed a malformed slice. + let Some((_, views)) = subwallets.iter().find(|(s, _)| s == id) else { + continue; + }; + // Gate on THIS subwallet's own watermark (not the network + // min): a caught-up subwallet skips re-deriving nullifiers + // for notes it already stored, while a lagging one still + // saves everything from its own start. `save_note` is an + // idempotent overwrite-by-nullifier, so a stray re-save is + // harmless — but gating per-subwallet keeps the + // `per_subwallet_new_notes` count honest. + let sub_watermark = watermarks.get(id).copied().unwrap_or(0); + for d in discovered { + if d.position < sub_watermark { + continue; } - - // 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 nullifier = d.note.nullifier(&views.full_viewing_key); + let value = d.note.value().inner(); + debug!( + wallet_id = %hex::encode(id.wallet_id), + account = id.account_index, + position = d.position, + value, + "Note DECRYPTED" + ); + let note_data = serialize_note(&d.note); let shielded_note = super::store::ShieldedNote { note_data, - position: dn.position, - cmx: dn.cmx, + position: d.position, + cmx: d.cmx, nullifier: nullifier.to_bytes(), block_height: result.block_height, is_spent: false, value, }; - store - .save_note(&shielded_note) + .save_note(*id, &shielded_note) .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; - - new_note_count += 1; + changeset.record_note(*id, shielded_note); + *per_subwallet_new_notes.entry(*id).or_default() += 1; } + } - // Step 6: Update last synced index - let new_index = aligned_start + result.total_notes_scanned; + // Advance every subwallet's watermark to the same global + // tree position so the next sync resumes coherently across + // the union. + let new_index = aligned_start + result.total_notes_scanned; + for (id, _) in subwallets { store - .set_last_synced_note_index(new_index) + .set_last_synced_note_index(*id, new_index) .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + changeset.record_synced_index(*id, new_index); + } + // Drop the write lock before returning so the caller's + // persister queue (which may take its own synchronous + // mutex) doesn't nest under our store lock. + drop(store); + + info!( + new_notes_total = per_subwallet_new_notes.values().sum::(), + new_index, "Multi-subwallet shielded sync finished" + ); + + // `total_scanned` keeps its host-visible meaning: wire-level scan + // volume this pass (encrypted notes pulled — decrypted + skipped), + // matching the exported FFI/Swift/UI "Scanned" contract. This is + // intentionally NOT `appended`: the tree_size append gate makes the + // two diverge whenever the SDK re-fetches positions the tree + // already holds (chunk-boundary realignment, lagging-subwallet + // rewind), and the host counter is documented as scan throughput, + // not tree growth. + let scanned_volume = (aligned_start + result.total_notes_scanned).saturating_sub(already_have); + Ok(MultiSyncNotesResult { + per_subwallet_new_notes, + total_scanned: scanned_volume, + changeset, + }) +} - info!( - "Shielded sync finished: {} new note(s), last_synced_index={}", - new_note_count, new_index, - ); +/// Multi-subwallet nullifier sync. One SDK call per subwallet +/// that has unspent notes — the SDK's nullifier-scan API is +/// keyed by a checkpoint per subwallet and can't be coalesced +/// the same way `sync_shielded_notes` can. Still drops the +/// per-wallet `last_caught_up_at` and persister hop, leaving +/// the caller in charge of changeset queueing. +pub(super) async fn check_nullifiers_across( + sdk: &Arc, + store: &Arc>, + subwallets: &[(SubwalletId, AccountViewingKeys)], +) -> Result<(BTreeMap, ShieldedChangeSet), PlatformWalletError> { + if subwallets.is_empty() { + return Ok((BTreeMap::new(), ShieldedChangeSet::default())); + } - Ok(SyncNotesResult { - new_notes: new_note_count, - total_scanned: result.total_notes_scanned, - }) + // Aggregate unspent nullifiers per subwallet so we hit the + // SDK once per subwallet, then route the `found` results + // back via a position lookup. + struct PerSub { + nullifiers: Vec<[u8; 32]>, + checkpoint: Option, } - /// 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 per_sub: Vec<(SubwalletId, PerSub)> = { + let store = store.read().await; + let mut out = Vec::with_capacity(subwallets.len()); + for (id, _) in subwallets { let unspent = store - .get_unspent_notes() + .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() + .nullifier_checkpoint(*id) .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))? .map(|(height, timestamp)| NullifierSyncCheckpoint { height, timestamp }); - (nullifiers, checkpoint) - }; - - if unspent_nullifiers.is_empty() { - return Ok(0); + out.push(( + *id, + PerSub { + nullifiers, + checkpoint, + }, + )); + } + out + }; + + let mut newly_spent: BTreeMap = BTreeMap::new(); + let mut changeset = ShieldedChangeSet::default(); + for (id, sub) in per_sub { + if sub.nullifiers.is_empty() { + continue; } - debug!( - "Checking {} nullifiers (checkpoint: {:?})", - unspent_nullifiers.len(), - last_checkpoint, + wallet_id = %hex::encode(id.wallet_id), + account = id.account_index, + checking = sub.nullifiers.len(), + ?sub.checkpoint, + "Checking nullifiers" ); - - // Step 2: Call SDK sync_nullifiers - let result = self - .sdk - .sync_nullifiers( - &unspent_nullifiers, - None::, - last_checkpoint, - ) + let result = sdk + .sync_nullifiers(&sub.nullifiers, None::, sub.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; - + let mut store = store.write().await; 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 { + if store + .mark_spent(id, nf_bytes) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))? + { + changeset.record_nullifier_spent(id, *nf_bytes); spent_count += 1; } } - - // Step 4: Update nullifier checkpoint store - .set_nullifier_checkpoint(result.new_sync_height, result.new_sync_timestamp) + .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 { - info!("{} note(s) newly detected as spent", spent_count); + newly_spent.insert(id, spent_count); + info!( + wallet_id = %hex::encode(id.wallet_id), + account = id.account_index, + spent_count, + "Notes newly detected as spent" + ); } - - Ok(spent_count) } + Ok((newly_spent, changeset)) +} - /// 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. - 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?; - - Ok(ShieldedSyncSummary { - notes_result, - newly_spent, - balance, - }) +/// Multi-subwallet unspent-balance snapshot. Pure read against +/// the shared store — does not trigger a sync. +pub(crate) async fn balances_across( + store: &Arc>, + subwallets: &[(SubwalletId, AccountViewingKeys)], +) -> Result, PlatformWalletError> { + let store = store.read().await; + let mut out: BTreeMap = BTreeMap::new(); + for (id, _) in subwallets { + let notes = store + .get_unspent_notes(*id) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + out.insert(*id, notes.iter().map(|n| n.value).sum()); } + Ok(out) +} + +/// 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/rs-sdk/src/platform/transition.rs b/packages/rs-sdk/src/platform/transition.rs index b5aa9aa051..a1b58e7135 100644 --- a/packages/rs-sdk/src/platform/transition.rs +++ b/packages/rs-sdk/src/platform/transition.rs @@ -1,6 +1,10 @@ //! State transitions used to put changed objects to the Dash Platform. pub mod address_credit_withdrawal; pub(crate) mod address_inputs; +/// Re-export the canonical address-input fetch + hard balance check so +/// downstream crates (e.g. platform-wallet's shield path) reuse it +/// instead of re-implementing the fetch-and-validate dance. +pub use address_inputs::fetch_inputs_with_nonce; pub mod broadcast; pub(crate) mod broadcast_identity; pub mod broadcast_request; diff --git a/packages/rs-sdk/src/platform/transition/address_inputs.rs b/packages/rs-sdk/src/platform/transition/address_inputs.rs index 38a5c4aecb..fb34cde103 100644 --- a/packages/rs-sdk/src/platform/transition/address_inputs.rs +++ b/packages/rs-sdk/src/platform/transition/address_inputs.rs @@ -9,7 +9,14 @@ use dpp::prelude::AddressNonce; use drive_proof_verifier::types::{AddressInfo, AddressInfos}; use std::collections::{BTreeMap, BTreeSet}; -pub(crate) async fn fetch_inputs_with_nonce( +/// Fetch each input address's current `(nonce, balance)` from Platform +/// and return `(nonce, amount)` per address, enforcing a hard balance +/// check — errors with `AddressNotEnoughFundsError` when any input is +/// short rather than letting an underfunded transition proceed. The +/// returned nonces are the *current* on-chain values; callers increment +/// them (see [`nonce_inc`], or apply their own checked increment) before +/// building a transition. +pub async fn fetch_inputs_with_nonce( sdk: &Sdk, amounts: &BTreeMap, ) -> Result, Error> { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift index 5120c8f284..f351698aec 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/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index 71a0fcc3c5..8a4ab4218a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -29,6 +29,8 @@ public enum DashModelContainer { PersistentTxo.self, PersistentPendingInput.self, PersistentWalletManagerMetadata.self, + PersistentShieldedNote.self, + PersistentShieldedSyncState.self, PersistentAssetLock.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 0000000000..527aebc084 --- /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 0000000000..611d5152ff --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedSyncState.swift @@ -0,0 +1,57 @@ +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 + /// Sync watermark: count of note positions scanned = the next global + /// commitment-tree index to scan (exclusive). `0` = nothing scanned + /// yet — *not* the last index scanned. + public var lastSyncedIndex: UInt64 + /// Whether the optional `(height, timestamp)` nullifier + /// checkpoint is populated. SwiftData predicate compilation is + /// 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/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index bb17801d39..1160ec4740 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -46,6 +46,22 @@ public class PlatformWalletManager: ObservableObject { /// Last completed shielded sync event emitted by Rust. @Published public internal(set) var lastShieldedSyncEvent: ShieldedSyncEvent? + /// When true, `handleShieldedSyncCompleted` drops incoming events + /// instead of publishing them. Set by `stopShieldedSync` / + /// `clearShielded` (after the Rust drain returns) and cleared by any + /// sync-start (`startShieldedSync` / `syncShieldedNow`). The Rust + /// quiesce barrier guarantees no persistence after stop/clear, but + /// the completion callback is re-dispatched onto this `@MainActor`, + /// so a final, already-dispatched event can land just after stop/ + /// clear returns; this gate keeps the published `lastShieldedSyncEvent` + /// honest for every SDK consumer (not just the example app). Both + /// stop/clear are synchronous on the main actor, so the flag is set + /// before the enqueued trailing-event task can run. + /// + /// `internal` (not `private`) because the shielded lifecycle methods + /// that read/write it live in an extension in a separate file. + var suppressShieldedCompletionEvents: Bool = false + /// All wallets currently held by the Rust-side /// `PlatformWalletManager`, keyed by the 32-byte wallet id. /// diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index 1b739d1145..52d56bc60a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -18,6 +18,15 @@ public struct ShieldedWalletSyncResult: Sendable { public let walletId: Data public let success: Bool public let skipped: Bool + /// `true` when `success` is true but the Rust pass was + /// short-circuited by the caught-up cooldown — no SDK fetch + /// / trial-decrypt / nullifier scan / balance read ran. + /// When this flag is set, every numeric field on this + /// struct (`newNotes`, `totalScanned`, `newlySpent`, + /// `balance`) is zero — hosts should preserve their cached + /// balance and counters rather than apply the payload. + /// `false` for any pass that actually walked Platform. + public let cooldownSkip: Bool public let newNotes: UInt32 public let totalScanned: UInt64 public let newlySpent: UInt32 @@ -29,6 +38,7 @@ public struct ShieldedWalletSyncResult: Sendable { self.walletId = withUnsafeBytes(of: &walletId) { Data($0) } self.success = ffi.success self.skipped = ffi.skipped + self.cooldownSkip = ffi.cooldown_skip self.newNotes = ffi.new_notes self.totalScanned = ffi.total_scanned self.newlySpent = ffi.newly_spent @@ -49,26 +59,41 @@ public struct ShieldedSyncEvent: Sendable { extension PlatformWalletManager { func handleShieldedSyncCompleted(_ event: ShieldedSyncEvent) { + // Drop a trailing event that the Rust drain already dispatched + // but the main actor only delivers after stop/clear returned + // (see `suppressShieldedCompletionEvents`). Any sync-start clears + // the flag, so legitimate events are never suppressed. + guard !suppressShieldedCompletionEvents else { return } lastShieldedSyncEvent = event } /// 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 replaces the previously-bound + /// shielded wallet. /// - /// Idempotent: calling again with a different account or - /// `dbPath` replaces the previously-bound shielded wallet. + /// **Prerequisite**: [`configureShielded(dbPath:)`] must have + /// been called on this manager first — the per-network + /// SQLite handle is opened there. `bindShielded` reuses it + /// across every wallet. public func bindShielded( walletId: Data, resolver: MnemonicResolver, - account: UInt32 = 0, - dbPath: String + accounts: [UInt32] = [0] ) throws { guard isConfigured, handle != NULL_HANDLE else { throw PlatformWalletError.invalidHandle( @@ -80,6 +105,16 @@ extension PlatformWalletManager { "walletId must be exactly 32 bytes" ) } + guard !accounts.isEmpty else { + throw PlatformWalletError.invalidParameter( + "accounts must be non-empty" + ) + } + guard accounts.count <= 64 else { + throw PlatformWalletError.invalidParameter( + "accounts must contain at most 64 entries" + ) + } guard let resolverHandle = resolver.handle else { throw PlatformWalletError.invalidParameter( "MnemonicResolver has no handle" @@ -92,18 +127,44 @@ extension PlatformWalletManager { else { throw PlatformWalletError.invalidParameter("walletId baseAddress is nil") } - try dbPath.withCString { dbPathPtr in + try accounts.withUnsafeBufferPointer { accountsBuf in + guard let accountsPtr = accountsBuf.baseAddress else { + throw PlatformWalletError.invalidParameter( + "accounts baseAddress is nil" + ) + } try platform_wallet_manager_bind_shielded( handle, walletIdPtr, resolverHandle, - account, - dbPathPtr + accountsPtr, + UInt(accountsBuf.count) ).check() } } } + /// Configure the network-scoped shielded coordinator. Opens + /// the per-network commitment-tree SQLite file at `dbPath` + /// and installs a single shared handle every subsequent + /// [`bindShielded`] call reuses. + /// + /// Must be called once before any `bindShielded` on this + /// manager. Idempotent: a second call with the same `dbPath` + /// is a no-op; a second call with a different `dbPath` + /// throws — the SQLite handle is opened once per manager and + /// can't be repointed mid-flight. + public func configureShielded(dbPath: String) throws { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidHandle( + "PlatformWalletManager not configured" + ) + } + try dbPath.withCString { dbPathPtr in + try platform_wallet_manager_configure_shielded(handle, dbPathPtr).check() + } + } + /// Start the shielded sync coordinator's background loop. /// /// Wallets that have not yet been bound via [`bindShielded`] are @@ -120,6 +181,8 @@ extension PlatformWalletManager { if let intervalSeconds { try setShieldedSyncInterval(seconds: intervalSeconds) } + // A new sync run should publish its completion events again. + suppressShieldedCompletionEvents = false try platform_wallet_manager_shielded_sync_start(handle).check() } @@ -130,6 +193,36 @@ extension PlatformWalletManager { ) } try platform_wallet_manager_shielded_sync_stop(handle).check() + // The Rust drain returned; suppress any trailing completion + // event the main actor delivers after this point. + suppressShieldedCompletionEvents = true + } + + /// Reset the Rust-side shielded state on this manager: + /// stops the background sync loop, drops every wallet + /// registration on the network-scoped coordinator, and + /// resets the caught-up cooldown stamp. + /// + /// Use this from the host's "Clear" flow before wiping + /// host-side persistence (e.g. SwiftData rows). The single + /// per-network SQLite commitment-tree file stays open — + /// Clear semantics are "wipe my host persistence and + /// re-sync from index 0 on the shared tree", not "blow + /// away the chain-wide cache". After Clear, the next + /// [`bindShielded`] call repopulates the coordinator's + /// registries and the next sync pass re-saves notes via + /// the changeset path. + public func clearShielded() throws { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidHandle( + "PlatformWalletManager not configured" + ) + } + try platform_wallet_manager_shielded_clear(handle).check() + // The Rust drain returned; suppress any trailing completion + // event the main actor delivers after Clear (it would otherwise + // briefly repopulate the mirror the host is about to wipe). + suppressShieldedCompletionEvents = true } public func isShieldedSyncRunning() throws -> Bool { @@ -180,21 +273,27 @@ extension PlatformWalletManager { "PlatformWalletManager not configured" ) } + // A user-initiated sync should publish its completion event even + // if a prior stop/clear had suppressed events. + suppressShieldedCompletionEvents = false let handle = self.handle try await Task.detached(priority: .userInitiated) { try platform_wallet_manager_shielded_sync_sync_now(handle).check() }.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 +320,7 @@ extension PlatformWalletManager { try platform_wallet_manager_shielded_default_address( handle, ptr, + account, outPtr, &present ).check() @@ -229,6 +329,212 @@ 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 `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 { + 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, account, recipientPtr, amount + ).check() + } + } + }.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, + shieldedAccount: UInt32 = 0, + paymentAccount: 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, shieldedAccount, paymentAccount, amount, signerHandle + ).check() + } + }.value + } + + /// Shielded → Platform unshield. Spends notes from `walletId`'s + /// 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, + account: UInt32 = 0, + toPlatformAddress: String, + 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.withCString { addrCStr in + try platform_wallet_manager_shielded_unshield( + handle, widPtr, account, addrCStr, 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, + account: UInt32 = 0, + 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, account, 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/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index b73e21b1c5..20a0b62816 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -942,6 +942,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 cb.on_persist_asset_locks_fn = persistAssetLocksCallback cb.on_get_core_tx_record_fn = getCoreTxRecordCallback cb.on_get_core_tx_record_free_fn = getCoreTxRecordFreeCallback @@ -2166,6 +2175,356 @@ 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 + } + + /// Wallet ids belonging to the handler's bound network, used to + /// scope the shielded loaders the same way `loadWalletList()` scopes + /// its wallet fetch. Returns `nil` when the handler has no bound + /// network (legacy callers that haven't threaded `network` through), + /// signalling "don't filter" so those paths keep their pre-refactor + /// cross-network behavior. Caller must be on `onQueue`. + private func inNetworkWalletIds() -> Set? { + guard let network = self.network else { return nil } + let raw = network.rawValue + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.networkRaw == raw } + ) + let wallets = (try? backgroundContext.fetch(descriptor)) ?? [] + return Set(wallets.map { $0.walletId }) + } + + /// Build the host-allocated `ShieldedNoteRestoreFFI` array Rust + /// reads at boot. The allocation is tracked in + /// `shieldedLoadAllocations` and freed by + /// `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() + var rows: [PersistentShieldedNote] + do { + rows = try backgroundContext.fetch(descriptor) + } catch { + resultErrored = true + return + } + // Scope to the handler's bound network so a per-network + // manager never rehydrates another network's shielded notes + // (the commitment tree DB is network-scoped). `nil` ids => + // no in-network wallets => nothing to restore. + if let inNetworkIds = self.inNetworkWalletIds() { + rows = rows.filter { inNetworkIds.contains($0.walletId) } + } + if rows.isEmpty { + return + } + 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 + // `written` is the next free slot in `buf`; we increment it + // only after a row's struct is fully populated, so the + // returned prefix `[0..written)` is contiguous initialized + // memory regardless of how many rows are skipped by the + // length guards below. Indexing by `rows.enumerated()`'s + // `idx` here would leave gaps when an early row is skipped + // and Rust would read uninitialized bytes off the + // `slice::from_raw_parts(ptr, count)` it builds in + // `FFIPersister::load`. + var written = 0 + for row in rows { + guard row.walletId.count == 32 else { continue } + guard row.cmx.count == 32 else { continue } + guard row.nullifier.count == 32 else { continue } + 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[written] = 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) + ) + written += 1 + allocation.entriesInitialized = written + } + let entriesPtr = UnsafePointer(buf) + shieldedLoadAllocations[UnsafeRawPointer(entriesPtr)] = allocation + resultEntries = entriesPtr + resultCount = written + } + 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() + var rows: [PersistentShieldedSyncState] + do { + rows = try backgroundContext.fetch(descriptor) + } catch { + resultErrored = true + return + } + // Same network scoping as `loadShieldedNotes` — keep both + // loaders consistent so a per-network manager doesn't restore + // foreign-network sync watermarks. + if let inNetworkIds = self.inNetworkWalletIds() { + rows = rows.filter { inNetworkIds.contains($0.walletId) } + } + if rows.isEmpty { + return + } + let allocation = ShieldedSyncStateLoadAllocation() + let buf = UnsafeMutablePointer.allocate( + capacity: rows.count + ) + allocation.entries = buf + allocation.entriesCount = rows.count + // Same `written`-counter pattern as `loadShieldedNotes`: + // skip malformed rows without leaving holes in the + // contiguous prefix Rust will read. + var written = 0 + for row in rows { + guard row.walletId.count == 32 else { continue } + var walletIdTuple: FFIByteTuple32 = (0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0) + row.walletId.withUnsafeBytes { src in + Swift.withUnsafeMutableBytes(of: &walletIdTuple) { dst in + dst.copyMemory(from: src) + } + } + buf[written] = 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 + ) + written += 1 + allocation.entriesInitialized = written + } + let entriesPtr = UnsafePointer(buf) + shieldedSyncStateLoadAllocations[UnsafeRawPointer(entriesPtr)] = allocation + resultEntries = entriesPtr + resultCount = written + } + 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 @@ -3673,6 +4032,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 @@ -4422,6 +4822,197 @@ 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)) +} + +// MARK: - Core tx-record persister fallback + /// C shim for `on_get_core_tx_record_fn`. Calls /// `PlatformWalletPersistenceHandler.coreTxRecord(...)` and writes /// the row's actual context kind, block info (when applicable), and diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift index c0dd4e84a8..1142abffe4 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift @@ -273,6 +273,19 @@ 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 + // 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 +293,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) } } @@ -291,32 +306,36 @@ 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 } } - // 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: // 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 @@ -324,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 } } @@ -336,9 +359,11 @@ 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" - recoveryError = prefix + perWalletFailures.joined(separator: "\n") + ? "Recovery failed: " + : "Recovery failed for \(perWalletFailures.count) wallets:\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 @@ -390,17 +415,22 @@ 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 + 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 @@ -435,17 +465,18 @@ struct ContentView: View { // would persist the row as testnet. `backgroundManager` // lazy-builds the per-network manager (and its SDK) without // changing the user's currently visible network. - let targetManager: PlatformWalletManager + let recoveryManager: PlatformWalletManager do { - targetManager = try walletManagerStore.backgroundManager(for: restoredNetwork) + recoveryManager = try walletManagerStore.backgroundManager(for: restoredNetwork) } catch { - recoveryError = "Failed to prepare \(restoredNetwork.displayName) manager " - + "for \"\(entry.displayName)\": \(error.localizedDescription)" - return false + let message = "\"\(entry.displayName)\" (\(restoredNetwork.displayName)): " + + "failed to prepare wallet manager — \(error.localizedDescription)" + SDKLogger.error("Recovery: \(message) (raw: \(error))") + return message } do { - let managed = try targetManager.createWallet( + let managed = try recoveryManager.createWallet( mnemonic: mnemonic, network: restoredNetwork, name: restoredName @@ -494,10 +525,12 @@ struct ContentView: View { } try? modelContext.save() } - return true + return nil } catch { - recoveryError = "Failed to recreate \"\(entry.displayName)\": \(error.localizedDescription)" - return false + let message = "\"\(entry.displayName)\" (\(restoredNetwork.displayName)): " + + error.localizedDescription + SDKLogger.error("Recovery: createWallet failed — \(message) (raw: \(error))") + return message } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift index d6a7ed66f8..8100283058 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -11,6 +11,7 @@ import Foundation import SwiftUI import Combine +import SwiftData import SwiftDashSDK /// Observable service mirroring Rust-owned shielded sync state. @@ -57,18 +58,42 @@ 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. private weak var walletManager: PlatformWalletManager? - /// Wallet id we filter sync results by. - private var walletId: Data? + /// Wallet id we filter sync results by. Exposed read-only so + /// diagnostics views (e.g. Sync Status) can resolve persisted + /// per-account watermarks without each one knowing the active + /// wallet through a separate path. + @Published private(set) var boundWalletId: Data? + + /// Network of the currently-bound wallet. Stashed so + /// `switchTo(walletId:)` can reach the right per-network + /// 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? @@ -80,8 +105,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 @@ -90,10 +122,13 @@ class ShieldedService: ObservableObject { walletManager: PlatformWalletManager, walletId: Data, network: Network, - resolver: MnemonicResolver + resolver: MnemonicResolver, + accounts: [UInt32] = [0] ) { self.walletManager = walletManager - self.walletId = walletId + self.boundWalletId = walletId + self.network = network + self.resolver = resolver self.syncStateCancellable?.cancel() self.syncEventCancellable?.cancel() @@ -113,35 +148,57 @@ 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 { + // The per-network SQLite tree handle now lives on the + // manager (one shared `NetworkShieldedCoordinator`), + // not per-wallet. `configureShielded` is idempotent at + // the path level — first call opens the file, every + // subsequent same-path call no-ops. Has to run before + // `bindShielded`; doing it inline keeps the call shape + // simple and avoids a separate bootstrap step. + try walletManager.configureShielded(dbPath: dbPath) try walletManager.bindShielded( walletId: walletId, resolver: resolver, - account: 0, - dbPath: dbPath + accounts: sortedAccounts ) 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) { - 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 { @@ -161,6 +218,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.boundWalletId == 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. /// @@ -172,9 +263,34 @@ class ShieldedService: ObservableObject { /// rely on the subscription alone to flip it back. func manualSync() async { guard !isSyncing else { return } - guard let walletManager else { - lastError = "Shielded service not configured" - return + guard let walletManager else { return } + + // If we're unbound (typically because the user pressed + // Clear earlier) but still have the bind credentials, + // re-bind first so the next `syncShieldedNow()` call + // has a Rust-side shielded sub-wallet to walk. Without + // this, post-Clear Sync Now would no-op forever and the + // user has no path back to a synced state from this + // screen. + if !isBound, canResume { + guard + let walletId = boundWalletId, + let resolver, + let network + else { return } + let accounts = boundAccounts.isEmpty ? [0] : boundAccounts + bind( + walletManager: walletManager, + walletId: walletId, + network: network, + resolver: resolver, + accounts: accounts + ) + // `bind` is best-effort; if it failed (e.g. the + // mnemonic resolver was declined), `isBound` stays + // false and `lastError` is populated. Bail rather + // than chain a sync that will fail the same way. + guard isBound else { return } } isSyncing = true @@ -186,6 +302,41 @@ class ShieldedService: ObservableObject { lastError = "Shielded sync error: \(error.localizedDescription)" SDKLogger.log(lastError ?? "", minimumLevel: .medium) } + + // Restart the manager-wide shielded sync loop AFTER the + // manual `syncShieldedNow()` call completes. `start()` + // spawns a background thread whose first iteration calls + // `sync_now(false)` immediately, and the manager's + // `is_syncing` CAS in `sync_now` means whichever caller + // gets there first wins — the other silently no-ops with + // an empty summary. Starting *after* the manual pass + // returns lets the user-initiated tap run uncontested, + // and the loop's first tick happens on its own cadence. + // The guard against double-start mirrors the equivalent + // call in `SwiftExampleAppApp.rebindWalletScopedServices`. + do { + if try !walletManager.isShieldedSyncRunning() { + try walletManager.startShieldedSync() + } + } catch { + SDKLogger.error( + "ShieldedService.manualSync: failed to (re)start shielded sync loop: \(error.localizedDescription)" + ) + } + } + + /// Whether the service has enough stashed state to perform a + /// `bind` on demand from a Clear → Sync Now flow. Distinct + /// from [`isBound`]: after Clear we are not currently bound, + /// but the credentials live on so [`manualSync`] can rebind + /// without the user navigating away from the Sync Status + /// screen. False on a fresh session (no bind has ever run) + /// or after [`reset`]. + var canResume: Bool { + walletManager != nil + && boundWalletId != nil + && resolver != nil + && network != nil } /// Reset display state. Cancels the manager subscriptions but @@ -196,7 +347,7 @@ class ShieldedService: ObservableObject { syncStateCancellable?.cancel() syncEventCancellable?.cancel() walletManager = nil - walletId = nil + boundWalletId = nil isSyncing = false shieldedBalance = 0 lastNewNotes = 0 @@ -205,6 +356,138 @@ class ShieldedService: ObservableObject { lastSyncTime = nil lastError = nil orchardDisplayAddress = nil + boundAccounts = [] + addressesByAccount = [:] + syncCountSinceLaunch = 0 + totalScanned = 0 + totalNewNotes = 0 + totalNewlySpent = 0 + } + + /// Wipe every wallet's persisted shielded state and stop. The + /// service is left unbound, but the stashed bind credentials + /// (`walletManager` / `boundWalletId` / `network` / `resolver` + /// / `boundAccounts`) survive so [`manualSync`] can rebind on + /// demand without the user navigating away. The "Sync Now" + /// button on the Sync Status screen is the path back — + /// pressing it self-binds + syncs from a clean SQLite tree + /// and an empty SwiftData snapshot. + /// + /// The user reaches this through the Clear button on the + /// **global** Sync Status surface, not a per-wallet screen. + /// "Clear" therefore wipes every wallet's shielded rows + the + /// per-network commitment-tree SQLite file, so the rebind on + /// the next Sync Now walks the cmx stream from genesis. + /// + /// What it does NOT touch: + /// * The manager-wide shielded sync loop is `stopShieldedSync`'d + /// first so the persister callback can't re-derive the + /// rows we're deleting. It restarts on either of the two + /// bind paths: [`manualSync`] self-binding (which calls + /// `startShieldedSync()` after a successful self-rebind), + /// or `rebindWalletScopedServices` firing on a navigation. + /// * The per-network commitment-tree SQLite file at + /// `dbPath(for:)`. Earlier revisions of this helper + /// unlinked it for a "true clean slate", but with no + /// unbind FFI the Rust-side `FileBackedShieldedStore` + /// keeps the SQLite handle open across the wipe — yanking + /// the file (plus -wal / -shm / -journal sidecars) out + /// from under a live connection is a SQLite-documented + /// corruption pattern, and the corruption it causes is + /// precisely the "Merkle witness unavailable" class of + /// failures the wipe was meant to defuse. The tree's + /// existing leaves are still correct (just chain history); + /// the marked-position auth paths survive; and re-sync + /// with an empty SwiftData snapshot re-decrypts every + /// note while `append_commitment` skips already-appended + /// positions, so the tree stays consistent across rebind. + /// * The Rust-side shielded sub-wallet binding (there's no + /// unbind FFI today; the next `bindShielded` call replaces + /// the binding wholesale). + /// * The stashed credentials on the service itself — bare + /// [`reset`] would nil them, leaving the user with no + /// path back to a synced state from this screen. The + /// inline soft-cleanup below only zeroes the published + /// mirror; [`canResume`] therefore stays `true` and the + /// Sync Now button stays usable. + /// + /// Per-wallet scoping was tried first and rejected because the + /// Clear button doesn't carry wallet context — other wallets' + /// `PersistentShieldedSyncState` rows would silently survive + /// (the symptom the user reported when "Clear" left a row + /// behind for a non-active wallet). + func clearLocalState(modelContext: ModelContext) async { + // Capture the manager before the soft-cleanup below + // touches anything, so we can stop the background loop + // first. (We used to capture `network` here too for the + // per-network SQLite delete; that step is gone — see + // doc above for why.) + let managerForStop = walletManager + + // 1) Reset the Rust-side shielded state BEFORE touching + // state on disk. The Swift `ShieldedService` is + // per-wallet-at-a-time, but the Rust + // `PlatformWalletManager` keeps **every** wallet that + // ever ran `bind_shielded` registered on the + // network-scoped coordinator. Without this call the + // coordinator's next sync iterates the still-registered + // wallets and the persister callback immediately + // re-creates the `PersistentShieldedNote` / + // `PersistentShieldedSyncState` rows we're about to + // delete (the "Clear left a row behind / re-derived a + // fresh row" symptom from before). `clearShielded` + // does three things on the Rust side in one call: + // - stops the background sync loop + // - drops every wallet registration from the + // coordinator (`accounts` + `persisters` maps) + // - resets the network-wide caught-up cooldown + // The single SQLite commitment-tree file stays open; + // the next `bindShielded` call repopulates the + // registries and the next sync re-saves notes via + // the changeset path. Best-effort — failure logs but + // doesn't abort the wipe. + if let managerForStop { + do { + try managerForStop.clearShielded() + } catch { + SDKLogger.error( + "ShieldedService.clearLocalState: clearShielded failed: \(error.localizedDescription)" + ) + } + } + + // 2) Delete every shielded SwiftData row across all + // wallets on this device. The Clear button is on the + // global Sync Status surface, so its semantics are + // "blow away shielded persistence", not "scope to one + // wallet". + do { + try modelContext.delete(model: PersistentShieldedNote.self) + try modelContext.delete(model: PersistentShieldedSyncState.self) + try modelContext.save() + } catch { + lastError = "Failed to wipe persisted shielded state: \(error.localizedDescription)" + SDKLogger.error(lastError ?? "") + return + } + + // 3) Soft cleanup: zero the published mirror + cancel + // subscriptions, but KEEP the bind credentials + // (walletManager / boundWalletId / network / resolver + // / boundAccounts) so [`manualSync`] can re-bind on + // the next Sync Now tap. Bare [`reset`] would nil + // them and leave the user stranded on this screen. + syncStateCancellable?.cancel() + syncEventCancellable?.cancel() + isBound = false + isSyncing = false + shieldedBalance = 0 + lastNewNotes = 0 + lastNewlySpent = 0 + lastSyncTime = nil + lastError = nil + orchardDisplayAddress = nil + addressesByAccount = [:] syncCountSinceLaunch = 0 totalScanned = 0 totalNewNotes = 0 @@ -214,21 +497,52 @@ class ShieldedService: ObservableObject { // MARK: - Sync event handling private func handleShieldedSyncEvent(_ event: ShieldedSyncEvent) { - guard let walletId, let result = event.result(for: walletId) else { + // Drop completion events that arrive while unbound. Clear + // (`clearLocalState`) sets `isBound = false` and cancels this + // subscription, but the Rust completion event is hopped onto the + // main actor and a final, already-dispatched one can land just + // after Clear returns — applying it would briefly repopulate the + // mirror Clear just zeroed. The Rust quiesce barrier already + // guarantees no *persistence* happens after Clear; this guards + // the in-memory display mirror. `bind()` sets `isBound = true` + // before its sync events flow, so a legitimate post-bind event + // is never dropped. + guard isBound else { return } + guard let walletId = boundWalletId, + let result = event.result(for: walletId) else { return } if result.success { lastError = nil isBound = true - shieldedBalance = result.balance - lastNewNotes = result.newNotes - lastNewlySpent = result.newlySpent - lastSyncTime = Date(timeIntervalSince1970: TimeInterval(event.syncUnixSeconds)) - syncCountSinceLaunch += 1 - totalScanned += result.totalScanned - totalNewNotes += UInt64(result.newNotes) - totalNewlySpent += UInt64(result.newlySpent) + + // Suppress counter / timestamp / balance updates on + // cooldown skips. The Rust side returns + // `result.cooldownSkip = true` with zeroed counters + // *and* an empty balances payload (`result.balance == + // 0`) because no work was attempted; updating the + // host's cached balance would clobber it to zero + // every cooldown tick on a wallet that actually has + // a balance. Genuine steady-state caught-up passes + // (background loop ran, found nothing, returned the + // real balance) still advance `lastSyncTime` so users + // have a live signal that the loop is running. Per + // swift-sdk/CLAUDE.md the policy decision (real sync + // vs. cooldown skip) lives on the Rust side; Swift + // just marshals the flag. + if !result.cooldownSkip { + shieldedBalance = result.balance + lastNewNotes = result.newNotes + lastNewlySpent = result.newlySpent + lastSyncTime = Date( + timeIntervalSince1970: TimeInterval(event.syncUnixSeconds) + ) + syncCountSinceLaunch += 1 + totalScanned += result.totalScanned + totalNewNotes += UInt64(result.newNotes) + totalNewlySpent += UInt64(result.newlySpent) + } } else if result.skipped { // Skipped means the wallet hasn't been bound yet on the // Rust side. The UI can prompt the user to retry the @@ -241,8 +555,16 @@ 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). + /// 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) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift index 38c2058bd9..b727a7f2df 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -102,14 +102,34 @@ class SendViewModel: ObservableObject { } /// Amount in platform credits (1 DASH = 1e11 credits). Used by - /// platform-credit flows. Same `Decimal`-backed parsing as + /// every flow that touches the credits ledger + /// (`platformToShielded`, `shieldedToShielded`, + /// `shieldedToPlatform`, `shieldedToCore`, + /// `platformToPlatform`). Same `Decimal`-backed parsing as /// `amount`; the divisor difference is just the `decimals` arg. var amountCredits: UInt64? { parseTokenAmount(amountString, decimals: 11) } + /// Unit-explicit alias for [`amount`] — kept so the Core-side + /// shielded send flows that read `amountDuffs` stay self-documenting + /// (Core uses duffs; Platform / shielded use credits). + var amountDuffs: UInt64? { amount } + var canSend: Bool { - detectedFlow != nil && amount != nil && !isSending + guard let flow = detectedFlow, !isSending else { return false } + // Gate on the scaled integer for the *active* flow's unit, not + // just non-nil. A sub-unit amount (e.g. "0.000000001" in DASH) + // parses to 0 once scaled; sending that reaches the backend as a + // zero-value transfer. Core/L1 settles in duffs (1e8); every + // credits-ledger flow settles in credits (1e11). + switch flow { + case .coreToCore: + return (amountDuffs ?? 0) > 0 + case .platformToPlatform, .platformToShielded, + .shieldedToShielded, .shieldedToPlatform, .shieldedToCore: + return (amountCredits ?? 0) > 0 + } } /// Determine which fund sources are available based on destination and balances. @@ -175,6 +195,7 @@ class SendViewModel: ObservableObject { func executeSend( sdk: SDK, + walletManager: PlatformWalletManager, shieldedService: ShieldedService, platformState: AppState, wallet: PersistentWallet, @@ -195,14 +216,17 @@ 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 } - guard let amount = amount else { return } let address = recipientAddress.trimmingCharacters(in: .whitespacesAndNewlines) let _ = try core.sendToAddresses( - recipients: [(address: address, amountDuffs: amount)] + recipients: [(address: address, amountDuffs: amountDuffs)] ) successMessage = "Payment sent" @@ -307,23 +331,110 @@ class SendViewModel: ObservableObject { successMessage = "Platform transfer 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. 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 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 + } + try await walletManager.shieldedTransfer( + walletId: wallet.walletId, + account: 0, + recipientRaw43: recipientRaw, + amount: amountCredits + ) + successMessage = "Shielded transfer complete" + + case .shieldedToPlatform: + // Shielded → Platform: spend notes, credit the + // 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 trimmed = recipientAddress.trimmingCharacters(in: .whitespacesAndNewlines) + try await walletManager.shieldedUnshield( + walletId: wallet.walletId, + account: 0, + toPlatformAddress: trimmed, + amount: amountCredits + ) + successMessage = "Unshield complete" + + case .shieldedToCore: + // 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, + account: 0, + toCoreAddress: trimmed, + amount: amountCredits, + coreFeePerByte: 1 + ) + successMessage = "Withdrawal submitted" + + case .platformToShielded: + // Platform → Shielded (Type 15): spend credits from + // the wallet's first Platform Payment account into + // the bound shielded pool. Credits scale. + guard let amountCredits else { + error = "Invalid amount" + return + } _ = platformState - _ = shieldedService - _ = wallet - _ = modelContext _ = sdk - error = "Shielded sending is being rebuilt — see follow-up PR" - return + // `shieldedShield` has no recipient parameter — Rust + // always shields into this wallet's own default Orchard + // address (shieldedAccount 0). If the user typed a + // *different* Orchard address we'd report success while + // nothing reached that recipient, so constrain this path + // to self-shield only. + let enteredRecipient = recipientAddress + .trimmingCharacters(in: .whitespacesAndNewlines) + let ownShieldedAddress = + shieldedService.addressesByAccount[0] + ?? shieldedService.orchardDisplayAddress + if !enteredRecipient.isEmpty, + enteredRecipient != ownShieldedAddress { + // Don't advertise "leave it blank": a blank recipient + // clears `detectedFlow` upstream (detectAddressType → + // updateFlow), so `canSend` disables the button and + // this branch is only reachable with a non-empty + // address. Tell the user to enter their own. + error = "Shield always sends to your own shielded " + + "address; enter your own shielded address as " + + "the recipient" + return + } + let signer = KeychainSigner(modelContainer: modelContext.container) + try await walletManager.shieldedShield( + walletId: wallet.walletId, + shieldedAccount: 0, + paymentAccount: 0, + amount: amountCredits, + addressSigner: signer + ) + successMessage = "Shielding complete" } } catch { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift index 8732d91423..766ac15455 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,22 @@ 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] { + // Gate on the service's currently-bound wallet id so navigating + // between wallet details doesn't briefly show the *previous* + // wallet's shielded accounts before the singleton service + // finishes rebinding to this wallet. + guard shieldedService.boundWalletId == wallet.walletId else { return [] } + return shieldedService.boundAccounts + } + var body: some View { ZStack { - if accounts.isEmpty { + if accounts.isEmpty && shieldedAccountsForThisWallet.isEmpty { ContentUnavailableView( "No Accounts", systemImage: "folder", @@ -63,18 +77,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 +115,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 diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index 258cc693c9..77371f56eb 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -8,6 +8,10 @@ struct CoreContentView: View { @EnvironmentObject var appUIState: AppUIState @EnvironmentObject var platformBalanceSyncService: PlatformBalanceSyncService @EnvironmentObject var shieldedService: ShieldedService + /// Threaded into `ShieldedService.clearLocalState(modelContext:)` + /// so the Clear button can scope its delete-by-predicate to + /// the bound wallet's persisted rows. + @Environment(\.modelContext) private var modelContext @State private var showProofDetail = false @State private var masternodesEnabled: Bool = true @State private var platformSyncExpanded: Bool = false @@ -420,36 +424,13 @@ var body: some View { Spacer() } - // Shielded balance - HStack { - Text("Shielded Balance") - .font(.subheadline) - .foregroundColor(.secondary) - Spacer() - if shieldedService.shieldedBalance > 0 { - Text(formatCredits(shieldedService.shieldedBalance)) - .font(.subheadline) - .fontWeight(.medium) - } else { - Text("0") - .font(.subheadline) - .foregroundColor(.secondary) - } - } - - // Orchard address - if let address = shieldedService.orchardDisplayAddress { - HStack { - Text("Orchard Address") - .font(.subheadline) - .foregroundColor(.secondary) - Spacer() - Text(address.prefix(12) + "..." + address.suffix(8)) - .font(.caption) - .fontWeight(.medium) - .foregroundColor(.purple) - } - } + // Aggregate shielded balance + notes-synced + // watermark across every wallet/account on disk. + // Read straight from SwiftData (network-wide, no + // wallet scoping) so the figures survive restart + // and reflect the whole pool rather than a single + // bound wallet. + ShieldedNetworkSummaryRows(walletIds: walletIdsOnNetwork) // Sync counters since launch — `total_scanned` // is the wire-level encrypted-note count (every @@ -512,10 +493,35 @@ var body: some View { .buttonStyle(.borderedProminent) .tint(.purple) .controlSize(.mini) - .disabled(shieldedService.isSyncing) + // Disabled while a sync is in flight, and + // pre-first-bind when there are no stashed + // credentials to resume from. Post-Clear + // is *not* disabled — `manualSync` rebinds + // on demand from the credentials kept by + // `clearLocalState`, so the user has a + // path back to a synced state without + // having to navigate away. + .disabled(shieldedService.isSyncing || !shieldedService.canResume) Button { - shieldedService.reset() + // Stop the manager-wide shielded sync + // loop, then wipe every wallet's + // persisted shielded rows (notes + + // sync state). The Swift mirror zeros + // out and the service goes unbound, + // but the bind credentials are kept + // so the user can tap Sync Now to + // self-rebind from this screen — no + // navigation detour required. The + // on-disk SQLite tree is intentionally + // NOT deleted (Rust still holds its + // handle open via FileBackedShieldedStore; + // see clearLocalState's doc). + Task { + await shieldedService.clearLocalState( + modelContext: modelContext + ) + } } label: { Text("Clear") .font(.caption) @@ -524,6 +530,16 @@ var body: some View { .buttonStyle(.borderedProminent) .tint(.red) .controlSize(.mini) + // Gated on `isSyncing` to close the + // double-tap window where the user could + // hit Clear *while* a sync is in flight. + // `clearLocalState` calls + // `stopShieldedSync()` first, but stop is + // best-effort and the persister callback + // can still drain rows into SwiftData + // between our delete and the loop + // actually quiescing. + .disabled(shieldedService.isSyncing) } } .padding(.vertical, 4) @@ -783,12 +799,26 @@ struct WalletRowView: View { /// addresses (no identities) showed "Empty". @Query private var addressBalances: [PersistentPlatformAddress] + /// Per-wallet unspent shielded notes. Same persisted-truth + /// source as the Sync Status diagnostic — sum of `value` over + /// unspent rows for this wallet, in credits. Without this the + /// wallet row's combined DASH total under-reported the wallet's + /// real value by every shielded note: a wallet with funds in + /// the shielded pool would show Core + Platform on the list but + /// silently exclude Shielded. + @Query private var shieldedNotes: [PersistentShieldedNote] + init(wallet: PersistentWallet) { self.wallet = wallet let walletId = wallet.walletId _addressBalances = Query( filter: #Predicate { $0.walletId == walletId } ) + _shieldedNotes = Query( + filter: #Predicate { + $0.walletId == walletId && !$0.isSpent + } + ) } /// Identities on this wallet — via the SwiftData relationship. @@ -836,12 +866,20 @@ struct WalletRowView: View { totals.confirmed + totals.unconfirmed + totals.immature + totals.locked } + /// Sum of unspent shielded note values in credits. Same scale + /// as `platformBalance` (1e11 credits/DASH), so it folds into + /// the same divisor in [`combinedDashAmount(coreTotal:)`]. + private var shieldedBalance: UInt64 { + shieldedNotes.reduce(UInt64(0)) { $0 + $1.value } + } + /// Combined wallet balance expressed in DASH for a precomputed - /// totals tuple. Core uses 1e8 duffs/DASH; Platform uses 1e11 - /// credits/DASH. + /// totals tuple. Core uses 1e8 duffs/DASH; Platform and Shielded + /// both use 1e11 credits/DASH. private func combinedDashAmount(coreTotal: UInt64) -> Double { Double(coreTotal) / 100_000_000.0 + Double(platformBalance) / 100_000_000_000.0 + + Double(shieldedBalance) / 100_000_000_000.0 } private var walletIdShort: String { @@ -921,7 +959,7 @@ struct WalletRowView: View { // re-invoking the accessor. let core = coreBalanceTotals() let coreTotal = Self.sumCoreBalance(core) - let hasAny = coreTotal > 0 || platformBalance > 0 + let hasAny = coreTotal > 0 || platformBalance > 0 || shieldedBalance > 0 return VStack(alignment: .leading, spacing: 6) { // Header: label (+ status badges) and total Core balance. HStack(alignment: .firstTextBaseline) { @@ -1177,3 +1215,99 @@ extension CoreContentView { return String(format: "%.8f DASH", dash) } } + +// MARK: - ShieldedNetworkSummaryRows + +/// Network-wide shielded summary: aggregate unspent balance and the +/// notes-synced watermark across every wallet/account **on the active +/// network**. +/// +/// Both figures are read **directly from SwiftData** (scoped to the +/// active network via `walletIds`) rather than from the +/// `ShieldedService.shieldedBalance` mirror that's updated +/// per-bound-wallet from sync events, so they survive restart and +/// reflect the whole on-network pool: +/// +/// * **Total Shielded Balance** — sum of `value` over every unspent +/// `PersistentShieldedNote` whose wallet is on this network. +/// +/// * **Notes Synced** — the highest `lastSyncedIndex` across this +/// network's `PersistentShieldedSyncState` rows. The Orchard +/// commitment tree is chain-wide and shared by every wallet/account +/// **on a given network**, so each subwallet advances toward the +/// same tip; the max is the furthest-scanned position and climbs as +/// sync progresses. Scoping matters: trees are per-chain, so a +/// `max()` across networks would blend unrelated tip positions. +private struct ShieldedNetworkSummaryRows: View { + /// Wallet ids on the active network. Both queries are filtered + /// against this so a multi-network install (e.g. regtest + testnet) + /// doesn't blend balances or take a watermark `max()` across + /// unrelated per-chain commitment trees — matching the Platform + /// Sync Status section's `walletIdsOnNetwork` scoping. + let walletIds: Set + + @Query private var allNotes: [PersistentShieldedNote] + @Query private var syncStates: [PersistentShieldedSyncState] + + /// Sum of `value` over this network's unspent notes, in credits. + private var totalUnspentCredits: UInt64 { + allNotes.lazy + .filter { !$0.isSpent && walletIds.contains($0.walletId) } + .reduce(UInt64(0)) { $0 &+ $1.value } + } + + /// Furthest-scanned commitment-tree index across this network's subwallets. + private var notesSynced: UInt64 { + syncStates.lazy + .filter { walletIds.contains($0.walletId) } + .map(\.lastSyncedIndex) + .max() ?? 0 + } + + /// 1 DASH = 100,000,000,000 credits — matches `formatCredits`. + private func formatCredits(_ credits: UInt64) -> String { + let dash = Double(credits) / 100_000_000_000.0 + let formatter = NumberFormatter() + formatter.minimumFractionDigits = 0 + formatter.maximumFractionDigits = 8 + formatter.numberStyle = .decimal + formatter.groupingSeparator = "," + formatter.decimalSeparator = "." + if let formatted = formatter.string(from: NSNumber(value: dash)) { + return "\(formatted) DASH" + } + return String(format: "%.4f DASH", dash) + } + + var body: some View { + VStack(spacing: 8) { + // Aggregate unspent balance across all wallets. + HStack { + Text("Total Shielded Balance") + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + if totalUnspentCredits > 0 { + Text(formatCredits(totalUnspentCredits)) + .font(.subheadline) + .fontWeight(.medium) + } else { + Text("0") + .font(.subheadline) + .foregroundColor(.secondary) + } + } + + // Notes-synced watermark — climbs as sync progresses. + HStack { + Text("Notes Synced") + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + Text(notesSynced, format: .number) + .font(.system(.caption, design: .monospaced)) + .fontWeight(.medium) + } + } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift index cfe28625b0..fa4f93f754 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift @@ -194,6 +194,7 @@ struct SendTransactionView: View { ) await viewModel.executeSend( sdk: sdk, + walletManager: walletManager, shieldedService: shieldedService, platformState: platformState, wallet: wallet, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index 7fbb2f1450..a51cdd518b 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) + } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index 616d0966e5..17c29990d4 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -107,6 +107,13 @@ 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 + // `backgroundManager(for:)` without flipping the + // user's active view. .environmentObject(walletManagerStore) .environmentObject(shieldedService) .environmentObject(platformBalanceSyncService) @@ -240,6 +247,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. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift index 82d02289f2..d0a2a2121e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift @@ -116,6 +116,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 { @@ -252,6 +262,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) + } filteredCount(PersistentAssetLock.self) { walletsOnNetwork.contains($0.walletId) } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift index 7a3a429607..8f277c645e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift @@ -1703,3 +1703,186 @@ 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 + NavigationLink(destination: ShieldedNoteStorageDetailView(record: record)) { + 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 scoped.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 + NavigationLink(destination: ShieldedSyncStateStorageDetailView(record: record)) { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text( + record.walletId.prefix(4) + .map { String(format: "%02x", $0) }.joined() + ) + .font(.system(.caption2, design: .monospaced)) + Text("acct \(record.accountIndex)") + .font(.caption2) + .foregroundColor(.secondary) + Spacer() + } + Text("synced index: \(record.lastSyncedIndex)") + .font(.caption) + if record.hasNullifierCheckpoint { + Text("nf: h \(record.nullifierCheckpointHeight) · ts \(record.nullifierCheckpointTimestamp)") + .font(.caption2) + .foregroundColor(.secondary) + } + } + } + } + } + .navigationTitle("Shielded Sync State (\(visible.count))") + .overlay { + if visible.isEmpty { + ContentUnavailableView("No Sync States", systemImage: "arrow.triangle.2.circlepath") + } + } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index 72c9e448bd..6e113144f8 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -1791,6 +1791,74 @@ struct WalletManagerMetadataStorageDetailView: View { } } +// MARK: - PersistentShieldedNote + +struct ShieldedNoteStorageDetailView: View { + let record: PersistentShieldedNote + + var body: some View { + Form { + Section("Identity") { + FieldRow(label: "Wallet ID", value: hexString(record.walletId)) + FieldRow(label: "Account Index", value: "\(record.accountIndex)") + FieldRow(label: "Position", value: "\(record.position)") + } + Section("Commitment") { + FieldRow(label: "cmx", value: hexString(record.cmx)) + FieldRow(label: "Nullifier", value: hexString(record.nullifier)) + } + Section("State") { + FieldRow(label: "Block Height", value: "\(record.blockHeight)") + FieldRow(label: "Spent", value: record.isSpent ? "Yes" : "No") + FieldRow(label: "Value", value: "\(record.value) credits") + } + Section("Note Bytes") { + Text(hexString(record.noteData)) + .font(.system(.caption2, design: .monospaced)) + .textSelection(.enabled) + } + Section("Timestamps") { + FieldRow(label: "Created", value: dateString(record.createdAt)) + FieldRow(label: "Updated", value: dateString(record.lastUpdated)) + } + } + .navigationTitle("Shielded Note") + .navigationBarTitleDisplayMode(.inline) + } +} + +// MARK: - PersistentShieldedSyncState + +struct ShieldedSyncStateStorageDetailView: View { + let record: PersistentShieldedSyncState + + var body: some View { + Form { + Section("Identity") { + FieldRow(label: "Wallet ID", value: hexString(record.walletId)) + FieldRow(label: "Account Index", value: "\(record.accountIndex)") + } + Section("Sync") { + FieldRow(label: "Last Synced Index", value: "\(record.lastSyncedIndex)") + } + Section("Nullifier Checkpoint") { + FieldRow(label: "Present", value: record.hasNullifierCheckpoint ? "Yes" : "No") + if record.hasNullifierCheckpoint { + FieldRow(label: "Height", value: "\(record.nullifierCheckpointHeight)") + FieldRow(label: "Timestamp", value: "\(record.nullifierCheckpointTimestamp)") + } + } + Section("Timestamps") { + FieldRow(label: "Updated", value: dateString(record.lastUpdated)) + } + } + .navigationTitle("Shielded Sync State") + .navigationBarTitleDisplayMode(.inline) + } +} + +// MARK: - PersistentAssetLock + struct AssetLockStorageDetailView: View { let record: PersistentAssetLock