From 6fd420cac249b2592f2f0d96da86ad750c7d4afe Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 5 May 2026 23:20:32 +0700 Subject: [PATCH 01/12] chore(swift-sdk,platform-wallet): rip duplicated Orchard FFI surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shielded path was implemented twice: a feature-gated ShieldedWallet in platform-wallet (correct location, but never wired) and an opaque ShieldedPoolClient duplicated in rs-sdk-ffi (the one Swift actually drove). Per the swift-sdk architectural rule, all shielded orchestration belongs on the platform-wallet side and Swift should only persist/load/bridge. Demolish the duplicate so the next PR can wire the platform-wallet ShieldedWallet through a thin FFI translator instead: - Delete rs-sdk-ffi/src/shielded/ (pool_client, transitions, queries, crypto, types). Drop "shielded" from rs-sdk-ffi's dash-sdk feature list. - Delete rs-sdk-ffi/src/nullifier_sync/ (only consumer was the deleted Swift NullifierSyncService). - Delete swift-sdk/Sources/SwiftDashSDK/Shielded/ wholesale. - Delete unused SwiftExampleApp ZKSyncService (no consumers). - Stub ShieldedService to display state only and gate Send shielded flows with a clear placeholder error; the Rust-side coordinator and external-signable signer plumbing land in a follow-up. Drop the spending_key field from OrchardKeySet — it was only used to derive FVK/ASK at construction and was retained dead. The SK is now strictly a transient local in `from_seed`. Also drop the #[allow(dead_code)] on ShieldedWallet itself plus the four unused crate-private accessor methods that hung on for "future use"; the manager wiring will reintroduce what it needs. No new behavior. Send shielded is broken until the Rust-side coordinator + OrchardSpendSigner ship; sync was already broken on launch (initializeShieldedService was a TODO stub). --- .../src/wallet/shielded/keys.rs | 20 +- .../src/wallet/shielded/mod.rs | 41 +- packages/rs-sdk-ffi/Cargo.toml | 1 - packages/rs-sdk-ffi/src/lib.rs | 4 - packages/rs-sdk-ffi/src/nullifier_sync/mod.rs | 327 ----- .../rs-sdk-ffi/src/nullifier_sync/types.rs | 87 -- .../rs-sdk-ffi/src/shielded/crypto/address.rs | 67 - .../src/shielded/crypto/bundle_build.rs | 1051 --------------- .../rs-sdk-ffi/src/shielded/crypto/decrypt.rs | 224 ---- .../rs-sdk-ffi/src/shielded/crypto/mod.rs | 50 - packages/rs-sdk-ffi/src/shielded/mod.rs | 39 - .../src/shielded/pool_client/bundle.rs | 713 ----------- .../src/shielded/pool_client/mod.rs | 281 ---- .../src/shielded/pool_client/sync.rs | 307 ----- .../src/shielded/queries/anchors.rs | 72 -- .../src/shielded/queries/encrypted_notes.rs | 98 -- .../rs-sdk-ffi/src/shielded/queries/mod.rs | 13 - .../shielded/queries/most_recent_anchor.rs | 73 -- .../src/shielded/queries/nullifiers.rs | 126 -- .../src/shielded/queries/pool_state.rs | 65 - .../src/shielded/transitions/broadcast.rs | 70 - .../src/shielded/transitions/builders.rs | 558 -------- .../src/shielded/transitions/mod.rs | 28 - .../src/shielded/transitions/shield.rs | 179 --- .../transitions/shield_from_asset_lock.rs | 169 --- .../shielded/transitions/shielded_transfer.rs | 64 - .../transitions/shielded_withdrawal.rs | 103 -- .../src/shielded/transitions/unshield.rs | 86 -- packages/rs-sdk-ffi/src/shielded/types.rs | 92 -- .../Shielded/NullifierSyncService.swift | 244 ---- .../Shielded/NullifierSyncTypes.swift | 154 --- .../Shielded/ShieldedCryptoService.swift | 690 ---------- .../Shielded/ShieldedCryptoTypes.swift | 272 ---- .../Shielded/ShieldedPoolClient.swift | 516 -------- .../Shielded/ShieldedPoolService.swift | 1134 ----------------- .../SwiftDashSDK/Shielded/ShieldedTypes.swift | 269 ---- .../Core/Services/ShieldedService.swift | 103 +- .../Core/Services/ZKSyncService.swift | 164 --- .../Core/ViewModels/SendViewModel.swift | 142 +-- .../Core/Views/CoreContentView.swift | 6 +- 40 files changed, 47 insertions(+), 8655 deletions(-) delete mode 100644 packages/rs-sdk-ffi/src/nullifier_sync/mod.rs delete mode 100644 packages/rs-sdk-ffi/src/nullifier_sync/types.rs delete mode 100644 packages/rs-sdk-ffi/src/shielded/crypto/address.rs delete mode 100644 packages/rs-sdk-ffi/src/shielded/crypto/bundle_build.rs delete mode 100644 packages/rs-sdk-ffi/src/shielded/crypto/decrypt.rs delete mode 100644 packages/rs-sdk-ffi/src/shielded/crypto/mod.rs delete mode 100644 packages/rs-sdk-ffi/src/shielded/mod.rs delete mode 100644 packages/rs-sdk-ffi/src/shielded/pool_client/bundle.rs delete mode 100644 packages/rs-sdk-ffi/src/shielded/pool_client/mod.rs delete mode 100644 packages/rs-sdk-ffi/src/shielded/pool_client/sync.rs delete mode 100644 packages/rs-sdk-ffi/src/shielded/queries/anchors.rs delete mode 100644 packages/rs-sdk-ffi/src/shielded/queries/encrypted_notes.rs delete mode 100644 packages/rs-sdk-ffi/src/shielded/queries/mod.rs delete mode 100644 packages/rs-sdk-ffi/src/shielded/queries/most_recent_anchor.rs delete mode 100644 packages/rs-sdk-ffi/src/shielded/queries/nullifiers.rs delete mode 100644 packages/rs-sdk-ffi/src/shielded/queries/pool_state.rs delete mode 100644 packages/rs-sdk-ffi/src/shielded/transitions/broadcast.rs delete mode 100644 packages/rs-sdk-ffi/src/shielded/transitions/builders.rs delete mode 100644 packages/rs-sdk-ffi/src/shielded/transitions/mod.rs delete mode 100644 packages/rs-sdk-ffi/src/shielded/transitions/shield.rs delete mode 100644 packages/rs-sdk-ffi/src/shielded/transitions/shield_from_asset_lock.rs delete mode 100644 packages/rs-sdk-ffi/src/shielded/transitions/shielded_transfer.rs delete mode 100644 packages/rs-sdk-ffi/src/shielded/transitions/shielded_withdrawal.rs delete mode 100644 packages/rs-sdk-ffi/src/shielded/transitions/unshield.rs delete mode 100644 packages/rs-sdk-ffi/src/shielded/types.rs delete mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Shielded/NullifierSyncService.swift delete mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Shielded/NullifierSyncTypes.swift delete mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Shielded/ShieldedCryptoService.swift delete mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Shielded/ShieldedCryptoTypes.swift delete mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Shielded/ShieldedPoolClient.swift delete mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Shielded/ShieldedPoolService.swift delete mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Shielded/ShieldedTypes.swift delete mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ZKSyncService.swift 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..b3f889e3955 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs @@ -28,7 +28,6 @@ pub use store::{InMemoryShieldedStore, ShieldedNote, ShieldedStore}; use std::sync::Arc; -use dashcore::Network; use tokio::sync::RwLock; use crate::error::PlatformWalletError; @@ -46,8 +45,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 +52,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 +77,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 +106,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..06302c92357 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 = [ 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/swift-sdk/Sources/SwiftDashSDK/Shielded/NullifierSyncService.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Shielded/NullifierSyncService.swift deleted file mode 100644 index 040cd4953f5..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Shielded/NullifierSyncService.swift +++ /dev/null @@ -1,244 +0,0 @@ -// NullifierSyncService.swift -// SwiftDashSDK -// -// High-level Swift API for privacy-preserving nullifier BLAST sync. -// Discovers which nullifiers have been spent in the shielded pool using -// trunk/branch chunk queries to hide the specific nullifiers being checked. - -import Foundation -import DashSDKFFI - -// MARK: - Nullifier Sync Service - -@MainActor -extension SDK { - - /// 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: - /// - nullifiers: Array of 32-byte nullifier hashes to check. - /// - config: Sync configuration. Pass nil to use default values. - /// - lastSyncHeight: Height from previous sync (0 for full scan). - /// - lastSyncTimestamp: Timestamp from previous sync (0 for full scan). - /// - Returns: `NullifierSyncResult` containing found/absent nullifiers and sync checkpoint. - /// - Throws: `SDKError` on failure. - public func syncNullifiers( - 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") - } - - // 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 - } - - let sk = seed.prefix(32) - self.spendingKey = Data(sk) - self.currentNetwork = network - - let dbPath = Self.dbPath(for: network) - - 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 - } - } - - /// Sync notes from the network. - func syncNotes(sdk: SDK) async throws { - guard let client = poolClient else { - throw SDKError.invalidState("Shielded pool not initialized") - } - - isSyncing = true - defer { isSyncing = false } - - let result = try await client.syncNotes(sdk: sdk) - shieldedBalance = result.balance - } - - /// Sync nullifiers (mark spent notes). - func syncNullifiers(sdk: SDK) async throws { - guard let client = poolClient else { - throw SDKError.invalidState("Shielded pool not initialized") - } - - isSyncing = true - defer { isSyncing = false } - - let result = try await client.syncNullifiers(sdk: sdk) - shieldedBalance = result.balance - } - - /// Full sync: notes then nullifiers. - func fullSync(sdk: SDK) async { - do { - try await syncNotes(sdk: sdk) - try await syncNullifiers(sdk: sdk) - lastError = nil - } catch { - lastError = "Sync error: \(error.localizedDescription)" - } - } - - /// Refresh balance from the local database (no network call). - func refreshBalance() { - guard let client = poolClient else { return } - do { - shieldedBalance = try client.balance - } catch { - lastError = "Balance refresh error: \(error.localizedDescription)" - } + /// Placeholder until the platform-wallet shielded sync coordinator + /// is wired up. Sets `lastError` so the UI can surface the state. + func manualSync() { + lastError = "Shielded sync is being rebuilt — see follow-up PR" } /// Reset state (e.g., on wallet deletion or logout). func reset() { - poolClient = nil - spendingKey = nil shieldedBalance = 0 orchardDisplayAddress = nil isSyncing = false lastError = nil } - - // MARK: - Private - - 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 - } } 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..f51d92bd357 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -415,11 +415,7 @@ var body: some View { Spacer() Button { - Task { - if let sdk = platformState.sdk { - await shieldedService.fullSync(sdk: sdk) - } - } + shieldedService.manualSync() } label: { HStack(spacing: 4) { Image(systemName: "arrow.clockwise") From 9f38d8443b9f50de419edca731116066ac069987 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 6 May 2026 00:56:47 +0700 Subject: [PATCH 02/12] feat(platform-wallet,swift-sdk): Rust-side shielded sync coordinator + FFI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the platform-wallet shielded sync end-to-end on the Rust side so the next PR can collapse Swift to a thin display-state surface. All new code is feature-gated behind the existing `shielded` Cargo feature; default builds are unaffected. Wallet - `FileBackedShieldedStore` — concrete `ShieldedStore` impl that wraps `ClientPersistentCommitmentTree` (SQLite, shared per network) for the commitment tree plus an in-memory note vec. Notes are rediscovered via trial decryption on cold start, matching the previous `ShieldedPoolClient` behavior. - `PlatformWallet.shielded` slot + `bind_shielded`/`shielded_sync` methods. `bind_shielded` takes a 32-252 byte BIP-39 seed, derives the FVK / IVK / OVK / default address via ZIP-32, opens the per-network commitment tree, and stores a `ShieldedWallet` on the wallet handle. The seed is consumed and dropped before return; nothing secret survives. Manager - `ShieldedSyncManager` — periodic shielded note + nullifier sync coordinator that mirrors `PlatformAddressSyncManager`. Iterates every wallet with a bound shielded sub-wallet on a 60s default cadence. Wallets without `bind_shielded` having run are emitted as `WalletShieldedOutcome::Skipped` rather than erroring the whole pass — bind is the host's responsibility (it requires keychain access via the mnemonic resolver). - Wired into `PlatformWalletManager::new` and `shutdown`. Accessor methods on the manager mirror `platform_address_sync()` / `platform_address_sync_arc()`. - New `on_shielded_sync_completed(&summary)` event on `PlatformEventHandler` (default no-op) plus the dispatcher method on `PlatformEventManager`. FFI - `platform_wallet_manager_shielded_sync_{start,stop,is_running, is_syncing,last_sync_unix_seconds,set_interval,sync_now, sync_wallet}` — same shape as `platform_address_sync_*`. - `platform_wallet_manager_bind_shielded(handle, wallet_id_bytes, mnemonic_resolver_handle, account, db_path_cstr)` — fires the resolver to fetch the host's mnemonic, derives a 64-byte BIP-39 seed in `Zeroizing`, and forwards into `PlatformWallet::bind_shielded`. Mnemonic / seed are scrubbed before return. - `ShieldedSyncWalletResultFFI` — tri-state per-wallet result (success / skipped / error) carrying note + balance counters. Defined unconditionally so `EventHandlerCallbacks` keeps a stable C struct layout across feature toggles; the `on_shielded_sync_completed_fn` callback slot is populated only when the `shielded` feature compiles in the dispatcher. - `rs-platform-wallet-ffi/shielded` and `rs-unified-sdk-ffi/shielded` features added (off by default). The iOS build still ships transparent-only by default. PR 3 will flip on `--features shielded` in `build_ios.sh` and replace the SwiftExampleApp `ShieldedService` placeholder with a thin wrapper around the new manager-handle-based sync surface. --- packages/rs-platform-wallet-ffi/Cargo.toml | 5 + .../src/event_handler.rs | 67 ++++ packages/rs-platform-wallet-ffi/src/lib.rs | 6 + .../src/shielded_sync.rs | 334 ++++++++++++++++++ .../src/shielded_types.rs | 84 +++++ packages/rs-platform-wallet/src/events.rs | 25 ++ .../src/manager/accessors.rs | 16 + .../rs-platform-wallet/src/manager/mod.rs | 20 ++ .../src/manager/shielded_sync.rs | 319 +++++++++++++++++ .../src/wallet/platform_wallet.rs | 77 ++++ .../src/wallet/shielded/file_store.rs | 177 ++++++++++ .../src/wallet/shielded/mod.rs | 3 + packages/rs-unified-sdk-ffi/Cargo.toml | 9 +- 13 files changed, 1141 insertions(+), 1 deletion(-) create mode 100644 packages/rs-platform-wallet-ffi/src/shielded_sync.rs create mode 100644 packages/rs-platform-wallet-ffi/src/shielded_types.rs create mode 100644 packages/rs-platform-wallet/src/manager/shielded_sync.rs create mode 100644 packages/rs-platform-wallet/src/wallet/shielded/file_store.rs 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..268b2fa65f2 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs @@ -0,0 +1,334 @@ +//! 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() +} + +// --------------------------------------------------------------------------- +// 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/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..3e58f0ae5d3 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; 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..40d62932f3f --- /dev/null +++ b/packages/rs-platform-wallet/src/manager/shielded_sync.rs @@ -0,0 +1,319 @@ +//! 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>, + 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), + 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()); + 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, + } + } + + 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. Does not set the global + /// `is_syncing` flag — callers that care about exclusion should + /// gate on [`is_syncing`] themselves. + /// + /// Returns `Ok(None)` if the wallet has no bound shielded + /// sub-wallet. + 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)) + })?; + + wallet.shielded_sync().await + } +} + +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/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 76610b8bca2..f89acc1cc95 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,6 +286,66 @@ 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), } } } @@ -394,6 +469,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..aa12ee76d8d --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -0,0 +1,177 @@ +//! 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> { + 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/mod.rs b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs index b3f889e3955..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,9 +23,11 @@ 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; 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 From 227a1bf246d4d648b3b54dccfef1db6830e4499d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 6 May 2026 01:06:11 +0700 Subject: [PATCH 03/12] feat(swift-sdk,swift-example-app): drive shielded sync from Rust manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the Swift side onto the platform-wallet shielded coordinator landed in the previous commit. The iOS framework now ships with the `shielded` Cargo feature enabled and the SwiftExampleApp drives shielded balance sync end-to-end through the Rust manager handle — no Swift-side ShieldedPoolClient, no spending key marshaled across the FFI, no separate iOS-owned periodic sync loop. swift-sdk - New PlatformWalletManagerShieldedSync.swift mirroring the PlatformAddressSync wrapper: bindShielded(walletId:resolver: account:dbPath:), startShieldedSync, stopShieldedSync, isShieldedSyncRunning/isSyncing, lastShieldedSyncUnixSeconds, setShieldedSyncInterval, syncShieldedNow, syncShieldedWalletNow. ShieldedSyncEvent + per-wallet ShieldedWalletSyncResult published via the manager's @Published lastShieldedSyncEvent and shieldedSyncIsSyncing. - Manager event-handler callbacks wire the new on_shielded_sync_completed_fn slot. Progress polling now mirrors shielded sync state alongside platform-address sync state. - bind_ios.sh passes --features shielded to all three target builds so the unified xcframework exposes the shielded FFI symbols. swift-example-app - ShieldedService is now a real consumer of the manager: bind() drives bind_shielded via the existing keychain-backed MnemonicResolver, then subscribes to the manager's published shielded sync events. Per-wallet result decoding distinguishes success / skipped (not yet bound) / error states. manualSync() forwards to syncShieldedNow. - App.bootstrap retires the old initializeShieldedService stub. rebindWalletScopedServices binds the shielded service alongside PlatformBalanceSyncService and starts the shielded sync loop whenever a wallet is active. CoreContentView's "Sync Now" button invokes manualSync as an async Task. Sends are still gated behind the placeholder rebuild banner — the spend-signer pipeline lands in a follow-up. --- .../PlatformWalletManager.swift | 12 + .../PlatformWalletManagerAddressSync.swift | 1 + .../PlatformWalletManagerShieldedSync.swift | 243 ++++++++++++++++++ .../Core/Services/ShieldedService.swift | 194 ++++++++++++-- .../Core/Views/CoreContentView.swift | 2 +- .../SwiftExampleApp/SwiftExampleAppApp.swift | 42 +-- packages/swift-sdk/build_ios.sh | 13 +- 7 files changed, 472 insertions(+), 35 deletions(-) create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 59c7067e5ee..7b7f529431d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -32,6 +32,13 @@ public class PlatformWalletManager: ObservableObject { /// 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 +83,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 +491,10 @@ public class PlatformWalletManager: ObservableObject { isSyncing != self.platformAddressSyncIsSyncing { self.platformAddressSyncIsSyncing = isSyncing } + if let isSyncing = try? self.isShieldedSyncing(), + isSyncing != self.shieldedSyncIsSyncing { + self.shieldedSyncIsSyncing = isSyncing + } 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/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift new file mode 100644 index 00000000000..de0b1346375 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -0,0 +1,243 @@ +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 + } + + 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.. String { + 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/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index f51d92bd357..7f2c2b69389 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -415,7 +415,7 @@ var body: some View { Spacer() Button { - shieldedService.manualSync() + 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" From ed089af642561f51835754bab46af433b8e7258c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 6 May 2026 01:20:01 +0700 Subject: [PATCH 04/12] fix(swift-example-app): clear shielded isSyncing after fast manual sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit manualSync set isSyncing=true to gate the button and disable re-entry, but only reset it to false on error. On success it relied on the manager's 1 Hz \$shieldedSyncIsSyncing poll to flip the local state back via the Combine subscription. Empty-tree shielded syncs complete in ~25 ms (well under the 1 s poll cadence), so the manager's atomic is_syncing flag goes true→false inside one tick — the polled @Published value never changes, the subscription never fires, and ShieldedService.isSyncing stays at true forever. UI shows "Syncing..." indefinitely with the Sync Now button disabled. Wrap the manualSync body with `defer { isSyncing = false }` so the local flag always relaxes on the way out, and the subscription remains the source of truth for any sync that does straddle a poll tick. --- .../SwiftExampleApp/Core/Services/ShieldedService.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift index f35cade049e..f7f47de6a3f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -117,6 +117,13 @@ class ShieldedService: ObservableObject { /// 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 { @@ -126,10 +133,10 @@ class ShieldedService: ObservableObject { isSyncing = true lastError = nil + defer { isSyncing = false } do { try await walletManager.syncShieldedNow() } catch { - isSyncing = false lastError = "Shielded sync error: \(error.localizedDescription)" SDKLogger.log(lastError ?? "", minimumLevel: .medium) } From b475b8028732e969cde21559f99cbee1b04b4822 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 6 May 2026 01:24:19 +0700 Subject: [PATCH 05/12] fix(swift-example-app): clear platform-address isSyncing after fast manual sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same shape as the shielded fix in the previous commit: performSync set isSyncing=true to gate re-entry but only reset it on error, relying on the manager's 1 Hz \$platformAddressSyncIsSyncing poll to flip it back through the Combine subscription. Empty-pool regtest BLAST syncs return inside tens of milliseconds — well under the 1 s poll cadence — so the polled @Published value never observes is_syncing=true, the subscription never fires, and the local flag stays at true forever after a manual Sync Now tap. Background loop syncs aren't affected because they don't go through performSync; the bug only surfaces for the manual path. Wrap the body with `defer { isSyncing = false }` so the local flag always relaxes regardless of outcome, and the subscription remains the source of truth for any sync that does straddle a poll tick. --- .../Core/Services/PlatformBalanceSyncService.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/PlatformBalanceSyncService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/PlatformBalanceSyncService.swift index 1234d0aa536..c7211c5f37f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/PlatformBalanceSyncService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/PlatformBalanceSyncService.swift @@ -197,6 +197,14 @@ class PlatformBalanceSyncService: ObservableObject { // MARK: - Sync /// Perform the actual BLAST address sync via platform-wallet. + /// + /// Drives `isSyncing` directly around the await so the spinner + /// flashes even when the underlying Rust pass completes faster + /// than the manager's 1 Hz `isPlatformAddressSyncing` poll + /// cadence. Empty-pool regtest syncs return in tens of ms — the + /// polled `$platformAddressSyncIsSyncing` stays `false` for the + /// whole call and the subscription never fires, so we have to + /// reset locally regardless of outcome. func performSync() async { guard !isSyncing else { return } guard let walletManager = walletManager else { @@ -206,11 +214,11 @@ class PlatformBalanceSyncService: ObservableObject { isSyncing = true lastError = nil + defer { isSyncing = false } do { try await walletManager.syncPlatformAddressNow() } catch { - isSyncing = false lastError = error.localizedDescription SDKLogger.log( "BLAST sync error: \(error.localizedDescription)", From 3c4af176f21361fe79a61cecd1ede2043633b13f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 6 May 2026 01:41:05 +0700 Subject: [PATCH 06/12] feat(swift-example-app): show last-sync timer + per-launch counters on shielded card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shielded sync status panel only had a binary "Syncing..." vs "Ready" indicator and a stale balance row — once a pass completed there was no signal that anything had run, no count of how many passes had landed since launch, and no per-pass throughput. Mirror the Platform Sync Status surface's shape on the shielded side. ShieldedService now tracks: - syncCountSinceLaunch — successful (non-skipped) pass count - totalScanned — sum of every pass's total_scanned - totalNewNotes / totalNewlySpent — cumulative wallet-side outcomes CoreContentView's Shielded Sync Status section now renders: - "Last sync: " with the green check, or "Not bound" when bind_shielded hasn't run yet, or "Not synced yet" when the bind succeeded but no pass has landed. - "Queries Since Launch: N syncs" plus three QueryCountBadge columns (Scanned / New / Spent), gated on at least one successful pass having been observed. The skipped/error states already feed through ShieldedService's existing fields; this just rounds out the success-path surface. --- .../Core/Services/ShieldedService.swift | 22 ++++++++ .../Core/Views/CoreContentView.swift | 56 ++++++++++++++++++- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift index f7f47de6a3f..f630073c210 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -39,6 +39,20 @@ class ShieldedService: ObservableObject { /// Local clock timestamp of the last completed sync pass. @Published var lastSyncTime: Date? + /// Number of successful shielded sync passes observed since + /// launch (skipped passes don't count). + @Published var syncCountSinceLaunch: Int = 0 + + /// Cumulative encrypted notes scanned since launch — sum of + /// every pass's `total_scanned`. + @Published var totalScanned: UInt64 = 0 + + /// Cumulative decrypted notes accepted since launch. + @Published var totalNewNotes: UInt64 = 0 + + /// Cumulative notes newly detected as spent since launch. + @Published var totalNewlySpent: UInt64 = 0 + /// Last error from a shielded operation. Cleared on a successful /// pass. @Published var lastError: String? @@ -159,6 +173,10 @@ class ShieldedService: ObservableObject { lastSyncTime = nil lastError = nil orchardDisplayAddress = nil + syncCountSinceLaunch = 0 + totalScanned = 0 + totalNewNotes = 0 + totalNewlySpent = 0 } // MARK: - Sync event handling @@ -175,6 +193,10 @@ class ShieldedService: ObservableObject { 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 diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index 7f2c2b69389..890f0f2d284 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -360,11 +360,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 +416,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) From 9da78a80282b72c3e016b027f0fac567f371e71c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 6 May 2026 02:10:47 +0700 Subject: [PATCH 07/12] feat(swift-sdk,platform-wallet): expose Orchard default address for the bound wallet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Receive sheet's Shielded tab showed "Not available" because ShieldedService.orchardDisplayAddress was permanently nil — the manager had no way to surface the per-wallet Orchard payment address. Add a tiny accessor on the bound shielded sub-wallet: - `PlatformWallet::shielded_default_address() -> Option<[u8; 43]>` reads `ShieldedWallet`'s `default_address.to_raw_address_bytes()` under the existing read lock; returns None when bind_shielded hasn't run. - `platform_wallet_manager_shielded_default_address` FFI: takes manager handle + 32-byte wallet id, writes 43 raw bytes plus a presence flag. Unknown wallet → ErrorWalletOperation, known but unbound → out_present = false. - `PlatformWalletManager.shieldedDefaultAddress(walletId:) -> Data?` Swift wrapper. - `ShieldedService.bind` calls it post-bindShielded and runs the bytes through `DashAddress.encodeOrchard` (existing bech32m helper) to populate `orchardDisplayAddress`. Best-effort — failures here don't unbind the wallet. Receive Dash → Shielded now renders the wallet's default Orchard address as soon as the shielded bind completes, matching the Core / Platform tabs. --- .../src/shielded_sync.rs | 69 +++++++++++++++++++ .../src/wallet/platform_wallet.rs | 12 ++++ .../PlatformWalletManagerShieldedSync.swift | 43 ++++++++++++ .../Core/Services/ShieldedService.swift | 12 ++++ 4 files changed, 136 insertions(+) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs index 268b2fa65f2..31e9bd43140 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs @@ -295,6 +295,75 @@ pub unsafe extern "C" fn platform_wallet_manager_bind_shielded( 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 // --------------------------------------------------------------------------- diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index f89acc1cc95..dcd9486798e 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -348,6 +348,18 @@ impl PlatformWallet { 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 { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index de0b1346375..1b739d1145c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -186,6 +186,49 @@ extension PlatformWalletManager { }.value } + /// Read the default Orchard payment address for `walletId` as + /// the 43 raw bytes. Returns `nil` when the wallet exists on + /// the manager but has no bound shielded sub-wallet (i.e. + /// [`bindShielded`] hasn't run, or it failed). Throws when the + /// wallet id isn't known to the manager. + /// + /// 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( diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift index f630073c210..7815a5ea566 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -108,6 +108,18 @@ class ShieldedService: ObservableObject { ) 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 From f827e0ea1e7e743dbfd111ac5c5d0f48310f190d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 6 May 2026 03:21:32 +0700 Subject: [PATCH 08/12] feat(swift-sdk,platform-wallet): surface SPV chain-tip block time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Core Sync Status panel had no signal for "is the chain actually advancing?" — once SPV caught up to the tip and the four progress bars hit 100%, a stalled core (no new blocks) and a healthy core looked identical from the wallet's side. Plumb the tip header's block time through to the UI so a glance at the Core Sync card answers the question directly: - `SpvRuntime::tip_block_time() -> Option`: walks the running client → storage → block-headers store → `get_tip` and reads `header.time`. None when SPV isn't running or no headers are stored. - `platform_wallet_manager_spv_tip_unix_seconds(handle, *out_unix_seconds)`: FFI translator, writes 0 as the no-tip sentinel. - `PlatformWalletManager.currentSpvTipBlockTime() throws -> Date?` one-shot wrapper plus a `@Published var spvTipBlockTime: Date?` mirror fed by the existing 1 Hz progress poll. Distinct names to avoid the `@Published` / method shadowing that breaks `\.spvTipBlockTime` keypath access. - CoreContentView's Core Sync Status section renders a Block Time row with ` ago` plus the wall-clock time, hidden when no tip is available. Mirrors the existing Block Time row on the Platform Sync card. When the row stops advancing across polls, core has stalled producing blocks even though the local SPV client is healthy. --- packages/rs-platform-wallet-ffi/src/spv.rs | 25 +++++++++++++++++++ .../rs-platform-wallet/src/spv/runtime.rs | 24 ++++++++++++++++++ .../PlatformWalletManager.swift | 11 ++++++++ .../PlatformWalletManagerSPV.swift | 19 ++++++++++++++ .../Core/Views/CoreContentView.swift | 20 +++++++++++++++ 5 files changed, 99 insertions(+) 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/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/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 7b7f529431d..8f6cdc3dc0c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -26,6 +26,13 @@ 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 @@ -495,6 +502,10 @@ public class PlatformWalletManager: ObservableObject { 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/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/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index 890f0f2d284..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() From 825f0fe2e0303c3498a718a97c553b81d8fb48fd Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 6 May 2026 03:40:03 +0700 Subject: [PATCH 09/12] fix(platform-wallet): clean up clippy warnings in manager/accessors.rs CI clippy gate (`cargo clippy --workspace --all-features --locked -- --no-deps -D warnings`) tripped after the new `shielded` feature on `rs-platform-wallet-ffi` / `rs-unified-sdk-ffi` made `--all-features` reach paths that were previously gated behind disabled feature combos. Three latent lints surfaced: - `manual_unwrap_or_default` on `try_queue_depth()` match - `unnecessary_cast` (`*reg_idx as u32` where `reg_idx` is u32) - `redundant_closure` (`.map(|info| addr_info_snapshot(info))`) Pure mechanical replacements; no behavior change. --- .../rs-platform-wallet/src/manager/accessors.rs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index 3e58f0ae5d3..8b4d4810ff5 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -363,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, @@ -718,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(), } }) @@ -755,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, From 4a42e2c83780366becc6d073a28b6eacef60f682 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 6 May 2026 03:40:22 +0700 Subject: [PATCH 10/12] fix(platform-wallet): tighten ShieldedSyncManager + FileBackedShieldedStore concurrency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues raised on PR review: **`background_cancel` slot race.** `ShieldedSyncManager::start` spawned a worker thread whose cleanup unconditionally cleared `background_cancel` to None on exit. A tight `stop()` → `start()` reschedule had the prior thread overwrite the *new* generation's token, leaving the new loop running but unobservable via `is_running()` and unstoppable via `stop()`. Fix: bump a generation counter under the slot lock on every `start()`, and have the exiting thread skip the cleanup unless the active generation still matches its own. **`sync_wallet` bypassed exclusion.** The per-wallet on-demand entrypoint took only a read lock on `PlatformWallet.shielded` and ran independently of the manager's `is_syncing` flag, so a manual single-wallet sync could race the periodic `sync_now()` against the same `ShieldedWallet` / store and corrupt commitment-tree state. Reuse the same `compare_exchange` exclusion as `sync_now`; return `Ok(None)` when another pass is already in flight rather than serializing. **`save_note` double-counted on rescan.** Re-saving an already-known note (e.g. a cold-start trial-decrypt of the same chunk) appended a second `ShieldedNote` while overwriting the nullifier index. `get_unspent_notes` then returned both copies (double-counted balance) and `mark_spent` only flipped the second. Orchard nullifiers are globally unique, so an existing entry for the same nullifier means we already have the note — overwrite-in-place rather than append. --- .../src/manager/shielded_sync.rs | 60 ++++++++++++++++--- .../src/wallet/shielded/file_store.rs | 13 ++++ 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/shielded_sync.rs b/packages/rs-platform-wallet/src/manager/shielded_sync.rs index 40d62932f3f..167958bd8c8 100644 --- a/packages/rs-platform-wallet/src/manager/shielded_sync.rs +++ b/packages/rs-platform-wallet/src/manager/shielded_sync.rs @@ -125,6 +125,14 @@ pub struct ShieldedSyncManager { 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. @@ -140,6 +148,7 @@ impl ShieldedSyncManager { 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), @@ -195,6 +204,10 @@ impl ShieldedSyncManager { } 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(); @@ -217,8 +230,16 @@ impl ShieldedSyncManager { } } - if let Ok(mut guard) = this.background_cancel.lock() { - *guard = None; + // 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; + } } }); }) @@ -285,12 +306,19 @@ impl ShieldedSyncManager { summary } - /// Sync a single wallet on demand. Does not set the global - /// `is_syncing` flag — callers that care about exclusion should - /// gate on [`is_syncing`] themselves. + /// 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. + /// sub-wallet, or if another sync pass was already in flight. pub async fn sync_wallet( &self, wallet_id: &WalletId, @@ -303,7 +331,25 @@ impl ShieldedSyncManager { crate::error::PlatformWalletError::WalletNotFound(hex::encode(wallet_id)) })?; - wallet.shielded_sync().await + // 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 } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index aa12ee76d8d..c217d0febe9 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -89,6 +89,19 @@ 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()); From 35293e197a6011afca412c1b0f05a22a44ccccf4 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 6 May 2026 03:40:32 +0700 Subject: [PATCH 11/12] fix(swift-example-app): reset ShieldedService snapshot on rebind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bind(walletManager:walletId:network:resolver:)` swapped the target wallet id and re-attached the manager subscriptions, but left every per-wallet `@Published` field carrying the previous wallet's snapshot until a new sync event landed. After a wallet switch (or a failed rebind) the UI showed the wrong balance, counters, last-sync timer and orchard address for tens of seconds — or indefinitely if the new bind never produced a successful pass. Reset the per-wallet fields up front. Done inline instead of calling `reset()` because the manager subscriptions get re-attached just below — `reset()` would also nil out `walletManager` / `walletId` and require re-setting them. --- .../Core/Services/ShieldedService.swift | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift index 7815a5ea566..d6a7ed66f81 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -96,7 +96,27 @@ class ShieldedService: ObservableObject { self.walletId = walletId self.syncStateCancellable?.cancel() self.syncEventCancellable?.cancel() - self.isBound = false + + // 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 { From 162abd3fabfbf91ecdda43d9f21d94db454c5cba Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 6 May 2026 03:49:36 +0700 Subject: [PATCH 12/12] chore(rs-sdk-ffi): drop unused rand dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cargo machete` (running after the clippy gate in CI) flagged `rand = \"0.8\"` in `rs-sdk-ffi/Cargo.toml` as unused. The dep was only pulled in by the now-deleted `rs-sdk-ffi/src/shielded/` family — every remaining crate imports `rand` indirectly via dash-sdk where it's actually used. --- Cargo.lock | 1 - packages/rs-sdk-ffi/Cargo.toml | 1 - 2 files changed, 2 deletions(-) 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-sdk-ffi/Cargo.toml b/packages/rs-sdk-ffi/Cargo.toml index 06302c92357..f56c7c9a88f 100644 --- a/packages/rs-sdk-ffi/Cargo.toml +++ b/packages/rs-sdk-ffi/Cargo.toml @@ -62,7 +62,6 @@ libc = "0.2" # Cryptography getrandom = "0.2" -rand = "0.8" zeroize = "1.8" # Concurrency