From 0690f52e6a0eafbe7d3f5c5fc38e6dd4aefa8a39 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 13 Mar 2026 02:33:55 +0700 Subject: [PATCH 1/5] feat(rs-sdk-ffi): add shielded pool FFI query bindings Add C-compatible FFI bindings for querying the shielded pool state, including pool balance, encrypted notes, anchors, most recent anchor, and nullifier statuses. These enable iOS/Swift clients to interact with the shielded pool through the unified SDK. Co-Authored-By: Claude Opus 4.6 --- packages/rs-sdk-ffi/src/lib.rs | 2 + packages/rs-sdk-ffi/src/shielded/mod.rs | 6 + .../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 | 118 ++++++++++++++++++ .../src/shielded/queries/pool_state.rs | 65 ++++++++++ 8 files changed, 447 insertions(+) create mode 100644 packages/rs-sdk-ffi/src/shielded/mod.rs create mode 100644 packages/rs-sdk-ffi/src/shielded/queries/anchors.rs create mode 100644 packages/rs-sdk-ffi/src/shielded/queries/encrypted_notes.rs create mode 100644 packages/rs-sdk-ffi/src/shielded/queries/mod.rs create mode 100644 packages/rs-sdk-ffi/src/shielded/queries/most_recent_anchor.rs create mode 100644 packages/rs-sdk-ffi/src/shielded/queries/nullifiers.rs create mode 100644 packages/rs-sdk-ffi/src/shielded/queries/pool_state.rs diff --git a/packages/rs-sdk-ffi/src/lib.rs b/packages/rs-sdk-ffi/src/lib.rs index a1a129c8a61..f9c47ab8835 100644 --- a/packages/rs-sdk-ffi/src/lib.rs +++ b/packages/rs-sdk-ffi/src/lib.rs @@ -25,6 +25,7 @@ mod identity; mod platform_wallet_types; mod protocol_version; mod sdk; +mod shielded; mod signer; mod signer_simple; mod system; @@ -56,6 +57,7 @@ pub use identity::*; pub use platform_wallet_types::*; 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/shielded/mod.rs b/packages/rs-sdk-ffi/src/shielded/mod.rs new file mode 100644 index 00000000000..7a456361675 --- /dev/null +++ b/packages/rs-sdk-ffi/src/shielded/mod.rs @@ -0,0 +1,6 @@ +//! Shielded pool queries module + +mod queries; + +// Re-export all query functions +pub use queries::*; diff --git a/packages/rs-sdk-ffi/src/shielded/queries/anchors.rs b/packages/rs-sdk-ffi/src/shielded/queries/anchors.rs new file mode 100644 index 00000000000..7036aedc46c --- /dev/null +++ b/packages/rs-sdk-ffi/src/shielded/queries/anchors.rs @@ -0,0 +1,72 @@ +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 new file mode 100644 index 00000000000..fb91196380f --- /dev/null +++ b/packages/rs-sdk-ffi/src/shielded/queries/encrypted_notes.rs @@ -0,0 +1,98 @@ +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 new file mode 100644 index 00000000000..6ab7cc674c8 --- /dev/null +++ b/packages/rs-sdk-ffi/src/shielded/queries/mod.rs @@ -0,0 +1,13 @@ +// 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 new file mode 100644 index 00000000000..df9e2294d39 --- /dev/null +++ b/packages/rs-sdk-ffi/src/shielded/queries/most_recent_anchor.rs @@ -0,0 +1,73 @@ +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 new file mode 100644 index 00000000000..480d89a9b7a --- /dev/null +++ b/packages/rs-sdk-ffi/src/shielded/queries/nullifiers.rs @@ -0,0 +1,118 @@ +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 = (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 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 new file mode 100644 index 00000000000..7737d9ea59a --- /dev/null +++ b/packages/rs-sdk-ffi/src/shielded/queries/pool_state.rs @@ -0,0 +1,65 @@ +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)), + } +} From dd6ccb1f07037a10727cd838185b8c5d3ad1f0e0 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 13 Mar 2026 02:52:37 +0700 Subject: [PATCH 2/5] feat(rs-sdk-ffi): add nullifier BLAST sync and shielded transition FFI bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add nullifier BLAST sync (privacy-preserving trunk/branch queries) and FFI bindings for all 5 shielded state transitions: - Shield (platform address → shielded pool) - ShieldFromAssetLock (L1 asset lock → shielded pool, instant + chain) - ShieldedTransfer (shielded → shielded) - Unshield (shielded pool → platform address) - ShieldedWithdrawal (shielded pool → L1 Core address) Shared OrchardBundleParams FFI types allow the iOS client to construct Orchard bundles independently and pass pre-built bytes through FFI. Co-Authored-By: Claude Opus 4.6 --- packages/rs-sdk-ffi/Cargo.toml | 1 + packages/rs-sdk-ffi/src/lib.rs | 2 + packages/rs-sdk-ffi/src/nullifier_sync/mod.rs | 326 ++++++++++++++++++ .../rs-sdk-ffi/src/nullifier_sync/types.rs | 87 +++++ packages/rs-sdk-ffi/src/shielded/mod.rs | 14 +- .../src/shielded/transitions/mod.rs | 15 + .../src/shielded/transitions/shield.rs | 162 +++++++++ .../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 +++++ 12 files changed, 1120 insertions(+), 1 deletion(-) create mode 100644 packages/rs-sdk-ffi/src/nullifier_sync/mod.rs create mode 100644 packages/rs-sdk-ffi/src/nullifier_sync/types.rs create mode 100644 packages/rs-sdk-ffi/src/shielded/transitions/mod.rs create mode 100644 packages/rs-sdk-ffi/src/shielded/transitions/shield.rs create mode 100644 packages/rs-sdk-ffi/src/shielded/transitions/shield_from_asset_lock.rs create mode 100644 packages/rs-sdk-ffi/src/shielded/transitions/shielded_transfer.rs create mode 100644 packages/rs-sdk-ffi/src/shielded/transitions/shielded_withdrawal.rs create mode 100644 packages/rs-sdk-ffi/src/shielded/transitions/unshield.rs create mode 100644 packages/rs-sdk-ffi/src/shielded/types.rs diff --git a/packages/rs-sdk-ffi/Cargo.toml b/packages/rs-sdk-ffi/Cargo.toml index fb7fd601e55..4dbeee05395 100644 --- a/packages/rs-sdk-ffi/Cargo.toml +++ b/packages/rs-sdk-ffi/Cargo.toml @@ -14,6 +14,7 @@ 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 f9c47ab8835..064f8d7aa58 100644 --- a/packages/rs-sdk-ffi/src/lib.rs +++ b/packages/rs-sdk-ffi/src/lib.rs @@ -22,6 +22,7 @@ mod error; mod evonode; mod group; mod identity; +mod nullifier_sync; mod platform_wallet_types; mod protocol_version; mod sdk; @@ -53,6 +54,7 @@ pub use error::*; pub use evonode::*; pub use group::*; pub use identity::*; +pub use nullifier_sync::*; #[allow(unused_imports)] pub use platform_wallet_types::*; pub use protocol_version::*; diff --git a/packages/rs-sdk-ffi/src/nullifier_sync/mod.rs b/packages/rs-sdk-ffi/src/nullifier_sync/mod.rs new file mode 100644 index 00000000000..997983147d2 --- /dev/null +++ b/packages/rs-sdk-ffi/src/nullifier_sync/mod.rs @@ -0,0 +1,326 @@ +//! 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 means no previous sync + 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(), + }) + }; + + 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 new file mode 100644 index 00000000000..de7d99fcc68 --- /dev/null +++ b/packages/rs-sdk-ffi/src/nullifier_sync/types.rs @@ -0,0 +1,87 @@ +//! 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/mod.rs b/packages/rs-sdk-ffi/src/shielded/mod.rs index 7a456361675..0f57e2475b2 100644 --- a/packages/rs-sdk-ffi/src/shielded/mod.rs +++ b/packages/rs-sdk-ffi/src/shielded/mod.rs @@ -1,6 +1,18 @@ -//! Shielded pool queries module +//! Shielded pool queries and state transition FFI bindings. mod queries; +mod transitions; +pub(crate) mod types; // Re-export all query functions pub use queries::*; + +// Re-export transition functions (individual functions, not the module) +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; +pub use transitions::shield::DashSDKShieldInput; +pub use types::{DashSDKOrchardBundleParams, DashSDKSerializedAction}; diff --git a/packages/rs-sdk-ffi/src/shielded/transitions/mod.rs b/packages/rs-sdk-ffi/src/shielded/transitions/mod.rs new file mode 100644 index 00000000000..9f0ad0cf610 --- /dev/null +++ b/packages/rs-sdk-ffi/src/shielded/transitions/mod.rs @@ -0,0 +1,15 @@ +//! Shielded state transition FFI bindings. + +pub mod shield; +pub mod shield_from_asset_lock; +pub mod shielded_transfer; +pub mod shielded_withdrawal; +pub mod unshield; + +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; diff --git a/packages/rs-sdk-ffi/src/shielded/transitions/shield.rs b/packages/rs-sdk-ffi/src/shielded/transitions/shield.rs new file mode 100644 index 00000000000..ce7e1a540f0 --- /dev/null +++ b/packages/rs-sdk-ffi/src/shielded/transitions/shield.rs @@ -0,0 +1,162 @@ +//! 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 + let mut input_map: BTreeMap = BTreeMap::new(); + let mut signer = AddressSigner::new(); + + 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), + )) + } + }; + + let private_key = PrivateKey::new(secret_key, Network::Testnet); + signer.add_key(&address, private_key); + 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()), + }; + + let fee_strategy: AddressFundsFeeStrategy = vec![AddressFundsFeeStrategyStep::DeductFromInput( + fee_from_input_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 new file mode 100644 index 00000000000..52318bf031a --- /dev/null +++ b/packages/rs-sdk-ffi/src/shielded/transitions/shield_from_asset_lock.rs @@ -0,0 +1,169 @@ +//! 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 new file mode 100644 index 00000000000..23918b20bbf --- /dev/null +++ b/packages/rs-sdk-ffi/src/shielded/transitions/shielded_transfer.rs @@ -0,0 +1,64 @@ +//! 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 new file mode 100644 index 00000000000..852bffbe877 --- /dev/null +++ b/packages/rs-sdk-ffi/src/shielded/transitions/shielded_withdrawal.rs @@ -0,0 +1,103 @@ +//! 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 new file mode 100644 index 00000000000..9f734069214 --- /dev/null +++ b/packages/rs-sdk-ffi/src/shielded/transitions/unshield.rs @@ -0,0 +1,86 @@ +//! 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 new file mode 100644 index 00000000000..29829b00439 --- /dev/null +++ b/packages/rs-sdk-ffi/src/shielded/types.rs @@ -0,0 +1,92 @@ +//! 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, + }) +} From a2dde63f7851a6103927489a4a65a7178e726ef7 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 13 Mar 2026 03:06:28 +0700 Subject: [PATCH 3/5] feat(rs-sdk-ffi): add separate build and broadcast for shielded transitions Add builder functions that construct and serialize shielded state transitions without broadcasting, allowing the iOS client to build, inspect/store, and later broadcast transitions separately: - dash_sdk_shielded_build_transfer - dash_sdk_shielded_build_unshield - dash_sdk_shielded_build_shield - dash_sdk_shielded_build_shield_from_instant_lock - dash_sdk_shielded_build_shield_from_chain_lock - dash_sdk_shielded_build_withdrawal - dash_sdk_shielded_broadcast (generic broadcast from serialized bytes) Builders return hex-encoded serialized StateTransition bytes. The broadcast function deserializes and broadcasts pre-built transitions. Co-Authored-By: Claude Opus 4.6 --- packages/rs-sdk-ffi/src/shielded/mod.rs | 15 +- .../src/shielded/transitions/broadcast.rs | 70 +++ .../src/shielded/transitions/builders.rs | 540 ++++++++++++++++++ .../src/shielded/transitions/mod.rs | 13 + 4 files changed, 637 insertions(+), 1 deletion(-) create mode 100644 packages/rs-sdk-ffi/src/shielded/transitions/broadcast.rs create mode 100644 packages/rs-sdk-ffi/src/shielded/transitions/builders.rs diff --git a/packages/rs-sdk-ffi/src/shielded/mod.rs b/packages/rs-sdk-ffi/src/shielded/mod.rs index 0f57e2475b2..5b8e9e7ebe5 100644 --- a/packages/rs-sdk-ffi/src/shielded/mod.rs +++ b/packages/rs-sdk-ffi/src/shielded/mod.rs @@ -7,12 +7,25 @@ pub(crate) mod types; // Re-export all query functions pub use queries::*; -// Re-export transition functions (individual functions, not the module) +// 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/transitions/broadcast.rs b/packages/rs-sdk-ffi/src/shielded/transitions/broadcast.rs new file mode 100644 index 00000000000..1a7c5b19bc3 --- /dev/null +++ b/packages/rs-sdk-ffi/src/shielded/transitions/broadcast.rs @@ -0,0 +1,70 @@ +//! 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 new file mode 100644 index 00000000000..0397deab886 --- /dev/null +++ b/packages/rs-sdk-ffi/src/shielded/transitions/builders.rs @@ -0,0 +1,540 @@ +//! 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) => StateTransition::from(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) => StateTransition::from(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 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), + )) + } + }; + + let private_key = PrivateKey::new(secret_key, Network::Testnet); + signer.add_key(&address, private_key); + 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()), + }; + + let fee_strategy: AddressFundsFeeStrategy = vec![AddressFundsFeeStrategyStep::DeductFromInput( + fee_from_input_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(|e| FFIError::SDKError(e))?; + + // 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(), + ) + .map_err(|e| { + FFIError::InternalError(format!("Failed to build shield transition: {}", e)) + })?; + + Ok::(StateTransition::from(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) => StateTransition::from(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) => StateTransition::from(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) => StateTransition::from(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 index 9f0ad0cf610..9e4bf48568d 100644 --- a/packages/rs-sdk-ffi/src/shielded/transitions/mod.rs +++ b/packages/rs-sdk-ffi/src/shielded/transitions/mod.rs @@ -1,11 +1,14 @@ //! 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, @@ -13,3 +16,13 @@ pub use shield_from_asset_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; From f599c4155b868e1e0c9be08e5eb45e9a7f80557c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 13 Mar 2026 03:11:27 +0700 Subject: [PATCH 4/5] fix(rs-sdk-ffi): fix clippy useless_conversion and redundant_closure in builders Co-Authored-By: Claude Opus 4.6 --- .../src/shielded/transitions/builders.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/rs-sdk-ffi/src/shielded/transitions/builders.rs b/packages/rs-sdk-ffi/src/shielded/transitions/builders.rs index 0397deab886..acaf1915390 100644 --- a/packages/rs-sdk-ffi/src/shielded/transitions/builders.rs +++ b/packages/rs-sdk-ffi/src/shielded/transitions/builders.rs @@ -116,7 +116,7 @@ pub unsafe extern "C" fn dash_sdk_shielded_build_transfer( orchard_bundle.binding_signature, wrapper.sdk.version(), ) { - Ok(st) => StateTransition::from(st), + Ok(st) => st, Err(e) => { return DashSDKResult::error(DashSDKError::new( DashSDKErrorCode::InternalError, @@ -181,7 +181,7 @@ pub unsafe extern "C" fn dash_sdk_shielded_build_unshield( orchard_bundle.binding_signature, wrapper.sdk.version(), ) { - Ok(st) => StateTransition::from(st), + Ok(st) => st, Err(e) => { return DashSDKResult::error(DashSDKError::new( DashSDKErrorCode::InternalError, @@ -290,7 +290,7 @@ pub unsafe extern "C" fn dash_sdk_shielded_build_shield( let addresses: BTreeSet = input_map.keys().copied().collect(); let address_infos = AddressInfo::fetch_many(&wrapper.sdk, addresses) .await - .map_err(|e| FFIError::SDKError(e))?; + .map_err(FFIError::SDKError)?; // Build inputs with nonce+1 (next nonce) let mut inputs_with_nonce: BTreeMap = @@ -324,7 +324,7 @@ pub unsafe extern "C" fn dash_sdk_shielded_build_shield( FFIError::InternalError(format!("Failed to build shield transition: {}", e)) })?; - Ok::(StateTransition::from(st)) + Ok::(st) }); match result { @@ -391,7 +391,7 @@ pub unsafe extern "C" fn dash_sdk_shielded_build_shield_from_instant_lock( orchard_bundle.binding_signature, wrapper.sdk.version(), ) { - Ok(st) => StateTransition::from(st), + Ok(st) => st, Err(e) => { return DashSDKResult::error(DashSDKError::new( DashSDKErrorCode::InternalError, @@ -452,7 +452,7 @@ pub unsafe extern "C" fn dash_sdk_shielded_build_shield_from_chain_lock( orchard_bundle.binding_signature, wrapper.sdk.version(), ) { - Ok(st) => StateTransition::from(st), + Ok(st) => st, Err(e) => { return DashSDKResult::error(DashSDKError::new( DashSDKErrorCode::InternalError, @@ -527,7 +527,7 @@ pub unsafe extern "C" fn dash_sdk_shielded_build_withdrawal( core_script, wrapper.sdk.version(), ) { - Ok(st) => StateTransition::from(st), + Ok(st) => st, Err(e) => { return DashSDKResult::error(DashSDKError::new( DashSDKErrorCode::InternalError, From 0132e9b7388f2c7ec715e6ecaa7eaa60f591b184 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 13 Mar 2026 03:46:50 +0700 Subject: [PATCH 5/5] fix(rs-sdk-ffi): address CodeRabbit review findings - Use `||` instead of `&&` for nullifier sync checkpoint zero check so either field being 0 triggers full scan - Add checked_mul for nullifier count to prevent overflow on 32-bit - Remap fee_from_input_index from caller's input order to BTreeMap's sorted key order for correct DeductFromInput resolution - Reject duplicate addresses in shield inputs upfront Co-Authored-By: Claude Opus 4.6 --- packages/rs-sdk-ffi/src/nullifier_sync/mod.rs | 7 +++--- .../src/shielded/queries/nullifiers.rs | 10 +++++++- .../src/shielded/transitions/builders.rs | 23 ++++++++++++++--- .../src/shielded/transitions/shield.rs | 25 ++++++++++++++++--- 4 files changed, 54 insertions(+), 11 deletions(-) diff --git a/packages/rs-sdk-ffi/src/nullifier_sync/mod.rs b/packages/rs-sdk-ffi/src/nullifier_sync/mod.rs index 997983147d2..59ae2f45034 100644 --- a/packages/rs-sdk-ffi/src/nullifier_sync/mod.rs +++ b/packages/rs-sdk-ffi/src/nullifier_sync/mod.rs @@ -94,8 +94,8 @@ pub unsafe extern "C" fn dash_sdk_sync_nullifiers( }) }; - // Convert checkpoint: 0 means no previous sync - let last_sync = if last_sync_height == 0 && last_sync_timestamp == 0 { + // 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 { @@ -194,7 +194,8 @@ pub unsafe extern "C" fn dash_sdk_sync_nullifiers_with_result( }) }; - let last_sync = if last_sync_height == 0 && last_sync_timestamp == 0 { + // 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 { diff --git a/packages/rs-sdk-ffi/src/shielded/queries/nullifiers.rs b/packages/rs-sdk-ffi/src/shielded/queries/nullifiers.rs index 480d89a9b7a..6eb6105fd63 100644 --- a/packages/rs-sdk-ffi/src/shielded/queries/nullifiers.rs +++ b/packages/rs-sdk-ffi/src/shielded/queries/nullifiers.rs @@ -45,7 +45,15 @@ pub unsafe extern "C" fn dash_sdk_shielded_get_nullifiers( let sdk = wrapper.sdk.clone(); // Parse the raw byte pointer into Vec<[u8; 32]> - let total_bytes = (nullifiers_count as usize) * 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) diff --git a/packages/rs-sdk-ffi/src/shielded/transitions/builders.rs b/packages/rs-sdk-ffi/src/shielded/transitions/builders.rs index acaf1915390..f3ba6dc7012 100644 --- a/packages/rs-sdk-ffi/src/shielded/transitions/builders.rs +++ b/packages/rs-sdk-ffi/src/shielded/transitions/builders.rs @@ -236,6 +236,7 @@ pub unsafe extern "C" fn dash_sdk_shielded_build_shield( 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() { @@ -268,8 +269,17 @@ pub unsafe extern "C" fn dash_sdk_shielded_build_shield( } }; + // 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); } @@ -278,9 +288,16 @@ pub unsafe extern "C" fn dash_sdk_shielded_build_shield( Err(e) => return DashSDKResult::error(e.into()), }; - let fee_strategy: AddressFundsFeeStrategy = vec![AddressFundsFeeStrategyStep::DeductFromInput( - fee_from_input_index, - )]; + // 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; diff --git a/packages/rs-sdk-ffi/src/shielded/transitions/shield.rs b/packages/rs-sdk-ffi/src/shielded/transitions/shield.rs index ce7e1a540f0..31a22af7116 100644 --- a/packages/rs-sdk-ffi/src/shielded/transitions/shield.rs +++ b/packages/rs-sdk-ffi/src/shielded/transitions/shield.rs @@ -84,9 +84,10 @@ pub unsafe extern "C" fn dash_sdk_shielded_shield_funds( let wrapper = &*(sdk_handle as *const SDKWrapper); - // Parse inputs and create signer + // 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() { @@ -126,8 +127,17 @@ pub unsafe extern "C" fn dash_sdk_shielded_shield_funds( } }; + // 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); } @@ -136,9 +146,16 @@ pub unsafe extern "C" fn dash_sdk_shielded_shield_funds( Err(e) => return DashSDKResult::error(e.into()), }; - let fee_strategy: AddressFundsFeeStrategy = vec![AddressFundsFeeStrategyStep::DeductFromInput( - fee_from_input_index, - )]; + // 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