From f4703d6daf3d82078f5e3f79be3588328eabe394 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 31 Mar 2026 14:17:44 +0300 Subject: [PATCH 1/7] feat(swift-sdk): use SPV-synced quorums for Platform proof verification Bridge the SPV client's locally synced masternode list data to the Platform SDK's context provider, replacing the dependency on a trusted HTTP quorum endpoint. When an SPV client handle is available, the SDK is created with callback-based quorum lookups that call through to ffi_dash_spv_get_quorum_public_key and ffi_dash_spv_get_platform_activation_height. A "Fallback to Trusted Quorums" toggle (default: ON) in Settings lets users fall back to the HTTP provider when SPV data is not yet synced. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../SwiftDashSDK/Core/SPV/SPVClient.swift | 7 ++ .../Core/SPV/SPVContextProvider.swift | 104 ++++++++++++++++++ .../Core/Services/WalletService.swift | 6 + .../swift-sdk/Sources/SwiftDashSDK/SDK.swift | 51 +++++++++ .../SwiftExampleApp/AppState.swift | 76 +++++++++++-- .../SwiftExampleApp/UnifiedAppState.swift | 15 ++- .../SwiftExampleApp/Views/OptionsView.swift | 3 + 7 files changed, 251 insertions(+), 11 deletions(-) create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Core/SPV/SPVContextProvider.swift diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/SPV/SPVClient.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/SPV/SPVClient.swift index 345fd6f7211..03b71ef52cd 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/SPV/SPVClient.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/SPV/SPVClient.swift @@ -130,6 +130,13 @@ class SPVClient: @unchecked Sendable { config = configPtr } + /// Raw FFI client pointer for use as context provider handle. + /// The caller must not free or retain this pointer beyond the SPVClient's lifetime. + var unsafeFFIClientPointer: UnsafeMutableRawPointer? { + guard let client = client else { return nil } + return UnsafeMutableRawPointer(client) + } + deinit { self.destroy() } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/SPV/SPVContextProvider.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/SPV/SPVContextProvider.swift new file mode 100644 index 00000000000..151f12b3ba0 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/SPV/SPVContextProvider.swift @@ -0,0 +1,104 @@ +import DashSDKFFI +import Foundation + +// MARK: - C Callback: Get quorum public key from SPV + +/// C-compatible callback that bridges Platform SDK quorum key requests to the SPV client. +/// +/// `handle` is the raw `FFIDashSpvClient*` pointer, passed as `core_handle` in +/// `ContextProviderCallbacks`. The SPV FFI function `ffi_dash_spv_get_quorum_public_key` +/// retrieves the BLS public key for the given quorum from the locally synced masternode list. +/// +/// - Parameters: +/// - handle: Raw pointer to `FFIDashSpvClient` (cast from `void*`). +/// - quorumType: The quorum type identifier. +/// - quorumHash: Pointer to 32-byte quorum hash. +/// - coreChainLockedHeight: The core chain locked height for the request. +/// - outPubkey: Pointer to a 48-byte output buffer for the BLS public key. +/// - Returns: `CallbackResult` with `success: true` on success or error details on failure. +func spvGetQuorumPublicKey( + handle: UnsafeMutableRawPointer?, + quorumType: UInt32, + quorumHash: UnsafePointer?, + coreChainLockedHeight: UInt32, + outPubkey: UnsafeMutablePointer? +) -> CallbackResult { + guard let handle = handle else { + return CallbackResult(success: false, error_code: -1, error_message: nil) + } + guard let quorumHash = quorumHash, let outPubkey = outPubkey else { + return CallbackResult(success: false, error_code: -2, error_message: nil) + } + + let client = handle.assumingMemoryBound(to: FFIDashSpvClient.self) + let ffiResult = ffi_dash_spv_get_quorum_public_key( + client, quorumType, quorumHash, coreChainLockedHeight, outPubkey, 48 + ) + + if ffiResult.error_code == 0 { + return CallbackResult(success: true, error_code: 0, error_message: nil) + } else { + return CallbackResult( + success: false, + error_code: ffiResult.error_code, + error_message: ffiResult.error_message + ) + } +} + +// MARK: - C Callback: Get platform activation height from SPV + +/// C-compatible callback that bridges Platform SDK activation height requests to the SPV client. +/// +/// `handle` is the raw `FFIDashSpvClient*` pointer. The SPV FFI function +/// `ffi_dash_spv_get_platform_activation_height` returns the core block height at which +/// Platform was activated, as determined from the locally synced chain. +/// +/// - Parameters: +/// - handle: Raw pointer to `FFIDashSpvClient` (cast from `void*`). +/// - outHeight: Pointer to a `UInt32` where the activation height will be written. +/// - Returns: `CallbackResult` with `success: true` on success or error details on failure. +func spvGetPlatformActivationHeight( + handle: UnsafeMutableRawPointer?, + outHeight: UnsafeMutablePointer? +) -> CallbackResult { + guard let handle = handle else { + return CallbackResult(success: false, error_code: -1, error_message: nil) + } + guard let outHeight = outHeight else { + return CallbackResult(success: false, error_code: -2, error_message: nil) + } + + let client = handle.assumingMemoryBound(to: FFIDashSpvClient.self) + let ffiResult = ffi_dash_spv_get_platform_activation_height(client, outHeight) + + if ffiResult.error_code == 0 { + return CallbackResult(success: true, error_code: 0, error_message: nil) + } else { + return CallbackResult( + success: false, + error_code: ffiResult.error_code, + error_message: ffiResult.error_message + ) + } +} + +// MARK: - Helper to create ContextProviderCallbacks + +/// Creates a `ContextProviderCallbacks` struct configured to use the SPV client +/// for quorum key lookups and platform activation height. +/// +/// The returned struct is suitable for passing to `dash_sdk_create_with_callbacks`. +/// The SPV client must remain alive for the entire lifetime of the SDK instance +/// that uses these callbacks. +/// +/// - Parameter spvClientHandle: Raw pointer to the `FFIDashSpvClient`, obtained +/// from `SPVClient.unsafeFFIClientPointer`. +/// - Returns: A `ContextProviderCallbacks` ready to pass to `dash_sdk_create_with_callbacks`. +func makeSPVContextProviderCallbacks(spvClientHandle: UnsafeMutableRawPointer) -> ContextProviderCallbacks { + return ContextProviderCallbacks( + core_handle: spvClientHandle, + get_platform_activation_height: spvGetPlatformActivationHeight, + get_quorum_public_key: spvGetQuorumPublicKey + ) +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/WalletService.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/WalletService.swift index ba19cdb52bb..5521c4ffbfc 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/WalletService.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/WalletService.swift @@ -104,6 +104,12 @@ public class WalletService: ObservableObject { private var spvClient: SPVClient public private(set) var walletManager: CoreWalletManager + /// Raw FFI client pointer for Platform SDK quorum callbacks. + /// The returned pointer is only valid while this WalletService (and its SPV client) is alive. + public var spvClientHandle: UnsafeMutableRawPointer? { + spvClient.unsafeFFIClientPointer + } + public init(modelContainer: ModelContainer, network: AppNetwork) { self.modelContainer = modelContainer self.network = network diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift index e2459e90175..39b61f2c077 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift @@ -208,6 +208,57 @@ public final class SDK: @unchecked Sendable { self.network = network } + /// Create a new SDK instance using SPV-synced quorum data for proof verification. + /// + /// Instead of fetching quorum keys from a trusted HTTP endpoint, this uses + /// quorum data already synced by the SPV client (masternode list sync). + /// + /// - Parameters: + /// - network: The Dash network to connect to. + /// - spvClientHandle: Raw pointer to the SPV client's `FFIDashSpvClient` handle. + /// Obtain this from `SPVClient.unsafeFFIClientPointer`. + /// The SPV client must remain alive for the lifetime of this SDK instance. + public init(network: Network, spvClientHandle: UnsafeMutableRawPointer) throws { + NSLog("SDK.init: Creating SDK with SPV quorum provider, network: \(network)") + var config = DashSDKConfig() + config.network = network + config.dapi_addresses = nil + config.skip_asset_lock_proof_verification = false + config.request_retry_count = 1 + config.request_timeout_ms = 8000 + + var callbacks = makeSPVContextProviderCallbacks(spvClientHandle: spvClientHandle) + + let result: DashSDKResult + let forceLocal = UserDefaults.standard.bool(forKey: "useLocalhostPlatform") + if forceLocal { + let localAddresses = Self.platformDAPIAddresses + NSLog("SDK.init: Using local DAPI addresses with SPV quorums: \(localAddresses)") + result = localAddresses.withCString { addressesCStr -> DashSDKResult in + var mutableConfig = config + mutableConfig.dapi_addresses = addressesCStr + return dash_sdk_create_with_callbacks(&mutableConfig, &callbacks) + } + } else { + result = dash_sdk_create_with_callbacks(&config, &callbacks) + } + + if result.error != nil { + let error = result.error!.pointee + let errorMessage = error.message != nil ? String(cString: error.message!) : "Unknown error" + defer { dash_sdk_error_free(result.error) } + throw SDKError.internalError("Failed to create SDK with SPV quorums: \(errorMessage)") + } + + guard result.data != nil else { + throw SDKError.internalError("No SDK handle returned") + } + + handle = result.data?.assumingMemoryBound(to: SDKHandle.self) + self.network = network + NSLog("SDK.init: SDK created with SPV quorum provider") + } + /// Load known contracts into the trusted context provider /// This avoids network calls for these contracts when they're needed public func loadKnownContracts(_ contracts: [(id: String, data: Data)]) throws { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift index a7816f3bb58..4994d052923 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift @@ -42,9 +42,17 @@ class AppState: ObservableObject { } } + @Published var useTrustedQuorumFallback: Bool { + didSet { + UserDefaults.standard.set(useTrustedQuorumFallback, forKey: "useTrustedQuorumFallback") + } + } + private let testSigner = TestSigner() private var dataManager: DataManager? private var modelContext: ModelContext? + /// Stored SPV client handle for reuse during network switches. + private var spvClientHandle: UnsafeMutableRawPointer? init() { // Load saved network preference or use default @@ -60,11 +68,16 @@ class AppState: ObservableObject { let hasCoreKey = UserDefaults.standard.object(forKey: "useLocalhostCore") != nil self.useLocalPlatform = hasPlatformKey ? UserDefaults.standard.bool(forKey: "useLocalhostPlatform") : legacyLocal self.useLocalCore = hasCoreKey ? UserDefaults.standard.bool(forKey: "useLocalhostCore") : legacyLocal + self.useTrustedQuorumFallback = UserDefaults.standard.object(forKey: "useTrustedQuorumFallback") != nil + ? UserDefaults.standard.bool(forKey: "useTrustedQuorumFallback") + : true // Default: ON (use trusted HTTP quorums as fallback) } - func initializeSDK(modelContext: ModelContext) { + func initializeSDK(modelContext: ModelContext, spvClientHandle: UnsafeMutableRawPointer? = nil) { // Save the model context for later use self.modelContext = modelContext + // Store the SPV handle for reuse during network switches + self.spvClientHandle = spvClientHandle // Initialize DataManager self.dataManager = DataManager(modelContext: modelContext, currentNetwork: currentNetwork) @@ -73,22 +86,43 @@ class AppState: ObservableObject { do { isLoading = true - NSLog("🔵 AppState: Initializing SDK library...") + NSLog("AppState: Initializing SDK library...") // Initialize the SDK library SDK.initialize() // Enable debug logging to see gRPC endpoints SDK.enableLogging(level: .debug) - NSLog("🔵 AppState: Enabled debug logging for gRPC requests") + NSLog("AppState: Enabled debug logging for gRPC requests") - NSLog("🔵 AppState: Creating SDK instance for network: \(currentNetwork)") + NSLog("AppState: Creating SDK instance for network: \(currentNetwork)") // Create SDK instance for current network let sdkNetwork: DashSDKNetwork = currentNetwork.sdkNetwork - NSLog("🔵 AppState: SDK network value: \(sdkNetwork)") + NSLog("AppState: SDK network value: \(sdkNetwork)") + + let newSDK: SDK + + // Try SPV quorums first if handle is available + if let spvHandle = spvClientHandle { + do { + newSDK = try SDK(network: sdkNetwork, spvClientHandle: spvHandle) + NSLog("AppState: SDK created with SPV quorum provider") + } catch { + if useTrustedQuorumFallback { + NSLog("AppState: SPV quorum provider failed (\(error.localizedDescription)), falling back to trusted") + newSDK = try SDK(network: sdkNetwork) + } else { + throw error + } + } + } else if useTrustedQuorumFallback { + NSLog("AppState: No SPV client available, using trusted quorum provider") + newSDK = try SDK(network: sdkNetwork) + } else { + throw SDKError.invalidState("No SPV client available and trusted fallback disabled") + } - let newSDK = try SDK(network: sdkNetwork) sdk = newSDK - NSLog("✅ AppState: SDK created successfully with handle: \(newSDK.handle != nil ? "exists" : "nil")") + NSLog("AppState: SDK created successfully with handle: \(newSDK.handle != nil ? "exists" : "nil")") // Load known contracts into the SDK's trusted provider await loadKnownContractsIntoSDK(sdk: newSDK, modelContext: modelContext) @@ -104,6 +138,11 @@ class AppState: ObservableObject { } } + /// Update the stored SPV client handle (e.g., after a network switch recreates the SPV client). + func updateSPVClientHandle(_ handle: UnsafeMutableRawPointer?) { + self.spvClientHandle = handle + } + func loadPersistedData() async { guard let dataManager = dataManager else { return } @@ -189,7 +228,28 @@ class AppState: ObservableObject { // Create new SDK instance for the network let sdkNetwork: DashSDKNetwork = network.sdkNetwork - let newSDK = try SDK(network: sdkNetwork) + + let newSDK: SDK + + // Try SPV quorums first if handle is available + if let spvHandle = spvClientHandle { + do { + newSDK = try SDK(network: sdkNetwork, spvClientHandle: spvHandle) + NSLog("AppState.switchNetwork: SDK created with SPV quorum provider") + } catch { + if useTrustedQuorumFallback { + NSLog("AppState.switchNetwork: SPV quorum provider failed (\(error.localizedDescription)), falling back to trusted") + newSDK = try SDK(network: sdkNetwork) + } else { + throw error + } + } + } else if useTrustedQuorumFallback { + newSDK = try SDK(network: sdkNetwork) + } else { + throw SDKError.invalidState("No SPV client available and trusted fallback disabled") + } + sdk = newSDK // Load known contracts into the SDK's trusted provider diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/UnifiedAppState.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/UnifiedAppState.swift index f205bdef6c7..155703ce881 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/UnifiedAppState.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/UnifiedAppState.swift @@ -68,9 +68,12 @@ class UnifiedAppState: ObservableObject { } func initialize() async { - // Initialize Platform SDK + // Get SPV client handle for Platform SDK quorum verification + let spvHandle = walletService.spvClientHandle + + // Initialize Platform SDK with SPV quorums when available await MainActor.run { - platformState.initializeSDK(modelContext: modelContainer.mainContext) + platformState.initializeSDK(modelContext: modelContainer.mainContext, spvClientHandle: spvHandle) } // Wait for Platform SDK to be ready @@ -120,9 +123,15 @@ class UnifiedAppState: ObservableObject { // Handle network switching - called when platformState.currentNetwork changes func handleNetworkSwitch(to network: AppNetwork) async { - // Switch wallet service to new network (convert to DashNetwork) + // Switch wallet service to new network (which recreates the SPV client) await walletService.switchNetwork(to: network) + // Update the SPV client handle in platform state (the old handle is now invalid) + let newSpvHandle = walletService.spvClientHandle + await MainActor.run { + platformState.updateSPVClientHandle(newSpvHandle) + } + // Reinitialize shielded service for the new network initializeShieldedService() diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift index 89258fca735..44b133fd70e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift @@ -55,6 +55,9 @@ struct OptionsView: View { } .help("When enabled, Core (SPV) connects only to configured peers (default 127.0.0.1 with network port). Override via 'corePeerAddresses'.") + Toggle("Fallback to Trusted Quorums", isOn: $appState.useTrustedQuorumFallback) + .help("When enabled, falls back to trusted HTTP quorum provider if SPV quorum data is unavailable. Disable to require SPV-synced quorums for proof verification.") + HStack { Text("Network Status") Spacer() From ad6904a114edf0a6ef5ac0dccb1068cc54ba9fb0 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 31 Mar 2026 14:34:09 +0300 Subject: [PATCH 2/7] refactor(rs-sdk-ffi): move SPV context provider from Swift to Rust The quorum/activation-height bridging no longer round-trips through Swift callbacks. Instead, rs-sdk-ffi now depends on dash-spv-ffi directly and implements SpvContextProvider in Rust, calling ffi_dash_spv_get_quorum_public_key and ffi_dash_spv_get_platform_activation_height without leaving Rust. A new FFI function dash_sdk_create_with_spv_context(config, spv_client) replaces the Swift-side ContextProviderCallbacks setup. Swift just passes the raw FFIDashSpvClient pointer. - Add dash-spv-ffi dependency to rs-sdk-ffi - Add spv_context_provider.rs implementing ContextProvider trait - Add dash_sdk_create_with_spv_context FFI function - Remove SPVContextProvider.swift (no longer needed) - Update SDK.swift to call dash_sdk_create_with_spv_context Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 1 + packages/rs-sdk-ffi/Cargo.toml | 3 + packages/rs-sdk-ffi/src/lib.rs | 1 + packages/rs-sdk-ffi/src/sdk.rs | 54 ++++++++ .../rs-sdk-ffi/src/spv_context_provider.rs | 117 ++++++++++++++++++ .../Core/SPV/SPVContextProvider.swift | 104 ---------------- .../swift-sdk/Sources/SwiftDashSDK/SDK.swift | 6 +- 7 files changed, 178 insertions(+), 108 deletions(-) create mode 100644 packages/rs-sdk-ffi/src/spv_context_provider.rs delete mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Core/SPV/SPVContextProvider.swift diff --git a/Cargo.lock b/Cargo.lock index a6bcdc931dd..c36f03ebfc2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5850,6 +5850,7 @@ dependencies = [ "bs58", "cbindgen 0.27.0", "dash-sdk", + "dash-spv-ffi", "dotenvy", "drive-proof-verifier", "env_logger 0.11.10", diff --git a/packages/rs-sdk-ffi/Cargo.toml b/packages/rs-sdk-ffi/Cargo.toml index 0f70507bcc2..ba33789c3fc 100644 --- a/packages/rs-sdk-ffi/Cargo.toml +++ b/packages/rs-sdk-ffi/Cargo.toml @@ -22,6 +22,9 @@ rs-sdk-trusted-context-provider = { path = "../rs-sdk-trusted-context-provider", ] } simple-signer = { path = "../simple-signer" } +# SPV client integration for quorum-based proof verification +dash-spv-ffi = { workspace = true } + # Platform Wallet integration for DashPay support platform-wallet-ffi = { path = "../rs-platform-wallet-ffi" } diff --git a/packages/rs-sdk-ffi/src/lib.rs b/packages/rs-sdk-ffi/src/lib.rs index 0a5e2277f69..d348d20cc0e 100644 --- a/packages/rs-sdk-ffi/src/lib.rs +++ b/packages/rs-sdk-ffi/src/lib.rs @@ -26,6 +26,7 @@ mod sdk; mod shielded; mod signer; mod signer_simple; +pub mod spv_context_provider; mod system; mod token; mod types; diff --git a/packages/rs-sdk-ffi/src/sdk.rs b/packages/rs-sdk-ffi/src/sdk.rs index 4d60eeb0ec6..812b196e6e2 100644 --- a/packages/rs-sdk-ffi/src/sdk.rs +++ b/packages/rs-sdk-ffi/src/sdk.rs @@ -619,6 +619,60 @@ pub unsafe extern "C" fn dash_sdk_create_with_callbacks( result } +/// Create a new SDK instance using SPV-synced quorum data for proof verification. +/// +/// Instead of fetching quorum keys from a trusted HTTP endpoint, this uses +/// quorum data from the SPV client's locally synced masternode list. +/// +/// # Safety +/// - `config` must be a valid pointer to a DashSDKConfig structure +/// - `spv_client` must be a valid pointer to an FFIDashSpvClient that outlives the SDK +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_create_with_spv_context( + config: *const DashSDKConfig, + spv_client: *mut std::os::raw::c_void, +) -> DashSDKResult { + if config.is_null() { + return DashSDKResult::error(DashSDKError::new( + DashSDKErrorCode::InvalidParameter, + "Config is null".to_string(), + )); + } + + if spv_client.is_null() { + return DashSDKResult::error(DashSDKError::new( + DashSDKErrorCode::InvalidParameter, + "SPV client pointer is null".to_string(), + )); + } + + info!("dash_sdk_create_with_spv_context: creating SDK with SPV quorum provider"); + + let context_provider = crate::spv_context_provider::SpvContextProvider::new(spv_client); + let wrapper = Box::new(ContextProviderWrapper::new(context_provider)); + let context_provider_handle = Box::into_raw(wrapper) as *mut ContextProviderHandle; + + let config_ref = &*config; + let extended_config = DashSDKConfigExtended { + base_config: DashSDKConfig { + network: config_ref.network, + dapi_addresses: config_ref.dapi_addresses, + skip_asset_lock_proof_verification: config_ref.skip_asset_lock_proof_verification, + request_retry_count: config_ref.request_retry_count, + request_timeout_ms: config_ref.request_timeout_ms, + }, + context_provider: context_provider_handle, + core_sdk_handle: std::ptr::null_mut(), + }; + + let result = dash_sdk_create_extended(&extended_config); + + // Reclaim the wrapper -- the SDK has already cloned what it needs + let _ = Box::from_raw(context_provider_handle as *mut ContextProviderWrapper); + + result +} + /// Get the current network the SDK is connected to /// /// # Safety diff --git a/packages/rs-sdk-ffi/src/spv_context_provider.rs b/packages/rs-sdk-ffi/src/spv_context_provider.rs new file mode 100644 index 00000000000..2ee7c9d3fbf --- /dev/null +++ b/packages/rs-sdk-ffi/src/spv_context_provider.rs @@ -0,0 +1,117 @@ +//! SPV-based Context Provider +//! +//! Implements `ContextProvider` by calling the SPV client's FFI functions +//! directly in Rust, avoiding a round-trip through Swift callbacks. + +use std::os::raw::c_void; +use std::sync::Arc; + +use dash_sdk::dpp::data_contract::TokenConfiguration; +use dash_sdk::dpp::prelude::{CoreBlockHeight, DataContract, Identifier}; +use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::error::ContextProviderError; +use drive_proof_verifier::ContextProvider; + +/// Context provider backed by an SPV client's synced masternode data. +/// +/// Calls `ffi_dash_spv_get_quorum_public_key` and +/// `ffi_dash_spv_get_platform_activation_height` from the `dash-spv-ffi` crate +/// directly, without crossing the FFI boundary into Swift. +pub struct SpvContextProvider { + /// Raw pointer to the `FFIDashSpvClient`. The SPV client must outlive this provider. + spv_client: *mut c_void, +} + +// SAFETY: The pointer is only used inside the FFI calls which are themselves thread-safe +// (the SPV client uses internal locking). +unsafe impl Send for SpvContextProvider {} +unsafe impl Sync for SpvContextProvider {} + +impl SpvContextProvider { + /// Create a new SPV context provider. + /// + /// # Safety + /// `spv_client` must be a valid `*mut FFIDashSpvClient` that outlives this provider. + pub unsafe fn new(spv_client: *mut c_void) -> Self { + Self { spv_client } + } +} + +impl ContextProvider for SpvContextProvider { + fn get_quorum_public_key( + &self, + quorum_type: u32, + quorum_hash: [u8; 32], + core_chain_locked_height: u32, + ) -> Result<[u8; 48], ContextProviderError> { + let client = self.spv_client as *mut dash_spv_ffi::FFIDashSpvClient; + + let mut public_key = [0u8; 48]; + + let result = unsafe { + dash_spv_ffi::ffi_dash_spv_get_quorum_public_key( + client, + quorum_type, + quorum_hash.as_ptr(), + core_chain_locked_height, + public_key.as_mut_ptr(), + 48, + ) + }; + + if result.error_code == 0 { + Ok(public_key) + } else { + let error_msg = if result.error_message.is_null() { + format!( + "SPV quorum key lookup failed: error code {}", + result.error_code + ) + } else { + let c_str = unsafe { std::ffi::CStr::from_ptr(result.error_message) }; + c_str.to_string_lossy().into_owned() + }; + Err(ContextProviderError::Generic(error_msg)) + } + } + + fn get_platform_activation_height(&self) -> Result { + let client = self.spv_client as *mut dash_spv_ffi::FFIDashSpvClient; + + let mut height = 0u32; + + let result = unsafe { + dash_spv_ffi::ffi_dash_spv_get_platform_activation_height(client, &mut height) + }; + + if result.error_code == 0 { + Ok(height) + } else { + let error_msg = if result.error_message.is_null() { + format!( + "SPV platform activation height lookup failed: error code {}", + result.error_code + ) + } else { + let c_str = unsafe { std::ffi::CStr::from_ptr(result.error_message) }; + c_str.to_string_lossy().into_owned() + }; + Err(ContextProviderError::Generic(error_msg)) + } + } + + fn get_data_contract( + &self, + _data_contract_id: &Identifier, + _platform_version: &PlatformVersion, + ) -> Result>, ContextProviderError> { + Ok(None) + } + + fn get_token_configuration( + &self, + _token_id: &Identifier, + ) -> Result, ContextProviderError> { + Ok(None) + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/SPV/SPVContextProvider.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/SPV/SPVContextProvider.swift deleted file mode 100644 index 151f12b3ba0..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/SPV/SPVContextProvider.swift +++ /dev/null @@ -1,104 +0,0 @@ -import DashSDKFFI -import Foundation - -// MARK: - C Callback: Get quorum public key from SPV - -/// C-compatible callback that bridges Platform SDK quorum key requests to the SPV client. -/// -/// `handle` is the raw `FFIDashSpvClient*` pointer, passed as `core_handle` in -/// `ContextProviderCallbacks`. The SPV FFI function `ffi_dash_spv_get_quorum_public_key` -/// retrieves the BLS public key for the given quorum from the locally synced masternode list. -/// -/// - Parameters: -/// - handle: Raw pointer to `FFIDashSpvClient` (cast from `void*`). -/// - quorumType: The quorum type identifier. -/// - quorumHash: Pointer to 32-byte quorum hash. -/// - coreChainLockedHeight: The core chain locked height for the request. -/// - outPubkey: Pointer to a 48-byte output buffer for the BLS public key. -/// - Returns: `CallbackResult` with `success: true` on success or error details on failure. -func spvGetQuorumPublicKey( - handle: UnsafeMutableRawPointer?, - quorumType: UInt32, - quorumHash: UnsafePointer?, - coreChainLockedHeight: UInt32, - outPubkey: UnsafeMutablePointer? -) -> CallbackResult { - guard let handle = handle else { - return CallbackResult(success: false, error_code: -1, error_message: nil) - } - guard let quorumHash = quorumHash, let outPubkey = outPubkey else { - return CallbackResult(success: false, error_code: -2, error_message: nil) - } - - let client = handle.assumingMemoryBound(to: FFIDashSpvClient.self) - let ffiResult = ffi_dash_spv_get_quorum_public_key( - client, quorumType, quorumHash, coreChainLockedHeight, outPubkey, 48 - ) - - if ffiResult.error_code == 0 { - return CallbackResult(success: true, error_code: 0, error_message: nil) - } else { - return CallbackResult( - success: false, - error_code: ffiResult.error_code, - error_message: ffiResult.error_message - ) - } -} - -// MARK: - C Callback: Get platform activation height from SPV - -/// C-compatible callback that bridges Platform SDK activation height requests to the SPV client. -/// -/// `handle` is the raw `FFIDashSpvClient*` pointer. The SPV FFI function -/// `ffi_dash_spv_get_platform_activation_height` returns the core block height at which -/// Platform was activated, as determined from the locally synced chain. -/// -/// - Parameters: -/// - handle: Raw pointer to `FFIDashSpvClient` (cast from `void*`). -/// - outHeight: Pointer to a `UInt32` where the activation height will be written. -/// - Returns: `CallbackResult` with `success: true` on success or error details on failure. -func spvGetPlatformActivationHeight( - handle: UnsafeMutableRawPointer?, - outHeight: UnsafeMutablePointer? -) -> CallbackResult { - guard let handle = handle else { - return CallbackResult(success: false, error_code: -1, error_message: nil) - } - guard let outHeight = outHeight else { - return CallbackResult(success: false, error_code: -2, error_message: nil) - } - - let client = handle.assumingMemoryBound(to: FFIDashSpvClient.self) - let ffiResult = ffi_dash_spv_get_platform_activation_height(client, outHeight) - - if ffiResult.error_code == 0 { - return CallbackResult(success: true, error_code: 0, error_message: nil) - } else { - return CallbackResult( - success: false, - error_code: ffiResult.error_code, - error_message: ffiResult.error_message - ) - } -} - -// MARK: - Helper to create ContextProviderCallbacks - -/// Creates a `ContextProviderCallbacks` struct configured to use the SPV client -/// for quorum key lookups and platform activation height. -/// -/// The returned struct is suitable for passing to `dash_sdk_create_with_callbacks`. -/// The SPV client must remain alive for the entire lifetime of the SDK instance -/// that uses these callbacks. -/// -/// - Parameter spvClientHandle: Raw pointer to the `FFIDashSpvClient`, obtained -/// from `SPVClient.unsafeFFIClientPointer`. -/// - Returns: A `ContextProviderCallbacks` ready to pass to `dash_sdk_create_with_callbacks`. -func makeSPVContextProviderCallbacks(spvClientHandle: UnsafeMutableRawPointer) -> ContextProviderCallbacks { - return ContextProviderCallbacks( - core_handle: spvClientHandle, - get_platform_activation_height: spvGetPlatformActivationHeight, - get_quorum_public_key: spvGetQuorumPublicKey - ) -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift index 39b61f2c077..dbfb2fdb025 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift @@ -227,8 +227,6 @@ public final class SDK: @unchecked Sendable { config.request_retry_count = 1 config.request_timeout_ms = 8000 - var callbacks = makeSPVContextProviderCallbacks(spvClientHandle: spvClientHandle) - let result: DashSDKResult let forceLocal = UserDefaults.standard.bool(forKey: "useLocalhostPlatform") if forceLocal { @@ -237,10 +235,10 @@ public final class SDK: @unchecked Sendable { result = localAddresses.withCString { addressesCStr -> DashSDKResult in var mutableConfig = config mutableConfig.dapi_addresses = addressesCStr - return dash_sdk_create_with_callbacks(&mutableConfig, &callbacks) + return dash_sdk_create_with_spv_context(&mutableConfig, spvClientHandle) } } else { - result = dash_sdk_create_with_callbacks(&config, &callbacks) + result = dash_sdk_create_with_spv_context(&config, spvClientHandle) } if result.error != nil { From d9e478ebfe6e61c0b3543f40398eacf2dca07ec6 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 31 Mar 2026 15:00:50 +0300 Subject: [PATCH 3/7] feat(platform-wallet): add pure-Rust SpvContextProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SpvContextProvider to platform-wallet behind the `spv-context` feature flag. It holds Arc> + Network and implements the ContextProvider trait by reading quorum data directly from the in-memory masternode list — zero FFI involvement. The rs-sdk-ffi bridge (which calls dash-spv-ffi functions as Rust calls in the same binary) remains as the pragmatic adapter until dash-spv-ffi exposes a public accessor for FFIDashSpvClient's MasternodeListEngine (currently pub(crate)). Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 3 + packages/rs-platform-wallet/Cargo.toml | 6 + packages/rs-platform-wallet/src/lib.rs | 3 + .../src/spv_context_provider.rs | 142 ++++++++++++++++++ .../rs-sdk-ffi/src/spv_context_provider.rs | 16 +- 5 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 packages/rs-platform-wallet/src/spv_context_provider.rs diff --git a/Cargo.lock b/Cargo.lock index c36f03ebfc2..af5c20b7024 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4869,7 +4869,9 @@ name = "platform-wallet" version = "3.1.0-dev.1" dependencies = [ "async-trait", + "dash-context-provider", "dash-sdk", + "dash-spv", "dashcore", "dpp", "indexmap 2.13.0", @@ -4878,6 +4880,7 @@ dependencies = [ "platform-encryption", "rand 0.8.5", "thiserror 1.0.69", + "tokio", ] [[package]] diff --git a/packages/rs-platform-wallet/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index c30e7e43e9a..5a7f80a96e9 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -19,6 +19,11 @@ key-wallet-manager = { workspace = true, optional = true } # Core dependencies dashcore = { workspace = true } +# SPV context provider dependencies (optional) +dash-spv = { workspace = true, optional = true } +dash-context-provider = { path = "../rs-context-provider", optional = true } +tokio = { version = "1.41", optional = true } + # Standard dependencies thiserror = "1.0" async-trait = "0.1" @@ -35,3 +40,4 @@ default = ["bls", "eddsa", "manager"] bls = ["key-wallet/bls"] eddsa = ["key-wallet/eddsa"] manager = ["key-wallet-manager"] +spv-context = ["dash-spv", "dash-context-provider", "tokio"] diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index 694bbb2d14a..8b8f923a4db 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -12,6 +12,9 @@ pub mod identity_manager; pub mod managed_identity; pub mod platform_wallet_info; +#[cfg(feature = "spv-context")] +pub mod spv_context_provider; + // Re-export main types at crate root pub use block_time::BlockTime; pub use contact_request::ContactRequest; diff --git a/packages/rs-platform-wallet/src/spv_context_provider.rs b/packages/rs-platform-wallet/src/spv_context_provider.rs new file mode 100644 index 00000000000..24d62766302 --- /dev/null +++ b/packages/rs-platform-wallet/src/spv_context_provider.rs @@ -0,0 +1,142 @@ +//! SPV-based Context Provider +//! +//! Pure Rust implementation that reads quorum data directly from a +//! [`MasternodeListEngine`], with no FFI calls. +//! +//! # Architecture +//! +//! The [`SpvContextProvider`] holds an `Arc>` +//! (shared with the SPV client) and reads quorum public keys by looking up +//! the masternode list closest to the requested core chain-locked height. +//! +//! This design eliminates the need for FFI round-trips: the same in-memory +//! masternode list engine that the SPV client populates during sync is read +//! directly by the Platform SDK's proof verifier. +//! +//! # Usage +//! +//! ```ignore +//! use std::sync::Arc; +//! use tokio::sync::RwLock; +//! use dash_spv::MasternodeListEngine; +//! use dashcore::Network; +//! use platform_wallet::spv_context_provider::SpvContextProvider; +//! +//! let engine: Arc> = /* from DashSpvClient */; +//! let provider = SpvContextProvider::new(engine, Network::Testnet); +//! ``` + +use std::sync::Arc; + +use dash_context_provider::ContextProvider; +use dash_context_provider::ContextProviderError; +use dash_spv::LLMQType; +use dash_spv::MasternodeListEngine; +use dashcore::hashes::Hash; +use dashcore::Network; +use dashcore::QuorumHash; +use dpp::data_contract::TokenConfiguration; +use dpp::prelude::{CoreBlockHeight, DataContract, Identifier}; +use dpp::version::PlatformVersion; +use tokio::sync::RwLock; + +/// Context provider backed by an SPV client's synced masternode data. +/// +/// Reads quorum public keys directly from the [`MasternodeListEngine`] +/// without any FFI calls. The engine is shared with the SPV client via +/// `Arc>`, so all data stays in-process. +pub struct SpvContextProvider { + masternode_engine: Arc>, + network: Network, +} + +impl SpvContextProvider { + /// Create a new SPV context provider. + /// + /// # Arguments + /// + /// * `masternode_engine` - Shared reference to the masternode list engine, + /// typically obtained from [`DashSpvClient::masternode_list_engine()`]. + /// * `network` - The Dash network (mainnet, testnet, devnet, etc.). + pub fn new(masternode_engine: Arc>, network: Network) -> Self { + Self { + masternode_engine, + network, + } + } +} + +impl ContextProvider for SpvContextProvider { + fn get_quorum_public_key( + &self, + quorum_type: u32, + quorum_hash: [u8; 32], + core_chain_locked_height: u32, + ) -> Result<[u8; 48], ContextProviderError> { + let llmq_type: LLMQType = (quorum_type as u8).into(); + let quorum_hash = QuorumHash::from_byte_array(quorum_hash); + + let engine = self.masternode_engine.blocking_read(); + let (before, _after) = engine.masternode_lists_around_height(core_chain_locked_height); + + let ml = before.ok_or_else(|| { + ContextProviderError::InvalidQuorum(format!( + "No masternode list found at or before height {}", + core_chain_locked_height + )) + })?; + + let list_height = ml.known_height; + + let quorums = ml.quorums.get(&llmq_type).ok_or_else(|| { + ContextProviderError::InvalidQuorum(format!( + "No quorums of type {} found at list height {} (requested {})", + quorum_type, list_height, core_chain_locked_height + )) + })?; + + let quorum = quorums.get(&quorum_hash).ok_or_else(|| { + ContextProviderError::InvalidQuorum(format!( + "Quorum not found: type {} at list height {} (requested {}) \ + with hash {:x} (masternode list has {} quorums of this type)", + quorum_type, + list_height, + core_chain_locked_height, + quorum_hash, + quorums.len() + )) + })?; + + let pubkey_bytes: &[u8; 48] = quorum.quorum_entry.quorum_public_key.as_ref(); + Ok(*pubkey_bytes) + } + + fn get_platform_activation_height(&self) -> Result { + let height = match self.network { + Network::Mainnet => 1_888_888, + Network::Testnet => 1_289_520, + Network::Devnet => 1, + _ => 0, + }; + Ok(height) + } + + fn get_data_contract( + &self, + _data_contract_id: &Identifier, + _platform_version: &PlatformVersion, + ) -> Result>, ContextProviderError> { + // Data contract lookup is handled by the SDK's contract cache, + // not the SPV layer. + Ok(None) + } + + fn get_token_configuration( + &self, + _token_id: &Identifier, + ) -> Result, ContextProviderError> { + // Token configuration lookup is handled by the SDK's contract cache, + // not the SPV layer. + Ok(None) + } +} diff --git a/packages/rs-sdk-ffi/src/spv_context_provider.rs b/packages/rs-sdk-ffi/src/spv_context_provider.rs index 2ee7c9d3fbf..702a8f179f1 100644 --- a/packages/rs-sdk-ffi/src/spv_context_provider.rs +++ b/packages/rs-sdk-ffi/src/spv_context_provider.rs @@ -1,7 +1,17 @@ -//! SPV-based Context Provider +//! SPV-based Context Provider (FFI bridge) //! -//! Implements `ContextProvider` by calling the SPV client's FFI functions -//! directly in Rust, avoiding a round-trip through Swift callbacks. +//! Implements `ContextProvider` by calling the `dash-spv-ffi` crate's +//! FFI functions directly in Rust (same binary, no actual FFI boundary +//! crossing), avoiding a round-trip through Swift callbacks. +//! +//! # Migration path +//! +//! The long-term replacement is [`platform_wallet::spv_context_provider::SpvContextProvider`], +//! which holds an `Arc>` directly and has zero FFI +//! involvement. To use it, `dash-spv-ffi` needs to expose a public accessor for +//! the `MasternodeListEngine` inside `FFIDashSpvClient` (its `inner` field is +//! currently `pub(crate)`). Once that accessor exists, this bridge module can be +//! removed and the pure-Rust provider used instead. use std::os::raw::c_void; use std::sync::Arc; From 8d2bb2764f65c25d16441f2b4c4038f9927b3800 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 2 Apr 2026 17:24:59 +0300 Subject: [PATCH 4/7] fix: address CodeRabbit review feedback on SPV quorums PR - AppState: trigger SDK reconnect when useTrustedQuorumFallback changes so the running app reflects the new setting immediately - UnifiedAppState: nil out old SDK before destroying SPV client during network switch to prevent use-after-free on the context provider's core_handle pointer - Cargo.toml: explicitly enable tokio "sync" feature for blocking_read() support - spv_context_provider.rs: replace lossy `as u8` cast with try_from + error, add comment explaining why blocking_read is safe, return error for unsupported networks instead of silent 0 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/rs-platform-wallet/Cargo.toml | 2 +- .../src/spv_context_provider.rs | 27 +++++++++++++------ .../SwiftExampleApp/AppState.swift | 2 ++ .../SwiftExampleApp/UnifiedAppState.swift | 11 ++++++-- 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/packages/rs-platform-wallet/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index 5a7f80a96e9..e9822c09ca3 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -22,7 +22,7 @@ dashcore = { workspace = true } # SPV context provider dependencies (optional) dash-spv = { workspace = true, optional = true } dash-context-provider = { path = "../rs-context-provider", optional = true } -tokio = { version = "1.41", optional = true } +tokio = { version = "1.41", features = ["sync"], optional = true } # Standard dependencies thiserror = "1.0" diff --git a/packages/rs-platform-wallet/src/spv_context_provider.rs b/packages/rs-platform-wallet/src/spv_context_provider.rs index 24d62766302..81f08529813 100644 --- a/packages/rs-platform-wallet/src/spv_context_provider.rs +++ b/packages/rs-platform-wallet/src/spv_context_provider.rs @@ -73,9 +73,18 @@ impl ContextProvider for SpvContextProvider { quorum_hash: [u8; 32], core_chain_locked_height: u32, ) -> Result<[u8; 48], ContextProviderError> { - let llmq_type: LLMQType = (quorum_type as u8).into(); + let quorum_type_u8 = u8::try_from(quorum_type).map_err(|_| { + ContextProviderError::InvalidQuorum(format!( + "Quorum type {} exceeds u8 range", + quorum_type + )) + })?; + let llmq_type: LLMQType = quorum_type_u8.into(); let quorum_hash = QuorumHash::from_byte_array(quorum_hash); + // NOTE: blocking_read() is used because ContextProvider::get_quorum_public_key + // is a sync trait method. The SDK calls it from a blocking context (inside + // tokio::task::block_in_place or from a sync thread), so this is safe. let engine = self.masternode_engine.blocking_read(); let (before, _after) = engine.masternode_lists_around_height(core_chain_locked_height); @@ -112,13 +121,15 @@ impl ContextProvider for SpvContextProvider { } fn get_platform_activation_height(&self) -> Result { - let height = match self.network { - Network::Mainnet => 1_888_888, - Network::Testnet => 1_289_520, - Network::Devnet => 1, - _ => 0, - }; - Ok(height) + match self.network { + Network::Mainnet => Ok(1_888_888), + Network::Testnet => Ok(1_289_520), + Network::Devnet => Ok(1), + _ => Err(ContextProviderError::Generic(format!( + "Platform activation height unknown for network {:?}", + self.network + ))), + } } fn get_data_contract( diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift index 4994d052923..8a9a7da6677 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift @@ -45,6 +45,8 @@ class AppState: ObservableObject { @Published var useTrustedQuorumFallback: Bool { didSet { UserDefaults.standard.set(useTrustedQuorumFallback, forKey: "useTrustedQuorumFallback") + guard modelContext != nil else { return } + Task { await switchNetwork(to: currentNetwork) } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/UnifiedAppState.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/UnifiedAppState.swift index 155703ce881..611e9fac3ff 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/UnifiedAppState.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/UnifiedAppState.swift @@ -123,10 +123,17 @@ class UnifiedAppState: ObservableObject { // Handle network switching - called when platformState.currentNetwork changes func handleNetworkSwitch(to network: AppNetwork) async { - // Switch wallet service to new network (which recreates the SPV client) + // Tear down the old SDK BEFORE destroying the SPV client to avoid + // use-after-free: the SDK's context provider holds a pointer to the + // SPV client, so the SDK must be released first. + await MainActor.run { + platformState.sdk = nil + } + + // Now safe to destroy the old SPV client and create a new one await walletService.switchNetwork(to: network) - // Update the SPV client handle in platform state (the old handle is now invalid) + // Update the SPV client handle and rebuild the SDK let newSpvHandle = walletService.spvClientHandle await MainActor.run { platformState.updateSPVClientHandle(newSpvHandle) From fa7e531471b68406395fcb1c657a180398dfe104 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 2 Apr 2026 17:36:05 +0300 Subject: [PATCH 5/7] feat: use pure-Rust SpvContextProvider, bump rust-dashcore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump rust-dashcore to 3f65002 which includes the FFIDashSpvClient::masternode_list_engine() accessor (PR #608). dash_sdk_create_with_spv_context now: 1. Casts the void* to FFIDashSpvClient 2. Extracts Arc> directly 3. Creates platform_wallet::SpvContextProvider (pure Rust) The FFI bridge module (rs-sdk-ffi/src/spv_context_provider.rs) is deleted — quorum lookups go directly from the Platform SDK through the pure-Rust provider to the in-memory masternode list, with zero FFI involvement in the data path. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 21 +-- Cargo.toml | 14 +- packages/rs-sdk-ffi/Cargo.toml | 3 + packages/rs-sdk-ffi/src/lib.rs | 1 - packages/rs-sdk-ffi/src/sdk.rs | 30 ++++- .../rs-sdk-ffi/src/spv_context_provider.rs | 127 ------------------ 6 files changed, 48 insertions(+), 148 deletions(-) delete mode 100644 packages/rs-sdk-ffi/src/spv_context_provider.rs diff --git a/Cargo.lock b/Cargo.lock index af5c20b7024..cf1c1b4214b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1607,7 +1607,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=3f6500200042a1388d34832214c4e9dc3ee72df8#3f6500200042a1388d34832214c4e9dc3ee72df8" dependencies = [ "anyhow", "async-trait", @@ -1640,7 +1640,7 @@ dependencies = [ [[package]] name = "dash-spv-ffi" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=3f6500200042a1388d34832214c4e9dc3ee72df8#3f6500200042a1388d34832214c4e9dc3ee72df8" dependencies = [ "cbindgen 0.29.2", "clap", @@ -1665,7 +1665,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=3f6500200042a1388d34832214c4e9dc3ee72df8#3f6500200042a1388d34832214c4e9dc3ee72df8" dependencies = [ "anyhow", "base64-compat", @@ -1690,12 +1690,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=3f6500200042a1388d34832214c4e9dc3ee72df8#3f6500200042a1388d34832214c4e9dc3ee72df8" [[package]] name = "dashcore-rpc" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=3f6500200042a1388d34832214c4e9dc3ee72df8#3f6500200042a1388d34832214c4e9dc3ee72df8" dependencies = [ "dashcore-rpc-json", "hex", @@ -1708,7 +1708,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=3f6500200042a1388d34832214c4e9dc3ee72df8#3f6500200042a1388d34832214c4e9dc3ee72df8" dependencies = [ "bincode", "dashcore", @@ -1723,7 +1723,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=3f6500200042a1388d34832214c4e9dc3ee72df8#3f6500200042a1388d34832214c4e9dc3ee72df8" dependencies = [ "bincode", "dashcore-private", @@ -3829,7 +3829,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=3f6500200042a1388d34832214c4e9dc3ee72df8#3f6500200042a1388d34832214c4e9dc3ee72df8" dependencies = [ "aes", "async-trait", @@ -3857,7 +3857,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=3f6500200042a1388d34832214c4e9dc3ee72df8#3f6500200042a1388d34832214c4e9dc3ee72df8" dependencies = [ "cbindgen 0.29.2", "dashcore", @@ -3872,7 +3872,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=3f6500200042a1388d34832214c4e9dc3ee72df8#3f6500200042a1388d34832214c4e9dc3ee72df8" dependencies = [ "async-trait", "bincode", @@ -5863,6 +5863,7 @@ dependencies = [ "libc", "log", "once_cell", + "platform-wallet", "platform-wallet-ffi", "rand 0.8.5", "reqwest 0.12.28", diff --git a/Cargo.toml b/Cargo.toml index 2a27563d527..24864c59108 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,13 +47,13 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" } -dash-spv-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "3f6500200042a1388d34832214c4e9dc3ee72df8" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "3f6500200042a1388d34832214c4e9dc3ee72df8" } +dash-spv-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "3f6500200042a1388d34832214c4e9dc3ee72df8" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "3f6500200042a1388d34832214c4e9dc3ee72df8" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "3f6500200042a1388d34832214c4e9dc3ee72df8" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "3f6500200042a1388d34832214c4e9dc3ee72df8" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "3f6500200042a1388d34832214c4e9dc3ee72df8" } # Optimize heavy crypto crates even in dev/test builds so that # Halo 2 proof generation and verification run at near-release speed. diff --git a/packages/rs-sdk-ffi/Cargo.toml b/packages/rs-sdk-ffi/Cargo.toml index ba33789c3fc..52e69507fed 100644 --- a/packages/rs-sdk-ffi/Cargo.toml +++ b/packages/rs-sdk-ffi/Cargo.toml @@ -28,6 +28,9 @@ dash-spv-ffi = { workspace = true } # Platform Wallet integration for DashPay support platform-wallet-ffi = { path = "../rs-platform-wallet-ffi" } +# Platform Wallet (pure-Rust SPV context provider) +platform-wallet = { path = "../rs-platform-wallet", features = ["spv-context"] } + # FFI and serialization serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/packages/rs-sdk-ffi/src/lib.rs b/packages/rs-sdk-ffi/src/lib.rs index d348d20cc0e..0a5e2277f69 100644 --- a/packages/rs-sdk-ffi/src/lib.rs +++ b/packages/rs-sdk-ffi/src/lib.rs @@ -26,7 +26,6 @@ mod sdk; mod shielded; mod signer; mod signer_simple; -pub mod spv_context_provider; mod system; mod token; mod types; diff --git a/packages/rs-sdk-ffi/src/sdk.rs b/packages/rs-sdk-ffi/src/sdk.rs index 812b196e6e2..52476562838 100644 --- a/packages/rs-sdk-ffi/src/sdk.rs +++ b/packages/rs-sdk-ffi/src/sdk.rs @@ -646,13 +646,37 @@ pub unsafe extern "C" fn dash_sdk_create_with_spv_context( )); } - info!("dash_sdk_create_with_spv_context: creating SDK with SPV quorum provider"); + info!("dash_sdk_create_with_spv_context: creating SDK with pure-Rust SPV context provider"); - let context_provider = crate::spv_context_provider::SpvContextProvider::new(spv_client); + let config_ref = &*config; + + // Cast to the actual FFIDashSpvClient type and obtain the masternode list engine. + let ffi_client = &*(spv_client as *mut dash_spv_ffi::FFIDashSpvClient); + let engine = match ffi_client.masternode_list_engine() { + Some(engine) => engine, + None => { + return DashSDKResult::error(DashSDKError::new( + DashSDKErrorCode::InvalidParameter, + "SPV client has no masternode list engine".to_string(), + )); + } + }; + + // Convert DashSDKNetwork → dashcore::Network + let network = match config_ref.network { + DashSDKNetwork::SDKMainnet => Network::Mainnet, + DashSDKNetwork::SDKTestnet => Network::Testnet, + DashSDKNetwork::SDKRegtest => Network::Regtest, + DashSDKNetwork::SDKDevnet => Network::Devnet, + DashSDKNetwork::SDKLocal => Network::Regtest, + }; + + // Create the pure-Rust SPV context provider (no FFI bridge needed). + let context_provider = + platform_wallet::spv_context_provider::SpvContextProvider::new(engine, network); let wrapper = Box::new(ContextProviderWrapper::new(context_provider)); let context_provider_handle = Box::into_raw(wrapper) as *mut ContextProviderHandle; - let config_ref = &*config; let extended_config = DashSDKConfigExtended { base_config: DashSDKConfig { network: config_ref.network, diff --git a/packages/rs-sdk-ffi/src/spv_context_provider.rs b/packages/rs-sdk-ffi/src/spv_context_provider.rs deleted file mode 100644 index 702a8f179f1..00000000000 --- a/packages/rs-sdk-ffi/src/spv_context_provider.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! SPV-based Context Provider (FFI bridge) -//! -//! Implements `ContextProvider` by calling the `dash-spv-ffi` crate's -//! FFI functions directly in Rust (same binary, no actual FFI boundary -//! crossing), avoiding a round-trip through Swift callbacks. -//! -//! # Migration path -//! -//! The long-term replacement is [`platform_wallet::spv_context_provider::SpvContextProvider`], -//! which holds an `Arc>` directly and has zero FFI -//! involvement. To use it, `dash-spv-ffi` needs to expose a public accessor for -//! the `MasternodeListEngine` inside `FFIDashSpvClient` (its `inner` field is -//! currently `pub(crate)`). Once that accessor exists, this bridge module can be -//! removed and the pure-Rust provider used instead. - -use std::os::raw::c_void; -use std::sync::Arc; - -use dash_sdk::dpp::data_contract::TokenConfiguration; -use dash_sdk::dpp::prelude::{CoreBlockHeight, DataContract, Identifier}; -use dash_sdk::dpp::version::PlatformVersion; -use dash_sdk::error::ContextProviderError; -use drive_proof_verifier::ContextProvider; - -/// Context provider backed by an SPV client's synced masternode data. -/// -/// Calls `ffi_dash_spv_get_quorum_public_key` and -/// `ffi_dash_spv_get_platform_activation_height` from the `dash-spv-ffi` crate -/// directly, without crossing the FFI boundary into Swift. -pub struct SpvContextProvider { - /// Raw pointer to the `FFIDashSpvClient`. The SPV client must outlive this provider. - spv_client: *mut c_void, -} - -// SAFETY: The pointer is only used inside the FFI calls which are themselves thread-safe -// (the SPV client uses internal locking). -unsafe impl Send for SpvContextProvider {} -unsafe impl Sync for SpvContextProvider {} - -impl SpvContextProvider { - /// Create a new SPV context provider. - /// - /// # Safety - /// `spv_client` must be a valid `*mut FFIDashSpvClient` that outlives this provider. - pub unsafe fn new(spv_client: *mut c_void) -> Self { - Self { spv_client } - } -} - -impl ContextProvider for SpvContextProvider { - fn get_quorum_public_key( - &self, - quorum_type: u32, - quorum_hash: [u8; 32], - core_chain_locked_height: u32, - ) -> Result<[u8; 48], ContextProviderError> { - let client = self.spv_client as *mut dash_spv_ffi::FFIDashSpvClient; - - let mut public_key = [0u8; 48]; - - let result = unsafe { - dash_spv_ffi::ffi_dash_spv_get_quorum_public_key( - client, - quorum_type, - quorum_hash.as_ptr(), - core_chain_locked_height, - public_key.as_mut_ptr(), - 48, - ) - }; - - if result.error_code == 0 { - Ok(public_key) - } else { - let error_msg = if result.error_message.is_null() { - format!( - "SPV quorum key lookup failed: error code {}", - result.error_code - ) - } else { - let c_str = unsafe { std::ffi::CStr::from_ptr(result.error_message) }; - c_str.to_string_lossy().into_owned() - }; - Err(ContextProviderError::Generic(error_msg)) - } - } - - fn get_platform_activation_height(&self) -> Result { - let client = self.spv_client as *mut dash_spv_ffi::FFIDashSpvClient; - - let mut height = 0u32; - - let result = unsafe { - dash_spv_ffi::ffi_dash_spv_get_platform_activation_height(client, &mut height) - }; - - if result.error_code == 0 { - Ok(height) - } else { - let error_msg = if result.error_message.is_null() { - format!( - "SPV platform activation height lookup failed: error code {}", - result.error_code - ) - } else { - let c_str = unsafe { std::ffi::CStr::from_ptr(result.error_message) }; - c_str.to_string_lossy().into_owned() - }; - Err(ContextProviderError::Generic(error_msg)) - } - } - - fn get_data_contract( - &self, - _data_contract_id: &Identifier, - _platform_version: &PlatformVersion, - ) -> Result>, ContextProviderError> { - Ok(None) - } - - fn get_token_configuration( - &self, - _token_id: &Identifier, - ) -> Result, ContextProviderError> { - Ok(None) - } -} From 54b6ed0cdb8a224139ec4cfb851854273ea6b5ac Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 2 Apr 2026 17:41:46 +0300 Subject: [PATCH 6/7] fix: eliminate race between didSet and handleNetworkSwitch Remove the switchNetwork() call from currentNetwork.didSet to prevent it racing with UnifiedAppState.handleNetworkSwitch(). The didSet now only persists the preference. handleNetworkSwitch() is the single coordinator: it nils the old SDK, switches the wallet, updates the SPV handle, then calls switchNetwork() to rebuild the SDK. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../SwiftExampleApp/SwiftExampleApp/AppState.swift | 6 +++--- .../SwiftExampleApp/SwiftExampleApp/UnifiedAppState.swift | 3 +++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift index 8a9a7da6677..fcf42d0aa36 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift @@ -18,9 +18,9 @@ class AppState: ObservableObject { @Published var currentNetwork: AppNetwork { didSet { UserDefaults.standard.set(currentNetwork.rawValue, forKey: "currentNetwork") - Task { - await switchNetwork(to: currentNetwork) - } + // NOTE: SDK rebuild is handled by UnifiedAppState.handleNetworkSwitch(), + // which coordinates SPV client teardown and handle update before rebuilding. + // Do NOT call switchNetwork here to avoid racing with handleNetworkSwitch. } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/UnifiedAppState.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/UnifiedAppState.swift index 611e9fac3ff..78829ceae10 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/UnifiedAppState.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/UnifiedAppState.swift @@ -139,6 +139,9 @@ class UnifiedAppState: ObservableObject { platformState.updateSPVClientHandle(newSpvHandle) } + // Rebuild the Platform SDK with the new SPV handle + await platformState.switchNetwork(to: network) + // Reinitialize shielded service for the new network initializeShieldedService() From cfde22437792c5e93f65c584b81ab61fa4b35670 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 2 Apr 2026 17:45:29 +0300 Subject: [PATCH 7/7] fix: use try_read() instead of blocking_read(), add dep: prefix - Replace blocking_read() with try_read() in SpvContextProvider to avoid panicking when called from within a Tokio async context (proof verification runs inside async tasks) - Use dep: prefix syntax in spv-context feature to suppress implicit feature creation for optional dependencies (Cargo 2021 edition) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/rs-platform-wallet/Cargo.toml | 2 +- .../rs-platform-wallet/src/spv_context_provider.rs | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/rs-platform-wallet/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index e9822c09ca3..4ef35365544 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -40,4 +40,4 @@ default = ["bls", "eddsa", "manager"] bls = ["key-wallet/bls"] eddsa = ["key-wallet/eddsa"] manager = ["key-wallet-manager"] -spv-context = ["dash-spv", "dash-context-provider", "tokio"] +spv-context = ["dep:dash-spv", "dep:dash-context-provider", "dep:tokio"] diff --git a/packages/rs-platform-wallet/src/spv_context_provider.rs b/packages/rs-platform-wallet/src/spv_context_provider.rs index 81f08529813..915c1018d3c 100644 --- a/packages/rs-platform-wallet/src/spv_context_provider.rs +++ b/packages/rs-platform-wallet/src/spv_context_provider.rs @@ -82,10 +82,14 @@ impl ContextProvider for SpvContextProvider { let llmq_type: LLMQType = quorum_type_u8.into(); let quorum_hash = QuorumHash::from_byte_array(quorum_hash); - // NOTE: blocking_read() is used because ContextProvider::get_quorum_public_key - // is a sync trait method. The SDK calls it from a blocking context (inside - // tokio::task::block_in_place or from a sync thread), so this is safe. - let engine = self.masternode_engine.blocking_read(); + // Use try_read() instead of blocking_read() because this sync method + // may be called from within a Tokio async context (proof verification + // happens inside async tasks). blocking_read() would panic in that case. + let engine = self.masternode_engine.try_read().map_err(|_| { + ContextProviderError::Generic( + "Masternode engine lock is busy; retry quorum lookup".to_string(), + ) + })?; let (before, _after) = engine.masternode_lists_around_height(core_chain_locked_height); let ml = before.ok_or_else(|| {