diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index 0085d8c8547..e81074a6472 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -52,6 +52,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; @@ -107,6 +111,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 82d29fb3b65..c80a22e03f0 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -287,6 +287,97 @@ pub struct PersistenceCallbacks { removed_incoming_count: usize, ) -> i32, >, + // ── Shielded (Orchard) persistence ───────────────────────────────── + // + // These four `on_persist_shielded_*` callbacks fire from + // `FFIPersister::store` whenever a `ShieldedChangeSet` arrives + // from `ShieldedWallet`. The matching `on_load_shielded_*` + // callbacks fire once on `FFIPersister::load` to rehydrate the + // in-memory `SubwalletState`s before the first sync pass. The + // `wallet_id` carried inside each entry scopes the row by + // wallet; the outer `wallet_id` argument on the `store` + // callback identifies the wallet the changeset originated from + // (always identical to every entry's nested `wallet_id`). + /// Per-subwallet decrypted notes upserts. + #[cfg(feature = "shielded")] + pub on_persist_shielded_notes_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + entries: *const crate::shielded_persistence::ShieldedNoteFFI, + count: usize, + ) -> i32, + >, + /// Per-subwallet nullifier-spent observations. + #[cfg(feature = "shielded")] + pub on_persist_shielded_nullifiers_spent_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + entries: *const crate::shielded_persistence::ShieldedNullifierSpentFFI, + count: usize, + ) -> i32, + >, + /// Per-subwallet sync watermark advances. + #[cfg(feature = "shielded")] + pub on_persist_shielded_synced_indices_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + entries: *const crate::shielded_persistence::ShieldedSyncedIndexFFI, + count: usize, + ) -> i32, + >, + /// Per-subwallet nullifier-sync checkpoint advances. + #[cfg(feature = "shielded")] + pub on_persist_shielded_nullifier_checkpoints_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + entries: *const crate::shielded_persistence::ShieldedNullifierCheckpointFFI, + count: usize, + ) -> i32, + >, + /// Restore-on-load: every persisted shielded note. Host + /// allocates the array; Rust calls the matching free + /// callback after copying. Same lifetime contract as + /// `on_load_wallet_list_fn`. Inlined here (rather than via + /// the `OnLoadShieldedNotesFn` type alias) so cbindgen sees + /// the full signature and emits the referenced struct + /// definitions in the generated header. + #[cfg(feature = "shielded")] + pub on_load_shielded_notes_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + out_entries: *mut *const crate::shielded_persistence::ShieldedNoteRestoreFFI, + out_count: *mut usize, + ) -> i32, + >, + #[cfg(feature = "shielded")] + pub on_load_shielded_notes_free_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + entries: *const crate::shielded_persistence::ShieldedNoteRestoreFFI, + count: usize, + ), + >, + /// Restore-on-load: every per-subwallet sync state. + #[cfg(feature = "shielded")] + pub on_load_shielded_sync_states_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + out_entries: *mut *const crate::shielded_persistence::ShieldedSubwalletSyncStateFFI, + out_count: *mut usize, + ) -> i32, + >, + #[cfg(feature = "shielded")] + pub on_load_shielded_sync_states_free_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + entries: *const crate::shielded_persistence::ShieldedSubwalletSyncStateFFI, + count: usize, + ), + >, } // SAFETY: The context pointer is managed by the FFI caller who must ensure @@ -800,6 +891,152 @@ impl PlatformWalletPersistence for FFIPersister { } } + // Shielded changeset (Orchard): four flat callback batches + // mirroring the four `ShieldedChangeSet` fields. Notes + // first so a follow-up `mark_spent` for the same nullifier + // upserts onto an existing row instead of falling on + // missing-row floor. + #[cfg(feature = "shielded")] + if let Some(ref shielded_cs) = changeset.shielded { + use crate::shielded_persistence::*; + + // 1) notes_saved + if !shielded_cs.notes_saved.is_empty() { + if let Some(cb) = self.callbacks.on_persist_shielded_notes_fn { + // Flatten the per-subwallet map into a single + // contiguous Vec so the callback gets one + // `entries: *const ShieldedNoteFFI` slice. The + // host copies `note_data` bytes during the call. + let entries: Vec = shielded_cs + .notes_saved + .iter() + .flat_map(|(id, notes)| { + notes.iter().map(|n| ShieldedNoteFFI { + wallet_id: id.wallet_id, + account_index: id.account_index, + position: n.position, + cmx: n.cmx, + nullifier: n.nullifier, + block_height: n.block_height, + is_spent: u8::from(n.is_spent), + value: n.value, + note_data_ptr: n.note_data.as_ptr(), + note_data_len: n.note_data.len(), + }) + }) + .collect(); + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + entries.as_ptr(), + entries.len(), + ) + }; + if result != 0 { + eprintln!( + "Shielded notes persistence callback returned error code {}", + result + ); + round_success = false; + } + } + } + + // 2) nullifiers_spent + if !shielded_cs.nullifiers_spent.is_empty() { + if let Some(cb) = self.callbacks.on_persist_shielded_nullifiers_spent_fn { + let entries: Vec = shielded_cs + .nullifiers_spent + .iter() + .flat_map(|(id, nfs)| { + nfs.iter().map(|nf| ShieldedNullifierSpentFFI { + wallet_id: id.wallet_id, + account_index: id.account_index, + nullifier: *nf, + }) + }) + .collect(); + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + entries.as_ptr(), + entries.len(), + ) + }; + if result != 0 { + eprintln!( + "Shielded nullifier-spent persistence callback returned error code {}", + result + ); + round_success = false; + } + } + } + + // 3) synced_indices + if !shielded_cs.synced_indices.is_empty() { + if let Some(cb) = self.callbacks.on_persist_shielded_synced_indices_fn { + let entries: Vec = shielded_cs + .synced_indices + .iter() + .map(|(id, &idx)| ShieldedSyncedIndexFFI { + wallet_id: id.wallet_id, + account_index: id.account_index, + last_synced_index: idx, + }) + .collect(); + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + entries.as_ptr(), + entries.len(), + ) + }; + if result != 0 { + eprintln!( + "Shielded synced-index persistence callback returned error code {}", + result + ); + round_success = false; + } + } + } + + // 4) nullifier_checkpoints + if !shielded_cs.nullifier_checkpoints.is_empty() { + if let Some(cb) = self.callbacks.on_persist_shielded_nullifier_checkpoints_fn { + let entries: Vec = shielded_cs + .nullifier_checkpoints + .iter() + .map(|(id, &(h, t))| ShieldedNullifierCheckpointFFI { + wallet_id: id.wallet_id, + account_index: id.account_index, + height: h, + timestamp: t, + }) + .collect(); + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + entries.as_ptr(), + entries.len(), + ) + }; + if result != 0 { + eprintln!( + "Shielded nullifier-checkpoint persistence callback returned error code {}", + result + ); + round_success = false; + } + } + } + } + // Close the round. Clients use this to commit (if // `round_success == true`) or roll back (otherwise) the // staged writes accumulated across the per-kind callbacks @@ -900,6 +1137,147 @@ impl PlatformWalletPersistence for FFIPersister { .insert(entry.wallet_id, platform_address_state); } } + + // Restore shielded sub-wallet state if the host has wired + // up the optional callbacks. Notes and per-subwallet sync + // states travel separately so the host can populate them + // from independent SwiftData fetch descriptors. Both arms + // walk the same `(wallet_id, account_index)` key space and + // funnel into a single `SubwalletId` map on + // `ClientStartState.shielded`. + #[cfg(feature = "shielded")] + { + use crate::shielded_persistence::*; + use platform_wallet::changeset::{ShieldedSubwalletStartState, ShieldedSyncStartState}; + use platform_wallet::wallet::shielded::{ShieldedNote, SubwalletId}; + + let mut shielded_state = ShieldedSyncStartState::default(); + + // 1) notes + if let Some(load_notes) = self.callbacks.on_load_shielded_notes_fn { + let mut notes_ptr: *const ShieldedNoteRestoreFFI = std::ptr::null(); + let mut notes_count: usize = 0; + let rc = + unsafe { load_notes(self.callbacks.context, &mut notes_ptr, &mut notes_count) }; + if rc != 0 { + return Err( + format!("on_load_shielded_notes_fn returned error code {}", rc).into(), + ); + } + struct NotesGuard { + context: *mut c_void, + free_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + entries: *const ShieldedNoteRestoreFFI, + count: usize, + ), + >, + entries: *const ShieldedNoteRestoreFFI, + count: usize, + } + impl Drop for NotesGuard { + fn drop(&mut self) { + if let Some(free_fn) = self.free_fn { + unsafe { free_fn(self.context, self.entries, self.count) }; + } + } + } + let _notes_guard = NotesGuard { + context: self.callbacks.context, + free_fn: self.callbacks.on_load_shielded_notes_free_fn, + entries: notes_ptr, + count: notes_count, + }; + if !notes_ptr.is_null() && notes_count > 0 { + let slice = unsafe { slice::from_raw_parts(notes_ptr, notes_count) }; + for ffi in slice { + if ffi.note_data_ptr.is_null() || ffi.note_data_len == 0 { + continue; + } + let note_data = unsafe { + std::slice::from_raw_parts(ffi.note_data_ptr, ffi.note_data_len) + .to_vec() + }; + let id = SubwalletId::new(ffi.wallet_id, ffi.account_index); + let entry = shielded_state + .per_subwallet + .entry(id) + .or_insert_with(ShieldedSubwalletStartState::default); + entry.notes.push(ShieldedNote { + position: ffi.position, + cmx: ffi.cmx, + nullifier: ffi.nullifier, + block_height: ffi.block_height, + is_spent: ffi.is_spent != 0, + value: ffi.value, + note_data, + }); + } + } + } + + // 2) per-subwallet sync states + if let Some(load_states) = self.callbacks.on_load_shielded_sync_states_fn { + let mut states_ptr: *const ShieldedSubwalletSyncStateFFI = std::ptr::null(); + let mut states_count: usize = 0; + let rc = unsafe { + load_states(self.callbacks.context, &mut states_ptr, &mut states_count) + }; + if rc != 0 { + return Err(format!( + "on_load_shielded_sync_states_fn returned error code {}", + rc + ) + .into()); + } + struct StatesGuard { + context: *mut c_void, + free_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + entries: *const ShieldedSubwalletSyncStateFFI, + count: usize, + ), + >, + entries: *const ShieldedSubwalletSyncStateFFI, + count: usize, + } + impl Drop for StatesGuard { + fn drop(&mut self) { + if let Some(free_fn) = self.free_fn { + unsafe { free_fn(self.context, self.entries, self.count) }; + } + } + } + let _states_guard = StatesGuard { + context: self.callbacks.context, + free_fn: self.callbacks.on_load_shielded_sync_states_free_fn, + entries: states_ptr, + count: states_count, + }; + if !states_ptr.is_null() && states_count > 0 { + let slice = unsafe { slice::from_raw_parts(states_ptr, states_count) }; + for ffi in slice { + let id = SubwalletId::new(ffi.wallet_id, ffi.account_index); + let entry = shielded_state + .per_subwallet + .entry(id) + .or_insert_with(ShieldedSubwalletStartState::default); + entry.last_synced_index = ffi.last_synced_index; + if ffi.has_nullifier_checkpoint != 0 { + entry.nullifier_checkpoint = Some(( + ffi.nullifier_checkpoint_height, + ffi.nullifier_checkpoint_timestamp, + )); + } + } + } + } + + out.shielded = shielded_state; + } + Ok(out) } } diff --git a/packages/rs-platform-wallet-ffi/src/shielded_persistence.rs b/packages/rs-platform-wallet-ffi/src/shielded_persistence.rs new file mode 100644 index 00000000000..1d58c9be86e --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/shielded_persistence.rs @@ -0,0 +1,125 @@ +//! C ABI types + callback signatures for shielded note persistence. +//! +//! Mirror of [`platform_wallet::changeset::ShieldedChangeSet`] for the +//! FFI boundary: per-subwallet decrypted notes, spent marks, sync +//! watermarks, nullifier checkpoints. Hosts implement the four +//! callbacks below in [`crate::persistence::PersistenceCallbacks`] +//! so changesets emitted by the Rust-side `ShieldedWallet` reach +//! durable storage (typically SwiftData on iOS). +//! +//! All pointers in these structs are valid for the duration of the +//! callback only — the host must copy any bytes it needs to retain +//! before the call returns. + +use std::ffi::c_void; + +/// One decrypted shielded note for the host to persist. +/// +/// The host writes one row keyed by +/// `(wallet_id, account_index, position)`. Re-saves with the same +/// `(wallet_id, account_index, nullifier)` overwrite the existing +/// row in place — Orchard nullifiers are globally unique, so a +/// rescan after a restart shouldn't produce duplicates. +#[repr(C)] +pub struct ShieldedNoteFFI { + /// 32-byte wallet identifier. + pub wallet_id: [u8; 32], + /// ZIP-32 account index. + pub account_index: u32, + /// Global commitment-tree position. + pub position: u64, + /// Note commitment (32 bytes). + pub cmx: [u8; 32], + /// Nullifier (32 bytes). + pub nullifier: [u8; 32], + /// Block height the note was first observed at. + pub block_height: u64, + /// `1` if this note has been observed as spent on-chain, `0` + /// otherwise. (`bool` would still take 1 byte but `u8` is + /// less surprising across the C ABI.) + pub is_spent: u8, + /// Note value in credits. + pub value: u64, + /// Pointer to the serialized `orchard::Note` payload. + /// `recipient(43) || value(8 LE) || rho(32) || rseed(32)` = + /// 115 bytes. Valid only for the callback window — the host + /// must copy. + pub note_data_ptr: *const u8, + /// Length of `note_data_ptr` in bytes (always 115 for valid notes). + pub note_data_len: usize, +} + +/// One nullifier observed as spent for `(wallet_id, account_index)`. +/// The host flips the matching `is_spent` flag on the existing +/// `ShieldedNoteFFI` row. +#[repr(C)] +pub struct ShieldedNullifierSpentFFI { + pub wallet_id: [u8; 32], + pub account_index: u32, + pub nullifier: [u8; 32], +} + +/// One per-subwallet sync-watermark advance. +#[repr(C)] +pub struct ShieldedSyncedIndexFFI { + pub wallet_id: [u8; 32], + pub account_index: u32, + /// Highest global commitment-tree index the subwallet has scanned. + pub last_synced_index: u64, +} + +/// One per-subwallet nullifier-sync checkpoint. +#[repr(C)] +pub struct ShieldedNullifierCheckpointFFI { + pub wallet_id: [u8; 32], + pub account_index: u32, + /// Block height of the most recent nullifier sync pass. + pub height: u64, + /// Block timestamp (Unix seconds) of the most recent pass. + pub timestamp: u64, +} + +// ── Restore (load) ────────────────────────────────────────────────────── + +/// One persisted note as the host hands it back at boot. Mirrors +/// [`ShieldedNoteFFI`] but lives in a Swift-allocated array, so +/// the buffer ownership / free contract differs (see +/// [`OnLoadShieldedNotesFreeFn`]). +#[repr(C)] +pub struct ShieldedNoteRestoreFFI { + pub wallet_id: [u8; 32], + pub account_index: u32, + pub position: u64, + pub cmx: [u8; 32], + pub nullifier: [u8; 32], + pub block_height: u64, + pub is_spent: u8, + pub value: u64, + pub note_data_ptr: *const u8, + pub note_data_len: usize, +} + +/// One per-subwallet sync-watermark + nullifier-checkpoint snapshot. +/// Restored alongside notes so the rehydrated `SubwalletState` +/// resumes incremental sync from the right place. +#[repr(C)] +pub struct ShieldedSubwalletSyncStateFFI { + pub wallet_id: [u8; 32], + pub account_index: u32, + pub last_synced_index: u64, + /// `1` iff the optional `nullifier_checkpoint` is populated. + pub has_nullifier_checkpoint: u8, + pub nullifier_checkpoint_height: u64, + pub nullifier_checkpoint_timestamp: u64, +} + +// The `on_load_shielded_*_fn` callback types are inlined inside +// [`PersistenceCallbacks`] (rather than declared as `pub type` +// aliases here) so cbindgen sees the full signature, walks into +// the referenced structs, and emits their full field layout in +// the generated header. Bare `pub type X = unsafe extern "C" fn` +// aliases are mangled into opaque structs by cbindgen and don't +// drag in their function-pointer arguments. + +#[allow(dead_code)] +fn _keep_c_void_in_scope(_x: *const c_void) {} diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs new file mode 100644 index 00000000000..9bbe60fbecb --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -0,0 +1,338 @@ +//! 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 = match resolve_wallet(handle, &wallet_id) { + Ok(w) => w, + 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(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 = match resolve_wallet(handle, &wallet_id) { + Ok(w) => w, + Err(result) => return result, + }; + + let result = block_on_worker(async move { + let prover = CachedOrchardProver::new(); + wallet + .shielded_unshield_to(account, &to_addr_str, amount, &prover) + .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 = match resolve_wallet(handle, &wallet_id) { + Ok(w) => w, + Err(result) => return result, + }; + + let result = block_on_worker(async move { + let prover = CachedOrchardProver::new(); + wallet + .shielded_withdraw_to(account, &to_core, amount, core_fee_per_byte, &prover) + .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 +/// `*mut SignerHandle` that outlives this call and points at a +/// `VTableSigner` with the callback variant (the native variant +/// doesn't satisfy `Signer`). +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_shielded_shield( + handle: Handle, + wallet_id_bytes: *const u8, + shielded_account: u32, + payment_account: u32, + amount: u64, + signer_address_handle: *mut SignerHandle, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id_bytes); + check_ptr!(signer_address_handle); + + let mut wallet_id = [0u8; 32]; + std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); + + let wallet = match resolve_wallet(handle, &wallet_id) { + Ok(w) => w, + Err(result) => return result, + }; + + // SAFETY: the caller retains ownership of the signer handle + // and guarantees it outlives this call. We block until the + // worker future completes, so the `'static` lifetime we paint + // on the borrow does not actually outlive the host's handle. + // `VTableSigner` is `Send + Sync` per its `unsafe impl` in + // rs-sdk-ffi, so `&'static VTableSigner` is automatically + // `Send + 'static` — exactly what `block_on_worker` needs. + let address_signer: &'static VTableSigner = + std::mem::transmute::<&VTableSigner, &'static VTableSigner>( + &*(signer_address_handle as *const VTableSigner), + ); + + // Run the proof on a worker thread (8 MB stack). Halo 2 circuit + // synthesis recurses past the ~512 KB iOS dispatch-thread stack + // and crashes with EXC_BAD_ACCESS at the first + // `synthesize(... measure(pass))` call when polled on the + // calling thread. + let result = block_on_worker(async move { + let prover = CachedOrchardProver::new(); + wallet + .shielded_shield_from_account( + 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)), + ) + }) +} diff --git a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs index 31e9bd43140..db26d69c0f3 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs @@ -29,8 +29,11 @@ use crate::{check_ptr, unwrap_option_or_return}; impl ShieldedSyncWalletResultFFI { pub(crate) fn ok(wallet_id: [u8; 32], summary: &ShieldedSyncSummary) -> Self { - let new_notes = u32::try_from(summary.notes_result.new_notes).unwrap_or(u32::MAX); - let newly_spent = u32::try_from(summary.newly_spent).unwrap_or(u32::MAX); + // Multi-account on the Rust side; flattened to wallet-level + // sums here. Hosts that want per-account detail call + // `platform_wallet_manager_shielded_balance(account)`. + let new_notes = u32::try_from(summary.notes_result.total_new_notes()).unwrap_or(u32::MAX); + let newly_spent = u32::try_from(summary.total_newly_spent()).unwrap_or(u32::MAX); Self { wallet_id, success: true, @@ -38,7 +41,7 @@ impl ShieldedSyncWalletResultFFI { new_notes, total_scanned: summary.notes_result.total_scanned, newly_spent, - balance: summary.balance, + balance: summary.balance_total(), error_message: std::ptr::null(), } } @@ -156,38 +159,57 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_sync_sync_now( /// `db_path`, and bind the resulting [`ShieldedWallet`] to the /// `PlatformWallet`. /// +/// `accounts_ptr` / `accounts_len` describe the ZIP-32 account +/// indices to derive. The slice must be non-empty and at most +/// `64` entries; pass a one-element `[0]` array for the +/// single-account default. Each entry produces an independent +/// [`OrchardKeySet`] and bookkeeping `SubwalletId` inside the +/// store; the same commitment tree backs every account on the +/// network. +/// /// The resolver fires exactly once per call. The mnemonic and the -/// derived seed live in `Zeroizing` buffers and are scrubbed before -/// this function returns; only the FVK / IVK / OVK / default -/// payment address survive on the wallet. +/// derived seed live in `Zeroizing` buffers and are scrubbed +/// before this function returns; only the per-account FVK / IVK / +/// OVK / default payment addresses survive on the wallet. /// /// `db_path` is owned by the host (typically -/// `/shielded_tree_.sqlite`). The same path is fine -/// to share across wallets on the same network — the commitment -/// tree is global per network and per-wallet decrypted notes live -/// in memory. +/// `/shielded_tree_.sqlite`). The same path is +/// fine to share across wallets on the same network — the +/// commitment tree is global per network; decrypted notes are +/// scoped per `(wallet_id, account_index)` inside the store. /// -/// Idempotent: a second call with a different db path / account -/// replaces the previously-bound shielded wallet. +/// Idempotent: a second call replaces the previously-bound +/// shielded wallet. /// /// # Safety /// - `wallet_id_bytes` must point at 32 readable bytes. +/// - `accounts_ptr` must point at `accounts_len` readable `u32`s. /// - `mnemonic_resolver_handle` must come from /// [`crate::dash_sdk_mnemonic_resolver_create`]. /// - `db_path_cstr` must be a valid NUL-terminated UTF-8 C string. /// /// [`ShieldedWallet`]: platform_wallet::wallet::shielded::ShieldedWallet +/// [`OrchardKeySet`]: platform_wallet::wallet::shielded::OrchardKeySet #[no_mangle] pub unsafe extern "C" fn platform_wallet_manager_bind_shielded( handle: Handle, wallet_id_bytes: *const u8, mnemonic_resolver_handle: *mut MnemonicResolverHandle, - account: u32, + accounts_ptr: *const u32, + accounts_len: usize, db_path_cstr: *const c_char, ) -> PlatformWalletFFIResult { check_ptr!(wallet_id_bytes); check_ptr!(mnemonic_resolver_handle); check_ptr!(db_path_cstr); + check_ptr!(accounts_ptr); + if accounts_len == 0 || accounts_len > 64 { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("accounts_len must be in 1..=64, got {accounts_len}"), + ); + } + let accounts: Vec = std::slice::from_raw_parts(accounts_ptr, accounts_len).to_vec(); let mut wallet_id = [0u8; 32]; std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); @@ -285,7 +307,9 @@ pub unsafe extern "C" fn platform_wallet_manager_bind_shielded( } }; - if let Err(e) = runtime().block_on(wallet_arc.bind_shielded(seed.as_ref(), account, &db_path)) { + if let Err(e) = + runtime().block_on(wallet_arc.bind_shielded(seed.as_ref(), accounts.as_slice(), &db_path)) + { return PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorWalletOperation, format!("bind_shielded failed: {e}"), @@ -299,16 +323,16 @@ pub unsafe extern "C" fn platform_wallet_manager_bind_shielded( // Default Orchard payment address // --------------------------------------------------------------------------- -/// Read the default Orchard payment address for the bound shielded -/// sub-wallet on `wallet_id`. The host receives the 43 raw bytes -/// (recipient + diversifier) and applies its own bech32m encoding. +/// Read the default Orchard payment address for `account` on the +/// bound shielded sub-wallet of `wallet_id`. The host receives 43 +/// raw bytes (recipient + diversifier) and applies its own +/// bech32m encoding. /// /// `*out_present` is set to `true` and 43 bytes are written to -/// `out_bytes_43` when the wallet has been bound via -/// [`platform_wallet_manager_bind_shielded`]. When the wallet is -/// known but not bound, `*out_present` is set to `false` and -/// `out_bytes_43` is left untouched. An unknown wallet returns -/// `ErrorWalletOperation`. +/// `out_bytes_43` when `account` is bound. `*out_present` is set +/// to `false` when the wallet is known but the shielded +/// sub-wallet hasn't been bound, or `account` isn't bound on it. +/// An unknown wallet returns `ErrorWalletOperation`. /// /// # Safety /// - `wallet_id_bytes` must point at 32 readable bytes. @@ -318,6 +342,7 @@ pub unsafe extern "C" fn platform_wallet_manager_bind_shielded( pub unsafe extern "C" fn platform_wallet_manager_shielded_default_address( handle: Handle, wallet_id_bytes: *const u8, + account: u32, out_bytes_43: *mut u8, out_present: *mut bool, ) -> PlatformWalletFFIResult { @@ -338,7 +363,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_default_address( runtime().block_on(async { match manager.get_wallet(&wallet_id).await { None => Outcome::WalletMissing, - Some(w) => match w.shielded_default_address().await { + Some(w) => match w.shielded_default_address(account).await { Some(bytes) => Outcome::Bound(bytes), None => Outcome::Unbound, }, diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 40af538a08f..c78e0a59e3d 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -891,6 +891,12 @@ pub struct PlatformWalletChangeSet { /// gap-limit population) and on any pool extension / "used" flip. /// See [`AccountAddressPoolEntry`] for the merge policy. pub account_address_pools: Vec, + /// Shielded sub-wallet deltas: per-subwallet decrypted notes, + /// spent marks, sync watermarks, nullifier checkpoints. The + /// commitment tree itself is **not** in here — it lives on + /// disk in `ClientPersistentCommitmentTree`'s SQLite file. + #[cfg(feature = "shielded")] + pub shielded: Option, } impl From for PlatformWalletChangeSet { @@ -987,10 +993,14 @@ impl Merge for PlatformWalletChangeSet { .extend(other.account_registrations); self.account_address_pools .extend(other.account_address_pools); + #[cfg(feature = "shielded")] + { + self.shielded.merge(other.shielded); + } } fn is_empty(&self) -> bool { - self.core.is_empty() + let core_empty = self.core.is_empty() && self.identities.is_empty() && self.identity_keys.is_empty() && self.contacts.is_empty() @@ -1004,7 +1014,15 @@ impl Merge for PlatformWalletChangeSet { .is_none_or(|m| m.is_empty()) && self.wallet_metadata.is_none() && self.account_registrations.is_empty() - && self.account_address_pools.is_empty() + && self.account_address_pools.is_empty(); + #[cfg(feature = "shielded")] + { + core_empty && self.shielded.as_ref().is_none_or(|s| s.is_empty()) + } + #[cfg(not(feature = "shielded"))] + { + core_empty + } } } diff --git a/packages/rs-platform-wallet/src/changeset/client_start_state.rs b/packages/rs-platform-wallet/src/changeset/client_start_state.rs index 3a85d0c9915..c63e5a262be 100644 --- a/packages/rs-platform-wallet/src/changeset/client_start_state.rs +++ b/packages/rs-platform-wallet/src/changeset/client_start_state.rs @@ -11,6 +11,8 @@ use std::collections::BTreeMap; use crate::changeset::client_wallet_start_state::ClientWalletStartState; use crate::changeset::platform_address_sync_start_state::PlatformAddressSyncStartState; +#[cfg(feature = "shielded")] +use crate::changeset::shielded_sync_start_state::ShieldedSyncStartState; use crate::wallet::platform_wallet::WalletId; /// Snapshot of everything a persister hands back on @@ -30,10 +32,24 @@ pub struct ClientStartState { /// Per-wallet startup slices (UTXOs and unused asset locks, each /// bucketed by account index). pub wallets: BTreeMap, + /// Restored shielded sub-wallet state — per-`SubwalletId` + /// notes + sync watermarks. Consumed at `bind_shielded` time + /// to rehydrate the in-memory `SubwalletState` so spending / + /// balance reads work without re-decrypting the chain. + #[cfg(feature = "shielded")] + pub shielded: ShieldedSyncStartState, } impl ClientStartState { pub fn is_empty(&self) -> bool { - self.platform_addresses.is_empty() && self.wallets.is_empty() + let core_empty = self.platform_addresses.is_empty() && self.wallets.is_empty(); + #[cfg(feature = "shielded")] + { + core_empty && self.shielded.is_empty() + } + #[cfg(not(feature = "shielded"))] + { + core_empty + } } } diff --git a/packages/rs-platform-wallet/src/changeset/mod.rs b/packages/rs-platform-wallet/src/changeset/mod.rs index bd6650431fe..1f669091c58 100644 --- a/packages/rs-platform-wallet/src/changeset/mod.rs +++ b/packages/rs-platform-wallet/src/changeset/mod.rs @@ -16,6 +16,10 @@ pub mod core_bridge; pub mod identity_manager_start_state; pub mod merge; pub mod platform_address_sync_start_state; +#[cfg(feature = "shielded")] +pub mod shielded_changeset; +#[cfg(feature = "shielded")] +pub mod shielded_sync_start_state; pub mod traits; pub use changeset::{ @@ -31,4 +35,8 @@ pub use core_bridge::spawn_wallet_event_adapter; pub use identity_manager_start_state::IdentityManagerStartState; pub use merge::Merge; pub use platform_address_sync_start_state::PlatformAddressSyncStartState; +#[cfg(feature = "shielded")] +pub use shielded_changeset::ShieldedChangeSet; +#[cfg(feature = "shielded")] +pub use shielded_sync_start_state::{ShieldedSubwalletStartState, ShieldedSyncStartState}; pub use traits::{PersistenceError, PlatformWalletPersistence}; diff --git a/packages/rs-platform-wallet/src/changeset/shielded_changeset.rs b/packages/rs-platform-wallet/src/changeset/shielded_changeset.rs new file mode 100644 index 00000000000..dc90afd5176 --- /dev/null +++ b/packages/rs-platform-wallet/src/changeset/shielded_changeset.rs @@ -0,0 +1,98 @@ +//! Delta of shielded-wallet state for the persister callback. +//! +//! Buffered into [`PlatformWalletChangeSet::shielded`] from the +//! `FileBackedShieldedStore` whenever a sync pass discovers a new +//! note, marks one spent, advances a per-subwallet sync watermark, +//! or records a nullifier-sync checkpoint. The host persister +//! flushes these to its durable store (SwiftData on iOS) so cold +//! starts can rehydrate the in-memory `SubwalletState` without +//! re-decrypting the chain from genesis. +//! +//! Scope: +//! - **In** this changeset: per-subwallet decrypted notes, spent +//! marks, sync watermarks, nullifier checkpoints. +//! - **Out** of this changeset: the commitment tree itself +//! (already persisted in `ClientPersistentCommitmentTree`'s +//! SQLite file at the host-supplied `db_path`). + +use std::collections::BTreeMap; + +use crate::changeset::merge::Merge; +use crate::wallet::shielded::{ShieldedNote, SubwalletId}; + +/// Aggregated delta of shielded state for one persister flush. +#[derive(Debug, Clone, Default)] +pub struct ShieldedChangeSet { + /// Notes discovered (or re-saved with updated state) per + /// subwallet. Keyed by `(wallet_id, account_index)`. Order + /// inside the `Vec` is insertion order — the persister can + /// upsert by `(SubwalletId, position)`. + pub notes_saved: BTreeMap>, + /// Nullifiers freshly observed as spent on-chain, keyed by + /// the subwallet that owns the corresponding note. The + /// persister flips that note's `is_spent` row to true. + pub nullifiers_spent: BTreeMap>, + /// Latest per-subwallet `last_synced_note_index`. Last write + /// wins on merge (sync only ever advances this monotonically). + pub synced_indices: BTreeMap, + /// Latest per-subwallet `(height, timestamp)` nullifier sync + /// checkpoint. Last write wins on merge. + pub nullifier_checkpoints: BTreeMap, +} + +impl ShieldedChangeSet { + /// `true` iff this changeset carries no shielded deltas. + pub fn is_empty(&self) -> bool { + self.notes_saved.is_empty() + && self.nullifiers_spent.is_empty() + && self.synced_indices.is_empty() + && self.nullifier_checkpoints.is_empty() + } + + /// Accumulator helper: record a saved note for `id`. + pub fn record_note(&mut self, id: SubwalletId, note: ShieldedNote) { + self.notes_saved.entry(id).or_default().push(note); + } + + /// Accumulator helper: record a nullifier seen as spent on `id`. + pub fn record_nullifier_spent(&mut self, id: SubwalletId, nullifier: [u8; 32]) { + self.nullifiers_spent.entry(id).or_default().push(nullifier); + } + + /// Accumulator helper: advance the per-subwallet sync watermark. + pub fn record_synced_index(&mut self, id: SubwalletId, index: u64) { + let entry = self.synced_indices.entry(id).or_insert(index); + if *entry < index { + *entry = index; + } + } + + /// Accumulator helper: record the latest nullifier sync checkpoint. + pub fn record_nullifier_checkpoint(&mut self, id: SubwalletId, height: u64, timestamp: u64) { + self.nullifier_checkpoints.insert(id, (height, timestamp)); + } +} + +impl Merge for ShieldedChangeSet { + fn merge(&mut self, other: Self) { + for (id, notes) in other.notes_saved { + self.notes_saved.entry(id).or_default().extend(notes); + } + for (id, nfs) in other.nullifiers_spent { + self.nullifiers_spent.entry(id).or_default().extend(nfs); + } + for (id, idx) in other.synced_indices { + let entry = self.synced_indices.entry(id).or_insert(idx); + if *entry < idx { + *entry = idx; + } + } + // Last write wins for nullifier checkpoints. + self.nullifier_checkpoints + .extend(other.nullifier_checkpoints); + } + + fn is_empty(&self) -> bool { + ShieldedChangeSet::is_empty(self) + } +} diff --git a/packages/rs-platform-wallet/src/changeset/shielded_sync_start_state.rs b/packages/rs-platform-wallet/src/changeset/shielded_sync_start_state.rs new file mode 100644 index 00000000000..6f55af36480 --- /dev/null +++ b/packages/rs-platform-wallet/src/changeset/shielded_sync_start_state.rs @@ -0,0 +1,51 @@ +//! Shielded sub-wallet state restored from storage. +//! +//! Returned as part of [`ClientStartState`] by +//! [`PlatformWalletPersistence::load`] so a freshly-bound +//! [`ShieldedWallet`] can rehydrate per-subwallet decrypted notes +//! and sync watermarks without re-decrypting the chain. +//! +//! Keyed by [`SubwalletId`] so a single `BTreeMap` covers every +//! `(wallet_id, account_index)` combination on the network. +//! +//! [`ClientStartState`]: crate::changeset::ClientStartState +//! [`PlatformWalletPersistence::load`]: crate::changeset::PlatformWalletPersistence::load +//! [`ShieldedWallet`]: crate::wallet::shielded::ShieldedWallet +//! [`SubwalletId`]: crate::wallet::shielded::SubwalletId + +use crate::wallet::shielded::{ShieldedNote, SubwalletId}; +use std::collections::BTreeMap; + +/// Per-subwallet snapshot — every note (spent + unspent) the +/// persister has on file plus the sync watermarks. +#[derive(Debug, Default, Clone)] +pub struct ShieldedSubwalletStartState { + /// All known notes for this subwallet, including spent ones. + /// `is_spent` is preserved from the persisted row so the + /// in-memory store reflects what nullifier sync has already + /// established. + pub notes: Vec, + /// Highest global note index that the subwallet has scanned. + pub last_synced_index: u64, + /// Last `(height, timestamp)` nullifier sync checkpoint. + pub nullifier_checkpoint: Option<(u64, u64)>, +} + +/// Whole-client shielded restore state, keyed by `SubwalletId`. +/// +/// Lives on [`ClientStartState`] alongside platform-address state. +/// On wallet bind, `PlatformWallet::bind_shielded` consumes the +/// entries that match `(self.wallet_id, account)` for each +/// requested account and hands them back to the in-memory store +/// before kicking off the first sync pass. +#[derive(Debug, Default)] +pub struct ShieldedSyncStartState { + pub per_subwallet: BTreeMap, +} + +impl ShieldedSyncStartState { + /// `true` iff no subwallet snapshot is restored. + pub fn is_empty(&self) -> bool { + self.per_subwallet.is_empty() + } +} diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 006e9b01331..e5583899ac6 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -135,6 +135,25 @@ pub enum PlatformWalletError { #[error("Shielded key derivation failed: {0}")] ShieldedKeyDerivation(String), + + #[error("Shielded sub-wallet not bound: call bind_shielded first")] + ShieldedNotBound, + + /// The local commitment tree has no checkpoint whose root is + /// in Platform's `recorded_anchors`. Spend can't proceed — + /// our tree has diverged from Platform's (mid-block sync, + /// dropped notes, double-append, etc.) and a re-sync is + /// required. + #[error( + "Shielded tree diverged from Platform: no local checkpoint matches any of {tried} \ + recorded anchor(s) over {depths_walked} checkpoint depth(s); a re-sync is required" + )] + ShieldedTreeDiverged { + /// Number of Platform-side anchors we checked against. + tried: usize, + /// Number of local checkpoint depths we walked. + depths_walked: usize, + }, } /// Check whether an SDK error indicates that an InstantSend lock proof was diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 36ba66e89a8..8e7af9be1c7 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -33,6 +33,10 @@ impl PlatformWalletManager

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

