diff --git a/Cargo.lock b/Cargo.lock index 8f04eb1128e..f5703840374 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5906,7 +5906,6 @@ dependencies = [ "libc", "log", "once_cell", - "rand 0.8.5", "reqwest 0.12.28", "rs-sdk-trusted-context-provider", "serde", diff --git a/packages/rs-platform-wallet-ffi/Cargo.toml b/packages/rs-platform-wallet-ffi/Cargo.toml index 76fac52dce2..db2a2ebb31e 100644 --- a/packages/rs-platform-wallet-ffi/Cargo.toml +++ b/packages/rs-platform-wallet-ffi/Cargo.toml @@ -64,3 +64,8 @@ cbindgen = "0.27" [features] default = [] mocks = [] +# Opt-in Orchard / shielded support. Pulls in the heavy +# `grovedb-commitment-tree` + `zip32` dependencies via +# `platform-wallet/shielded`. Builds that don't enable this feature +# omit every `shielded_*` FFI symbol cleanly. +shielded = ["platform-wallet/shielded"] diff --git a/packages/rs-platform-wallet-ffi/src/event_handler.rs b/packages/rs-platform-wallet-ffi/src/event_handler.rs index 22add5d1cfa..0bfa06085fa 100644 --- a/packages/rs-platform-wallet-ffi/src/event_handler.rs +++ b/packages/rs-platform-wallet-ffi/src/event_handler.rs @@ -3,7 +3,10 @@ use crate::platform_address_sync::{ PlatformAddressSyncMetricsFFI, PlatformAddressSyncWalletResultFFI, }; +use crate::shielded_types::ShieldedSyncWalletResultFFI; use platform_wallet::events::{EventHandler, PlatformEventHandler, WalletEvent}; +#[cfg(feature = "shielded")] +use platform_wallet::manager::shielded_sync::{ShieldedSyncPassSummary, WalletShieldedOutcome}; use platform_wallet::{PlatformAddressSyncSummary, WalletSyncOutcome}; use std::os::raw::{c_char, c_void}; @@ -31,6 +34,19 @@ pub struct EventHandlerCallbacks { sync_unix_seconds: u64, ), >, + /// Called when a shielded sync pass completes (only emitted when + /// the `shielded` feature is enabled in the FFI build). The + /// callback slot is plumbed unconditionally so the C ABI is + /// stable across feature toggles, but is only invoked when + /// shielded support is compiled in. + pub on_shielded_sync_completed_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + results: *const ShieldedSyncWalletResultFFI, + count: usize, + sync_unix_seconds: u64, + ), + >, } // SAFETY: The context pointer is managed by the FFI caller who must ensure @@ -140,4 +156,55 @@ impl PlatformEventHandler for FFIEventHandler { ); } } + + #[cfg(feature = "shielded")] + fn on_shielded_sync_completed(&self, summary: &ShieldedSyncPassSummary) { + let Some(cb) = self.callbacks.on_shielded_sync_completed_fn else { + return; + }; + + if summary.wallet_results.is_empty() { + unsafe { + cb( + self.callbacks.context, + std::ptr::null(), + 0, + summary.sync_unix_seconds, + ); + } + return; + } + + let mut owned_errors = Vec::new(); + let mut results = Vec::with_capacity(summary.wallet_results.len()); + for (&wallet_id, outcome) in &summary.wallet_results { + match outcome { + WalletShieldedOutcome::Ok(result) => { + results.push(ShieldedSyncWalletResultFFI::ok(wallet_id, result)); + } + WalletShieldedOutcome::Skipped => { + results.push(ShieldedSyncWalletResultFFI::skipped(wallet_id)); + } + WalletShieldedOutcome::Err(error) => { + let error_message = std::ffi::CString::new(error.as_str()).ok(); + let error_ptr = error_message + .as_ref() + .map_or(std::ptr::null(), |message| message.as_ptr()); + if let Some(error_message) = error_message { + owned_errors.push(error_message); + } + results.push(ShieldedSyncWalletResultFFI::err(wallet_id, error_ptr)); + } + } + } + + unsafe { + cb( + self.callbacks.context, + results.as_ptr(), + results.len(), + summary.sync_unix_seconds, + ); + } + } } diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index 65c5500325f..0085d8c8547 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -51,6 +51,9 @@ pub mod platform_address_types; pub mod platform_addresses; pub mod platform_wallet_info; mod runtime; +#[cfg(feature = "shielded")] +pub mod shielded_sync; +pub mod shielded_types; pub mod sign_with_mnemonic_resolver; pub mod spv; pub mod token_persistence; @@ -103,6 +106,9 @@ pub use platform_address_sync::*; pub use platform_address_types::*; pub use platform_addresses::*; pub use platform_wallet_info::*; +#[cfg(feature = "shielded")] +pub use shielded_sync::*; +pub use shielded_types::*; pub use sign_with_mnemonic_resolver::*; pub use spv::*; pub use token_persistence::*; diff --git a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs new file mode 100644 index 00000000000..31e9bd43140 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs @@ -0,0 +1,403 @@ +//! FFI bindings for `PlatformWalletManager`'s shielded sync +//! coordinator + the host-driven `bind_shielded` entry point. +//! +//! Mirror of [`platform_address_sync`](crate::platform_address_sync) +//! for the Orchard/ZK path. The whole module is feature-gated behind +//! `shielded`; builds without the feature emit none of these symbols +//! and the upstream [`ShieldedSyncManager`] doesn't exist. +//! +//! [`ShieldedSyncManager`]: platform_wallet::manager::shielded_sync::ShieldedSyncManager + +use std::ffi::CStr; +use std::os::raw::c_char; +use std::path::PathBuf; +use std::time::Duration; + +use platform_wallet::wallet::shielded::ShieldedSyncSummary; + +use zeroize::Zeroizing; + +use crate::derive_and_persist_callbacks::{ + mnemonic_resolver_result, MnemonicResolverHandle, MNEMONIC_RESOLVER_BUFFER_CAPACITY, +}; +use crate::error::*; +use crate::handle::*; +use crate::identity_keys_from_mnemonic::parse_mnemonic_any_language; +use crate::runtime::runtime; +use crate::shielded_types::ShieldedSyncWalletResultFFI; +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); + Self { + wallet_id, + success: true, + skipped: false, + new_notes, + total_scanned: summary.notes_result.total_scanned, + newly_spent, + balance: summary.balance, + error_message: std::ptr::null(), + } + } +} + +// --------------------------------------------------------------------------- +// Shielded sync coordinator FFI +// --------------------------------------------------------------------------- + +/// Start the shielded sync manager in the background. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_shielded_sync_start( + handle: Handle, +) -> PlatformWalletFFIResult { + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + let _entered = runtime().enter(); + manager.shielded_sync_arc().start(); + }); + unwrap_option_or_return!(option); + PlatformWalletFFIResult::ok() +} + +/// Stop the shielded sync manager if it is running. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_shielded_sync_stop( + handle: Handle, +) -> PlatformWalletFFIResult { + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + manager.shielded_sync().stop(); + }); + unwrap_option_or_return!(option); + PlatformWalletFFIResult::ok() +} + +/// Whether the shielded sync background loop is running. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_shielded_sync_is_running( + handle: Handle, + out_running: *mut bool, +) -> PlatformWalletFFIResult { + check_ptr!(out_running); + + let option = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(handle, |manager| manager.shielded_sync().is_running()); + *out_running = unwrap_option_or_return!(option); + PlatformWalletFFIResult::ok() +} + +/// Whether a shielded sync pass is currently in flight. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_shielded_sync_is_syncing( + handle: Handle, + out_syncing: *mut bool, +) -> PlatformWalletFFIResult { + check_ptr!(out_syncing); + + let option = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(handle, |manager| manager.shielded_sync().is_syncing()); + *out_syncing = unwrap_option_or_return!(option); + PlatformWalletFFIResult::ok() +} + +/// Unix seconds of the last completed shielded sync pass, or 0 if +/// none has ever completed. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_shielded_sync_last_sync_unix_seconds( + handle: Handle, + out_last_sync_unix: *mut u64, +) -> PlatformWalletFFIResult { + check_ptr!(out_last_sync_unix); + + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + manager + .shielded_sync() + .last_sync_unix_seconds() + .unwrap_or(0) + }); + *out_last_sync_unix = unwrap_option_or_return!(option); + PlatformWalletFFIResult::ok() +} + +/// Set the background shielded sync interval in seconds. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_shielded_sync_set_interval( + handle: Handle, + interval_seconds: u64, +) -> PlatformWalletFFIResult { + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + manager + .shielded_sync() + .set_interval(Duration::from_secs(interval_seconds)); + }); + unwrap_option_or_return!(option); + PlatformWalletFFIResult::ok() +} + +/// Run one shielded sync pass across all registered wallets. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_shielded_sync_sync_now( + handle: Handle, +) -> PlatformWalletFFIResult { + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + runtime().block_on(manager.shielded_sync().sync_now()); + }); + unwrap_option_or_return!(option); + PlatformWalletFFIResult::ok() +} + +// --------------------------------------------------------------------------- +// Bind shielded +// --------------------------------------------------------------------------- + +/// Derive Orchard keys for the given wallet from the host-supplied +/// mnemonic resolver, open the per-network commitment tree at +/// `db_path`, and bind the resulting [`ShieldedWallet`] to the +/// `PlatformWallet`. +/// +/// 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. +/// +/// `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. +/// +/// Idempotent: a second call with a different db path / account +/// replaces the previously-bound shielded wallet. +/// +/// # Safety +/// - `wallet_id_bytes` must point at 32 readable bytes. +/// - `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 +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_bind_shielded( + handle: Handle, + wallet_id_bytes: *const u8, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + account: u32, + db_path_cstr: *const c_char, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id_bytes); + check_ptr!(mnemonic_resolver_handle); + check_ptr!(db_path_cstr); + + let mut wallet_id = [0u8; 32]; + std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); + + let db_path = match CStr::from_ptr(db_path_cstr).to_str() { + Ok(s) => PathBuf::from(s), + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorUtf8Conversion, + format!("db_path is not valid UTF-8: {e}"), + ); + } + }; + + // Resolve mnemonic via the host callback. + let mut mnemonic_buf: Zeroizing<[u8; MNEMONIC_RESOLVER_BUFFER_CAPACITY]> = + Zeroizing::new([0u8; MNEMONIC_RESOLVER_BUFFER_CAPACITY]); + let mut mnemonic_len: usize = 0; + + let resolver = &*mnemonic_resolver_handle; + let resolver_vtable = &*resolver.vtable; + let rc = (resolver_vtable.resolve)( + resolver.ctx as *const std::os::raw::c_void, + wallet_id_bytes, + mnemonic_buf.as_mut_ptr() as *mut c_char, + MNEMONIC_RESOLVER_BUFFER_CAPACITY, + &mut mnemonic_len, + ); + + match rc { + x if x == mnemonic_resolver_result::SUCCESS => {} + x if x == mnemonic_resolver_result::NOT_FOUND => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "mnemonic missing for wallet", + ); + } + x if x == mnemonic_resolver_result::BUFFER_TOO_SMALL => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "mnemonic resolver buffer too small", + ); + } + _ => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "mnemonic resolver failed", + ); + } + } + if mnemonic_len == 0 || mnemonic_len > MNEMONIC_RESOLVER_BUFFER_CAPACITY { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "mnemonic resolver returned empty buffer", + ); + } + + // Parse and derive seed. Both intermediate forms live in + // `Zeroizing` so they're scrubbed when this function exits. + let mnemonic_str = match std::str::from_utf8(&mnemonic_buf[..mnemonic_len]) { + Ok(s) => s, + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorUtf8Conversion, + format!("mnemonic is not valid UTF-8: {e}"), + ); + } + }; + let mnemonic = match parse_mnemonic_any_language(mnemonic_str) { + Ok(m) => m, + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("invalid mnemonic: {e}"), + ); + } + }; + let seed: Zeroizing<[u8; 64]> = Zeroizing::new(mnemonic.to_seed("")); + drop(mnemonic); + + // Look up the wallet on the manager and bind shielded. + let wallet_arc = { + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + runtime().block_on(manager.get_wallet(&wallet_id)) + }); + unwrap_option_or_return!(option) + }; + let wallet_arc = match wallet_arc { + Some(w) => w, + None => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("wallet not found: {}", hex::encode(wallet_id)), + ); + } + }; + + if let Err(e) = runtime().block_on(wallet_arc.bind_shielded(seed.as_ref(), account, &db_path)) { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("bind_shielded failed: {e}"), + ); + } + + PlatformWalletFFIResult::ok() +} + +// --------------------------------------------------------------------------- +// Default Orchard payment address +// --------------------------------------------------------------------------- + +/// Read the default Orchard payment address for the bound shielded +/// sub-wallet on `wallet_id`. The host receives the 43 raw bytes +/// (recipient + diversifier) and applies its own bech32m encoding. +/// +/// `*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`. +/// +/// # Safety +/// - `wallet_id_bytes` must point at 32 readable bytes. +/// - `out_bytes_43` must point at 43 writable bytes. +/// - `out_present` must be writable. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_shielded_default_address( + handle: Handle, + wallet_id_bytes: *const u8, + out_bytes_43: *mut u8, + out_present: *mut bool, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id_bytes); + check_ptr!(out_bytes_43); + check_ptr!(out_present); + + let mut wallet_id = [0u8; 32]; + std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); + + enum Outcome { + WalletMissing, + Unbound, + Bound([u8; 43]), + } + + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + runtime().block_on(async { + match manager.get_wallet(&wallet_id).await { + None => Outcome::WalletMissing, + Some(w) => match w.shielded_default_address().await { + Some(bytes) => Outcome::Bound(bytes), + None => Outcome::Unbound, + }, + } + }) + }); + let outcome = unwrap_option_or_return!(option); + + match outcome { + Outcome::WalletMissing => PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("wallet not found: {}", hex::encode(wallet_id)), + ), + Outcome::Unbound => { + *out_present = false; + PlatformWalletFFIResult::ok() + } + Outcome::Bound(bytes) => { + std::ptr::copy_nonoverlapping(bytes.as_ptr(), out_bytes_43, 43); + *out_present = true; + PlatformWalletFFIResult::ok() + } + } +} + +// --------------------------------------------------------------------------- +// Per-wallet sync_now +// --------------------------------------------------------------------------- + +/// Run a shielded sync on a single wallet on demand. +/// +/// Does not set the manager's global `is_syncing` flag — gate on +/// [`platform_wallet_manager_shielded_sync_is_syncing`] yourself if +/// you want to avoid concurrent passes. Returns an error if the +/// wallet doesn't exist or the sync itself fails. Wallets with no +/// bound shielded sub-wallet succeed silently with no observable +/// state change. +/// +/// # Safety +/// - `wallet_id_bytes` must point at 32 readable bytes. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_shielded_sync_wallet( + handle: Handle, + wallet_id_bytes: *const u8, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id_bytes); + + let mut wallet_id = [0u8; 32]; + std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); + + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + runtime().block_on(manager.shielded_sync().sync_wallet(&wallet_id)) + }); + let result = unwrap_option_or_return!(option); + match result { + Ok(_) => PlatformWalletFFIResult::ok(), + Err(e) => PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("shielded sync failed: {e}"), + ), + } +} diff --git a/packages/rs-platform-wallet-ffi/src/shielded_types.rs b/packages/rs-platform-wallet-ffi/src/shielded_types.rs new file mode 100644 index 00000000000..3cf10d60c78 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/shielded_types.rs @@ -0,0 +1,84 @@ +//! C-ABI types for the shielded sync surface. +//! +//! Defined unconditionally — without the `shielded` Cargo feature +//! the `shielded_sync` module is omitted, but +//! [`EventHandlerCallbacks`](crate::event_handler::EventHandlerCallbacks) +//! still has to carry the `on_shielded_sync_completed_fn` slot so +//! the C struct layout doesn't drift between feature configurations. +//! The types here have no functional dependency on the shielded code +//! path; they're just `#[repr(C)]` data carriers. + +use std::os::raw::c_char; + +/// Per-wallet outcome from a completed shielded sync pass. +/// +/// Mirrors +/// [`PlatformAddressSyncWalletResultFFI`](crate::platform_address_sync::PlatformAddressSyncWalletResultFFI) +/// for the shielded path. The status fields encode three states: +/// +/// - `success == true`: sync succeeded; the numeric fields are +/// meaningful and `error_message` is NULL. +/// - `skipped == true`: the wallet had no bound shielded sub-wallet +/// so the pass passed it over; `success` is false and +/// `error_message` is NULL. +/// - both flags `false` and `error_message != NULL`: the sync +/// itself failed. +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct ShieldedSyncWalletResultFFI { + pub wallet_id: [u8; 32], + /// `true` only on a successful sync. + pub success: bool, + /// `true` if the wallet had no bound shielded sub-wallet (so the + /// pass simply skipped it). Mutually exclusive with `success`. + pub skipped: bool, + /// New decrypted notes detected this pass. + pub new_notes: u32, + /// Total encrypted notes scanned (decrypted + skipped). + pub total_scanned: u64, + /// Notes newly detected as spent this pass. + pub newly_spent: u32, + /// Current unspent shielded balance after the pass. + pub balance: u64, + /// NUL-terminated UTF-8 error message; valid until the callback + /// returns. NULL on success and skipped wallets. + pub error_message: *const c_char, +} + +impl Default for ShieldedSyncWalletResultFFI { + fn default() -> Self { + Self { + wallet_id: [0; 32], + success: false, + skipped: false, + new_notes: 0, + total_scanned: 0, + newly_spent: 0, + balance: 0, + error_message: std::ptr::null(), + } + } +} + +// Constructors only used by the feature-gated event-handler dispatch. +// Annotated rather than feature-gated at the impl level so the type +// can stay unconditional but the helpers don't generate dead-code +// warnings on no-shielded builds. +#[cfg(feature = "shielded")] +impl ShieldedSyncWalletResultFFI { + pub(crate) fn skipped(wallet_id: [u8; 32]) -> Self { + Self { + wallet_id, + skipped: true, + ..Self::default() + } + } + + pub(crate) fn err(wallet_id: [u8; 32], error_ptr: *const c_char) -> Self { + Self { + wallet_id, + error_message: error_ptr, + ..Self::default() + } + } +} diff --git a/packages/rs-platform-wallet-ffi/src/spv.rs b/packages/rs-platform-wallet-ffi/src/spv.rs index 1ad606ed290..b68421854a4 100644 --- a/packages/rs-platform-wallet-ffi/src/spv.rs +++ b/packages/rs-platform-wallet-ffi/src/spv.rs @@ -162,6 +162,31 @@ pub unsafe extern "C" fn platform_wallet_manager_spv_is_running( PlatformWalletFFIResult::ok() } +/// Read the unix-seconds block time of the SPV header storage's +/// current tip. Useful as a "is core producing blocks?" indicator — +/// a stale value across multiple polls means the chain has stalled. +/// +/// `*out_unix_seconds` is set to `0` when the SPV client isn't +/// running, no headers have been stored yet, or the tip header +/// can't be read for any reason. The function still returns +/// success in those cases — `0` is the in-band sentinel. +/// +/// # Safety +/// - `out_unix_seconds` must be writable for one `u64` value. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_spv_tip_unix_seconds( + handle: Handle, + out_unix_seconds: *mut u64, +) -> PlatformWalletFFIResult { + check_ptr!(out_unix_seconds); + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + runtime().block_on(manager.spv().tip_block_time()) + }); + let tip = unwrap_option_or_return!(option); + *out_unix_seconds = tip.map(|t| t as u64).unwrap_or(0); + PlatformWalletFFIResult::ok() +} + /// Start SPV sync in the background. #[no_mangle] #[allow(clippy::field_reassign_with_default)] diff --git a/packages/rs-platform-wallet/src/events.rs b/packages/rs-platform-wallet/src/events.rs index 5111636b459..e73ed5eb235 100644 --- a/packages/rs-platform-wallet/src/events.rs +++ b/packages/rs-platform-wallet/src/events.rs @@ -17,6 +17,8 @@ pub use dash_spv::EventHandler; pub use key_wallet_manager::WalletEvent; use crate::manager::platform_address_sync::PlatformAddressSyncSummary; +#[cfg(feature = "shielded")] +use crate::manager::shielded_sync::ShieldedSyncPassSummary; /// Extension of [`EventHandler`] for platform-wallet consumers. /// @@ -30,6 +32,17 @@ pub trait PlatformEventHandler: EventHandler { /// /// [`PlatformAddressSyncManager`]: crate::manager::platform_address_sync::PlatformAddressSyncManager fn on_platform_address_sync_completed(&self, _summary: &PlatformAddressSyncSummary) {} + + /// Fired after each [`ShieldedSyncManager`] pass completes, + /// including passes that produced no updates or skipped every + /// wallet because none had a bound shielded sub-wallet yet. + /// + /// Default impl is a no-op so existing handlers don't have to + /// care. + /// + /// [`ShieldedSyncManager`]: crate::manager::shielded_sync::ShieldedSyncManager + #[cfg(feature = "shielded")] + fn on_shielded_sync_completed(&self, _summary: &ShieldedSyncPassSummary) {} } /// Dispatches events to all registered [`PlatformEventHandler`]s. @@ -69,6 +82,18 @@ impl PlatformEventManager { h.on_platform_address_sync_completed(summary); } } + + /// Dispatch a shielded sync completion to every handler. + /// + /// Not on the SPV hot path — called once per shielded sync pass + /// (~60s by default). + #[cfg(feature = "shielded")] + pub fn on_shielded_sync_completed(&self, summary: &ShieldedSyncPassSummary) { + let handlers = self.handlers.load(); + for h in handlers.iter() { + h.on_shielded_sync_completed(summary); + } + } } impl EventHandler for PlatformEventManager { diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index ed9cf89964f..8b4d4810ff5 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -13,6 +13,8 @@ use key_wallet::WalletCoreBalance; use crate::changeset::PlatformWalletPersistence; use crate::manager::identity_sync::IdentitySyncManager; use crate::manager::platform_address_sync::PlatformAddressSyncManager; +#[cfg(feature = "shielded")] +use crate::manager::shielded_sync::ShieldedSyncManager; use crate::spv::SpvRuntime; use crate::wallet::platform_wallet::WalletId; use crate::wallet::PlatformWallet; @@ -240,6 +242,20 @@ impl PlatformWalletManager

{ Arc::clone(&self.identity_sync_manager) } + /// Access the shielded sync coordinator. + #[cfg(feature = "shielded")] + pub fn shielded_sync(&self) -> &ShieldedSyncManager { + &self.shielded_sync_manager + } + + /// Clone the `Arc` so callers (e.g. FFI) + /// can invoke [`ShieldedSyncManager::start`] which takes + /// `&Arc`. + #[cfg(feature = "shielded")] + pub fn shielded_sync_arc(&self) -> Arc { + Arc::clone(&self.shielded_sync_manager) + } + /// Get a clone of a wallet by its ID. pub async fn get_wallet(&self, wallet_id: &WalletId) -> Option> { let wallets = self.wallets.read().await; @@ -347,10 +363,10 @@ impl PlatformWalletManager

{ // through a helper on the manager — since the registry itself // isn't exposed, fall back to "0" until a sync getter is // added. This is intentionally a TODO surface, not a guess. - let queue_depth = match self.identity_sync_manager.try_queue_depth() { - Some(n) => n, - None => 0, - }; + let queue_depth = self + .identity_sync_manager + .try_queue_depth() + .unwrap_or_default(); IdentitySyncConfigSnapshot { interval_seconds: interval.as_secs().max(1), queue_depth, @@ -702,7 +718,7 @@ impl PlatformWalletManager

{ .map(|(reg_idx, managed)| { use dpp::identity::accessors::IdentityGettersV0; WalletIdentityRowSnapshot { - registration_index: *reg_idx as u32, + registration_index: *reg_idx, identity_id: managed.identity.id().to_buffer(), } }) @@ -739,11 +755,7 @@ fn pool_snapshot(pool: &AddressPool) -> AccountAddressPoolSnapshot { AddressPoolType::AbsentHardened => 3, }; let last_used_index: i64 = pool.highest_used.map(|i| i as i64).unwrap_or(-1); - let addresses = pool - .addresses - .values() - .map(|info| addr_info_snapshot(info)) - .collect(); + let addresses = pool.addresses.values().map(addr_info_snapshot).collect(); AccountAddressPoolSnapshot { pool_type, gap_limit: pool.gap_limit, diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 7870a18382a..ac44658e8f3 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -4,6 +4,8 @@ pub mod accessors; pub mod identity_sync; mod load; pub mod platform_address_sync; +#[cfg(feature = "shielded")] +pub mod shielded_sync; mod wallet_lifecycle; use std::sync::Arc; @@ -18,6 +20,8 @@ use crate::changeset::{spawn_wallet_event_adapter, PlatformWalletPersistence}; use crate::events::{PlatformEventHandler, PlatformEventManager}; use crate::manager::identity_sync::IdentitySyncManager; use crate::manager::platform_address_sync::PlatformAddressSyncManager; +#[cfg(feature = "shielded")] +use crate::manager::shielded_sync::ShieldedSyncManager; use crate::spv::SpvRuntime; use crate::wallet::asset_lock::LockNotifyHandler; use crate::wallet::core::BalanceUpdateHandler; @@ -48,6 +52,13 @@ pub struct PlatformWalletManager { /// wallet. Not auto-started — call `start` after wallets are /// registered. See [`IdentitySyncManager`]. pub(super) identity_sync_manager: Arc>, + /// Periodic shielded (Orchard) note + nullifier sync coordinator. + /// Iterates every wallet that has been bound via + /// [`PlatformWallet::bind_shielded`](crate::wallet::PlatformWallet::bind_shielded); + /// unbound wallets are skipped silently. Not auto-started — call + /// `start` after wallets are registered. + #[cfg(feature = "shielded")] + pub(super) shielded_sync_manager: Arc, pub(super) persister: Arc

, /// Cancellation token + join handle for the wallet-event adapter /// task. Held so [`shutdown`] can stop it cleanly when the manager @@ -107,6 +118,11 @@ impl PlatformWalletManager

{ Arc::clone(&sdk), Arc::clone(&persister), )); + #[cfg(feature = "shielded")] + let shielded_sync = Arc::new(ShieldedSyncManager::new( + Arc::clone(&wallets), + Arc::clone(&event_manager), + )); Self { sdk, wallet_manager, @@ -115,6 +131,8 @@ impl PlatformWalletManager

{ spv_manager: spv, platform_address_sync_manager: platform_address_sync, identity_sync_manager: identity_sync, + #[cfg(feature = "shielded")] + shielded_sync_manager: shielded_sync, persister, event_adapter_cancel, event_adapter_join: tokio::sync::Mutex::new(Some(event_adapter_join)), @@ -131,6 +149,8 @@ impl PlatformWalletManager

{ pub async fn shutdown(&self) { self.platform_address_sync_manager.stop(); self.identity_sync_manager.stop(); + #[cfg(feature = "shielded")] + self.shielded_sync_manager.stop(); self.event_adapter_cancel.cancel(); if let Some(handle) = self.event_adapter_join.lock().await.take() { diff --git a/packages/rs-platform-wallet/src/manager/shielded_sync.rs b/packages/rs-platform-wallet/src/manager/shielded_sync.rs new file mode 100644 index 00000000000..167958bd8c8 --- /dev/null +++ b/packages/rs-platform-wallet/src/manager/shielded_sync.rs @@ -0,0 +1,365 @@ +//! Periodic shielded (Orchard) note + nullifier sync coordinator. +//! +//! Mirrors [`PlatformAddressSyncManager`](super::platform_address_sync::PlatformAddressSyncManager): +//! runs [`PlatformWallet::shielded_sync`] for every wallet that has a +//! bound [`ShieldedWallet`] on a fixed cadence, and emits a summary +//! event so UI and persistence layers can react. +//! +//! Wallets without a bound shielded sub-wallet are silently skipped +//! — `bind_shielded` is the host's responsibility (it requires +//! mnemonic access via the keychain resolver), so the manager +//! shouldn't error out passes just because some wallets aren't yet +//! shielded-aware. +//! +//! Not auto-started. Call [`ShieldedSyncManager::start`] once the +//! shielded sub-wallets are bound. +//! +//! Feature-gated behind `shielded` — when the feature is off, the +//! whole module is omitted from the build. + +use std::collections::BTreeMap; +use std::sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, Mutex as StdMutex, +}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use tokio::sync::RwLock; +use tokio_util::sync::CancellationToken; + +use crate::events::PlatformEventManager; +use crate::wallet::platform_wallet::WalletId; +use crate::wallet::shielded::ShieldedSyncSummary; +use crate::wallet::PlatformWallet; + +/// Default cadence — 60s. Shielded sync is heavier than address sync +/// (chunked at 2048 entries with trial decryption per entry), so this +/// is conservative compared to the 15s address-sync cadence. +pub const DEFAULT_SYNC_INTERVAL_SECS: u64 = 60; + +/// Outcome of syncing a single wallet in a shielded sync pass. +/// +/// Not `Clone` because `ShieldedSyncSummary` carries the underlying +/// `dash_sdk` result types that aren't `Clone` either. Consumers +/// receive it by reference through the event-manager dispatch. +#[derive(Debug)] +pub enum WalletShieldedOutcome { + /// Successful sync. Carries the per-wallet sync summary + /// (`new_notes`, `total_scanned`, `newly_spent`, current `balance`). + Ok(ShieldedSyncSummary), + /// Either the wallet has no bound shielded sub-wallet (skipped) or + /// the sync failed. The string is empty for "skipped" and carries + /// an error message otherwise. + Skipped, + /// Error message from a failed sync. + Err(String), +} + +impl WalletShieldedOutcome { + pub fn is_ok(&self) -> bool { + matches!(self, WalletShieldedOutcome::Ok(_)) + } + + pub fn is_skipped(&self) -> bool { + matches!(self, WalletShieldedOutcome::Skipped) + } +} + +/// Summary of one full shielded sync pass across every registered +/// wallet. +#[derive(Debug, Default)] +pub struct ShieldedSyncPassSummary { + /// Per-wallet outcomes keyed by `WalletId`. Wallets without a + /// bound shielded sub-wallet appear as + /// [`WalletShieldedOutcome::Skipped`] so consumers can distinguish + /// "no shielded wallet here" from "sync errored". + pub wallet_results: BTreeMap, + /// Unix seconds at which the pass completed. `0` means "no pass + /// ran" (e.g. a concurrent pass was already in flight and we + /// skipped). + pub sync_unix_seconds: u64, +} + +impl ShieldedSyncPassSummary { + pub fn is_empty(&self) -> bool { + self.wallet_results.is_empty() + } + + pub fn success_count(&self) -> usize { + self.wallet_results.values().filter(|o| o.is_ok()).count() + } + + pub fn skipped_count(&self) -> usize { + self.wallet_results + .values() + .filter(|o| o.is_skipped()) + .count() + } + + pub fn error_count(&self) -> usize { + self.wallet_results.len() - self.success_count() - self.skipped_count() + } +} + +/// Periodic shielded sync coordinator. +/// +/// Holds a handle to the same `wallets` map owned by +/// [`PlatformWalletManager`](super::PlatformWalletManager) (via +/// `Arc`), so wallets bound after `start` are picked up on the next +/// tick without any re-registration. +/// +/// Each pass: +/// 1. Snapshots the wallet map (short read lock, no await while +/// held). +/// 2. Calls [`PlatformWallet::shielded_sync`] on each wallet +/// sequentially. Returns +/// [`WalletShieldedOutcome::Skipped`] for unbound wallets. +/// 3. Stores the pass timestamp. +/// 4. Dispatches +/// [`PlatformEventManager::on_shielded_sync_completed`]. +/// +/// `sync_now` is re-entrant-safe: if a pass is already running, +/// calling `sync_now` again returns an empty summary immediately. +pub struct ShieldedSyncManager { + wallets: Arc>>>, + event_manager: Arc, + /// Cancel token for the background loop, if running. + background_cancel: StdMutex>, + /// Monotonically increasing generation counter. Bumped on every + /// `start()` so the exiting thread can tell whether its + /// generation is still the active one before clearing + /// `background_cancel`. Without this, a `stop()` → `start()` + /// overlap lets the prior thread's cleanup strip the new + /// generation's token, leaving the new loop running but + /// untrackable via `is_running()`. + background_generation: AtomicU64, + interval_secs: AtomicU64, + is_syncing: AtomicBool, + /// Unix seconds of the last completed pass. `0` = never. + last_sync_unix: AtomicU64, +} + +impl ShieldedSyncManager { + pub fn new( + wallets: Arc>>>, + event_manager: Arc, + ) -> Self { + Self { + wallets, + event_manager, + background_cancel: StdMutex::new(None), + background_generation: AtomicU64::new(0), + interval_secs: AtomicU64::new(DEFAULT_SYNC_INTERVAL_SECS), + is_syncing: AtomicBool::new(false), + last_sync_unix: AtomicU64::new(0), + } + } + + /// Set the polling interval. Clamped to a minimum of 1s. + /// + /// The running loop picks this up on its next sleep. + pub fn set_interval(&self, interval: Duration) { + let secs = interval.as_secs().max(1); + self.interval_secs.store(secs, Ordering::Release); + } + + /// Current polling interval. + pub fn interval(&self) -> Duration { + Duration::from_secs(self.interval_secs.load(Ordering::Acquire)) + } + + /// Whether the background loop is currently running. + pub fn is_running(&self) -> bool { + self.background_cancel + .lock() + .map(|g| g.is_some()) + .unwrap_or(false) + } + + /// Whether a sync pass is in flight right now. + pub fn is_syncing(&self) -> bool { + self.is_syncing.load(Ordering::Acquire) + } + + /// Unix seconds of the last completed pass, or `None` if no pass + /// has ever completed. + pub fn last_sync_unix_seconds(&self) -> Option { + match self.last_sync_unix.load(Ordering::Acquire) { + 0 => None, + n => Some(n), + } + } + + /// Start the background sync loop. Idempotent — calling while + /// already running is a no-op. + /// + /// Runs on a dedicated OS thread, not on a tokio worker, because + /// the underlying `dash-sdk` shielded-sync future is `!Send` (the + /// GRPC client state isn't `Send + Sync`). Same trade-off as + /// [`PlatformAddressSyncManager::start`](super::platform_address_sync::PlatformAddressSyncManager::start). + pub fn start(self: Arc) { + let mut guard = self.background_cancel.lock().expect("bg_cancel poisoned"); + if guard.is_some() { + return; + } + let cancel = CancellationToken::new(); + *guard = Some(cancel.clone()); + // Bump the generation while we still hold the slot lock so + // the load below in any prior thread's cleanup observes + // `current_gen != my_gen` ordered against this token swap. + let my_gen = self.background_generation.fetch_add(1, Ordering::AcqRel) + 1; + drop(guard); + + let handle = tokio::runtime::Handle::current(); + let this = self; + std::thread::Builder::new() + .name("shielded-sync".into()) + .spawn(move || { + handle.block_on(async move { + loop { + if cancel.is_cancelled() { + break; + } + + this.sync_now().await; + + let interval = this.interval(); + tokio::select! { + _ = tokio::time::sleep(interval) => {} + _ = cancel.cancelled() => break, + } + } + + // Only clear `background_cancel` if the active + // generation is still ours. Without this guard a + // tight `stop()` → `start()` reschedule has the + // exiting thread overwrite the *new* generation's + // token, leaving the new loop running but + // unreflectable via `is_running()` / `stop()`. + if this.background_generation.load(Ordering::Acquire) == my_gen { + if let Ok(mut guard) = this.background_cancel.lock() { + *guard = None; + } + } + }); + }) + .expect("failed to spawn shielded-sync thread"); + } + + /// Stop the background sync loop. No-op if not running. + pub fn stop(&self) { + if let Some(token) = self + .background_cancel + .lock() + .expect("bg_cancel poisoned") + .take() + { + token.cancel(); + } + } + + /// Run one sync pass across every registered wallet. + /// + /// If a pass is already in flight, returns an empty summary and + /// skips — the caller can inspect [`is_syncing`] to distinguish. + pub async fn sync_now(&self) -> ShieldedSyncPassSummary { + if self + .is_syncing + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return ShieldedSyncPassSummary::default(); + } + + let snapshot: Vec<(WalletId, Arc)> = { + let wallets = self.wallets.read().await; + wallets.iter().map(|(id, w)| (*id, Arc::clone(w))).collect() + }; + + let mut summary = ShieldedSyncPassSummary::default(); + for (wallet_id, wallet) in snapshot { + let outcome = match wallet.shielded_sync().await { + Ok(Some(result)) => WalletShieldedOutcome::Ok(result), + Ok(None) => WalletShieldedOutcome::Skipped, + Err(e) => { + tracing::warn!( + "Shielded sync failed for wallet {}: {}", + hex::encode(wallet_id), + e + ); + WalletShieldedOutcome::Err(e.to_string()) + } + }; + summary.wallet_results.insert(wallet_id, outcome); + } + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + summary.sync_unix_seconds = now; + self.last_sync_unix.store(now, Ordering::Release); + self.is_syncing.store(false, Ordering::Release); + + self.event_manager.on_shielded_sync_completed(&summary); + + summary + } + + /// Sync a single wallet on demand. + /// + /// Acquires the manager's `is_syncing` exclusion before + /// touching the wallet's shielded sub-wallet, mirroring + /// [`sync_now`]. If a pass is already in flight this returns + /// `Ok(None)` immediately rather than serializing — the caller + /// got told "no" without their request also blocking the + /// running periodic pass. Inspect [`is_syncing`] beforehand if + /// you need to distinguish "wallet has no shielded sub-wallet" + /// from "another pass was running". + /// + /// Returns `Ok(None)` if the wallet has no bound shielded + /// sub-wallet, or if another sync pass was already in flight. + pub async fn sync_wallet( + &self, + wallet_id: &WalletId, + ) -> Result, crate::error::PlatformWalletError> { + let wallet = { + let wallets = self.wallets.read().await; + wallets.get(wallet_id).cloned() + }; + let wallet = wallet.ok_or_else(|| { + crate::error::PlatformWalletError::WalletNotFound(hex::encode(wallet_id)) + })?; + + // Reuse the manager-wide `is_syncing` flag so a per-wallet + // sync_wallet() can't race the periodic sync_now() against + // the same `ShieldedWallet` / store. PlatformWallet's + // `shielded_sync` only takes a read lock on the optional + // shielded slot, so without this gate two passes can step + // on each other's commitment-tree appends and + // last-synced-index updates. + if self + .is_syncing + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Ok(None); + } + + let result = wallet.shielded_sync().await; + + self.is_syncing.store(false, Ordering::Release); + result + } +} + +impl std::fmt::Debug for ShieldedSyncManager { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ShieldedSyncManager") + .field("is_running", &self.is_running()) + .field("is_syncing", &self.is_syncing()) + .field("interval_secs", &self.interval_secs.load(Ordering::Acquire)) + .field("last_sync_unix", &self.last_sync_unix_seconds()) + .finish() + } +} diff --git a/packages/rs-platform-wallet/src/spv/runtime.rs b/packages/rs-platform-wallet/src/spv/runtime.rs index eecb0e58607..d0c56e48b7a 100644 --- a/packages/rs-platform-wallet/src/spv/runtime.rs +++ b/packages/rs-platform-wallet/src/spv/runtime.rs @@ -233,6 +233,30 @@ impl SpvRuntime { Some(client.sync_progress().await) } + /// Read the unix-seconds block time of the SPV header storage's + /// current tip. + /// + /// Useful as a "is core producing blocks?" indicator: if this + /// stamp stays put across multiple polls, the chain has stalled + /// even though the local SPV client is healthy. + /// + /// Returns `None` if the SPV client isn't running, no headers + /// have been stored yet, or the tip header isn't readable for + /// any reason. + pub async fn tip_block_time(&self) -> Option { + use dash_spv::storage::{BlockHeaderStorage, StorageManager}; + + let client_guard = self.client.read().await; + let client = client_guard.as_ref()?; + let storage_arc = client.storage(); + let storage = storage_arc.lock().await; + let block_headers = StorageManager::block_headers(&*storage); + drop(storage); + let bh = block_headers.read().await; + let tip = BlockHeaderStorage::get_tip(&*bh).await?; + Some(tip.header().time) + } + /// Clear all persisted SPV storage (headers, filters, state). /// /// The SPV client must be running to perform this operation. diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 76610b8bca2..dcd9486798e 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -16,10 +16,16 @@ use super::core::{CoreWallet, WalletBalance}; use super::identity::{IdentityManager, IdentityWallet}; use super::persister::WalletPersister; use super::platform_addresses::PlatformAddressWallet; +#[cfg(feature = "shielded")] +use super::shielded::{FileBackedShieldedStore, ShieldedSyncSummary, ShieldedWallet}; use crate::broadcaster::SpvBroadcaster; use crate::changeset::{ ClientStartState, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, }; +#[cfg(feature = "shielded")] +use crate::error::PlatformWalletError; +#[cfg(feature = "shielded")] +use std::path::Path; /// Unique identifier for a wallet (32-byte hash). pub type WalletId = [u8; 32]; @@ -70,6 +76,15 @@ pub struct PlatformWallet { persister: WalletPersister, /// Lock-free balance for UI reads, cloned from `PlatformWalletInfo.balance`. pub(crate) balance: Arc, + /// Shielded (Orchard / ZK) sub-wallet. `None` until [`bind_shielded`] + /// has run; remains `None` for `WatchOnly` / `ExternalSignable` + /// wallets that have never had a resolver-driven bind. The + /// `RwLock` lets the shielded sync coordinator read the bound + /// state without serializing against unrelated wallet writes. + /// + /// [`bind_shielded`]: Self::bind_shielded + #[cfg(feature = "shielded")] + pub(crate) shielded: Arc>>>, } impl PlatformWallet { @@ -271,8 +286,80 @@ impl PlatformWallet { asset_locks, persister: wallet_persister, balance, + #[cfg(feature = "shielded")] + shielded: Arc::new(RwLock::new(None)), + } + } + + /// 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. + /// + /// Idempotent: a second call replaces the previously-bound + /// shielded wallet (e.g. after a network switch). + /// + /// [`SpendingKey::from_zip32_seed`]: grovedb_commitment_tree::SpendingKey::from_zip32_seed + #[cfg(feature = "shielded")] + pub async fn bind_shielded( + &self, + seed: &[u8], + account: u32, + db_path: impl AsRef, + ) -> Result<(), PlatformWalletError> { + // Open / create the SQLite-backed commitment tree first so + // any I/O failure surfaces before we touch the wallet's + // existing shielded slot. + let store = FileBackedShieldedStore::open_path(db_path, 100) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + let network = self.sdk.network; + let wallet = + ShieldedWallet::from_seed(Arc::clone(&self.sdk), seed, network, account, store)?; + + let mut slot = self.shielded.write().await; + *slot = Some(wallet); + Ok(()) + } + + /// Whether the shielded sub-wallet has been bound via + /// [`bind_shielded`](Self::bind_shielded). + #[cfg(feature = "shielded")] + pub async fn is_shielded_bound(&self) -> bool { + self.shielded.read().await.is_some() + } + + /// Run one shielded sync pass on this wallet. + /// + /// Returns `Ok(None)` if the shielded sub-wallet hasn't been + /// bound (the sync coordinator skips unbound wallets without + /// surfacing an error). Returns `Ok(Some(summary))` after a + /// successful pass, or `Err(_)` if the underlying sync failed. + #[cfg(feature = "shielded")] + pub async fn shielded_sync(&self) -> Result, PlatformWalletError> { + let guard = self.shielded.read().await; + match guard.as_ref() { + Some(wallet) => Ok(Some(wallet.sync().await?)), + None => Ok(None), } } + + /// 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. + #[cfg(feature = "shielded")] + pub async fn shielded_default_address(&self) -> Option<[u8; 43]> { + let guard = self.shielded.read().await; + guard + .as_ref() + .map(|w| w.default_address().to_raw_address_bytes()) + } } impl PlatformWallet { @@ -394,6 +481,8 @@ impl Clone for PlatformWallet { asset_locks: self.asset_locks.clone(), persister: self.persister.clone(), balance: self.balance.clone(), + #[cfg(feature = "shielded")] + shielded: self.shielded.clone(), } } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs new file mode 100644 index 00000000000..c217d0febe9 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -0,0 +1,190 @@ +//! 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. + +use std::collections::BTreeMap; +use std::error::Error as StdError; +use std::fmt; +use std::path::Path; +use std::sync::Mutex; + +use grovedb_commitment_tree::{ClientPersistentCommitmentTree, Position, Retention}; + +use super::store::{ShieldedNote, ShieldedStore}; + +/// Error type for [`FileBackedShieldedStore`]. +#[derive(Debug)] +pub struct FileShieldedStoreError(pub String); + +impl fmt::Display for FileShieldedStoreError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +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. +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. + 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)>, +} + +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, + ) -> Result { + let tree = ClientPersistentCommitmentTree::open_path(path, max_checkpoints) + .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, + }) + } +} + +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()); + Ok(()) + } + + fn get_unspent_notes(&self) -> Result, Self::Error> { + Ok(self.notes.iter().filter(|n| !n.is_spent).cloned().collect()) + } + + fn get_all_notes(&self) -> Result, Self::Error> { + Ok(self.notes.clone()) + } + + 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 append_commitment(&mut self, cmx: &[u8; 32], marked: bool) -> Result<(), Self::Error> { + let retention: Retention = if marked { + Retention::Marked + } else { + Retention::Ephemeral + }; + let mut tree = self + .tree + .lock() + .map_err(|e| FileShieldedStoreError(format!("tree mutex poisoned: {e}")))?; + tree.append(*cmx, retention) + .map_err(|e| FileShieldedStoreError(format!("append commitment: {e}"))) + } + + fn checkpoint_tree(&mut self, checkpoint_id: u32) -> Result<(), Self::Error> { + let mut tree = self + .tree + .lock() + .map_err(|e| FileShieldedStoreError(format!("tree mutex poisoned: {e}")))?; + tree.checkpoint(checkpoint_id) + .map(|_| ()) + .map_err(|e| FileShieldedStoreError(format!("checkpoint tree: {e}"))) + } + + fn tree_anchor(&self) -> Result<[u8; 32], Self::Error> { + let tree = self + .tree + .lock() + .map_err(|e| FileShieldedStoreError(format!("tree mutex poisoned: {e}")))?; + tree.anchor() + .map(|a| a.to_bytes()) + .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 last_synced_note_index(&self) -> Result { + Ok(self.last_synced_index) + } + + fn set_last_synced_note_index(&mut self, index: u64) -> Result<(), Self::Error> { + self.last_synced_index = index; + Ok(()) + } + + fn nullifier_checkpoint(&self) -> Result, Self::Error> { + Ok(self.nullifier_checkpoint) + } + + fn set_nullifier_checkpoint(&mut self, height: u64, timestamp: u64) -> Result<(), Self::Error> { + self.nullifier_checkpoint = Some((height, timestamp)); + Ok(()) + } +} diff --git a/packages/rs-platform-wallet/src/wallet/shielded/keys.rs b/packages/rs-platform-wallet/src/wallet/shielded/keys.rs index a60c2d804e8..356a42c354f 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/keys.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/keys.rs @@ -25,17 +25,19 @@ const DASH_COIN_TYPE_TESTNET: u32 = 1; /// ZIP-32 derived Orchard key hierarchy. /// -/// Contains all key material needed for shielded operations: -/// - `spending_key` — master secret, needed to authorize spends +/// Contains the key material needed for shielded sync and address +/// generation. The master `SpendingKey` is intentionally not retained: +/// it is derived inside [`Self::from_seed`] only long enough to extract +/// the FVK / ASK / IVK / OVK and is dropped before this struct is +/// returned. Spend authorization for an actual transaction re-derives +/// the SK transiently from the wallet seed via the host signer. +/// /// - `full_viewing_key` — derived from SK, can view all transactions /// - `spend_auth_key` — signs individual spend authorizations /// - `incoming_viewing_key` — detects incoming notes (trial decryption) /// - `outgoing_viewing_key` — recovers sent notes (wallet recovery) /// - `default_address` — the default payment address at index 0 pub struct OrchardKeySet { - /// The spending key (master secret). Crate-private — never expose externally. - #[allow(dead_code)] - pub(crate) spending_key: SpendingKey, /// Full viewing key derived from the spending key. pub full_viewing_key: FullViewingKey, /// Spend authorization key for signing spends. Crate-private. @@ -84,9 +86,15 @@ impl OrchardKeySet { let ivk = fvk.to_ivk(Scope::External); let ovk = fvk.to_ovk(Scope::External); let default_address = fvk.address_at(0u32, Scope::External); + // `sk` falls out of scope here. The FVK / ASK / IVK / OVK + // already capture every quantity the wallet needs; spend + // authorization is re-derived transiently from the wallet + // seed via the host signer at sign time. (Orchard + // `SpendingKey` is `Copy`, so explicit zeroization of this + // local would require wrapping in `Zeroizing`; revisit when + // the spend signer lands.) Ok(Self { - spending_key: sk, full_viewing_key: fvk, spend_auth_key: ask, incoming_viewing_key: ivk, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs index a89f4273b59..c68e0eb7507 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs @@ -15,6 +15,7 @@ //! plug in their own persistence (SQLite, RocksDB, etc.) while tests use the //! in-memory implementation. +pub mod file_store; pub mod keys; pub mod note_selection; pub mod operations; @@ -22,13 +23,14 @@ pub mod prover; pub mod store; 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 sync::{ShieldedSyncSummary, SyncNotesResult}; use std::sync::Arc; -use dashcore::Network; use tokio::sync::RwLock; use crate::error::PlatformWalletError; @@ -46,8 +48,6 @@ use crate::error::PlatformWalletError; /// 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. -// Fields and accessors used by sync/operations modules (not yet implemented). -#[allow(dead_code)] pub struct ShieldedWallet { /// Dash Platform SDK handle for network operations. sdk: Arc, @@ -55,25 +55,24 @@ pub struct ShieldedWallet { keys: OrchardKeySet, /// Pluggable storage backend behind a shared async lock. store: Arc>, - /// Network (mainnet / testnet / devnet / regtest). - network: Network, } impl ShieldedWallet { /// Create a shielded wallet from pre-derived keys and a store. - pub fn new(sdk: Arc, keys: OrchardKeySet, store: S, network: Network) -> Self { + pub fn new(sdk: Arc, keys: OrchardKeySet, store: S) -> Self { Self { sdk, keys, store: Arc::new(RwLock::new(store)), - network, } } /// 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). + /// 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 /// @@ -81,12 +80,12 @@ impl ShieldedWallet { pub fn from_seed( sdk: Arc, seed: &[u8], - network: Network, + network: dashcore::Network, account: u32, store: S, ) -> Result { let keys = OrchardKeySet::from_seed(seed, network, account)?; - Ok(Self::new(sdk, keys, store, network)) + Ok(Self::new(sdk, keys, store)) } /// Total unspent shielded balance in credits. @@ -110,29 +109,4 @@ impl ShieldedWallet { pub fn address_at(&self, index: u32) -> grovedb_commitment_tree::PaymentAddress { self.keys.address_at(index) } - - // Accessors used by sync and operations modules (not yet implemented). - #[allow(dead_code)] - /// Access the SDK handle (for sync and operations modules). - pub(crate) fn sdk(&self) -> &dash_sdk::Sdk { - &self.sdk - } - - #[allow(dead_code)] - /// Access the key set (for sync and operations modules). - pub(crate) fn keys(&self) -> &OrchardKeySet { - &self.keys - } - - #[allow(dead_code)] - /// Access the store (for sync and operations modules). - pub(crate) fn store(&self) -> &Arc> { - &self.store - } - - #[allow(dead_code)] - /// Access the network (for sync and operations modules). - pub(crate) fn network(&self) -> Network { - self.network - } } diff --git a/packages/rs-sdk-ffi/Cargo.toml b/packages/rs-sdk-ffi/Cargo.toml index ca0b1998281..f56c7c9a88f 100644 --- a/packages/rs-sdk-ffi/Cargo.toml +++ b/packages/rs-sdk-ffi/Cargo.toml @@ -14,7 +14,6 @@ crate-type = ["staticlib", "cdylib", "rlib"] dash-sdk = { path = "../rs-sdk", features = [ "dpns-contract", "dashpay-contract", - "shielded", ] } drive-proof-verifier = { path = "../rs-drive-proof-verifier" } rs-sdk-trusted-context-provider = { path = "../rs-sdk-trusted-context-provider", features = [ @@ -63,7 +62,6 @@ libc = "0.2" # Cryptography getrandom = "0.2" -rand = "0.8" zeroize = "1.8" # Concurrency diff --git a/packages/rs-sdk-ffi/src/lib.rs b/packages/rs-sdk-ffi/src/lib.rs index f44402ec455..d367bb42527 100644 --- a/packages/rs-sdk-ffi/src/lib.rs +++ b/packages/rs-sdk-ffi/src/lib.rs @@ -19,10 +19,8 @@ mod error; mod evonode; mod group; mod identity; -mod nullifier_sync; mod protocol_version; mod sdk; -mod shielded; mod signer; mod signer_simple; mod system; @@ -48,10 +46,8 @@ pub use error::*; pub use evonode::*; pub use group::*; pub use identity::*; -pub use nullifier_sync::*; pub use protocol_version::*; pub use sdk::*; -pub use shielded::*; pub use signer::*; pub use signer_simple::*; pub use system::*; diff --git a/packages/rs-sdk-ffi/src/nullifier_sync/mod.rs b/packages/rs-sdk-ffi/src/nullifier_sync/mod.rs deleted file mode 100644 index 59ae2f45034..00000000000 --- a/packages/rs-sdk-ffi/src/nullifier_sync/mod.rs +++ /dev/null @@ -1,327 +0,0 @@ -//! Nullifier BLAST sync FFI bindings. -//! -//! Privacy-preserving nullifier status checking using trunk/branch chunk queries, -//! mirroring the `address_sync` pattern. - -mod types; - -pub use types::*; - -use crate::sdk::SDKWrapper; -use crate::types::SDKHandle; -use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, FFIError}; -use dash_sdk::platform::shielded::nullifier_sync::{ - NullifierSyncCheckpoint, NullifierSyncConfig, NullifierSyncResult, -}; -use dash_sdk::RequestSettings; -use tracing::{debug, error, info}; - -/// Synchronize nullifier statuses using privacy-preserving BLAST sync. -/// -/// Discovers which of the supplied nullifiers have been spent in the shielded pool. -/// Uses trunk/branch chunk queries for privacy (hides which specific nullifiers -/// the wallet cares about). -/// -/// # Parameters -/// - `sdk_handle`: SDK handle -/// - `nullifiers_ptr`: Contiguous array of 32-byte nullifier hashes -/// - `nullifiers_count`: Number of nullifiers (total bytes = count × 32) -/// - `config`: Optional sync config (null for defaults) -/// - `last_sync_height`: Height from previous sync (0 = full scan) -/// - `last_sync_timestamp`: Timestamp from previous sync (0 = full scan) -/// -/// # Returns -/// Pointer to `DashSDKNullifierSyncResult`. Free with `dash_sdk_nullifier_sync_result_free`. -/// Returns null on error. -/// -/// # Safety -/// - `sdk_handle` must be a valid SDK handle. -/// - `nullifiers_ptr` must point to `nullifiers_count × 32` valid bytes. -/// - `config` may be null (uses defaults). -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_sync_nullifiers( - sdk_handle: *const SDKHandle, - nullifiers_ptr: *const u8, - nullifiers_count: u32, - config: *const DashSDKNullifierSyncConfig, - last_sync_height: u64, - last_sync_timestamp: u64, -) -> *mut DashSDKNullifierSyncResult { - info!("dash_sdk_sync_nullifiers: called"); - - if sdk_handle.is_null() { - error!("dash_sdk_sync_nullifiers: SDK handle is null"); - return std::ptr::null_mut(); - } - - if nullifiers_ptr.is_null() || nullifiers_count == 0 { - error!("dash_sdk_sync_nullifiers: nullifiers pointer is null or count is zero"); - return std::ptr::null_mut(); - } - - let wrapper = &*(sdk_handle as *const SDKWrapper); - - // Parse nullifiers from raw bytes - let total_bytes = (nullifiers_count as usize) * 32; - let raw_bytes = std::slice::from_raw_parts(nullifiers_ptr, total_bytes); - let nullifiers: Vec<[u8; 32]> = raw_bytes - .chunks_exact(32) - .map(|chunk| { - let mut arr = [0u8; 32]; - arr.copy_from_slice(chunk); - arr - }) - .collect(); - - // Convert config - let rust_config = if config.is_null() { - None - } else { - let c = &*config; - let pool_identifier = if c.has_pool_identifier { - Some(c.pool_identifier) - } else { - None - }; - Some(NullifierSyncConfig { - min_privacy_count: c.min_privacy_count, - max_concurrent_requests: c.max_concurrent_requests as usize, - max_iterations: c.max_iterations as usize, - pool_type: c.pool_type, - pool_identifier, - full_rescan_after_time_s: c.full_rescan_after_time_s, - request_settings: RequestSettings::default(), - }) - }; - - // Convert checkpoint: 0 in either field means no previous sync (full scan) - let last_sync = if last_sync_height == 0 || last_sync_timestamp == 0 { - None - } else { - Some(NullifierSyncCheckpoint { - height: last_sync_height, - timestamp: last_sync_timestamp, - }) - }; - - debug!( - "dash_sdk_sync_nullifiers: syncing {} nullifiers, checkpoint: {:?}", - nullifiers_count, last_sync - ); - - let result = wrapper.runtime.block_on(async { - wrapper - .sdk - .sync_nullifiers(&nullifiers, rust_config, last_sync) - .await - }); - - match result { - Ok(sync_result) => { - info!( - "dash_sdk_sync_nullifiers: success - {} found, {} absent", - sync_result.found.len(), - sync_result.absent.len() - ); - Box::into_raw(Box::new(convert_nullifier_sync_result(sync_result))) - } - Err(e) => { - error!("dash_sdk_sync_nullifiers: error - {}", e); - std::ptr::null_mut() - } - } -} - -/// Synchronize nullifiers and return result via `DashSDKResult` for better error handling. -/// -/// Same as `dash_sdk_sync_nullifiers` but returns errors via `DashSDKResult`. -/// -/// # Safety -/// Same safety requirements as `dash_sdk_sync_nullifiers`. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_sync_nullifiers_with_result( - sdk_handle: *const SDKHandle, - nullifiers_ptr: *const u8, - nullifiers_count: u32, - config: *const DashSDKNullifierSyncConfig, - last_sync_height: u64, - last_sync_timestamp: u64, -) -> DashSDKResult { - if sdk_handle.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "SDK handle is null".to_string(), - )); - } - - if nullifiers_ptr.is_null() || nullifiers_count == 0 { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "Nullifiers pointer is null or count is zero".to_string(), - )); - } - - let wrapper = &*(sdk_handle as *const SDKWrapper); - - let total_bytes = (nullifiers_count as usize) * 32; - let raw_bytes = std::slice::from_raw_parts(nullifiers_ptr, total_bytes); - let nullifiers: Vec<[u8; 32]> = raw_bytes - .chunks_exact(32) - .map(|chunk| { - let mut arr = [0u8; 32]; - arr.copy_from_slice(chunk); - arr - }) - .collect(); - - let rust_config = if config.is_null() { - None - } else { - let c = &*config; - let pool_identifier = if c.has_pool_identifier { - Some(c.pool_identifier) - } else { - None - }; - Some(NullifierSyncConfig { - min_privacy_count: c.min_privacy_count, - max_concurrent_requests: c.max_concurrent_requests as usize, - max_iterations: c.max_iterations as usize, - pool_type: c.pool_type, - pool_identifier, - full_rescan_after_time_s: c.full_rescan_after_time_s, - request_settings: RequestSettings::default(), - }) - }; - - // Convert checkpoint: 0 in either field means no previous sync (full scan) - let last_sync = if last_sync_height == 0 || last_sync_timestamp == 0 { - None - } else { - Some(NullifierSyncCheckpoint { - height: last_sync_height, - timestamp: last_sync_timestamp, - }) - }; - - let result = wrapper.runtime.block_on(async { - wrapper - .sdk - .sync_nullifiers(&nullifiers, rust_config, last_sync) - .await - }); - - match result { - Ok(sync_result) => { - let ffi_result = Box::new(convert_nullifier_sync_result(sync_result)); - DashSDKResult::success(Box::into_raw(ffi_result) as *mut std::os::raw::c_void) - } - Err(e) => DashSDKResult::error(FFIError::SDKError(e).into()), - } -} - -/// Free a nullifier sync result. -/// -/// # Safety -/// - `result` must be a valid pointer from `dash_sdk_sync_nullifiers` or null (no-op). -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_nullifier_sync_result_free( - result: *mut DashSDKNullifierSyncResult, -) { - if result.is_null() { - return; - } - - let result = Box::from_raw(result); - - if !result.found.is_null() && result.found_count > 0 { - let len = (result.found_count as usize) * 32; - let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(result.found, len)); - } - - if !result.absent.is_null() && result.absent_count > 0 { - let len = (result.absent_count as usize) * 32; - let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(result.absent, len)); - } -} - -fn convert_nullifier_sync_result(result: NullifierSyncResult) -> DashSDKNullifierSyncResult { - // Convert found nullifiers to contiguous byte array - let found_count = result.found.len() as u32; - let found_ptr = if result.found.is_empty() { - std::ptr::null_mut() - } else { - let mut bytes: Vec = Vec::with_capacity(result.found.len() * 32); - for key in &result.found { - bytes.extend_from_slice(key); - } - let boxed = bytes.into_boxed_slice(); - Box::into_raw(boxed) as *mut u8 - }; - - // Convert absent nullifiers to contiguous byte array - let absent_count = result.absent.len() as u32; - let absent_ptr = if result.absent.is_empty() { - std::ptr::null_mut() - } else { - let mut bytes: Vec = Vec::with_capacity(result.absent.len() * 32); - for key in &result.absent { - bytes.extend_from_slice(key); - } - let boxed = bytes.into_boxed_slice(); - Box::into_raw(boxed) as *mut u8 - }; - - let metrics = DashSDKNullifierSyncMetrics { - trunk_queries: result.metrics.trunk_queries as u32, - branch_queries: result.metrics.branch_queries as u32, - total_elements_seen: result.metrics.total_elements_seen as u32, - total_proof_bytes: result.metrics.total_proof_bytes as u32, - branch_query_failures: result.metrics.branch_query_failures as u32, - iterations: result.metrics.iterations as u32, - compacted_queries: result.metrics.compacted_queries as u32, - recent_queries: result.metrics.recent_queries as u32, - }; - - DashSDKNullifierSyncResult { - found: found_ptr, - found_count, - absent: absent_ptr, - absent_count, - checkpoint_height: result.checkpoint_height, - new_sync_height: result.new_sync_height, - new_sync_timestamp: result.new_sync_timestamp, - metrics, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_config_default() { - let config = DashSDKNullifierSyncConfig::default(); - assert_eq!(config.min_privacy_count, 32); - assert_eq!(config.max_concurrent_requests, 10); - assert_eq!(config.max_iterations, 50); - assert_eq!(config.pool_type, 0); - assert!(!config.has_pool_identifier); - assert_eq!(config.full_rescan_after_time_s, 7 * 24 * 60 * 60); - } - - #[test] - fn test_null_sync_nullifiers() { - unsafe { - let result = dash_sdk_sync_nullifiers( - std::ptr::null(), - std::ptr::null(), - 0, - std::ptr::null(), - 0, - 0, - ); - assert!(result.is_null()); - } - } -} diff --git a/packages/rs-sdk-ffi/src/nullifier_sync/types.rs b/packages/rs-sdk-ffi/src/nullifier_sync/types.rs deleted file mode 100644 index de7d99fcc68..00000000000 --- a/packages/rs-sdk-ffi/src/nullifier_sync/types.rs +++ /dev/null @@ -1,87 +0,0 @@ -//! FFI-compatible types for nullifier synchronization. - -/// Configuration for nullifier BLAST sync. -/// -/// Pass null to `dash_sdk_sync_nullifiers` to use default values. -#[repr(C)] -#[derive(Debug, Clone)] -pub struct DashSDKNullifierSyncConfig { - /// Minimum privacy count — subtrees smaller than this are expanded. - /// Default: 32 - pub min_privacy_count: u64, - - /// Maximum concurrent branch queries. - /// Default: 10 - pub max_concurrent_requests: u32, - - /// Maximum number of iterations (safety limit). - /// Default: 50 - pub max_iterations: u32, - - /// Shielded pool type (0 = credit, 1 = main token, 2 = individual token). - /// Default: 0 - pub pool_type: u32, - - /// Optional 32-byte pool identifier for individual token pools. - /// Only used when `has_pool_identifier` is true. - pub pool_identifier: [u8; 32], - - /// Whether `pool_identifier` is valid. - pub has_pool_identifier: bool, - - /// Maximum age in seconds before a full tree rescan is forced. - /// Default: 604800 (7 days) - pub full_rescan_after_time_s: u64, -} - -impl Default for DashSDKNullifierSyncConfig { - fn default() -> Self { - Self { - min_privacy_count: 32, - max_concurrent_requests: 10, - max_iterations: 50, - pool_type: 0, - pool_identifier: [0u8; 32], - has_pool_identifier: false, - full_rescan_after_time_s: 7 * 24 * 60 * 60, - } - } -} - -/// Metrics about the nullifier sync process. -#[repr(C)] -#[derive(Debug, Clone, Default)] -pub struct DashSDKNullifierSyncMetrics { - pub trunk_queries: u32, - pub branch_queries: u32, - pub total_elements_seen: u32, - pub total_proof_bytes: u32, - pub branch_query_failures: u32, - pub iterations: u32, - pub compacted_queries: u32, - pub recent_queries: u32, -} - -/// Result of nullifier BLAST sync. -/// -/// `found` and `absent` are contiguous arrays of 32-byte nullifiers. -/// Free with `dash_sdk_nullifier_sync_result_free`. -#[repr(C)] -pub struct DashSDKNullifierSyncResult { - /// Contiguous array of found (spent) nullifiers, each 32 bytes. - pub found: *mut u8, - /// Number of found nullifiers. - pub found_count: u32, - /// Contiguous array of absent (unspent) nullifiers, each 32 bytes. - pub absent: *mut u8, - /// Number of absent nullifiers. - pub absent_count: u32, - /// Block height of the tree snapshot (from trunk/branch scan). - pub checkpoint_height: u64, - /// Highest block height seen — persist for next sync call. - pub new_sync_height: u64, - /// Block time at the latest response — persist for next sync call. - pub new_sync_timestamp: u64, - /// Sync metrics. - pub metrics: DashSDKNullifierSyncMetrics, -} diff --git a/packages/rs-sdk-ffi/src/shielded/crypto/address.rs b/packages/rs-sdk-ffi/src/shielded/crypto/address.rs deleted file mode 100644 index a90dc5586a2..00000000000 --- a/packages/rs-sdk-ffi/src/shielded/crypto/address.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! Orchard address derivation FFI function. - -use std::ffi::CString; -use std::os::raw::c_void; - -use dash_sdk::grovedb_commitment_tree::{FullViewingKey, Scope, SpendingKey}; -use zeroize::Zeroize; - -use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, DashSDKResultDataType}; - -/// Derive an Orchard payment address from a 32-byte spending key. -/// -/// # Parameters -/// - `spending_key_bytes`: Pointer to a 32-byte spending key. -/// - `diversifier_index`: Address diversifier index (usually 0). -/// -/// # Returns -/// `DashSDKResult` with a hex-encoded 43-byte Orchard raw address string on success. -/// The string is returned inside `DashSDKResult.data` and freed when the result is consumed. -/// -/// # Safety -/// - `spending_key_bytes` must point to exactly 32 valid bytes. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_shielded_derive_address( - spending_key_bytes: *const [u8; 32], - diversifier_index: u32, -) -> DashSDKResult { - if spending_key_bytes.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "spending_key_bytes is null".to_string(), - )); - } - - let mut key_copy = *spending_key_bytes; - - let sk = match SpendingKey::from_bytes(key_copy).into_option() { - Some(sk) => { - key_copy.zeroize(); - sk - } - None => { - key_copy.zeroize(); - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "Invalid spending key bytes".to_string(), - )); - } - }; - - let fvk = FullViewingKey::from(&sk); - let payment_address = fvk.address_at(diversifier_index, Scope::External); - let raw_bytes = payment_address.to_raw_address_bytes(); - let hex_str = hex::encode(raw_bytes); - - match CString::new(hex_str) { - Ok(c_str) => DashSDKResult { - data_type: DashSDKResultDataType::String, - data: c_str.into_raw() as *mut c_void, - error: std::ptr::null_mut(), - }, - Err(e) => DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InternalError, - format!("Failed to create CString: {}", e), - )), - } -} diff --git a/packages/rs-sdk-ffi/src/shielded/crypto/bundle_build.rs b/packages/rs-sdk-ffi/src/shielded/crypto/bundle_build.rs deleted file mode 100644 index 4cf894428b9..00000000000 --- a/packages/rs-sdk-ffi/src/shielded/crypto/bundle_build.rs +++ /dev/null @@ -1,1051 +0,0 @@ -//! Orchard bundle building FFI functions. -//! -//! These functions construct authorized Orchard bundles internally (proof + signatures) -//! and return a heap-allocated `DashSDKOrchardBundleParams` pointer via `DashSDKResult`. -//! Free the returned bundle with `dash_sdk_shielded_bundle_params_free`. -//! -//! Since the DPP builder helpers (`build_output_only_bundle`, `build_spend_bundle`) are -//! `pub(crate)`, we replicate the bundle construction logic here using the public -//! `grovedb_commitment_tree` Builder API and `dpp::shielded` public functions -//! (`serialize_authorized_bundle`, `compute_platform_sighash`). - -use std::os::raw::c_void; - -use dash_sdk::dpp::identity::core_script::CoreScript; -use dash_sdk::dpp::shielded::builder::{serialize_authorized_bundle, OrchardProver}; -use dash_sdk::dpp::shielded::{compute_minimum_shielded_fee, compute_platform_sighash}; -use dash_sdk::dpp::version::PlatformVersion; -use dash_sdk::grovedb_commitment_tree::{ - Anchor, Builder, BundleType, DashMemo, Flags as OrchardFlags, FullViewingKey, Hashable, - MerkleHashOrchard, MerklePath, Note, NoteValue, PaymentAddress, RandomSeed, Rho, Scope, - SpendAuthorizingKey, SpendingKey, NOTE_COMMITMENT_TREE_DEPTH, -}; -use rand::rngs::OsRng; -use zeroize::Zeroize; - -use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, DashSDKResultDataType}; - -use super::CachedProver; - -// --------------------------------------------------------------------------- -// JSON input/output structures -// --------------------------------------------------------------------------- - -/// A spendable note parsed from JSON input. -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct SpendableNoteJson { - /// Hex-encoded 43-byte Orchard address. - address: String, - /// Note value in credits. - value: u64, - /// Hex-encoded 32-byte Rho. - rho: String, - /// Hex-encoded 32-byte random seed. - rseed: String, - /// Position in the commitment tree. - position: u32, - /// Array of 32 hex-encoded 32-byte Merkle path hashes. - merkle_path: Vec, -} - -/// Parsed spendable note with its Merkle path. -struct ParsedSpendableNote { - note: Note, - merkle_path: MerklePath, -} - -// --------------------------------------------------------------------------- -// Internal helpers -// --------------------------------------------------------------------------- - -/// Convert a `SerializedBundle` into a heap-allocated `DashSDKOrchardBundleParams`. -/// -/// The returned pointer owns all its inner allocations (actions array, each -/// action's encrypted_note, and proof bytes). Free with -/// `dash_sdk_shielded_bundle_params_free`. -pub(crate) fn bundle_to_ffi_params( - sb: &dash_sdk::dpp::shielded::builder::SerializedBundle, -) -> *mut crate::shielded::types::DashSDKOrchardBundleParams { - use crate::shielded::types::{DashSDKOrchardBundleParams, DashSDKSerializedAction}; - - // Build heap-allocated actions with heap-allocated encrypted_note blobs. - let ffi_actions: Vec = sb - .actions - .iter() - .map(|a| { - let enc_note = a.encrypted_note.clone().into_boxed_slice(); - let enc_note_len = enc_note.len(); - let enc_note_ptr = Box::into_raw(enc_note) as *const u8; - DashSDKSerializedAction { - nullifier: a.nullifier, - rk: a.rk, - cmx: a.cmx, - encrypted_note: enc_note_ptr, - encrypted_note_len: enc_note_len, - cv_net: a.cv_net, - spend_auth_sig: a.spend_auth_sig, - } - }) - .collect(); - - let actions_count = ffi_actions.len() as u32; - let actions_box = ffi_actions.into_boxed_slice(); - let actions_ptr = Box::into_raw(actions_box) as *const DashSDKSerializedAction; - - let proof = sb.proof.clone().into_boxed_slice(); - let proof_len = proof.len(); - let proof_ptr = Box::into_raw(proof) as *const u8; - - let params = DashSDKOrchardBundleParams { - actions: actions_ptr, - actions_count, - anchor: sb.anchor, - proof: proof_ptr, - proof_len, - binding_signature: sb.binding_signature, - }; - - Box::into_raw(Box::new(params)) -} - -/// Return a DashSDKResult containing a heap-allocated `DashSDKOrchardBundleParams`. -fn bundle_result(sb: &dash_sdk::dpp::shielded::builder::SerializedBundle) -> DashSDKResult { - let ptr = bundle_to_ffi_params(sb); - DashSDKResult { - data_type: DashSDKResultDataType::BinaryData, - data: ptr as *mut c_void, - error: std::ptr::null_mut(), - } -} - -/// Free a heap-allocated `DashSDKOrchardBundleParams` and all its inner allocations. -/// -/// # Safety -/// - `bundle` must be a pointer returned by one of the `dash_sdk_shielded_build_*_bundle` -/// functions and not previously freed. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_shielded_bundle_params_free( - bundle: *mut crate::shielded::types::DashSDKOrchardBundleParams, -) { - use crate::shielded::types::DashSDKSerializedAction; - - if bundle.is_null() { - return; - } - - let params = Box::from_raw(bundle); - - // Free each action's encrypted_note allocation. - if !params.actions.is_null() && params.actions_count > 0 { - let actions_ptr = params.actions as *mut DashSDKSerializedAction; - let actions_count = params.actions_count as usize; - let actions_slice = std::slice::from_raw_parts(actions_ptr, actions_count); - for action in actions_slice.iter() { - if !action.encrypted_note.is_null() && action.encrypted_note_len > 0 { - drop(Vec::from_raw_parts( - action.encrypted_note as *mut u8, - action.encrypted_note_len, - action.encrypted_note_len, - )); - } - } - // Free the actions array itself. - drop(Vec::from_raw_parts( - actions_ptr, - actions_count, - actions_count, - )); - } - - // Free the proof allocation. - if !params.proof.is_null() && params.proof_len > 0 { - drop(Vec::from_raw_parts( - params.proof as *mut u8, - params.proof_len, - params.proof_len, - )); - } -} - -/// Parse the optional 36-byte memo pointer. Returns `[0u8; 36]` if null. -unsafe fn parse_memo(memo: *const [u8; 36]) -> [u8; 36] { - if memo.is_null() { - [0u8; 36] - } else { - *memo - } -} - -/// Derive FullViewingKey and SpendAuthorizingKey from raw spending key bytes. -/// The local copy of key bytes is zeroized after derivation. -fn derive_keys(sk_bytes: &[u8; 32]) -> Result<(FullViewingKey, SpendAuthorizingKey), String> { - let mut key_copy = *sk_bytes; - let sk: SpendingKey = match SpendingKey::from_bytes(key_copy).into_option() { - Some(sk) => { - key_copy.zeroize(); - sk - } - None => { - key_copy.zeroize(); - return Err("Invalid spending key bytes".to_string()); - } - }; - let fvk = FullViewingKey::from(&sk); - let ask = SpendAuthorizingKey::from(&sk); - // sk is dropped here; SpendingKey may or may not zeroize on drop - // but key_copy is already zeroed above - Ok((fvk, ask)) -} - -/// Decode a hex string into a fixed-size byte array. -fn hex_to_array(hex_str: &str, field_name: &str) -> Result<[u8; N], String> { - let bytes = hex::decode(hex_str) - .map_err(|e| format!("Failed to decode hex for {}: {}", field_name, e))?; - bytes.try_into().map_err(|_| { - format!( - "{} must be {} bytes, got {}", - field_name, - N, - hex_str.len() / 2 - ) - }) -} - -/// Parse a JSON string into a vector of `ParsedSpendableNote`. -fn parse_spendable_notes(notes_json: &str) -> Result, String> { - let notes: Vec = serde_json::from_str(notes_json) - .map_err(|e| format!("Failed to parse notes JSON: {}", e))?; - - let mut spendable_notes = Vec::with_capacity(notes.len()); - - for (i, n) in notes.iter().enumerate() { - // Parse address to get PaymentAddress (for Note::from_parts) - let addr_bytes: [u8; 43] = hex_to_array(&n.address, &format!("notes[{}].address", i))?; - let payment_address = PaymentAddress::from_raw_address_bytes(&addr_bytes) - .into_option() - .ok_or_else(|| format!("notes[{}].address is not a valid Orchard address", i))?; - - // Parse Rho - let rho_bytes: [u8; 32] = hex_to_array(&n.rho, &format!("notes[{}].rho", i))?; - let rho = Rho::from_bytes(&rho_bytes) - .into_option() - .ok_or_else(|| format!("notes[{}].rho is not a valid Rho", i))?; - - // Parse RandomSeed - let rseed_bytes: [u8; 32] = hex_to_array(&n.rseed, &format!("notes[{}].rseed", i))?; - let rseed = RandomSeed::from_bytes(rseed_bytes, &rho) - .into_option() - .ok_or_else(|| format!("notes[{}].rseed is not a valid RandomSeed", i))?; - - // Construct Note - let note = Note::from_parts(payment_address, NoteValue::from_raw(n.value), rho, rseed) - .into_option() - .ok_or_else(|| format!("notes[{}] failed to construct valid Note", i))?; - - // Parse Merkle path - if n.merkle_path.len() != NOTE_COMMITMENT_TREE_DEPTH { - return Err(format!( - "notes[{}].merklePath must have {} entries, got {}", - i, - NOTE_COMMITMENT_TREE_DEPTH, - n.merkle_path.len() - )); - } - - let mut auth_path = [MerkleHashOrchard::empty_leaf(); NOTE_COMMITMENT_TREE_DEPTH]; - for (j, hash_hex) in n.merkle_path.iter().enumerate() { - let hash_bytes: [u8; 32] = - hex_to_array(hash_hex, &format!("notes[{}].merklePath[{}]", i, j))?; - auth_path[j] = MerkleHashOrchard::from_bytes(&hash_bytes) - .into_option() - .ok_or_else(|| { - format!( - "notes[{}].merklePath[{}] is not a valid MerkleHashOrchard", - i, j - ) - })?; - } - - let merkle_path = MerklePath::from_parts(n.position, auth_path); - - spendable_notes.push(ParsedSpendableNote { note, merkle_path }); - } - - Ok(spendable_notes) -} - -/// Parse an anchor from 32-byte pointer. -unsafe fn parse_anchor(anchor_bytes: *const [u8; 32]) -> Result { - if anchor_bytes.is_null() { - return Err("anchor_bytes is null".to_string()); - } - let bytes = &*anchor_bytes; - Anchor::from_bytes(*bytes) - .into_option() - .ok_or_else(|| "Invalid anchor bytes".to_string()) -} - -/// Parse a C string into a Rust &str. -unsafe fn parse_c_str<'a>(ptr: *const std::os::raw::c_char, name: &str) -> Result<&'a str, String> { - if ptr.is_null() { - return Err(format!("{} is null", name)); - } - std::ffi::CStr::from_ptr(ptr) - .to_str() - .map_err(|e| format!("{} is not valid UTF-8: {}", name, e)) -} - -/// Build an output-only Orchard bundle (no spends). Replicates the logic from -/// `dpp::shielded::builder::build_output_only_bundle` which is `pub(crate)`. -fn build_output_only_bundle_local( - recipient: &PaymentAddress, - amount: u64, - memo: [u8; 36], - prover: &CachedProver, -) -> Result { - let anchor = Anchor::empty_tree(); - let mut builder = Builder::::new( - BundleType::Transactional { - flags: OrchardFlags::SPENDS_DISABLED, - bundle_required: false, - }, - anchor, - ); - - builder - .add_output(None, *recipient, NoteValue::from_raw(amount), memo) - .map_err(|e| format!("failed to add output: {:?}", e))?; - - let bundle = prove_and_sign_bundle_local(builder, prover, &[], &[])?; - Ok(serialize_authorized_bundle(&bundle)) -} - -/// An optional change output to add to the bundle. -struct ChangeOutput { - address: PaymentAddress, - amount: u64, -} - -/// Build a spend+output Orchard bundle. Replicates the logic from -/// `dpp::shielded::builder::build_spend_bundle` which is `pub(crate)`. -/// -/// If `change` is `Some`, a second output is added returning change to the sender. -#[allow(clippy::too_many_arguments)] -fn build_spend_bundle_local( - spends: Vec, - output_address: &PaymentAddress, - output_amount: u64, - memo: [u8; 36], - change: Option, - fvk: &FullViewingKey, - ask: &SpendAuthorizingKey, - anchor: Anchor, - prover: &CachedProver, - extra_sighash_data: &[u8], -) -> Result { - let mut builder = Builder::::new(BundleType::DEFAULT, anchor); - - for spend in spends { - builder - .add_spend(fvk.clone(), spend.note, spend.merkle_path) - .map_err(|e| format!("failed to add spend: {:?}", e))?; - } - - // Primary output - builder - .add_output( - None, - *output_address, - NoteValue::from_raw(output_amount), - memo, - ) - .map_err(|e| format!("failed to add output: {:?}", e))?; - - // Change output (if any) - if let Some(ch) = change { - if ch.amount > 0 { - builder - .add_output( - None, - ch.address, - NoteValue::from_raw(ch.amount), - [0u8; 36], // change memo is always empty - ) - .map_err(|e| format!("failed to add change output: {:?}", e))?; - } - } - - let bundle = prove_and_sign_bundle_local( - builder, - prover, - std::slice::from_ref(ask), - extra_sighash_data, - )?; - Ok(serialize_authorized_bundle(&bundle)) -} - -/// Prove and sign an Orchard bundle. Replicates the logic from -/// `dpp::shielded::builder::prove_and_sign_bundle` which is `pub(crate)`. -fn prove_and_sign_bundle_local( - builder: Builder, - prover: &CachedProver, - signing_keys: &[SpendAuthorizingKey], - extra_sighash_data: &[u8], -) -> Result< - dash_sdk::grovedb_commitment_tree::Bundle< - dash_sdk::grovedb_commitment_tree::Authorized, - i64, - DashMemo, - >, - String, -> { - let mut rng = OsRng; - - let (unauthorized, _) = builder - .build::(&mut rng) - .map_err(|e| format!("failed to build bundle: {:?}", e))? - .ok_or_else(|| "bundle was empty after build".to_string())?; - - let bundle_commitment: [u8; 32] = unauthorized.commitment().into(); - let sighash = compute_platform_sighash(&bundle_commitment, extra_sighash_data); - - let proven = unauthorized - .create_proof(prover.proving_key(), &mut rng) - .map_err(|e| format!("failed to create proof: {:?}", e))?; - - proven - .apply_signatures(rng, sighash, signing_keys) - .map_err(|e| format!("failed to apply signatures: {:?}", e)) -} - -// --------------------------------------------------------------------------- -// FFI functions -// --------------------------------------------------------------------------- - -/// Build an output-only (shield) Orchard bundle. -/// -/// This is the simplest bundle type: it creates a new note for the recipient -/// with no spends. Used when shielding transparent platform credits. -/// -/// # Parameters -/// - `spending_key_bytes`: 32-byte spending key (recipient address derived at index 0). -/// - `amount`: Amount in credits to shield. -/// - `memo`: Optional 36-byte memo (null for zero memo). -/// -/// # Returns -/// JSON string with the serialized bundle (see module docs for format). -/// -/// # Safety -/// - `spending_key_bytes` must point to exactly 32 bytes. -/// - `memo`, if non-null, must point to exactly 36 bytes. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_shielded_build_shield_bundle( - spending_key_bytes: *const [u8; 32], - amount: u64, - memo: *const [u8; 36], -) -> DashSDKResult { - if spending_key_bytes.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "spending_key_bytes is null".to_string(), - )); - } - - if amount == 0 { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "shield amount must be greater than zero".to_string(), - )); - } - - let sk_bytes = &*spending_key_bytes; - let memo = parse_memo(memo); - - let (fvk, _ask) = match derive_keys(sk_bytes) { - Ok(keys) => keys, - Err(e) => { - return DashSDKResult::error(DashSDKError::new(DashSDKErrorCode::InvalidParameter, e)) - } - }; - - let recipient = fvk.address_at(0u32, Scope::External); - let prover = CachedProver; - - let sb = match build_output_only_bundle_local(&recipient, amount, memo, &prover) { - Ok(sb) => sb, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::CryptoError, - format!("Failed to build shield bundle: {}", e), - )) - } - }; - - bundle_result(&sb) -} - -/// Build a shielded transfer bundle (shielded-to-shielded). -/// -/// Spends existing notes and creates a new note for the recipient. Change -/// (if any) is returned to the sender's own address (derived at index 0). -/// -/// # Parameters -/// - `spending_key_bytes`: 32-byte spending key of the sender. -/// - `anchor_bytes`: 32-byte Sinsemilla anchor of the commitment tree. -/// - `notes_json`: JSON array of spendable notes (see module docs for format). -/// - `recipient_addr_bytes`: Raw 43-byte Orchard address of the recipient. -/// - `recipient_addr_len`: Length of recipient address (must be 43). -/// - `transfer_amount`: Amount in credits to transfer to the recipient. -/// - `memo`: Optional 36-byte memo (null for zero memo). -/// -/// # Returns -/// JSON string with the serialized bundle. -/// -/// # Safety -/// - All pointers must be valid for their specified lengths. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_shielded_build_transfer_bundle( - spending_key_bytes: *const [u8; 32], - anchor_bytes: *const [u8; 32], - notes_json: *const std::os::raw::c_char, - recipient_addr_bytes: *const u8, - recipient_addr_len: usize, - transfer_amount: u64, - memo: *const [u8; 36], -) -> DashSDKResult { - if spending_key_bytes.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "spending_key_bytes is null".to_string(), - )); - } - - let sk_bytes = &*spending_key_bytes; - let memo = parse_memo(memo); - - let anchor = match parse_anchor(anchor_bytes) { - Ok(a) => a, - Err(e) => { - return DashSDKResult::error(DashSDKError::new(DashSDKErrorCode::InvalidParameter, e)) - } - }; - - let notes_str = match parse_c_str(notes_json, "notes_json") { - Ok(s) => s, - Err(e) => { - return DashSDKResult::error(DashSDKError::new(DashSDKErrorCode::InvalidParameter, e)) - } - }; - - let spends = match parse_spendable_notes(notes_str) { - Ok(n) => n, - Err(e) => { - return DashSDKResult::error(DashSDKError::new(DashSDKErrorCode::InvalidParameter, e)) - } - }; - - // Parse recipient address - if recipient_addr_bytes.is_null() || recipient_addr_len != 43 { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!( - "recipient address must be 43 bytes, got {}", - if recipient_addr_bytes.is_null() { - 0 - } else { - recipient_addr_len - } - ), - )); - } - let addr_slice = std::slice::from_raw_parts(recipient_addr_bytes, 43); - let mut addr_array = [0u8; 43]; - addr_array.copy_from_slice(addr_slice); - let recipient_payment = match PaymentAddress::from_raw_address_bytes(&addr_array).into_option() - { - Some(a) => a, - None => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "Invalid recipient address: not a valid Pallas curve point".to_string(), - )) - } - }; - - let (fvk, ask) = match derive_keys(sk_bytes) { - Ok(keys) => keys, - Err(e) => { - return DashSDKResult::error(DashSDKError::new(DashSDKErrorCode::InvalidParameter, e)) - } - }; - - let prover = CachedProver; - let platform_version = PlatformVersion::latest(); - - // Compute total spent value with overflow check - let total_spent: u64 = match spends - .iter() - .try_fold(0u64, |acc, s| acc.checked_add(s.note.value().inner())) - { - Some(v) => v, - None => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "total note values overflow u64".to_string(), - )) - } - }; - - // Try with no-change fee first (recipient output only). If there's surplus - // after that, recompute with change output included. - let no_change_actions = spends.len().max(1); - let no_change_fee = compute_minimum_shielded_fee(no_change_actions, platform_version); - let no_change_required = match transfer_amount.checked_add(no_change_fee) { - Some(v) => v, - None => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "transfer amount + fee overflows u64".to_string(), - )) - } - }; - - let (min_fee, change_amount) = if no_change_required == total_spent { - // Exact match — no change output needed - (no_change_fee, 0u64) - } else if no_change_required > total_spent { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!( - "transfer amount {} + fee {} = {} exceeds total spendable value {}", - transfer_amount, no_change_fee, no_change_required, total_spent - ), - )); - } else { - // Surplus exists — recompute with change output (adds an action) - let with_change_actions = spends.len().max(2); - let with_change_fee = compute_minimum_shielded_fee(with_change_actions, platform_version); - let with_change_required = match transfer_amount.checked_add(with_change_fee) { - Some(v) => v, - None => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "transfer amount + fee overflows u64".to_string(), - )) - } - }; - if with_change_required > total_spent { - // Can't afford change output fee — use no-change fee and burn the dust - (no_change_fee, 0u64) - } else { - (with_change_fee, total_spent - with_change_required) - } - }; - - // Validate required <= i64::MAX (Orchard value_balance is i64) - let required = transfer_amount + min_fee + change_amount; - debug_assert_eq!(required, total_spent - change_amount + change_amount); // sanity - let value_balance = min_fee; // only fee leaves the shielded pool - if value_balance > i64::MAX as u64 { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!( - "fee {} exceeds i64::MAX, cannot represent as Orchard value_balance", - value_balance - ), - )); - } - - // Change goes back to sender's address (derived at index 0) - let change_output = if change_amount > 0 { - let change_address = fvk.address_at(0u32, Scope::External); - Some(ChangeOutput { - address: change_address, - amount: change_amount, - }) - } else { - None - }; - - // ShieldedTransfer: recipient gets transfer_amount, sender gets change, fee leaves pool. - // No extra sighash data for transfers. - let sb = match build_spend_bundle_local( - spends, - &recipient_payment, - transfer_amount, - memo, - change_output, - &fvk, - &ask, - anchor, - &prover, - &[], - ) { - Ok(sb) => sb, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::CryptoError, - format!("Failed to build transfer bundle: {}", e), - )) - } - }; - - bundle_result(&sb) -} - -/// Build an unshield bundle (shielded pool -> platform address). -/// -/// Spends existing notes and creates a change output back to the sender. -/// The `output_address` receives funds via the state transition's transparent field. -/// -/// # Parameters -/// - `spending_key_bytes`: 32-byte spending key. -/// - `anchor_bytes`: 32-byte Sinsemilla anchor. -/// - `notes_json`: JSON array of spendable notes. -/// - `output_addr_bytes`: Platform address bytes for the unshield recipient. -/// - `output_addr_len`: Length of `output_addr_bytes`. -/// - `unshield_amount`: Amount to unshield. -/// - `memo`: Optional 36-byte memo (null for zero memo). -/// -/// # Returns -/// JSON string with the serialized bundle. -/// -/// # Safety -/// - All pointers must be valid for their specified lengths. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_shielded_build_unshield_bundle( - spending_key_bytes: *const [u8; 32], - anchor_bytes: *const [u8; 32], - notes_json: *const std::os::raw::c_char, - output_addr_bytes: *const u8, - output_addr_len: usize, - unshield_amount: u64, - memo: *const [u8; 36], -) -> DashSDKResult { - if spending_key_bytes.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "spending_key_bytes is null".to_string(), - )); - } - - let sk_bytes = &*spending_key_bytes; - let memo = parse_memo(memo); - - let anchor = match parse_anchor(anchor_bytes) { - Ok(a) => a, - Err(e) => { - return DashSDKResult::error(DashSDKError::new(DashSDKErrorCode::InvalidParameter, e)) - } - }; - - let notes_str = match parse_c_str(notes_json, "notes_json") { - Ok(s) => s, - Err(e) => { - return DashSDKResult::error(DashSDKError::new(DashSDKErrorCode::InvalidParameter, e)) - } - }; - - let spends = match parse_spendable_notes(notes_str) { - Ok(n) => n, - Err(e) => { - return DashSDKResult::error(DashSDKError::new(DashSDKErrorCode::InvalidParameter, e)) - } - }; - - // Parse output address bytes - if output_addr_bytes.is_null() || output_addr_len == 0 { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "output_addr_bytes is null or empty".to_string(), - )); - } - let addr_slice = std::slice::from_raw_parts(output_addr_bytes, output_addr_len); - let output_address = match dash_sdk::dpp::address_funds::PlatformAddress::from_bytes(addr_slice) - { - Ok(a) => a, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!("Invalid output address: {}", e), - )) - } - }; - - let (fvk, ask) = match derive_keys(sk_bytes) { - Ok(keys) => keys, - Err(e) => { - return DashSDKResult::error(DashSDKError::new(DashSDKErrorCode::InvalidParameter, e)) - } - }; - - if unshield_amount > i64::MAX as u64 { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!( - "unshield amount {} exceeds maximum allowed value {}", - unshield_amount, - i64::MAX as u64 - ), - )); - } - - let change_payment = fvk.address_at(0u32, Scope::External); - let prover = CachedProver; - let platform_version = PlatformVersion::latest(); - - // Compute total spent value with overflow check - let total_spent: u64 = match spends - .iter() - .try_fold(0u64, |acc, s| acc.checked_add(s.note.value().inner())) - { - Some(v) => v, - None => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "total note values overflow u64".to_string(), - )) - } - }; - - // Compute minimum fee - let num_actions = spends.len().max(1); - let min_fee = compute_minimum_shielded_fee(num_actions, platform_version); - - // Validate sufficient funds for unshield + fee - let required = match unshield_amount.checked_add(min_fee) { - Some(v) => v, - None => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "unshield amount + fee overflows u64".to_string(), - )) - } - }; - if required > i64::MAX as u64 { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!( - "unshield amount {} + fee {} = {} exceeds i64::MAX", - unshield_amount, min_fee, required - ), - )); - } - if required > total_spent { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!( - "unshield amount {} + fee {} = {} exceeds total spendable value {}", - unshield_amount, min_fee, required, total_spent - ), - )); - } - - let change_amount = total_spent - required; - - // Unshield extra_data = output_address || value_balance (le bytes) - // value_balance = unshield_amount + fee, becomes v0.unshielding_amount in the state transition - // Must match server-side sighash in shielded_proof.rs - let mut extra_sighash_data = output_address.to_bytes(); - extra_sighash_data.extend_from_slice(&required.to_le_bytes()); - - let sb = match build_spend_bundle_local( - spends, - &change_payment, - change_amount, - memo, - None, // no second output — the unshield amount goes via transparent field - &fvk, - &ask, - anchor, - &prover, - &extra_sighash_data, - ) { - Ok(sb) => sb, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::CryptoError, - format!("Failed to build unshield bundle: {}", e), - )) - } - }; - - bundle_result(&sb) -} - -/// Build a withdrawal bundle (shielded pool -> core L1 address). -/// -/// Spends existing notes and creates a change output back to the sender. -/// The `output_script` receives funds via the state transition's withdrawal mechanism. -/// -/// # Parameters -/// - `spending_key_bytes`: 32-byte spending key. -/// - `anchor_bytes`: 32-byte Sinsemilla anchor. -/// - `notes_json`: JSON array of spendable notes. -/// - `output_script`: Core chain script bytes. -/// - `output_script_len`: Length of `output_script`. -/// - `withdrawal_amount`: Amount to withdraw. -/// - `memo`: Optional 36-byte memo (null for zero memo). -/// - `core_fee_per_byte`: Core chain fee rate (unused in bundle, included for API consistency). -/// - `pooling`: Withdrawal pooling strategy (0=Never, 1=IfAvailable, 2=Standard; unused in bundle). -/// -/// # Returns -/// JSON string with the serialized bundle. -/// -/// # Safety -/// - All pointers must be valid for their specified lengths. -#[no_mangle] -#[allow(clippy::too_many_arguments)] -pub unsafe extern "C" fn dash_sdk_shielded_build_withdrawal_bundle( - spending_key_bytes: *const [u8; 32], - anchor_bytes: *const [u8; 32], - notes_json: *const std::os::raw::c_char, - output_script: *const u8, - output_script_len: usize, - withdrawal_amount: u64, - memo: *const [u8; 36], - _core_fee_per_byte: u32, - _pooling: u8, -) -> DashSDKResult { - if spending_key_bytes.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "spending_key_bytes is null".to_string(), - )); - } - - let sk_bytes = &*spending_key_bytes; - let memo = parse_memo(memo); - - let anchor = match parse_anchor(anchor_bytes) { - Ok(a) => a, - Err(e) => { - return DashSDKResult::error(DashSDKError::new(DashSDKErrorCode::InvalidParameter, e)) - } - }; - - let notes_str = match parse_c_str(notes_json, "notes_json") { - Ok(s) => s, - Err(e) => { - return DashSDKResult::error(DashSDKError::new(DashSDKErrorCode::InvalidParameter, e)) - } - }; - - let spends = match parse_spendable_notes(notes_str) { - Ok(n) => n, - Err(e) => { - return DashSDKResult::error(DashSDKError::new(DashSDKErrorCode::InvalidParameter, e)) - } - }; - - // Parse output script - if output_script.is_null() || output_script_len == 0 { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "output_script is null or empty".to_string(), - )); - } - let script_bytes = std::slice::from_raw_parts(output_script, output_script_len); - let core_script = CoreScript::from_bytes(script_bytes.to_vec()); - - let (fvk, ask) = match derive_keys(sk_bytes) { - Ok(keys) => keys, - Err(e) => { - return DashSDKResult::error(DashSDKError::new(DashSDKErrorCode::InvalidParameter, e)) - } - }; - - if withdrawal_amount > i64::MAX as u64 { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!( - "withdrawal amount {} exceeds maximum allowed value {}", - withdrawal_amount, - i64::MAX as u64 - ), - )); - } - - let change_payment = fvk.address_at(0u32, Scope::External); - let prover = CachedProver; - let platform_version = PlatformVersion::latest(); - - // Compute total spent value with overflow check - let total_spent: u64 = match spends - .iter() - .try_fold(0u64, |acc, s| acc.checked_add(s.note.value().inner())) - { - Some(v) => v, - None => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "total note values overflow u64".to_string(), - )) - } - }; - - // Compute minimum fee - let num_actions = spends.len().max(1); - let min_fee = compute_minimum_shielded_fee(num_actions, platform_version); - - // Validate sufficient funds for withdrawal + fee - let required = match withdrawal_amount.checked_add(min_fee) { - Some(v) => v, - None => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "withdrawal amount + fee overflows u64".to_string(), - )) - } - }; - if required > i64::MAX as u64 { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!( - "withdrawal amount {} + fee {} = {} exceeds i64::MAX", - withdrawal_amount, min_fee, required - ), - )); - } - if required > total_spent { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!( - "withdrawal amount {} + fee {} = {} exceeds total spendable value {}", - withdrawal_amount, min_fee, required, total_spent - ), - )); - } - - let change_amount = total_spent - required; - - // ShieldedWithdrawal extra_data = output_script || value_balance (le bytes) - // value_balance = withdrawal_amount + fee, becomes v0.unshielding_amount in the state transition - // Must match server-side sighash in shielded_proof.rs - let mut extra_sighash_data = core_script.as_bytes().to_vec(); - extra_sighash_data.extend_from_slice(&required.to_le_bytes()); - - let sb = match build_spend_bundle_local( - spends, - &change_payment, - change_amount, - memo, - None, // no second output — withdrawal goes via transparent field - &fvk, - &ask, - anchor, - &prover, - &extra_sighash_data, - ) { - Ok(sb) => sb, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::CryptoError, - format!("Failed to build withdrawal bundle: {}", e), - )) - } - }; - - bundle_result(&sb) -} diff --git a/packages/rs-sdk-ffi/src/shielded/crypto/decrypt.rs b/packages/rs-sdk-ffi/src/shielded/crypto/decrypt.rs deleted file mode 100644 index 735e35beec1..00000000000 --- a/packages/rs-sdk-ffi/src/shielded/crypto/decrypt.rs +++ /dev/null @@ -1,224 +0,0 @@ -//! Orchard note trial decryption FFI function. - -use std::ffi::CString; -use std::os::raw::c_void; - -use dash_sdk::grovedb_commitment_tree::{ - FullViewingKey, PreparedIncomingViewingKey, Scope, SpendingKey, -}; -use drive_proof_verifier::types::ShieldedEncryptedNote; -use zeroize::Zeroize; - -use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, DashSDKResultDataType}; - -/// Encrypted note parsed from JSON input. -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct EncryptedNoteJson { - /// Hex-encoded 32-byte note commitment. - cmx: String, - /// Hex-encoded 32-byte nullifier. - nullifier: String, - /// Hex-encoded 216-byte encrypted note data. - encrypted_note: String, -} - -/// Decrypted note for JSON output. -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -struct DecryptedNoteJson { - /// Position index of this note in the input array. - position: usize, - /// Note value in credits. - value: u64, - /// Hex-encoded 32-byte nullifier. - nullifier: String, - /// Hex-encoded 32-byte note commitment. - cmx: String, - /// Hex-encoded 43-byte Orchard address. - address: String, - /// Hex-encoded 32-byte Rho. - rho: String, - /// Hex-encoded 32-byte random seed. - rseed: String, -} - -/// Try to decrypt encrypted notes using the spending key's incoming viewing key. -/// -/// Performs compact trial decryption on each note. Only notes that successfully -/// decrypt (i.e., belong to the viewer) are included in the result. -/// -/// # Parameters -/// - `spending_key_bytes`: 32-byte spending key. -/// - `notes_json`: JSON array of encrypted notes: -/// `[{ "cmx": "hex32", "nullifier": "hex32", "encryptedNote": "hex216" }]` -/// -/// # Returns -/// JSON array of successfully decrypted notes: -/// ```json -/// [{ "position": idx, "value": u64, "nullifier": "hex32", "cmx": "hex32", -/// "address": "hex43", "rho": "hex32", "rseed": "hex32" }] -/// ``` -/// -/// # Safety -/// - `spending_key_bytes` must point to exactly 32 bytes. -/// - `notes_json` must be a valid null-terminated UTF-8 C string. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_shielded_decrypt_notes( - spending_key_bytes: *const [u8; 32], - notes_json: *const std::os::raw::c_char, -) -> DashSDKResult { - if spending_key_bytes.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "spending_key_bytes is null".to_string(), - )); - } - - if notes_json.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "notes_json is null".to_string(), - )); - } - - // Derive incoming viewing key — zeroize key copy immediately after derivation - let mut key_copy = *spending_key_bytes; - let sk = match SpendingKey::from_bytes(key_copy).into_option() { - Some(sk) => { - key_copy.zeroize(); - sk - } - None => { - key_copy.zeroize(); - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "Invalid spending key bytes".to_string(), - )); - } - }; - let fvk = FullViewingKey::from(&sk); - let ivk = PreparedIncomingViewingKey::new(&fvk.to_ivk(Scope::External)); - - // Parse JSON input - let notes_str = match std::ffi::CStr::from_ptr(notes_json).to_str() { - Ok(s) => s, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!("notes_json is not valid UTF-8: {}", e), - )); - } - }; - - let encrypted_notes: Vec = match serde_json::from_str(notes_str) { - Ok(n) => n, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!("Failed to parse notes JSON: {}", e), - )); - } - }; - - let mut decrypted = Vec::new(); - - for (idx, enc) in encrypted_notes.iter().enumerate() { - // Decode hex fields — skip malformed entries with a debug log - let cmx_bytes = match hex::decode(&enc.cmx) { - Ok(b) => b, - Err(e) => { - tracing::debug!("Skipping note {}: invalid cmx hex: {}", idx, e); - continue; - } - }; - let nf_bytes = match hex::decode(&enc.nullifier) { - Ok(b) => b, - Err(e) => { - tracing::debug!("Skipping note {}: invalid nullifier hex: {}", idx, e); - continue; - } - }; - let enc_note_bytes = match hex::decode(&enc.encrypted_note) { - Ok(b) => b, - Err(e) => { - tracing::debug!("Skipping note {}: invalid encrypted_note hex: {}", idx, e); - continue; - } - }; - - // Validate field sizes - if cmx_bytes.len() != 32 { - tracing::debug!( - "Skipping note {}: cmx must be 32 bytes, got {}", - idx, - cmx_bytes.len() - ); - continue; - } - if nf_bytes.len() != 32 { - tracing::debug!( - "Skipping note {}: nullifier must be 32 bytes, got {}", - idx, - nf_bytes.len() - ); - continue; - } - // encrypted_note minimum: 32 (epk) + COMPACT_NOTE_SIZE bytes - if enc_note_bytes.len() < 84 { - tracing::debug!( - "Skipping note {}: encrypted_note too short ({} bytes)", - idx, - enc_note_bytes.len() - ); - continue; - } - - let shielded_note = ShieldedEncryptedNote { - cmx: cmx_bytes, - nullifier: nf_bytes, - encrypted_note: enc_note_bytes, - }; - - // Try trial decryption using the SDK's decrypt function - if let Some((note, address)) = - dash_sdk::platform::shielded::try_decrypt_note(&ivk, &shielded_note) - { - let rho_bytes = note.rho().to_bytes(); - let rseed_bytes = note.rseed().as_bytes(); - let addr_bytes = address.to_raw_address_bytes(); - - decrypted.push(DecryptedNoteJson { - position: idx, - value: note.value().inner(), - nullifier: enc.nullifier.clone(), - cmx: enc.cmx.clone(), - address: hex::encode(addr_bytes), - rho: hex::encode(rho_bytes), - rseed: hex::encode(rseed_bytes), - }); - } - } - - let json_str = match serde_json::to_string(&decrypted) { - Ok(s) => s, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InternalError, - format!("Failed to serialize decrypted notes: {}", e), - )); - } - }; - - match CString::new(json_str) { - Ok(c_str) => DashSDKResult { - data_type: DashSDKResultDataType::String, - data: c_str.into_raw() as *mut c_void, - error: std::ptr::null_mut(), - }, - Err(e) => DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InternalError, - format!("Failed to create CString: {}", e), - )), - } -} diff --git a/packages/rs-sdk-ffi/src/shielded/crypto/mod.rs b/packages/rs-sdk-ffi/src/shielded/crypto/mod.rs deleted file mode 100644 index 46e708c933b..00000000000 --- a/packages/rs-sdk-ffi/src/shielded/crypto/mod.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! Orchard cryptographic FFI functions for iOS. -//! -//! Provides key derivation, bundle building, and note decryption functions -//! that perform all Orchard cryptography internally and return JSON results. -//! This enables iOS to build shielded bundles without needing to construct -//! the complex `DashSDKOrchardBundleParams` externally. - -mod address; -mod bundle_build; -mod decrypt; - -pub use address::*; -pub use bundle_build::*; -pub use decrypt::*; - -use std::sync::OnceLock; - -use dash_sdk::dpp::shielded::builder::OrchardProver; -use dash_sdk::grovedb_commitment_tree::ProvingKey; - -use crate::DashSDKResult; - -/// Global cached proving key. Building the Halo 2 proving key takes ~30 seconds -/// on first call, so we cache it for the lifetime of the process. -static PROVING_KEY: OnceLock = OnceLock::new(); - -/// Cached prover that wraps the global `OnceLock` proving key. -pub(crate) struct CachedProver; - -impl OrchardProver for CachedProver { - fn proving_key(&self) -> &ProvingKey { - PROVING_KEY.get_or_init(ProvingKey::build) - } -} - -/// Pre-build and cache the Halo 2 proving key (~30s on first call, instant after). -/// -/// Call this on a background thread at app startup so that subsequent bundle -/// building calls do not incur the proving key generation latency. -/// -/// # Returns -/// `DashSDKResult` with no data on success. -/// -/// # Safety -/// This function has no pointer parameters and is safe to call from any thread. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_shielded_warmup_proving_key() -> DashSDKResult { - let _ = PROVING_KEY.get_or_init(ProvingKey::build); - DashSDKResult::success(std::ptr::null_mut()) -} diff --git a/packages/rs-sdk-ffi/src/shielded/mod.rs b/packages/rs-sdk-ffi/src/shielded/mod.rs deleted file mode 100644 index efbff9eb0b8..00000000000 --- a/packages/rs-sdk-ffi/src/shielded/mod.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! Shielded pool queries and state transition FFI bindings. - -mod crypto; -pub mod pool_client; -mod queries; -mod transitions; -pub(crate) mod types; - -// Re-export pool client functions (opaque handle lifecycle, sync, bundle building) -pub use pool_client::*; - -// Re-export all query functions -pub use queries::*; - -// Re-export crypto functions (address derivation, bundle building, decryption) -pub use crypto::*; - -// Re-export transition functions — build + broadcast combined -pub use transitions::dash_sdk_shielded_shield_from_chain_lock; -pub use transitions::dash_sdk_shielded_shield_from_instant_lock; -pub use transitions::dash_sdk_shielded_shield_funds; -pub use transitions::dash_sdk_shielded_transfer; -pub use transitions::dash_sdk_shielded_unshield_funds; -pub use transitions::dash_sdk_shielded_withdraw; - -// Re-export builder functions — build only (returns serialized bytes) -pub use transitions::dash_sdk_shielded_build_shield; -pub use transitions::dash_sdk_shielded_build_shield_from_chain_lock; -pub use transitions::dash_sdk_shielded_build_shield_from_instant_lock; -pub use transitions::dash_sdk_shielded_build_transfer; -pub use transitions::dash_sdk_shielded_build_unshield; -pub use transitions::dash_sdk_shielded_build_withdrawal; - -// Re-export generic broadcast -pub use transitions::dash_sdk_shielded_broadcast; - -// Re-export FFI types -pub use transitions::shield::DashSDKShieldInput; -pub use types::{DashSDKOrchardBundleParams, DashSDKSerializedAction}; diff --git a/packages/rs-sdk-ffi/src/shielded/pool_client/bundle.rs b/packages/rs-sdk-ffi/src/shielded/pool_client/bundle.rs deleted file mode 100644 index 8fa14403132..00000000000 --- a/packages/rs-sdk-ffi/src/shielded/pool_client/bundle.rs +++ /dev/null @@ -1,713 +0,0 @@ -//! Bundle building FFI functions for the shielded pool client. -//! -//! Uses the commitment tree's witness generation and the DPP public builder -//! functions to construct authorized Orchard bundles. iOS receives opaque -//! `DashSDKOrchardBundleParams` handles and never sees Merkle paths. - -use std::os::raw::c_void; - -use dash_sdk::dpp::address_funds::OrchardAddress; -use dash_sdk::dpp::identity::core_script::CoreScript; -use dash_sdk::dpp::shielded::builder::{ - build_shielded_transfer_transition, build_shielded_withdrawal_transition, - build_unshield_transition, serialize_authorized_bundle, SpendableNote, -}; -use dash_sdk::dpp::shielded::compute_minimum_shielded_fee; -use dash_sdk::dpp::version::PlatformVersion; -use dash_sdk::dpp::withdrawal::Pooling; - -// Access crypto items from the sibling `crypto` module via `super`. -// pool_client is a child of shielded, and crypto is also a child of shielded. -// In Rust, child modules can access private siblings through `super`. -use super::super::crypto::{bundle_to_ffi_params, CachedProver}; -use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, DashSDKResultDataType}; - -use super::ShieldedPoolClient; - -/// Select notes to cover the requested amount using a greedy algorithm -/// (largest-value first). -fn select_notes_for_amount( - state: &super::ShieldedPoolState, - amount: u64, -) -> Result<(Vec<&super::ShieldedNote>, u64), String> { - let unspent = state.unspent_notes(); - - if unspent.is_empty() { - return Err("No unspent shielded notes available".to_string()); - } - - let total_available: u64 = unspent - .iter() - .try_fold(0u64, |acc, n| acc.checked_add(n.value)) - .unwrap_or(u64::MAX); - if total_available < amount { - return Err(format!( - "Insufficient shielded balance: have {}, need {}", - total_available, amount - )); - } - - let mut sorted = unspent; - sorted.sort_by(|a, b| b.value.cmp(&a.value)); - - let mut selected = Vec::new(); - let mut accumulated = 0u64; - - for note in sorted { - selected.push(note); - accumulated += note.value; - if accumulated >= amount { - break; - } - } - - Ok((selected, accumulated)) -} - -/// Convert a `PaymentAddress` to an `OrchardAddress`. -fn payment_address_to_orchard( - addr: &dash_sdk::grovedb_commitment_tree::PaymentAddress, -) -> Result { - let raw = addr.to_raw_address_bytes(); - OrchardAddress::from_raw_bytes(&raw).map_err(|e| format!("Invalid Orchard address: {}", e)) -} - -/// Get Merkle witnesses and anchor for the selected notes from the locked state. -fn get_witnesses_and_anchor( - state: &super::ShieldedPoolState, - selected: &[&super::ShieldedNote], -) -> Result< - ( - Vec, - dash_sdk::grovedb_commitment_tree::Anchor, - ), - String, -> { - let tree = &state.commitment_tree; - - let spends = selected - .iter() - .map(|note| { - let merkle_path = tree - .witness(note.position, 0) - .map_err(|e| format!("Failed to get Merkle witness: {}", e))? - .ok_or("No Merkle path available for note")?; - Ok(SpendableNote { - note: note.note, - merkle_path, - }) - }) - .collect::, String>>()?; - - let anchor = tree - .anchor() - .map_err(|e| format!("Failed to get tree anchor: {}", e))?; - - Ok((spends, anchor)) -} - -/// Return a `DashSDKResult` containing a heap-allocated `DashSDKOrchardBundleParams`. -fn bundle_result(sb: &dash_sdk::dpp::shielded::builder::SerializedBundle) -> DashSDKResult { - let ptr = bundle_to_ffi_params(sb); - DashSDKResult { - data_type: DashSDKResultDataType::BinaryData, - data: ptr as *mut c_void, - error: std::ptr::null_mut(), - } -} - -/// Parse the optional 36-byte memo pointer. Returns `[0u8; 36]` if null. -unsafe fn parse_memo(memo: *const [u8; 36]) -> [u8; 36] { - if memo.is_null() { - [0u8; 36] - } else { - *memo - } -} - -// --------------------------------------------------------------------------- -// FFI functions -// --------------------------------------------------------------------------- - -/// Build a shield bundle (transparent -> shielded). No tree witness needed. -/// -/// Creates an output-only bundle that deposits `amount` credits into the -/// client's default shielded address. -/// -/// # Safety -/// - `handle` must be a valid pointer to a `ShieldedPoolClient`. -/// - `memo`, if non-null, must point to exactly 36 bytes. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_shielded_pool_client_build_shield_bundle( - handle: *const ShieldedPoolClient, - amount: u64, - memo: *const [u8; 36], -) -> DashSDKResult { - if handle.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "handle is null".to_string(), - )); - } - - if amount == 0 { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "shield amount must be greater than zero".to_string(), - )); - } - - let client = &*handle; - let memo = parse_memo(memo); - let prover = CachedProver; - - let recipient = &client.keys.default_address; - - // Build output-only bundle using the local builder (same as the existing - // `dash_sdk_shielded_build_shield_bundle` pattern). - use dash_sdk::dpp::shielded::builder::OrchardProver; - use dash_sdk::dpp::shielded::compute_platform_sighash; - use dash_sdk::grovedb_commitment_tree::{ - Anchor, Builder, BundleType, DashMemo, Flags as OrchardFlags, NoteValue, - }; - use rand::rngs::OsRng; - - let anchor = Anchor::empty_tree(); - let mut builder = Builder::::new( - BundleType::Transactional { - flags: OrchardFlags::SPENDS_DISABLED, - bundle_required: false, - }, - anchor, - ); - - if let Err(e) = builder.add_output(None, *recipient, NoteValue::from_raw(amount), memo) { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::CryptoError, - format!("Failed to add output: {:?}", e), - )); - } - - let mut rng = OsRng; - let (unauthorized, _) = match builder.build::(&mut rng) { - Ok(Some(pair)) => pair, - Ok(None) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::CryptoError, - "Bundle was empty after build".to_string(), - )) - } - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::CryptoError, - format!("Failed to build bundle: {:?}", e), - )) - } - }; - - let bundle_commitment: [u8; 32] = unauthorized.commitment().into(); - let sighash = compute_platform_sighash(&bundle_commitment, &[]); - - let proven = match unauthorized.create_proof(prover.proving_key(), &mut rng) { - Ok(p) => p, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::CryptoError, - format!("Failed to create proof: {:?}", e), - )) - } - }; - - let authorized = match proven.apply_signatures(rng, sighash, &[]) { - Ok(b) => b, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::CryptoError, - format!("Failed to apply signatures: {:?}", e), - )) - } - }; - - let sb = serialize_authorized_bundle(&authorized); - bundle_result(&sb) -} - -/// Build a transfer bundle (shielded -> shielded). -/// -/// Selects notes, gets witnesses from the commitment tree, and builds an -/// authorized bundle using the DPP builder. -/// -/// # Safety -/// - `handle` must be a valid pointer to a `ShieldedPoolClient`. -/// - `recipient_address` must point to `recipient_address_len` bytes (43 expected). -/// - `memo`, if non-null, must point to exactly 36 bytes. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_shielded_pool_client_build_transfer_bundle( - handle: *const ShieldedPoolClient, - recipient_address: *const u8, - recipient_address_len: usize, - amount: u64, - memo: *const [u8; 36], -) -> DashSDKResult { - if handle.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "handle is null".to_string(), - )); - } - - if recipient_address.is_null() || recipient_address_len != 43 { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!( - "recipient address must be 43 bytes, got {}", - if recipient_address.is_null() { - 0 - } else { - recipient_address_len - } - ), - )); - } - - if amount == 0 { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "transfer amount must be greater than zero".to_string(), - )); - } - - let client = &*handle; - let memo = parse_memo(memo); - let prover = CachedProver; - let platform_version = PlatformVersion::latest(); - - // Parse recipient address. - let addr_slice = std::slice::from_raw_parts(recipient_address, 43); - let mut addr_array = [0u8; 43]; - addr_array.copy_from_slice(addr_slice); - let recipient_addr = match OrchardAddress::from_raw_bytes(&addr_array) { - Ok(a) => a, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!("Invalid recipient address: {}", e), - )) - } - }; - - // Lock state once for note selection and witness generation. - let state = client.state.lock().unwrap(); - - // Fee-aware note selection: select enough to cover amount + estimated fee. - let (mut selected, mut total) = match select_notes_for_amount(&state, amount) { - Ok(s) => s, - Err(e) => { - return DashSDKResult::error(DashSDKError::new(DashSDKErrorCode::InvalidParameter, e)) - } - }; - - // Transfer has recipient + change = at least 2 outputs. - let num_actions = selected.len().max(2); - let estimated_fee = compute_minimum_shielded_fee(num_actions, platform_version); - let required = match amount.checked_add(estimated_fee) { - Some(v) => v, - None => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "amount + fee overflows".to_string(), - )) - } - }; - - // Re-select if initial selection doesn't cover amount + fee. - if total < required { - match select_notes_for_amount(&state, required) { - Ok((reselected, retotal)) => { - selected = reselected; - total = retotal; - let _ = total; // suppress unused warning - } - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - e, - )) - } - } - } - - // Get witnesses and anchor from locked state. - let (spends, anchor) = match get_witnesses_and_anchor(&state, &selected) { - Ok(s) => s, - Err(e) => return DashSDKResult::error(DashSDKError::new(DashSDKErrorCode::CryptoError, e)), - }; - - // Drop state lock before expensive proof generation. - drop(state); - - let change_addr = match payment_address_to_orchard(&client.keys.default_address) { - Ok(a) => a, - Err(e) => { - return DashSDKResult::error(DashSDKError::new(DashSDKErrorCode::InternalError, e)) - } - }; - - let st = match build_shielded_transfer_transition( - spends, - &recipient_addr, - amount, - &change_addr, - &client.keys.fvk, - &client.keys.ask, - anchor, - &prover, - memo, - None, - platform_version, - ) { - Ok(st) => st, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::CryptoError, - format!("Failed to build shielded transfer: {}", e), - )) - } - }; - - // Extract the serialized bundle from the state transition. - match extract_bundle_from_st(&st) { - Ok(sb) => bundle_result(&sb), - Err(e) => DashSDKResult::error(DashSDKError::new(DashSDKErrorCode::CryptoError, e)), - } -} - -/// Build an unshield bundle (shielded -> platform address). -/// -/// # Safety -/// - `handle` must be a valid pointer to a `ShieldedPoolClient`. -/// - `output_address` must point to `output_address_len` bytes. -/// - `memo`, if non-null, must point to exactly 36 bytes. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_shielded_pool_client_build_unshield_bundle( - handle: *const ShieldedPoolClient, - output_address: *const u8, - output_address_len: usize, - amount: u64, - memo: *const [u8; 36], -) -> DashSDKResult { - if handle.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "handle is null".to_string(), - )); - } - - if output_address.is_null() || output_address_len == 0 { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "output_address is null or empty".to_string(), - )); - } - - if amount == 0 { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "unshield amount must be greater than zero".to_string(), - )); - } - - let client = &*handle; - let memo = parse_memo(memo); - let prover = CachedProver; - let platform_version = PlatformVersion::latest(); - - // Parse output platform address. - let addr_slice = std::slice::from_raw_parts(output_address, output_address_len); - let platform_addr = match dash_sdk::dpp::address_funds::PlatformAddress::from_bytes(addr_slice) - { - Ok(a) => a, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!("Invalid output address: {}", e), - )) - } - }; - - // Lock state once for note selection and witness generation. - let state = client.state.lock().unwrap(); - - // Fee-aware note selection: select enough to cover amount + estimated fee. - let (mut selected, mut total) = match select_notes_for_amount(&state, amount) { - Ok(s) => s, - Err(e) => { - return DashSDKResult::error(DashSDKError::new(DashSDKErrorCode::InvalidParameter, e)) - } - }; - - // Unshield has change output = at least 1 output. - let num_actions = selected.len().max(1); - let estimated_fee = compute_minimum_shielded_fee(num_actions, platform_version); - let required = match amount.checked_add(estimated_fee) { - Some(v) => v, - None => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "amount + fee overflows".to_string(), - )) - } - }; - - // Re-select if initial selection doesn't cover amount + fee. - if total < required { - match select_notes_for_amount(&state, required) { - Ok((reselected, retotal)) => { - selected = reselected; - total = retotal; - let _ = total; - } - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - e, - )) - } - } - } - - // Get witnesses and anchor from locked state. - let (spends, anchor) = match get_witnesses_and_anchor(&state, &selected) { - Ok(s) => s, - Err(e) => return DashSDKResult::error(DashSDKError::new(DashSDKErrorCode::CryptoError, e)), - }; - - // Drop state lock before expensive proof generation. - drop(state); - - let change_addr = match payment_address_to_orchard(&client.keys.default_address) { - Ok(a) => a, - Err(e) => { - return DashSDKResult::error(DashSDKError::new(DashSDKErrorCode::InternalError, e)) - } - }; - - let st = match build_unshield_transition( - spends, - platform_addr, - amount, - &change_addr, - &client.keys.fvk, - &client.keys.ask, - anchor, - &prover, - memo, - None, - platform_version, - ) { - Ok(st) => st, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::CryptoError, - format!("Failed to build unshield transition: {}", e), - )) - } - }; - - match extract_bundle_from_st(&st) { - Ok(sb) => bundle_result(&sb), - Err(e) => DashSDKResult::error(DashSDKError::new(DashSDKErrorCode::CryptoError, e)), - } -} - -/// Build a withdrawal bundle (shielded -> L1 core address). -/// -/// # Safety -/// - `handle` must be a valid pointer to a `ShieldedPoolClient`. -/// - `output_script` must point to `output_script_len` bytes. -/// - `memo`, if non-null, must point to exactly 36 bytes. -/// - `pooling`: 0=Never, 1=IfAvailable, 2=Standard. -#[no_mangle] -#[allow(clippy::too_many_arguments)] -pub unsafe extern "C" fn dash_sdk_shielded_pool_client_build_withdrawal_bundle( - handle: *const ShieldedPoolClient, - output_script: *const u8, - output_script_len: usize, - amount: u64, - memo: *const [u8; 36], - core_fee_per_byte: u32, - pooling: u8, -) -> DashSDKResult { - if handle.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "handle is null".to_string(), - )); - } - - if output_script.is_null() || output_script_len == 0 { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "output_script is null or empty".to_string(), - )); - } - - if amount == 0 { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "withdrawal amount must be greater than zero".to_string(), - )); - } - - let client = &*handle; - let memo = parse_memo(memo); - let prover = CachedProver; - let platform_version = PlatformVersion::latest(); - - // Parse output script. - let script_bytes = std::slice::from_raw_parts(output_script, output_script_len); - let core_script = CoreScript::from_bytes(script_bytes.to_vec()); - - // Map pooling byte to enum. - let pooling_enum = match pooling { - 0 => Pooling::Never, - 1 => Pooling::IfAvailable, - _ => Pooling::Standard, - }; - - // Lock state once for note selection and witness generation. - let state = client.state.lock().unwrap(); - - // Fee-aware note selection: select enough to cover amount + estimated fee. - let (mut selected, mut total) = match select_notes_for_amount(&state, amount) { - Ok(s) => s, - Err(e) => { - return DashSDKResult::error(DashSDKError::new(DashSDKErrorCode::InvalidParameter, e)) - } - }; - - // Withdrawal has change output = at least 1 output. - let num_actions = selected.len().max(1); - let estimated_fee = compute_minimum_shielded_fee(num_actions, platform_version); - let required = match amount.checked_add(estimated_fee) { - Some(v) => v, - None => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "amount + fee overflows".to_string(), - )) - } - }; - - // Re-select if initial selection doesn't cover amount + fee. - if total < required { - match select_notes_for_amount(&state, required) { - Ok((reselected, retotal)) => { - selected = reselected; - total = retotal; - let _ = total; - } - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - e, - )) - } - } - } - - // Get witnesses and anchor from locked state. - let (spends, anchor) = match get_witnesses_and_anchor(&state, &selected) { - Ok(s) => s, - Err(e) => return DashSDKResult::error(DashSDKError::new(DashSDKErrorCode::CryptoError, e)), - }; - - // Drop state lock before expensive proof generation. - drop(state); - - let change_addr = match payment_address_to_orchard(&client.keys.default_address) { - Ok(a) => a, - Err(e) => { - return DashSDKResult::error(DashSDKError::new(DashSDKErrorCode::InternalError, e)) - } - }; - - let st = match build_shielded_withdrawal_transition( - spends, - amount, - core_script, - core_fee_per_byte, - pooling_enum, - &change_addr, - &client.keys.fvk, - &client.keys.ask, - anchor, - &prover, - memo, - None, - platform_version, - ) { - Ok(st) => st, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::CryptoError, - format!("Failed to build shielded withdrawal: {}", e), - )) - } - }; - - match extract_bundle_from_st(&st) { - Ok(sb) => bundle_result(&sb), - Err(e) => DashSDKResult::error(DashSDKError::new(DashSDKErrorCode::CryptoError, e)), - } -} - -/// Extract the serialized Orchard bundle from a state transition. -/// -/// The DPP builder functions return `StateTransition` objects that contain -/// the bundle fields (actions, anchor, proof, binding_signature) directly -/// in their inner V0 structs. We destructure them to produce a -/// `SerializedBundle` that can be converted to FFI params. -fn extract_bundle_from_st( - st: &dash_sdk::dpp::state_transition::StateTransition, -) -> Result { - use dash_sdk::dpp::shielded::SerializedAction; - use dash_sdk::dpp::state_transition::StateTransition; - - // All shielded transition variants store the same bundle fields in - // their V0 structs. Extract a (actions, anchor, proof, binding_sig) tuple. - let (actions, anchor, proof, binding_signature): ( - &[SerializedAction], - [u8; 32], - &[u8], - [u8; 64], - ) = match st { - StateTransition::ShieldedTransfer(t) => match t { - dash_sdk::dpp::state_transition::shielded_transfer_transition::ShieldedTransferTransition::V0(v0) => { - (&v0.actions, v0.anchor, &v0.proof, v0.binding_signature) - } - }, - StateTransition::Unshield(t) => match t { - dash_sdk::dpp::state_transition::unshield_transition::UnshieldTransition::V0(v0) => { - (&v0.actions, v0.anchor, &v0.proof, v0.binding_signature) - } - }, - StateTransition::ShieldedWithdrawal(t) => match t { - dash_sdk::dpp::state_transition::shielded_withdrawal_transition::ShieldedWithdrawalTransition::V0(v0) => { - (&v0.actions, v0.anchor, &v0.proof, v0.binding_signature) - } - }, - _ => return Err("Unexpected state transition type".to_string()), - }; - - // Both SerializedBundle and the transition V0 structs use the same - // SerializedAction type from dpp::shielded, so we just clone. - Ok(dash_sdk::dpp::shielded::builder::SerializedBundle { - actions: actions.to_vec(), - flags: 0, - value_balance: 0, - anchor, - proof: proof.to_vec(), - binding_signature, - }) -} diff --git a/packages/rs-sdk-ffi/src/shielded/pool_client/mod.rs b/packages/rs-sdk-ffi/src/shielded/pool_client/mod.rs deleted file mode 100644 index dd62c7470d8..00000000000 --- a/packages/rs-sdk-ffi/src/shielded/pool_client/mod.rs +++ /dev/null @@ -1,281 +0,0 @@ -//! High-level shielded pool client FFI module. -//! -//! Wraps the commitment tree, note tracking, and key management into a single -//! opaque handle so that iOS callers never deal with Merkle paths, anchors, or -//! note selection directly. - -mod bundle; -mod sync; - -use std::ffi::{CStr, CString}; -use std::os::raw::{c_char, c_void}; -use std::sync::Mutex; - -use dash_sdk::grovedb_commitment_tree::{ - ClientPersistentCommitmentTree, FullViewingKey, IncomingViewingKey, Note, Nullifier, - PaymentAddress, Position, Scope, SpendAuthorizingKey, SpendingKey, -}; -use zeroize::Zeroize; - -use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, DashSDKResultDataType}; - -// Re-export bundle and sync FFI functions. -pub use bundle::*; -pub use sync::*; - -/// Orchard key set derived from a spending key. -pub(crate) struct OrchardKeySet { - pub fvk: FullViewingKey, - pub ask: SpendAuthorizingKey, - pub ivk: IncomingViewingKey, - pub default_address: PaymentAddress, -} - -/// A decrypted shielded note owned by the wallet. -pub(crate) struct ShieldedNote { - pub note: Note, - pub position: Position, - pub nullifier: Nullifier, - pub is_spent: bool, - pub value: u64, -} - -/// Mutable state behind a Mutex for thread-safe FFI access. -/// -/// All mutable fields are grouped here so concurrent FFI calls from -/// different Swift dispatch queues don't produce data races. -pub(crate) struct ShieldedPoolState { - pub notes: Vec, - pub commitment_tree: ClientPersistentCommitmentTree, - pub last_synced_index: u64, - pub last_nullifier_sync_height: u64, - pub last_nullifier_sync_timestamp: u64, -} - -impl ShieldedPoolState { - /// Recalculate the shielded balance from unspent notes. - pub fn recalculate_balance(&self) -> u64 { - self.notes - .iter() - .filter(|n| !n.is_spent) - .map(|n| n.value) - .sum() - } - - /// Get unspent notes. - pub fn unspent_notes(&self) -> Vec<&ShieldedNote> { - self.notes.iter().filter(|n| !n.is_spent).collect() - } -} - -/// Opaque shielded pool client exposed across the FFI boundary. -/// -/// Holds derived Orchard keys and all mutable state behind a `Mutex`. -/// This is `Sync` because all mutable access goes through the mutex, -/// making concurrent FFI calls from different Swift dispatch queues safe. -pub struct ShieldedPoolClient { - pub(crate) keys: OrchardKeySet, - pub(crate) state: Mutex, -} - -/// Derive an `OrchardKeySet` from raw spending key bytes. -/// -/// The local copy of the key bytes is zeroized after derivation. -fn derive_key_set(sk_bytes: &[u8; 32]) -> Result { - let mut key_copy = *sk_bytes; - let sk: SpendingKey = match SpendingKey::from_bytes(key_copy).into_option() { - Some(sk) => { - key_copy.zeroize(); - sk - } - None => { - key_copy.zeroize(); - return Err("Invalid spending key bytes".to_string()); - } - }; - - let fvk = FullViewingKey::from(&sk); - let ask = SpendAuthorizingKey::from(&sk); - let ivk = fvk.to_ivk(Scope::External); - let default_address = fvk.address_at(0u32, Scope::External); - - Ok(OrchardKeySet { - fvk, - ask, - ivk, - default_address, - }) -} - -// --------------------------------------------------------------------------- -// FFI functions -// --------------------------------------------------------------------------- - -/// Create a shielded pool client backed by SQLite at `db_path`. -/// -/// The spending key is used to derive all Orchard keys (FVK, ASK, IVK, address). -/// The commitment tree resumes from its last synced position automatically. -/// -/// # Safety -/// - `db_path` must be a valid NUL-terminated C string. -/// - `spending_key_bytes` must point to exactly 32 bytes. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_shielded_pool_client_create( - db_path: *const c_char, - spending_key_bytes: *const [u8; 32], -) -> DashSDKResult { - if db_path.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "db_path is null".to_string(), - )); - } - if spending_key_bytes.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "spending_key_bytes is null".to_string(), - )); - } - - let path_str = match CStr::from_ptr(db_path).to_str() { - Ok(s) => s, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!("db_path is not valid UTF-8: {}", e), - )) - } - }; - - let sk_bytes = &*spending_key_bytes; - - // Derive Orchard keys from the spending key. - let keys = match derive_key_set(sk_bytes) { - Ok(k) => k, - Err(e) => { - return DashSDKResult::error(DashSDKError::new(DashSDKErrorCode::InvalidParameter, e)) - } - }; - - // Open the SQLite-backed commitment tree. Tables are created automatically. - let commitment_tree = match ClientPersistentCommitmentTree::open_path(path_str, 100) { - Ok(tree) => tree, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InternalError, - format!("Failed to open commitment tree: {}", e), - )) - } - }; - - // Notes are not persisted separately — start from index 0 so - // sync_notes rediscovers all notes via trial decryption. The - // commitment tree IS persisted (SQLite), so appending already-seen - // positions is a no-op handled by the tree internally. - let client = ShieldedPoolClient { - keys, - state: Mutex::new(ShieldedPoolState { - notes: Vec::new(), - commitment_tree, - last_synced_index: 0, - last_nullifier_sync_height: 0, - last_nullifier_sync_timestamp: 0, - }), - }; - - let handle = Box::into_raw(Box::new(client)); - - DashSDKResult { - data_type: DashSDKResultDataType::BinaryData, - data: handle as *mut c_void, - error: std::ptr::null_mut(), - } -} - -/// Destroy and free a shielded pool client. -/// -/// Also closes the SQLite connection used by the commitment tree. -/// -/// # Safety -/// - `handle` must be a valid pointer returned by -/// `dash_sdk_shielded_pool_client_create` and not previously freed. -/// - It may be null (no-op). -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_shielded_pool_client_destroy(handle: *mut ShieldedPoolClient) { - if !handle.is_null() { - let _ = Box::from_raw(handle); - } -} - -/// Get the default Orchard payment address (hex-encoded 43 bytes). -/// -/// # Safety -/// - `handle` must be a valid pointer to a `ShieldedPoolClient`. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_shielded_pool_client_get_address( - handle: *const ShieldedPoolClient, -) -> DashSDKResult { - if handle.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "handle is null".to_string(), - )); - } - - let client = &*handle; - let raw_bytes = client.keys.default_address.to_raw_address_bytes(); - let hex_str = hex::encode(raw_bytes); - - match CString::new(hex_str) { - Ok(c_str) => { - let ptr = c_str.into_raw(); - DashSDKResult { - data_type: DashSDKResultDataType::String, - data: ptr as *mut c_void, - error: std::ptr::null_mut(), - } - } - Err(e) => DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InternalError, - format!("Failed to create C string: {}", e), - )), - } -} - -/// Get the current shielded balance (sum of unspent note values). -/// -/// Returns the balance as a decimal string. -/// -/// # Safety -/// - `handle` must be a valid pointer to a `ShieldedPoolClient`. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_shielded_pool_client_get_balance( - handle: *const ShieldedPoolClient, -) -> DashSDKResult { - if handle.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "handle is null".to_string(), - )); - } - - let client = &*handle; - let state = client.state.lock().unwrap(); - let balance = state.recalculate_balance(); - let balance_str = balance.to_string(); - - match CString::new(balance_str) { - Ok(c_str) => { - let ptr = c_str.into_raw(); - DashSDKResult { - data_type: DashSDKResultDataType::String, - data: ptr as *mut c_void, - error: std::ptr::null_mut(), - } - } - Err(e) => DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InternalError, - format!("Failed to create C string: {}", e), - )), - } -} diff --git a/packages/rs-sdk-ffi/src/shielded/pool_client/sync.rs b/packages/rs-sdk-ffi/src/shielded/pool_client/sync.rs deleted file mode 100644 index 6cd1a1dbab0..00000000000 --- a/packages/rs-sdk-ffi/src/shielded/pool_client/sync.rs +++ /dev/null @@ -1,307 +0,0 @@ -//! Note sync and nullifier sync FFI functions for the shielded pool client. - -use std::ffi::CString; -use std::os::raw::c_void; - -use dash_sdk::grovedb_commitment_tree::{Position, Retention}; -use dash_sdk::platform::shielded::nullifier_sync::NullifierSyncCheckpoint; -use dash_sdk::platform::shielded::sync_shielded_notes; - -use crate::sdk::SDKWrapper; -use crate::types::SDKHandle; -use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, DashSDKResultDataType}; - -use super::{ShieldedNote, ShieldedPoolClient}; - -/// Server-enforced chunk size -- start_index must be a multiple of this. -const CHUNK_SIZE: u64 = 2048; - -/// Sync notes from the platform. -/// -/// Fetches encrypted notes, trial-decrypts with IVK, appends ALL commitments -/// to the commitment tree, and tracks owned notes. -/// -/// Returns JSON: `{ "newNotes": , "balance": }` -/// -/// # Safety -/// - `handle` must be a valid pointer to a `ShieldedPoolClient`. -/// - `sdk_handle` must be a valid pointer to an `SDKHandle`. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_shielded_pool_client_sync_notes( - handle: *mut ShieldedPoolClient, - sdk_handle: *const SDKHandle, -) -> DashSDKResult { - if handle.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "handle is null".to_string(), - )); - } - if sdk_handle.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "sdk_handle is null".to_string(), - )); - } - - let client = &*handle; - let wrapper = &*(sdk_handle as *const SDKWrapper); - - // Prepare the incoming viewing key for trial decryption. - let prepared_ivk = client.keys.ivk.prepare(); - - // Read sync position under lock, then release before async call. - let (already_have, aligned_start) = { - let state = client.state.lock().unwrap(); - let already = state.last_synced_index; - let aligned = (already / CHUNK_SIZE) * CHUNK_SIZE; - (already, aligned) - }; - - // Execute the async note sync on the SDK runtime (no lock held). - let result = wrapper.runtime.block_on(async { - sync_shielded_notes(&wrapper.sdk, &prepared_ivk, aligned_start, None).await - }); - - let result = match result { - Ok(r) => r, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InternalError, - format!("Failed to sync shielded notes: {}", e), - )) - } - }; - - // Re-acquire lock for tree updates and note tracking. - let mut state = client.state.lock().unwrap(); - - // Append notes to the local commitment tree, skipping positions already present. - let mut appended = 0u32; - for (i, raw_note) in result.all_notes.iter().enumerate() { - let global_pos = aligned_start + i as u64; - if global_pos < already_have { - continue; - } - - let cmx_bytes: [u8; 32] = match raw_note.cmx.as_slice().try_into() { - Ok(b) => b, - Err(_) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InternalError, - "Invalid cmx length".to_string(), - )) - } - }; - - let is_ours = result - .decrypted_notes - .iter() - .any(|dn| dn.position == global_pos); - let retention = if is_ours { - Retention::Marked - } else { - Retention::Ephemeral - }; - - if let Err(e) = state.commitment_tree.append(cmx_bytes, retention) { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InternalError, - format!("Failed to append note to tree: {}", e), - )); - } - - appended += 1; - } - - // Checkpoint the tree if we appended anything. - if appended > 0 { - let checkpoint_id = result.next_start_index as u32; - if let Err(e) = state.commitment_tree.checkpoint(checkpoint_id) { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InternalError, - format!("Failed to checkpoint tree: {}", e), - )); - } - } - - // Track newly decrypted notes. - let mut new_note_count = 0u32; - for dn in &result.decrypted_notes { - if dn.position < already_have { - continue; - } - - let nullifier = dn.note.nullifier(&client.keys.fvk); - let value = dn.note.value().inner(); - - state.notes.push(ShieldedNote { - note: dn.note, - position: Position::from(dn.position), - nullifier, - is_spent: false, - value, - }); - - new_note_count += 1; - } - - // Update sync position. - state.last_synced_index = aligned_start + result.total_notes_scanned; - - let balance = state.recalculate_balance(); - - // Drop lock before JSON serialization. - drop(state); - - let json = serde_json::json!({ - "newNotes": new_note_count, - "balance": balance, - }); - - match CString::new(json.to_string()) { - Ok(c_str) => { - let ptr = c_str.into_raw(); - DashSDKResult { - data_type: DashSDKResultDataType::String, - data: ptr as *mut c_void, - error: std::ptr::null_mut(), - } - } - Err(e) => DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InternalError, - format!("Failed to create JSON string: {}", e), - )), - } -} - -/// Check which owned notes have been spent on-chain using nullifier sync. -/// -/// Returns JSON: `{ "spentCount": , "balance": }` -/// -/// # Safety -/// - `handle` must be a valid pointer to a `ShieldedPoolClient`. -/// - `sdk_handle` must be a valid pointer to an `SDKHandle`. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_shielded_pool_client_sync_nullifiers( - handle: *mut ShieldedPoolClient, - sdk_handle: *const SDKHandle, -) -> DashSDKResult { - if handle.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "handle is null".to_string(), - )); - } - if sdk_handle.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "sdk_handle is null".to_string(), - )); - } - - let client = &*handle; - let wrapper = &*(sdk_handle as *const SDKWrapper); - - // Collect unspent nullifiers and sync checkpoint under lock, then release. - let (unspent_nullifiers, last_sync) = { - let state = client.state.lock().unwrap(); - - let nullifiers: Vec<[u8; 32]> = state - .notes - .iter() - .filter(|n| !n.is_spent) - .map(|n| n.nullifier.to_bytes()) - .collect(); - - let checkpoint = if state.last_nullifier_sync_height > 0 { - Some(NullifierSyncCheckpoint { - height: state.last_nullifier_sync_height, - timestamp: state.last_nullifier_sync_timestamp, - }) - } else { - None - }; - - (nullifiers, checkpoint) - }; - - if unspent_nullifiers.is_empty() { - let balance = client.state.lock().unwrap().recalculate_balance(); - let json = serde_json::json!({ - "spentCount": 0, - "balance": balance, - }); - return match CString::new(json.to_string()) { - Ok(c_str) => { - let ptr = c_str.into_raw(); - DashSDKResult { - data_type: DashSDKResultDataType::String, - data: ptr as *mut c_void, - error: std::ptr::null_mut(), - } - } - Err(e) => DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InternalError, - format!("Failed to create JSON string: {}", e), - )), - }; - } - - // Execute async nullifier sync (no lock held). - let result = wrapper.runtime.block_on(async { - wrapper - .sdk - .sync_nullifiers(&unspent_nullifiers, None, last_sync) - .await - }); - - let result = match result { - Ok(r) => r, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InternalError, - format!("Nullifier sync failed: {}", e), - )) - } - }; - - // Re-acquire lock to update state. - let mut state = client.state.lock().unwrap(); - - let mut spent_count = 0u32; - for nf_bytes in &result.found { - for note in &mut state.notes { - if !note.is_spent && note.nullifier.to_bytes() == *nf_bytes { - note.is_spent = true; - spent_count += 1; - } - } - } - - state.last_nullifier_sync_height = result.new_sync_height; - state.last_nullifier_sync_timestamp = result.new_sync_timestamp; - - let balance = state.recalculate_balance(); - drop(state); - - let json = serde_json::json!({ - "spentCount": spent_count, - "balance": balance, - }); - - match CString::new(json.to_string()) { - Ok(c_str) => { - let ptr = c_str.into_raw(); - DashSDKResult { - data_type: DashSDKResultDataType::String, - data: ptr as *mut c_void, - error: std::ptr::null_mut(), - } - } - Err(e) => DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InternalError, - format!("Failed to create JSON string: {}", e), - )), - } -} diff --git a/packages/rs-sdk-ffi/src/shielded/queries/anchors.rs b/packages/rs-sdk-ffi/src/shielded/queries/anchors.rs deleted file mode 100644 index 7036aedc46c..00000000000 --- a/packages/rs-sdk-ffi/src/shielded/queries/anchors.rs +++ /dev/null @@ -1,72 +0,0 @@ -use crate::sdk::SDKWrapper; -use crate::types::SDKHandle; -use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, DashSDKResultDataType}; -use dash_sdk::platform::fetch_current_no_parameters::FetchCurrent; -use dash_sdk::query_types::ShieldedAnchors; -use std::ffi::CString; -use std::os::raw::c_void; - -/// Fetches all anchors from the shielded pool. -/// -/// # Parameters -/// * `sdk_handle` - Handle to the SDK instance -/// -/// # Returns -/// * JSON array of hex-encoded 32-byte anchor hashes -/// * Error if the operation fails -/// -/// # Safety -/// - `sdk_handle` must be a valid, non-null pointer to an initialized `SDKHandle`. -/// - On success, returns a heap-allocated C string (JSON); caller must free with `dash_sdk_string_free`. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_shielded_get_anchors( - sdk_handle: *const SDKHandle, -) -> DashSDKResult { - if sdk_handle.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "SDK handle is null".to_string(), - )); - } - - let wrapper = &*(sdk_handle as *const SDKWrapper); - let sdk = wrapper.sdk.clone(); - - let rt = match tokio::runtime::Runtime::new() { - Ok(rt) => rt, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InternalError, - format!("Failed to create Tokio runtime: {}", e), - )); - } - }; - - let result = rt.block_on(async move { - ShieldedAnchors::fetch_current(&sdk) - .await - .map_err(|e| format!("Failed to fetch shielded anchors: {}", e)) - }); - - match result { - Ok(ShieldedAnchors(anchors)) => { - let json_anchors: Vec = anchors.iter().map(hex::encode).collect(); - - let json_str = - serde_json::to_string(&json_anchors).unwrap_or_else(|_| "[]".to_string()); - - match CString::new(json_str) { - Ok(c_str) => DashSDKResult { - data_type: DashSDKResultDataType::String, - data: c_str.into_raw() as *mut c_void, - error: std::ptr::null_mut(), - }, - Err(e) => DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InternalError, - format!("Failed to create CString: {}", e), - )), - } - } - Err(e) => DashSDKResult::error(DashSDKError::new(DashSDKErrorCode::InternalError, e)), - } -} diff --git a/packages/rs-sdk-ffi/src/shielded/queries/encrypted_notes.rs b/packages/rs-sdk-ffi/src/shielded/queries/encrypted_notes.rs deleted file mode 100644 index fb91196380f..00000000000 --- a/packages/rs-sdk-ffi/src/shielded/queries/encrypted_notes.rs +++ /dev/null @@ -1,98 +0,0 @@ -use crate::sdk::SDKWrapper; -use crate::types::SDKHandle; -use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, DashSDKResultDataType}; -use dash_sdk::platform::Fetch; -use dash_sdk::query_types::{ShieldedEncryptedNotes, ShieldedEncryptedNotesQuery}; -use std::ffi::CString; -use std::os::raw::c_void; - -/// Fetches encrypted notes from the shielded pool, paginated. -/// -/// # Parameters -/// * `sdk_handle` - Handle to the SDK instance -/// * `start_index` - Starting index (0-based) in the encrypted notes tree -/// * `count` - Maximum number of notes to return -/// -/// # Returns -/// * JSON array of encrypted note objects, each with `cmx`, `nullifier`, `encryptedNote` (hex-encoded) -/// * Error if the operation fails -/// -/// # Safety -/// - `sdk_handle` must be a valid, non-null pointer to an initialized `SDKHandle`. -/// - On success, returns a heap-allocated C string (JSON); caller must free with `dash_sdk_string_free`. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_shielded_get_encrypted_notes( - sdk_handle: *const SDKHandle, - start_index: u64, - count: u32, -) -> DashSDKResult { - if sdk_handle.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "SDK handle is null".to_string(), - )); - } - - let wrapper = &*(sdk_handle as *const SDKWrapper); - let sdk = wrapper.sdk.clone(); - - let rt = match tokio::runtime::Runtime::new() { - Ok(rt) => rt, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InternalError, - format!("Failed to create Tokio runtime: {}", e), - )); - } - }; - - let query = ShieldedEncryptedNotesQuery { start_index, count }; - - let result = rt.block_on(async move { - ShieldedEncryptedNotes::fetch(&sdk, query) - .await - .map_err(|e| format!("Failed to fetch encrypted notes: {}", e)) - }); - - match result { - Ok(Some(notes)) => { - let json_notes: Vec = notes - .0 - .iter() - .map(|note| { - serde_json::json!({ - "cmx": hex::encode(¬e.cmx), - "nullifier": hex::encode(¬e.nullifier), - "encryptedNote": hex::encode(¬e.encrypted_note), - }) - }) - .collect(); - - let json_str = serde_json::to_string(&json_notes).unwrap_or_else(|_| "[]".to_string()); - - match CString::new(json_str) { - Ok(c_str) => DashSDKResult { - data_type: DashSDKResultDataType::String, - data: c_str.into_raw() as *mut c_void, - error: std::ptr::null_mut(), - }, - Err(e) => DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InternalError, - format!("Failed to create CString: {}", e), - )), - } - } - Ok(None) => match CString::new("[]") { - Ok(c_str) => DashSDKResult { - data_type: DashSDKResultDataType::String, - data: c_str.into_raw() as *mut c_void, - error: std::ptr::null_mut(), - }, - Err(e) => DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InternalError, - format!("Failed to create CString: {}", e), - )), - }, - Err(e) => DashSDKResult::error(DashSDKError::new(DashSDKErrorCode::InternalError, e)), - } -} diff --git a/packages/rs-sdk-ffi/src/shielded/queries/mod.rs b/packages/rs-sdk-ffi/src/shielded/queries/mod.rs deleted file mode 100644 index 6ab7cc674c8..00000000000 --- a/packages/rs-sdk-ffi/src/shielded/queries/mod.rs +++ /dev/null @@ -1,13 +0,0 @@ -// Shielded pool queries -pub mod anchors; -pub mod encrypted_notes; -pub mod most_recent_anchor; -pub mod nullifiers; -pub mod pool_state; - -// Re-export all public functions -pub use anchors::dash_sdk_shielded_get_anchors; -pub use encrypted_notes::dash_sdk_shielded_get_encrypted_notes; -pub use most_recent_anchor::dash_sdk_shielded_get_most_recent_anchor; -pub use nullifiers::dash_sdk_shielded_get_nullifiers; -pub use pool_state::dash_sdk_shielded_get_pool_state; diff --git a/packages/rs-sdk-ffi/src/shielded/queries/most_recent_anchor.rs b/packages/rs-sdk-ffi/src/shielded/queries/most_recent_anchor.rs deleted file mode 100644 index df9e2294d39..00000000000 --- a/packages/rs-sdk-ffi/src/shielded/queries/most_recent_anchor.rs +++ /dev/null @@ -1,73 +0,0 @@ -use crate::sdk::SDKWrapper; -use crate::types::SDKHandle; -use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, DashSDKResultDataType}; -use dash_sdk::platform::Fetch; -use dash_sdk::query_types::{MostRecentShieldedAnchor, NoParamQuery}; -use std::ffi::CString; -use std::os::raw::c_void; - -/// Fetches the most recent anchor from the shielded pool. -/// -/// # Parameters -/// * `sdk_handle` - Handle to the SDK instance -/// -/// # Returns -/// * Hex-encoded string of the 32-byte anchor hash -/// * Error if the operation fails -/// -/// # Safety -/// - `sdk_handle` must be a valid, non-null pointer to an initialized `SDKHandle`. -/// - On success, returns a heap-allocated C string; caller must free with `dash_sdk_string_free`. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_shielded_get_most_recent_anchor( - sdk_handle: *const SDKHandle, -) -> DashSDKResult { - if sdk_handle.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "SDK handle is null".to_string(), - )); - } - - let wrapper = &*(sdk_handle as *const SDKWrapper); - let sdk = wrapper.sdk.clone(); - - let rt = match tokio::runtime::Runtime::new() { - Ok(rt) => rt, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InternalError, - format!("Failed to create Tokio runtime: {}", e), - )); - } - }; - - let result = rt.block_on(async move { - MostRecentShieldedAnchor::fetch(&sdk, NoParamQuery {}) - .await - .map_err(|e| format!("Failed to fetch most recent shielded anchor: {}", e)) - }); - - match result { - Ok(Some(MostRecentShieldedAnchor(anchor))) => { - let hex_str = hex::encode(anchor); - - match CString::new(hex_str) { - Ok(c_str) => DashSDKResult { - data_type: DashSDKResultDataType::String, - data: c_str.into_raw() as *mut c_void, - error: std::ptr::null_mut(), - }, - Err(e) => DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InternalError, - format!("Failed to create CString: {}", e), - )), - } - } - Ok(None) => DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InternalError, - "No anchor found in shielded pool".to_string(), - )), - Err(e) => DashSDKResult::error(DashSDKError::new(DashSDKErrorCode::InternalError, e)), - } -} diff --git a/packages/rs-sdk-ffi/src/shielded/queries/nullifiers.rs b/packages/rs-sdk-ffi/src/shielded/queries/nullifiers.rs deleted file mode 100644 index 6eb6105fd63..00000000000 --- a/packages/rs-sdk-ffi/src/shielded/queries/nullifiers.rs +++ /dev/null @@ -1,126 +0,0 @@ -use crate::sdk::SDKWrapper; -use crate::types::SDKHandle; -use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, DashSDKResultDataType}; -use dash_sdk::platform::Fetch; -use dash_sdk::query_types::{ShieldedNullifierStatuses, ShieldedNullifiersQuery}; -use std::ffi::CString; -use std::os::raw::c_void; - -/// Fetches nullifier statuses from the shielded pool. -/// -/// # Parameters -/// * `sdk_handle` - Handle to the SDK instance -/// * `nullifiers_ptr` - Pointer to a contiguous array of 32-byte nullifier hashes -/// * `nullifiers_count` - Number of nullifiers (each is 32 bytes) -/// -/// # Returns -/// * JSON array of objects with `nullifier` (hex) and `isSpent` (bool) fields -/// * Error if the operation fails -/// -/// # Safety -/// - `sdk_handle` must be a valid, non-null pointer to an initialized `SDKHandle`. -/// - `nullifiers_ptr` must point to a valid byte array of length `nullifiers_count * 32`. -/// - On success, returns a heap-allocated C string (JSON); caller must free with `dash_sdk_string_free`. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_shielded_get_nullifiers( - sdk_handle: *const SDKHandle, - nullifiers_ptr: *const u8, - nullifiers_count: u32, -) -> DashSDKResult { - if sdk_handle.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "SDK handle is null".to_string(), - )); - } - - if nullifiers_ptr.is_null() || nullifiers_count == 0 { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "Nullifiers pointer is null or count is zero".to_string(), - )); - } - - let wrapper = &*(sdk_handle as *const SDKWrapper); - let sdk = wrapper.sdk.clone(); - - // Parse the raw byte pointer into Vec<[u8; 32]> - let total_bytes = match (nullifiers_count as usize).checked_mul(32) { - Some(n) => n, - None => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "Nullifiers count too large".to_string(), - )); - } - }; - let raw_bytes = std::slice::from_raw_parts(nullifiers_ptr, total_bytes); - let nullifiers: Vec<[u8; 32]> = raw_bytes - .chunks_exact(32) - .map(|chunk| { - let mut arr = [0u8; 32]; - arr.copy_from_slice(chunk); - arr - }) - .collect(); - - let rt = match tokio::runtime::Runtime::new() { - Ok(rt) => rt, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InternalError, - format!("Failed to create Tokio runtime: {}", e), - )); - } - }; - - let query = ShieldedNullifiersQuery(nullifiers); - - let result = rt.block_on(async move { - ShieldedNullifierStatuses::fetch(&sdk, query) - .await - .map_err(|e| format!("Failed to fetch nullifier statuses: {}", e)) - }); - - match result { - Ok(Some(statuses)) => { - let json_statuses: Vec = statuses - .0 - .iter() - .map(|status| { - serde_json::json!({ - "nullifier": hex::encode(status.nullifier), - "isSpent": status.is_spent, - }) - }) - .collect(); - - let json_str = - serde_json::to_string(&json_statuses).unwrap_or_else(|_| "[]".to_string()); - - match CString::new(json_str) { - Ok(c_str) => DashSDKResult { - data_type: DashSDKResultDataType::String, - data: c_str.into_raw() as *mut c_void, - error: std::ptr::null_mut(), - }, - Err(e) => DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InternalError, - format!("Failed to create CString: {}", e), - )), - } - } - Ok(None) => match CString::new("[]") { - Ok(c_str) => DashSDKResult { - data_type: DashSDKResultDataType::String, - data: c_str.into_raw() as *mut c_void, - error: std::ptr::null_mut(), - }, - Err(e) => DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InternalError, - format!("Failed to create CString: {}", e), - )), - }, - Err(e) => DashSDKResult::error(DashSDKError::new(DashSDKErrorCode::InternalError, e)), - } -} diff --git a/packages/rs-sdk-ffi/src/shielded/queries/pool_state.rs b/packages/rs-sdk-ffi/src/shielded/queries/pool_state.rs deleted file mode 100644 index 7737d9ea59a..00000000000 --- a/packages/rs-sdk-ffi/src/shielded/queries/pool_state.rs +++ /dev/null @@ -1,65 +0,0 @@ -use crate::sdk::SDKWrapper; -use crate::types::SDKHandle; -use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, DashSDKResultDataType}; -use dash_sdk::platform::fetch_current_no_parameters::FetchCurrent; -use dash_sdk::query_types::ShieldedPoolState; -use std::ffi::CString; -use std::os::raw::c_void; - -/// Fetches the total shielded pool balance. -/// -/// # Parameters -/// * `sdk_handle` - Handle to the SDK instance -/// -/// # Returns -/// * String with the total shielded pool balance (u64 as decimal string) -/// * Error if the operation fails -/// -/// # Safety -/// - `sdk_handle` must be a valid, non-null pointer to an initialized `SDKHandle`. -/// - On success, returns a heap-allocated C string; caller must free with `dash_sdk_string_free`. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_shielded_get_pool_state( - sdk_handle: *const SDKHandle, -) -> DashSDKResult { - if sdk_handle.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "SDK handle is null".to_string(), - )); - } - - let wrapper = &*(sdk_handle as *const SDKWrapper); - let sdk = wrapper.sdk.clone(); - - let rt = match tokio::runtime::Runtime::new() { - Ok(rt) => rt, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InternalError, - format!("Failed to create Tokio runtime: {}", e), - )); - } - }; - - let result = rt.block_on(async move { - ShieldedPoolState::fetch_current(&sdk) - .await - .map_err(|e| format!("Failed to fetch shielded pool state: {}", e)) - }); - - match result { - Ok(ShieldedPoolState(balance)) => match CString::new(balance.to_string()) { - Ok(c_str) => DashSDKResult { - data_type: DashSDKResultDataType::String, - data: c_str.into_raw() as *mut c_void, - error: std::ptr::null_mut(), - }, - Err(e) => DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InternalError, - format!("Failed to create CString: {}", e), - )), - }, - Err(e) => DashSDKResult::error(DashSDKError::new(DashSDKErrorCode::InternalError, e)), - } -} diff --git a/packages/rs-sdk-ffi/src/shielded/transitions/broadcast.rs b/packages/rs-sdk-ffi/src/shielded/transitions/broadcast.rs deleted file mode 100644 index 1a7c5b19bc3..00000000000 --- a/packages/rs-sdk-ffi/src/shielded/transitions/broadcast.rs +++ /dev/null @@ -1,70 +0,0 @@ -//! Generic broadcast for pre-built shielded state transitions. - -use crate::sdk::SDKWrapper; -use crate::types::SDKHandle; -use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, FFIError}; -use dash_sdk::dpp::serialization::PlatformDeserializable; -use dash_sdk::dpp::state_transition::StateTransition; -use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; - -/// Broadcast a pre-built, serialized state transition. -/// -/// Use this with the `dash_sdk_shielded_build_*` functions to build a transition -/// first, inspect or store it, then broadcast when ready. -/// -/// # Parameters -/// - `sdk_handle`: SDK handle -/// - `st_bytes`: Serialized state transition bytes (from a `build_*` function) -/// - `st_len`: Length of the serialized bytes -/// -/// # Returns -/// `DashSDKResult` with no data on success, error on failure. -/// -/// # Safety -/// - `sdk_handle` must be a valid SDK handle. -/// - `st_bytes` must point to `st_len` valid bytes of a serialized `StateTransition`. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_shielded_broadcast( - sdk_handle: *const SDKHandle, - st_bytes: *const u8, - st_len: usize, -) -> DashSDKResult { - if sdk_handle.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "SDK handle is null".to_string(), - )); - } - - if st_bytes.is_null() || st_len == 0 { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "State transition bytes are null or empty".to_string(), - )); - } - - let wrapper = &*(sdk_handle as *const SDKWrapper); - let bytes = std::slice::from_raw_parts(st_bytes, st_len); - - let state_transition = match StateTransition::deserialize_from_bytes(bytes) { - Ok(st) => st, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!("Failed to deserialize state transition: {}", e), - )) - } - }; - - let result = wrapper.runtime.block_on(async { - state_transition - .broadcast(&wrapper.sdk, None) - .await - .map_err(FFIError::from) - }); - - match result { - Ok(()) => DashSDKResult::success(std::ptr::null_mut()), - Err(e) => DashSDKResult::error(e.into()), - } -} diff --git a/packages/rs-sdk-ffi/src/shielded/transitions/builders.rs b/packages/rs-sdk-ffi/src/shielded/transitions/builders.rs deleted file mode 100644 index cc13fcea547..00000000000 --- a/packages/rs-sdk-ffi/src/shielded/transitions/builders.rs +++ /dev/null @@ -1,558 +0,0 @@ -//! Builder functions that construct shielded state transitions without broadcasting. -//! -//! Each function returns the serialized `StateTransition` bytes. The caller can -//! inspect, store, or later broadcast them via `dash_sdk_shielded_broadcast`. - -use crate::address::AddressSigner; -use crate::identity::{create_chain_asset_lock_proof, create_instant_asset_lock_proof}; -use crate::sdk::SDKWrapper; -use crate::shielded::transitions::shield::DashSDKShieldInput; -use crate::shielded::types::{convert_orchard_bundle_params, DashSDKOrchardBundleParams}; -use crate::types::SDKHandle; -use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, DashSDKResultDataType, FFIError}; -use dash_sdk::dpp::address_funds::{ - AddressFundsFeeStrategy, AddressFundsFeeStrategyStep, PlatformAddress, -}; -use dash_sdk::dpp::dashcore::secp256k1::SecretKey; -use dash_sdk::dpp::dashcore::{Network, PrivateKey}; -use dash_sdk::dpp::fee::Credits; -use dash_sdk::dpp::identity::core_script::CoreScript; -use dash_sdk::dpp::prelude::AddressNonce; -use dash_sdk::dpp::serialization::PlatformSerializable; -use dash_sdk::dpp::state_transition::shield_from_asset_lock_transition::methods::ShieldFromAssetLockTransitionMethodsV0; -use dash_sdk::dpp::state_transition::shield_from_asset_lock_transition::ShieldFromAssetLockTransition; -use dash_sdk::dpp::state_transition::shield_transition::methods::ShieldTransitionMethodsV0; -use dash_sdk::dpp::state_transition::shield_transition::ShieldTransition; -use dash_sdk::dpp::state_transition::shielded_transfer_transition::methods::ShieldedTransferTransitionMethodsV0; -use dash_sdk::dpp::state_transition::shielded_transfer_transition::ShieldedTransferTransition; -use dash_sdk::dpp::state_transition::shielded_withdrawal_transition::methods::ShieldedWithdrawalTransitionMethodsV0; -use dash_sdk::dpp::state_transition::shielded_withdrawal_transition::ShieldedWithdrawalTransition; -use dash_sdk::dpp::state_transition::unshield_transition::methods::UnshieldTransitionMethodsV0; -use dash_sdk::dpp::state_transition::unshield_transition::UnshieldTransition; -use dash_sdk::dpp::state_transition::StateTransition; -use dash_sdk::dpp::withdrawal::Pooling; -use dash_sdk::platform::FetchMany; -use drive_proof_verifier::types::AddressInfo; -use std::collections::{BTreeMap, BTreeSet}; -use std::ffi::CString; -use std::os::raw::c_void; - -/// Helper to serialize a StateTransition and return as DashSDKResult with byte data. -fn serialize_state_transition(st: &StateTransition) -> DashSDKResult { - match st.serialize_to_bytes() { - Ok(bytes) => { - let len = bytes.len(); - let boxed = bytes.into_boxed_slice(); - let ptr = Box::into_raw(boxed) as *mut c_void; - // Return bytes with length encoded as a JSON string "{\"bytes\":\"\",\"len\":}" - // Actually, return raw bytes — caller gets pointer + length via the data field. - // We'll use String data type with hex encoding for FFI safety. - let hex_str = hex::encode(unsafe { std::slice::from_raw_parts(ptr as *const u8, len) }); - match CString::new(hex_str) { - Ok(c_str) => { - // Free the raw bytes we allocated above - unsafe { - let _ = - Box::from_raw(std::ptr::slice_from_raw_parts_mut(ptr as *mut u8, len)); - } - DashSDKResult { - data_type: DashSDKResultDataType::String, - data: c_str.into_raw() as *mut c_void, - error: std::ptr::null_mut(), - } - } - Err(e) => { - unsafe { - let _ = - Box::from_raw(std::ptr::slice_from_raw_parts_mut(ptr as *mut u8, len)); - } - DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InternalError, - format!("Failed to create CString: {}", e), - )) - } - } - } - Err(e) => DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InternalError, - format!("Failed to serialize state transition: {}", e), - )), - } -} - -/// Build a shielded transfer (shielded-to-shielded) state transition without broadcasting. -/// -/// Returns the serialized state transition as a hex-encoded string. -/// Use `dash_sdk_shielded_broadcast` to send it later. -/// -/// # Safety -/// - `sdk_handle` must be a valid SDK handle. -/// - `bundle` must be a valid pointer to `DashSDKOrchardBundleParams`. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_shielded_build_transfer( - sdk_handle: *const SDKHandle, - bundle: *const DashSDKOrchardBundleParams, - value_balance: u64, -) -> DashSDKResult { - if sdk_handle.is_null() || bundle.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "SDK handle or bundle is null".to_string(), - )); - } - - let wrapper = &*(sdk_handle as *const SDKWrapper); - - let orchard_bundle = match convert_orchard_bundle_params(&*bundle) { - Ok(b) => b, - Err(e) => return DashSDKResult::error(e.into()), - }; - - let st = match ShieldedTransferTransition::try_from_bundle( - orchard_bundle.actions, - value_balance, - orchard_bundle.anchor, - orchard_bundle.proof, - orchard_bundle.binding_signature, - wrapper.sdk.version(), - ) { - Ok(st) => st, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InternalError, - format!("Failed to build shielded transfer: {}", e), - )) - } - }; - - serialize_state_transition(&st) -} - -/// Build an unshield (shielded → platform address) state transition without broadcasting. -/// -/// # Safety -/// - All pointers must be valid. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_shielded_build_unshield( - sdk_handle: *const SDKHandle, - output_address: *const u8, - output_address_len: usize, - unshielding_amount: u64, - bundle: *const DashSDKOrchardBundleParams, -) -> DashSDKResult { - if sdk_handle.is_null() || bundle.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "SDK handle or bundle is null".to_string(), - )); - } - - if output_address.is_null() || output_address_len == 0 { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "Output address is null or empty".to_string(), - )); - } - - let wrapper = &*(sdk_handle as *const SDKWrapper); - - let address_bytes = std::slice::from_raw_parts(output_address, output_address_len); - let address = match PlatformAddress::from_bytes(address_bytes) { - Ok(addr) => addr, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!("Invalid output address: {}", e), - )) - } - }; - - let orchard_bundle = match convert_orchard_bundle_params(&*bundle) { - Ok(b) => b, - Err(e) => return DashSDKResult::error(e.into()), - }; - - let st = match UnshieldTransition::try_from_bundle( - address, - orchard_bundle.actions, - unshielding_amount, - orchard_bundle.anchor, - orchard_bundle.proof, - orchard_bundle.binding_signature, - wrapper.sdk.version(), - ) { - Ok(st) => st, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InternalError, - format!("Failed to build unshield transition: {}", e), - )) - } - }; - - serialize_state_transition(&st) -} - -/// Build a shield (platform addresses → shielded pool) state transition without broadcasting. -/// -/// Requires SDK handle to fetch address nonces from the network. -/// -/// # Safety -/// - All pointers must be valid. Private keys must be exactly 32 bytes. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_shielded_build_shield( - sdk_handle: *const SDKHandle, - inputs: *const DashSDKShieldInput, - inputs_count: u32, - bundle: *const DashSDKOrchardBundleParams, - amount: u64, - fee_from_input_index: u16, -) -> DashSDKResult { - if sdk_handle.is_null() || bundle.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "SDK handle or bundle is null".to_string(), - )); - } - - if inputs.is_null() || inputs_count == 0 { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "Inputs array is null or empty".to_string(), - )); - } - - if (fee_from_input_index as u32) >= inputs_count { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!( - "Fee input index {} out of bounds (count: {})", - fee_from_input_index, inputs_count - ), - )); - } - - let wrapper = &*(sdk_handle as *const SDKWrapper); - - let mut input_map: BTreeMap = BTreeMap::new(); - let mut signer = AddressSigner::new(); - let mut ordered_addresses: Vec = Vec::with_capacity(inputs_count as usize); - - let inputs_slice = std::slice::from_raw_parts(inputs, inputs_count as usize); - for (i, input) in inputs_slice.iter().enumerate() { - if input.address.is_null() || input.address_len == 0 || input.private_key.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!("Input {} has null address or private key", i), - )); - } - - let address_bytes = std::slice::from_raw_parts(input.address, input.address_len); - let address = match PlatformAddress::from_bytes(address_bytes) { - Ok(addr) => addr, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!("Input {} invalid address: {}", i, e), - )) - } - }; - - let pk_bytes = std::slice::from_raw_parts(input.private_key, 32); - let secret_key = match SecretKey::from_slice(pk_bytes) { - Ok(sk) => sk, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!("Input {} invalid private key: {}", i, e), - )) - } - }; - - // Reject duplicate addresses - if input_map.contains_key(&address) { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!("Duplicate address at input index {}", i), - )); - } - - let private_key = PrivateKey::new(secret_key, Network::Testnet); - signer.add_key(&address, private_key); - ordered_addresses.push(address); - input_map.insert(address, input.amount); - } - - let orchard_bundle = match convert_orchard_bundle_params(&*bundle) { - Ok(b) => b, - Err(e) => return DashSDKResult::error(e.into()), - }; - - // Remap fee_from_input_index: caller's index is in original input order, - // but DeductFromInput resolves against BTreeMap's sorted key order. - let fee_address = &ordered_addresses[fee_from_input_index as usize]; - let sorted_index = input_map - .keys() - .position(|k| k == fee_address) - .expect("fee address must exist in input_map") as u16; - - let fee_strategy: AddressFundsFeeStrategy = - vec![AddressFundsFeeStrategyStep::DeductFromInput(sorted_index)]; - - let user_fee_increase = 0u16; - - // Fetch nonces from the network and build the transition - let result = wrapper.runtime.block_on(async { - // Fetch address infos to get nonces - let addresses: BTreeSet = input_map.keys().copied().collect(); - let address_infos = AddressInfo::fetch_many(&wrapper.sdk, addresses) - .await - .map_err(FFIError::SDKError)?; - - // Build inputs with nonce+1 (next nonce) - let mut inputs_with_nonce: BTreeMap = - BTreeMap::new(); - for (address, amount) in &input_map { - let info = address_infos - .get(address) - .and_then(|opt| opt.as_ref()) - .ok_or_else(|| { - FFIError::InternalError(format!( - "Address not found on platform: {}", - hex::encode(address.to_bytes()) - )) - })?; - inputs_with_nonce.insert(*address, (info.nonce + 1, *amount)); - } - - let st = ShieldTransition::try_from_bundle_with_signer( - inputs_with_nonce, - orchard_bundle.actions, - amount, - orchard_bundle.anchor, - orchard_bundle.proof, - orchard_bundle.binding_signature, - fee_strategy, - &signer, - user_fee_increase, - wrapper.sdk.version(), - ) - .await - .map_err(|e| { - FFIError::InternalError(format!("Failed to build shield transition: {}", e)) - })?; - - Ok::(st) - }); - - match result { - Ok(st) => serialize_state_transition(&st), - Err(e) => DashSDKResult::error(e.into()), - } -} - -/// Build a shield-from-instant-lock state transition without broadcasting. -/// -/// # Safety -/// - All pointers must be valid. `private_key` must be exactly 32 bytes. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_shielded_build_shield_from_instant_lock( - sdk_handle: *const SDKHandle, - instant_lock_bytes: *const u8, - instant_lock_len: usize, - transaction_bytes: *const u8, - transaction_len: usize, - output_index: u32, - private_key: *const u8, - bundle: *const DashSDKOrchardBundleParams, - value_balance: u64, -) -> DashSDKResult { - if sdk_handle.is_null() - || instant_lock_bytes.is_null() - || transaction_bytes.is_null() - || private_key.is_null() - || bundle.is_null() - { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "One or more required pointers are null".to_string(), - )); - } - - let wrapper = &*(sdk_handle as *const SDKWrapper); - - let asset_lock_proof = match create_instant_asset_lock_proof( - instant_lock_bytes, - instant_lock_len, - transaction_bytes, - transaction_len, - output_index, - ) { - Ok(proof) => proof, - Err(e) => return DashSDKResult::error(DashSDKError::from(e)), - }; - - let pk_bytes = std::slice::from_raw_parts(private_key, 32); - - let orchard_bundle = match convert_orchard_bundle_params(&*bundle) { - Ok(b) => b, - Err(e) => return DashSDKResult::error(DashSDKError::from(e)), - }; - - let st = match ShieldFromAssetLockTransition::try_from_asset_lock_with_bundle( - asset_lock_proof, - pk_bytes, - orchard_bundle.actions, - value_balance, - orchard_bundle.anchor, - orchard_bundle.proof, - orchard_bundle.binding_signature, - wrapper.sdk.version(), - ) { - Ok(st) => st, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InternalError, - format!("Failed to build shield from asset lock: {}", e), - )) - } - }; - - serialize_state_transition(&st) -} - -/// Build a shield-from-chain-lock state transition without broadcasting. -/// -/// # Safety -/// - All pointers must be valid. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_shielded_build_shield_from_chain_lock( - sdk_handle: *const SDKHandle, - core_chain_locked_height: u32, - out_point_bytes: *const [u8; 36], - private_key: *const u8, - bundle: *const DashSDKOrchardBundleParams, - value_balance: u64, -) -> DashSDKResult { - if sdk_handle.is_null() - || out_point_bytes.is_null() - || private_key.is_null() - || bundle.is_null() - { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "One or more required pointers are null".to_string(), - )); - } - - let wrapper = &*(sdk_handle as *const SDKWrapper); - - let asset_lock_proof = - match create_chain_asset_lock_proof(core_chain_locked_height, out_point_bytes) { - Ok(proof) => proof, - Err(e) => return DashSDKResult::error(DashSDKError::from(e)), - }; - - let pk_bytes = std::slice::from_raw_parts(private_key, 32); - - let orchard_bundle = match convert_orchard_bundle_params(&*bundle) { - Ok(b) => b, - Err(e) => return DashSDKResult::error(DashSDKError::from(e)), - }; - - let st = match ShieldFromAssetLockTransition::try_from_asset_lock_with_bundle( - asset_lock_proof, - pk_bytes, - orchard_bundle.actions, - value_balance, - orchard_bundle.anchor, - orchard_bundle.proof, - orchard_bundle.binding_signature, - wrapper.sdk.version(), - ) { - Ok(st) => st, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InternalError, - format!("Failed to build shield from chain lock: {}", e), - )) - } - }; - - serialize_state_transition(&st) -} - -/// Build a shielded withdrawal (shielded → L1) state transition without broadcasting. -/// -/// # Safety -/// - All pointers must be valid. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_shielded_build_withdrawal( - sdk_handle: *const SDKHandle, - unshielding_amount: u64, - bundle: *const DashSDKOrchardBundleParams, - core_fee_per_byte: u32, - pooling: u8, - output_script: *const u8, - output_script_len: usize, -) -> DashSDKResult { - if sdk_handle.is_null() || bundle.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "SDK handle or bundle is null".to_string(), - )); - } - - if output_script.is_null() || output_script_len == 0 { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "Output script is null or empty".to_string(), - )); - } - - let wrapper = &*(sdk_handle as *const SDKWrapper); - - let pooling_enum = match pooling { - 0 => Pooling::Never, - 1 => Pooling::IfAvailable, - 2 => Pooling::Standard, - _ => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!("Invalid pooling value: {} (expected 0, 1, or 2)", pooling), - )) - } - }; - - let script_bytes = std::slice::from_raw_parts(output_script, output_script_len); - let core_script = CoreScript::new( - dash_sdk::dpp::dashcore::blockdata::script::ScriptBuf::from_bytes(script_bytes.to_vec()), - ); - - let orchard_bundle = match convert_orchard_bundle_params(&*bundle) { - Ok(b) => b, - Err(e) => return DashSDKResult::error(e.into()), - }; - - let st = match ShieldedWithdrawalTransition::try_from_bundle( - orchard_bundle.actions, - unshielding_amount, - orchard_bundle.anchor, - orchard_bundle.proof, - orchard_bundle.binding_signature, - core_fee_per_byte, - pooling_enum, - core_script, - wrapper.sdk.version(), - ) { - Ok(st) => st, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InternalError, - format!("Failed to build shielded withdrawal: {}", e), - )) - } - }; - - serialize_state_transition(&st) -} diff --git a/packages/rs-sdk-ffi/src/shielded/transitions/mod.rs b/packages/rs-sdk-ffi/src/shielded/transitions/mod.rs deleted file mode 100644 index 9e4bf48568d..00000000000 --- a/packages/rs-sdk-ffi/src/shielded/transitions/mod.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! Shielded state transition FFI bindings. - -pub mod broadcast; -pub mod builders; -pub mod shield; -pub mod shield_from_asset_lock; -pub mod shielded_transfer; -pub mod shielded_withdrawal; -pub mod unshield; - -// Build + broadcast combined -pub use shield::dash_sdk_shielded_shield_funds; -pub use shield_from_asset_lock::{ - dash_sdk_shielded_shield_from_chain_lock, dash_sdk_shielded_shield_from_instant_lock, -}; -pub use shielded_transfer::dash_sdk_shielded_transfer; -pub use shielded_withdrawal::dash_sdk_shielded_withdraw; -pub use unshield::dash_sdk_shielded_unshield_funds; - -// Build-only (returns serialized bytes) -pub use builders::{ - dash_sdk_shielded_build_shield, dash_sdk_shielded_build_shield_from_chain_lock, - dash_sdk_shielded_build_shield_from_instant_lock, dash_sdk_shielded_build_transfer, - dash_sdk_shielded_build_unshield, dash_sdk_shielded_build_withdrawal, -}; - -// Generic broadcast for pre-built transitions -pub use broadcast::dash_sdk_shielded_broadcast; diff --git a/packages/rs-sdk-ffi/src/shielded/transitions/shield.rs b/packages/rs-sdk-ffi/src/shielded/transitions/shield.rs deleted file mode 100644 index 31a22af7116..00000000000 --- a/packages/rs-sdk-ffi/src/shielded/transitions/shield.rs +++ /dev/null @@ -1,179 +0,0 @@ -//! Shield (platform addresses → shielded pool) FFI binding. - -use crate::address::AddressSigner; -use crate::sdk::SDKWrapper; -use crate::shielded::types::{convert_orchard_bundle_params, DashSDKOrchardBundleParams}; -use crate::types::SDKHandle; -use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, FFIError}; -use dash_sdk::dpp::address_funds::{ - AddressFundsFeeStrategy, AddressFundsFeeStrategyStep, PlatformAddress, -}; -use dash_sdk::dpp::dashcore::secp256k1::SecretKey; -use dash_sdk::dpp::dashcore::{Network, PrivateKey}; -use dash_sdk::dpp::fee::Credits; -use dash_sdk::platform::transition::shield::ShieldFunds; -use std::collections::BTreeMap; - -/// Input entry for shield operation: address + amount + private key. -#[repr(C)] -pub struct DashSDKShieldInput { - /// Platform address bytes. - pub address: *const u8, - /// Length of address bytes. - pub address_len: usize, - /// Amount to shield from this address (in credits). - pub amount: u64, - /// 32-byte private key for signing. - pub private_key: *const u8, -} - -/// Shield funds from platform addresses into the shielded pool. -/// -/// # Parameters -/// - `sdk_handle`: SDK handle -/// - `inputs`: Array of input addresses with amounts and private keys -/// - `inputs_count`: Number of inputs -/// - `bundle`: Orchard bundle parameters -/// - `amount`: Total amount being shielded (must match bundle value balance) -/// - `fee_from_input_index`: Which input index to deduct fees from (0-based) -/// -/// # Returns -/// `DashSDKResult` with no data on success, error on failure. -/// -/// # Safety -/// - All pointers must be valid. Private keys must be exactly 32 bytes. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_shielded_shield_funds( - sdk_handle: *const SDKHandle, - inputs: *const DashSDKShieldInput, - inputs_count: u32, - bundle: *const DashSDKOrchardBundleParams, - amount: u64, - fee_from_input_index: u16, -) -> DashSDKResult { - if sdk_handle.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "SDK handle is null".to_string(), - )); - } - - if inputs.is_null() || inputs_count == 0 { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "Inputs array is null or empty".to_string(), - )); - } - - if bundle.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "Bundle params is null".to_string(), - )); - } - - if (fee_from_input_index as u32) >= inputs_count { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!( - "Fee input index {} is out of bounds (inputs count: {})", - fee_from_input_index, inputs_count - ), - )); - } - - let wrapper = &*(sdk_handle as *const SDKWrapper); - - // Parse inputs and create signer, preserving original order - let mut input_map: BTreeMap = BTreeMap::new(); - let mut signer = AddressSigner::new(); - let mut ordered_addresses: Vec = Vec::with_capacity(inputs_count as usize); - - let inputs_slice = std::slice::from_raw_parts(inputs, inputs_count as usize); - for (i, input) in inputs_slice.iter().enumerate() { - if input.address.is_null() || input.address_len == 0 { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!("Input {} has null or empty address", i), - )); - } - - if input.private_key.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!("Input {} has null private key", i), - )); - } - - let address_bytes = std::slice::from_raw_parts(input.address, input.address_len); - let address = match PlatformAddress::from_bytes(address_bytes) { - Ok(addr) => addr, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!("Failed to parse input address {}: {}", i, e), - )) - } - }; - - let pk_bytes = std::slice::from_raw_parts(input.private_key, 32); - let secret_key = match SecretKey::from_slice(pk_bytes) { - Ok(sk) => sk, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!("Failed to parse private key for input {}: {}", i, e), - )) - } - }; - - // Reject duplicate addresses — BTreeMap would silently drop them - if input_map.contains_key(&address) { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!("Duplicate address at input index {}", i), - )); - } - - let private_key = PrivateKey::new(secret_key, Network::Testnet); - signer.add_key(&address, private_key); - ordered_addresses.push(address); - input_map.insert(address, input.amount); - } - - let orchard_bundle = match convert_orchard_bundle_params(&*bundle) { - Ok(b) => b, - Err(e) => return DashSDKResult::error(e.into()), - }; - - // Remap fee_from_input_index: the caller's index is in original input order, - // but DeductFromInput resolves against BTreeMap's sorted key order. - let fee_address = &ordered_addresses[fee_from_input_index as usize]; - let sorted_index = input_map - .keys() - .position(|k| k == fee_address) - .expect("fee address must exist in input_map") as u16; - - let fee_strategy: AddressFundsFeeStrategy = - vec![AddressFundsFeeStrategyStep::DeductFromInput(sorted_index)]; - - let result = wrapper.runtime.block_on(async { - wrapper - .sdk - .shield_funds( - input_map, - orchard_bundle, - amount, - fee_strategy, - &signer, - None, - ) - .await - .map_err(FFIError::from) - }); - - match result { - Ok(()) => DashSDKResult::success(std::ptr::null_mut()), - Err(e) => DashSDKResult::error(e.into()), - } -} diff --git a/packages/rs-sdk-ffi/src/shielded/transitions/shield_from_asset_lock.rs b/packages/rs-sdk-ffi/src/shielded/transitions/shield_from_asset_lock.rs deleted file mode 100644 index 52318bf031a..00000000000 --- a/packages/rs-sdk-ffi/src/shielded/transitions/shield_from_asset_lock.rs +++ /dev/null @@ -1,169 +0,0 @@ -//! Shield from asset lock (L1 → shielded pool) FFI binding. - -use crate::identity::{create_chain_asset_lock_proof, create_instant_asset_lock_proof}; -use crate::sdk::SDKWrapper; -use crate::shielded::types::{convert_orchard_bundle_params, DashSDKOrchardBundleParams}; -use crate::types::SDKHandle; -use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, FFIError}; -use dash_sdk::platform::transition::shield_from_asset_lock::ShieldFromAssetLock; - -/// Shield funds from an L1 instant asset lock into the shielded pool. -/// -/// # Parameters -/// - `sdk_handle`: SDK handle -/// - `instant_lock_bytes`: Serialized instant lock -/// - `instant_lock_len`: Length of instant lock bytes -/// - `transaction_bytes`: Serialized funding transaction -/// - `transaction_len`: Length of transaction bytes -/// - `output_index`: Output index in the transaction -/// - `private_key`: 32-byte ECDSA private key for the asset lock -/// - `bundle`: Orchard bundle parameters -/// - `value_balance`: Net value flowing into the shielded pool -/// -/// # Returns -/// `DashSDKResult` with no data on success, error on failure. -/// -/// # Safety -/// - All pointers must be valid. `private_key` must be exactly 32 bytes. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_shielded_shield_from_instant_lock( - sdk_handle: *const SDKHandle, - instant_lock_bytes: *const u8, - instant_lock_len: usize, - transaction_bytes: *const u8, - transaction_len: usize, - output_index: u32, - private_key: *const u8, - bundle: *const DashSDKOrchardBundleParams, - value_balance: u64, -) -> DashSDKResult { - if sdk_handle.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "SDK handle is null".to_string(), - )); - } - - if instant_lock_bytes.is_null() - || transaction_bytes.is_null() - || private_key.is_null() - || bundle.is_null() - { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "One or more required pointers are null".to_string(), - )); - } - - let wrapper = &*(sdk_handle as *const SDKWrapper); - - let asset_lock_proof = match create_instant_asset_lock_proof( - instant_lock_bytes, - instant_lock_len, - transaction_bytes, - transaction_len, - output_index, - ) { - Ok(proof) => proof, - Err(e) => return DashSDKResult::error(DashSDKError::from(e)), - }; - - let pk_bytes = std::slice::from_raw_parts(private_key, 32); - - let orchard_bundle = match convert_orchard_bundle_params(&*bundle) { - Ok(b) => b, - Err(e) => return DashSDKResult::error(DashSDKError::from(e)), - }; - - let result = wrapper.runtime.block_on(async { - wrapper - .sdk - .shield_from_asset_lock( - asset_lock_proof, - pk_bytes, - orchard_bundle, - value_balance, - None, - ) - .await - .map_err(FFIError::from) - }); - - match result { - Ok(()) => DashSDKResult::success(std::ptr::null_mut()), - Err(e) => DashSDKResult::error(e.into()), - } -} - -/// Shield funds from an L1 chain asset lock into the shielded pool. -/// -/// # Parameters -/// - `sdk_handle`: SDK handle -/// - `core_chain_locked_height`: Core chain locked height for the asset lock -/// - `out_point_bytes`: 36-byte outpoint (32 txid + 4 index) -/// - `private_key`: 32-byte ECDSA private key for the asset lock -/// - `bundle`: Orchard bundle parameters -/// - `value_balance`: Net value flowing into the shielded pool -/// -/// # Returns -/// `DashSDKResult` with no data on success, error on failure. -/// -/// # Safety -/// - All pointers must be valid. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_shielded_shield_from_chain_lock( - sdk_handle: *const SDKHandle, - core_chain_locked_height: u32, - out_point_bytes: *const [u8; 36], - private_key: *const u8, - bundle: *const DashSDKOrchardBundleParams, - value_balance: u64, -) -> DashSDKResult { - if sdk_handle.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "SDK handle is null".to_string(), - )); - } - - if out_point_bytes.is_null() || private_key.is_null() || bundle.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "One or more required pointers are null".to_string(), - )); - } - - let wrapper = &*(sdk_handle as *const SDKWrapper); - - let asset_lock_proof = - match create_chain_asset_lock_proof(core_chain_locked_height, out_point_bytes) { - Ok(proof) => proof, - Err(e) => return DashSDKResult::error(DashSDKError::from(e)), - }; - - let pk_bytes = std::slice::from_raw_parts(private_key, 32); - - let orchard_bundle = match convert_orchard_bundle_params(&*bundle) { - Ok(b) => b, - Err(e) => return DashSDKResult::error(DashSDKError::from(e)), - }; - - let result = wrapper.runtime.block_on(async { - wrapper - .sdk - .shield_from_asset_lock( - asset_lock_proof, - pk_bytes, - orchard_bundle, - value_balance, - None, - ) - .await - .map_err(FFIError::from) - }); - - match result { - Ok(()) => DashSDKResult::success(std::ptr::null_mut()), - Err(e) => DashSDKResult::error(e.into()), - } -} diff --git a/packages/rs-sdk-ffi/src/shielded/transitions/shielded_transfer.rs b/packages/rs-sdk-ffi/src/shielded/transitions/shielded_transfer.rs deleted file mode 100644 index 23918b20bbf..00000000000 --- a/packages/rs-sdk-ffi/src/shielded/transitions/shielded_transfer.rs +++ /dev/null @@ -1,64 +0,0 @@ -//! Shielded transfer (shielded-to-shielded) FFI binding. - -use crate::sdk::SDKWrapper; -use crate::shielded::types::{convert_orchard_bundle_params, DashSDKOrchardBundleParams}; -use crate::types::SDKHandle; -use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, FFIError}; -use dash_sdk::platform::transition::shielded_transfer::TransferShielded; - -/// Transfer funds within the shielded pool (shielded-to-shielded). -/// -/// Authentication is entirely via Orchard spend authorization signatures -/// embedded in the bundle actions — no identity or platform key required. -/// -/// # Parameters -/// - `sdk_handle`: SDK handle -/// - `bundle`: Orchard bundle parameters (actions, anchor, proof, binding signature) -/// - `value_balance`: Net value flowing out of the shielded pool (typically 0 for pure transfers, >0 if paying fees) -/// -/// # Returns -/// `DashSDKResult` with no data on success, error on failure. -/// -/// # Safety -/// - `sdk_handle` must be a valid SDK handle. -/// - `bundle` must be a valid pointer to a `DashSDKOrchardBundleParams`. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_shielded_transfer( - sdk_handle: *const SDKHandle, - bundle: *const DashSDKOrchardBundleParams, - value_balance: u64, -) -> DashSDKResult { - if sdk_handle.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "SDK handle is null".to_string(), - )); - } - - if bundle.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "Bundle params is null".to_string(), - )); - } - - let wrapper = &*(sdk_handle as *const SDKWrapper); - - let orchard_bundle = match convert_orchard_bundle_params(&*bundle) { - Ok(b) => b, - Err(e) => return DashSDKResult::error(e.into()), - }; - - let result = wrapper.runtime.block_on(async { - wrapper - .sdk - .transfer_shielded(orchard_bundle, value_balance, None) - .await - .map_err(FFIError::from) - }); - - match result { - Ok(()) => DashSDKResult::success(std::ptr::null_mut()), - Err(e) => DashSDKResult::error(e.into()), - } -} diff --git a/packages/rs-sdk-ffi/src/shielded/transitions/shielded_withdrawal.rs b/packages/rs-sdk-ffi/src/shielded/transitions/shielded_withdrawal.rs deleted file mode 100644 index 852bffbe877..00000000000 --- a/packages/rs-sdk-ffi/src/shielded/transitions/shielded_withdrawal.rs +++ /dev/null @@ -1,103 +0,0 @@ -//! Shielded withdrawal (shielded pool → L1) FFI binding. - -use crate::sdk::SDKWrapper; -use crate::shielded::types::{convert_orchard_bundle_params, DashSDKOrchardBundleParams}; -use crate::types::SDKHandle; -use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, FFIError}; -use dash_sdk::dpp::identity::core_script::CoreScript; -use dash_sdk::dpp::withdrawal::Pooling; -use dash_sdk::platform::transition::shielded_withdrawal::WithdrawShielded; - -/// Withdraw funds from the shielded pool to a Core (L1) address. -/// -/// Authentication is via Orchard spend authorization signatures in the bundle. -/// -/// # Parameters -/// - `sdk_handle`: SDK handle -/// - `unshielding_amount`: Amount to withdraw (in credits) -/// - `bundle`: Orchard bundle parameters -/// - `core_fee_per_byte`: Core fee per byte for the withdrawal transaction -/// - `pooling`: Pooling mode (0 = Never, 1 = IfAvailable, 2 = Standard) -/// - `output_script`: Raw Core output script bytes -/// - `output_script_len`: Length of output script bytes -/// -/// # Returns -/// `DashSDKResult` with no data on success, error on failure. -/// -/// # Safety -/// - All pointers must be valid. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_shielded_withdraw( - sdk_handle: *const SDKHandle, - unshielding_amount: u64, - bundle: *const DashSDKOrchardBundleParams, - core_fee_per_byte: u32, - pooling: u8, - output_script: *const u8, - output_script_len: usize, -) -> DashSDKResult { - if sdk_handle.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "SDK handle is null".to_string(), - )); - } - - if bundle.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "Bundle params is null".to_string(), - )); - } - - if output_script.is_null() || output_script_len == 0 { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "Output script is null or empty".to_string(), - )); - } - - let wrapper = &*(sdk_handle as *const SDKWrapper); - - let pooling_enum = match pooling { - 0 => Pooling::Never, - 1 => Pooling::IfAvailable, - 2 => Pooling::Standard, - _ => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!("Invalid pooling value: {} (expected 0, 1, or 2)", pooling), - )) - } - }; - - let script_bytes = std::slice::from_raw_parts(output_script, output_script_len); - let core_script = CoreScript::new( - dash_sdk::dpp::dashcore::blockdata::script::ScriptBuf::from_bytes(script_bytes.to_vec()), - ); - - let orchard_bundle = match convert_orchard_bundle_params(&*bundle) { - Ok(b) => b, - Err(e) => return DashSDKResult::error(e.into()), - }; - - let result = wrapper.runtime.block_on(async { - wrapper - .sdk - .withdraw_shielded( - unshielding_amount, - orchard_bundle, - core_fee_per_byte, - pooling_enum, - core_script, - None, - ) - .await - .map_err(FFIError::from) - }); - - match result { - Ok(()) => DashSDKResult::success(std::ptr::null_mut()), - Err(e) => DashSDKResult::error(e.into()), - } -} diff --git a/packages/rs-sdk-ffi/src/shielded/transitions/unshield.rs b/packages/rs-sdk-ffi/src/shielded/transitions/unshield.rs deleted file mode 100644 index 9f734069214..00000000000 --- a/packages/rs-sdk-ffi/src/shielded/transitions/unshield.rs +++ /dev/null @@ -1,86 +0,0 @@ -//! Unshield (shielded pool → platform address) FFI binding. - -use crate::sdk::SDKWrapper; -use crate::shielded::types::{convert_orchard_bundle_params, DashSDKOrchardBundleParams}; -use crate::types::SDKHandle; -use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, FFIError}; -use dash_sdk::dpp::address_funds::PlatformAddress; -use dash_sdk::platform::transition::unshield::UnshieldFunds; - -/// Unshield funds from the shielded pool to a platform address. -/// -/// Authentication is via Orchard spend authorization signatures in the bundle. -/// -/// # Parameters -/// - `sdk_handle`: SDK handle -/// - `output_address`: Platform address bytes (typically 21 bytes: 1 type + 20 hash) -/// - `output_address_len`: Length of address bytes -/// - `unshielding_amount`: Amount to unshield (in credits) -/// - `bundle`: Orchard bundle parameters -/// -/// # Returns -/// `DashSDKResult` with no data on success, error on failure. -/// -/// # Safety -/// - `sdk_handle` must be valid. `output_address` must point to `output_address_len` bytes. -/// - `bundle` must be a valid pointer. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_shielded_unshield_funds( - sdk_handle: *const SDKHandle, - output_address: *const u8, - output_address_len: usize, - unshielding_amount: u64, - bundle: *const DashSDKOrchardBundleParams, -) -> DashSDKResult { - if sdk_handle.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "SDK handle is null".to_string(), - )); - } - - if output_address.is_null() || output_address_len == 0 { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "Output address is null or empty".to_string(), - )); - } - - if bundle.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "Bundle params is null".to_string(), - )); - } - - let wrapper = &*(sdk_handle as *const SDKWrapper); - - let address_bytes = std::slice::from_raw_parts(output_address, output_address_len); - let address = match PlatformAddress::from_bytes(address_bytes) { - Ok(addr) => addr, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!("Invalid output address: {}", e), - )) - } - }; - - let orchard_bundle = match convert_orchard_bundle_params(&*bundle) { - Ok(b) => b, - Err(e) => return DashSDKResult::error(e.into()), - }; - - let result = wrapper.runtime.block_on(async { - wrapper - .sdk - .unshield_funds(address, unshielding_amount, orchard_bundle, None) - .await - .map_err(FFIError::from) - }); - - match result { - Ok(()) => DashSDKResult::success(std::ptr::null_mut()), - Err(e) => DashSDKResult::error(e.into()), - } -} diff --git a/packages/rs-sdk-ffi/src/shielded/types.rs b/packages/rs-sdk-ffi/src/shielded/types.rs deleted file mode 100644 index 29829b00439..00000000000 --- a/packages/rs-sdk-ffi/src/shielded/types.rs +++ /dev/null @@ -1,92 +0,0 @@ -//! Shared FFI types for shielded pool operations. - -use crate::FFIError; -use dash_sdk::dpp::shielded::{OrchardBundleParams, SerializedAction}; - -/// A serialized Orchard action for FFI. -#[repr(C)] -pub struct DashSDKSerializedAction { - pub nullifier: [u8; 32], - pub rk: [u8; 32], - pub cmx: [u8; 32], - pub encrypted_note: *const u8, - pub encrypted_note_len: usize, - pub cv_net: [u8; 32], - pub spend_auth_sig: [u8; 64], -} - -/// Orchard bundle parameters for FFI. -/// -/// Shared across all 5 shielded transition types. The client constructs the -/// Orchard bundle (proof, signatures) independently and passes the raw bytes. -#[repr(C)] -pub struct DashSDKOrchardBundleParams { - pub actions: *const DashSDKSerializedAction, - pub actions_count: u32, - pub anchor: [u8; 32], - pub proof: *const u8, - pub proof_len: usize, - pub binding_signature: [u8; 64], -} - -/// Convert FFI Orchard bundle params to Rust `OrchardBundleParams`. -/// -/// # Safety -/// All pointers in the FFI struct must be valid for the specified lengths. -pub unsafe fn convert_orchard_bundle_params( - ffi: &DashSDKOrchardBundleParams, -) -> Result { - if ffi.actions.is_null() && ffi.actions_count > 0 { - return Err(FFIError::InvalidParameter( - "Orchard bundle actions pointer is null".to_string(), - )); - } - - if ffi.proof.is_null() && ffi.proof_len > 0 { - return Err(FFIError::InvalidParameter( - "Orchard bundle proof pointer is null".to_string(), - )); - } - - let mut actions = Vec::with_capacity(ffi.actions_count as usize); - if ffi.actions_count > 0 { - let actions_slice = std::slice::from_raw_parts(ffi.actions, ffi.actions_count as usize); - for (i, action) in actions_slice.iter().enumerate() { - if action.encrypted_note.is_null() && action.encrypted_note_len > 0 { - return Err(FFIError::InvalidParameter(format!( - "Action {} encrypted_note pointer is null", - i - ))); - } - - let encrypted_note = if action.encrypted_note_len > 0 { - std::slice::from_raw_parts(action.encrypted_note, action.encrypted_note_len) - .to_vec() - } else { - Vec::new() - }; - - actions.push(SerializedAction { - nullifier: action.nullifier, - rk: action.rk, - cmx: action.cmx, - encrypted_note, - cv_net: action.cv_net, - spend_auth_sig: action.spend_auth_sig, - }); - } - } - - let proof = if ffi.proof_len > 0 { - std::slice::from_raw_parts(ffi.proof, ffi.proof_len).to_vec() - } else { - Vec::new() - }; - - Ok(OrchardBundleParams { - actions, - anchor: ffi.anchor, - proof, - binding_signature: ffi.binding_signature, - }) -} diff --git a/packages/rs-unified-sdk-ffi/Cargo.toml b/packages/rs-unified-sdk-ffi/Cargo.toml index 14a1af512f5..33edafc1e76 100644 --- a/packages/rs-unified-sdk-ffi/Cargo.toml +++ b/packages/rs-unified-sdk-ffi/Cargo.toml @@ -12,4 +12,11 @@ dash-spv-ffi = { workspace = true } key-wallet-ffi = { workspace = true } platform-wallet-ffi = { path = "../rs-platform-wallet-ffi" } rs-sdk-ffi = { path = "../rs-sdk-ffi" } -dash-network = { workspace = true, features = ["ffi"] } \ No newline at end of file +dash-network = { workspace = true, features = ["ffi"] } + +[features] +default = [] +# Forwards to `platform-wallet-ffi/shielded`. Pulls Orchard / ZK +# shielded sync support into the unified iOS framework. Off by +# default so the framework's transparent surface stays slim. +shielded = ["platform-wallet-ffi/shielded"] \ No newline at end of file diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 59c7067e5ee..8f6cdc3dc0c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -26,12 +26,26 @@ public class PlatformWalletManager: ObservableObject { /// started in [`configure`]. @Published public private(set) var spvProgress: PlatformSpvSyncProgress = .empty + /// Block time of the SPV header storage's current tip (if any). + /// `nil` while SPV isn't running or hasn't stored a header yet. + /// Useful as a "is core producing blocks?" indicator — when this + /// stamp stops advancing, the chain is stalled even though the + /// local SPV client is healthy. + @Published public private(set) var spvTipBlockTime: Date? + /// Whether the Rust-owned platform-address sync manager is currently in flight. @Published public private(set) var platformAddressSyncIsSyncing: Bool = false /// Last completed platform-address sync event emitted by Rust. @Published public internal(set) var lastPlatformAddressSyncEvent: PlatformAddressSyncEvent? + /// Whether the Rust-owned shielded sync coordinator currently has + /// a pass in flight. + @Published public private(set) var shieldedSyncIsSyncing: Bool = false + + /// Last completed shielded sync event emitted by Rust. + @Published public internal(set) var lastShieldedSyncEvent: ShieldedSyncEvent? + /// All wallets currently held by the Rust-side /// `PlatformWalletManager`, keyed by the 32-byte wallet id. /// @@ -76,6 +90,7 @@ public class PlatformWalletManager: ObservableObject { progressPollTask?.cancel() if handle != NULL_HANDLE { platform_wallet_manager_platform_address_sync_stop(handle).discard() + platform_wallet_manager_shielded_sync_stop(handle).discard() platform_wallet_manager_destroy(handle).discard() } } @@ -483,6 +498,14 @@ public class PlatformWalletManager: ObservableObject { isSyncing != self.platformAddressSyncIsSyncing { self.platformAddressSyncIsSyncing = isSyncing } + if let isSyncing = try? self.isShieldedSyncing(), + isSyncing != self.shieldedSyncIsSyncing { + self.shieldedSyncIsSyncing = isSyncing + } + let tip = (try? self.currentSpvTipBlockTime()) ?? nil + if tip != self.spvTipBlockTime { + self.spvTipBlockTime = tip + } try? await Task.sleep(for: .seconds(1)) } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerAddressSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerAddressSync.swift index 783fa110fd7..c0023d00372 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerAddressSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerAddressSync.swift @@ -48,6 +48,7 @@ final class PlatformWalletEventHandler { var callbacks = EventHandlerCallbacks() callbacks.context = Unmanaged.passUnretained(self).toOpaque() callbacks.on_platform_address_sync_completed_fn = platformAddressSyncCompletedCallback + callbacks.on_shielded_sync_completed_fn = shieldedSyncCompletedCallback return callbacks } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerSPV.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerSPV.swift index 7f8751e4cfa..832910c9423 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerSPV.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerSPV.swift @@ -159,6 +159,25 @@ extension PlatformWalletManager { return running } + /// Read the unix-seconds block time of the SPV header storage's + /// current tip. Returns `nil` when no tip is available — i.e. + /// the SPV client isn't running, no headers have been stored + /// yet, or the tip header isn't readable. + /// + /// Distinct from the `@Published var spvTipBlockTime` mirror on + /// the manager: this is a one-shot FFI query, the published + /// property is the cached value the 1 Hz progress poll feeds. + /// + /// Useful as a "is core producing blocks?" indicator: a stale + /// stamp across multiple polls means the chain has stalled + /// even though the local SPV client is healthy. + public func currentSpvTipBlockTime() throws -> Date? { + var unixSeconds: UInt64 = 0 + try platform_wallet_manager_spv_tip_unix_seconds(handle, &unixSeconds).check() + guard unixSeconds > 0 else { return nil } + return Date(timeIntervalSince1970: TimeInterval(unixSeconds)) + } + /// Start the SPV client in the background. /// /// Spawns the sync loop on the shared tokio runtime and returns diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift new file mode 100644 index 00000000000..1b739d1145c --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -0,0 +1,286 @@ +import Foundation +import DashSDKFFI + +/// Per-wallet outcome from a completed shielded sync pass. +/// +/// Mirrors the Rust-side +/// [`ShieldedSyncWalletResultFFI`](https://github.com/dashpay/platform/blob/v3.1-dev/packages/rs-platform-wallet-ffi/src/shielded_types.rs) +/// with three states: +/// +/// - `success == true`: sync succeeded; the numeric counters are +/// meaningful and `errorMessage` is `nil`. +/// - `skipped == true`: the wallet has no bound shielded sub-wallet +/// so the pass passed it over; both `success` and `errorMessage` +/// are vacuous. +/// - both flags `false` and `errorMessage != nil`: the sync itself +/// failed. +public struct ShieldedWalletSyncResult: Sendable { + public let walletId: Data + public let success: Bool + public let skipped: Bool + public let newNotes: UInt32 + public let totalScanned: UInt64 + public let newlySpent: UInt32 + public let balance: UInt64 + public let errorMessage: String? + + init(ffi: ShieldedSyncWalletResultFFI) { + var walletId = ffi.wallet_id + self.walletId = withUnsafeBytes(of: &walletId) { Data($0) } + self.success = ffi.success + self.skipped = ffi.skipped + self.newNotes = ffi.new_notes + self.totalScanned = ffi.total_scanned + self.newlySpent = ffi.newly_spent + self.balance = ffi.balance + self.errorMessage = ffi.error_message.map { String(cString: $0) } + } +} + +/// One shielded sync pass dispatched from the Rust coordinator. +public struct ShieldedSyncEvent: Sendable { + public let syncUnixSeconds: UInt64 + public let walletResults: [ShieldedWalletSyncResult] + + public func result(for walletId: Data) -> ShieldedWalletSyncResult? { + walletResults.first { $0.walletId == walletId } + } +} + +extension PlatformWalletManager { + func handleShieldedSyncCompleted(_ event: ShieldedSyncEvent) { + lastShieldedSyncEvent = event + } + + /// Derive Orchard keys for `walletId` from the host-side mnemonic + /// resolver, open or create the per-network commitment tree at + /// `dbPath`, and bind the resulting shielded sub-wallet to the + /// `PlatformWallet`. + /// + /// 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. + /// + /// Idempotent: calling again with a different account or + /// `dbPath` replaces the previously-bound shielded wallet. + public func bindShielded( + walletId: Data, + resolver: MnemonicResolver, + account: UInt32 = 0, + dbPath: String + ) 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 let resolverHandle = resolver.handle else { + throw PlatformWalletError.invalidParameter( + "MnemonicResolver has no handle" + ) + } + + try walletId.withUnsafeBytes { walletIdRaw in + guard let walletIdPtr = walletIdRaw.baseAddress? + .assumingMemoryBound(to: UInt8.self) + 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() + } + } + } + + /// Start the shielded sync coordinator's background loop. + /// + /// Wallets that have not yet been bound via [`bindShielded`] are + /// emitted as `skipped` results on every pass — the host can + /// call `bindShielded` later and the loop will pick the binding + /// up on its next tick. + public func startShieldedSync(intervalSeconds: UInt64? = nil) throws { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidHandle( + "PlatformWalletManager not configured" + ) + } + + if let intervalSeconds { + try setShieldedSyncInterval(seconds: intervalSeconds) + } + try platform_wallet_manager_shielded_sync_start(handle).check() + } + + public func stopShieldedSync() throws { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidHandle( + "PlatformWalletManager not configured" + ) + } + try platform_wallet_manager_shielded_sync_stop(handle).check() + } + + public func isShieldedSyncRunning() throws -> Bool { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidHandle( + "PlatformWalletManager not configured" + ) + } + var running = false + try platform_wallet_manager_shielded_sync_is_running(handle, &running).check() + return running + } + + public func isShieldedSyncing() throws -> Bool { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidHandle( + "PlatformWalletManager not configured" + ) + } + var syncing = false + try platform_wallet_manager_shielded_sync_is_syncing(handle, &syncing).check() + return syncing + } + + public func lastShieldedSyncUnixSeconds() throws -> UInt64 { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidHandle( + "PlatformWalletManager not configured" + ) + } + var lastSync: UInt64 = 0 + try platform_wallet_manager_shielded_sync_last_sync_unix_seconds(handle, &lastSync).check() + return lastSync + } + + public func setShieldedSyncInterval(seconds: UInt64) throws { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidHandle( + "PlatformWalletManager not configured" + ) + } + try platform_wallet_manager_shielded_sync_set_interval(handle, seconds).check() + } + + public func syncShieldedNow() async throws { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidHandle( + "PlatformWalletManager not configured" + ) + } + let handle = self.handle + try await Task.detached(priority: .userInitiated) { + try platform_wallet_manager_shielded_sync_sync_now(handle).check() + }.value + } + + /// Read the default Orchard payment address for `walletId` as + /// the 43 raw bytes. Returns `nil` when the wallet exists on + /// the manager but has no bound shielded sub-wallet (i.e. + /// [`bindShielded`] hasn't run, or it failed). Throws when the + /// wallet id isn't known to the manager. + /// + /// The host is responsible for bech32m-encoding the result for + /// display (HRP `dash` / `tdash` + `0x10` type byte). + public func shieldedDefaultAddress(walletId: Data) throws -> Data? { + 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" + ) + } + + var bytes = [UInt8](repeating: 0, count: 43) + var present = false + try walletId.withUnsafeBytes { raw in + guard let ptr = raw.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + throw PlatformWalletError.invalidParameter("walletId baseAddress is nil") + } + try bytes.withUnsafeMutableBufferPointer { outBuf in + guard let outPtr = outBuf.baseAddress else { + throw PlatformWalletError.invalidParameter( + "shieldedDefaultAddress out buffer baseAddress is nil" + ) + } + try platform_wallet_manager_shielded_default_address( + handle, + ptr, + outPtr, + &present + ).check() + } + } + return present ? Data(bytes) : nil + } + + public func syncShieldedWalletNow(walletId: Data) 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 walletIdCopy = walletId + try await Task.detached(priority: .userInitiated) { + try walletIdCopy.withUnsafeBytes { raw in + guard let ptr = raw.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + throw PlatformWalletError.invalidParameter("walletId baseAddress is nil") + } + try platform_wallet_manager_shielded_sync_wallet(handle, ptr).check() + } + }.value + } +} + +/// C trampoline matching `EventHandlerCallbacks.on_shielded_sync_completed_fn`. +func shieldedSyncCompletedCallback( + context: UnsafeMutableRawPointer?, + resultsPtr: UnsafePointer?, + count: UInt, + syncUnixSeconds: UInt64 +) { + guard let context else { return } + + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + + var results: [ShieldedWalletSyncResult] = [] + if let resultsPtr, count > 0 { + results.reserveCapacity(Int(count)) + for i in 0.. NullifierSyncResult { - guard let sdkHandle = handle else { - throw SDKError.invalidState("SDK not initialized") - } - - guard !nullifiers.isEmpty else { - throw SDKError.invalidParameter("Nullifiers array must not be empty") - } - - // Validate all nullifiers are 32 bytes - for (i, nf) in nullifiers.enumerated() { - guard nf.count == 32 else { - throw SDKError.invalidParameter( - "Nullifier at index \(i) must be 32 bytes, got \(nf.count)" - ) - } - } - - // Concatenate all nullifiers into a single contiguous byte array - var concatenated = Data(capacity: nullifiers.count * 32) - for nf in nullifiers { - concatenated.append(nf) - } - - let sdkPtr = NullifierSendableSdkPtr(sdkHandle) - let count = UInt32(nullifiers.count) - - // Prepare config — use let so it can be captured by @Sendable closure - let ffiConfig: FFINullifierSyncConfig? = if let cfg = config { - try cfg.toFFI() - } else { - nil - } - let nullifierData = concatenated // immutable copy for capture - - return try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global().async { - // Use the _with_result variant for better error handling via DashSDKResult. - let result: DashSDKResult - - if var cfg = ffiConfig { - result = nullifierData.withUnsafeBytes { bytesPtr -> DashSDKResult in - guard let base = bytesPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return DashSDKResult() - } - return withUnsafePointer(to: &cfg) { cfgPtr in - dash_sdk_sync_nullifiers_with_result( - UnsafePointer(sdkPtr.ptr), - base, - count, - cfgPtr, - lastSyncHeight, - lastSyncTimestamp - ) - } - } - } else { - result = nullifierData.withUnsafeBytes { bytesPtr -> DashSDKResult in - guard let base = bytesPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return DashSDKResult() - } - return dash_sdk_sync_nullifiers_with_result( - UnsafePointer(sdkPtr.ptr), - base, - count, - nil, - lastSyncHeight, - lastSyncTimestamp - ) - } - } - - // Check for error - if let error = result.error { - let errorMessage = error.pointee.message != nil - ? String(cString: error.pointee.message!) - : "Unknown error" - dash_sdk_error_free(error) - continuation.resume(throwing: SDKError.internalError(errorMessage)) - return - } - - guard let dataPtr = result.data else { - continuation.resume(throwing: SDKError.internalError("No sync result returned")) - return - } - - // The data pointer is a FFINullifierSyncResult allocated by Box::into_raw. - let ffiResultPtr = dataPtr.assumingMemoryBound(to: FFINullifierSyncResult.self) - let ffiResult = ffiResultPtr.pointee - - // Copy data out into Swift types before freeing. - let syncResult = NullifierSyncResult(ffi: ffiResult) - - // Free the FFI result (this also frees the found/absent arrays). - dash_sdk_nullifier_sync_result_free( - UnsafeMutablePointer(mutating: ffiResultPtr) - ) - - continuation.resume(returning: syncResult) - } - } - } - - /// Convenience: Synchronize nullifiers using the raw pointer API. - /// Prefer `syncNullifiers(nullifiers:config:lastSyncHeight:lastSyncTimestamp:)` instead, - /// which uses the DashSDKResult-based variant for better error handling. - /// - /// - Parameters: - /// - nullifiers: Array of 32-byte nullifier hashes. - /// - config: Sync configuration. Pass nil to use defaults. - /// - lastSyncHeight: Height from previous sync (0 for full scan). - /// - lastSyncTimestamp: Timestamp from previous sync (0 for full scan). - /// - Returns: `NullifierSyncResult` or nil if the sync failed. - public func syncNullifiersRaw( - nullifiers: [Data], - config: NullifierSyncConfig? = nil, - lastSyncHeight: UInt64 = 0, - lastSyncTimestamp: UInt64 = 0 - ) async throws -> NullifierSyncResult { - guard let sdkHandle = handle else { - throw SDKError.invalidState("SDK not initialized") - } - - guard !nullifiers.isEmpty else { - throw SDKError.invalidParameter("Nullifiers array must not be empty") - } - - for (i, nf) in nullifiers.enumerated() { - guard nf.count == 32 else { - throw SDKError.invalidParameter( - "Nullifier at index \(i) must be 32 bytes, got \(nf.count)" - ) - } - } - - var concatenated = Data(capacity: nullifiers.count * 32) - for nf in nullifiers { - concatenated.append(nf) - } - - let sdkPtr = NullifierSendableSdkPtr(sdkHandle) - let count = UInt32(nullifiers.count) - - let ffiConfig: FFINullifierSyncConfig? = if let cfg = config { - try cfg.toFFI() - } else { - nil - } - let nullifierData = concatenated - - return try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global().async { - let resultPtr: UnsafeMutablePointer? - - if var cfg = ffiConfig { - resultPtr = nullifierData.withUnsafeBytes { bytesPtr -> UnsafeMutablePointer? in - guard let base = bytesPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return nil - } - return withUnsafePointer(to: &cfg) { cfgPtr in - dash_sdk_sync_nullifiers( - UnsafePointer(sdkPtr.ptr), - base, - count, - cfgPtr, - lastSyncHeight, - lastSyncTimestamp - ) - } - } - } else { - resultPtr = nullifierData.withUnsafeBytes { bytesPtr -> UnsafeMutablePointer? in - guard let base = bytesPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return nil - } - return dash_sdk_sync_nullifiers( - UnsafePointer(sdkPtr.ptr), - base, - count, - nil, - lastSyncHeight, - lastSyncTimestamp - ) - } - } - - guard let ffiResultPtr = resultPtr else { - continuation.resume( - throwing: SDKError.internalError("Nullifier sync returned null (check SDK logs for details)") - ) - return - } - - let ffiResult = ffiResultPtr.pointee - let syncResult = NullifierSyncResult(ffi: ffiResult) - dash_sdk_nullifier_sync_result_free(ffiResultPtr) - - continuation.resume(returning: syncResult) - } - } - } -} - -// MARK: - Private Sendable Wrapper - -/// Sendable wrapper for the SDK handle pointer, for use in nullifier sync closures. -private final class NullifierSendableSdkPtr: @unchecked Sendable { - let ptr: UnsafeMutablePointer - init(_ p: UnsafeMutablePointer) { self.ptr = p } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Shielded/NullifierSyncTypes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Shielded/NullifierSyncTypes.swift deleted file mode 100644 index 6e1d5e0e410..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Shielded/NullifierSyncTypes.swift +++ /dev/null @@ -1,154 +0,0 @@ -// NullifierSyncTypes.swift -// SwiftDashSDK -// -// Swift types for nullifier BLAST sync operations. -// These match the Rust #[repr(C)] structs in rs-sdk-ffi/src/nullifier_sync/types.rs. - -import Foundation - -// MARK: - FFI Type Aliases -// These types are imported from the DashSDKFFI C header. - -typealias FFINullifierSyncConfig = DashSDKNullifierSyncConfig -typealias FFINullifierSyncMetrics = DashSDKNullifierSyncMetrics -typealias FFINullifierSyncResult = DashSDKNullifierSyncResult - -// MARK: - High-Level Swift Models - -/// Configuration for nullifier BLAST sync. -public struct NullifierSyncConfig: Sendable { - /// Minimum privacy count -- subtrees smaller than this are expanded. - public var minPrivacyCount: UInt64 - /// Maximum concurrent branch queries. - public var maxConcurrentRequests: UInt32 - /// Maximum number of iterations (safety limit). - public var maxIterations: UInt32 - /// Shielded pool type (0 = credit, 1 = main token, 2 = individual token). - public var poolType: UInt32 - /// Optional 32-byte pool identifier for individual token pools. - public var poolIdentifier: Data? - /// Maximum age in seconds before a full tree rescan is forced. - public var fullRescanAfterTimeSeconds: UInt64 - - /// Create a config with default values. - public init( - minPrivacyCount: UInt64 = 32, - maxConcurrentRequests: UInt32 = 10, - maxIterations: UInt32 = 50, - poolType: UInt32 = 0, - poolIdentifier: Data? = nil, - fullRescanAfterTimeSeconds: UInt64 = 7 * 24 * 60 * 60 - ) { - self.minPrivacyCount = minPrivacyCount - self.maxConcurrentRequests = maxConcurrentRequests - self.maxIterations = maxIterations - self.poolType = poolType - self.poolIdentifier = poolIdentifier - self.fullRescanAfterTimeSeconds = fullRescanAfterTimeSeconds - } - - /// Convert to FFI struct. - /// - Throws: If `poolType` is invalid, or if `poolType == 2` without a valid `poolIdentifier`. - func toFFI() throws -> FFINullifierSyncConfig { - // Validate poolType: 0=credit, 1=main token, 2=individual token - guard poolType <= 2 else { - throw SDKError.invalidParameter("poolType must be 0, 1, or 2, got \(poolType)") - } - - let hasPoolId: Bool - let poolIdTuple: Bytes32Tuple - if let pid = poolIdentifier { - guard pid.count == 32 else { - throw SDKError.invalidParameter( - "poolIdentifier must be exactly 32 bytes, got \(pid.count)" - ) - } - hasPoolId = true - poolIdTuple = dataToBytes32(pid) - } else { - // poolType 2 (individual token) requires a poolIdentifier - if poolType == 2 { - throw SDKError.invalidParameter( - "poolType 2 (individual token) requires a poolIdentifier" - ) - } - hasPoolId = false - poolIdTuple = dataToBytes32(Data(count: 32)) - } - - return FFINullifierSyncConfig( - min_privacy_count: minPrivacyCount, - max_concurrent_requests: maxConcurrentRequests, - max_iterations: maxIterations, - pool_type: poolType, - pool_identifier: poolIdTuple, - has_pool_identifier: hasPoolId, - full_rescan_after_time_s: fullRescanAfterTimeSeconds - ) - } -} - -/// Metrics about a nullifier sync operation. -public struct NullifierSyncMetrics: Sendable { - public let trunkQueries: UInt32 - public let branchQueries: UInt32 - public let totalElementsSeen: UInt32 - public let totalProofBytes: UInt32 - public let branchQueryFailures: UInt32 - public let iterations: UInt32 - public let compactedQueries: UInt32 - public let recentQueries: UInt32 - - init(ffi: FFINullifierSyncMetrics) { - self.trunkQueries = ffi.trunk_queries - self.branchQueries = ffi.branch_queries - self.totalElementsSeen = ffi.total_elements_seen - self.totalProofBytes = ffi.total_proof_bytes - self.branchQueryFailures = ffi.branch_query_failures - self.iterations = ffi.iterations - self.compactedQueries = ffi.compacted_queries - self.recentQueries = ffi.recent_queries - } -} - -/// Result of a nullifier BLAST sync operation. -public struct NullifierSyncResult: Sendable { - /// Nullifiers that were found (spent) in the shielded pool. - public let found: [Data] - /// Nullifiers that were absent (unspent) in the shielded pool. - public let absent: [Data] - /// Block height of the tree snapshot. - public let checkpointHeight: UInt64 - /// Highest block height seen -- persist for next sync call. - public let newSyncHeight: UInt64 - /// Block time at the latest response -- persist for next sync call. - public let newSyncTimestamp: UInt64 - /// Sync metrics. - public let metrics: NullifierSyncMetrics - - /// Initialize from FFI result. Copies data out before the FFI pointer is freed. - init(ffi: FFINullifierSyncResult) { - var foundArr: [Data] = [] - if let ptr = ffi.found, ffi.found_count > 0 { - for i in 0.. 0 { - for i in 0..) in - DispatchQueue.global().async { - let result = dash_sdk_shielded_warmup_proving_key() - - do { - try self.cryptoExtractVoid(result) - continuation.resume() - } catch { - continuation.resume(throwing: error) - } - } - } - } - - // MARK: - Address Derivation - - /// Derive an Orchard payment address from a spending key. - /// - /// - Parameters: - /// - spendingKey: 32-byte spending key. - /// - diversifierIndex: Address diversifier index (default 0). - /// - Returns: 43-byte raw Orchard payment address. - /// - Throws: `SDKError` on failure. - public func deriveShieldedAddress( - spendingKey: Data, - diversifierIndex: UInt32 = 0 - ) async throws -> Data { - guard spendingKey.count == 32 else { - throw SDKError.invalidParameter("Spending key must be exactly 32 bytes, got \(spendingKey.count)") - } - - let skCopy = spendingKey - return try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global().async { - var skTuple = dataToBytes32(skCopy) - defer { _ = withUnsafeMutableBytes(of: &skTuple) { $0.baseAddress?.initializeMemory(as: UInt8.self, repeating: 0, count: 32) } } - let result = withUnsafePointer(to: &skTuple) { skPtr in - dash_sdk_shielded_derive_address(skPtr, diversifierIndex) - } - - do { - let hexString = try self.cryptoExtractString(result) - guard let addressData = hexToData(hexString) else { - continuation.resume(throwing: SDKError.serializationError("Invalid hex address returned")) - return - } - guard addressData.count == 43 else { - continuation.resume(throwing: SDKError.serializationError( - "Derived address must be 43 bytes, got \(addressData.count)")) - return - } - continuation.resume(returning: addressData) - } catch { - continuation.resume(throwing: error) - } - } - } - } - - // MARK: - Bundle Building - - /// Build an output-only Orchard bundle for shielding funds. - /// - /// Creates a bundle with no spends -- only a single output note for the recipient - /// (derived from the spending key at diversifier index 0). - /// - /// - Parameters: - /// - spendingKey: 32-byte spending key. - /// - amount: Amount in credits to shield. - /// - memo: Optional 36-byte memo. If nil, uses zero bytes. - /// - Returns: `ShieldedBundleHandle` to pass to `shieldFunds(bundle:)`. - /// - Throws: `SDKError` on failure. - public func buildShieldBundle( - spendingKey: Data, - amount: UInt64, - memo: Data? = nil - ) async throws -> ShieldedBundleHandle { - guard spendingKey.count == 32 else { - throw SDKError.invalidParameter("Spending key must be exactly 32 bytes, got \(spendingKey.count)") - } - - if let memo = memo, memo.count != 36 { - throw SDKError.invalidParameter("Memo must be exactly 36 bytes, got \(memo.count)") - } - - let skCopy = spendingKey - let memoCopy = memo - return try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global().async { - var skTuple = dataToBytes32(skCopy) - defer { _ = withUnsafeMutableBytes(of: &skTuple) { $0.baseAddress?.initializeMemory(as: UInt8.self, repeating: 0, count: 32) } } - var memoTuple = dataToBytes36(memoCopy ?? Data(count: 36)) - - let result = withUnsafePointer(to: &skTuple) { skPtr in - withUnsafePointer(to: &memoTuple) { memoPtr in - dash_sdk_shielded_build_shield_bundle( - skPtr, - amount, - memoPtr - ) - } - } - - do { - let handle = try self.cryptoExtractBundleHandle(result) - continuation.resume(returning: handle) - } catch { - continuation.resume(throwing: error) - } - } - } - } - - /// Build a spend+output bundle for shielded transfer (shielded-to-shielded). - /// - /// Spends existing notes and creates a new note for the recipient. Change - /// is returned to the sender's own address (derived at index 0). - /// Fee is computed automatically. - /// - /// - Parameters: - /// - spendingKey: 32-byte spending key of the sender. - /// - anchor: 32-byte Sinsemilla anchor of the commitment tree. - /// - notes: Spendable notes with Merkle authentication paths. - /// - recipientAddress: 43-byte raw Orchard address of the recipient. - /// - amount: Amount in credits to transfer. - /// - memo: Optional 36-byte memo. If nil, uses zero bytes. - /// - Returns: `ShieldedBundleHandle` to pass to `shieldedTransfer(bundle:)`. - /// - Throws: `SDKError` on failure. - public func buildTransferBundle( - spendingKey: Data, - anchor: Data, - notes: [SpendableNoteInfo], - recipientAddress: Data, - amount: UInt64, - memo: Data? = nil - ) async throws -> ShieldedBundleHandle { - guard spendingKey.count == 32 else { - throw SDKError.invalidParameter("Spending key must be exactly 32 bytes, got \(spendingKey.count)") - } - guard anchor.count == 32 else { - throw SDKError.invalidParameter("Anchor must be exactly 32 bytes, got \(anchor.count)") - } - guard recipientAddress.count == 43 else { - throw SDKError.invalidParameter("Recipient address must be exactly 43 bytes, got \(recipientAddress.count)") - } - guard !notes.isEmpty else { - throw SDKError.invalidParameter("Notes array must not be empty") - } - if let memo = memo, memo.count != 36 { - throw SDKError.invalidParameter("Memo must be exactly 36 bytes, got \(memo.count)") - } - - let skCopy = spendingKey - let anchorCopy = anchor - let addrCopy = recipientAddress - let memoCopy = memo - let notesJSON = try spendableNotesToJSON(notes) - - return try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global().async { - var skTuple = dataToBytes32(skCopy) - defer { _ = withUnsafeMutableBytes(of: &skTuple) { $0.baseAddress?.initializeMemory(as: UInt8.self, repeating: 0, count: 32) } } - var anchorTuple = dataToBytes32(anchorCopy) - var memoTuple = dataToBytes36(memoCopy ?? Data(count: 36)) - - let result = notesJSON.withCString { notesCStr in - addrCopy.withUnsafeBytes { addrPtr -> DashSDKResult in - guard let addrBase = addrPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return DashSDKResult() - } - return withUnsafePointer(to: &skTuple) { skPtr in - withUnsafePointer(to: &anchorTuple) { anchorPtr in - withUnsafePointer(to: &memoTuple) { memoPtr in - dash_sdk_shielded_build_transfer_bundle( - skPtr, - anchorPtr, - notesCStr, - addrBase, - UInt(addrCopy.count), - amount, - memoPtr - ) - } - } - } - } - } - - do { - let handle = try self.cryptoExtractBundleHandle(result) - continuation.resume(returning: handle) - } catch { - continuation.resume(throwing: error) - } - } - } - } - - /// Build a spend bundle for unshielding to a platform address. - /// - /// Spends existing notes and creates a change output back to the sender. - /// The platform address receives funds via the state transition's transparent field. - /// Fee is computed automatically. - /// - /// - Parameters: - /// - spendingKey: 32-byte spending key. - /// - anchor: 32-byte Sinsemilla anchor of the commitment tree. - /// - notes: Spendable notes with Merkle authentication paths. - /// - outputAddress: Platform address bytes for the unshield recipient. - /// - amount: Amount in credits to unshield. - /// - memo: Optional 36-byte memo. If nil, uses zero bytes. - /// - Returns: `ShieldedBundleHandle` to pass to `unshieldFunds(bundle:)`. - /// - Throws: `SDKError` on failure. - public func buildUnshieldBundle( - spendingKey: Data, - anchor: Data, - notes: [SpendableNoteInfo], - outputAddress: Data, - amount: UInt64, - memo: Data? = nil - ) async throws -> ShieldedBundleHandle { - guard spendingKey.count == 32 else { - throw SDKError.invalidParameter("Spending key must be exactly 32 bytes, got \(spendingKey.count)") - } - guard anchor.count == 32 else { - throw SDKError.invalidParameter("Anchor must be exactly 32 bytes, got \(anchor.count)") - } - guard !outputAddress.isEmpty else { - throw SDKError.invalidParameter("Output address must not be empty") - } - guard !notes.isEmpty else { - throw SDKError.invalidParameter("Notes array must not be empty") - } - if let memo = memo, memo.count != 36 { - throw SDKError.invalidParameter("Memo must be exactly 36 bytes, got \(memo.count)") - } - - let skCopy = spendingKey - let anchorCopy = anchor - let addrCopy = outputAddress - let memoCopy = memo - let notesJSON = try spendableNotesToJSON(notes) - - return try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global().async { - var skTuple = dataToBytes32(skCopy) - defer { _ = withUnsafeMutableBytes(of: &skTuple) { $0.baseAddress?.initializeMemory(as: UInt8.self, repeating: 0, count: 32) } } - var anchorTuple = dataToBytes32(anchorCopy) - var memoTuple = dataToBytes36(memoCopy ?? Data(count: 36)) - - let result = notesJSON.withCString { notesCStr in - addrCopy.withUnsafeBytes { addrPtr -> DashSDKResult in - guard let addrBase = addrPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return DashSDKResult() - } - return withUnsafePointer(to: &skTuple) { skPtr in - withUnsafePointer(to: &anchorTuple) { anchorPtr in - withUnsafePointer(to: &memoTuple) { memoPtr in - dash_sdk_shielded_build_unshield_bundle( - skPtr, - anchorPtr, - notesCStr, - addrBase, - UInt(addrCopy.count), - amount, - memoPtr - ) - } - } - } - } - } - - do { - let handle = try self.cryptoExtractBundleHandle(result) - continuation.resume(returning: handle) - } catch { - continuation.resume(throwing: error) - } - } - } - } - - /// Build a spend bundle for withdrawing to a Core (L1) address. - /// - /// Spends existing notes and creates a change output back to the sender. - /// The output script receives funds via the state transition's withdrawal mechanism. - /// Fee is computed automatically. - /// - /// - Parameters: - /// - spendingKey: 32-byte spending key. - /// - anchor: 32-byte Sinsemilla anchor of the commitment tree. - /// - notes: Spendable notes with Merkle authentication paths. - /// - outputScript: Core chain output script bytes. - /// - amount: Amount in credits to withdraw. - /// - memo: Optional 36-byte memo. If nil, uses zero bytes. - /// - coreFeePerByte: Core chain fee rate. - /// - pooling: Withdrawal pooling strategy. - /// - Returns: `ShieldedBundleHandle` to pass to `shieldedWithdraw(bundle:)`. - /// - Throws: `SDKError` on failure. - public func buildWithdrawalBundle( - spendingKey: Data, - anchor: Data, - notes: [SpendableNoteInfo], - outputScript: Data, - amount: UInt64, - memo: Data? = nil, - coreFeePerByte: UInt32, - pooling: WithdrawalPooling - ) async throws -> ShieldedBundleHandle { - guard spendingKey.count == 32 else { - throw SDKError.invalidParameter("Spending key must be exactly 32 bytes, got \(spendingKey.count)") - } - guard anchor.count == 32 else { - throw SDKError.invalidParameter("Anchor must be exactly 32 bytes, got \(anchor.count)") - } - guard !outputScript.isEmpty else { - throw SDKError.invalidParameter("Output script must not be empty") - } - guard !notes.isEmpty else { - throw SDKError.invalidParameter("Notes array must not be empty") - } - if let memo = memo, memo.count != 36 { - throw SDKError.invalidParameter("Memo must be exactly 36 bytes, got \(memo.count)") - } - - let skCopy = spendingKey - let anchorCopy = anchor - let scriptCopy = outputScript - let memoCopy = memo - let notesJSON = try spendableNotesToJSON(notes) - - return try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global().async { - var skTuple = dataToBytes32(skCopy) - defer { _ = withUnsafeMutableBytes(of: &skTuple) { $0.baseAddress?.initializeMemory(as: UInt8.self, repeating: 0, count: 32) } } - var anchorTuple = dataToBytes32(anchorCopy) - var memoTuple = dataToBytes36(memoCopy ?? Data(count: 36)) - - let result = notesJSON.withCString { notesCStr in - scriptCopy.withUnsafeBytes { scriptPtr -> DashSDKResult in - guard let scriptBase = scriptPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return DashSDKResult() - } - return withUnsafePointer(to: &skTuple) { skPtr in - withUnsafePointer(to: &anchorTuple) { anchorPtr in - withUnsafePointer(to: &memoTuple) { memoPtr in - dash_sdk_shielded_build_withdrawal_bundle( - skPtr, - anchorPtr, - notesCStr, - scriptBase, - UInt(scriptCopy.count), - amount, - memoPtr, - coreFeePerByte, - pooling.rawValue - ) - } - } - } - } - } - - do { - let handle = try self.cryptoExtractBundleHandle(result) - continuation.resume(returning: handle) - } catch { - continuation.resume(throwing: error) - } - } - } - } - - // MARK: - Note Decryption - - /// Decrypt encrypted notes using the spending key's incoming viewing key. - /// - /// Performs trial decryption on each note. Only notes that successfully decrypt - /// (i.e., belong to this spending key) are included in the result. - /// - /// - Parameters: - /// - spendingKey: 32-byte spending key. - /// - encryptedNotes: Array of encrypted notes from the shielded pool. - /// - Returns: Array of `DecryptedNote` for notes belonging to this key. - /// - Throws: `SDKError` on failure. - public func decryptNotes( - spendingKey: Data, - encryptedNotes: [EncryptedNote] - ) async throws -> [DecryptedNote] { - guard spendingKey.count == 32 else { - throw SDKError.invalidParameter("Spending key must be exactly 32 bytes, got \(spendingKey.count)") - } - - guard !encryptedNotes.isEmpty else { - return [] - } - - let skCopy = spendingKey - let notesJSON = encryptedNotesToJSON(encryptedNotes) - - return try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global().async { - var skTuple = dataToBytes32(skCopy) - defer { _ = withUnsafeMutableBytes(of: &skTuple) { $0.baseAddress?.initializeMemory(as: UInt8.self, repeating: 0, count: 32) } } - - let result = notesJSON.withCString { notesCStr in - withUnsafePointer(to: &skTuple) { skPtr in - dash_sdk_shielded_decrypt_notes(skPtr, notesCStr) - } - } - - do { - let jsonString = try self.cryptoExtractString(result) - let notes = try parseDecryptedNotesJSON(jsonString) - continuation.resume(returning: notes) - } catch { - continuation.resume(throwing: error) - } - } - } - } -} - -// MARK: - Transition Overloads for ShieldedBundleHandle - -@MainActor -extension SDK { - - /// Shield funds using a pre-built bundle handle. - /// - /// - Parameters: - /// - inputs: Platform address inputs with amounts and private keys. - /// - bundle: Bundle handle from `buildShieldBundle`. - /// - amount: Total amount being shielded. - /// - feeFromInputIndex: Which input to deduct fees from. - /// - Throws: `SDKError` on failure. - public func shieldFunds( - inputs: [ShieldFundsInput], - bundle: ShieldedBundleHandle, - amount: UInt64, - feeFromInputIndex: UInt16 - ) async throws { - guard let sdkHandle = handle else { - throw SDKError.invalidState("SDK not initialized") - } - guard !inputs.isEmpty else { - throw SDKError.invalidParameter("Inputs array must not be empty") - } - - let sdkPtr = SendableSdkPtr(sdkHandle) - let inputCount = UInt32(inputs.count) - let retainedBundle = bundle // retain handle so deinit doesn't free ptr during FFI call - - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - DispatchQueue.global().async { [self, retainedBundle] in - let result = self.withFFIShieldInputs(inputs) { inputsPtr in - dash_sdk_shielded_shield_funds( - UnsafePointer(sdkPtr.ptr), - inputsPtr, - inputCount, - UnsafePointer(retainedBundle.ptr), - amount, - feeFromInputIndex - ) - } - - do { - try self.shieldedExtractVoid(result) - continuation.resume() - } catch { - continuation.resume(throwing: error) - } - } - } - } - - /// Shielded transfer using a pre-built bundle handle. - /// - /// - Parameters: - /// - bundle: Bundle handle from `buildTransferBundle`. - /// - valueBalance: Net value flowing out of the shielded pool (fee amount). - /// - Throws: `SDKError` on failure. - public func shieldedTransfer( - bundle: ShieldedBundleHandle, - valueBalance: UInt64 - ) async throws { - guard let sdkHandle = handle else { - throw SDKError.invalidState("SDK not initialized") - } - - let sdkPtr = SendableSdkPtr(sdkHandle) - let retainedBundle = bundle - - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - DispatchQueue.global().async { [self, retainedBundle] in - let result = dash_sdk_shielded_transfer( - UnsafePointer(sdkPtr.ptr), - UnsafePointer(retainedBundle.ptr), - valueBalance - ) - - do { - try self.shieldedExtractVoid(result) - continuation.resume() - } catch { - continuation.resume(throwing: error) - } - } - } - } - - /// Unshield funds using a pre-built bundle handle. - /// - /// - Parameters: - /// - outputAddress: Platform address to receive unshielded funds. - /// - amount: Amount to unshield. - /// - bundle: Bundle handle from `buildUnshieldBundle`. - /// - Throws: `SDKError` on failure. - public func unshieldFunds( - outputAddress: Data, - amount: UInt64, - bundle: ShieldedBundleHandle - ) async throws { - guard let sdkHandle = handle else { - throw SDKError.invalidState("SDK not initialized") - } - - let sdkPtr = SendableSdkPtr(sdkHandle) - let retainedBundle = bundle - - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - DispatchQueue.global().async { [self, retainedBundle] in - let result = outputAddress.withUnsafeBytes { addrPtr -> DashSDKResult in - guard let addrBase = addrPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return DashSDKResult() - } - return dash_sdk_shielded_unshield_funds( - UnsafePointer(sdkPtr.ptr), - addrBase, - UInt(outputAddress.count), - amount, - UnsafePointer(retainedBundle.ptr) - ) - } - - do { - try self.shieldedExtractVoid(result) - continuation.resume() - } catch { - continuation.resume(throwing: error) - } - } - } - } - - /// Withdraw from shielded pool to Core (L1) using a pre-built bundle handle. - /// - /// - Parameters: - /// - amount: Amount to withdraw (in credits). - /// - bundle: Bundle handle from `buildWithdrawalBundle`. - /// - coreFeePerByte: Core fee per byte for the withdrawal transaction. - /// - pooling: Withdrawal pooling mode. - /// - outputScript: Raw Core output script bytes. - /// - Throws: `SDKError` on failure. - public func shieldedWithdraw( - amount: UInt64, - bundle: ShieldedBundleHandle, - coreFeePerByte: UInt32, - pooling: WithdrawalPooling, - outputScript: Data - ) async throws { - guard let sdkHandle = handle else { - throw SDKError.invalidState("SDK not initialized") - } - guard !outputScript.isEmpty else { - throw SDKError.invalidParameter("Output script must not be empty") - } - - let sdkPtr = SendableSdkPtr(sdkHandle) - let retainedBundle = bundle - - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - DispatchQueue.global().async { [self, retainedBundle] in - let result = outputScript.withUnsafeBytes { scriptPtr -> DashSDKResult in - guard let scriptBase = scriptPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return DashSDKResult() - } - return dash_sdk_shielded_withdraw( - UnsafePointer(sdkPtr.ptr), - amount, - UnsafePointer(retainedBundle.ptr), - coreFeePerByte, - pooling.rawValue, - scriptBase, - UInt(outputScript.count) - ) - } - - do { - try self.shieldedExtractVoid(result) - continuation.resume() - } catch { - continuation.resume(throwing: error) - } - } - } - } -} - -// MARK: - Private Helpers - -extension SDK { - - /// Extract a string from a DashSDKResult. Thread-safe (no @MainActor). - nonisolated func cryptoExtractString(_ result: DashSDKResult) throws -> String { - if let error = result.error { - let errorMessage = error.pointee.message != nil - ? String(cString: error.pointee.message!) - : "Unknown error" - dash_sdk_error_free(error) - throw SDKError.internalError(errorMessage) - } - - guard let dataPtr = result.data else { - throw SDKError.notFound("No data returned") - } - - let string = String(cString: dataPtr.assumingMemoryBound(to: CChar.self)) - dash_sdk_string_free(dataPtr) - return string - } - - /// Extract void (success/error) from a DashSDKResult. Thread-safe. - nonisolated func cryptoExtractVoid(_ result: DashSDKResult) throws { - if let error = result.error { - let errorMessage = error.pointee.message != nil - ? String(cString: error.pointee.message!) - : "Unknown error" - dash_sdk_error_free(error) - throw SDKError.internalError(errorMessage) - } - } - - /// Extract a ShieldedBundleHandle from a DashSDKResult. Thread-safe. - /// The result.data must be a *mut DashSDKOrchardBundleParams allocated by Rust. - nonisolated func cryptoExtractBundleHandle(_ result: DashSDKResult) throws -> ShieldedBundleHandle { - if let error = result.error { - let errorMessage = error.pointee.message != nil - ? String(cString: error.pointee.message!) - : "Unknown error" - dash_sdk_error_free(error) - throw SDKError.internalError(errorMessage) - } - - guard let dataPtr = result.data else { - throw SDKError.internalError("No bundle data returned") - } - - let typedPtr = dataPtr.assumingMemoryBound(to: FFIOrchardBundleParams.self) - return ShieldedBundleHandle(typedPtr) - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Shielded/ShieldedCryptoTypes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Shielded/ShieldedCryptoTypes.swift deleted file mode 100644 index 8522a495753..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Shielded/ShieldedCryptoTypes.swift +++ /dev/null @@ -1,272 +0,0 @@ -// ShieldedCryptoTypes.swift -// SwiftDashSDK -// -// Swift types for shielded crypto operations (decrypted notes, spendable notes). -// These complement the types in ShieldedTypes.swift with models specific to -// the client-side crypto FFI functions (decrypt_notes, build_*_bundle). - -import Foundation - -// MARK: - Decrypted Note - -/// A successfully decrypted Orchard note returned by `decryptNotes`. -public struct DecryptedNote: Sendable { - /// Position index of this note in the encrypted notes array that was passed to decryption. - public let position: Int - /// Note value in credits. - public let value: UInt64 - /// 32-byte nullifier hash. - public let nullifier: Data - /// 32-byte note commitment (cmx). - public let cmx: Data - /// 43-byte Orchard payment address this note is sent to. - public let address: Data - /// 32-byte Rho (nullifier randomness domain separator). - public let rho: Data - /// 32-byte random seed used to derive note encryption key. - public let rseed: Data - - public init( - position: Int, - value: UInt64, - nullifier: Data, - cmx: Data, - address: Data, - rho: Data, - rseed: Data - ) { - self.position = position - self.value = value - self.nullifier = nullifier - self.cmx = cmx - self.address = address - self.rho = rho - self.rseed = rseed - } -} - -// MARK: - Spendable Note Info - -/// Information about a spendable note, used as input to bundle building functions. -/// -/// This contains the full note data plus the Merkle authentication path needed -/// to prove the note exists in the commitment tree. -public struct SpendableNoteInfo: Sendable { - /// 43-byte Orchard payment address. - public let address: Data - /// Note value in credits. - public let value: UInt64 - /// 32-byte Rho. - public let rho: Data - /// 32-byte random seed. - public let rseed: Data - /// Position of the note in the commitment tree. - public let position: UInt32 - /// Merkle authentication path: array of 32 entries, each a 32-byte hash. - public let merklePath: [Data] - - public init( - address: Data, - value: UInt64, - rho: Data, - rseed: Data, - position: UInt32, - merklePath: [Data] - ) { - self.address = address - self.value = value - self.rho = rho - self.rseed = rseed - self.position = position - self.merklePath = merklePath - } - - /// Convert this spendable note to a dictionary matching the JSON format expected by Rust. - /// - /// Format: - /// ```json - /// { - /// "address": "hex43bytes", - /// "value": 100000, - /// "rho": "hex32bytes", - /// "rseed": "hex32bytes", - /// "position": 42, - /// "merklePath": ["hex32bytes", ...] - /// } - /// ``` - /// Validate field sizes before serialization. - public func validate() throws { - guard address.count == 43 else { - throw SDKError.invalidParameter("SpendableNoteInfo address must be 43 bytes, got \(address.count)") - } - guard rho.count == 32 else { - throw SDKError.invalidParameter("SpendableNoteInfo rho must be 32 bytes, got \(rho.count)") - } - guard rseed.count == 32 else { - throw SDKError.invalidParameter("SpendableNoteInfo rseed must be 32 bytes, got \(rseed.count)") - } - guard merklePath.count == 32 else { - throw SDKError.invalidParameter("SpendableNoteInfo merklePath must have 32 entries, got \(merklePath.count)") - } - for (i, node) in merklePath.enumerated() { - guard node.count == 32 else { - throw SDKError.invalidParameter("SpendableNoteInfo merklePath[\(i)] must be 32 bytes, got \(node.count)") - } - } - } - - public func toJSON() -> [String: Any] { - return [ - "address": address.toHexString(), - "value": value, - "rho": rho.toHexString(), - "rseed": rseed.toHexString(), - "position": position, - "merklePath": merklePath.map { $0.toHexString() } - ] - } -} - -// MARK: - JSON Serialization Helpers - -/// Convert an array of EncryptedNote models to a JSON string for the decrypt_notes FFI function. -/// -/// The Rust FFI expects a JSON array of objects: -/// ```json -/// [{ "cmx": "hex32", "nullifier": "hex32", "encryptedNote": "hex216" }] -/// ``` -func encryptedNotesToJSON(_ notes: [EncryptedNote]) -> String { - let jsonArray: [[String: Any]] = notes.map { note in - [ - "cmx": note.cmx.toHexString(), - "nullifier": note.nullifier.toHexString(), - "encryptedNote": note.encryptedNote.toHexString() - ] - } - - guard let data = try? JSONSerialization.data(withJSONObject: jsonArray, options: []), - let string = String(data: data, encoding: .utf8) - else { - return "[]" - } - return string -} - -/// Convert an array of SpendableNoteInfo models to a JSON string for bundle building FFI functions. -/// -/// The Rust FFI expects a JSON array of objects with camelCase keys: -/// ```json -/// [{ "address": "hex43", "value": u64, "rho": "hex32", "rseed": "hex32", -/// "position": u32, "merklePath": ["hex32", ...32 entries] }] -/// ``` -func spendableNotesToJSON(_ notes: [SpendableNoteInfo]) throws -> String { - for (i, note) in notes.enumerated() { - do { - try note.validate() - } catch { - throw SDKError.invalidParameter("SpendableNoteInfo[\(i)]: \(error.localizedDescription)") - } - } - let jsonArray: [[String: Any]] = notes.map { $0.toJSON() } - - guard let data = try? JSONSerialization.data(withJSONObject: jsonArray, options: []), - let string = String(data: data, encoding: .utf8) - else { - return "[]" - } - return string -} - -// MARK: - Shielded Bundle Handle - -/// Opaque handle to a heap-allocated Orchard bundle (DashSDKOrchardBundleParams). -/// -/// Returned by the `buildShieldBundle`, `buildTransferBundle`, etc. functions. -/// Pass directly to transition functions (`shieldFunds`, `shieldedTransfer`, etc.) -/// via the overloads that accept `ShieldedBundleHandle`. -/// -/// Automatically freed when deallocated — do NOT call `dash_sdk_shielded_bundle_params_free` -/// manually on a handle that is managed by this class. -public final class ShieldedBundleHandle: @unchecked Sendable { - let ptr: UnsafeMutablePointer - - init(_ ptr: UnsafeMutablePointer) { - self.ptr = ptr - } - - deinit { - dash_sdk_shielded_bundle_params_free(ptr) - } -} - -/// Parse a JSON string returned by the decrypt_notes FFI function into DecryptedNote models. -/// -/// The JSON format is: -/// ```json -/// [{ "position": idx, "value": u64, "nullifier": "hex32", "cmx": "hex32", -/// "address": "hex43", "rho": "hex32", "rseed": "hex32" }] -/// ``` -func parseDecryptedNotesJSON(_ jsonString: String) throws -> [DecryptedNote] { - guard let jsonData = jsonString.data(using: .utf8) else { - throw SDKError.serializationError("Decrypted notes JSON is not valid UTF-8") - } - - guard let jsonArray = try JSONSerialization.jsonObject(with: jsonData) as? [[String: Any]] else { - throw SDKError.serializationError("Decrypted notes JSON root is not an array") - } - - var notes: [DecryptedNote] = [] - for (i, obj) in jsonArray.enumerated() { - guard let positionNum = obj["position"] as? NSNumber, - let valueNum = obj["value"] as? NSNumber, - let nullifierHex = obj["nullifier"] as? String, - let cmxHex = obj["cmx"] as? String, - let addressHex = obj["address"] as? String, - let rhoHex = obj["rho"] as? String, - let rseedHex = obj["rseed"] as? String - else { - throw SDKError.serializationError("DecryptedNote[\(i)] is missing required fields") - } - - let position = positionNum.intValue - let value = valueNum.uint64Value - - guard let nullifier = hexToData(nullifierHex), - let cmx = hexToData(cmxHex), - let address = hexToData(addressHex), - let rho = hexToData(rhoHex), - let rseed = hexToData(rseedHex) - else { - throw SDKError.serializationError("DecryptedNote[\(i)] contains invalid hex") - } - - // Validate field sizes - guard nullifier.count == 32 else { - throw SDKError.serializationError("DecryptedNote[\(i)] nullifier must be 32 bytes, got \(nullifier.count)") - } - guard cmx.count == 32 else { - throw SDKError.serializationError("DecryptedNote[\(i)] cmx must be 32 bytes, got \(cmx.count)") - } - guard address.count == 43 else { - throw SDKError.serializationError("DecryptedNote[\(i)] address must be 43 bytes, got \(address.count)") - } - guard rho.count == 32 else { - throw SDKError.serializationError("DecryptedNote[\(i)] rho must be 32 bytes, got \(rho.count)") - } - guard rseed.count == 32 else { - throw SDKError.serializationError("DecryptedNote[\(i)] rseed must be 32 bytes, got \(rseed.count)") - } - - notes.append(DecryptedNote( - position: position, - value: value, - nullifier: nullifier, - cmx: cmx, - address: address, - rho: rho, - rseed: rseed - )) - } - - return notes -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Shielded/ShieldedPoolClient.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Shielded/ShieldedPoolClient.swift deleted file mode 100644 index 1feb1324e6d..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Shielded/ShieldedPoolClient.swift +++ /dev/null @@ -1,516 +0,0 @@ -// ShieldedPoolClient.swift -// SwiftDashSDK -// -// High-level Swift wrapper for the Rust ShieldedPoolClient. -// Manages a local commitment tree (SQLite-backed), note tracking, -// and Orchard bundle construction. All cryptographic operations -// stay in Rust across the FFI boundary. -// -// Usage: -// let client = try ShieldedPoolClient(dbPath: path, spendingKey: key) -// let (newNotes, bal) = try await client.syncNotes(sdk: sdk) -// let balance = try client.balance -// let bundle = try await client.buildTransferBundle(recipientAddress: addr, amount: 1000) -// try await sdk.shieldedTransfer(bundle: bundle, valueBalance: ...) - -import Foundation -import DashSDKFFI - -// MARK: - ShieldedPoolClient - -/// Client for the Dash Platform shielded pool. -/// -/// Manages the local commitment tree (SQLite-backed), note tracking, -/// and Orchard bundle construction. All crypto stays in Rust. -/// -/// The underlying Rust handle is thread-safe. This class is marked -/// `@unchecked Sendable` so it can be passed across actor boundaries. -/// The handle is freed automatically on `deinit`. -/// Sendable wrapper for the pool client handle pointer. -private final class SendablePoolHandle: @unchecked Sendable { - let ptr: UnsafeMutablePointer - init(_ p: UnsafeMutablePointer) { self.ptr = p } -} - -public final class ShieldedPoolClient: @unchecked Sendable { - - /// Pointer to the Rust `ShieldedPoolClient`. - private let handle: UnsafeMutablePointer - - // MARK: - Lifecycle - - /// Create a new shielded pool client. - /// - /// - Parameters: - /// - dbPath: Path to the SQLite database file (created if it does not exist). - /// - spendingKey: 32-byte Orchard spending key. - /// - Throws: `SDKError.invalidParameter` if the spending key is not 32 bytes. - /// `SDKError.internalError` if the Rust constructor fails. - public init(dbPath: String, spendingKey: Data) throws { - guard spendingKey.count == 32 else { - throw SDKError.invalidParameter( - "Spending key must be exactly 32 bytes, got \(spendingKey.count)" - ) - } - - var skTuple = dataToBytes32(spendingKey) - defer { _ = withUnsafeMutableBytes(of: &skTuple) { $0.baseAddress?.initializeMemory(as: UInt8.self, repeating: 0, count: 32) } } - - let result = dbPath.withCString { pathCStr in - withUnsafePointer(to: &skTuple) { skPtr in - dash_sdk_shielded_pool_client_create(pathCStr, skPtr) - } - } - - if let error = result.error { - let errorMessage = error.pointee.message != nil - ? String(cString: error.pointee.message!) - : "Unknown error" - dash_sdk_error_free(error) - throw SDKError.internalError("Failed to create ShieldedPoolClient: \(errorMessage)") - } - - guard let dataPtr = result.data else { - throw SDKError.internalError("No handle returned from ShieldedPoolClient create") - } - - self.handle = dataPtr.assumingMemoryBound(to: DashSDKFFI.ShieldedPoolClient.self) - } - - deinit { - dash_sdk_shielded_pool_client_destroy(handle) - } - - // MARK: - Properties - - /// The default Orchard payment address (raw bytes, typically 43 bytes). - /// - /// - Throws: `SDKError` if the underlying Rust call fails or returns invalid hex. - public var address: Data { - get throws { - let result = dash_sdk_shielded_pool_client_get_address(handle) - let hexString = try Self.extractString(result) - guard let addressData = hexToData(hexString) else { - throw SDKError.serializationError("Invalid hex address returned from pool client") - } - return addressData - } - } - - /// Current shielded balance (sum of unspent note values, in credits). - /// - /// - Throws: `SDKError` if the underlying Rust call fails or the balance - /// string cannot be parsed. - public var balance: UInt64 { - get throws { - let result = dash_sdk_shielded_pool_client_get_balance(handle) - let decimalString = try Self.extractString(result) - guard let value = UInt64(decimalString) else { - throw SDKError.serializationError( - "Invalid balance string returned: \(decimalString)" - ) - } - return value - } - } - - // MARK: - Sync - - /// Sync notes from the platform shielded pool. - /// - /// Fetches encrypted notes from the network, decrypts those belonging to this - /// spending key, and appends them to the local commitment tree. - /// - /// This method blocks on the Rust side (`block_on`), so it is dispatched to a - /// background queue and bridged back via `withCheckedThrowingContinuation`. - /// - /// - Parameter sdk: An initialized `SDK` instance for network access. - /// - Returns: Tuple of (number of new notes found, current balance in credits). - /// - Throws: `SDKError` on network or processing failure. - @MainActor - public func syncNotes(sdk: SDK) async throws -> (newNotes: Int, balance: UInt64) { - guard let sdkHandle = sdk.handle else { - throw SDKError.invalidState("SDK not initialized") - } - - let poolHandle = SendablePoolHandle(self.handle) - let sdkPtr = PoolClientSendableSdkPtr(sdkHandle) - - return try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global().async { - let result = dash_sdk_shielded_pool_client_sync_notes( - poolHandle.ptr, - UnsafePointer(sdkPtr.ptr) - ) - - do { - let jsonString = try Self.extractString(result) - let parsed = try Self.parseSyncNotesJSON(jsonString) - continuation.resume(returning: parsed) - } catch { - continuation.resume(throwing: error) - } - } - } - } - - /// Check which notes have been spent on-chain (nullifier sync). - /// - /// Queries the network for nullifier statuses and marks spent notes in the - /// local database. - /// - /// This method blocks on the Rust side (`block_on`), so it is dispatched to a - /// background queue and bridged back via `withCheckedThrowingContinuation`. - /// - /// - Parameter sdk: An initialized `SDK` instance for network access. - /// - Returns: Tuple of (number of notes found spent, current balance in credits). - /// - Throws: `SDKError` on network or processing failure. - @MainActor - public func syncNullifiers(sdk: SDK) async throws -> (spentCount: Int, balance: UInt64) { - guard let sdkHandle = sdk.handle else { - throw SDKError.invalidState("SDK not initialized") - } - - let poolHandle = SendablePoolHandle(self.handle) - let sdkPtr = PoolClientSendableSdkPtr(sdkHandle) - - return try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global().async { - let result = dash_sdk_shielded_pool_client_sync_nullifiers( - poolHandle.ptr, - UnsafePointer(sdkPtr.ptr) - ) - - do { - let jsonString = try Self.extractString(result) - let parsed = try Self.parseSyncNullifiersJSON(jsonString) - continuation.resume(returning: parsed) - } catch { - continuation.resume(throwing: error) - } - } - } - } - - // MARK: - Bundle Building - - /// Build a shield bundle (transparent -> shielded). - /// - /// Creates an output-only Orchard bundle that moves credits from a transparent - /// platform balance into the shielded pool. - /// - /// This may block for up to ~30 seconds on the first call while the Halo 2 - /// proving key is generated and cached. Subsequent calls are fast. - /// - /// - Parameters: - /// - amount: Amount in credits to shield. - /// - memo: Optional 36-byte memo (OutPoint). If nil, uses zero bytes. - /// - Returns: `ShieldedBundleHandle` to pass to a transition function. - /// - Throws: `SDKError` on failure. - public func buildShieldBundle( - amount: UInt64, - memo: Data? = nil - ) async throws -> ShieldedBundleHandle { - if let memo = memo, memo.count != 36 { - throw SDKError.invalidParameter("Memo must be exactly 36 bytes, got \(memo.count)") - } - - let poolHandle = SendablePoolHandle(self.handle) - let memoCopy = memo - - return try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global().async { - var memoTuple = dataToBytes36(memoCopy ?? Data(count: 36)) - - let result = withUnsafePointer(to: &memoTuple) { memoPtr in - dash_sdk_shielded_pool_client_build_shield_bundle( - poolHandle.ptr, - amount, - memoPtr - ) - } - - do { - let handle = try Self.extractBundleHandle(result) - continuation.resume(returning: handle) - } catch { - continuation.resume(throwing: error) - } - } - } - } - - /// Build a transfer bundle (shielded -> shielded). - /// - /// Selects notes and computes Merkle authentication paths automatically from - /// the local commitment tree. Creates a spend+output bundle that transfers - /// credits to the recipient within the shielded pool. - /// - /// - Parameters: - /// - recipientAddress: Raw Orchard payment address bytes of the recipient. - /// - amount: Amount in credits to transfer. - /// - memo: Optional 36-byte memo. If nil, uses zero bytes. - /// - Returns: `ShieldedBundleHandle` to pass to `shieldedTransfer(bundle:)`. - /// - Throws: `SDKError` on failure. - public func buildTransferBundle( - recipientAddress: Data, - amount: UInt64, - memo: Data? = nil - ) async throws -> ShieldedBundleHandle { - guard !recipientAddress.isEmpty else { - throw SDKError.invalidParameter("Recipient address must not be empty") - } - if let memo = memo, memo.count != 36 { - throw SDKError.invalidParameter("Memo must be exactly 36 bytes, got \(memo.count)") - } - - let poolHandle = SendablePoolHandle(self.handle) - let addrCopy = recipientAddress - let memoCopy = memo - - return try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global().async { - var memoTuple = dataToBytes36(memoCopy ?? Data(count: 36)) - - let result = addrCopy.withUnsafeBytes { addrPtr -> DashSDKResult in - guard let addrBase = addrPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return DashSDKResult() - } - return withUnsafePointer(to: &memoTuple) { memoPtr in - dash_sdk_shielded_pool_client_build_transfer_bundle( - poolHandle.ptr, - addrBase, - UInt(addrCopy.count), - amount, - memoPtr - ) - } - } - - do { - let handle = try Self.extractBundleHandle(result) - continuation.resume(returning: handle) - } catch { - continuation.resume(throwing: error) - } - } - } - } - - /// Build an unshield bundle (shielded -> platform address). - /// - /// Selects notes and computes Merkle paths automatically. The platform address - /// receives funds via the state transition's transparent output field. - /// - /// - Parameters: - /// - outputAddress: Platform address bytes for the unshield recipient. - /// - amount: Amount in credits to unshield. - /// - memo: Optional 36-byte memo. If nil, uses zero bytes. - /// - Returns: `ShieldedBundleHandle` to pass to `unshieldFunds(bundle:)`. - /// - Throws: `SDKError` on failure. - public func buildUnshieldBundle( - outputAddress: Data, - amount: UInt64, - memo: Data? = nil - ) async throws -> ShieldedBundleHandle { - guard !outputAddress.isEmpty else { - throw SDKError.invalidParameter("Output address must not be empty") - } - if let memo = memo, memo.count != 36 { - throw SDKError.invalidParameter("Memo must be exactly 36 bytes, got \(memo.count)") - } - - let poolHandle = SendablePoolHandle(self.handle) - let addrCopy = outputAddress - let memoCopy = memo - - return try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global().async { - var memoTuple = dataToBytes36(memoCopy ?? Data(count: 36)) - - let result = addrCopy.withUnsafeBytes { addrPtr -> DashSDKResult in - guard let addrBase = addrPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return DashSDKResult() - } - return withUnsafePointer(to: &memoTuple) { memoPtr in - dash_sdk_shielded_pool_client_build_unshield_bundle( - poolHandle.ptr, - addrBase, - UInt(addrCopy.count), - amount, - memoPtr - ) - } - } - - do { - let handle = try Self.extractBundleHandle(result) - continuation.resume(returning: handle) - } catch { - continuation.resume(throwing: error) - } - } - } - } - - /// Build a withdrawal bundle (shielded -> Core L1 address). - /// - /// Selects notes and computes Merkle paths automatically. The output script - /// receives funds via the state transition's withdrawal mechanism on the - /// Core (L1) chain. - /// - /// - Parameters: - /// - outputScript: Core chain output script bytes (e.g. P2PKH or P2SH). - /// - amount: Amount in credits to withdraw. - /// - memo: Optional 36-byte memo. If nil, uses zero bytes. - /// - coreFeePerByte: Core chain fee rate in duffs per byte. - /// - pooling: Withdrawal pooling strategy. - /// - Returns: `ShieldedBundleHandle` to pass to `shieldedWithdraw(bundle:)`. - /// - Throws: `SDKError` on failure. - public func buildWithdrawalBundle( - outputScript: Data, - amount: UInt64, - memo: Data? = nil, - coreFeePerByte: UInt32, - pooling: WithdrawalPooling - ) async throws -> ShieldedBundleHandle { - guard !outputScript.isEmpty else { - throw SDKError.invalidParameter("Output script must not be empty") - } - if let memo = memo, memo.count != 36 { - throw SDKError.invalidParameter("Memo must be exactly 36 bytes, got \(memo.count)") - } - - let poolHandle = SendablePoolHandle(self.handle) - let scriptCopy = outputScript - let memoCopy = memo - - return try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global().async { - var memoTuple = dataToBytes36(memoCopy ?? Data(count: 36)) - - let result = scriptCopy.withUnsafeBytes { scriptPtr -> DashSDKResult in - guard let scriptBase = scriptPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return DashSDKResult() - } - return withUnsafePointer(to: &memoTuple) { memoPtr in - dash_sdk_shielded_pool_client_build_withdrawal_bundle( - poolHandle.ptr, - scriptBase, - UInt(scriptCopy.count), - amount, - memoPtr, - coreFeePerByte, - pooling.rawValue - ) - } - } - - do { - let handle = try Self.extractBundleHandle(result) - continuation.resume(returning: handle) - } catch { - continuation.resume(throwing: error) - } - } - } - } -} - -// MARK: - Private Helpers - -extension ShieldedPoolClient { - - /// Extract a string from a DashSDKResult, freeing the error or data pointer as needed. - private static func extractString(_ result: DashSDKResult) throws -> String { - if let error = result.error { - let errorMessage = error.pointee.message != nil - ? String(cString: error.pointee.message!) - : "Unknown error" - dash_sdk_error_free(error) - throw SDKError.internalError(errorMessage) - } - - guard let dataPtr = result.data else { - throw SDKError.notFound("No data returned") - } - - let string = String(cString: dataPtr.assumingMemoryBound(to: CChar.self)) - dash_sdk_string_free(dataPtr) - return string - } - - /// Extract a ShieldedBundleHandle from a DashSDKResult. - /// The result.data must be a *mut DashSDKOrchardBundleParams allocated by Rust. - private static func extractBundleHandle(_ result: DashSDKResult) throws -> ShieldedBundleHandle { - if let error = result.error { - let errorMessage = error.pointee.message != nil - ? String(cString: error.pointee.message!) - : "Unknown error" - dash_sdk_error_free(error) - throw SDKError.internalError(errorMessage) - } - - guard let dataPtr = result.data else { - throw SDKError.internalError("No bundle data returned") - } - - let typedPtr = dataPtr.assumingMemoryBound(to: FFIOrchardBundleParams.self) - return ShieldedBundleHandle(typedPtr) - } - - /// Parse the JSON returned by sync_notes: {"newNotes":N,"balance":N}. - private static func parseSyncNotesJSON( - _ jsonString: String - ) throws -> (newNotes: Int, balance: UInt64) { - guard let jsonData = jsonString.data(using: .utf8) else { - throw SDKError.serializationError("Sync notes JSON is not valid UTF-8") - } - - guard let obj = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else { - throw SDKError.serializationError("Sync notes JSON root is not an object") - } - - guard let newNotesNum = obj["newNotes"] as? NSNumber else { - throw SDKError.serializationError("Missing 'newNotes' field in sync result") - } - - guard let balanceNum = obj["balance"] as? NSNumber else { - throw SDKError.serializationError("Missing 'balance' field in sync result") - } - - return (newNotes: newNotesNum.intValue, balance: balanceNum.uint64Value) - } - - /// Parse the JSON returned by sync_nullifiers: {"spentCount":N,"balance":N}. - private static func parseSyncNullifiersJSON( - _ jsonString: String - ) throws -> (spentCount: Int, balance: UInt64) { - guard let jsonData = jsonString.data(using: .utf8) else { - throw SDKError.serializationError("Sync nullifiers JSON is not valid UTF-8") - } - - guard let obj = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else { - throw SDKError.serializationError("Sync nullifiers JSON root is not an object") - } - - guard let spentCountNum = obj["spentCount"] as? NSNumber else { - throw SDKError.serializationError("Missing 'spentCount' field in sync result") - } - - guard let balanceNum = obj["balance"] as? NSNumber else { - throw SDKError.serializationError("Missing 'balance' field in sync result") - } - - return (spentCount: spentCountNum.intValue, balance: balanceNum.uint64Value) - } -} - -// MARK: - Sendable SDK Pointer Wrapper - -/// Sendable wrapper for the SDK handle pointer, for use in pool client async closures. -/// This is private to this file to avoid collisions with similar wrappers elsewhere. -private final class PoolClientSendableSdkPtr: @unchecked Sendable { - let ptr: UnsafeMutablePointer - init(_ p: UnsafeMutablePointer) { self.ptr = p } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Shielded/ShieldedPoolService.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Shielded/ShieldedPoolService.swift deleted file mode 100644 index 04be178dce4..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Shielded/ShieldedPoolService.swift +++ /dev/null @@ -1,1134 +0,0 @@ -// ShieldedPoolService.swift -// SwiftDashSDK -// -// High-level Swift API for shielded pool (Orchard/ZK) operations. -// Provides query, transition, builder, and broadcast methods as extensions on SDK. - -import Foundation -import DashSDKFFI - -// MARK: - Shielded Pool Service - -@MainActor -extension SDK { - - // MARK: - Internal Helpers - - /// Process a DashSDKResult and extract a string value. - /// Mirrors the pattern from PlatformQueryExtensions.swift. - private func shieldedProcessStringResult(_ result: DashSDKResult) throws -> String { - if let error = result.error { - let errorMessage = error.pointee.message != nil - ? String(cString: error.pointee.message!) - : "Unknown error" - dash_sdk_error_free(error) - throw SDKError.internalError(errorMessage) - } - - guard let dataPtr = result.data else { - throw SDKError.notFound("No data returned") - } - - let string = String(cString: dataPtr.assumingMemoryBound(to: CChar.self)) - dash_sdk_string_free(dataPtr) - return string - } - - /// Process a DashSDKResult that returns no data (success/error only). - private func shieldedProcessVoidResult(_ result: DashSDKResult) throws { - if let error = result.error { - let errorMessage = error.pointee.message != nil - ? String(cString: error.pointee.message!) - : "Unknown error" - dash_sdk_error_free(error) - throw SDKError.internalError(errorMessage) - } - } - - - // MARK: - Query Methods - - /// Fetch all anchor hashes from the shielded pool. - /// - /// - Returns: Array of 32-byte anchor hashes. - /// - Throws: `SDKError` on failure. - public func getShieldedAnchors() async throws -> [Data] { - guard let sdkHandle = handle else { - throw SDKError.invalidState("SDK not initialized") - } - - let sdkPtr = SendableSdkPtr(sdkHandle) - return try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global().async { - let result = dash_sdk_shielded_get_anchors( - UnsafePointer(sdkPtr.ptr) - ) - - do { - let jsonString = try self.shieldedExtractString(result) - guard let jsonData = jsonString.data(using: .utf8), - let hexArray = try? JSONSerialization.jsonObject(with: jsonData) as? [String] - else { - continuation.resume(throwing: SDKError.serializationError("Failed to parse anchors JSON")) - return - } - - let anchors: [Data] = hexArray.compactMap { hexToData($0) } - continuation.resume(returning: anchors) - } catch { - continuation.resume(throwing: error) - } - } - } - } - - /// Fetch the most recent anchor from the shielded pool. - /// - /// - Returns: 32-byte anchor hash. - /// - Throws: `SDKError` on failure. - public func getMostRecentAnchor() async throws -> Data { - guard let sdkHandle = handle else { - throw SDKError.invalidState("SDK not initialized") - } - - let sdkPtr = SendableSdkPtr(sdkHandle) - return try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global().async { - let result = dash_sdk_shielded_get_most_recent_anchor( - UnsafePointer(sdkPtr.ptr) - ) - - do { - let hexString = try self.shieldedExtractString(result) - guard let data = hexToData(hexString) else { - continuation.resume(throwing: SDKError.serializationError("Invalid hex anchor")) - return - } - continuation.resume(returning: data) - } catch { - continuation.resume(throwing: error) - } - } - } - } - - /// Check nullifier statuses against the shielded pool. - /// - /// - Parameter nullifiers: Array of 32-byte nullifier hashes. - /// - Returns: Array of `NullifierStatus` indicating spent/unspent for each. - /// - Throws: `SDKError` on failure. - public func checkNullifiers(_ nullifiers: [Data]) async throws -> [NullifierStatus] { - guard let sdkHandle = handle else { - throw SDKError.invalidState("SDK not initialized") - } - - guard !nullifiers.isEmpty else { - return [] - } - - // Validate all nullifiers are 32 bytes - for (i, nf) in nullifiers.enumerated() { - guard nf.count == 32 else { - throw SDKError.invalidParameter("Nullifier at index \(i) must be 32 bytes, got \(nf.count)") - } - } - - // Concatenate all nullifiers into a single contiguous byte array - var concatenated = Data(capacity: nullifiers.count * 32) - for nf in nullifiers { - concatenated.append(nf) - } - - let sdkPtr = SendableSdkPtr(sdkHandle) - let count = UInt32(nullifiers.count) - let nullifierData = concatenated // immutable copy for @Sendable capture - - return try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global().async { - let result = nullifierData.withUnsafeBytes { bytesPtr -> DashSDKResult in - guard let base = bytesPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return DashSDKResult() - } - return dash_sdk_shielded_get_nullifiers( - UnsafePointer(sdkPtr.ptr), - base, - count - ) - } - - do { - let jsonString = try self.shieldedExtractString(result) - guard let jsonData = jsonString.data(using: .utf8), - let jsonArray = try? JSONSerialization.jsonObject(with: jsonData) as? [[String: Any]] - else { - continuation.resume(throwing: SDKError.serializationError("Failed to parse nullifier statuses")) - return - } - - let statuses: [NullifierStatus] = jsonArray.compactMap { obj in - guard let hexStr = obj["nullifier"] as? String, - let isSpent = obj["isSpent"] as? Bool, - let data = hexToData(hexStr) - else { return nil } - return NullifierStatus(nullifier: data, isSpent: isSpent) - } - continuation.resume(returning: statuses) - } catch { - continuation.resume(throwing: error) - } - } - } - } - - /// Fetch encrypted notes from the shielded pool. - /// - /// - Parameters: - /// - startIndex: Starting index (0-based) in the encrypted notes tree. - /// - count: Maximum number of notes to return. - /// - Returns: Array of `EncryptedNote`. - /// - Throws: `SDKError` on failure. - public func getEncryptedNotes(startIndex: UInt64, count: UInt32) async throws -> [EncryptedNote] { - guard let sdkHandle = handle else { - throw SDKError.invalidState("SDK not initialized") - } - - let sdkPtr = SendableSdkPtr(sdkHandle) - return try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global().async { - let result = dash_sdk_shielded_get_encrypted_notes( - UnsafePointer(sdkPtr.ptr), - startIndex, - count - ) - - do { - let jsonString = try self.shieldedExtractString(result) - guard let jsonData = jsonString.data(using: .utf8), - let jsonArray = try? JSONSerialization.jsonObject(with: jsonData) as? [[String: Any]] - else { - continuation.resume(throwing: SDKError.serializationError("Failed to parse encrypted notes")) - return - } - - let notes: [EncryptedNote] = jsonArray.compactMap { obj in - guard let cmxHex = obj["cmx"] as? String, - let nfHex = obj["nullifier"] as? String, - let encHex = obj["encryptedNote"] as? String, - let cmx = hexToData(cmxHex), - let nf = hexToData(nfHex), - let enc = hexToData(encHex) - else { return nil } - return EncryptedNote(cmx: cmx, nullifier: nf, encryptedNote: enc) - } - continuation.resume(returning: notes) - } catch { - continuation.resume(throwing: error) - } - } - } - } - - /// Fetch the total shielded pool balance. - /// - /// - Returns: Total pool balance in credits. - /// - Throws: `SDKError` on failure. - public func getShieldedPoolBalance() async throws -> UInt64 { - guard let sdkHandle = handle else { - throw SDKError.invalidState("SDK not initialized") - } - - let sdkPtr = SendableSdkPtr(sdkHandle) - return try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global().async { - let result = dash_sdk_shielded_get_pool_state( - UnsafePointer(sdkPtr.ptr) - ) - - do { - let balanceStr = try self.shieldedExtractString(result) - guard let balance = UInt64(balanceStr) else { - continuation.resume(throwing: SDKError.serializationError("Failed to parse pool balance")) - return - } - continuation.resume(returning: balance) - } catch { - continuation.resume(throwing: error) - } - } - } - } - - // MARK: - Transition Methods (Build + Broadcast Combined) - - /// Shield funds from platform addresses into the shielded pool. - /// - /// - Parameters: - /// - inputs: Array of shield inputs (address + amount + private key). - /// - bundle: Orchard bundle parameters. - /// - amount: Total amount being shielded. - /// - feeFromInputIndex: Which input index to deduct fees from (0-based). - /// - Throws: `SDKError` on failure. - public func shieldFunds( - inputs: [ShieldFundsInput], - bundle: OrchardBundle, - amount: UInt64, - feeFromInputIndex: UInt16 - ) async throws { - guard let sdkHandle = handle else { - throw SDKError.invalidState("SDK not initialized") - } - - guard !inputs.isEmpty else { - throw SDKError.invalidParameter("Inputs array must not be empty") - } - for (i, input) in inputs.enumerated() { - guard input.privateKey.count == 32 else { - throw SDKError.invalidParameter("Input[\(i)] privateKey must be 32 bytes, got \(input.privateKey.count)") - } - } - - let sdkPtr = SendableSdkPtr(sdkHandle) - let inputCount = UInt32(inputs.count) - - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - DispatchQueue.global().async { - let result = self.withFFIBundle(bundle) { bundlePtr in - self.withFFIShieldInputs(inputs) { inputsPtr in - dash_sdk_shielded_shield_funds( - UnsafePointer(sdkPtr.ptr), - inputsPtr, - inputCount, - bundlePtr, - amount, - feeFromInputIndex - ) - } - } - - do { - try self.shieldedExtractVoid(result) - continuation.resume() - } catch { - continuation.resume(throwing: error) - } - } - } - } - - /// Transfer funds within the shielded pool (shielded-to-shielded). - /// - /// - Parameters: - /// - bundle: Orchard bundle parameters. - /// - valueBalance: Net value flowing out of the shielded pool. - /// - Throws: `SDKError` on failure. - public func shieldedTransfer( - bundle: OrchardBundle, - valueBalance: UInt64 - ) async throws { - guard let sdkHandle = handle else { - throw SDKError.invalidState("SDK not initialized") - } - - let sdkPtr = SendableSdkPtr(sdkHandle) - - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - DispatchQueue.global().async { - let result = self.withFFIBundle(bundle) { bundlePtr in - dash_sdk_shielded_transfer( - UnsafePointer(sdkPtr.ptr), - bundlePtr, - valueBalance - ) - } - - do { - try self.shieldedExtractVoid(result) - continuation.resume() - } catch { - continuation.resume(throwing: error) - } - } - } - } - - /// Unshield funds from the shielded pool to a platform address. - /// - /// - Parameters: - /// - outputAddress: Platform address bytes. - /// - amount: Amount to unshield (in credits). - /// - bundle: Orchard bundle parameters. - /// - Throws: `SDKError` on failure. - public func unshieldFunds( - outputAddress: Data, - amount: UInt64, - bundle: OrchardBundle - ) async throws { - guard let sdkHandle = handle else { - throw SDKError.invalidState("SDK not initialized") - } - - guard !outputAddress.isEmpty else { - throw SDKError.invalidParameter("Output address must not be empty") - } - - let sdkPtr = SendableSdkPtr(sdkHandle) - - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - DispatchQueue.global().async { - let result = outputAddress.withUnsafeBytes { addrPtr -> DashSDKResult in - guard let addrBase = addrPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return DashSDKResult() - } - return self.withFFIBundle(bundle) { bundlePtr in - dash_sdk_shielded_unshield_funds( - UnsafePointer(sdkPtr.ptr), - addrBase, - UInt(outputAddress.count), - amount, - bundlePtr - ) - } - } - - do { - try self.shieldedExtractVoid(result) - continuation.resume() - } catch { - continuation.resume(throwing: error) - } - } - } - } - - /// Shield funds from an L1 instant asset lock into the shielded pool. - /// - /// - Parameters: - /// - instantLock: Serialized instant lock bytes. - /// - transaction: Serialized funding transaction bytes. - /// - outputIndex: Output index in the transaction. - /// - privateKey: 32-byte ECDSA private key for the asset lock. - /// - bundle: Orchard bundle parameters. - /// - valueBalance: Net value flowing into the shielded pool. - /// - Throws: `SDKError` on failure. - public func shieldFromInstantLock( - instantLock: Data, - transaction: Data, - outputIndex: UInt32, - privateKey: Data, - bundle: OrchardBundle, - valueBalance: UInt64 - ) async throws { - guard let sdkHandle = handle else { - throw SDKError.invalidState("SDK not initialized") - } - - guard privateKey.count == 32 else { - throw SDKError.invalidParameter("Private key must be exactly 32 bytes") - } - - let sdkPtr = SendableSdkPtr(sdkHandle) - - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - DispatchQueue.global().async { - let result = instantLock.withUnsafeBytes { ilPtr -> DashSDKResult in - guard let ilBase = ilPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return DashSDKResult() - } - return transaction.withUnsafeBytes { txPtr -> DashSDKResult in - guard let txBase = txPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return DashSDKResult() - } - return privateKey.withUnsafeBytes { pkPtr -> DashSDKResult in - guard let pkBase = pkPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return DashSDKResult() - } - return self.withFFIBundle(bundle) { bundlePtr in - dash_sdk_shielded_shield_from_instant_lock( - UnsafePointer(sdkPtr.ptr), - ilBase, - UInt(instantLock.count), - txBase, - UInt(transaction.count), - outputIndex, - pkBase, - bundlePtr, - valueBalance - ) - } - } - } - } - - do { - try self.shieldedExtractVoid(result) - continuation.resume() - } catch { - continuation.resume(throwing: error) - } - } - } - } - - /// Shield funds from an L1 chain asset lock into the shielded pool. - /// - /// - Parameters: - /// - coreChainLockedHeight: Core chain locked height for the asset lock. - /// - outPoint: 36-byte outpoint (32 txid + 4 index). - /// - privateKey: 32-byte ECDSA private key for the asset lock. - /// - bundle: Orchard bundle parameters. - /// - valueBalance: Net value flowing into the shielded pool. - /// - Throws: `SDKError` on failure. - public func shieldFromChainLock( - coreChainLockedHeight: UInt32, - outPoint: Data, - privateKey: Data, - bundle: OrchardBundle, - valueBalance: UInt64 - ) async throws { - guard let sdkHandle = handle else { - throw SDKError.invalidState("SDK not initialized") - } - - guard outPoint.count == 36 else { - throw SDKError.invalidParameter("OutPoint must be exactly 36 bytes") - } - - guard privateKey.count == 32 else { - throw SDKError.invalidParameter("Private key must be exactly 32 bytes") - } - - let sdkPtr = SendableSdkPtr(sdkHandle) - - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - DispatchQueue.global().async { - var outPointTuple = dataToBytes36(outPoint) - let result = privateKey.withUnsafeBytes { pkPtr -> DashSDKResult in - guard let pkBase = pkPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return DashSDKResult() - } - return self.withFFIBundle(bundle) { bundlePtr in - withUnsafePointer(to: &outPointTuple) { opPtr in - dash_sdk_shielded_shield_from_chain_lock( - UnsafePointer(sdkPtr.ptr), - coreChainLockedHeight, - opPtr, - pkBase, - bundlePtr, - valueBalance - ) - } - } - } - - do { - try self.shieldedExtractVoid(result) - continuation.resume() - } catch { - continuation.resume(throwing: error) - } - } - } - } - - /// Withdraw funds from the shielded pool to a Core (L1) address. - /// - /// - Parameters: - /// - amount: Amount to withdraw (in credits). - /// - bundle: Orchard bundle parameters. - /// - coreFeePerByte: Core fee per byte for the withdrawal transaction. - /// - pooling: Withdrawal pooling mode. - /// - outputScript: Raw Core output script bytes. - /// - Throws: `SDKError` on failure. - public func shieldedWithdraw( - amount: UInt64, - bundle: OrchardBundle, - coreFeePerByte: UInt32, - pooling: WithdrawalPooling, - outputScript: Data - ) async throws { - guard let sdkHandle = handle else { - throw SDKError.invalidState("SDK not initialized") - } - - guard !outputScript.isEmpty else { - throw SDKError.invalidParameter("Output script must not be empty") - } - - let sdkPtr = SendableSdkPtr(sdkHandle) - - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - DispatchQueue.global().async { - let result = outputScript.withUnsafeBytes { scriptPtr -> DashSDKResult in - guard let scriptBase = scriptPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return DashSDKResult() - } - return self.withFFIBundle(bundle) { bundlePtr in - dash_sdk_shielded_withdraw( - UnsafePointer(sdkPtr.ptr), - amount, - bundlePtr, - coreFeePerByte, - pooling.rawValue, - scriptBase, - UInt(outputScript.count) - ) - } - } - - do { - try self.shieldedExtractVoid(result) - continuation.resume() - } catch { - continuation.resume(throwing: error) - } - } - } - } - - // MARK: - Builder Methods (Return Serialized State Transition Bytes) - - /// Build a shielded transfer state transition without broadcasting. - /// - /// - Parameters: - /// - bundle: Orchard bundle parameters. - /// - valueBalance: Net value flowing out of the shielded pool. - /// - Returns: Serialized state transition bytes. - /// - Throws: `SDKError` on failure. - public func buildShieldedTransfer( - bundle: OrchardBundle, - valueBalance: UInt64 - ) async throws -> Data { - guard let sdkHandle = handle else { - throw SDKError.invalidState("SDK not initialized") - } - - let sdkPtr = SendableSdkPtr(sdkHandle) - - return try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global().async { - let result = self.withFFIBundle(bundle) { bundlePtr in - dash_sdk_shielded_build_transfer( - UnsafePointer(sdkPtr.ptr), - bundlePtr, - valueBalance - ) - } - - do { - let data = try self.shieldedExtractHexData(result) - continuation.resume(returning: data) - } catch { - continuation.resume(throwing: error) - } - } - } - } - - /// Build an unshield state transition without broadcasting. - /// - /// - Parameters: - /// - outputAddress: Platform address bytes. - /// - amount: Amount to unshield (in credits). - /// - bundle: Orchard bundle parameters. - /// - Returns: Serialized state transition bytes. - /// - Throws: `SDKError` on failure. - public func buildUnshield( - outputAddress: Data, - amount: UInt64, - bundle: OrchardBundle - ) async throws -> Data { - guard let sdkHandle = handle else { - throw SDKError.invalidState("SDK not initialized") - } - - guard !outputAddress.isEmpty else { - throw SDKError.invalidParameter("Output address must not be empty") - } - - let sdkPtr = SendableSdkPtr(sdkHandle) - - return try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global().async { - let result = outputAddress.withUnsafeBytes { addrPtr -> DashSDKResult in - guard let addrBase = addrPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return DashSDKResult() - } - return self.withFFIBundle(bundle) { bundlePtr in - dash_sdk_shielded_build_unshield( - UnsafePointer(sdkPtr.ptr), - addrBase, - UInt(outputAddress.count), - amount, - bundlePtr - ) - } - } - - do { - let data = try self.shieldedExtractHexData(result) - continuation.resume(returning: data) - } catch { - continuation.resume(throwing: error) - } - } - } - } - - /// Build a shield state transition without broadcasting. - /// - /// - Parameters: - /// - inputs: Array of shield inputs. - /// - bundle: Orchard bundle parameters. - /// - amount: Total amount being shielded. - /// - feeFromInputIndex: Which input index to deduct fees from (0-based). - /// - Returns: Serialized state transition bytes. - /// - Throws: `SDKError` on failure. - public func buildShield( - inputs: [ShieldFundsInput], - bundle: OrchardBundle, - amount: UInt64, - feeFromInputIndex: UInt16 - ) async throws -> Data { - guard let sdkHandle = handle else { - throw SDKError.invalidState("SDK not initialized") - } - - guard !inputs.isEmpty else { - throw SDKError.invalidParameter("Inputs array must not be empty") - } - - let sdkPtr = SendableSdkPtr(sdkHandle) - let inputCount = UInt32(inputs.count) - - return try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global().async { - let result = self.withFFIBundle(bundle) { bundlePtr in - self.withFFIShieldInputs(inputs) { inputsPtr in - dash_sdk_shielded_build_shield( - UnsafePointer(sdkPtr.ptr), - inputsPtr, - inputCount, - bundlePtr, - amount, - feeFromInputIndex - ) - } - } - - do { - let data = try self.shieldedExtractHexData(result) - continuation.resume(returning: data) - } catch { - continuation.resume(throwing: error) - } - } - } - } - - /// Build a shield-from-instant-lock state transition without broadcasting. - /// - /// - Parameters: - /// - instantLock: Serialized instant lock bytes. - /// - transaction: Serialized funding transaction bytes. - /// - outputIndex: Output index in the transaction. - /// - privateKey: 32-byte ECDSA private key. - /// - bundle: Orchard bundle parameters. - /// - valueBalance: Net value flowing into the shielded pool. - /// - Returns: Serialized state transition bytes. - /// - Throws: `SDKError` on failure. - public func buildShieldFromInstantLock( - instantLock: Data, - transaction: Data, - outputIndex: UInt32, - privateKey: Data, - bundle: OrchardBundle, - valueBalance: UInt64 - ) async throws -> Data { - guard let sdkHandle = handle else { - throw SDKError.invalidState("SDK not initialized") - } - - guard privateKey.count == 32 else { - throw SDKError.invalidParameter("Private key must be exactly 32 bytes") - } - - let sdkPtr = SendableSdkPtr(sdkHandle) - - return try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global().async { - let result = instantLock.withUnsafeBytes { ilPtr -> DashSDKResult in - guard let ilBase = ilPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return DashSDKResult() - } - return transaction.withUnsafeBytes { txPtr -> DashSDKResult in - guard let txBase = txPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return DashSDKResult() - } - return privateKey.withUnsafeBytes { pkPtr -> DashSDKResult in - guard let pkBase = pkPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return DashSDKResult() - } - return self.withFFIBundle(bundle) { bundlePtr in - dash_sdk_shielded_build_shield_from_instant_lock( - UnsafePointer(sdkPtr.ptr), - ilBase, - UInt(instantLock.count), - txBase, - UInt(transaction.count), - outputIndex, - pkBase, - bundlePtr, - valueBalance - ) - } - } - } - } - - do { - let data = try self.shieldedExtractHexData(result) - continuation.resume(returning: data) - } catch { - continuation.resume(throwing: error) - } - } - } - } - - /// Build a shield-from-chain-lock state transition without broadcasting. - /// - /// - Parameters: - /// - coreChainLockedHeight: Core chain locked height. - /// - outPoint: 36-byte outpoint (32 txid + 4 index). - /// - privateKey: 32-byte ECDSA private key. - /// - bundle: Orchard bundle parameters. - /// - valueBalance: Net value flowing into the shielded pool. - /// - Returns: Serialized state transition bytes. - /// - Throws: `SDKError` on failure. - public func buildShieldFromChainLock( - coreChainLockedHeight: UInt32, - outPoint: Data, - privateKey: Data, - bundle: OrchardBundle, - valueBalance: UInt64 - ) async throws -> Data { - guard let sdkHandle = handle else { - throw SDKError.invalidState("SDK not initialized") - } - - guard outPoint.count == 36 else { - throw SDKError.invalidParameter("OutPoint must be exactly 36 bytes") - } - - guard privateKey.count == 32 else { - throw SDKError.invalidParameter("Private key must be exactly 32 bytes") - } - - let sdkPtr = SendableSdkPtr(sdkHandle) - - return try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global().async { - var outPointTuple = dataToBytes36(outPoint) - let result = privateKey.withUnsafeBytes { pkPtr -> DashSDKResult in - guard let pkBase = pkPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return DashSDKResult() - } - return self.withFFIBundle(bundle) { bundlePtr in - withUnsafePointer(to: &outPointTuple) { opPtr in - dash_sdk_shielded_build_shield_from_chain_lock( - UnsafePointer(sdkPtr.ptr), - coreChainLockedHeight, - opPtr, - pkBase, - bundlePtr, - valueBalance - ) - } - } - } - - do { - let data = try self.shieldedExtractHexData(result) - continuation.resume(returning: data) - } catch { - continuation.resume(throwing: error) - } - } - } - } - - /// Build a shielded withdrawal state transition without broadcasting. - /// - /// - Parameters: - /// - amount: Amount to withdraw (in credits). - /// - bundle: Orchard bundle parameters. - /// - coreFeePerByte: Core fee per byte. - /// - pooling: Withdrawal pooling mode. - /// - outputScript: Raw Core output script bytes. - /// - Returns: Serialized state transition bytes. - /// - Throws: `SDKError` on failure. - public func buildShieldedWithdrawal( - amount: UInt64, - bundle: OrchardBundle, - coreFeePerByte: UInt32, - pooling: WithdrawalPooling, - outputScript: Data - ) async throws -> Data { - guard let sdkHandle = handle else { - throw SDKError.invalidState("SDK not initialized") - } - - guard !outputScript.isEmpty else { - throw SDKError.invalidParameter("Output script must not be empty") - } - - let sdkPtr = SendableSdkPtr(sdkHandle) - - return try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global().async { - let result = outputScript.withUnsafeBytes { scriptPtr -> DashSDKResult in - guard let scriptBase = scriptPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return DashSDKResult() - } - return self.withFFIBundle(bundle) { bundlePtr in - dash_sdk_shielded_build_withdrawal( - UnsafePointer(sdkPtr.ptr), - amount, - bundlePtr, - coreFeePerByte, - pooling.rawValue, - scriptBase, - UInt(outputScript.count) - ) - } - } - - do { - let data = try self.shieldedExtractHexData(result) - continuation.resume(returning: data) - } catch { - continuation.resume(throwing: error) - } - } - } - } - - // MARK: - Broadcast Method - - /// Broadcast a pre-built, serialized state transition. - /// - /// Use with the `build*` methods: first build a transition, inspect or store it, - /// then broadcast when ready. - /// - /// - Parameter transitionBytes: Serialized state transition bytes (from a `build*` method). - /// - Throws: `SDKError` on failure. - public func broadcastShieldedTransition(_ transitionBytes: Data) async throws { - guard let sdkHandle = handle else { - throw SDKError.invalidState("SDK not initialized") - } - - guard !transitionBytes.isEmpty else { - throw SDKError.invalidParameter("State transition bytes must not be empty") - } - - let sdkPtr = SendableSdkPtr(sdkHandle) - - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - DispatchQueue.global().async { - let result = transitionBytes.withUnsafeBytes { bytesPtr -> DashSDKResult in - guard let base = bytesPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { - return DashSDKResult() - } - return dash_sdk_shielded_broadcast( - UnsafePointer(sdkPtr.ptr), - base, - UInt(transitionBytes.count) - ) - } - - do { - try self.shieldedExtractVoid(result) - continuation.resume() - } catch { - continuation.resume(throwing: error) - } - } - } - } -} - -// MARK: - Private Helpers - -extension SDK { - - // Sendable wrapper for SDK handle pointer, used to cross @Sendable boundaries. - final class SendableSdkPtr: @unchecked Sendable { - let ptr: UnsafeMutablePointer - init(_ p: UnsafeMutablePointer) { self.ptr = p } - } - - /// Extract a string from a DashSDKResult. Thread-safe (no @MainActor). - nonisolated func shieldedExtractString(_ result: DashSDKResult) throws -> String { - if let error = result.error { - let errorMessage = error.pointee.message != nil - ? String(cString: error.pointee.message!) - : "Unknown error" - dash_sdk_error_free(error) - throw SDKError.internalError(errorMessage) - } - - guard let dataPtr = result.data else { - throw SDKError.notFound("No data returned") - } - - let string = String(cString: dataPtr.assumingMemoryBound(to: CChar.self)) - dash_sdk_string_free(dataPtr) - return string - } - - /// Extract void (success/error) from a DashSDKResult. Thread-safe. - nonisolated func shieldedExtractVoid(_ result: DashSDKResult) throws { - if let error = result.error { - let errorMessage = error.pointee.message != nil - ? String(cString: error.pointee.message!) - : "Unknown error" - dash_sdk_error_free(error) - throw SDKError.internalError(errorMessage) - } - } - - /// Extract hex-encoded data from a DashSDKResult (for builder functions). - nonisolated private func shieldedExtractHexData(_ result: DashSDKResult) throws -> Data { - let hexString = try shieldedExtractString(result) - guard let data = hexToData(hexString) else { - throw SDKError.serializationError("Invalid hex data returned from builder") - } - return data - } - - /// Execute a closure with a temporary FFIOrchardBundleParams, ensuring all backing - /// memory (actions, proof, encrypted notes) stays alive for the call duration. - nonisolated func withFFIBundle( - _ bundle: OrchardBundle, - body: (UnsafePointer) -> R - ) -> R { - // Guard against stack overflow from recursive withUnsafeBytes nesting - precondition(bundle.actions.count <= 2048, "Bundle has too many actions (\(bundle.actions.count) > 2048)") - - // Build FFI action structs. The encrypted note data must live long enough. - var ffiActions: [FFISerializedAction] = [] - let encryptedNoteStorage = bundle.actions.map { $0.encryptedNote } - - for (i, action) in bundle.actions.enumerated() { - let ffiAction = FFISerializedAction( - nullifier: dataToBytes32(action.nullifier), - rk: dataToBytes32(action.rk), - cmx: dataToBytes32(action.cmx), - encrypted_note: nil, - encrypted_note_len: UInt(action.encryptedNote.count), - cv_net: dataToBytes32(action.cvNet), - spend_auth_sig: dataToBytes64(action.spendAuthSig) - ) - // We will set encrypted_note pointer below when we have buffer pointers. - _ = i // suppress unused warning - ffiActions.append(ffiAction) - } - - // Now call with all pointers valid. - return bundle.proof.withUnsafeBytes { proofPtr in - // We need stable pointers to each encrypted note. Use a nested approach. - func recurse( - actionIndex: Int, - notePointers: [UnsafePointer?] - ) -> R { - if actionIndex >= encryptedNoteStorage.count { - // All encrypted note pointers are now available. - // Update ffiActions with the correct pointers. - for (j, notePtr) in notePointers.enumerated() { - ffiActions[j].encrypted_note = notePtr - } - - // Build the params struct. - return ffiActions.withUnsafeBufferPointer { actionsBuffer in - var params = FFIOrchardBundleParams( - actions: actionsBuffer.baseAddress, - actions_count: UInt32(ffiActions.count), - anchor: dataToBytes32(bundle.anchor), - proof: proofPtr.baseAddress?.assumingMemoryBound(to: UInt8.self), - proof_len: UInt(bundle.proof.count), - binding_signature: dataToBytes64(bundle.bindingSignature) - ) - return withUnsafePointer(to: ¶ms) { paramsPtr in - body(paramsPtr) - } - } - } else { - let noteData = encryptedNoteStorage[actionIndex] - if noteData.isEmpty { - return recurse( - actionIndex: actionIndex + 1, - notePointers: notePointers + [nil] - ) - } else { - return noteData.withUnsafeBytes { noteBytes in - let notePtr = noteBytes.baseAddress?.assumingMemoryBound(to: UInt8.self) - return recurse( - actionIndex: actionIndex + 1, - notePointers: notePointers + [notePtr] - ) - } - } - } - } - - return recurse(actionIndex: 0, notePointers: []) - } - } - - /// Execute a closure with a temporary array of FFIShieldInput structs. - /// All backing Data (address, privateKey) stays alive for the call duration. - nonisolated func withFFIShieldInputs( - _ inputs: [ShieldFundsInput], - body: (UnsafePointer) -> R - ) -> R { - // We need stable pointers to each address and private key. - func recurse( - index: Int, - addrPtrs: [(UnsafePointer?, Int)], - pkPtrs: [UnsafePointer?] - ) -> R { - if index >= inputs.count { - // Build FFIShieldInput array - var ffiInputs: [FFIShieldInput] = [] - for (i, input) in inputs.enumerated() { - let (addrPtr, addrLen) = addrPtrs[i] - ffiInputs.append(FFIShieldInput( - address: addrPtr, - address_len: UInt(addrLen), - amount: input.amount, - private_key: pkPtrs[i] - )) - } - return ffiInputs.withUnsafeBufferPointer { buf in - body(buf.baseAddress!) - } - } else { - let input = inputs[index] - return input.address.withUnsafeBytes { addrBytes in - let addrPtr = addrBytes.baseAddress?.assumingMemoryBound(to: UInt8.self) - return input.privateKey.withUnsafeBytes { pkBytes in - let pkPtr = pkBytes.baseAddress?.assumingMemoryBound(to: UInt8.self) - return recurse( - index: index + 1, - addrPtrs: addrPtrs + [(addrPtr, input.address.count)], - pkPtrs: pkPtrs + [pkPtr] - ) - } - } - } - } - - return recurse(index: 0, addrPtrs: [], pkPtrs: []) - } -} - diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Shielded/ShieldedTypes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Shielded/ShieldedTypes.swift deleted file mode 100644 index cf3681d7b25..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Shielded/ShieldedTypes.swift +++ /dev/null @@ -1,269 +0,0 @@ -// ShieldedTypes.swift -// SwiftDashSDK -// -// Swift types for shielded pool (Orchard/ZK) operations. -// These match the Rust #[repr(C)] structs in rs-sdk-ffi/src/shielded/types.rs -// and rs-sdk-ffi/src/shielded/transitions/shield.rs. - -import Foundation - -// MARK: - FFI Type Aliases -// These types are imported from the DashSDKFFI C header. -// Aliases preserve backward compatibility with code that uses the FFI* prefix. - -typealias FFISerializedAction = DashSDKSerializedAction -typealias FFIOrchardBundleParams = DashSDKOrchardBundleParams -typealias FFIShieldInput = DashSDKShieldInput - -// MARK: - Tuple Helpers - -/// Type alias for a 32-byte tuple (used for nullifiers, anchors, keys, etc.) -typealias Bytes32Tuple = ( - UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, - UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, - UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, - UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8 -) - -/// Type alias for a 36-byte tuple (used for OutPoint: 32 txid + 4 index) -typealias Bytes36Tuple = ( - UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, - UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, - UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, - UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, - UInt8, UInt8, UInt8, UInt8 -) - -/// Type alias for a 64-byte tuple (used for signatures) -typealias Bytes64Tuple = ( - UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, - UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, - UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, - UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, - UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, - UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, - UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, - UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8 -) - -/// Copy Data into a 32-byte tuple, zero-padding if shorter. -func dataToBytes32(_ data: Data) -> Bytes32Tuple { - var tuple: Bytes32Tuple = ( - 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 - ) - data.withUnsafeBytes { src in - withUnsafeMutableBytes(of: &tuple) { dst in - let count = min(src.count, 32) - if count > 0, let srcBase = src.baseAddress { - dst.baseAddress?.copyMemory(from: srcBase, byteCount: count) - } - } - } - return tuple -} - -/// Copy Data into a 36-byte tuple. -func dataToBytes36(_ data: Data) -> Bytes36Tuple { - var tuple: Bytes36Tuple = ( - 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, - 0, 0, 0, 0 - ) - data.withUnsafeBytes { src in - withUnsafeMutableBytes(of: &tuple) { dst in - let count = min(src.count, 36) - if count > 0, let srcBase = src.baseAddress { - dst.baseAddress?.copyMemory(from: srcBase, byteCount: count) - } - } - } - return tuple -} - -/// Copy Data into a 64-byte tuple. -func dataToBytes64(_ data: Data) -> Bytes64Tuple { - var tuple: Bytes64Tuple = ( - 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, - 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 - ) - data.withUnsafeBytes { src in - withUnsafeMutableBytes(of: &tuple) { dst in - let count = min(src.count, 64) - if count > 0, let srcBase = src.baseAddress { - dst.baseAddress?.copyMemory(from: srcBase, byteCount: count) - } - } - } - return tuple -} - -/// Extract Data from a 32-byte tuple. -func bytes32ToData(_ tuple: Bytes32Tuple) -> Data { - var mutable = tuple - return withUnsafeBytes(of: &mutable) { Data($0) } -} - -// MARK: - High-Level Swift Models - -/// A single Orchard action (high-level Swift representation). -public struct OrchardAction: Sendable { - /// 32-byte nullifier - public let nullifier: Data - /// 32-byte randomized verification key - public let rk: Data - /// 32-byte note commitment - public let cmx: Data - /// Variable-length encrypted note ciphertext - public let encryptedNote: Data - /// 32-byte value commitment - public let cvNet: Data - /// 64-byte spend authorization signature - public let spendAuthSig: Data - - public init( - nullifier: Data, - rk: Data, - cmx: Data, - encryptedNote: Data, - cvNet: Data, - spendAuthSig: Data - ) { - self.nullifier = nullifier - self.rk = rk - self.cmx = cmx - self.encryptedNote = encryptedNote - self.cvNet = cvNet - self.spendAuthSig = spendAuthSig - } -} - -/// Orchard bundle parameters (high-level Swift representation). -/// Clients construct the Orchard proof and signatures independently, -/// then pass these parameters to shielded transition functions. -public struct OrchardBundle: Sendable { - /// Actions in the bundle - public let actions: [OrchardAction] - /// 32-byte anchor (tree root) - public let anchor: Data - /// Variable-length Orchard proof bytes - public let proof: Data - /// 64-byte binding signature - public let bindingSignature: Data - - public init( - actions: [OrchardAction], - anchor: Data, - proof: Data, - bindingSignature: Data - ) { - self.actions = actions - self.anchor = anchor - self.proof = proof - self.bindingSignature = bindingSignature - } -} - -/// Input for shield operation: address + amount + private key. -public struct ShieldFundsInput: Sendable { - /// Platform address bytes - public let address: Data - /// Amount to shield from this address (in credits) - public let amount: UInt64 - /// 32-byte private key for signing - public let privateKey: Data - - public init(address: Data, amount: UInt64, privateKey: Data) { - self.address = address - self.amount = amount - self.privateKey = privateKey - } -} - -/// Withdrawal pooling mode. -public enum WithdrawalPooling: UInt8, Sendable { - /// Never pool withdrawals - case never = 0 - /// Pool if available - case ifAvailable = 1 - /// Standard pooling - case standard = 2 -} - -/// Status of a single nullifier as returned by query. -public struct NullifierStatus: Sendable { - /// The 32-byte nullifier hash - public let nullifier: Data - /// Whether this nullifier has been spent - public let isSpent: Bool - - public init(nullifier: Data, isSpent: Bool) { - self.nullifier = nullifier - self.isSpent = isSpent - } -} - -/// An encrypted note from the shielded pool. -public struct EncryptedNote: Sendable { - /// Note commitment (hex-decoded from cmx) - public let cmx: Data - /// Nullifier hash - public let nullifier: Data - /// Encrypted note ciphertext - public let encryptedNote: Data - - public init(cmx: Data, nullifier: Data, encryptedNote: Data) { - self.cmx = cmx - self.nullifier = nullifier - self.encryptedNote = encryptedNote - } -} - -// MARK: - FFI Conversion Helpers - -/// Helper that builds an array of FFISerializedAction structs from OrchardAction models. -/// The returned array and its backing Data objects must remain alive during the FFI call. -/// -/// Returns (ffiActions, backingStorage) where backingStorage keeps encrypted note Data alive. -func buildFFIActions( - from actions: [OrchardAction] -) -> ([FFISerializedAction], [Data]) { - var ffiActions: [FFISerializedAction] = [] - // Keep encrypted note Data objects alive so pointers remain valid. - var storage: [Data] = [] - - for action in actions { - storage.append(action.encryptedNote) - - let ffiAction = FFISerializedAction( - nullifier: dataToBytes32(action.nullifier), - rk: dataToBytes32(action.rk), - cmx: dataToBytes32(action.cmx), - encrypted_note: nil, // set below via withUnsafeBytes during call - encrypted_note_len: UInt(action.encryptedNote.count), - cv_net: dataToBytes32(action.cvNet), - spend_auth_sig: dataToBytes64(action.spendAuthSig) - ) - ffiActions.append(ffiAction) - } - - return (ffiActions, storage) -} - -/// Decode a hex string to Data. Returns nil on invalid hex. -func hexToData(_ hex: String) -> Data? { - let cleaned = hex.trimmingCharacters(in: .whitespacesAndNewlines) - guard cleaned.count % 2 == 0 else { return nil } - let len = cleaned.count / 2 - var data = Data(capacity: len) - var index = cleaned.startIndex - for _ in 0..= 32 bytes). First 32 bytes used as spending key. - /// - network: Current network. - func initialize(seed: Data, network: Network) { - guard seed.count >= 32 else { - lastError = "Seed must be at least 32 bytes" - return - } + /// New decrypted notes detected on the most recent sync pass. + @Published var lastNewNotes: UInt32 = 0 - let sk = seed.prefix(32) - self.spendingKey = Data(sk) - self.currentNetwork = network + /// Notes newly detected as spent on the most recent sync pass. + @Published var lastNewlySpent: UInt32 = 0 - let dbPath = Self.dbPath(for: network) + /// Whether the bound wallet has a shielded sub-wallet on the Rust + /// side. Until [`bind`] runs successfully every pass marks the + /// wallet as `skipped` and we surface that here so the UI can + /// show a clear "not yet bound" state instead of stale zeros. + @Published var isBound: Bool = false - do { - poolClient = try ShieldedPoolClient(dbPath: dbPath, spendingKey: Data(sk)) - let rawAddress = try poolClient!.address - orchardDisplayAddress = DashAddress.encodeOrchard(rawBytes: rawAddress, network: network) - shieldedBalance = try poolClient!.balance - lastError = nil - } catch { - lastError = "Failed to init shielded pool: \(error.localizedDescription)" - poolClient = nil - orchardDisplayAddress = nil - shieldedBalance = 0 - } - } + /// Local clock timestamp of the last completed sync pass. + @Published var lastSyncTime: Date? - /// Sync notes from the network. - func syncNotes(sdk: SDK) async throws { - guard let client = poolClient else { - throw SDKError.invalidState("Shielded pool not initialized") - } + /// Number of successful shielded sync passes observed since + /// launch (skipped passes don't count). + @Published var syncCountSinceLaunch: Int = 0 - isSyncing = true - defer { isSyncing = false } + /// Cumulative encrypted notes scanned since launch — sum of + /// every pass's `total_scanned`. + @Published var totalScanned: UInt64 = 0 - let result = try await client.syncNotes(sdk: sdk) - shieldedBalance = result.balance - } + /// Cumulative decrypted notes accepted since launch. + @Published var totalNewNotes: UInt64 = 0 - /// Sync nullifiers (mark spent notes). - func syncNullifiers(sdk: SDK) async throws { - guard let client = poolClient else { - throw SDKError.invalidState("Shielded pool not initialized") - } + /// Cumulative notes newly detected as spent since launch. + @Published var totalNewlySpent: UInt64 = 0 - isSyncing = true - defer { isSyncing = false } + /// Last error from a shielded operation. Cleared on a successful + /// pass. + @Published var lastError: String? - let result = try await client.syncNullifiers(sdk: sdk) - shieldedBalance = result.balance - } + /// Bech32m-encoded Orchard payment address. Currently a + /// placeholder — the manager doesn't expose the per-wallet + /// address yet (defer until bundle building lands). + @Published var orchardDisplayAddress: String? + + // MARK: - Internals + + /// Wallet manager whose shielded sync events we mirror. + private weak var walletManager: PlatformWalletManager? + + /// Wallet id we filter sync results by. + private var walletId: Data? + + /// Subscription to `walletManager.$shieldedSyncIsSyncing`. + private var syncStateCancellable: AnyCancellable? + + /// Subscription to `walletManager.$lastShieldedSyncEvent`. + private var syncEventCancellable: AnyCancellable? - /// Full sync: notes then nullifiers. - func fullSync(sdk: SDK) async { + // MARK: - Lifecycle + + /// 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`. + /// + /// Failure during the Rust-side bind sets `lastError`; the + /// service continues to subscribe to events so a successful + /// `bind` retried later picks up automatically. + func bind( + walletManager: PlatformWalletManager, + walletId: Data, + network: Network, + resolver: MnemonicResolver + ) { + self.walletManager = walletManager + self.walletId = walletId + self.syncStateCancellable?.cancel() + self.syncEventCancellable?.cancel() + + // Clear the previous wallet's snapshot up front. Without + // this, switching wallets (or a failed rebind) leaves the + // prior wallet's balance / counters / orchard address on + // the UI until the new wallet's first sync event lands — + // which can be tens of seconds, or never if the new bind + // fails. Per-published-field reset rather than `reset()` + // because the manager subscriptions get re-attached just + // below; we don't want to nil out walletManager/walletId. + isBound = false + isSyncing = false + shieldedBalance = 0 + lastNewNotes = 0 + lastNewlySpent = 0 + lastSyncTime = nil + lastError = nil + orchardDisplayAddress = nil + syncCountSinceLaunch = 0 + totalScanned = 0 + totalNewNotes = 0 + totalNewlySpent = 0 + + let dbPath = Self.dbPath(for: network) do { - try await syncNotes(sdk: sdk) - try await syncNullifiers(sdk: sdk) + try walletManager.bindShielded( + walletId: walletId, + resolver: resolver, + account: 0, + 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 + ) + } + + SDKLogger.log( + "Shielded bound: walletId=\(walletId.prefix(4).map { String(format: "%02x", $0) }.joined())… network=\(network.networkName) tree=\(dbPath)", + minimumLevel: .medium + ) } catch { - lastError = "Sync error: \(error.localizedDescription)" + lastError = "Shielded bind failed: \(error.localizedDescription)" + SDKLogger.log(lastError ?? "", minimumLevel: .medium) } + + syncStateCancellable = walletManager.$shieldedSyncIsSyncing + .sink { [weak self] isSyncing in + self?.isSyncing = isSyncing + } + + syncEventCancellable = walletManager.$lastShieldedSyncEvent + .sink { [weak self] event in + guard let self, let event else { return } + self.handleShieldedSyncEvent(event) + } } - /// Refresh balance from the local database (no network call). - func refreshBalance() { - guard let client = poolClient else { return } + /// Trigger a manual shielded sync pass. No-op if a pass is + /// already in flight. + /// + /// Drives `isSyncing` directly around the await so the spinner + /// flashes even when the underlying Rust pass completes faster + /// than the manager's 1 Hz `isShieldedSyncing` poll cadence — + /// the published `$shieldedSyncIsSyncing` stays `false` the + /// whole time on a fast (e.g. empty-tree) sync, so we can't + /// rely on the subscription alone to flip it back. + func manualSync() async { + guard !isSyncing else { return } + guard let walletManager else { + lastError = "Shielded service not configured" + return + } + + isSyncing = true + lastError = nil + defer { isSyncing = false } do { - shieldedBalance = try client.balance + try await walletManager.syncShieldedNow() } catch { - lastError = "Balance refresh error: \(error.localizedDescription)" + lastError = "Shielded sync error: \(error.localizedDescription)" + SDKLogger.log(lastError ?? "", minimumLevel: .medium) } } - /// Reset state (e.g., on wallet deletion or logout). + /// Reset display state. Cancels the manager subscriptions but + /// does not stop the manager-wide background loop — that's the + /// caller's responsibility (see + /// [`PlatformWalletManager.stopShieldedSync`]). func reset() { - poolClient = nil - spendingKey = nil - shieldedBalance = 0 - orchardDisplayAddress = nil + syncStateCancellable?.cancel() + syncEventCancellable?.cancel() + walletManager = nil + walletId = nil isSyncing = false + shieldedBalance = 0 + lastNewNotes = 0 + lastNewlySpent = 0 + isBound = false + lastSyncTime = nil lastError = nil + orchardDisplayAddress = nil + syncCountSinceLaunch = 0 + totalScanned = 0 + totalNewNotes = 0 + totalNewlySpent = 0 + } + + // MARK: - Sync event handling + + private func handleShieldedSyncEvent(_ event: ShieldedSyncEvent) { + guard let walletId, let result = event.result(for: walletId) else { + return + } + + if result.success { + lastError = nil + isBound = true + shieldedBalance = result.balance + lastNewNotes = result.newNotes + lastNewlySpent = result.newlySpent + lastSyncTime = Date(timeIntervalSince1970: TimeInterval(event.syncUnixSeconds)) + syncCountSinceLaunch += 1 + totalScanned += result.totalScanned + totalNewNotes += UInt64(result.newNotes) + totalNewlySpent += UInt64(result.newlySpent) + } else if result.skipped { + // Skipped means the wallet hasn't been bound yet on the + // Rust side. The UI can prompt the user to retry the + // bind step. + isBound = false + } else { + lastError = result.errorMessage ?? "Shielded sync failed" + } } // MARK: - Private + /// One commitment tree per network (the Orchard tree is global per + /// network; only the per-wallet decrypted notes are wallet-scoped). private static func dbPath(for network: Network) -> String { - let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! - return docs.appendingPathComponent("shielded_\(network.networkName).sqlite").path + let docs = FileManager.default + .urls(for: .documentDirectory, in: .userDomainMask) + .first! + return docs + .appendingPathComponent("shielded_tree_\(network.networkName).sqlite") + .path } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ZKSyncService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ZKSyncService.swift deleted file mode 100644 index 9aeabfa9033..00000000000 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ZKSyncService.swift +++ /dev/null @@ -1,164 +0,0 @@ -// ZKSyncService.swift -// SwiftExampleApp -// -// App-level service that performs periodic ZK shielded sync (notes + nullifiers) -// with UI status display. Follows the same pattern as PlatformBalanceSyncService. - -import Foundation -import SwiftUI -import SwiftDashSDK - -/// Observable service managing periodic ZK shielded pool sync. -/// -/// Syncs every 30 seconds while the app is active, or on manual pull-to-refresh. -/// Persists `shieldedBalance` and `orchardAddress` in UserDefaults for display across launches. -@MainActor -class ZKSyncService: ObservableObject { - // MARK: - Published State - - /// Whether a sync is currently in progress. - @Published var isSyncing: Bool = false - - /// Last successful sync time (local clock). - @Published var lastSyncTime: Date? - - /// Current shielded balance (in credits). - @Published var shieldedBalance: UInt64 = 0 - - /// Orchard display address (Bech32m-encoded). - @Published var orchardAddress: String? - - /// Number of new notes found in the most recent sync. - @Published var notesSynced: Int = 0 - - /// Number of nullifiers spent in the most recent sync. - @Published var nullifiersSpent: Int = 0 - - /// Cumulative notes synced since launch. - @Published var totalNotesSynced: Int = 0 - - /// Cumulative nullifiers spent since launch. - @Published var totalNullifiersSpent: Int = 0 - - /// Total number of successful syncs since launch. - @Published var syncCountSinceLaunch: Int = 0 - - /// Last error message, cleared on successful sync. - @Published var lastError: String? - - // MARK: - Persisted State - - /// Persisted shielded balance (credits). - private var persistedBalance: UInt64 { - get { UInt64(UserDefaults.standard.integer(forKey: "\(keyPrefix)_balance")) } - set { UserDefaults.standard.set(Int(newValue), forKey: "\(keyPrefix)_balance") } - } - - /// Persisted orchard address string. - private var persistedOrchardAddress: String? { - get { UserDefaults.standard.string(forKey: "\(keyPrefix)_orchardAddress") } - set { UserDefaults.standard.set(newValue, forKey: "\(keyPrefix)_orchardAddress") } - } - - /// UserDefaults key prefix scoped to network. - private var keyPrefix: String { - "zkSync_\(networkName)" - } - - private var networkName: String = "testnet" - - // MARK: - Lifecycle - - /// Initialize for a network. Restores persisted balance and address. - /// The actual periodic loop is managed by UnifiedAppState. - func startPeriodicSync(network: Network) { - networkName = network.networkName - - // Restore persisted state from previous session - let savedBalance = persistedBalance - if savedBalance > 0 { - shieldedBalance = savedBalance - } - - let savedAddress = persistedOrchardAddress - if let addr = savedAddress, !addr.isEmpty { - orchardAddress = addr - } - } - - /// Perform a single ZK shielded sync (notes then nullifiers). - /// - /// - Parameters: - /// - sdk: The initialized SDK instance. - /// - shieldedService: The shielded service with an initialized pool client. - func performSync(sdk: SDK, shieldedService: ShieldedService) async { - guard !isSyncing else { return } - guard let poolClient = shieldedService.poolClient else { return } - - isSyncing = true - lastError = nil - - do { - // Step 1: Sync notes - let notesResult = try await poolClient.syncNotes(sdk: sdk) - let newNotes = notesResult.newNotes - - // Step 2: Sync nullifiers - let nullifiersResult = try await poolClient.syncNullifiers(sdk: sdk) - let spentCount = nullifiersResult.spentCount - let finalBalance = nullifiersResult.balance - - // Update per-sync stats - notesSynced = newNotes - nullifiersSpent = spentCount - - // Update cumulative stats - totalNotesSynced += newNotes - totalNullifiersSpent += spentCount - - // Update balance and address - shieldedBalance = finalBalance - orchardAddress = shieldedService.orchardDisplayAddress - - // Persist balance and address - persistedBalance = finalBalance - persistedOrchardAddress = shieldedService.orchardDisplayAddress - - // Update sync metadata - lastSyncTime = Date() - syncCountSinceLaunch += 1 - - SDKLogger.log( - "ZK sync complete: \(newNotes) notes, \(spentCount) spent, balance: \(finalBalance)", - minimumLevel: .medium - ) - - } catch { - lastError = error.localizedDescription - SDKLogger.log( - "ZK sync error: \(error.localizedDescription)", - minimumLevel: .medium - ) - } - - isSyncing = false - } - - /// Reset all state (e.g. on wallet deletion or network switch). - func reset() { - isSyncing = false - lastSyncTime = nil - shieldedBalance = 0 - orchardAddress = nil - notesSynced = 0 - nullifiersSpent = 0 - totalNotesSynced = 0 - totalNullifiersSpent = 0 - syncCountSinceLaunch = 0 - lastError = nil - - // Clear persisted state - persistedBalance = 0 - persistedOrchardAddress = nil - } -} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift index 6a23fbd8c8f..d07dc7a7d06 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -184,133 +184,25 @@ class SendViewModel: ObservableObject { ) successMessage = "Payment sent" - case .platformToShielded: - _ = platformState // quiet unused-param warnings - guard let poolClient = shieldedService.poolClient else { - error = "Shielded pool not initialized" - return - } - let bundle = try await poolClient.buildShieldBundle(amount: amount) - - // Fetch a PersistentIdentity on this wallet/network - // that has enough platform balance to cover `amount`. - // `balance` is stored as Int64 (bit-pattern cast of - // the UInt64 DPP credits), so we compare against the - // same bit-pattern cast of the requested amount. - let walletId = wallet.walletId - // Identities are scoped to a network; match the - // wallet's resolved network directly. The `?? .testnet` - // keeps the predicate well-formed when the wallet row - // hasn't had its network stamped yet — a wallet in - // that state has no identities to find anyway. - // - // Filter against `networkRaw` (the UInt32-backed shadow - // field) because Foundation's predicate engine can't - // capture `Network`. - let walletNetworkRaw = (wallet.network ?? .testnet).rawValue - let amountThreshold = Int64(bitPattern: amount) - let descriptor = FetchDescriptor( - predicate: #Predicate { identity in - identity.wallet?.walletId == walletId && - identity.networkRaw == walletNetworkRaw && - identity.balance >= amountThreshold - } - ) - guard let identity = try? modelContext.fetch(descriptor).first else { - error = "No identity with sufficient platform balance" - return - } - - // Pick the first public key that has an associated - // private key in the keychain. Private keys no - // longer live on the identity row. - guard let privateKey = identity.publicKeys.lazy - .compactMap({ key -> Data? in - KeychainManager.shared.retrievePrivateKey( - identityId: identity.identityId, - keyIndex: key.keyId - ) - }) - .first else { - error = "No private key available for identity" - return - } - - let addressBytes = identity.identityId.prefix(21) - let input = ShieldFundsInput( - address: Data(addressBytes), - amount: amount, - privateKey: privateKey - ) - try await sdk.shieldFunds( - inputs: [input], - bundle: bundle, - amount: amount, - feeFromInputIndex: 0 - ) - successMessage = "Shielding complete" - - case .shieldedToShielded: - guard let poolClient = shieldedService.poolClient else { - error = "Shielded pool not initialized" - return - } - let parsed = DashAddress.parse(recipientAddress, network: network) - guard case .orchard(let rawAddress) = parsed.type else { return } - let bundle = try await poolClient.buildTransferBundle( - recipientAddress: rawAddress, - amount: amount - ) - try await sdk.shieldedTransfer( - bundle: bundle, - valueBalance: flow.estimatedFee - ) - successMessage = "Shielded transfer complete" - - case .shieldedToPlatform: - guard let poolClient = shieldedService.poolClient else { - error = "Shielded pool not initialized" - return - } - let parsed = DashAddress.parse(recipientAddress, network: network) - guard case .platform(let addressBytes) = parsed.type else { return } - let bundle = try await poolClient.buildUnshieldBundle( - outputAddress: addressBytes, - amount: amount - ) - try await sdk.unshieldFunds( - outputAddress: addressBytes, - amount: amount, - bundle: bundle - ) - successMessage = "Unshield complete" - - case .shieldedToCore: - guard let poolClient = shieldedService.poolClient else { - error = "Shielded pool not initialized" - return - } - let parsed = DashAddress.parse(recipientAddress, network: network) - guard case .core(let outputScript) = parsed.type else { return } - let bundle = try await poolClient.buildWithdrawalBundle( - outputScript: outputScript, - amount: amount, - coreFeePerByte: 1, - pooling: .never - ) - try await sdk.shieldedWithdraw( - amount: amount, - bundle: bundle, - coreFeePerByte: 1, - pooling: .never, - outputScript: outputScript - ) - successMessage = "Withdrawal submitted" + 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. + _ = platformState + _ = shieldedService + _ = wallet + _ = modelContext + _ = sdk + error = "Shielded sending is being rebuilt — see follow-up PR" + return } - // Refresh balances - shieldedService.refreshBalance() - } catch { self.error = error.localizedDescription } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index c49b1fbb766..846ca6181ee 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -130,6 +130,26 @@ var body: some View { value: filterHeightsDisplay ) + // Block time of the SPV chain tip — a stale + // value across polls means core stopped + // producing blocks even though our SPV client + // is healthy. Hidden until the first tip + // header is stored. + if let tipTime = walletManager.spvTipBlockTime { + HStack { + Text("Block Time") + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + Text("\(tipTime, style: .relative) ago") + .font(.caption) + .foregroundColor(.secondary) + Text(AppDate.formatted(tipTime, dateStyle: .omitted, timeStyle: .shortened)) + .font(.caption) + .foregroundColor(.secondary) + } + } + // Controls row HStack(spacing: 8) { Spacer() @@ -360,11 +380,25 @@ var body: some View { Text("Syncing...") .font(.subheadline) .foregroundColor(.secondary) + } else if let lastSync = shieldedService.lastSyncTime { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + .font(.caption) + Text("Last sync: \(lastSync, style: .relative)") + .font(.caption) + .foregroundColor(.secondary) + } else if !shieldedService.isBound { + Image(systemName: "shield.slash") + .foregroundColor(.secondary) + .font(.caption) + Text("Not bound") + .font(.subheadline) + .foregroundColor(.secondary) } else { - Image(systemName: "shield.checkered") - .foregroundColor(.purple) + Image(systemName: "circle.dashed") + .foregroundColor(.secondary) .font(.caption) - Text("Ready") + Text("Not synced yet") .font(.subheadline) .foregroundColor(.secondary) } @@ -402,6 +436,42 @@ var body: some View { } } + // Sync counters since launch — `total_scanned` + // is the wire-level encrypted-note count (every + // pass), while new + spent are the wallet-side + // outcomes (only ours). + if shieldedService.syncCountSinceLaunch > 0 { + let svc = shieldedService + VStack(spacing: 4) { + HStack { + Text("Queries Since Launch") + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + Text("\(svc.syncCountSinceLaunch) syncs") + .font(.caption) + .foregroundColor(.secondary) + } + HStack(spacing: 12) { + QueryCountBadge( + label: "Scanned", + count: UInt32(min(svc.totalScanned, UInt64(UInt32.max))), + color: .blue + ) + QueryCountBadge( + label: "New", + count: UInt32(min(svc.totalNewNotes, UInt64(UInt32.max))), + color: .purple + ) + QueryCountBadge( + label: "Spent", + count: UInt32(min(svc.totalNewlySpent, UInt64(UInt32.max))), + color: .orange + ) + } + } + } + // Error display if let error = shieldedService.lastError { Text(error) @@ -415,11 +485,7 @@ var body: some View { Spacer() Button { - Task { - if let sdk = platformState.sdk { - await shieldedService.fullSync(sdk: sdk) - } - } + Task { await shieldedService.manualSync() } } label: { HStack(spacing: 4) { Image(systemName: "arrow.clockwise") diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index 69caa96bef1..05c619dd754 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -53,6 +53,13 @@ struct SwiftExampleAppApp: App { @State private var bootstrapError: Error? @State private var bootstrapTask: Task? + /// Resolver that backs the platform-wallet-ffi `MnemonicResolverHandle` + /// for shielded wallet binding. Reuses the default `WalletStorage` + /// keychain access — same shape as the identity-key signing path. + /// Held for the lifetime of the App so the underlying handle is + /// valid across every `bind_shielded` call. + private let shieldedResolver = MnemonicResolver() + init() { // Suppress auto layout constraint warnings in debug builds // These are typically harmless keyboard-related warnings @@ -180,12 +187,14 @@ struct SwiftExampleAppApp: App { guard let wallet else { do { try walletManager.stopPlatformAddressSync() + try walletManager.stopShieldedSync() } catch { SDKLogger.error( - "Failed to stop platform address sync: \(error.localizedDescription)" + "Failed to stop sync coordinators: \(error.localizedDescription)" ) } platformBalanceSyncService.reset() + shieldedService.reset() return } do { @@ -203,9 +212,24 @@ struct SwiftExampleAppApp: App { "🔗 BLAST sync running; balance-sync UI bound to wallet \(wallet.walletId.prefix(4).map { String(format: "%02x", $0) }.joined())… on \(platformState.currentNetwork.displayName) (of \(walletManager.wallets.count) loaded)", minimumLevel: .medium ) + + // Bind the shielded service against the same wallet. + // The bind is best-effort — failures (no mnemonic in + // keychain, biometric prompt declined, etc.) leave the + // service in a "not bound" state and the user can + // retry from the Sync Status surface. + shieldedService.bind( + walletManager: walletManager, + walletId: wallet.walletId, + network: platformState.currentNetwork, + resolver: shieldedResolver + ) + if try !walletManager.isShieldedSyncRunning() { + try walletManager.startShieldedSync() + } } catch { SDKLogger.error( - "Failed to bind platform address wallet: \(error.localizedDescription)" + "Failed to bind wallet-scoped services: \(error.localizedDescription)" ) } } @@ -240,8 +264,6 @@ struct SwiftExampleAppApp: App { ) } - // Initialize shielded pool using first available wallet's data. - initializeShieldedService() rebindWalletScopedServices() } @@ -269,16 +291,4 @@ struct SwiftExampleAppApp: App { } return ["127.0.0.1"] } - - /// Initialize the shielded pool client. Best-effort — does nothing if no - /// wallet is available yet. - private func initializeShieldedService() { - // TODO(platform-wallet): Derive a ZIP32 spending key from - // the managed wallet. The legacy code path reused the - // seed bytes stashed on the (now-deleted) HDWallet row; - // the seed now lives only in the keychain, so a fresh - // derivation path is needed. For now the shielded - // service starts empty; it will be re-initialized once - // the user creates/loads a wallet via `createWallet(...)`. - } } diff --git a/packages/swift-sdk/build_ios.sh b/packages/swift-sdk/build_ios.sh index 5e64d324f4c..cb15755e563 100755 --- a/packages/swift-sdk/build_ios.sh +++ b/packages/swift-sdk/build_ios.sh @@ -160,11 +160,18 @@ EOF done } +# Shielded (Orchard / ZK) support is compiled in by default. The +# `shielded` Cargo feature is opt-in at the crate level so non-iOS +# consumers don't pay for the heavy crypto deps, but the iOS +# framework ships everything — keep `--features shielded` here so +# the bundled SDK exposes the platform-wallet shielded FFI. +CARGO_FEATURES="shielded" + # iOS device if $BUILD_IOS; then IOS_TARGET="aarch64-apple-ios" log_info "Building iOS device ($IOS_TARGET)..." - cargo build -p "$PACKAGE" --profile "$PROFILE" --target "$IOS_TARGET" + cargo build -p "$PACKAGE" --profile "$PROFILE" --target "$IOS_TARGET" --features "$CARGO_FEATURES" IOS_LIB="$TARGET_DIR/$IOS_TARGET/$OUTPUT_DIR/librs_unified_sdk_ffi.a" IOS_HEADERS="$TARGET_DIR/$IOS_TARGET/$OUTPUT_DIR/include" inject_modulemap "$IOS_HEADERS" @@ -174,7 +181,7 @@ fi if $BUILD_SIM; then SIM_TARGET="aarch64-apple-ios-sim" log_info "Building iOS simulator ($SIM_TARGET)..." - cargo build -p "$PACKAGE" --profile "$PROFILE" --target "$SIM_TARGET" + cargo build -p "$PACKAGE" --profile "$PROFILE" --target "$SIM_TARGET" --features "$CARGO_FEATURES" SIM_LIB="$TARGET_DIR/$SIM_TARGET/$OUTPUT_DIR/librs_unified_sdk_ffi.a" SIM_HEADERS="$TARGET_DIR/$SIM_TARGET/$OUTPUT_DIR/include" inject_modulemap "$SIM_HEADERS" @@ -184,7 +191,7 @@ fi if $BUILD_MAC; then MAC_TARGET="aarch64-apple-darwin" log_info "Building macOS ($MAC_TARGET)..." - cargo build -p "$PACKAGE" --profile "$PROFILE" --target "$MAC_TARGET" + cargo build -p "$PACKAGE" --profile "$PROFILE" --target "$MAC_TARGET" --features "$CARGO_FEATURES" MAC_LIB="$TARGET_DIR/$MAC_TARGET/$OUTPUT_DIR/librs_unified_sdk_ffi.a" MAC_HEADERS="$TARGET_DIR/$MAC_TARGET/$OUTPUT_DIR/include" inject_modulemap "$MAC_HEADERS"