{ let crate::changeset::ClientStartState { mut platform_addresses, wallets: _, + #[cfg(feature = "shielded")] + shielded: _, } = match platform_wallet.load_persisted() { Ok(state) => state, Err(e) => { diff --git a/packages/rs-platform-wallet/src/wallet/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs index 1c0ea40654b..3f6d75a61b3 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -108,6 +108,12 @@ impl PlatformWalletInfo { wallet_metadata: _, account_registrations: _, account_address_pools: _, + // Shielded deltas are owned by `ShieldedWallet` (which + // mutates its store directly during sync / spend); the + // canonical in-memory state lives there and the + // changeset is persistence-side only. Drop here. + #[cfg(feature = "shielded")] + shielded: _, } = cs; // 1. Core wallet state. In the new event-bus model, a diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs index d5836be9ff1..f9a612e4a52 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs @@ -115,6 +115,20 @@ impl PerAccountPlatformAddressState { self.addresses.insert(address_index, address); self.found.insert(address, funds); } + + /// Iterate the (address, funds) pairs currently held in `found` — + /// the persisted-or-synced address balance snapshot. Used by the + /// restore path on + /// [`PlatformAddressWallet::initialize_from_persisted`](crate::wallet::platform_addresses::PlatformAddressWallet::initialize_from_persisted) + /// to seed the in-memory `ManagedPlatformAccount.address_balances` + /// map at startup, so spend paths that enumerate funded + /// addresses don't read `0` while waiting for the first BLAST + /// sync to repopulate them. + pub fn persisted_balances( + &self, + ) -> impl Iterator { + self.found.iter() + } } /// Per-wallet account map — keys are DIP-17 account indexes (hardened diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs index 0c08fc8a425..63a0da338c5 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs @@ -91,6 +91,33 @@ impl PlatformAddressWallet { &self, persisted: crate::PlatformAddressSyncStartState, ) -> Result<(), PlatformWalletError> { + // Push the persisted address balances into the in-memory + // `ManagedPlatformAccount.address_balances` map so callers + // that read via `addresses_with_balances()` / + // `address_credit_balance()` see the same numbers the + // BLAST sync saved last session. Without this the + // in-memory map starts empty after a restart and stays + // that way until the first sync pass repopulates it — + // any spend that needs to enumerate funded addresses + // (e.g. `shielded_shield_from_account`) sees `available = + // 0` even though the wallet detail screen reports a real + // balance from SwiftData. + { + let mut wm = self.wallet_manager.write().await; + if let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) { + for (account_index, account_state) in &persisted.per_account { + if let Some(account) = info + .core_wallet + .platform_payment_managed_account_at_index_mut(*account_index) + { + for (p2pkh, funds) in account_state.persisted_balances() { + account.set_address_credit_balance(*p2pkh, funds.balance, None); + } + } + } + } + } + let mut per_wallet = std::collections::BTreeMap::new(); per_wallet.insert(self.wallet_id, persisted.per_account); let provider = PlatformPaymentAddressProvider::from_persisted( diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index dcd9486798e..a1818c228d6 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -293,24 +293,29 @@ impl PlatformWallet { /// Bind a shielded (Orchard) sub-wallet to this `PlatformWallet`. /// - /// Derives ZIP-32 Orchard keys from `seed` (a 32-252 byte BIP-39 - /// seed; see [`SpendingKey::from_zip32_seed`]), opens or creates - /// the per-network commitment tree at `db_path`, and stores the - /// resulting [`ShieldedWallet`] on this handle. The caller is - /// responsible for sourcing the seed (e.g. via the host - /// `MnemonicResolverHandle`) and for zeroizing it once this call - /// returns. The seed is not retained — only the FVK / IVK / OVK - /// / default address derived from it survive on the wallet. + /// Derives ZIP-32 Orchard keys for every entry of `accounts` + /// from `seed` (a 32-252 byte BIP-39 seed; see + /// [`SpendingKey::from_zip32_seed`]), opens or creates the + /// per-network commitment tree at `db_path`, and stores the + /// resulting multi-account [`ShieldedWallet`] on this handle. + /// The caller is responsible for sourcing the seed (e.g. via + /// the host `MnemonicResolverHandle`) and for zeroizing it + /// once this call returns. The seed is not retained — only + /// the per-account FVK / IVK / OVK / default address derived + /// from it survive on the wallet. /// /// Idempotent: a second call replaces the previously-bound /// shielded wallet (e.g. after a network switch). /// + /// `accounts` must be non-empty; pass `&[0]` for the + /// single-account default. + /// /// [`SpendingKey::from_zip32_seed`]: grovedb_commitment_tree::SpendingKey::from_zip32_seed #[cfg(feature = "shielded")] pub async fn bind_shielded( &self, seed: &[u8], - account: u32, + accounts: &[u32], db_path: impl AsRef, ) -> Result<(), PlatformWalletError> { // Open / create the SQLite-backed commitment tree first so @@ -319,14 +324,71 @@ impl PlatformWallet { let store = FileBackedShieldedStore::open_path(db_path, 100) .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; let network = self.sdk.network; - let wallet = - ShieldedWallet::from_seed(Arc::clone(&self.sdk), seed, network, account, store)?; + let mut wallet = ShieldedWallet::from_seed_accounts( + Arc::clone(&self.sdk), + self.wallet_id, + seed, + network, + accounts, + store, + )?; + + // Attach the persister so future sync passes emit + // shielded changesets the host can mirror (SwiftData + // on iOS). + wallet.set_persister(self.persister.clone()); + + // Rehydrate per-subwallet notes / sync watermarks from + // the persister's start state if any are present for + // this wallet. The lookup is cheap: load() is the + // boot-time snapshot, indexed by SubwalletId. Errors are + // logged but not fatal — first-launch wallets simply + // see no persisted state. + match self.persister.load() { + Ok(start) => { + if let Err(e) = wallet.restore_from_snapshot(&start.shielded).await { + tracing::warn!( + wallet_id = %hex::encode(self.wallet_id), + error = %e, + "Failed to restore shielded snapshot at bind time" + ); + } + } + Err(e) => { + tracing::warn!( + wallet_id = %hex::encode(self.wallet_id), + error = %e, + "persister.load() failed at shielded bind time" + ); + } + } let mut slot = self.shielded.write().await; *slot = Some(wallet); Ok(()) } + /// Add another ZIP-32 account to the already-bound shielded + /// sub-wallet. Returns `ShieldedNotBound` if `bind_shielded` + /// hasn't run yet. + /// + /// **Caveat**: notes belonging to `account` that already + /// landed on-chain before the bind call only become spendable + /// after a tree wipe + re-sync. Hosts that need to discover + /// historical funds for a freshly-added account should drop + /// the commitment-tree DB and call [`bind_shielded`] again + /// with the full account list. + #[cfg(feature = "shielded")] + pub async fn shielded_add_account( + &self, + seed: &[u8], + account: u32, + ) -> Result<(), PlatformWalletError> { + let mut slot = self.shielded.write().await; + let wallet = slot.as_mut().ok_or(PlatformWalletError::ShieldedNotBound)?; + wallet.add_account_from_seed(seed, self.sdk.network, account) + } + /// Whether the shielded sub-wallet has been bound via /// [`bind_shielded`](Self::bind_shielded). #[cfg(feature = "shielded")] @@ -334,7 +396,20 @@ impl PlatformWallet { self.shielded.read().await.is_some() } - /// Run one shielded sync pass on this wallet. + /// Bound ZIP-32 account indices on the shielded sub-wallet, + /// in ascending order. Empty if not bound. + #[cfg(feature = "shielded")] + pub async fn shielded_account_indices(&self) -> Vec { + self.shielded + .read() + .await + .as_ref() + .map(|w| w.account_indices()) + .unwrap_or_default() + } + + /// Run one shielded sync pass on this wallet (covers every + /// bound account in a single chain walk). /// /// Returns `Ok(None)` if the shielded sub-wallet hasn't been /// bound (the sync coordinator skips unbound wallets without @@ -349,16 +424,324 @@ impl PlatformWallet { } } - /// The default Orchard payment address for this wallet, as the - /// raw 43-byte representation. Returns `None` if the shielded - /// sub-wallet hasn't been bound. Hosts apply their own bech32m - /// encoding (HRP + 0x10 type byte) on top. + /// The default Orchard payment address for `account` on this + /// wallet, as the raw 43-byte representation. Returns `None` + /// if the shielded sub-wallet hasn't been bound or `account` + /// isn't bound on it. Hosts apply their own bech32m encoding + /// (HRP + 0x10 type byte) on top. #[cfg(feature = "shielded")] - pub async fn shielded_default_address(&self) -> Option<[u8; 43]> { + pub async fn shielded_default_address(&self, account: u32) -> Option<[u8; 43]> { let guard = self.shielded.read().await; guard .as_ref() - .map(|w| w.default_address().to_raw_address_bytes()) + .and_then(|w| w.default_address(account).ok()) + .map(|addr| addr.to_raw_address_bytes()) + } + + /// Per-account default Orchard payment addresses (raw 43 bytes). + #[cfg(feature = "shielded")] + pub async fn shielded_default_addresses(&self) -> std::collections::BTreeMap { + let guard = self.shielded.read().await; + let Some(wallet) = guard.as_ref() else { + return std::collections::BTreeMap::new(); + }; + wallet + .account_indices() + .into_iter() + .filter_map(|account| { + wallet + .default_address(account) + .ok() + .map(|addr| (account, addr.to_raw_address_bytes())) + }) + .collect() + } + + /// Per-account unspent shielded balance. + #[cfg(feature = "shielded")] + pub async fn shielded_balances( + &self, + ) -> Result, PlatformWalletError> { + let guard = self.shielded.read().await; + match guard.as_ref() { + Some(wallet) => wallet.balances().await, + None => Ok(std::collections::BTreeMap::new()), + } + } + + /// Send a private shielded → shielded transfer from `account`'s + /// notes to `recipient_raw_43` (the recipient's Orchard payment + /// address as the 43 raw bytes). + /// + /// The prover is consumed by value rather than borrowed because + /// `OrchardProver` is impl'd on `&CachedOrchardProver` (the + /// reference type), not on the bare struct. Callers pass + /// `&CachedOrchardProver::new()` and we forward it down to the + /// underlying `ShieldedWallet::transfer`'s `&P` parameter. + #[cfg(feature = "shielded")] + pub async fn shielded_transfer_to( + &self, + account: u32, + recipient_raw_43: &[u8; 43], + amount: u64, + prover: P, + ) -> Result<(), PlatformWalletError> { + let guard = self.shielded.read().await; + let shielded = guard + .as_ref() + .ok_or(PlatformWalletError::ShieldedNotBound)?; + let recipient = Option::::from( + grovedb_commitment_tree::PaymentAddress::from_raw_address_bytes(recipient_raw_43), + ) + .ok_or_else(|| { + PlatformWalletError::ShieldedBuildError( + "invalid Orchard payment address bytes".to_string(), + ) + })?; + shielded + .transfer(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, + account: u32, + to_platform_addr_bech32m: &str, + amount: u64, + prover: P, + ) -> Result<(), PlatformWalletError> { + let guard = self.shielded.read().await; + let shielded = guard + .as_ref() + .ok_or(PlatformWalletError::ShieldedNotBound)?; + let (to, addr_network) = + dpp::address_funds::PlatformAddress::from_bech32m_string(to_platform_addr_bech32m) + .map_err(|e| { + PlatformWalletError::ShieldedBuildError(format!( + "invalid platform address: {e}" + )) + })?; + if addr_network != self.sdk.network { + return Err(PlatformWalletError::ShieldedBuildError(format!( + "platform address network mismatch: address {addr_network:?}, wallet {:?}", + self.sdk.network + ))); + } + shielded.unshield(account, &to, amount, &prover).await + } + + /// Withdraw from `account`'s notes to a Core L1 address + /// (Base58Check string). `core_fee_per_byte` is the L1 fee + /// rate (duffs/byte). + #[cfg(feature = "shielded")] + pub async fn shielded_withdraw_to( + &self, + account: u32, + to_core_address: &str, + amount: u64, + core_fee_per_byte: u32, + prover: P, + ) -> Result<(), PlatformWalletError> { + let guard = self.shielded.read().await; + let shielded = guard + .as_ref() + .ok_or(PlatformWalletError::ShieldedNotBound)?; + let network = self.sdk.network; + let parsed = to_core_address + .parse::>() + .map_err(|e| { + PlatformWalletError::ShieldedBuildError(format!("invalid core address: {e}")) + })? + .require_network(network) + .map_err(|e| { + PlatformWalletError::ShieldedBuildError(format!( + "core address network mismatch: {e}" + )) + })?; + shielded + .withdraw(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, + { + // 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 mut candidates: Vec<(dpp::address_funds::PlatformAddress, u64)> = account + .addresses + .addresses + .values() + .filter_map(|addr_info| { + let p2pkh = + key_wallet::PlatformP2PKHAddress::from_address(&addr_info.address).ok()?; + let balance = account.address_credit_balance(&p2pkh); + if balance == 0 { + None + } else { + Some(( + dpp::address_funds::PlatformAddress::P2pkh(p2pkh.to_bytes()), + balance, + )) + } + }) + .collect(); + candidates.sort_by_key(|(addr, _)| *addr); + + // The address that will be the bundle's `input_0` must + // have balance > FEE_RESERVE so we can claim at least 1 + // credit while leaving the reserve untouched. Skip any + // leading dust address that can't satisfy that — the + // next address up will become input 0 instead. If + // every funded address is below the reserve, fail fast: + // the network would reject the broadcast on the + // boundary anyway, only after we've spent ~30 s + // building the Halo 2 proof. + let Some(viable_input_0) = candidates + .iter() + .position(|(_, balance)| *balance > FEE_RESERVE_CREDITS) + else { + let total: u64 = candidates.iter().map(|(_, b)| b).sum(); + return Err(PlatformWalletError::ShieldedInsufficientBalance { + available: total, + required: amount.saturating_add(FEE_RESERVE_CREDITS), + }); + }; + let usable: &[(dpp::address_funds::PlatformAddress, u64)] = + &candidates[viable_input_0..]; + + let total_usable: u64 = usable.iter().map(|(_, b)| b).sum(); + let needed = amount.saturating_add(FEE_RESERVE_CREDITS); + if total_usable < needed { + return Err(PlatformWalletError::ShieldedInsufficientBalance { + available: total_usable, + required: needed, + }); + } + + // Walk usable inputs in BTreeMap order, claiming only + // what's needed to cover `amount`. The fee reserve is + // taken off input 0's max claim so its post-claim + // remaining stays ≥ FEE_RESERVE_CREDITS for the + // network's `DeductFromInput(0)` step. + let mut chosen: std::collections::BTreeMap< + dpp::address_funds::PlatformAddress, + dpp::fee::Credits, + > = std::collections::BTreeMap::new(); + let mut accumulated_claim: u64 = 0; + for (i, (addr, balance)) in usable.iter().enumerate() { + if accumulated_claim >= amount { + break; + } + let max_claim = if i == 0 { + balance.saturating_sub(FEE_RESERVE_CREDITS) + } else { + *balance + }; + let still_need = amount - accumulated_claim; + let claim = max_claim.min(still_need); + if claim > 0 { + chosen.insert(*addr, claim); + accumulated_claim = accumulated_claim.saturating_add(claim); + } + } + + if accumulated_claim < amount { + return Err(PlatformWalletError::ShieldedInsufficientBalance { + available: accumulated_claim, + required: amount, + }); + } + chosen + }; + + let guard = self.shielded.read().await; + let shielded = guard + .as_ref() + .ok_or(PlatformWalletError::ShieldedNotBound)?; + shielded + .shield(shielded_account, inputs, amount, signer, &prover) + .await } } @@ -456,6 +839,8 @@ impl PlatformWallet { let ClientStartState { mut platform_addresses, wallets: _, + #[cfg(feature = "shielded")] + shielded: _, } = self.load_persisted()?; if let Some(persisted) = platform_addresses.remove(&self.wallet_id) { diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index c217d0febe9..240e79077eb 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -1,18 +1,15 @@ //! File-backed `ShieldedStore` impl. //! -//! The Orchard commitment tree is shared across every wallet that -//! decrypts notes against the same network — there is one global -//! tree of commitments and each wallet keeps its own decrypted-note -//! subset. This store therefore persists the tree to a SQLite file -//! (via [`ClientPersistentCommitmentTree`]) while keeping the -//! per-wallet decrypted notes and nullifier bookkeeping in memory. -//! Notes are rediscovered on cold start by re-running -//! [`ShieldedWallet::sync_notes`](super::ShieldedWallet::sync_notes) -//! against the cached tree. -//! -//! Witness generation (needed for spends) is intentionally not -//! implemented yet — the spend signer pipeline that drives it lands -//! in a follow-up. +//! The Orchard commitment tree is shared across every subwallet +//! that decrypts notes against the same network — the on-chain +//! commitment stream is identical for every consumer. This store +//! therefore persists the tree to a SQLite file (via +//! [`ClientPersistentCommitmentTree`]) and keeps per-subwallet +//! decrypted notes / nullifier bookkeeping in memory, scoped by +//! [`SubwalletId`]. Notes are rediscovered on cold start by +//! re-running [`ShieldedWallet::sync_notes`] against the cached +//! tree (or, when the host persister is wired up, restored from +//! SwiftData before sync runs). use std::collections::BTreeMap; use std::error::Error as StdError; @@ -22,7 +19,7 @@ use std::sync::Mutex; use grovedb_commitment_tree::{ClientPersistentCommitmentTree, Position, Retention}; -use super::store::{ShieldedNote, ShieldedStore}; +use super::store::{ShieldedNote, ShieldedStore, SubwalletId, SubwalletState}; /// Error type for [`FileBackedShieldedStore`]. #[derive(Debug)] @@ -36,39 +33,24 @@ impl fmt::Display for FileShieldedStoreError { impl StdError for FileShieldedStoreError {} -/// File-backed shielded store: SQLite-persisted commitment tree plus -/// in-memory decrypted notes / nullifier bookkeeping. -/// -/// The commitment tree is keyed per-network at the call site (the -/// path is supplied by [`Self::open_path`]). Decrypted notes are -/// kept in memory and rediscovered via trial decryption on every -/// cold start — same shape the previous `ShieldedPoolClient` had, -/// suitable for the MVP shielded sync path. Persisting notes via -/// the host's data store is a follow-up. +/// File-backed shielded store: SQLite-persisted commitment tree +/// plus in-memory per-subwallet decrypted notes / nullifier +/// bookkeeping. pub struct FileBackedShieldedStore { - /// SQLite-backed commitment tree. Wrapped in a `Mutex` rather than - /// relying on `&mut self` because the underlying SQLite store is - /// not `Sync` on its own and the [`ShieldedStore`] trait requires - /// `Send + Sync`. Outer concurrency is still serialized through - /// `ShieldedWallet`'s `RwLock`; this inner mutex is just a - /// `Sync`-restoring shim and is uncontended in practice. + /// SQLite-backed commitment tree. Wrapped in a `Mutex` because + /// the underlying SQLite store is not `Sync`; the + /// [`ShieldedStore`] trait requires `Send + Sync`. Outer + /// concurrency is still serialized through `ShieldedWallet`'s + /// `RwLock`; this inner mutex is just a `Sync`-restoring + /// shim and is uncontended in practice. tree: Mutex, - notes: Vec, - /// Nullifier → index into `notes`, for `mark_spent` lookups. - nullifier_index: BTreeMap<[u8; 32], usize>, - /// Last global note index synced from Platform. - last_synced_index: u64, - /// `(height, timestamp)` from the most recent nullifier sync. - nullifier_checkpoint: Option<(u64, u64)>, + /// Per-subwallet notes + sync state, keyed by `(wallet_id, + /// account_index)`. Lazily populated on first use of an id. + subwallets: BTreeMap, } impl FileBackedShieldedStore { /// Open or create a shielded store at `path`. - /// - /// `max_checkpoints` controls how many tree checkpoints the - /// underlying [`ClientPersistentCommitmentTree`] retains for - /// witness generation. A value of `100` matches what the previous - /// SDK-side client used. pub fn open_path( path: impl AsRef, max_checkpoints: usize, @@ -77,10 +59,7 @@ impl FileBackedShieldedStore { .map_err(|e| FileShieldedStoreError(format!("open commitment tree: {e}")))?; Ok(Self { tree: Mutex::new(tree), - notes: Vec::new(), - nullifier_index: BTreeMap::new(), - last_synced_index: 0, - nullifier_checkpoint: None, + subwallets: BTreeMap::new(), }) } } @@ -88,42 +67,33 @@ impl FileBackedShieldedStore { impl ShieldedStore for FileBackedShieldedStore { type Error = FileShieldedStoreError; - fn save_note(&mut self, note: &ShieldedNote) -> Result<(), Self::Error> { - // Re-saving an already-known note (e.g. a re-scan after a - // cold start trial-decrypts the same chunk) used to append - // a duplicate `ShieldedNote` while overwriting the - // nullifier index. The result was a double-counted balance - // (`get_unspent_notes` returned both copies) and a stuck - // unspent flag (`mark_spent` only marked the second copy). - // Orchard nullifiers are globally unique, so an existing - // entry for the same nullifier means we already have this - // note — overwrite-in-place rather than append. - if let Some(&existing_idx) = self.nullifier_index.get(¬e.nullifier) { - self.notes[existing_idx] = note.clone(); - return Ok(()); - } - let idx = self.notes.len(); - self.nullifier_index.insert(note.nullifier, idx); - self.notes.push(note.clone()); + fn save_note(&mut self, id: SubwalletId, note: &ShieldedNote) -> Result<(), Self::Error> { + self.subwallets.entry(id).or_default().save_note(note); Ok(()) } - fn get_unspent_notes(&self) -> Result, Self::Error> { - Ok(self.notes.iter().filter(|n| !n.is_spent).cloned().collect()) + fn get_unspent_notes(&self, id: SubwalletId) -> Result, Self::Error> { + Ok(self + .subwallets + .get(&id) + .map(SubwalletState::unspent_notes) + .unwrap_or_default()) } - fn get_all_notes(&self) -> Result, Self::Error> { - Ok(self.notes.clone()) + fn get_all_notes(&self, id: SubwalletId) -> Result, Self::Error> { + Ok(self + .subwallets + .get(&id) + .map(SubwalletState::all_notes) + .unwrap_or_default()) } - fn mark_spent(&mut self, nullifier: &[u8; 32]) -> Result { - if let Some(&idx) = self.nullifier_index.get(nullifier) { - if !self.notes[idx].is_spent { - self.notes[idx].is_spent = true; - return Ok(true); - } - } - Ok(false) + fn mark_spent(&mut self, id: SubwalletId, nullifier: &[u8; 32]) -> Result { + Ok(self + .subwallets + .get_mut(&id) + .map(|sw| sw.mark_spent(nullifier)) + .unwrap_or(false)) } fn append_commitment(&mut self, cmx: &[u8; 32], marked: bool) -> Result<(), Self::Error> { @@ -160,31 +130,58 @@ 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, + checkpoint_depth: usize, + ) -> Result, Self::Error> { + let tree = self + .tree + .lock() + .map_err(|e| FileShieldedStoreError(format!("tree mutex poisoned: {e}")))?; + // `checkpoint_depth` indexes our local checkpoints (0 = + // most recent, 1 = one back, ...). The spend path walks + // depths to find one whose root matches a Platform-recorded + // anchor — see `ShieldedWallet::find_anchor_depth`. + tree.witness(Position::from(position), checkpoint_depth) + .map_err(|e| { + FileShieldedStoreError(format!( + "witness(position={position}, depth={checkpoint_depth}): {e}" + )) + }) } - fn last_synced_note_index(&self) -> Result { - Ok(self.last_synced_index) + fn last_synced_note_index(&self, id: SubwalletId) -> Result { + Ok(self + .subwallets + .get(&id) + .map(|sw| sw.last_synced_index) + .unwrap_or(0)) } - fn set_last_synced_note_index(&mut self, index: u64) -> Result<(), Self::Error> { - self.last_synced_index = index; + fn set_last_synced_note_index( + &mut self, + id: SubwalletId, + index: u64, + ) -> Result<(), Self::Error> { + self.subwallets.entry(id).or_default().last_synced_index = index; Ok(()) } - fn nullifier_checkpoint(&self) -> Result, Self::Error> { - Ok(self.nullifier_checkpoint) + fn nullifier_checkpoint(&self, id: SubwalletId) -> Result, Self::Error> { + Ok(self + .subwallets + .get(&id) + .and_then(|sw| sw.nullifier_checkpoint)) } - fn set_nullifier_checkpoint(&mut self, height: u64, timestamp: u64) -> Result<(), Self::Error> { - self.nullifier_checkpoint = Some((height, timestamp)); + fn set_nullifier_checkpoint( + &mut self, + id: SubwalletId, + height: u64, + timestamp: u64, + ) -> Result<(), Self::Error> { + self.subwallets.entry(id).or_default().nullifier_checkpoint = Some((height, timestamp)); Ok(()) } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs index c68e0eb7507..2b0bc239313 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs @@ -1,19 +1,20 @@ -//! Feature-gated shielded (Orchard/Halo2) wallet support. +//! Feature-gated shielded (Orchard / Halo 2) wallet support. //! -//! This module provides ZK-private transactions on Dash Platform using the -//! Orchard circuit (Halo 2 proving system). It is gated behind the `shielded` -//! Cargo feature because it pulls in heavy cryptographic dependencies. +//! This module provides ZK-private transactions on Dash Platform +//! using the Orchard circuit (Halo 2 proving system). It is +//! gated behind the `shielded` Cargo feature because it pulls in +//! heavy cryptographic dependencies. //! //! # Architecture //! -//! - [`OrchardKeySet`] — ZIP-32 key derivation from wallet seed -//! - [`ShieldedStore`] / [`InMemoryShieldedStore`] — storage abstraction -//! - [`CachedOrchardProver`] — lazy-init proving key cache -//! - [`ShieldedWallet`] — top-level coordinator tying keys, store, and SDK together -//! -//! The `ShieldedWallet` is generic over `S: ShieldedStore` so consumers can -//! plug in their own persistence (SQLite, RocksDB, etc.) while tests use the -//! in-memory implementation. +//! - [`OrchardKeySet`] — ZIP-32 key derivation from a wallet seed. +//! - [`ShieldedStore`] / [`InMemoryShieldedStore`] — storage abstraction. +//! The shared commitment tree lives here too; per-subwallet +//! notes are scoped by [`SubwalletId`] inside the store. +//! - [`CachedOrchardProver`] — lazy-init proving key cache. +//! - [`ShieldedWallet`] — multi-account coordinator tying the +//! wallet's Orchard accounts (`BTreeMap`), +//! the shared store, and the SDK together. pub mod file_store; pub mod keys; @@ -26,87 +27,296 @@ pub mod sync; pub use file_store::{FileBackedShieldedStore, FileShieldedStoreError}; pub use keys::OrchardKeySet; pub use prover::CachedOrchardProver; -pub use store::{InMemoryShieldedStore, ShieldedNote, ShieldedStore}; +pub use store::{InMemoryShieldedStore, ShieldedNote, ShieldedStore, SubwalletId}; pub use sync::{ShieldedSyncSummary, SyncNotesResult}; +use std::collections::BTreeMap; use std::sync::Arc; use tokio::sync::RwLock; +use crate::changeset::ShieldedChangeSet; +use crate::changeset::{PlatformWalletChangeSet, ShieldedSyncStartState}; use crate::error::PlatformWalletError; +use crate::wallet::persister::WalletPersister; +use crate::wallet::platform_wallet::WalletId; -/// Feature-gated shielded wallet. -/// -/// Coordinates Orchard key material, a pluggable storage backend, and the -/// Dash SDK for note sync, nullifier checks, and shielded state transitions. +/// Per-account state held inside a [`ShieldedWallet`]. /// -/// Generic over `S: ShieldedStore` — consumers provide their persistence -/// layer. For tests, use [`InMemoryShieldedStore`]. -/// -/// # Thread safety +/// Crate-private — callers go through `ShieldedWallet`'s +/// per-account helpers (`default_address(account)`, +/// `balance(account)`, etc.). Held by value (not behind a lock) +/// because the parent wallet's `RwLock` already serializes +/// access, and key material is read-only after derivation. +pub(super) struct AccountState { + pub(super) keys: OrchardKeySet, +} + +/// Feature-gated multi-account shielded wallet. /// -/// The store is wrapped in `Arc>` so the wallet can be shared -/// across async tasks. Read operations (balance, address queries) take a -/// read lock; mutating operations (sync, spend) take a write lock. +/// One [`ShieldedWallet`] lives inside one [`PlatformWallet`] and +/// holds every Orchard account that wallet has bound. Operations +/// take `account: u32` and route to the right keyset internally. +/// The shared `store: Arc>` is keyed per-account via +/// [`SubwalletId`] so multiple accounts on the same wallet (and +/// multiple wallets on the same network) cohabit the same store +/// without cross-talk. pub struct ShieldedWallet { /// Dash Platform SDK handle for network operations. - sdk: Arc, - /// ZIP-32 derived Orchard keys. - keys: OrchardKeySet, - /// Pluggable storage backend behind a shared async lock. - store: Arc>, + pub(super) sdk: Arc, + /// 32-byte wallet identifier — used to construct + /// [`SubwalletId`] for every store call. + pub(super) wallet_id: WalletId, + /// Bound Orchard accounts, keyed by ZIP-32 account index. + pub(super) accounts: BTreeMap, + /// Pluggable storage backend behind a shared async lock. The + /// commitment tree inside is global per network; notes are + /// scoped per-subwallet by the store's `SubwalletId` keying. + pub(super) store: Arc>, + /// Optional persister handle. When set, every state-changing + /// sync / spend pass emits a [`PlatformWalletChangeSet`] with + /// a populated `shielded` field so the host (typically + /// SwiftData on iOS) can mirror per-subwallet notes / sync + /// watermarks. `None` means in-memory only — useful for + /// tests and short-lived wallets. + pub(super) persister: Option, } impl ShieldedWallet { - /// Create a shielded wallet from pre-derived keys and a store. - pub fn new(sdk: Arc, keys: OrchardKeySet, store: S) -> Self { - Self { + /// Construct a [`ShieldedWallet`] from pre-derived keysets. + /// + /// `accounts` maps ZIP-32 account index → [`OrchardKeySet`]. + /// At least one account must be supplied. + pub fn from_keysets( + sdk: Arc, + wallet_id: WalletId, + accounts: BTreeMap, + store: S, + ) -> Result { + if accounts.is_empty() { + return Err(PlatformWalletError::ShieldedKeyDerivation( + "shielded wallet requires at least one account".to_string(), + )); + } + let accounts = accounts + .into_iter() + .map(|(idx, keys)| (idx, AccountState { keys })) + .collect(); + Ok(Self { sdk, - keys, + wallet_id, + accounts, store: Arc::new(RwLock::new(store)), + persister: None, + }) + } + + /// Attach a [`WalletPersister`] so future sync / spend passes + /// emit shielded changesets to the host. + pub fn set_persister(&mut self, persister: WalletPersister) { + self.persister = Some(persister); + } + + /// Queue a shielded changeset on the persister if one is + /// attached. No-op otherwise. + pub(super) fn queue_shielded_changeset(&self, cs: ShieldedChangeSet) { + if cs.is_empty() { + return; + } + let Some(persister) = &self.persister else { + return; + }; + let full = PlatformWalletChangeSet { + shielded: Some(cs), + ..Default::default() + }; + if let Err(e) = persister.store(full) { + tracing::warn!( + wallet_id = %hex::encode(self.wallet_id), + error = %e, + "Failed to queue shielded changeset" + ); } } - /// Derive Orchard keys 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 + /// Rehydrate per-subwallet state from a persisted snapshot. + /// Should be called after `from_seed_accounts(...)` and before + /// the first sync pass so the in-memory store matches what + /// the host already has on disk. + pub async fn restore_from_snapshot( + &self, + snapshot: &ShieldedSyncStartState, + ) -> Result<(), PlatformWalletError> { + if snapshot.is_empty() { + return Ok(()); + } + let mut store = self.store.write().await; + for (id, sub) in &snapshot.per_subwallet { + // Only restore subwallets that belong to this wallet. + if id.wallet_id != self.wallet_id { + continue; + } + // Skip accounts that aren't bound on this wallet — + // they'd accumulate state we can never spend. + if !self.accounts.contains_key(&id.account_index) { + continue; + } + for note in &sub.notes { + store + .save_note(*id, note) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + if note.is_spent { + store + .mark_spent(*id, ¬e.nullifier) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + } + } + store + .set_last_synced_note_index(*id, sub.last_synced_index) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + if let Some((h, t)) = sub.nullifier_checkpoint { + store + .set_nullifier_checkpoint(*id, h, t) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + } + } + Ok(()) + } + + /// Derive Orchard keys for every listed `account` from a + /// wallet seed and return a [`ShieldedWallet`]. /// - /// Returns an error if key derivation fails (invalid seed or account index). - pub fn from_seed( + /// `seed` is the BIP-39 seed bytes (32–252 bytes; typically + /// 64). `network` selects the ZIP-32 coin type. Each entry of + /// `accounts` becomes a separate ZIP-32 account + /// (`m / 32' / coin_type' / account'`); duplicates are + /// silently deduplicated. + pub fn from_seed_accounts( sdk: Arc, + wallet_id: WalletId, seed: &[u8], network: dashcore::Network, - account: u32, + accounts: &[u32], store: S, ) -> Result { - let keys = OrchardKeySet::from_seed(seed, network, account)?; - Ok(Self::new(sdk, keys, store)) + if accounts.is_empty() { + return Err(PlatformWalletError::ShieldedKeyDerivation( + "shielded wallet requires at least one account".to_string(), + )); + } + let mut keysets: BTreeMap = BTreeMap::new(); + for &account in accounts { + let keys = OrchardKeySet::from_seed(seed, network, account)?; + keysets.insert(account, keys); + } + Self::from_keysets(sdk, wallet_id, keysets, store) } - /// Total unspent shielded balance in credits. + /// Add another ZIP-32 account to this wallet by re-deriving + /// from the seed. No-op if `account` is already bound. /// + /// **Caveat**: the commitment tree only retains + /// authentication paths for positions `Retention::Marked` at + /// append time. Notes that reached the tree before this + /// account existed were marked `Ephemeral` and can never + /// produce witnesses for it without a tree wipe + full + /// re-sync. New accounts therefore only see notes from + /// future syncs. The host should drop the tree DB and + /// re-sync from genesis when the user adds an account they + /// expect to discover historical funds for. + pub fn add_account_from_seed( + &mut self, + seed: &[u8], + network: dashcore::Network, + account: u32, + ) -> Result<(), PlatformWalletError> { + if self.accounts.contains_key(&account) { + return Ok(()); + } + let keys = OrchardKeySet::from_seed(seed, network, account)?; + self.accounts.insert(account, AccountState { keys }); + Ok(()) + } + + /// All bound ZIP-32 account indices, in ascending order. + pub fn account_indices(&self) -> Vec { + self.accounts.keys().copied().collect() + } + + /// `true` iff `account` is bound on this wallet. + pub fn has_account(&self, account: u32) -> bool { + self.accounts.contains_key(&account) + } + + /// Borrow the keyset for `account`. + pub(super) fn keys_for(&self, account: u32) -> Result<&OrchardKeySet, PlatformWalletError> { + self.accounts.get(&account).map(|s| &s.keys).ok_or_else(|| { + PlatformWalletError::ShieldedKeyDerivation(format!( + "shielded account {account} not bound" + )) + }) + } + + /// Construct the [`SubwalletId`] for `account` on this wallet. + pub(super) fn subwallet_id(&self, account: u32) -> SubwalletId { + SubwalletId::new(self.wallet_id, account) + } + + /// Total unspent shielded balance for `account` in credits. /// Reads from the store — does not trigger a sync. - pub async fn balance(&self) -> Result { + pub async fn balance(&self, account: u32) -> Result { + self.keys_for(account)?; // existence check + let id = self.subwallet_id(account); let store = self.store.read().await; let notes = store - .get_unspent_notes() + .get_unspent_notes(id) .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; Ok(notes.iter().map(|n| n.value).sum()) } - /// The default payment address (diversifier index 0) for receiving - /// shielded funds. - pub fn default_address(&self) -> &grovedb_commitment_tree::PaymentAddress { - &self.keys.default_address + /// Sum of unspent shielded balance across every bound account. + pub async fn balance_total(&self) -> Result { + let store = self.store.read().await; + let mut total: u64 = 0; + for account in self.accounts.keys() { + let id = SubwalletId::new(self.wallet_id, *account); + let notes = store + .get_unspent_notes(id) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + total = total.saturating_add(notes.iter().map(|n| n.value).sum::()); + } + Ok(total) + } + + /// Per-account unspent shielded balance, in ascending account order. + pub async fn balances(&self) -> Result, PlatformWalletError> { + let store = self.store.read().await; + let mut out: BTreeMap = BTreeMap::new(); + for account in self.accounts.keys() { + let id = SubwalletId::new(self.wallet_id, *account); + let notes = store + .get_unspent_notes(id) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + out.insert(*account, notes.iter().map(|n| n.value).sum()); + } + Ok(out) + } + + /// The default payment address (diversifier index 0) for + /// `account`. Returns an error if `account` isn't bound. + pub fn default_address( + &self, + account: u32, + ) -> Result<&grovedb_commitment_tree::PaymentAddress, PlatformWalletError> { + self.keys_for(account).map(|k| &k.default_address) } - /// Derive a payment address at the given diversifier index. - pub fn address_at(&self, index: u32) -> grovedb_commitment_tree::PaymentAddress { - self.keys.address_at(index) + /// Derive a payment address at `index` under `account`. + pub fn address_at( + &self, + account: u32, + index: u32, + ) -> Result { + Ok(self.keys_for(account)?.address_at(index)) } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index fb6d6ea41da..0bdf0abfdc5 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -1,27 +1,19 @@ -//! Shielded transaction operations (5 transition types). +//! Shielded transaction operations (5 transition types), multi-account. //! -//! Each operation follows a common pattern: -//! 1. Select spendable notes (if spending from the shielded pool) -//! 2. Get Merkle witnesses from the commitment tree -//! 3. Build Orchard bundle via DPP builder functions -//! 4. Broadcast the resulting state transition via SDK -//! 5. Mark spent notes (if any) in the store +//! Each operation now takes `account: u32` and routes through the +//! corresponding `OrchardKeySet` / `SubwalletId`. Spends never +//! cross account boundaries — note selection reads only the +//! given account's unspent notes. //! //! The five transition types are: -//! - **Shield** (Type 15): transparent platform addresses -> shielded pool -//! - **ShieldFromAssetLock** (Type 18): Core L1 asset lock -> shielded pool -//! - **Unshield** (Type 17): shielded pool -> transparent platform address -//! - **Transfer** (Type 16): shielded pool -> shielded pool (private) -//! - **Withdraw** (Type 19): shielded pool -> Core L1 address -//! -//! # Store requirements -//! -//! Spending operations (unshield, transfer, withdraw) require the store to -//! provide Merkle witness paths. The `ShieldedStore` trait needs a `witness()` -//! method for this -- see the TODO in `extract_spends_and_anchor()`. +//! - **Shield** (Type 15): transparent platform addresses → shielded pool +//! - **ShieldFromAssetLock** (Type 18): Core L1 asset lock → shielded pool +//! - **Unshield** (Type 17): shielded pool → transparent platform address +//! - **Transfer** (Type 16): shielded pool → shielded pool (private) +//! - **Withdraw** (Type 19): shielded pool → Core L1 address use super::note_selection::select_notes_with_fee; -use super::store::{ShieldedNote, ShieldedStore}; +use super::store::{ShieldedNote, ShieldedStore, SubwalletId}; use super::ShieldedWallet; use crate::error::PlatformWalletError; @@ -42,55 +34,132 @@ use dpp::shielded::builder::{ }; use dpp::withdrawal::Pooling; use grovedb_commitment_tree::{Anchor, PaymentAddress}; -use tracing::{info, trace}; +use tracing::{info, trace, warn}; + +/// Try to extract a structured `AddressesNotEnoughFundsError` from +/// a broadcast error so the shield path can format a diagnostic +/// that includes Platform's actual per-input view (nonce + balance) +/// rather than just the stringified message. +fn addresses_not_enough_funds( + e: &dash_sdk::Error, +) -> Option<&dpp::consensus::state::address_funds::AddressesNotEnoughFundsError> { + use dpp::consensus::state::state_error::StateError; + use dpp::consensus::ConsensusError; + use dpp::ProtocolError; + + let consensus: &ConsensusError = match e { + dash_sdk::Error::Protocol(ProtocolError::ConsensusError(boxed)) => boxed.as_ref(), + dash_sdk::Error::StateTransitionBroadcastError(s) => s.cause.as_ref()?, + _ => return None, + }; + match consensus { + ConsensusError::StateError(StateError::AddressesNotEnoughFundsError(err)) => Some(err), + _ => None, + } +} + +/// Format a one-line `addresses_with_info` summary for diagnostics — +/// each entry rendered as `=(nonce , credits)`, +/// 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(", ") +} impl ShieldedWallet { // ------------------------------------------------------------------------- // Shield: platform addresses -> shielded pool (Type 15) // ------------------------------------------------------------------------- - /// Shield funds from transparent platform addresses into the shielded pool. - /// - /// This is an output-only operation -- no notes are spent. Funds are deducted - /// from the transparent input addresses and a new shielded note is created for - /// this wallet's default payment address. - /// - /// # Parameters - /// - /// - `inputs` - Map of platform addresses to credits to spend from each - /// - `amount` - Total amount to shield (in credits) - /// - `signer` - Signs the transparent input witnesses (ECDSA) - /// - `prover` - Orchard prover for Halo 2 proof generation + /// Shield credits from transparent platform addresses into the + /// shielded pool, with the resulting note assigned to `account`'s + /// default Orchard payment address. pub async fn shield, P: OrchardProver>( &self, + account: u32, inputs: BTreeMap, amount: u64, signer: &Sig, prover: &P, ) -> Result<(), PlatformWalletError> { - let recipient_addr = self.default_orchard_address()?; + let recipient_addr = self.default_orchard_address(account)?; - // Build nonce map: The DPP builder takes (AddressNonce, Credits) pairs. - // For now we use nonce=0 as a placeholder -- the actual nonce should be - // fetched from the platform. In production, callers may use the SDK's - // ShieldFunds trait directly which fetches nonces automatically. - // - // TODO: Add proper nonce fetching, either here or require callers to - // provide inputs_with_nonce directly. - let inputs_with_nonce: BTreeMap = inputs - .into_iter() - .map(|(addr, credits)| (addr, (0u32, credits))) - .collect(); + // Fetch the current address nonces from Platform. Each + // input address has a per-address nonce that the next + // state transition must use as `last_used + 1`. + use dash_sdk::platform::FetchMany; + use dash_sdk::query_types::AddressInfo; + use std::collections::BTreeSet; + + let address_set: BTreeSet = inputs.keys().copied().collect(); + let infos = AddressInfo::fetch_many(&self.sdk, address_set) + .await + .map_err(|e| { + PlatformWalletError::ShieldedBuildError(format!("fetch input nonces: {e}")) + })?; + + let mut inputs_with_nonce: BTreeMap = BTreeMap::new(); + for (addr, credits) in inputs { + let info = infos + .get(&addr) + .and_then(|opt| opt.as_ref()) + .ok_or_else(|| { + PlatformWalletError::ShieldedBuildError(format!( + "input address not found on platform: {:?}", + addr + )) + })?; + if info.balance < credits { + warn!( + address = ?addr, + claimed_credits = credits, + platform_balance = info.balance, + platform_nonce = info.nonce, + "Shield input claims more credits than Platform reports — broadcast will likely fail" + ); + } else { + info!( + address = ?addr, + claimed_credits = credits, + platform_balance = info.balance, + platform_nonce = info.nonce, + "Shield input" + ); + } + // `AddressNonce` is `u32`; `info.nonce + 1` would + // wrap silently in release once an address reaches + // u32::MAX. drive-abci treats wrap-to-0 as a replay + // and rejects it after the wallet has spent ~30 s on + // a Halo 2 proof. Bail loudly here instead. + let next_nonce = info.nonce.checked_add(1).ok_or_else(|| { + PlatformWalletError::ShieldedBuildError(format!( + "input address nonce exhausted on platform: {:?}", + addr + )) + })?; + inputs_with_nonce.insert(addr, (next_nonce, credits)); + } let fee_strategy: AddressFundsFeeStrategy = vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]; - info!("Shield credits: {} credits, building proof...", amount,); + info!(account, credits = amount, "Shield: building proof"); + + let claimed_inputs = inputs_with_nonce.clone(); - // Build the state transition using the DPP builder. - // `build_shield_transition` is async (cascade from the dpp - // `Signer` trait being made async upstream); await before - // mapping the error. let state_transition = build_shield_transition( &recipient_addr, amount, @@ -105,14 +174,36 @@ impl ShieldedWallet { .await .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; - // Broadcast trace!("Shield credits: state transition built, broadcasting..."); + let network = self.sdk.network; state_transition .broadcast(&self.sdk, None) .await - .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; + .map_err(|e| { + if let Some(rich) = addresses_not_enough_funds(&e) { + let claimed = claimed_inputs + .iter() + .map(|(addr, (nonce, credits))| { + 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 credits broadcast succeeded: {} credits", amount); + info!(account, credits = amount, "Shield broadcast succeeded"); Ok(()) } @@ -120,29 +211,22 @@ impl ShieldedWallet { // ShieldFromAssetLock: Core L1 -> shielded pool (Type 18) // ------------------------------------------------------------------------- - /// Shield funds from a Core L1 asset lock directly into the shielded pool. - /// - /// The asset lock proof proves ownership of L1 funds. The ECDSA signature - /// from the private key binds those funds to the Orchard bundle. - /// - /// # Parameters - /// - /// - `asset_lock_proof` - Proof that funds are locked on the Core chain - /// - `private_key` - Private key for the asset lock (signs the transition) - /// - `amount` - Amount to shield (in credits) - /// - `prover` - Orchard prover for Halo 2 proof generation + /// Shield funds from a Core L1 asset lock directly into + /// `account`'s shielded pool entry. pub async fn shield_from_asset_lock( &self, + account: u32, asset_lock_proof: AssetLockProof, private_key: &[u8], amount: u64, prover: &P, ) -> Result<(), PlatformWalletError> { - let recipient_addr = self.default_orchard_address()?; + let recipient_addr = self.default_orchard_address(account)?; info!( - "Shield from asset lock: building state transition for {} credits", - amount, + account, + credits = amount, + "Shield from asset lock: building state transition" ); let state_transition = build_shield_from_asset_lock_transition( @@ -151,7 +235,7 @@ impl ShieldedWallet { asset_lock_proof, private_key, prover, - [0u8; 36], // empty memo + [0u8; 36], self.sdk.version(), ) .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; @@ -163,8 +247,9 @@ impl ShieldedWallet { .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; info!( - "Shield from asset lock broadcast succeeded: {} credits", - amount, + account, + credits = amount, + "Shield from asset lock broadcast succeeded" ); Ok(()) } @@ -173,42 +258,36 @@ impl ShieldedWallet { // Unshield: shielded pool -> platform address (Type 17) // ------------------------------------------------------------------------- - /// Unshield funds from the shielded pool to a transparent platform address. - /// - /// Selects notes to cover the requested amount plus fee, builds the Orchard - /// bundle with spend proofs, and broadcasts the state transition. - /// - /// # Parameters - /// - /// - `to_address` - Platform address to receive the unshielded funds - /// - `amount` - Amount to unshield (in credits) - /// - `prover` - Orchard prover for Halo 2 proof generation + /// Unshield funds from `account`'s shielded notes to a + /// transparent platform address. pub async fn unshield( &self, + account: u32, to_address: &PlatformAddress, amount: u64, prover: &P, ) -> Result<(), PlatformWalletError> { - let change_addr = self.default_orchard_address()?; + let keys = self.keys_for(account)?; + let change_addr = self.default_orchard_address(account)?; + let id = self.subwallet_id(account); - // Select notes with fee convergence (min 1 action for unshield change output) let (selected_notes, total_input, exact_fee) = { let store = self.store.read().await; let unspent = store - .get_unspent_notes() + .get_unspent_notes(id) .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; select_notes_with_fee(&unspent, amount, 1, self.sdk.version())?.into_owned() }; info!( - "Unshield: {} credits, fee {} credits, spending {} input note(s), total {} credits", - amount, - exact_fee, - selected_notes.len(), + account, + credits = amount, + fee = exact_fee, + inputs = selected_notes.len(), total_input, + "Unshield" ); - // Build SpendableNote structs with Merkle witnesses let (spends, anchor) = self.extract_spends_and_anchor(&selected_notes).await?; let state_transition = build_unshield_transition( @@ -216,11 +295,11 @@ impl ShieldedWallet { *to_address, amount, &change_addr, - &self.keys.full_viewing_key, - &self.keys.spend_auth_key, + &keys.full_viewing_key, + &keys.spend_auth_key, anchor, prover, - [0u8; 36], // empty memo + [0u8; 36], Some(exact_fee), self.sdk.version(), ) @@ -232,10 +311,9 @@ impl ShieldedWallet { .await .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; - // Mark spent notes in store - self.mark_notes_spent(&selected_notes).await?; + self.mark_notes_spent(id, &selected_notes).await?; - info!("Unshield broadcast succeeded: {} credits", amount); + info!(account, credits = amount, "Unshield broadcast succeeded"); Ok(()) } @@ -243,40 +321,35 @@ impl ShieldedWallet { // Transfer: shielded pool -> shielded pool (Type 16) // ------------------------------------------------------------------------- - /// Transfer funds privately within the shielded pool. - /// - /// Both input and output are shielded -- an observer learns nothing about - /// the sender, recipient, or amount. - /// - /// # Parameters - /// - /// - `to_address` - Recipient's Orchard payment address - /// - `amount` - Amount to transfer (in credits) - /// - `prover` - Orchard prover for Halo 2 proof generation + /// Transfer funds privately from `account`'s shielded notes + /// to another Orchard payment address. pub async fn transfer( &self, + account: u32, to_address: &PaymentAddress, amount: u64, prover: &P, ) -> Result<(), PlatformWalletError> { + let keys = self.keys_for(account)?; let recipient_addr = payment_address_to_orchard(to_address)?; - let change_addr = self.default_orchard_address()?; + let change_addr = self.default_orchard_address(account)?; + let id = self.subwallet_id(account); - // Select notes with fee convergence (min 2 actions: recipient + change) let (selected_notes, total_input, exact_fee) = { let store = self.store.read().await; let unspent = store - .get_unspent_notes() + .get_unspent_notes(id) .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; select_notes_with_fee(&unspent, amount, 2, self.sdk.version())?.into_owned() }; info!( - "Shielded transfer: {} credits, fee {} credits, spending {} input note(s), total {} credits", - amount, - exact_fee, - selected_notes.len(), + account, + credits = amount, + fee = exact_fee, + inputs = selected_notes.len(), total_input, + "Shielded transfer" ); let (spends, anchor) = self.extract_spends_and_anchor(&selected_notes).await?; @@ -286,11 +359,11 @@ impl ShieldedWallet { &recipient_addr, amount, &change_addr, - &self.keys.full_viewing_key, - &self.keys.spend_auth_key, + &keys.full_viewing_key, + &keys.spend_auth_key, anchor, prover, - [0u8; 36], // empty memo + [0u8; 36], Some(exact_fee), self.sdk.version(), ) @@ -302,9 +375,13 @@ impl ShieldedWallet { .await .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; - self.mark_notes_spent(&selected_notes).await?; + self.mark_notes_spent(id, &selected_notes).await?; - info!("Shielded transfer broadcast succeeded: {} credits", amount); + info!( + account, + credits = amount, + "Shielded transfer broadcast succeeded" + ); Ok(()) } @@ -312,42 +389,35 @@ impl ShieldedWallet { // Withdraw: shielded pool -> Core L1 address (Type 19) // ------------------------------------------------------------------------- - /// Withdraw funds from the shielded pool to a Core L1 address. - /// - /// Spends shielded notes and creates a withdrawal to the specified Core - /// chain address. The withdrawal uses standard pooling by default. - /// - /// # Parameters - /// - /// - `to_address` - Core chain address to receive the withdrawal - /// - `amount` - Amount to withdraw (in credits) - /// - `core_fee_per_byte` - Core chain fee rate (duffs per byte) - /// - `prover` - Orchard prover for Halo 2 proof generation + /// Withdraw funds from `account`'s shielded notes to a Core L1 address. pub async fn withdraw( &self, + account: u32, to_address: &dashcore::Address, amount: u64, core_fee_per_byte: u32, prover: &P, ) -> Result<(), PlatformWalletError> { - let change_addr = self.default_orchard_address()?; + let keys = self.keys_for(account)?; + let change_addr = self.default_orchard_address(account)?; + let id = self.subwallet_id(account); let output_script = CoreScript::from_bytes(to_address.script_pubkey().to_bytes()); - // Select notes with fee convergence (min 1 action for change output) let (selected_notes, total_input, exact_fee) = { let store = self.store.read().await; let unspent = store - .get_unspent_notes() + .get_unspent_notes(id) .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; select_notes_with_fee(&unspent, amount, 1, self.sdk.version())?.into_owned() }; info!( - "Shielded withdrawal: {} credits, fee {} credits, spending {} input note(s), total {} credits", - amount, - exact_fee, - selected_notes.len(), + account, + credits = amount, + fee = exact_fee, + inputs = selected_notes.len(), total_input, + "Shielded withdrawal" ); let (spends, anchor) = self.extract_spends_and_anchor(&selected_notes).await?; @@ -359,11 +429,11 @@ impl ShieldedWallet { core_fee_per_byte, Pooling::Standard, &change_addr, - &self.keys.full_viewing_key, - &self.keys.spend_auth_key, + &keys.full_viewing_key, + &keys.spend_auth_key, anchor, prover, - [0u8; 36], // empty memo + [0u8; 36], Some(exact_fee), self.sdk.version(), ) @@ -375,11 +445,12 @@ impl ShieldedWallet { .await .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; - self.mark_notes_spent(&selected_notes).await?; + self.mark_notes_spent(id, &selected_notes).await?; info!( - "Shielded withdrawal broadcast succeeded: {} credits", - amount + account, + credits = amount, + "Shielded withdrawal broadcast succeeded" ); Ok(()) } @@ -388,99 +459,271 @@ impl ShieldedWallet { // Internal helpers // ------------------------------------------------------------------------- - /// Convert this wallet's default PaymentAddress to an OrchardAddress. - fn default_orchard_address(&self) -> Result { - payment_address_to_orchard(&self.keys.default_address) + /// Convert `account`'s default `PaymentAddress` to an `OrchardAddress`. + fn default_orchard_address(&self, account: u32) -> Result { + let keys = self.keys_for(account)?; + payment_address_to_orchard(&keys.default_address) } - /// Extract SpendableNote structs with Merkle witnesses and the tree anchor. + /// Build witnesses + anchor for `notes`, validated against + /// Platform's `recorded_anchors` set so the resulting bundle + /// is guaranteed to pass the broadcast-time + /// `validate_anchor_exists` check. /// - /// Reads the commitment tree from the store, computes a Merkle path for each - /// selected note, and returns them alongside the current tree anchor. + /// Why this isn't just "use depth 0": /// - /// # Note + /// Platform records anchors only at block boundaries + /// ([`record_shielded_pool_anchor_if_changed`]) — the + /// depth-0 root of our local tree may reflect a mid-block / + /// partial-sync state that no Platform-side anchor matches. + /// Walking depths catches the common case of "synced one + /// block past where the anchor was last recorded" for free, + /// and surfaces a clean `ShieldedTreeDiverged` error if + /// every local checkpoint disagrees with every Platform + /// anchor (= our tree has fundamentally drifted and needs a + /// re-sync). /// - /// 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)] + /// The probe path: every note's witness at a given depth + /// derives the same root, so we walk depths using a single + /// note's `(position, cmx)` pair until the derived root is + /// in Platform's anchor set. Then we re-witness every + /// selected note at that depth and return the bundle. async fn extract_spends_and_anchor( &self, notes: &[ShieldedNote], ) -> Result<(Vec, Anchor), PlatformWalletError> { - let store = self.store.read().await; + use dash_sdk::platform::fetch_current_no_parameters::FetchCurrent; + use dash_sdk::query_types::{MostRecentShieldedAnchor, ShieldedAnchors}; + use grovedb_commitment_tree::ExtractedNoteCommitment; + use std::collections::HashSet; - 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 {}", - note.position - )) - })?; + if notes.is_empty() { + return Err(PlatformWalletError::ShieldedBuildError( + "no spendable notes selected — anchor undefined".into(), + )); + } - // 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()) - })?; + // Pull Platform's current set of valid anchors. + // Retention is 1000 blocks per the drive-abci method, + // so this comfortably covers any recently-synced state. + // + // The proof verifier's `FromProof` impl maps an empty + // anchors result to `None` (rather than `Some(vec![])`) + // and `fetch_current_with_metadata` then turns that + // into a `Generic("shielded anchors not found")` error. + // That error is indistinguishable from a transport + // failure, so we treat it as a non-fatal "set is + // empty" signal here and fall through to the + // most-recent fallback below. + let mut valid_anchors: HashSet<[u8; 32]> = HashSet::new(); + match ShieldedAnchors::fetch_current(&self.sdk).await { + Ok(set) => { + for a in set.0 { + valid_anchors.insert(a); + } + } + Err(e) => { + trace!("fetch shielded anchors returned no result (treated as empty set): {e}"); + } + } + + // Always fold in `MostRecentShieldedAnchor` too. It's + // the canonical "live" anchor — Platform updates it on + // every block where the commitment tree changes — and + // it's the single anchor that's most likely to match a + // freshly-synced wallet's depth-0 root. On a regtest + // where the recorded-anchors tree was never populated + // (e.g. the chain was running on an older platform + // version when the notes were added, and the + // `record_shielded_pool_anchor_if_changed` upgrade + // hasn't backfilled), this is the only valid anchor we + // can spend against. + match MostRecentShieldedAnchor::fetch_current(&self.sdk).await { + Ok(latest) => { + valid_anchors.insert(latest.0); + } + Err(e) => { + trace!( + "fetch most-recent shielded anchor returned no result \ + (treated as none): {e}" + ); + } + } - // 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; + let tried_anchors = valid_anchors.len(); + if tried_anchors == 0 { return Err(PlatformWalletError::ShieldedBuildError( - "Spending operations require a ShieldedStore that provides MerklePath witnesses. Not yet implemented.".to_string(), + "Platform returned no shielded anchors (neither the recorded set \ + nor the most-recent slot is populated); the pool may be empty \ + or the anchor-recording upgrade hasn't run yet on this network" + .into(), )); } - let anchor_bytes = store - .tree_anchor() - .map_err(|e| PlatformWalletError::ShieldedMerkleWitnessUnavailable(e.to_string()))?; - let anchor = Anchor::from_bytes(anchor_bytes) + let probe = ¬es[0]; + let probe_cmx = ExtractedNoteCommitment::from_bytes(&probe.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 {}", + probe.position + )) })?; - Ok((spends, anchor)) - } + // shardtree returns `Ok(None)` once we walk past the + // last available checkpoint, which terminates the loop + // cleanly. The `MAX_CHECKPOINT_DEPTHS` bound is + // intentionally generous — `FileBackedShieldedStore` + // pins `max_checkpoints = 100` today. + const MAX_CHECKPOINT_DEPTHS: usize = 128; + let store = self.store.read().await; + let mut chosen_depth: Option = None; + let mut depths_walked = 0usize; + for depth in 0..MAX_CHECKPOINT_DEPTHS { + let probe_path = match store.witness(probe.position, depth) { + Ok(Some(path)) => path, + // No checkpoint at this depth — no point + // walking further; older depths can't exist. + Ok(None) => break, + Err(e) => { + // Position not contained at this depth + // (note appended after the older + // checkpoint) — keep walking to deeper + // checkpoints, but record the error so we + // can surface useful diagnostics. + trace!( + depth, + position = probe.position, + "witness unavailable at depth: {e}" + ); + depths_walked += 1; + continue; + } + }; + depths_walked += 1; + let root = probe_path.root(probe_cmx).to_bytes(); + if valid_anchors.contains(&root) { + chosen_depth = Some(depth); + break; + } + } + + let depth = match chosen_depth { + Some(d) => d, + None => { + // Best-effort diagnostics: log our local depth-0 + // root + a few Platform anchors so a divergence + // is debuggable from the trace alone. + let local_root = store + .witness(probe.position, 0) + .ok() + .flatten() + .map(|p| hex::encode(p.root(probe_cmx).to_bytes())) + .unwrap_or_else(|| "".to_string()); + let mut sample: Vec = + valid_anchors.iter().take(4).map(hex::encode).collect(); + if valid_anchors.len() > sample.len() { + sample.push(format!("…({} total)", valid_anchors.len())); + } + warn!( + local_depth_0_root = %local_root, + platform_anchors = %sample.join(","), + depths_walked, + "No local checkpoint matches any Platform anchor — tree diverged" + ); + return Err(PlatformWalletError::ShieldedTreeDiverged { + tried: tried_anchors, + depths_walked, + }); + } + }; - /// 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; + info!( + depth, + platform_anchor_count = tried_anchors, + notes = notes.len(), + "Selected anchor depth for shielded spend" + ); + // Re-witness every selected note at the chosen depth. + // The probe-path's root above already proved one note + // works; the remaining notes must witness at the same + // depth (same checkpoint state) for the spend bundle's + // single-anchor invariant to hold. + let mut spends = Vec::with_capacity(notes.len()); + let mut anchor: Option = None; for note in notes { - store - .mark_spent(¬e.nullifier) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + 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, depth) + .map_err(|e| PlatformWalletError::ShieldedMerkleWitnessUnavailable(e.to_string()))? + .ok_or_else(|| { + PlatformWalletError::ShieldedMerkleWitnessUnavailable(format!( + "no witness at depth {depth} for note at position {}", + note.position + )) + })?; + let cmx = ExtractedNoteCommitment::from_bytes(¬e.cmx) + .into_option() + .ok_or_else(|| { + PlatformWalletError::ShieldedBuildError(format!( + "invalid stored cmx for note at position {}", + note.position + )) + })?; + let witness_anchor = merkle_path.root(cmx); + match &anchor { + None => anchor = Some(witness_anchor), + Some(prev) if prev.to_bytes() != witness_anchor.to_bytes() => { + return Err(PlatformWalletError::ShieldedBuildError(format!( + "witness anchor mismatch across selected notes at depth {depth} (position {})", + note.position + ))); + } + _ => {} + } + spends.push(SpendableNote { + note: orchard_note, + merkle_path, + }); } + let anchor = anchor.expect("anchor set after non-empty loop"); + 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. + async fn mark_notes_spent( + &self, + id: SubwalletId, + notes: &[ShieldedNote], + ) -> Result<(), PlatformWalletError> { + let mut changeset = crate::changeset::ShieldedChangeSet::default(); + { + let mut store = self.store.write().await; + for note in notes { + if store + .mark_spent(id, ¬e.nullifier) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))? + { + changeset.record_nullifier_spent(id, note.nullifier); + } + } + } + self.queue_shielded_changeset(changeset); Ok(()) } } -/// Helper trait extension for note selection results that need to own the data. -/// -/// When note selection is performed inside a store lock scope, we need to -/// clone the results so they can outlive the lock. +/// Helper to clone selection results out from under the store lock. trait SelectionResultOwned { fn into_owned(self) -> (Vec, u64, u64); } @@ -493,7 +736,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 +750,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 54e5bde9de7..405f51c75f1 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -1,24 +1,60 @@ //! Storage abstraction for shielded wallet state. //! -//! The `ShieldedStore` trait decouples `ShieldedWallet` from any particular -//! persistence backend. Consumers provide their own implementation (e.g. -//! SQLite-backed for production) while tests can use `InMemoryShieldedStore`. +//! The `ShieldedStore` trait decouples `ShieldedWallet` from any +//! particular persistence backend. Consumers provide their own +//! implementation (e.g. SwiftData via the host persister) while +//! tests can use [`InMemoryShieldedStore`]. //! -//! Note data is stored as raw bytes (`note_data: Vec`) — a serialized -//! `orchard::Note` — 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::error::Error as StdError; use std::fmt; -/// A note decrypted and owned by this wallet. +/// Identifies a single shielded "subwallet" — one Orchard account +/// within one wallet. Used to scope notes, nullifier indices, and +/// sync watermarks inside a [`ShieldedStore`] so a single store +/// can hold state for many wallets/accounts without leakage. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SubwalletId { + /// 32-byte wallet identifier (matches `PlatformWallet::wallet_id`). + pub wallet_id: [u8; 32], + /// ZIP-32 account index (`m / 32' / coin_type' / account'`). + pub account_index: u32, +} + +impl SubwalletId { + /// Construct a [`SubwalletId`] from its parts. + pub fn new(wallet_id: [u8; 32], account_index: u32) -> Self { + Self { + wallet_id, + account_index, + } + } +} + +/// A note decrypted and owned by a specific subwallet. /// -/// This struct carries all the bookkeeping fields needed by the shielded -/// wallet. The actual `orchard::Note` is stored as opaque bytes in -/// `note_data` so that the storage layer does not need to depend on the -/// Orchard crate. +/// Carries the bookkeeping the spend pipeline needs without +/// pulling the orchard crate into this trait. The actual +/// `orchard::Note` is in `note_data` as 115 bytes +/// (`recipient(43) || value(8 LE) || rho(32) || rseed(32)`). #[derive(Debug, Clone)] pub struct ShieldedNote { /// Global position in the commitment tree. @@ -34,78 +70,139 @@ pub struct ShieldedNote { /// Note value in credits. pub value: u64, /// Serialized `orchard::Note` bytes (115 bytes). - /// Format: `recipient(43) || value(8 LE) || rho(32) || rseed(32)`. pub note_data: Vec, } /// Storage abstraction for shielded wallet state. /// -/// Consumers implement this for their persistence layer. The trait is -/// object-safe (no generics in method signatures) so it can be stored -/// behind `Arc>` when needed. +/// Consumers implement this for their persistence layer. The +/// trait is object-safe (no generics on method signatures) so it +/// can be stored behind `Arc>`. /// -/// All mutating methods take `&mut self` to allow the implementation to -/// batch writes or hold open transactions without interior mutability. +/// All mutating methods take `&mut self` so implementations can +/// batch writes without interior mutability. pub trait ShieldedStore: Send + Sync { /// The error type returned by storage operations. type Error: StdError + Send + Sync + 'static; - // ── Notes ────────────────────────────────────────────────────────── + // ── Notes (per-subwallet) ────────────────────────────────────────── - /// Persist a newly decrypted note. - fn save_note(&mut self, note: &ShieldedNote) -> Result<(), Self::Error>; + /// Persist a newly decrypted note for `id`. + fn save_note(&mut self, id: SubwalletId, note: &ShieldedNote) -> Result<(), Self::Error>; - /// Return all unspent (not yet nullified) notes. - fn get_unspent_notes(&self) -> Result, Self::Error>; + /// Return all unspent notes for `id`. + fn get_unspent_notes(&self, id: SubwalletId) -> Result, Self::Error>; - /// Return all notes (both spent and unspent). - fn get_all_notes(&self) -> Result, Self::Error>; + /// Return all notes (spent and unspent) for `id`. + fn get_all_notes(&self, id: SubwalletId) -> Result, Self::Error>; - /// Mark the note identified by `nullifier` as spent. - /// - /// Returns `true` if a matching unspent note was found and marked, - /// `false` if no unspent note has that nullifier. - fn mark_spent(&mut self, nullifier: &[u8; 32]) -> Result; + /// Mark `id`'s note with `nullifier` as spent. Returns `true` + /// if a matching unspent note was found. + fn mark_spent(&mut self, id: SubwalletId, nullifier: &[u8; 32]) -> Result; - // ── Commitment tree ──────────────────────────────────────────────── + // ── Commitment tree (network-shared) ─────────────────────────────── - /// Append a note commitment to the commitment tree. + /// Append a note commitment to the shared tree. /// - /// `marked` indicates whether this position should be remembered for - /// future witness generation (i.e. it belongs to this wallet). + /// `marked` should be `true` if **any** tracked subwallet owns + /// this position. The tree only retains authentication paths + /// for marked positions; unmarked positions are pruned. fn append_commitment(&mut self, cmx: &[u8; 32], marked: bool) -> Result<(), Self::Error>; /// Create a tree checkpoint at the given identifier. - /// - /// Checkpoints allow the tree to be rewound to this point if a sync - /// batch needs to be rolled back. fn checkpoint_tree(&mut self, checkpoint_id: u32) -> Result<(), Self::Error>; /// Return the current tree root (Sinsemilla anchor, 32 bytes). fn tree_anchor(&self) -> Result<[u8; 32], Self::Error>; - /// Generate a Merkle authentication path (witness) for the note at the - /// given global position. Returns the path as raw bytes. - /// - /// 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>; + /// Generate a Merkle authentication path for `position` + /// against the tree state at `checkpoint_depth` checkpoints + /// before the current state (0 = most recent checkpoint, 1 = + /// one before, etc.). Returns `Ok(None)` if no checkpoint + /// exists at the requested depth or if the position is not + /// marked / has been pruned. + fn witness( + &self, + position: u64, + checkpoint_depth: usize, + ) -> Result, Self::Error>; + + // ── Sync state (per-subwallet) ───────────────────────────────────── + + /// The last global note index that was synced for `id`. + fn last_synced_note_index(&self, id: SubwalletId) -> Result; + + /// Persist the last synced note index 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>; +} - // ── Sync state ───────────────────────────────────────────────────── +// ── Per-subwallet bookkeeping ────────────────────────────────────────── + +/// Per-subwallet note + sync state used by both the in-memory and +/// file-backed stores. Kept in this module so both share the +/// exact same shape and the persister callback can serialize it +/// without re-defining the structure on the host side. +#[derive(Debug, Default, Clone)] +pub(super) struct SubwalletState { + /// All known notes (spent + unspent), in insertion order. + pub notes: Vec, + /// Nullifier → index into `notes`, for O(1) `mark_spent`. + pub nullifier_index: BTreeMap<[u8; 32], usize>, + /// Highest global note index ever scanned. + pub last_synced_index: u64, + /// `(height, timestamp)` from the most recent nullifier sync. + pub nullifier_checkpoint: Option<(u64, u64)>, +} - /// The last global note index that was synced from Platform. - fn last_synced_note_index(&self) -> Result; +impl SubwalletState { + /// Save (or overwrite-by-nullifier) a note. + /// + /// 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()); + } - /// Persist the last synced note index. - fn set_last_synced_note_index(&mut self, index: u64) -> Result<(), Self::Error>; + pub(super) fn unspent_notes(&self) -> Vec { + self.notes.iter().filter(|n| !n.is_spent).cloned().collect() + } - /// The last nullifier sync checkpoint, if any. - /// - /// Returns `(height, timestamp)` from the most recent nullifier sync. - fn nullifier_checkpoint(&self) -> Result, Self::Error>; + pub(super) fn all_notes(&self) -> Vec { + self.notes.clone() + } - /// Persist the nullifier sync checkpoint. - fn set_nullifier_checkpoint(&mut self, height: u64, timestamp: u64) -> Result<(), Self::Error>; + pub(super) fn mark_spent(&mut self, nullifier: &[u8; 32]) -> bool { + if let Some(&idx) = self.nullifier_index.get(nullifier) { + if !self.notes[idx].is_spent { + self.notes[idx].is_spent = true; + return true; + } + } + false + } } // ── InMemoryShieldedStore ────────────────────────────────────────────── @@ -122,82 +219,62 @@ impl fmt::Display for InMemoryStoreError { impl StdError for InMemoryStoreError {} -/// In-memory implementation of [`ShieldedStore`] for tests and short-lived -/// wallets. -/// -/// Notes are stored in a `Vec`; the commitment tree is represented as a flat -/// list of commitments (sufficient for anchor computation via the incremental -/// merkle tree crate, but witness generation is **not** implemented — use a -/// real store for operations that require Merkle paths). -#[derive(Debug)] +/// In-memory implementation of [`ShieldedStore`] for tests and +/// short-lived wallets. Notes are kept per [`SubwalletId`]; the +/// commitment tree is a flat list (anchor is a placeholder, so +/// real witness generation is **not** supported — use a real +/// store for spends). +#[derive(Debug, Default)] pub struct InMemoryShieldedStore { - /// All notes, keyed by nullifier for O(1) lookup during `mark_spent`. - notes: Vec, - /// Nullifier -> index into `notes` for fast spend marking. - nullifier_index: BTreeMap<[u8; 32], usize>, + /// Per-subwallet notes + sync state. + subwallets: BTreeMap, /// Flat list of commitments appended to the tree. commitments: Vec<[u8; 32]>, - /// Positions that are marked (belong to this wallet). + /// Mark flag per position. marked_positions: Vec, - /// Checkpoint IDs in order. + /// Checkpoint ids in order. checkpoints: Vec, - /// Current anchor (recomputed lazily — for the in-memory store we - /// store a dummy zero value; production stores compute from the real tree). + /// Placeholder anchor; production stores compute the real Sinsemilla root. anchor: [u8; 32], - /// Last synced note index. - last_synced_index: u64, - /// Nullifier sync checkpoint: `(height, timestamp)`. - nullifier_checkpoint: Option<(u64, u64)>, } impl InMemoryShieldedStore { /// Create a new empty in-memory store. pub fn new() -> Self { - Self { - notes: Vec::new(), - nullifier_index: BTreeMap::new(), - commitments: Vec::new(), - marked_positions: Vec::new(), - checkpoints: Vec::new(), - anchor: [0u8; 32], - last_synced_index: 0, - nullifier_checkpoint: None, - } - } -} - -impl Default for InMemoryShieldedStore { - fn default() -> Self { - Self::new() + Self::default() } } impl ShieldedStore for InMemoryShieldedStore { type Error = InMemoryStoreError; - fn save_note(&mut self, note: &ShieldedNote) -> Result<(), Self::Error> { - let idx = self.notes.len(); - self.nullifier_index.insert(note.nullifier, idx); - self.notes.push(note.clone()); + fn save_note(&mut self, id: SubwalletId, note: &ShieldedNote) -> Result<(), Self::Error> { + self.subwallets.entry(id).or_default().save_note(note); Ok(()) } - fn get_unspent_notes(&self) -> Result, Self::Error> { - Ok(self.notes.iter().filter(|n| !n.is_spent).cloned().collect()) + fn get_unspent_notes(&self, id: SubwalletId) -> Result, Self::Error> { + Ok(self + .subwallets + .get(&id) + .map(SubwalletState::unspent_notes) + .unwrap_or_default()) } - fn get_all_notes(&self) -> Result, Self::Error> { - Ok(self.notes.clone()) + fn get_all_notes(&self, id: SubwalletId) -> Result, Self::Error> { + Ok(self + .subwallets + .get(&id) + .map(SubwalletState::all_notes) + .unwrap_or_default()) } - fn mark_spent(&mut self, nullifier: &[u8; 32]) -> Result { - if let Some(&idx) = self.nullifier_index.get(nullifier) { - if !self.notes[idx].is_spent { - self.notes[idx].is_spent = true; - return Ok(true); - } - } - Ok(false) + fn mark_spent(&mut self, id: SubwalletId, nullifier: &[u8; 32]) -> Result { + Ok(self + .subwallets + .get_mut(&id) + .map(|sw| sw.mark_spent(nullifier)) + .unwrap_or(false)) } fn append_commitment(&mut self, cmx: &[u8; 32], marked: bool) -> Result<(), Self::Error> { @@ -212,34 +289,50 @@ 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, + _checkpoint_depth: usize, + ) -> 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 last_synced_note_index(&self, id: SubwalletId) -> Result { + Ok(self + .subwallets + .get(&id) + .map(|sw| sw.last_synced_index) + .unwrap_or(0)) } - fn set_last_synced_note_index(&mut self, index: u64) -> Result<(), Self::Error> { - self.last_synced_index = index; + fn set_last_synced_note_index( + &mut self, + id: SubwalletId, + index: u64, + ) -> Result<(), Self::Error> { + self.subwallets.entry(id).or_default().last_synced_index = index; Ok(()) } - fn nullifier_checkpoint(&self) -> Result, Self::Error> { - Ok(self.nullifier_checkpoint) + fn nullifier_checkpoint(&self, id: SubwalletId) -> Result, Self::Error> { + Ok(self + .subwallets + .get(&id) + .and_then(|sw| sw.nullifier_checkpoint)) } - fn set_nullifier_checkpoint(&mut self, height: u64, timestamp: u64) -> Result<(), Self::Error> { - self.nullifier_checkpoint = Some((height, timestamp)); + fn set_nullifier_checkpoint( + &mut self, + id: SubwalletId, + height: u64, + timestamp: u64, + ) -> Result<(), Self::Error> { + self.subwallets.entry(id).or_default().nullifier_checkpoint = Some((height, timestamp)); Ok(()) } } @@ -248,9 +341,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 +358,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 +384,43 @@ mod tests { value: 500, note_data: vec![0u8; 115], }; - store.save_note(¬e).unwrap(); - - // Mark spent - let found = store.mark_spent(&nullifier).unwrap(); - assert!(found); - - // Should no longer appear in unspent - let unspent = store.get_unspent_notes().unwrap(); - assert!(unspent.is_empty()); + store.save_note(id, ¬e).unwrap(); - // But should appear in all notes - let all = store.get_all_notes().unwrap(); + assert!(store.mark_spent(id, &nullifier).unwrap()); + assert!(store.get_unspent_notes(id).unwrap().is_empty()); + let all = store.get_all_notes(id).unwrap(); assert_eq!(all.len(), 1); assert!(all[0].is_spent); - - // Marking again returns false - let found_again = store.mark_spent(&nullifier).unwrap(); - assert!(!found_again); + // Marking again returns false (already spent). + assert!(!store.mark_spent(id, &nullifier).unwrap()); } #[test] - fn test_sync_state() { + fn test_sync_state_per_subwallet() { let mut store = InMemoryShieldedStore::new(); + let a = test_id(0); + let b = test_id(1); - assert_eq!(store.last_synced_note_index().unwrap(), 0); - store.set_last_synced_note_index(100).unwrap(); - assert_eq!(store.last_synced_note_index().unwrap(), 100); + assert_eq!(store.last_synced_note_index(a).unwrap(), 0); + store.set_last_synced_note_index(a, 100).unwrap(); + assert_eq!(store.last_synced_note_index(a).unwrap(), 100); + // Different subwallet still at 0. + assert_eq!(store.last_synced_note_index(b).unwrap(), 0); - assert!(store.nullifier_checkpoint().unwrap().is_none()); - store.set_nullifier_checkpoint(200, 1234567890).unwrap(); + store.set_nullifier_checkpoint(a, 200, 1234567890).unwrap(); assert_eq!( - store.nullifier_checkpoint().unwrap(), + store.nullifier_checkpoint(a).unwrap(), Some((200, 1234567890)) ); + assert!(store.nullifier_checkpoint(b).unwrap().is_none()); } #[test] fn test_commitment_tree_operations() { let mut store = InMemoryShieldedStore::new(); - store.append_commitment(&[1u8; 32], true).unwrap(); store.append_commitment(&[2u8; 32], false).unwrap(); store.checkpoint_tree(1).unwrap(); - - // Anchor is dummy for in-memory - let anchor = store.tree_anchor().unwrap(); - assert_eq!(anchor, [0u8; 32]); + assert_eq!(store.tree_anchor().unwrap(), [0u8; 32]); } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs index fdb3ed05471..b04410a87fe 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs @@ -1,272 +1,416 @@ -//! Shielded note and nullifier synchronization. +//! Shielded note + nullifier synchronization (multi-account). //! -//! Implements the sync methods on `ShieldedWallet`: -//! - `sync_notes()` -- fetch and trial-decrypt encrypted notes from Platform -//! - `check_nullifiers()` -- privacy-preserving nullifier status check -//! - `sync()` -- full sync (notes + nullifiers + balance) - -use super::store::ShieldedStore; -use super::ShieldedWallet; -use crate::error::PlatformWalletError; +//! Implements sync methods on `ShieldedWallet`: +//! - `sync_notes()` — fetch encrypted notes once, trial-decrypt +//! with every bound account's IVK, append commitments to the +//! shared tree once with `marked = any account decrypted the +//! position`, save decrypted notes per-subwallet. +//! - `check_nullifiers()` — privacy-preserving nullifier scan, +//! marks spent notes per-subwallet. +//! - `sync()` — full pass: notes + nullifiers + per-account +//! balance summary. + +use std::collections::{BTreeMap, BTreeSet}; use dash_sdk::platform::shielded::nullifier_sync::{NullifierSyncCheckpoint, NullifierSyncConfig}; -use dash_sdk::platform::shielded::sync_shielded_notes; +use dash_sdk::platform::shielded::{sync_shielded_notes, try_decrypt_note}; +use grovedb_commitment_tree::{Note as OrchardNote, PaymentAddress, PreparedIncomingViewingKey}; use tracing::{debug, info, warn}; -/// Server-enforced chunk size -- start_index must be a multiple of this. +use super::store::{ShieldedStore, SubwalletId}; +use super::ShieldedWallet; +use crate::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, + /// Total encrypted notes scanned. pub total_scanned: u64, } -/// Summary of a full sync (notes + nullifiers + balance). -#[derive(Debug, Clone)] +impl SyncNotesResult { + /// Total new notes across every account. + pub fn total_new_notes(&self) -> usize { + self.new_notes_per_account.values().sum() + } +} + +/// Summary of a full sync (notes + nullifiers + balances). +#[derive(Debug, Clone, Default)] pub struct ShieldedSyncSummary { - /// Results from note sync. + /// Note-sync result. pub notes_result: SyncNotesResult, - /// Number of notes newly detected as spent. - pub newly_spent: usize, - /// Current unspent balance after sync. - pub balance: u64, + /// Per-account count of notes newly detected as spent. + pub newly_spent_per_account: BTreeMap, + /// Per-account unspent balance after sync. + pub balances: BTreeMap, +} + +impl ShieldedSyncSummary { + /// Sum of unspent balances across accounts. + pub fn balance_total(&self) -> u64 { + self.balances.values().copied().sum() + } + + /// Sum of newly-spent counts across accounts. + pub fn total_newly_spent(&self) -> usize { + self.newly_spent_per_account.values().sum() + } } impl ShieldedWallet { - /// Sync encrypted notes from Platform. - /// - /// 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` + /// Sync encrypted notes from Platform across every bound account. /// - /// # Returns - /// - /// `SyncNotesResult` with the count of new notes found and total scanned. + /// Fetches raw chunks once via the SDK (using account 0's IVK + /// as the trial-decrypt key for the SDK call), then locally + /// trial-decrypts the same chunks against every other + /// account's IVK. Commitments are appended to the shared + /// tree exactly once per global position with `marked = + /// (any bound account owns this position)`. Decrypted notes + /// land in the store under the discovering account's + /// [`SubwalletId`]. pub async fn sync_notes(&self) -> Result { - let prepared_ivk = self.keys.prepared_ivk(); - - // Step 1: Get last synced index and align to chunk boundary + // Snapshot accounts + their prepared IVKs. The IVKs are + // owned `PreparedIncomingViewingKey` values so we can hold + // them across the await without borrowing `self`. + let account_indices: Vec = self.account_indices(); + if account_indices.is_empty() { + return Ok(SyncNotesResult::default()); + } + let prepared: Vec<(u32, PreparedIncomingViewingKey)> = account_indices + .iter() + .map(|&a| Ok((a, self.keys_for(a)?.prepared_ivk()))) + .collect::>()?; + + // Use the lowest per-account watermark as the canonical + // tree-fetch start. Today we wipe-and-re-sync when an + // account is added, so all accounts share the same + // watermark in practice — this `min` is just defensive. let already_have = { let store = self.store.read().await; - store - .last_synced_note_index() - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))? + let mut min_idx: Option = None; + for &account in &account_indices { + let id = self.subwallet_id(account); + let idx = store + .last_synced_note_index(id) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + min_idx = Some(min_idx.map_or(idx, |m| m.min(idx))); + } + min_idx.unwrap_or(0) }; let aligned_start = (already_have / CHUNK_SIZE) * CHUNK_SIZE; info!( - "Starting shielded note sync: last_synced={}, aligned_start={}", - already_have, aligned_start, + accounts = account_indices.len(), + already_have, aligned_start, "Starting shielded note sync" ); - // Step 2: Fetch and trial-decrypt via SDK - let result = sync_shielded_notes(&self.sdk, &prepared_ivk, aligned_start, None) + // Fetch + trial-decrypt with the FIRST bound account's + // IVK in one SDK call. We also reuse the returned + // `all_notes` for local trial-decryption with every other + // account's IVK below. + let (driver_account, driver_ivk) = &prepared[0]; + let result = sync_shielded_notes(&self.sdk, driver_ivk, aligned_start, None) .await .map_err(|e| PlatformWalletError::ShieldedSyncFailed(e.to_string()))?; info!( - "Sync complete: total_scanned={}, decrypted={}, next_start_index={}", - result.total_notes_scanned, - result.decrypted_notes.len(), - result.next_start_index, + total_scanned = result.total_notes_scanned, + decrypted_for_driver = result.decrypted_notes.len(), + next_start_index = result.next_start_index, + "SDK sync returned" ); if result.next_start_index == 0 && result.total_notes_scanned > 0 { warn!( - "Shielded sync: next_start_index is 0 after scanning {} notes -- \ - next sync will rescan everything from the beginning", + "Shielded sync: next_start_index is 0 after scanning {} notes — \ + next sync will rescan from the beginning", result.total_notes_scanned, ); } + // Index decryptions by `(account, position) → DecryptedNote`. + // The driver account's hits come from the SDK call; + // every other account's are produced by local + // trial-decryption against `result.all_notes`. + let mut decrypted_by_account: BTreeMap> = BTreeMap::new(); + for dn in &result.decrypted_notes { + decrypted_by_account + .entry(*driver_account) + .or_default() + .push(DiscoveredNote { + position: dn.position, + cmx: dn.cmx, + note: dn.note, + }); + } + + for (account, ivk) in prepared.iter().skip(1) { + for (i, raw_note) in result.all_notes.iter().enumerate() { + let position = aligned_start + i as u64; + if let Some((note, _addr)) = try_decrypt_note(ivk, raw_note) { + let cmx_bytes: [u8; 32] = match raw_note.cmx.as_slice().try_into() { + Ok(b) => b, + Err(_) => continue, + }; + decrypted_by_account + .entry(*account) + .or_default() + .push(DiscoveredNote { + position, + cmx: cmx_bytes, + note, + }); + } + } + } + + // Build the union of "owned" positions for tree marking. + let owned_positions: BTreeSet = decrypted_by_account + .values() + .flat_map(|v| v.iter().map(|n| n.position)) + .collect(); + let mut store = self.store.write().await; - // Step 3: Append commitments to the tree, skipping positions already present + // Append every commitment to the shared tree exactly + // once per position. Skip positions already in the tree + // (re-scan after a partial chunk advance). let mut appended = 0u32; for (i, raw_note) in result.all_notes.iter().enumerate() { let global_pos = aligned_start + i as u64; if global_pos < already_have { - continue; // already appended in a previous sync + continue; } - let cmx_bytes: [u8; 32] = raw_note.cmx.as_slice().try_into().map_err(|_| { PlatformWalletError::ShieldedSyncFailed("Invalid cmx length".into()) })?; - - let is_ours = result - .decrypted_notes - .iter() - .any(|dn| dn.position == global_pos); - + let is_ours = owned_positions.contains(&global_pos); store .append_commitment(&cmx_bytes, is_ours) .map_err(|e| PlatformWalletError::ShieldedTreeUpdateFailed(e.to_string()))?; - appended += 1; } - // Step 4: Checkpoint tree if appended > 0 { - let checkpoint_id = result.next_start_index as u32; + // Use the high-water position (`aligned_start + + // total_notes_scanned` — i.e. one past the last + // appended position) as the checkpoint id rather than + // `result.next_start_index`, which rewinds to the last + // partial chunk's start and can therefore be the same + // value across consecutive syncs. shardtree's + // `checkpoint(id)` silently dedups duplicate ids, so + // a non-monotonic id leaves depth-0 pinned at the + // first checkpoint while later appends extend the + // tree past it. The witness at depth 0 then reflects + // an old state whose root Platform never recorded, + // and the bundle's anchor fails the + // `validate_anchor_exists` check on broadcast. + let new_index = aligned_start + result.total_notes_scanned; + let checkpoint_id: u32 = new_index.try_into().unwrap_or(u32::MAX); store .checkpoint_tree(checkpoint_id) .map_err(|e| PlatformWalletError::ShieldedTreeUpdateFailed(e.to_string()))?; } - // Step 5: Save decrypted notes - let mut new_note_count = 0usize; - for dn in &result.decrypted_notes { - if dn.position < already_have { - continue; // already stored in a previous sync + // Save decrypted notes scoped per subwallet, count new + // notes per account, and accumulate a changeset to hand + // to the persister at the end. + let mut new_notes_per_account: BTreeMap = BTreeMap::new(); + let mut changeset = ShieldedChangeSet::default(); + for (account, discovered) in &decrypted_by_account { + let fvk = &self.keys_for(*account)?.full_viewing_key; + let id = self.subwallet_id(*account); + for d in discovered { + if d.position < already_have { + continue; + } + let nullifier = d.note.nullifier(fvk); + let value = d.note.value().inner(); + debug!( + account = account, + position = d.position, + value, + "Note DECRYPTED" + ); + let note_data = serialize_note(&d.note); + let shielded_note = super::store::ShieldedNote { + note_data, + position: d.position, + cmx: d.cmx, + nullifier: nullifier.to_bytes(), + block_height: result.block_height, + is_spent: false, + value, + }; + store + .save_note(id, &shielded_note) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + changeset.record_note(id, shielded_note); + *new_notes_per_account.entry(*account).or_default() += 1; } + } - // Compute the spending nullifier from our FVK. - // dn.nullifier is the rho/nf from the compact action, not the spending nullifier. - let nullifier = dn.note.nullifier(&self.keys.full_viewing_key); - let value = dn.note.value().inner(); - - debug!("Note[{}]: DECRYPTED, value={} credits", dn.position, value,); - - // Serialize the note for storage. - let note_data = serialize_note(&dn.note); - - let shielded_note = super::store::ShieldedNote { - note_data, - position: dn.position, - cmx: dn.cmx, - nullifier: nullifier.to_bytes(), - block_height: result.block_height, - is_spent: false, - value, - }; - + // Update every account's watermark to the same global + // tree position so the next sync resumes coherently. + let new_index = aligned_start + result.total_notes_scanned; + for &account in &account_indices { + let id = self.subwallet_id(account); store - .save_note(&shielded_note) + .set_last_synced_note_index(id, new_index) .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; - - new_note_count += 1; + changeset.record_synced_index(id, new_index); } - - // Step 6: Update last synced index - let new_index = aligned_start + result.total_notes_scanned; - store - .set_last_synced_note_index(new_index) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + // Drop the write lock before queuing the changeset so + // the persister callback (which may take its own + // synchronous mutex) doesn't nest under our store lock. + drop(store); + self.queue_shielded_changeset(changeset); info!( - "Shielded sync finished: {} new note(s), last_synced_index={}", - new_note_count, new_index, + new_notes_total = new_notes_per_account.values().sum::(), + new_index, "Shielded sync finished" ); Ok(SyncNotesResult { - new_notes: new_note_count, + new_notes_per_account, total_scanned: result.total_notes_scanned, }) } - /// Check nullifier status for unspent notes. - /// - /// Uses the SDK's privacy-preserving trunk/branch tree scan with incremental - /// catch-up. Marks spent notes in the store. - /// - /// # Returns - /// - /// The number of notes newly detected as spent. - pub async fn check_nullifiers(&self) -> Result { - // Step 1: Collect unspent nullifiers from store - let (unspent_nullifiers, last_checkpoint) = { - let store = self.store.read().await; - let unspent = store - .get_unspent_notes() - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; - let nullifiers: Vec<[u8; 32]> = unspent.iter().map(|n| n.nullifier).collect(); - let checkpoint = store - .nullifier_checkpoint() - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))? - .map(|(height, timestamp)| NullifierSyncCheckpoint { height, timestamp }); - (nullifiers, checkpoint) - }; - - if unspent_nullifiers.is_empty() { - return Ok(0); + /// Check nullifier status for unspent notes across every bound + /// account. Spent notes are marked per-subwallet. + pub async fn check_nullifiers(&self) -> Result, PlatformWalletError> { + let account_indices = self.account_indices(); + if account_indices.is_empty() { + return Ok(BTreeMap::new()); } - debug!( - "Checking {} nullifiers (checkpoint: {:?})", - unspent_nullifiers.len(), - last_checkpoint, - ); - - // Step 2: Call SDK sync_nullifiers - let result = self - .sdk - .sync_nullifiers( - &unspent_nullifiers, - None::, - last_checkpoint, - ) - .await - .map_err(|e| PlatformWalletError::ShieldedNullifierSyncFailed(e.to_string()))?; - - // Step 3: Mark found (spent) nullifiers in store - let mut store = self.store.write().await; + // Aggregate unspent nullifiers across accounts so we hit + // the SDK once, then route the `found` results back to + // the right subwallet via a position lookup. + struct AccountUnspent { + id: SubwalletId, + nullifiers: Vec<[u8; 32]>, + checkpoint: Option, + } - let mut spent_count = 0usize; - for nf_bytes in &result.found { - let was_unspent = store - .mark_spent(nf_bytes) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; - if was_unspent { - spent_count += 1; + let per_account: Vec<(u32, AccountUnspent)> = { + let store = self.store.read().await; + let mut out = Vec::with_capacity(account_indices.len()); + for &account in &account_indices { + let id = self.subwallet_id(account); + let unspent = store + .get_unspent_notes(id) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + let nullifiers: Vec<[u8; 32]> = unspent.iter().map(|n| n.nullifier).collect(); + let checkpoint = store + .nullifier_checkpoint(id) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))? + .map(|(height, timestamp)| NullifierSyncCheckpoint { height, timestamp }); + out.push(( + account, + AccountUnspent { + id, + nullifiers, + checkpoint, + }, + )); } - } + out + }; - // Step 4: Update nullifier checkpoint - store - .set_nullifier_checkpoint(result.new_sync_height, result.new_sync_timestamp) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + let mut newly_spent: BTreeMap = BTreeMap::new(); + let mut changeset = ShieldedChangeSet::default(); + for ( + account, + AccountUnspent { + id, + nullifiers, + checkpoint, + }, + ) in per_account + { + if nullifiers.is_empty() { + continue; + } + debug!( + account, + checking = nullifiers.len(), + ?checkpoint, + "Checking nullifiers" + ); + let result = self + .sdk + .sync_nullifiers(&nullifiers, None::, checkpoint) + .await + .map_err(|e| PlatformWalletError::ShieldedNullifierSyncFailed(e.to_string()))?; + + let mut store = self.store.write().await; + let mut spent_count = 0usize; + for nf_bytes in &result.found { + if store + .mark_spent(id, nf_bytes) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))? + { + changeset.record_nullifier_spent(id, *nf_bytes); + spent_count += 1; + } + } + store + .set_nullifier_checkpoint(id, result.new_sync_height, result.new_sync_timestamp) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + changeset.record_nullifier_checkpoint( + id, + result.new_sync_height, + result.new_sync_timestamp, + ); - if spent_count > 0 { - info!("{} note(s) newly detected as spent", spent_count); + if spent_count > 0 { + newly_spent.insert(account, spent_count); + info!(account, spent_count, "Notes newly detected as spent"); + } } + self.queue_shielded_changeset(changeset); - Ok(spent_count) + Ok(newly_spent) } - /// Full sync: notes + nullifiers + balance. - /// - /// Performs note sync first to discover new notes, then checks nullifiers - /// to detect spent notes, and finally computes the current balance. + /// Full sync: notes + nullifiers + per-account balance summary. pub async fn sync(&self) -> Result { - // Sync notes first let notes_result = self.sync_notes().await?; - - // Then check nullifiers - let newly_spent = self.check_nullifiers().await?; - - // Compute balance - let balance = self.balance().await?; - + let newly_spent_per_account = self.check_nullifiers().await?; + let balances = self.balances().await?; Ok(ShieldedSyncSummary { notes_result, - newly_spent, - balance, + newly_spent_per_account, + balances, }) } } +/// One decrypted note discovered during a sync pass. +#[derive(Clone)] +struct DiscoveredNote { + position: u64, + cmx: [u8; 32], + note: OrchardNote, +} + +// Suppress dead_code on `address` field — kept for future use +// (e.g. surfacing diversifier index per discovered note). +#[allow(dead_code)] +fn _unused_payment_address(_pa: PaymentAddress) {} + /// Serialize an Orchard note to bytes for storage. /// /// Format: `recipient(43) || value(8 LE) || rho(32) || rseed(32)` = 115 bytes. -/// /// Must be kept in sync with `deserialize_note()` in operations.rs. fn serialize_note(note: &grovedb_commitment_tree::Note) -> Vec { let mut data = Vec::with_capacity(115); diff --git a/packages/rs-sdk/src/platform/types/shielded.rs b/packages/rs-sdk/src/platform/types/shielded.rs index 38b41f0ab4b..64a12538127 100644 --- a/packages/rs-sdk/src/platform/types/shielded.rs +++ b/packages/rs-sdk/src/platform/types/shielded.rs @@ -3,7 +3,9 @@ use crate::platform::fetch_current_no_parameters::FetchCurrent; use crate::{platform::Fetch, Error, Sdk}; use async_trait::async_trait; use dapi_grpc::platform::v0::{Proof, ResponseMetadata}; -use drive_proof_verifier::types::{NoParamQuery, ShieldedAnchors, ShieldedPoolState}; +use drive_proof_verifier::types::{ + MostRecentShieldedAnchor, NoParamQuery, ShieldedAnchors, ShieldedPoolState, +}; #[async_trait] impl FetchCurrent for ShieldedPoolState { @@ -60,3 +62,35 @@ impl FetchCurrent for ShieldedAnchors { )) } } + +#[async_trait] +impl FetchCurrent for MostRecentShieldedAnchor { + async fn fetch_current(sdk: &Sdk) -> Result { + let (anchor, _) = Self::fetch_current_with_metadata(sdk).await?; + Ok(anchor) + } + + async fn fetch_current_with_metadata(sdk: &Sdk) -> Result<(Self, ResponseMetadata), Error> { + let (anchor, metadata) = Self::fetch_with_metadata(sdk, NoParamQuery {}, None).await?; + Ok(( + anchor.ok_or(Error::Generic( + "most recent shielded anchor not set".to_string(), + ))?, + metadata, + )) + } + + async fn fetch_current_with_metadata_and_proof( + sdk: &Sdk, + ) -> Result<(Self, ResponseMetadata, Proof), Error> { + let (anchor, metadata, proof) = + Self::fetch_with_metadata_and_proof(sdk, NoParamQuery {}, None).await?; + Ok(( + anchor.ok_or(Error::Generic( + "most recent shielded anchor not set".to_string(), + ))?, + metadata, + proof, + )) + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift index 05cf840783f..e57dc1e6b1e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift @@ -69,6 +69,13 @@ public enum SDKLogger { } public static func error(_ message: String) { + // Route through both `NSLog` (unified log — Console.app, device + // console, Xcode debug area without depending on stdout + // capture) and `Swift.print` (stdout — preserves the existing + // dev-loop behaviour where `print` output is what's visible). + // Errors are rare; double-emit is fine and makes them harder + // to miss when something does go wrong. + NSLog("%@", message) Swift.print(message) } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index 2623a53f3e1..cc44e0b53d5 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -28,7 +28,9 @@ public enum DashModelContainer { PersistentTransaction.self, PersistentTxo.self, PersistentPendingInput.self, - PersistentWalletManagerMetadata.self + PersistentWalletManagerMetadata.self, + PersistentShieldedNote.self, + PersistentShieldedSyncState.self ] } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedNote.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedNote.swift new file mode 100644 index 00000000000..527aebc084d --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedNote.swift @@ -0,0 +1,73 @@ +import Foundation +import SwiftData + +/// SwiftData row for one decrypted shielded (Orchard) note owned by +/// a specific subwallet. +/// +/// Mirrors `platform_wallet::changeset::ShieldedChangeSet::notes_saved` +/// from the Rust side. The persister callback writes one row per +/// `(walletId, accountIndex, position)` and re-saves with the same +/// nullifier overwrite the existing row in place — Orchard +/// nullifiers are globally unique, so repeated discovery of the +/// same note (e.g. after a re-sync) shouldn't double-count. +/// +/// On cold start the matching `loadShieldedNotes` callback streams +/// every row back to Rust so `ShieldedWallet::restore_from_snapshot` +/// can rehydrate `SubwalletState.notes` before the first sync runs. +@Model +public final class PersistentShieldedNote { + /// Index `(walletId, accountIndex)` so per-subwallet balance + /// scans hit an index instead of the full table. + #Index([\.walletId, \.accountIndex]) + + /// 32-byte wallet identifier (matches `PersistentWallet.walletId`). + public var walletId: Data + /// ZIP-32 account index inside the wallet. + public var accountIndex: UInt32 + /// Global commitment-tree position. + public var position: UInt64 + /// Note commitment (32 bytes). + public var cmx: Data + /// Spending nullifier (32 bytes). Unique across the table — + /// Orchard nullifiers are globally unique, so making this the + /// upsert key prevents double-counts on re-sync. + @Attribute(.unique) public var nullifier: Data + /// Block height this note was first observed at. + public var blockHeight: UInt64 + /// Whether the nullifier has been observed as spent on-chain. + public var isSpent: Bool + /// Note value in credits. + public var value: UInt64 + /// Serialized `orchard::Note` bytes (115 bytes: + /// `recipient(43) || value(8 LE) || rho(32) || rseed(32)`). + public var noteData: Data + + /// Insertion timestamps. + public var createdAt: Date + public var lastUpdated: Date + + public init( + walletId: Data, + accountIndex: UInt32, + position: UInt64, + cmx: Data, + nullifier: Data, + blockHeight: UInt64, + isSpent: Bool, + value: UInt64, + noteData: Data + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.position = position + self.cmx = cmx + self.nullifier = nullifier + self.blockHeight = blockHeight + self.isSpent = isSpent + self.value = value + self.noteData = noteData + let now = Date() + self.createdAt = now + self.lastUpdated = now + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedSyncState.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedSyncState.swift new file mode 100644 index 00000000000..0676fd7b996 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedSyncState.swift @@ -0,0 +1,55 @@ +import Foundation +import SwiftData + +/// SwiftData row for per-subwallet shielded sync watermarks. +/// +/// Mirrors `platform_wallet::changeset::ShieldedChangeSet::synced_indices` +/// + `nullifier_checkpoints` from the Rust side. One row per +/// `(walletId, accountIndex)`. Updated via the +/// `on_persist_shielded_synced_indices_fn` and +/// `on_persist_shielded_nullifier_checkpoints_fn` FFI callbacks; +/// streamed back to Rust on cold start via +/// `on_load_shielded_sync_states_fn` so the rehydrated +/// `SubwalletState` resumes incremental sync from where it left off. +@Model +public final class PersistentShieldedSyncState { + /// Composite uniqueness on `(walletId, accountIndex)` — at + /// most one watermark row per subwallet. + #Unique([\.walletId, \.accountIndex]) + #Index([\.walletId]) + + public var walletId: Data + public var accountIndex: UInt32 + /// Highest global commitment-tree index that the subwallet has scanned. + public var lastSyncedIndex: UInt64 + /// Whether the optional `(height, timestamp)` nullifier + /// checkpoint is populated. SwiftData predicate compilation is + /// finicky around chained optionals; an explicit `Bool` flag + /// keeps the watermark-restore query simple. + public var hasNullifierCheckpoint: Bool + /// Block height of the most recent nullifier sync pass. + /// Meaningful iff `hasNullifierCheckpoint == true`. + public var nullifierCheckpointHeight: UInt64 + /// Block timestamp (Unix seconds) of the most recent pass. + /// Meaningful iff `hasNullifierCheckpoint == true`. + public var nullifierCheckpointTimestamp: UInt64 + + public var lastUpdated: Date + + public init( + walletId: Data, + accountIndex: UInt32, + lastSyncedIndex: UInt64 = 0, + hasNullifierCheckpoint: Bool = false, + nullifierCheckpointHeight: UInt64 = 0, + nullifierCheckpointTimestamp: UInt64 = 0 + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.lastSyncedIndex = lastSyncedIndex + self.hasNullifierCheckpoint = hasNullifierCheckpoint + self.nullifierCheckpointHeight = nullifierCheckpointHeight + self.nullifierCheckpointTimestamp = nullifierCheckpointTimestamp + self.lastUpdated = Date() + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index 1b739d1145c..a3694cbba67 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -54,20 +54,26 @@ extension PlatformWalletManager { /// Derive Orchard keys for `walletId` from the host-side mnemonic /// resolver, open or create the per-network commitment tree at - /// `dbPath`, and bind the resulting shielded sub-wallet to the - /// `PlatformWallet`. + /// `dbPath`, and bind the resulting multi-account shielded + /// sub-wallet to the `PlatformWallet`. + /// + /// `accounts` is the list of ZIP-32 account indices to derive. + /// Pass `[0]` for the single-account default; pass + /// `[0, 1, …]` to bind multiple accounts up front. Each entry + /// produces an independent FVK / IVK / OVK / default address; + /// notes are scoped per-`(walletId, accountIndex)` inside the + /// store. Must be non-empty and at most 64 entries. /// /// The resolver is fired exactly once. The mnemonic and the /// derived seed live in zeroized buffers on the Rust side and - /// are scrubbed before this call returns; only the FVK / IVK / - /// OVK / default address survive on the wallet handle. + /// are scrubbed before this call returns. /// - /// Idempotent: calling again with a different account or - /// `dbPath` replaces the previously-bound shielded wallet. + /// Idempotent: calling again replaces the previously-bound + /// shielded wallet. public func bindShielded( walletId: Data, resolver: MnemonicResolver, - account: UInt32 = 0, + accounts: [UInt32] = [0], dbPath: String ) throws { guard isConfigured, handle != NULL_HANDLE else { @@ -80,6 +86,11 @@ extension PlatformWalletManager { "walletId must be exactly 32 bytes" ) } + guard !accounts.isEmpty else { + throw PlatformWalletError.invalidParameter( + "accounts must be non-empty" + ) + } guard let resolverHandle = resolver.handle else { throw PlatformWalletError.invalidParameter( "MnemonicResolver has no handle" @@ -92,14 +103,22 @@ extension PlatformWalletManager { else { throw PlatformWalletError.invalidParameter("walletId baseAddress is nil") } - try dbPath.withCString { dbPathPtr in - try platform_wallet_manager_bind_shielded( - handle, - walletIdPtr, - resolverHandle, - account, - dbPathPtr - ).check() + try accounts.withUnsafeBufferPointer { accountsBuf in + guard let accountsPtr = accountsBuf.baseAddress else { + throw PlatformWalletError.invalidParameter( + "accounts baseAddress is nil" + ) + } + try dbPath.withCString { dbPathPtr in + try platform_wallet_manager_bind_shielded( + handle, + walletIdPtr, + resolverHandle, + accountsPtr, + UInt(accountsBuf.count), + dbPathPtr + ).check() + } } } } @@ -186,15 +205,18 @@ extension PlatformWalletManager { }.value } - /// Read the default Orchard payment address for `walletId` as - /// the 43 raw bytes. Returns `nil` when the wallet exists on - /// the manager but has no bound shielded sub-wallet (i.e. - /// [`bindShielded`] hasn't run, or it failed). Throws when the - /// wallet id isn't known to the manager. + /// Read the default Orchard payment address for `account` on + /// `walletId` as the 43 raw bytes. Returns `nil` when the + /// wallet exists on the manager but has no bound shielded + /// sub-wallet, or `account` isn't bound on it. Throws when + /// the wallet id isn't known to the manager. /// - /// The host is responsible for bech32m-encoding the result for - /// display (HRP `dash` / `tdash` + `0x10` type byte). - public func shieldedDefaultAddress(walletId: Data) throws -> Data? { + /// The host is responsible for bech32m-encoding the result + /// for display (HRP `dash` / `tdash` + `0x10` type byte). + public func shieldedDefaultAddress( + walletId: Data, + account: UInt32 = 0 + ) throws -> Data? { guard isConfigured, handle != NULL_HANDLE else { throw PlatformWalletError.invalidHandle( "PlatformWalletManager not configured" @@ -221,6 +243,7 @@ extension PlatformWalletManager { try platform_wallet_manager_shielded_default_address( handle, ptr, + account, outPtr, &present ).check() @@ -229,6 +252,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 345ff652779..e35d80ab337 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -781,6 +781,15 @@ public class PlatformWalletPersistenceHandler { cb.on_persist_identity_keys_fn = persistIdentityKeysCallback cb.on_persist_token_balances_fn = persistTokenBalancesCallback cb.on_persist_contacts_fn = persistContactsCallback + cb.on_persist_shielded_notes_fn = persistShieldedNotesCallback + cb.on_persist_shielded_nullifiers_spent_fn = persistShieldedNullifiersSpentCallback + cb.on_persist_shielded_synced_indices_fn = persistShieldedSyncedIndicesCallback + cb.on_persist_shielded_nullifier_checkpoints_fn = + persistShieldedNullifierCheckpointsCallback + cb.on_load_shielded_notes_fn = loadShieldedNotesCallback + cb.on_load_shielded_notes_free_fn = loadShieldedNotesFreeCallback + cb.on_load_shielded_sync_states_fn = loadShieldedSyncStatesCallback + cb.on_load_shielded_sync_states_free_fn = loadShieldedSyncStatesFreeCallback return cb } @@ -2002,6 +2011,311 @@ public class PlatformWalletPersistenceHandler { // MARK: - Watch-only Restore: Wallet Metadata + // MARK: - Shielded persistence (Orchard) + + /// One incoming shielded-note row from + /// `ShieldedChangeSet::notes_saved`. Decoupled from + /// `ShieldedNoteFFI` so the trampoline can copy bytes out + /// before this method runs on `onQueue`. + struct ShieldedNoteSnapshot { + let walletId: Data + let accountIndex: UInt32 + let position: UInt64 + let cmx: Data + let nullifier: Data + let blockHeight: UInt64 + let isSpent: Bool + let value: UInt64 + let noteData: Data + } + + /// Upsert a batch of decrypted shielded notes by `nullifier`. + /// Re-saves with the same nullifier overwrite the existing + /// row in place — Orchard nullifiers are globally unique. + func persistShieldedNotes(walletId: Data, snapshots: [ShieldedNoteSnapshot]) { + onQueue { + for snap in snapshots { + let nf = snap.nullifier + let predicate = #Predicate { $0.nullifier == nf } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + if let existing = try? backgroundContext.fetch(descriptor).first { + existing.walletId = snap.walletId + existing.accountIndex = snap.accountIndex + existing.position = snap.position + existing.cmx = snap.cmx + existing.blockHeight = snap.blockHeight + existing.isSpent = snap.isSpent + existing.value = snap.value + existing.noteData = snap.noteData + existing.lastUpdated = Date() + } else { + let row = PersistentShieldedNote( + walletId: snap.walletId, + accountIndex: snap.accountIndex, + position: snap.position, + cmx: snap.cmx, + nullifier: snap.nullifier, + blockHeight: snap.blockHeight, + isSpent: snap.isSpent, + value: snap.value, + noteData: snap.noteData + ) + backgroundContext.insert(row) + } + } + if !self.inChangeset { try? backgroundContext.save() } + } + } + + /// Mark notes as spent by nullifier. + func persistShieldedNullifiersSpent( + walletId: Data, + entries: [(walletId: Data, accountIndex: UInt32, nullifier: Data)] + ) { + onQueue { + for entry in entries { + let nf = entry.nullifier + let predicate = #Predicate { $0.nullifier == nf } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + if let row = try? backgroundContext.fetch(descriptor).first { + if !row.isSpent { + row.isSpent = true + row.lastUpdated = Date() + } + } + } + if !self.inChangeset { try? backgroundContext.save() } + } + } + + /// Upsert per-subwallet sync watermarks. + func persistShieldedSyncedIndices( + walletId: Data, + entries: [(walletId: Data, accountIndex: UInt32, lastSyncedIndex: UInt64)] + ) { + onQueue { + for entry in entries { + let row = ensureShieldedSyncStateRow( + walletId: entry.walletId, + accountIndex: entry.accountIndex + ) + if entry.lastSyncedIndex > row.lastSyncedIndex { + row.lastSyncedIndex = entry.lastSyncedIndex + } + row.lastUpdated = Date() + } + if !self.inChangeset { try? backgroundContext.save() } + } + } + + /// Upsert per-subwallet nullifier-sync checkpoints. + func persistShieldedNullifierCheckpoints( + walletId: Data, + entries: [(walletId: Data, accountIndex: UInt32, height: UInt64, timestamp: UInt64)] + ) { + onQueue { + for entry in entries { + let row = ensureShieldedSyncStateRow( + walletId: entry.walletId, + accountIndex: entry.accountIndex + ) + row.hasNullifierCheckpoint = true + row.nullifierCheckpointHeight = entry.height + row.nullifierCheckpointTimestamp = entry.timestamp + row.lastUpdated = Date() + } + if !self.inChangeset { try? backgroundContext.save() } + } + } + + /// Fetch-or-create a `PersistentShieldedSyncState` row for + /// `(walletId, accountIndex)`. Caller must be on `onQueue`. + private func ensureShieldedSyncStateRow( + walletId: Data, + accountIndex: UInt32 + ) -> PersistentShieldedSyncState { + let predicate = #Predicate { row in + row.walletId == walletId && row.accountIndex == accountIndex + } + var descriptor = FetchDescriptor(predicate: predicate) + descriptor.fetchLimit = 1 + if let row = try? backgroundContext.fetch(descriptor).first { + return row + } + let row = PersistentShieldedSyncState( + walletId: walletId, + accountIndex: accountIndex + ) + backgroundContext.insert(row) + return row + } + + /// Build the host-allocated `ShieldedNoteRestoreFFI` array Rust + /// reads at boot. The allocation is tracked in + /// `shieldedLoadAllocations` and freed by + /// `loadShieldedNotesFree` once Rust hands the pointer back. + func loadShieldedNotes() -> ( + entries: UnsafePointer?, + count: Int, + errored: Bool + ) { + var resultEntries: UnsafePointer? + var resultCount: Int = 0 + var resultErrored = false + onQueue { + let descriptor = FetchDescriptor() + let rows: [PersistentShieldedNote] + do { + rows = try backgroundContext.fetch(descriptor) + } catch { + resultErrored = true + return + } + if rows.isEmpty { + return + } + let allocation = ShieldedLoadAllocation() + // Allocate the entries buffer up front; populate slots + // one by one and track `entriesInitialized` so a + // mid-loop bail-out can deinit only the populated + // slots. (Today nothing fails in this loop, but + // matching the existing `LoadAllocation` pattern keeps + // future field additions safe.) + let buf = UnsafeMutablePointer.allocate(capacity: rows.count) + allocation.entries = buf + allocation.entriesCount = rows.count + for (idx, row) in rows.enumerated() { + guard row.walletId.count == 32 else { continue } + guard row.cmx.count == 32 else { continue } + guard row.nullifier.count == 32 else { continue } + let noteDataBuf = UnsafeMutablePointer.allocate(capacity: row.noteData.count) + row.noteData.copyBytes(to: noteDataBuf, count: row.noteData.count) + allocation.scalarBuffers.append((noteDataBuf, row.noteData.count)) + + var walletIdTuple: FFIByteTuple32 = (0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0) + row.walletId.withUnsafeBytes { src in + Swift.withUnsafeMutableBytes(of: &walletIdTuple) { dst in + dst.copyMemory(from: src) + } + } + var cmxTuple: FFIByteTuple32 = (0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0) + row.cmx.withUnsafeBytes { src in + Swift.withUnsafeMutableBytes(of: &cmxTuple) { dst in + dst.copyMemory(from: src) + } + } + var nullifierTuple: FFIByteTuple32 = (0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0) + row.nullifier.withUnsafeBytes { src in + Swift.withUnsafeMutableBytes(of: &nullifierTuple) { dst in + dst.copyMemory(from: src) + } + } + buf[idx] = ShieldedNoteRestoreFFI( + wallet_id: walletIdTuple, + account_index: row.accountIndex, + position: row.position, + cmx: cmxTuple, + nullifier: nullifierTuple, + block_height: row.blockHeight, + is_spent: row.isSpent ? 1 : 0, + value: row.value, + note_data_ptr: UnsafePointer(noteDataBuf), + note_data_len: UInt(row.noteData.count) + ) + allocation.entriesInitialized += 1 + } + let entriesPtr = UnsafePointer(buf) + shieldedLoadAllocations[UnsafeRawPointer(entriesPtr)] = allocation + resultEntries = entriesPtr + resultCount = allocation.entriesInitialized + } + return (resultEntries, resultCount, resultErrored) + } + + func loadShieldedNotesFree(entries: UnsafeRawPointer?) { + onQueue { + guard let entries = entries, + let allocation = shieldedLoadAllocations.removeValue(forKey: entries) else { + return + } + allocation.release() + } + } + + /// Build the host-allocated `ShieldedSubwalletSyncStateFFI` + /// array Rust reads at boot. Same allocation pattern as + /// `loadShieldedNotes`. + func loadShieldedSyncStates() -> ( + entries: UnsafePointer?, + count: Int, + errored: Bool + ) { + var resultEntries: UnsafePointer? + var resultCount: Int = 0 + var resultErrored = false + onQueue { + let descriptor = FetchDescriptor() + let rows: [PersistentShieldedSyncState] + do { + rows = try backgroundContext.fetch(descriptor) + } catch { + resultErrored = true + return + } + if rows.isEmpty { + return + } + let allocation = ShieldedSyncStateLoadAllocation() + let buf = UnsafeMutablePointer.allocate( + capacity: rows.count + ) + allocation.entries = buf + allocation.entriesCount = rows.count + for (idx, row) in rows.enumerated() { + guard row.walletId.count == 32 else { continue } + var walletIdTuple: FFIByteTuple32 = (0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0) + row.walletId.withUnsafeBytes { src in + Swift.withUnsafeMutableBytes(of: &walletIdTuple) { dst in + dst.copyMemory(from: src) + } + } + buf[idx] = ShieldedSubwalletSyncStateFFI( + wallet_id: walletIdTuple, + account_index: row.accountIndex, + last_synced_index: row.lastSyncedIndex, + has_nullifier_checkpoint: row.hasNullifierCheckpoint ? 1 : 0, + nullifier_checkpoint_height: row.nullifierCheckpointHeight, + nullifier_checkpoint_timestamp: row.nullifierCheckpointTimestamp + ) + allocation.entriesInitialized += 1 + } + let entriesPtr = UnsafePointer(buf) + shieldedSyncStateLoadAllocations[UnsafeRawPointer(entriesPtr)] = allocation + resultEntries = entriesPtr + resultCount = allocation.entriesInitialized + } + return (resultEntries, resultCount, resultErrored) + } + + func loadShieldedSyncStatesFree(entries: UnsafeRawPointer?) { + onQueue { + guard let entries = entries, + let allocation = shieldedSyncStateLoadAllocations.removeValue(forKey: entries) + else { + return + } + allocation.release() + } + } + + /// Outstanding shielded-load allocations keyed by the entries + /// pointer we handed Rust. Drained by `loadShieldedNotesFree`. + private var shieldedLoadAllocations: [UnsafeRawPointer: ShieldedLoadAllocation] = [:] + private var shieldedSyncStateLoadAllocations: + [UnsafeRawPointer: ShieldedSyncStateLoadAllocation] = [:] + /// Set network + birth height on the `PersistentWallet` row. Fires /// once at wallet registration with values the Rust side can /// contribute but Swift can't easily recompute (network is on the @@ -2878,6 +3192,47 @@ private final class LoadAllocation { } } +/// Allocation tracker for `loadShieldedNotes` — the entries +/// buffer plus per-row `note_data` byte buffers. +private final class ShieldedLoadAllocation { + var entries: UnsafeMutablePointer? + var entriesCount: Int = 0 + var entriesInitialized: Int = 0 + /// Per-row `note_data` byte buffers; each entry's + /// `note_data_ptr` references one of these. + var scalarBuffers: [(UnsafeMutablePointer, Int)] = [] + + func release() { + if let entries = entries { + if entriesInitialized > 0 { + entries.deinitialize(count: entriesInitialized) + } + entries.deallocate() + } + for (ptr, _) in scalarBuffers { + ptr.deallocate() + } + } +} + +/// Allocation tracker for `loadShieldedSyncStates`. No nested +/// buffers — every field is plain-data — so this is just the +/// entries buffer. +private final class ShieldedSyncStateLoadAllocation { + var entries: UnsafeMutablePointer? + var entriesCount: Int = 0 + var entriesInitialized: Int = 0 + + func release() { + if let entries = entries { + if entriesInitialized > 0 { + entries.deinitialize(count: entriesInitialized) + } + entries.deallocate() + } + } +} + /// Copy bytes from `src` into a fixed-size C-tuple field. Swift /// imports `u8[N]` as an N-tuple — identical memory layout, so /// `withUnsafeMutableBytes` gives us a contiguous write window of @@ -3555,3 +3910,192 @@ private func persistWalletMetadataCallback( ) return 0 } + +// MARK: - Shielded persistence (Orchard) +// +// Mirror of the four `on_persist_shielded_*_fn` callbacks declared +// in `rs-platform-wallet-ffi/src/persistence.rs` plus the matching +// load callbacks used at boot to rehydrate `SubwalletState`s. + +private func persistShieldedNotesCallback( + context: UnsafeMutableRawPointer?, + walletIdPtr: UnsafePointer?, + entriesPtr: UnsafePointer?, + count: UInt +) -> Int32 { + guard let context = context, let walletIdPtr = walletIdPtr else { return 0 } + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + let walletId = Data(bytes: walletIdPtr, count: 32) + + var snapshots: [PlatformWalletPersistenceHandler.ShieldedNoteSnapshot] = [] + if count > 0, let entriesPtr = entriesPtr { + snapshots.reserveCapacity(Int(count)) + for i in 0.. 0 { + noteData = Data(bytes: dataPtr, count: Int(e.note_data_len)) + } else { + noteData = Data() + } + snapshots.append(.init( + walletId: dataFromTuple32(e.wallet_id), + accountIndex: e.account_index, + position: e.position, + cmx: dataFromTuple32(e.cmx), + nullifier: dataFromTuple32(e.nullifier), + blockHeight: e.block_height, + isSpent: e.is_spent != 0, + value: e.value, + noteData: noteData + )) + } + } + handler.persistShieldedNotes(walletId: walletId, snapshots: snapshots) + return 0 +} + +private func persistShieldedNullifiersSpentCallback( + context: UnsafeMutableRawPointer?, + walletIdPtr: UnsafePointer?, + entriesPtr: UnsafePointer?, + count: UInt +) -> Int32 { + guard let context = context, let walletIdPtr = walletIdPtr else { return 0 } + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + let walletId = Data(bytes: walletIdPtr, count: 32) + + var entries: [(walletId: Data, accountIndex: UInt32, nullifier: Data)] = [] + if count > 0, let entriesPtr = entriesPtr { + entries.reserveCapacity(Int(count)) + for i in 0..?, + entriesPtr: UnsafePointer?, + count: UInt +) -> Int32 { + guard let context = context, let walletIdPtr = walletIdPtr else { return 0 } + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + let walletId = Data(bytes: walletIdPtr, count: 32) + + var entries: [(walletId: Data, accountIndex: UInt32, lastSyncedIndex: UInt64)] = [] + if count > 0, let entriesPtr = entriesPtr { + entries.reserveCapacity(Int(count)) + for i in 0..?, + entriesPtr: UnsafePointer?, + count: UInt +) -> Int32 { + guard let context = context, let walletIdPtr = walletIdPtr else { return 0 } + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + let walletId = Data(bytes: walletIdPtr, count: 32) + + var entries: [(walletId: Data, accountIndex: UInt32, height: UInt64, timestamp: UInt64)] = [] + if count > 0, let entriesPtr = entriesPtr { + entries.reserveCapacity(Int(count)) + for i in 0..?>?, + outCount: UnsafeMutablePointer? +) -> Int32 { + guard let context = context, let outEntries = outEntries, let outCount = outCount else { + return 1 + } + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + let (entries, count, errored) = handler.loadShieldedNotes() + outEntries.pointee = entries + outCount.pointee = UInt(count) + return errored ? 1 : 0 +} + +private func loadShieldedNotesFreeCallback( + context: UnsafeMutableRawPointer?, + entries: UnsafePointer?, + _ count: UInt +) { + guard let context = context else { return } + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + handler.loadShieldedNotesFree(entries: entries.map(UnsafeRawPointer.init)) +} + +private func loadShieldedSyncStatesCallback( + context: UnsafeMutableRawPointer?, + outEntries: UnsafeMutablePointer?>?, + outCount: UnsafeMutablePointer? +) -> Int32 { + guard let context = context, let outEntries = outEntries, let outCount = outCount else { + return 1 + } + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + let (entries, count, errored) = handler.loadShieldedSyncStates() + outEntries.pointee = entries + outCount.pointee = UInt(count) + return errored ? 1 : 0 +} + +private func loadShieldedSyncStatesFreeCallback( + context: UnsafeMutableRawPointer?, + entries: UnsafePointer?, + _ count: UInt +) { + guard let context = context else { return } + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + handler.loadShieldedSyncStatesFree(entries: entries.map(UnsafeRawPointer.init)) +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift index 9528791342d..b2869dd3c23 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift @@ -13,6 +13,7 @@ struct ContentView: View { let onRetry: () -> Void @EnvironmentObject var walletManager: PlatformWalletManager + @EnvironmentObject var walletManagerStore: WalletManagerStore @EnvironmentObject var appUIState: AppUIState @EnvironmentObject var platformState: AppState @Environment(\.modelContext) private var modelContext @@ -272,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." @@ -279,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) } } @@ -290,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 @@ -323,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 } } @@ -335,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 @@ -389,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 @@ -427,8 +458,48 @@ struct ContentView: View { entry.network ?? metadata?.resolvedNetworks.first ?? platformState.currentNetwork let restoredBirthHeight = metadata?.birthHeight + // Route the create call through the wallet's + // intended-network manager, NOT the user's currently-active + // one. See the prior fix's commit message for the full + // rationale; the short version is that the active manager's + // persistence handler pins SwiftData rows to its own + // network, so a regtest wallet recovered while the user is + // looking at testnet would land on testnet without this. + // + // SDK init splits out from the manager get-or-create so + // local-only networks (regtest / devnet) surface a clear + // "is your local stack running?" hint when SDK creation + // fails — those networks talk to a local quorum sidecar + // (typically `localhost:22444`) and reject SDK creation + // when it isn't reachable, while public networks + // (testnet / mainnet) hit always-on remote endpoints. + let networkSdk: SDK + do { + networkSdk = try SDK(network: restoredNetwork) + } catch { + let hint = (restoredNetwork == .regtest || restoredNetwork == .devnet) + ? " — is your local \(restoredNetwork.displayName) stack running?" + : "" + let message = "\"\(entry.displayName)\" (\(restoredNetwork.displayName)): " + + "failed to spin up SDK — \(error.localizedDescription)\(hint)" + SDKLogger.error("Recovery: \(message) (raw: \(error))") + return message + } + let recoveryManager: PlatformWalletManager + do { + recoveryManager = try walletManagerStore.getOrCreateManager( + network: restoredNetwork, + sdk: networkSdk + ) + } catch { + 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 walletManager.createWallet( + let managed = try recoveryManager.createWallet( mnemonic: mnemonic, network: restoredNetwork, name: restoredName @@ -452,10 +523,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 d6a7ed66f81..45bc2aef058 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -57,11 +57,22 @@ class ShieldedService: ObservableObject { /// pass. @Published var lastError: String? - /// Bech32m-encoded Orchard payment address. Currently a - /// placeholder — the manager doesn't expose the per-wallet - /// address yet (defer until bundle building lands). + /// Bech32m-encoded Orchard payment address for account 0. + /// Kept for the existing Receive sheet which is still + /// single-account; multi-account-aware UI uses + /// `addressesByAccount` instead. @Published var orchardDisplayAddress: String? + /// Bound shielded ZIP-32 accounts, in ascending order. Driven + /// by `bind` — every entry of `accounts:` becomes a row here. + @Published var boundAccounts: [UInt32] = [] + + /// Bech32m-encoded Orchard payment address per bound account. + /// Populated alongside `boundAccounts` from per-account + /// `shieldedDefaultAddress` calls. Empty for accounts that + /// failed to bind. + @Published var addressesByAccount: [UInt32: String] = [:] + // MARK: - Internals /// Wallet manager whose shielded sync events we mirror. @@ -70,6 +81,16 @@ class ShieldedService: ObservableObject { /// Wallet id we filter sync results by. private var walletId: Data? + /// Network of the currently-bound wallet. Stashed so + /// `switchTo(walletId:)` can reach the right per-network + /// dbPath without re-plumbing it from the call site. + private var network: Network? + + /// Mnemonic resolver stashed from the first `bind`. Reused by + /// `switchTo(walletId:)` so detail views can rebind without + /// pulling a fresh resolver out of the SwiftUI environment. + private var resolver: MnemonicResolver? + /// Subscription to `walletManager.$shieldedSyncIsSyncing`. private var syncStateCancellable: AnyCancellable? @@ -80,8 +101,15 @@ class ShieldedService: ObservableObject { /// Bind the service to a wallet. Drives `bindShielded` on the /// Rust side first (resolver-driven mnemonic lookup, ZIP-32 - /// derivation, per-network commitment tree open) and then - /// subscribes to shielded sync events for `walletId`. + /// derivation per `accounts`, per-network commitment tree + /// open) and then subscribes to shielded sync events for + /// `walletId`. + /// + /// `accounts` is the list of ZIP-32 account indices to bind. + /// Defaults to `[0]` for the single-account default; pass + /// `[0, 1, …]` to bind multiple accounts up front. Each + /// gets its own subwallet bookkeeping inside the store; the + /// commitment tree is shared per network. /// /// Failure during the Rust-side bind sets `lastError`; the /// service continues to subscribe to events so a successful @@ -90,10 +118,13 @@ class ShieldedService: ObservableObject { walletManager: PlatformWalletManager, walletId: Data, network: Network, - resolver: MnemonicResolver + resolver: MnemonicResolver, + accounts: [UInt32] = [0] ) { self.walletManager = walletManager self.walletId = walletId + self.network = network + self.resolver = resolver self.syncStateCancellable?.cancel() self.syncEventCancellable?.cancel() @@ -113,35 +144,50 @@ class ShieldedService: ObservableObject { lastSyncTime = nil lastError = nil orchardDisplayAddress = nil + boundAccounts = [] + addressesByAccount = [:] syncCountSinceLaunch = 0 totalScanned = 0 totalNewNotes = 0 totalNewlySpent = 0 let dbPath = Self.dbPath(for: network) + let sortedAccounts = Array(Set(accounts)).sorted() do { try walletManager.bindShielded( walletId: walletId, resolver: resolver, - account: 0, + accounts: sortedAccounts, dbPath: dbPath ) isBound = true lastError = nil - - // Pull the default Orchard payment address now that bind - // succeeded so the Receive sheet has something to render - // before the first sync pass lands. Best-effort — - // failures here don't unbind the wallet. - if let raw = try? walletManager.shieldedDefaultAddress(walletId: walletId) { - 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 +207,40 @@ class ShieldedService: ObservableObject { } } + /// Re-bind the singleton service to a different wallet using the + /// `walletManager` / `resolver` / `network` stashed by the first + /// `bind(...)`. Per-detail-view code paths call this when the + /// user navigates into a wallet other than the one + /// `rebindWalletScopedServices()` initially selected — without + /// it, the published `shieldedBalance` stays pinned to the + /// first-bound wallet and every detail screen shows that + /// wallet's balance. + /// + /// No-op if the requested wallet is already bound. Logs and + /// returns early if `bind(...)` was never called yet. + func switchTo(walletId: Data) { + if self.walletId == walletId, isBound { + return + } + guard + let walletManager, + let resolver, + let network + else { + SDKLogger.log( + "ShieldedService.switchTo called before initial bind — ignoring", + minimumLevel: .medium + ) + return + } + bind( + walletManager: walletManager, + walletId: walletId, + network: network, + resolver: resolver + ) + } + /// Trigger a manual shielded sync pass. No-op if a pass is /// already in flight. /// @@ -205,6 +285,8 @@ class ShieldedService: ObservableObject { lastSyncTime = nil lastError = nil orchardDisplayAddress = nil + boundAccounts = [] + addressesByAccount = [:] syncCountSinceLaunch = 0 totalScanned = 0 totalNewNotes = 0 @@ -241,8 +323,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 d07dc7a7d06..230772215fa 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -89,13 +89,32 @@ class SendViewModel: ObservableObject { self.network = network } - var amount: UInt64? { + /// Parsed amount expressed in **L1 duffs** (1 DASH = 1e8). Right + /// for Core sends; *wrong* for Platform / shielded sends, which + /// use the credits scale (1 DASH = 1e11) instead. Use [`amountCredits`] + /// for those paths — picking duffs underpays them by 1000×. + var amountDuffs: UInt64? { guard let double = Double(amountString), double > 0 else { return nil } return UInt64(double * 100_000_000) } + /// Parsed amount expressed in Platform / shielded **credits** + /// (1 DASH = 1e11). Used for any flow that touches the credits + /// ledger (`platformToShielded`, `shieldedToShielded`, + /// `shieldedToPlatform`, `shieldedToCore`). + var amountCredits: UInt64? { + guard let double = Double(amountString), double > 0 else { return nil } + return UInt64(double * 100_000_000_000) + } + + /// Backwards-compatibility shim — the original `amount` property + /// always returned duffs, so any leftover call site that hasn't + /// switched to the unit-explicit pair stays correct for Core + /// flows. + var amount: UInt64? { amountDuffs } + var canSend: Bool { - detectedFlow != nil && amount != nil && !isSending + detectedFlow != nil && amountDuffs != nil && !isSending } /// Determine which fund sources are available based on destination and balances. @@ -158,13 +177,14 @@ class SendViewModel: ObservableObject { func executeSend( sdk: SDK, + walletManager: PlatformWalletManager, shieldedService: ShieldedService, platformState: AppState, wallet: PersistentWallet, coreWallet: ManagedCoreWallet?, modelContext: ModelContext ) async { - guard let flow = detectedFlow, let amount = amount else { return } + guard let flow = detectedFlow else { return } isSending = true error = nil @@ -174,33 +194,102 @@ class SendViewModel: ObservableObject { do { switch flow { case .coreToCore: + guard let amountDuffs else { + error = "Invalid amount" + return + } guard let core = coreWallet else { error = "Core wallet not available" return } let address = recipientAddress.trimmingCharacters(in: .whitespacesAndNewlines) let _ = try core.sendToAddresses( - recipients: [(address: address, amountDuffs: amount)] + recipients: [(address: address, amountDuffs: amountDuffs)] ) successMessage = "Payment sent" - case .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 + 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 8732d91423d..27a2a1ab014 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountListView.swift @@ -6,6 +6,7 @@ import SwiftData struct AccountListView: View { let wallet: PersistentWallet @EnvironmentObject var walletManager: PlatformWalletManager + @EnvironmentObject var shieldedService: ShieldedService @Query private var accounts: [PersistentAccount] @@ -53,9 +54,21 @@ struct AccountListView: View { return (group, account.accountType, account.standardTag, account.accountIndex) } + /// Bound shielded accounts to render in their own section + /// below the Core / Platform accounts. Empty until + /// `ShieldedService.bind` has populated the list — which + /// happens once per wallet detail open. + private var shieldedAccountsForThisWallet: [UInt32] { + // Filter by wallet id so navigating between wallet + // details doesn't briefly show the previous wallet's + // accounts before the singleton service rebinds. + guard shieldedService.boundAccounts.isEmpty == false else { return [] } + return shieldedService.boundAccounts + } + var body: some View { ZStack { - if accounts.isEmpty { + if accounts.isEmpty && shieldedAccountsForThisWallet.isEmpty { ContentUnavailableView( "No Accounts", systemImage: "folder", @@ -63,18 +76,36 @@ struct AccountListView: View { ) } else { let balances = walletManager.accountBalances(for: wallet.walletId) - List(orderedAccounts) { account in - NavigationLink(destination: AccountDetailView(wallet: wallet, account: account)) { - let match = balances.first { b in - UInt32(b.typeTag) == account.accountType && - b.standardTag == account.standardTag && - b.index == account.accountIndex + List { + if !accounts.isEmpty { + Section { + ForEach(orderedAccounts) { account in + NavigationLink( + destination: AccountDetailView(wallet: wallet, account: account) + ) { + let match = balances.first { b in + UInt32(b.typeTag) == account.accountType && + b.standardTag == account.standardTag && + b.index == account.accountIndex + } + AccountRowView( + account: account, + coreConfirmedBalance: match?.confirmed ?? 0, + coreUnconfirmedBalance: match?.unconfirmed ?? 0 + ) + } + } + } + } + if !shieldedAccountsForThisWallet.isEmpty { + Section("Shielded") { + ForEach(shieldedAccountsForThisWallet, id: \.self) { account in + ShieldedAccountRowView( + accountIndex: account, + address: shieldedService.addressesByAccount[account] + ) + } } - AccountRowView( - account: account, - coreConfirmedBalance: match?.confirmed ?? 0, - coreUnconfirmedBalance: match?.unconfirmed ?? 0 - ) } } .listStyle(.plain) @@ -83,6 +114,43 @@ struct AccountListView: View { } } +// MARK: - Shielded Account Row + +/// Compact row that mirrors `AccountRowView` for shielded ZIP-32 +/// accounts. There's no `PersistentShieldedAccount` SwiftData +/// model — bound accounts live on `ShieldedService.boundAccounts` +/// — so the row is purely a display projection of `(index, +/// address)`. +private struct ShieldedAccountRowView: View { + let accountIndex: UInt32 + let address: String? + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "lock.shield.fill") + .foregroundColor(.purple) + .font(.title3) + VStack(alignment: .leading, spacing: 2) { + Text("Shielded #\(accountIndex)") + .font(.subheadline) + .fontWeight(.medium) + if let address { + Text(address) + .font(.system(.caption2, design: .monospaced)) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } else { + Text("address not available") + .font(.caption2) + .foregroundColor(.secondary) + .italic() + } + } + } + } +} + // MARK: - Account Row View struct AccountRowView: View { let account: PersistentAccount diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift index 80ffe41816a..2e4b0a7b71b 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift @@ -169,6 +169,7 @@ struct SendTransactionView: View { .coreWallet() await viewModel.executeSend( sdk: sdk, + walletManager: walletManager, shieldedService: shieldedService, platformState: platformState, wallet: wallet, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index 1df76b683c0..f34dc79aaf4 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -22,6 +22,7 @@ struct WalletDetailView: View { @EnvironmentObject var walletManager: PlatformWalletManager @EnvironmentObject var platformState: AppState @EnvironmentObject var appUIState: AppUIState + @EnvironmentObject var shieldedService: ShieldedService @Environment(\.dismiss) private var dismiss let wallet: PersistentWallet @State private var showReceiveAddress = false @@ -176,7 +177,18 @@ struct WalletDetailView: View { dismiss() } } - .onAppear { appUIState.showWalletsSyncDetails = false } + .onAppear { + appUIState.showWalletsSyncDetails = false + // Repoint the singleton ShieldedService at THIS wallet — + // the app-level bind only attaches it to `firstWallet`, + // so without this every detail screen would show the + // first-bound wallet's shielded balance regardless of + // which wallet the user opened. + shieldedService.switchTo(walletId: wallet.walletId) + } + .onChange(of: wallet.walletId) { _, newId in + shieldedService.switchTo(walletId: newId) + } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index 05c619dd754..cc95f7666eb 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -107,6 +107,14 @@ struct SwiftExampleAppApp: App { // PlatformWalletManager` consumers see the right // network's manager without any view changes. .environmentObject(walletManager) + // Inject the store itself so flows that need to + // operate on a non-active network's manager + // (orphan-mnemonic recovery — wallets restored + // from keychain may belong to networks the user + // isn't currently looking at) can route through + // `getOrCreateManager(network:sdk:)` without + // flipping the user's active view. + .environmentObject(walletManagerStore) .environmentObject(shieldedService) .environmentObject(platformBalanceSyncService) .environmentObject(transitionState) @@ -239,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 6297ebdf2bf..459e32186f0 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift @@ -113,6 +113,16 @@ struct StorageExplorerView: View { modelRow("Manager Metadata", icon: "gearshape.2", type: PersistentWalletManagerMetadata.self) { WalletManagerMetadataStorageListView(network: network) } + modelRow("Shielded Notes", icon: "lock.shield", type: PersistentShieldedNote.self) { + ShieldedNoteStorageListView(network: network) + } + modelRow( + "Shielded Sync State", + icon: "arrow.triangle.2.circlepath", + type: PersistentShieldedSyncState.self + ) { + ShieldedSyncStateStorageListView(network: network) + } } .navigationTitle("Storage Explorer") .toolbar { @@ -249,6 +259,12 @@ struct StorageExplorerView: View { filteredCount(PersistentPendingInput.self) { walletsOnNetwork.contains($0.walletId) } + filteredCount(PersistentShieldedNote.self) { + walletsOnNetwork.contains($0.walletId) + } + filteredCount(PersistentShieldedSyncState.self) { + walletsOnNetwork.contains($0.walletId) + } // Core / Platform addresses partition the same family of // tables by account type, so they need their own counts. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift index 08c4e4c2f27..2fee807e856 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift @@ -1631,3 +1631,182 @@ struct WalletManagerMetadataStorageListView: View { .overlay { if visible.isEmpty { ContentUnavailableView("No Records", systemImage: "gearshape.2") } } } } + +// MARK: - PersistentShieldedNote + +/// Filter enum local to this view — mirrors the private one +/// inside `TxoStorageListView`. Both views need the same +/// "all / unspent / spent" segmented control; duplicating two +/// lines beats hoisting the private type to file scope and +/// touching the existing TXO view. +private enum ShieldedSpentFilter: CaseIterable, Hashable { + case all, unspent, spent + + var title: String { + switch self { + case .all: return "All" + case .unspent: return "Unspent" + case .spent: return "Spent" + } + } +} + +/// Read-only browser for the per-(wallet, account) decrypted +/// shielded notes the persister mirrors out of +/// `ShieldedChangeSet`. Scoped by the active network via the +/// denormalized `walletId` column on each row — same trick +/// `TxoStorageListView` uses. +struct ShieldedNoteStorageListView: View { + let network: Network + + /// Sort by block height (newest first), then position so + /// rows from the same block stay deterministic. + @Query( + sort: [ + SortDescriptor(\PersistentShieldedNote.blockHeight, order: .reverse), + SortDescriptor(\PersistentShieldedNote.position), + ] + ) + private var records: [PersistentShieldedNote] + + @Query private var allWallets: [PersistentWallet] + + private var walletIdsOnNetwork: Set { + Set(allWallets.lazy + .filter { $0.networkRaw == network.rawValue } + .map(\.walletId)) + } + + private var scopedRecords: [PersistentShieldedNote] { + let ids = walletIdsOnNetwork + return records.filter { ids.contains($0.walletId) } + } + + @State private var filter: ShieldedSpentFilter = .all + + private var filteredRecords: [PersistentShieldedNote] { + let scoped = scopedRecords + switch filter { + case .all: return scoped + case .unspent: return scoped.filter { !$0.isSpent } + case .spent: return scoped.filter { $0.isSpent } + } + } + + var body: some View { + let scoped = scopedRecords + let visible = filteredRecords + List { + Section { + Picker("Filter", selection: $filter) { + ForEach(ShieldedSpentFilter.allCases, id: \.self) { f in + Text(f.title).tag(f) + } + } + .pickerStyle(.segmented) + } + if !scoped.isEmpty && visible.isEmpty { + Section { + ContentUnavailableView( + "No \(filter.title) Notes", + systemImage: "lock.shield" + ) + } + } + ForEach(visible) { record in + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text("acct \(record.accountIndex)") + .font(.caption2) + .foregroundColor(.secondary) + Text("pos \(record.position)") + .font(.caption2) + .foregroundColor(.secondary) + if record.blockHeight > 0 { + Text("h \(record.blockHeight)") + .font(.caption2) + .foregroundColor(.secondary) + } + Spacer() + if record.isSpent { + Text("spent") + .font(.caption2) + .foregroundColor(.red) + } + } + Text("\(record.value) credits") + .font(.caption) + Text(record.nullifier.prefix(8).map { String(format: "%02x", $0) }.joined()) + .font(.system(.caption2, design: .monospaced)) + .foregroundColor(.secondary) + } + } + } + .navigationTitle("Shielded Notes (\(visible.count))") + .overlay { + if visible.isEmpty { + ContentUnavailableView("No Notes", systemImage: "lock.shield") + } + } + } +} + +// MARK: - PersistentShieldedSyncState + +struct ShieldedSyncStateStorageListView: View { + let network: Network + + // SwiftData's `SortDescriptor` doesn't accept `Data` fields + // (Data isn't Comparable), so sort only by `accountIndex` + // and let the wallet-id grouping fall out of insertion + // order — there are at most a handful of rows per device. + @Query(sort: [SortDescriptor(\PersistentShieldedSyncState.accountIndex)]) + private var records: [PersistentShieldedSyncState] + + @Query private var allWallets: [PersistentWallet] + + private var walletIdsOnNetwork: Set { + Set(allWallets.lazy + .filter { $0.networkRaw == network.rawValue } + .map(\.walletId)) + } + + private var scopedRecords: [PersistentShieldedSyncState] { + let ids = walletIdsOnNetwork + return records.filter { ids.contains($0.walletId) } + } + + var body: some View { + let visible = scopedRecords + List { + ForEach(visible) { record in + VStack(alignment: .leading, spacing: 4) { + HStack { + Text( + record.walletId.prefix(4) + .map { String(format: "%02x", $0) }.joined() + ) + .font(.system(.caption2, design: .monospaced)) + Text("acct \(record.accountIndex)") + .font(.caption2) + .foregroundColor(.secondary) + Spacer() + } + Text("synced index: \(record.lastSyncedIndex)") + .font(.caption) + if record.hasNullifierCheckpoint { + Text("nf: h \(record.nullifierCheckpointHeight) · ts \(record.nullifierCheckpointTimestamp)") + .font(.caption2) + .foregroundColor(.secondary) + } + } + } + } + .navigationTitle("Shielded Sync State (\(visible.count))") + .overlay { + if visible.isEmpty { + ContentUnavailableView("No Sync States", systemImage: "arrow.triangle.2.circlepath") + } + } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift index 28d8d32626a..5ca994ea072 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift @@ -112,4 +112,35 @@ final class WalletManagerStore: ObservableObject { func manager(for network: Network) -> PlatformWalletManager? { managers[network] } + + /// Get or create the manager for `network` **without** changing + /// `activeManager`. Used by flows that need to operate on a + /// specific network's manager outside the user's current view — + /// e.g. orphan-mnemonic recovery, where wallets restored from + /// keychain metadata may belong to networks the user isn't + /// currently looking at and switching the active network for + /// each one would flicker the UI. + /// + /// Same configure / load-from-persistor side effects as + /// [`activate`]: a fresh manager comes up via + /// `manager.configure(sdk:modelContainer:)` and then + /// `manager.loadFromPersistor()`. Failures propagate to the + /// caller and the cache is left untouched. + func getOrCreateManager(network: Network, sdk: SDK) throws -> PlatformWalletManager { + if let existing = managers[network] { + return existing + } + let manager = PlatformWalletManager() + try manager.configure(sdk: sdk, modelContainer: modelContainer) + do { + _ = try manager.loadFromPersistor() + } catch { + SDKLogger.error( + "WalletManagerStore: load-from-persistor failed for " + + "\(network.displayName): \(error.localizedDescription)" + ) + } + managers[network] = manager + return manager + } }