From f4703d6daf3d82078f5e3f79be3588328eabe394 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 31 Mar 2026 14:17:44 +0300 Subject: [PATCH 01/21] 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 02/21] 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 03/21] 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 04/21] 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 05/21] 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 06/21] 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 07/21] 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(|| { From 05fd41d6c6fe7f0dc3cec3daecd491678cbc666b Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Sat, 11 Jul 2026 20:14:40 +0700 Subject: [PATCH 08/21] style(platform-wallet-ffi): apply rustfmt; drop internal re-integration spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rustfmt line-wrap of the wrapper-reclaim call in the SPV SDK-create FFI function (fixes the `Check formatting` CI step; `cargo fmt --all --check` clean). - Remove docs/SPV_CONTEXT_PROVIDER_REINTEGRATION_SPEC.md β€” an internal design doc that doesn't belong in the PR (its content lives in the merge commit's history). Co-Authored-By: Claude Opus 4.8 --- ...SPV_CONTEXT_PROVIDER_REINTEGRATION_SPEC.md | 230 ------------------ packages/rs-platform-wallet-ffi/src/spv.rs | 4 +- 2 files changed, 3 insertions(+), 231 deletions(-) delete mode 100644 docs/SPV_CONTEXT_PROVIDER_REINTEGRATION_SPEC.md diff --git a/docs/SPV_CONTEXT_PROVIDER_REINTEGRATION_SPEC.md b/docs/SPV_CONTEXT_PROVIDER_REINTEGRATION_SPEC.md deleted file mode 100644 index b6ea1eb4ee9..00000000000 --- a/docs/SPV_CONTEXT_PROVIDER_REINTEGRATION_SPEC.md +++ /dev/null @@ -1,230 +0,0 @@ -# SPV Context Provider β€” Re-integration onto v4.1-dev - -Status: DRAFT for review Β· Branch: `review/ios-spv-quorums` (PR #3417 merged onto `v4.1-dev`) - -## 1. Problem - -PR #3417 wires the SPV-synced masternode quorum data into the Platform SDK's -`ContextProvider` so proof verification uses locally-synced, trustless quorum -public keys instead of the centralized `TrustedHttpContextProvider` -(`quorums.*.networks.dash.org`). - -The PR was authored against a Swift/FFI layout that `v4.1-dev` has since -**deleted and restructured** (827 commits of drift). The merge compiles on the -Rust side after conflict resolution, but the PR's integration surface is -**architecturally obsolete**: - -- `dash_sdk_create_with_spv_context(config, spv_client: *mut c_void)` casts to - `*mut FFIDashSpvClient` β€” a type base **no longer produces in live code**. - Nothing can call it correctly. -- Base deleted the Swift files the PR builds on: `SPVClient.swift`, - `WalletService.swift`, `UnifiedAppState.swift`. SPV is now owned by - `PlatformWalletManager` (`platform_wallet_manager_spv_*` FFI over the - manager handle). -- The PR's standalone `SpvContextProvider` (hand-walks - `engine.masternode_lists_around_height`) overlaps with a lookup base already - ships: `SpvRuntime::get_quorum_public_key` (`spv/runtime.rs:156`) β€” but base's - version is **dead code (zero callers, zero tests) and contains a byte-order - bug**: it applies `QuorumHash::from_byte_array(qh).reverse()`. The - `.reverse()` is **wrong**. `quorum_hash` flows drive-abciβ†’tenderdashβ†’SDK in - internal byte order (proven by the consensus-equality check at - `finalize_block_proposal/v0/mod.rs:131`), and the dash-spv engine keys its - quorum map in that same internal order, so the correct lookup key is - `QuorumHash::from_byte_array(qh)` with **no reverse** β€” which is exactly what - the PR's provider AND the old `dash-spv-ffi` reference provider both do. - Base's `.reverse()` makes every real lookup miss β†’ `QuorumNotFound` β†’ - fail-closed rejection, silently masked by the Swift trusted fallback (feature - looks alive but never uses SPV). - -## 2. Chosen approach - -Replace the dead `FFIDashSpvClient` path with a thin `ContextProvider` that -**delegates to `SpvRuntime::get_quorum_public_key`** (base's method walks back -up to 4 active quorum windows and skips `Invalid` entries β€” strictly more -robust than the PR's single-nearest-list walk, which false-misses a signing -quorum selected several DKG intervals back), wired through the -`PlatformWalletManager` handle. - -**Prerequisite fix (Layer 0):** first correct base's dead method β€” remove the -erroneous `.reverse()` in `SpvRuntime::get_quorum_public_key` -(`spv/runtime.rs:168`) so it becomes `QuorumHash::from_byte_array(quorum_hash)`, -and land a regression test (Β§5) that pins the correct byte order with a real -proof. Delegating to it *before* this fix would silently disable SPV. - -Three layers on top: - -### Layer 1 β€” `platform-wallet` (generic crate, gated `spv-context`) - -Rewrite `spv_context_provider.rs`: - -```rust -pub struct SpvContextProvider { - spv: Arc, // non-generic; SpvRuntime is concrete - handle: tokio::runtime::Handle, // captured at construction (see risk #1) - network: Network, -} - -impl ContextProvider for SpvContextProvider { - fn get_quorum_public_key(&self, qt: u32, qh: [u8;32], h: u32) - -> Result<[u8;48], ContextProviderError> - { - // Bridge sync trait method -> the (reversal-corrected) async lookup. - // Called from inside the SDK's BigStackRuntime block_on (verify path). - // block_in_place avoids the nested-runtime panic; the STORED handle - // (not Handle::current()) keeps this correct even if a future caller - // invokes us off the runtime thread. BigStackRuntime is multi-thread - // (confirmed: Builder::new_multi_thread, rs-sdk-ffi/src/runtime.rs:60), - // so block_in_place does not panic. - tokio::task::block_in_place(|| { - self.handle.block_on(self.spv.get_quorum_public_key(qt, qh, h)) - }) - .map_err(|e| ContextProviderError::InvalidQuorum(e.to_string())) - } - fn get_platform_activation_height(&self) -> Result { - match self.network { - Mainnet => Ok(1_888_888), Testnet => Ok(1_289_520), - Devnet | Regtest => Ok(1), // FIX vs PR: Regtest was Err - _ => Err(...), - } - } - fn get_data_contract(..) -> Ok(None) // served by SDK cache - fn get_token_configuration(..) -> Ok(None) -} -``` - -Deletes the PR's engine-walking logic, the separate `Arc>`, the -`try_read()` design, and the missing-reversal bug β€” all now base's concern. - -`PlatformWalletManager` already stores `spv_manager: Arc` and -exposes an Arc-cloning accessor (`manager/accessors.rs`). - -### Layer 2 β€” FFI entry point lives in `platform-wallet-ffi` (NOT rs-sdk-ffi) - -`rs-sdk-ffi` cannot reach the manager handle: base made -`platform-wallet-ffi β†’ rs-sdk-ffi`, so the reverse edge is a cycle. The manager -storage (`PLATFORM_WALLET_MANAGER_STORAGE : HandleStorage>`) -lives in `platform-wallet-ffi`, which **can** depend on `rs-sdk-ffi`. - -**Required rs-sdk-ffi change (Blocker 1):** `ContextProviderWrapper` is -`pub(crate)` and there is no public API to turn a native `impl ContextProvider` -into a `*mut ContextProviderHandle` (only a C-callbacks constructor exists). -Make the wrapper usable from `platform-wallet-ffi`: mark -`ContextProviderWrapper` + its `new` `pub`, so the FFI entry point can build the -handle directly: - -```rust -// platform-wallet-ffi call site -let wrapper = Box::new(rs_sdk_ffi::ContextProviderWrapper::new(provider)); -let cp_handle = Box::into_raw(wrapper) as *mut rs_sdk_ffi::ContextProviderHandle; -``` - -New fn (note: builds `DashSDKConfigExtended`, not a bare `DashSDKConfig` β€” -Blocker 2; `dash_sdk_create_extended` takes `*const DashSDKConfigExtended`): - -```rust -// platform-wallet-ffi/src/spv.rs -pub unsafe extern "C" fn platform_wallet_manager_create_sdk_with_spv_context( - manager_handle: Handle, - config: *const DashSDKConfig, // borrowed; copied into Extended -) -> DashSDKResult { - // 1. Arc = STORAGE.with_item(handle, |m| m.spv_arc()) (owned clone) - // 2. let provider = platform_wallet::SpvContextProvider::new(spv, handle, network) - // 3. let cp_handle = rs_sdk_ffi::context_provider_handle_from_provider(provider) - // 4. let ext = DashSDKConfigExtended { - // base_config: (*config with all 7 fields), - // context_provider: cp_handle, core_sdk_handle: null, - // }; - // rs_sdk_ffi::dash_sdk_create_extended(&ext) -} -``` - -Removes from `rs-sdk-ffi`: the dead `dash_sdk_create_with_spv_context`, the -`dash-spv-ffi` dependency, and the `platform-wallet` `spv-context` feature -dependency (moves to `platform-wallet-ffi`). Also removes `dash-spv-ffi` from -`[workspace.dependencies]` (only added during merge to prop up the dead path). - -### Layer 3 β€” Swift - -- `SDK.swift`: replace `init(network:spvClientHandle:)` with - `init(network:walletManager:)` calling the new FFI fn with - `walletManager.handle`. -- Accept base's deletion of `SPVClient.swift` / `WalletService.swift` / - `UnifiedAppState.swift` (drop the PR's edits to them). -- `AppState.swift`: redo "SPV-first, trusted fallback" against - `PlatformWalletManager` (resolve 7 conflict hunks: take base + re-add the - fallback wired to the manager). `OptionsView.swift` toggle stays. - -## 3. Alternatives rejected - -- **Port the PR's standalone provider (hold `Arc>`, sync - `try_read`).** Rejected: duplicates base's lookup, carries the - `try_read` spurious-failure bug (High) and the missing hash-reversal bug, and - must be kept in sync with base by hand. -- **Put the FFI fn in `rs-sdk-ffi` taking the manager handle.** Rejected: - cannot resolve `PlatformWalletManager` without depending on - `platform-wallet-ffi` β†’ dependency cycle. -- **arc-swap snapshot published by SPV sync (lock-free reads).** Best long-term - for the async-bridge cost, but requires upstream `dash-spv` changes. Out of - scope; revisit if the block_on bridge proves problematic. - -## 4. Failure modes / risks - -0. **Byte-order regression (HIGHEST β€” the feature-killer).** Base's - `.reverse()` must be removed (Layer 0). Without the fix, every lookup misses - and SPV is silently dead behind the fallback. Pinned by a real-proof - regression test (Β§5). This is the one that decides whether the feature works - at all. -1. **Async-bridge re-entrancy.** Bridge runs inside `BigStackRuntime::block_on`. - `block_in_place` is valid because BigStackRuntime is **confirmed - multi-threaded** (`Builder::new_multi_thread`, `rs-sdk-ffi/src/runtime.rs:60`) - β€” no panic. No deadlock: `get_quorum_at_height` (dash-spv rev `1860089`) is - **pure in-memory** (two brief tokio-RwLock reads, no network I/O), and every - `SpvRuntime.client` writer drops its guard before any long await, so the - single external blocked reader can always be woken. NOTE the behavioral - change: base's `.read().await` **waits** on write contention β€” the lookup is - now **fail-slow** (blocks until the writer releases), not fail-fast (the PR's - `try_read()` errored). Under a large QRINFO `apply_diff` this briefly stalls - the verify worker (throughput risk, not deadlock). Validation: a Rust test - that calls the provider from inside `block_on` while a writer holds the - client lock and asserts it **returns the correct key once the writer - releases** (NOT that it errors β€” the old fail-fast expectation is gone). -2. **Quorum coverage during sync.** Same as base: if SPV hasn't synced the list - at the proof's `core_chain_locked_height`, lookup errors β†’ proof rejected - (fail-closed). Trusted fallback (Swift) covers construction; per-lookup - misses still surface as errors (documented; acceptable for v1). -3. **Lifetime.** Provider holds `Arc`; safe if the manager is - dropped while the SDK lives. But if SPV is stopped/restarted (network - switch), the `Arc` may point at a stopped runtime β†’ lookups - error until re-init. Swift must rebuild the SDK on network switch (base's - AppState already tears down/rebuilds). - -## 5. Verification plan - -- Rust: `cargo clippy -p platform-wallet --features spv-context -p platform-wallet-ffi -p rs-sdk-ffi --all-targets`; `cargo fmt --check`. -- **Byte-order regression test (risk #0, mandatory):** with a real testnet - proof + a synced masternode list containing the signing quorum, assert - `SpvRuntime::get_quorum_public_key` returns the correct 48-byte key. Must be - RED against the `.reverse()` version and GREEN after removal β€” this is the - proof the fix is real, not a tautology. Do NOT rely on the manual iOS toggle - to catch this (too easy to skip; the fallback masks it). -- Rust test (risk #1): provider returns the correct key from within a - `block_on`, and **completes with the correct key once a holding writer - releases** (fail-slow) β€” assert no panic/deadlock; do NOT assert an error. -- **`get_platform_activation_height` values RESOLVED.** The PR's - `1_888_888`/`1_289_520` were `dash-spv-ffi` "needs verification" placeholders. - Matched to the production `rs-sdk-trusted-context-provider` values instead - (Mainnet `2_132_092`, Testnet `1_090_319`, Devnet/Regtest `1`) so the SPV and - trusted paths gate proof verification identically. -- iOS: `cd packages/swift-sdk && ./build_ios.sh`; build SwiftExampleApp - (`iPhone 17` sim per repo note); manual: switch to testnet, confirm proof - verification succeeds against SPV quorums with the trusted-fallback toggle OFF. -- Confirm no consensus/wire impact (this is client-side proof verification only; - the `.reverse()` removal touches a zero-caller method, not consensus code). - -## 6. Out of scope - -- Composite provider (SPV primary + trusted fallback) inside Rust so per-lookup - misses degrade gracefully β€” follow-up. -- `verified`-status gate on quorum entries (base's `get_quorum_at_height` - concern; track separately). -- Tests for the broader SPV sync path. diff --git a/packages/rs-platform-wallet-ffi/src/spv.rs b/packages/rs-platform-wallet-ffi/src/spv.rs index 01a5370c281..f9738566792 100644 --- a/packages/rs-platform-wallet-ffi/src/spv.rs +++ b/packages/rs-platform-wallet-ffi/src/spv.rs @@ -559,7 +559,9 @@ pub unsafe extern "C" fn platform_wallet_manager_create_sdk_with_spv_context( // `dash_sdk_create_extended` only borrows the wrapper and clones the inner // provider `Arc`; it never takes ownership. Reclaim the box so the wrapper // (and its `Arc`) isn't leaked on every SDK creation. - drop(Box::from_raw(context_provider as *mut rs_sdk_ffi::ContextProviderWrapper)); + drop(Box::from_raw( + context_provider as *mut rs_sdk_ffi::ContextProviderWrapper, + )); result } From 3fad6f8bc1d919e81086b10911149f829c45dea3 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 13 Jul 2026 18:59:01 +0700 Subject: [PATCH 09/21] feat(swift-sdk): verify proofs against SPV quorums via attach-after-build + indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The example app never actually verified proofs against SPV data: the manager is constructed FROM the platform SDK (`manager.configure(sdk)` -> `platform_wallet_manager_create(sdk_ptr)` -> `PlatformWalletManager::new(sdk)` builds the SpvRuntime), while the SPV SDK needed the manager's SpvRuntime β€” circular. `SDK.init(network:walletManager:)` therefore always got the empty default manager (SPV not started) and silently fell back to trusted, so SPV never engaged. Replace build-from-manager with swap-after-build: - rs-sdk-ffi: `dash_sdk_install_context_provider(sdk, provider)` swaps a live SDK's context provider via `Sdk::set_context_provider`. Takes ownership of the `ContextProviderWrapper` box and reclaims it exactly once on every return path. - platform-wallet-ffi: `platform_wallet_manager_attach_spv_context(manager, sdk, network)` builds `SpvContextProvider` from the manager's `SpvRuntime` and installs it (never reclaims β€” ownership moves to install). Replaces the dead `create_sdk_with_spv_context`. - swift-sdk: `SDK.attachSpvQuorums(from:)` + `SDK.QuorumSource`; removed the dead `init(network:walletManager:)`. - example app: `AppState` builds trusted, then `attachSpvIfReady` swaps to SPV once the active manager's SPV headers+masternodes are `.synced` (fallback OFF = attach once merely running, no trusted net). App observes `walletManager.spvProgress`. Options shows a Trusted/SPV "Proof Quorum Source" badge that flips automatically when SPV attaches. v1 scope: attaches to the app's query SDK (what user proof-verified fetches use); the manager's own cloned SDK stays trusted (`Sdk::clone` snapshots the provider slot, sdk.rs:204) β€” v2 follow-up. Verified: rs fmt + clippy clean; SwiftExampleApp (simulator) BUILD SUCCEEDED. Behavioral verification (badge flips to SPV + a fetch verifies via SPV on a synced testnet) is on-device only. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet-ffi/src/spv.rs | 68 +++++---------- packages/rs-sdk-ffi/src/sdk.rs | 47 ++++++++++ .../swift-sdk/Sources/SwiftDashSDK/SDK.swift | 86 ++++++------------- .../SwiftExampleApp/AppState.swift | 76 ++++++++++------ .../SwiftExampleApp/SwiftExampleAppApp.swift | 12 ++- .../SwiftExampleApp/Views/OptionsView.swift | 18 +++- 6 files changed, 171 insertions(+), 136 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/spv.rs b/packages/rs-platform-wallet-ffi/src/spv.rs index f9738566792..4654148e5f0 100644 --- a/packages/rs-platform-wallet-ffi/src/spv.rs +++ b/packages/rs-platform-wallet-ffi/src/spv.rs @@ -497,33 +497,31 @@ pub unsafe extern "C" fn platform_wallet_manager_spv_clear_storage( PlatformWalletFFIResult::ok() } -/// Create a Platform SDK that verifies proofs using this wallet manager's -/// SPV-synced quorum data instead of a trusted HTTP quorum service. +/// Install this wallet manager's SPV-synced quorum data as the proof-verification +/// context provider on an already-built Platform SDK, replacing its trusted +/// provider. Subsequent proof-verified queries on that SDK handle resolve quorum +/// public keys live from the locally synced masternode list. /// -/// The returned SDK holds a shared reference to the manager's SPV runtime and -/// resolves quorum public keys live from the locally synced masternode list. -/// The manager's SPV client should be started (and ideally synced) for lookups -/// to succeed; otherwise proof verification fails closed until it catches up. +/// Call this only once the manager's SPV client is started and its masternode +/// list is synced (see `platform_wallet_manager_sync_progress`); before that, +/// lookups fail closed (no per-lookup fallback once installed). +/// +/// The manager must have been created (`configure`d) against this same SDK +/// before attaching β€” that ordering is what keeps the manager's own cloned SDK +/// on its trusted provider (the SDK clone snapshots the provider slot). /// /// # Safety -/// - `config` must be a valid pointer to a `DashSDKConfig` for the call. /// - `manager_handle` must be a live `PlatformWalletManager` handle. +/// - `sdk_handle` must be a valid `SDKHandle` for the duration of the call. #[no_mangle] -pub unsafe extern "C" fn platform_wallet_manager_create_sdk_with_spv_context( +pub unsafe extern "C" fn platform_wallet_manager_attach_spv_context( manager_handle: Handle, - config: *const rs_sdk_ffi::DashSDKConfig, + sdk_handle: *mut rs_sdk_ffi::SDKHandle, + network: crate::types::FFINetwork, ) -> rs_sdk_ffi::DashSDKResult { - if config.is_null() { - return rs_sdk_ffi::DashSDKResult::error(rs_sdk_ffi::DashSDKError::new( - rs_sdk_ffi::DashSDKErrorCode::InvalidParameter, - "Config is null".to_string(), - )); - } - let config_ref = &*config; - // A shared handle to the manager's SPV runtime β€” the same runtime the SPV // client writes to during sync. Cloned out of the storage closure so it - // outlives the borrow and backs the provider for the SDK's lifetime. + // outlives the borrow and backs the provider for as long as it's installed. let spv = match PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| m.spv_arc()) { Some(spv) => spv, None => { @@ -534,34 +532,12 @@ pub unsafe extern "C" fn platform_wallet_manager_create_sdk_with_spv_context( } }; - let network: crate::types::Network = config_ref.network.into(); - + let network: crate::types::Network = network.into(); let provider = platform_wallet::spv_context_provider::SpvContextProvider::new(spv, network); - let wrapper = Box::new(rs_sdk_ffi::ContextProviderWrapper::new(provider)); - let context_provider = Box::into_raw(wrapper) as *mut rs_sdk_ffi::ContextProviderHandle; - - let extended = rs_sdk_ffi::DashSDKConfigExtended { - base_config: rs_sdk_ffi::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, - quorum_url: config_ref.quorum_url, - platform_version: config_ref.platform_version, - }, - context_provider, - core_sdk_handle: std::ptr::null_mut(), - }; - - let result = rs_sdk_ffi::dash_sdk_create_extended(&extended); - - // `dash_sdk_create_extended` only borrows the wrapper and clones the inner - // provider `Arc`; it never takes ownership. Reclaim the box so the wrapper - // (and its `Arc`) isn't leaked on every SDK creation. - drop(Box::from_raw( - context_provider as *mut rs_sdk_ffi::ContextProviderWrapper, - )); + let handle = Box::into_raw(Box::new(rs_sdk_ffi::ContextProviderWrapper::new(provider))) + as *mut rs_sdk_ffi::ContextProviderHandle; - result + // `dash_sdk_install_context_provider` TAKES ownership of the wrapper box and + // reclaims it exactly once β€” this function must not reclaim it. + rs_sdk_ffi::dash_sdk_install_context_provider(sdk_handle, handle) } diff --git a/packages/rs-sdk-ffi/src/sdk.rs b/packages/rs-sdk-ffi/src/sdk.rs index 0f3f89b666b..cd979a1d36f 100644 --- a/packages/rs-sdk-ffi/src/sdk.rs +++ b/packages/rs-sdk-ffi/src/sdk.rs @@ -554,6 +554,53 @@ pub unsafe extern "C" fn dash_sdk_get_inner_sdk_ptr( &wrapper.sdk as *const dash_sdk::Sdk as *const std::os::raw::c_void } +/// Install a native context provider onto an existing SDK, replacing whatever +/// provider it currently uses. Subsequent proof-verified queries on this SDK +/// handle resolve quorum keys through the new provider (the SDK loads the +/// provider fresh per verification). +/// +/// Ownership of `provider` is TAKEN: this function reclaims the +/// `ContextProviderWrapper` box exactly once on every return path. Callers +/// (e.g. `platform-wallet-ffi`) must build it via +/// `Box::into_raw(Box::new(ContextProviderWrapper::new(..)))` and must NOT +/// reclaim it themselves. +/// +/// # Safety +/// - `sdk_handle` must be a valid `SDKHandle` for the duration of the call (or +/// null, which returns an error). +/// - `provider` must be a `*mut ContextProviderHandle` produced as described +/// above (or null, which returns an error). +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_install_context_provider( + sdk_handle: *mut SDKHandle, + provider: *mut ContextProviderHandle, +) -> DashSDKResult { + if provider.is_null() { + return DashSDKResult::error(DashSDKError::new( + DashSDKErrorCode::InvalidParameter, + "Context provider handle is null".to_string(), + )); + } + // Take ownership immediately so the box is reclaimed exactly once on every + // path below (including the null-SDK error return). + let wrapper = Box::from_raw(provider as *mut ContextProviderWrapper); + + if sdk_handle.is_null() { + return DashSDKResult::error(DashSDKError::new( + DashSDKErrorCode::InvalidParameter, + "SDK handle is null".to_string(), + )); + } + + let sdk_wrapper = &*(sdk_handle as *const SDKWrapper); + // Swaps the SDK's `ArcSwapOption` provider slot in place; the SDK keeps its + // own `Arc` clone. `wrapper` drops at end of scope, releasing this + // function's refcount on the provider. + sdk_wrapper.sdk.set_context_provider(wrapper.provider()); + + DashSDKResult::success(std::ptr::null_mut()) +} + /// Register global context provider callbacks /// /// This must be called before creating an SDK instance that needs Core SDK functionality. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift index fd605f696b8..640ea5e8297 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift @@ -367,74 +367,42 @@ public final class SDK: @unchecked Sendable { self.network = network } - /// Create a new SDK instance that verifies proofs using SPV-synced quorum - /// data from `walletManager`'s locally synced masternode list, instead of a - /// trusted HTTP quorum service. DAPI transport is resolved exactly as in - /// `init(network:)` β€” SPV only replaces the quorum-key source. + /// Which quorum-key source this SDK's proof verification currently uses. + public enum QuorumSource: Sendable { + /// Centralized trusted HTTP quorum service β€” how every `SDK` starts. + case trusted + /// SPV-synced masternode quorum data, installed via `attachSpvQuorums`. + case spv + } + + /// The quorum source proof verification currently uses. Starts `.trusted` + /// and flips to `.spv` after a successful `attachSpvQuorums(from:)`. + public private(set) var quorumSource: QuorumSource = .trusted + + /// Swap this SDK's proof-verification context provider to use + /// `walletManager`'s SPV-synced quorum data instead of the trusted HTTP + /// service. After this returns, proof-verified queries on this SDK verify + /// quorum signatures against the locally synced masternode list. /// - /// The wallet manager's SPV client should be started (and ideally synced) - /// for lookups to succeed; otherwise proof verification fails closed until - /// it catches up. `walletManager` must outlive this SDK instance. + /// Call only once the manager's SPV client is started and its masternode + /// list is synced β€” before that, lookups fail closed (there is no per-lookup + /// fallback to trusted once SPV is installed). /// /// `@MainActor`: reads `walletManager.handle`, which is main-actor isolated. @MainActor - public init(network: Network, walletManager: PlatformWalletManager, platformVersion: UInt32 = 0) - throws - { - var config = DashSDKConfig() - config.network = network.ffiValue - config.dapi_addresses = nil - config.quorum_url = nil - config.skip_asset_lock_proof_verification = false - config.request_retry_count = 1 - config.request_timeout_ms = 8000 - config.platform_version = platformVersion - - // Resolve DAPI addresses / quorum URL identically to `init(network:)`: - // devnet auto-discovers from `{quorumURL}/masternodes`; regtest and the - // `useDockerSetup` flow use the `platformDAPIAddresses` override; plain - // mainnet/testnet let the Rust side pick canonical seeds. - // Read the main-actor-isolated handle here (in the isolated init body) - // and capture the plain `Handle` value into the nonisolated C-string - // closure below β€” the handle is Sendable, `walletManager` is not. - let managerHandle = walletManager.handle - - let useOverrideAddresses = - network == .regtest - || network == .devnet - || UserDefaults.standard.bool(forKey: "useDockerSetup") - let overrideQuorumURL: String? = useOverrideAddresses ? Self.platformQuorumURL : nil - let overrideAddresses: String? - if network == .devnet { - overrideAddresses = overrideQuorumURL.flatMap { Self.discoverDAPIAddresses(quorumBase: $0) } - } else if useOverrideAddresses { - overrideAddresses = Self.platformDAPIAddresses - } else { - overrideAddresses = nil - } - - let result = SDK.withOptionalCStrings(overrideAddresses, overrideQuorumURL) { - addressesCStr, quorumCStr in - var mutableConfig = config - if let addressesCStr { mutableConfig.dapi_addresses = addressesCStr } - if let quorumCStr { mutableConfig.quorum_url = quorumCStr } - return platform_wallet_manager_create_sdk_with_spv_context( - managerHandle, &mutableConfig) + public func attachSpvQuorums(from walletManager: PlatformWalletManager) throws { + guard let handle else { + throw SDKError.internalError("Cannot attach SPV quorums: SDK handle is nil") } - + let result = platform_wallet_manager_attach_spv_context( + walletManager.handle, handle, network.ffiValue) if result.error != nil { let error = result.error!.pointee - let errorMessage = error.message != nil ? String(cString: error.message!) : "Unknown error" + let message = 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") + throw SDKError.internalError("Failed to attach SPV quorum provider: \(message)") } - - handle = OpaquePointer(result.data) - self.network = network + quorumSource = .spv } /// Run `body` with two optional C-string pointers. Each input string, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift index ba10577edff..12185bd5dcf 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift @@ -9,6 +9,13 @@ class AppState: ObservableObject { @Published var showError = false @Published var errorMessage = "" + /// The quorum-key source the current SDK uses for proof verification. + /// Starts `.trusted` on every SDK build and flips to `.spv` once + /// `attachSpvIfReady` installs the SPV provider. Drives the app's indicator; + /// intentionally mirrors `SDK.quorumSource` (this one is the observable the + /// UI binds to). + @Published private(set) var quorumSource: SDK.QuorumSource = .trusted + @Published var currentNetwork: Network { didSet { UserDefaults.standard.set(Int(currentNetwork.rawValue), forKey: "currentNetwork") @@ -58,9 +65,6 @@ class AppState: ObservableObject { // construction effectively free. private var dataManager: DataManager? private var modelContext: ModelContext? - /// The active per-network wallet manager, supplied by the app at - /// `initializeSDK`. Its SPV runtime backs trustless quorum lookups. - private var walletManager: PlatformWalletManager? init() { // Load saved network preference or use default. Read via @@ -90,11 +94,9 @@ class AppState: ObservableObject { : true } - func initializeSDK(modelContext: ModelContext, walletManager: PlatformWalletManager? = nil) { + func initializeSDK(modelContext: ModelContext) { // Save the model context for later use self.modelContext = modelContext - // The manager whose SPV runtime backs trustless quorum lookups. - self.walletManager = walletManager // Initialize DataManager self.dataManager = DataManager(modelContext: modelContext, currentNetwork: currentNetwork) @@ -108,8 +110,14 @@ class AppState: ObservableObject { SDK.enableLogging(level: .debug) NSLog("πŸ”΅ AppState: Creating SDK for network=\(currentNetwork), docker=\(useDockerSetup)") - let newSDK = try makeSDK(for: currentNetwork) + // Build with the trusted quorum provider. Proof verification is + // switched to SPV-synced quorums later via `attachSpvIfReady`, + // once the active wallet manager's SPV masternode list is synced + // (the manager can only be created *from* an SDK, so the SDK + // must exist first β€” hence attach-after rather than build-from). + let newSDK = try SDK(network: currentNetwork) sdk = newSDK + quorumSource = .trusted NSLog("βœ… AppState: SDK created successfully") // Eagerly learn the network's protocol version so @@ -117,7 +125,7 @@ class AppState: ObservableObject { // first metadata-bearing response ratchets the SDK. refreshProtocolVersion(for: newSDK) - // Load known contracts into the SDK's trusted provider + // Load known contracts into the SDK's provider cache. await loadKnownContractsIntoSDK(sdk: newSDK, modelContext: modelContext) isLoading = false @@ -130,26 +138,35 @@ class AppState: ObservableObject { } } - /// Build an SDK for `network`, preferring the wallet manager's SPV-synced - /// quorums for trustless proof verification and falling back to the trusted - /// HTTP quorum provider when SPV construction fails (if enabled). - private func makeSDK(for network: Network) throws -> SDK { - if let walletManager { - do { - let newSDK = try SDK(network: network, walletManager: walletManager) - NSLog("βœ… AppState: SDK using SPV-synced quorum provider") - return newSDK - } catch { - guard useTrustedQuorumFallback else { throw error } - NSLog( - "⚠️ AppState: SPV quorum provider unavailable (\(error.localizedDescription)); using trusted" - ) - } - } else if !useTrustedQuorumFallback { - throw SDKError.internalError( - "No wallet manager available and trusted quorum fallback is disabled") + /// Switch the SDK's proof verification from the trusted HTTP quorum service + /// to `manager`'s SPV-synced quorum data, once SPV has synced enough for + /// lookups to succeed. Idempotent β€” a no-op once already on SPV. Call + /// whenever the active manager's `spvProgress` changes. + /// + /// Policy (`useTrustedQuorumFallback`): + /// - ON (default): stay on trusted until the SPV header + masternode lists + /// are fully synced, then switch to SPV. + /// - OFF: switch to SPV as soon as the SPV client is running (before full + /// sync), so proof verification exercises SPV with no trusted safety net + /// (lookups fail closed until sync catches up). + @MainActor + func attachSpvIfReady(manager: PlatformWalletManager) { + guard let sdk, quorumSource == .trusted else { return } + + let progress = manager.spvProgress + let running = + progress.overallState == .syncing || progress.overallState == .synced + let fullySynced = + progress.headers?.state == .synced && progress.masternodes?.state == .synced + guard useTrustedQuorumFallback ? fullySynced : running else { return } + + do { + try sdk.attachSpvQuorums(from: manager) + quorumSource = .spv + NSLog("βœ… AppState: proof verification now uses SPV-synced quorums") + } catch { + NSLog("⚠️ AppState: failed to attach SPV quorums: \(error.localizedDescription)") } - return try SDK(network: network) } func showError(message: String) { @@ -172,9 +189,12 @@ class AppState: ObservableObject { do { isLoading = true - // Create new SDK instance for the network + // Create new SDK instance for the network (trusted provider; SPV is + // re-attached by `attachSpvIfReady` once the new network's manager + // has synced). let newSDK = try SDK(network: network) sdk = newSDK + quorumSource = .trusted // Eagerly learn the new network's protocol version (see // `initializeSDK`). Non-fatal: the SDK still ratchets from diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index 89f7d236748..2c3ab535ce2 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -168,6 +168,15 @@ struct SwiftExampleAppApp: App { .onChange(of: platformState.walletScopedServicesRebindTick) { _, _ in rebindWalletScopedServices() } + // Switch Platform proof verification from the trusted quorum + // service to the active manager's SPV-synced quorum data once + // SPV has synced. Driven off the manager's published sync + // progress (also powers the sync UI). Idempotent β€” a no-op once + // already on SPV, and re-evaluated for the new manager after a + // network switch (which resets `quorumSource` to `.trusted`). + .onChange(of: walletManager.spvProgress) { _, _ in + platformState.attachSpvIfReady(manager: walletManager) + } } } @@ -316,8 +325,7 @@ struct SwiftExampleAppApp: App { await PlatformWalletManager.warmUpShieldedProver() } - platformState.initializeSDK( - modelContext: modelContainer.mainContext, walletManager: walletManager) + platformState.initializeSDK(modelContext: modelContainer.mainContext) // Give the Platform SDK a moment to finish its internal init. try? await Task.sleep(for: .milliseconds(500)) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift index 450a82d756a..1e6e16f97ae 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift @@ -348,7 +348,23 @@ struct OptionsView: View { } 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.") + .help("When enabled, proof verification stays on the trusted HTTP quorum provider until the SPV masternode list is synced, then switches to SPV. Disable to switch to SPV as soon as it is running (no trusted fallback; proofs fail until synced). Takes effect on the next SDK build.") + + HStack { + Text("Proof Quorum Source") + Spacer() + switch appState.quorumSource { + case .spv: + Label("SPV (trustless)", systemImage: "checkmark.shield.fill") + .foregroundColor(.green) + .font(.caption.weight(.semibold)) + case .trusted: + Label("Trusted (HTTP)", systemImage: "network") + .foregroundColor(.orange) + .font(.caption.weight(.semibold)) + } + } + .help("Which quorum public-key source the SDK currently uses to verify Platform proofs. Switches to SPV automatically once the masternode list has synced (start SPV sync from the Core tab).") HStack { Text("Network Status") From 600b44002b3130cba35e645f397b888388a961fd Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 13 Jul 2026 21:16:27 +0700 Subject: [PATCH 10/21] feat(sdk): share context provider across SDK clones; live Auto/SPV/Trusted quorum picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manager SDK on SPV: make `dash_sdk::Sdk` share its context-provider slot across clones β€” `context_provider: Arc>` and `Clone` shares the `Arc` (was a per-clone snapshot). A `set_context_provider` on the app's SDK now also flips the wallet manager's cloned SDK, so both the app's proof-verified queries and the manager's background wallet/DashPay sync verify against the same SPV-synced quorums. Adversarially reviewed: nothing relied on clone-provider isolation (every other Sdk field was already Arc-shared); the arc-swap read path is lock-free with no torn read / use-after-free. Live quorum-source control: replace the fallback toggle with an Auto/SPV/Trusted picker, applied live via provider swaps on the shared slot (no SDK rebuild): - rs-sdk-ffi: `dash_sdk_restore_trusted_context_provider` re-installs the trusted provider captured on the SDK wrapper β€” symmetric with the existing `dash_sdk_install_context_provider`. - swift-sdk: `SDK.restoreTrustedQuorums()`. - example app: `AppState.quorumMode` + `applyQuorumMode` (Auto = SPV once fully synced, trusted until then; SPV = force SPV once running; Trusted = force trusted). `OptionsView` segmented picker; the "Proof Quorum Source" badge shows what is actually installed. Verified: `cargo check --workspace` + clippy + fmt clean; SwiftExampleApp (simulator) clean BUILD SUCCEEDED; on-device β€” Auto auto-attaches to SPV once synced, and the picker flips Trusted<->SPV live (badge + attach/restore FFIs) on a synced testnet node. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet-ffi/src/spv.rs | 7 +- packages/rs-sdk-ffi/src/sdk.rs | 34 +++++++ packages/rs-sdk/src/sdk.rs | 20 +++- .../swift-sdk/Sources/SwiftDashSDK/SDK.swift | 18 ++++ .../SwiftExampleApp/AppState.swift | 91 +++++++++++++------ .../SwiftExampleApp/SwiftExampleAppApp.swift | 8 +- .../SwiftExampleApp/Views/OptionsView.swift | 12 ++- 7 files changed, 151 insertions(+), 39 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/spv.rs b/packages/rs-platform-wallet-ffi/src/spv.rs index 4654148e5f0..0c7dce46b07 100644 --- a/packages/rs-platform-wallet-ffi/src/spv.rs +++ b/packages/rs-platform-wallet-ffi/src/spv.rs @@ -506,9 +506,10 @@ pub unsafe extern "C" fn platform_wallet_manager_spv_clear_storage( /// list is synced (see `platform_wallet_manager_sync_progress`); before that, /// lookups fail closed (no per-lookup fallback once installed). /// -/// The manager must have been created (`configure`d) against this same SDK -/// before attaching β€” that ordering is what keeps the manager's own cloned SDK -/// on its trusted provider (the SDK clone snapshots the provider slot). +/// `dash_sdk::Sdk` shares its context-provider slot across clones, so this +/// single install also switches the manager's own cloned SDK (used for wallet / +/// DashPay sync) to SPV β€” both the app's queries and the manager's background +/// sync verify against the same SPV-synced quorum data. /// /// # Safety /// - `manager_handle` must be a live `PlatformWalletManager` handle. diff --git a/packages/rs-sdk-ffi/src/sdk.rs b/packages/rs-sdk-ffi/src/sdk.rs index cd979a1d36f..ce8338daebf 100644 --- a/packages/rs-sdk-ffi/src/sdk.rs +++ b/packages/rs-sdk-ffi/src/sdk.rs @@ -601,6 +601,40 @@ pub unsafe extern "C" fn dash_sdk_install_context_provider( DashSDKResult::success(std::ptr::null_mut()) } +/// Restore the trusted HTTP quorum provider on an SDK that previously had an +/// SPV provider installed via [`dash_sdk_install_context_provider`]. The +/// trusted provider is the one captured at `dash_sdk_create_trusted` time, so +/// this only works for SDKs created that way (returns an error otherwise). +/// +/// Because the provider slot is shared across `Sdk` clones, this reverts both +/// the app's SDK and any manager clone back to trusted proof verification. +/// +/// # Safety +/// - `sdk_handle` must be a valid `SDKHandle` for the duration of the call (or +/// null, which returns an error). +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_restore_trusted_context_provider( + sdk_handle: *mut 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); + match &wrapper.trusted_provider { + Some(tp) => { + wrapper.sdk.set_context_provider(Arc::clone(tp)); + DashSDKResult::success(std::ptr::null_mut()) + } + None => DashSDKResult::error(DashSDKError::new( + DashSDKErrorCode::InvalidParameter, + "SDK has no stored trusted quorum provider to restore".to_string(), + )), + } +} + /// Register global context provider callbacks /// /// This must be called before creating an SDK instance that needs Core SDK functionality. diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index 205d66c9478..7b3897e1477 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -157,10 +157,17 @@ pub struct Sdk { /// Context provider used by the SDK. /// + /// Wrapped in `Arc` so the slot is **shared across clones**: a + /// [`set_context_provider`](Sdk::set_context_provider) on any clone is + /// observed by all of them. This lets a consumer that hands out SDK clones + /// (e.g. a wallet manager) swap the provider once and have every clone pick + /// it up β€” used to switch proof verification from a trusted quorum service + /// to SPV-synced quorums at runtime. + /// /// ## Panics /// /// Note that setting this to None can panic. - context_provider: ArcSwapOption>, + context_provider: Arc>>, /// Protocol version number detected from the network. Shared between clones. protocol_version: Arc, @@ -201,7 +208,10 @@ impl Clone for Sdk { inner: self.inner.clone(), proofs: self.proofs, nonce_cache: Arc::clone(&self.nonce_cache), - context_provider: ArcSwapOption::new(self.context_provider.load_full()), + // Share the provider slot (not a snapshot): a later + // `set_context_provider` on either the original or this clone is + // seen by both. + context_provider: Arc::clone(&self.context_provider), cancel_token: self.cancel_token.clone(), protocol_version: Arc::clone(&self.protocol_version), version_pinned: self.version_pinned, @@ -1119,7 +1129,9 @@ impl SdkBuilder { dapi_client_settings, inner:SdkInstance::Dapi { dapi }, proofs:self.proofs, - context_provider: ArcSwapOption::new( self.context_provider.map(Arc::new)), + context_provider: Arc::new(ArcSwapOption::new( + self.context_provider.map(Arc::new), + )), cancel_token: self.cancel_token, nonce_cache: Default::default(), // Seed atomic with the initial version; whether the version is @@ -1194,7 +1206,7 @@ impl SdkBuilder { nonce_cache: Default::default(), protocol_version: Arc::new(atomic::AtomicU32::new(initial_version.protocol_version)), version_pinned: self.version_pinned, - context_provider: ArcSwapOption::new(Some(Arc::new(context_provider))), + context_provider: Arc::new(ArcSwapOption::new(Some(Arc::new(context_provider)))), cancel_token: self.cancel_token, metadata_last_seen_height: Arc::new(atomic::AtomicU64::new(0)), metadata_height_tolerance: self.metadata_height_tolerance, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift index 640ea5e8297..4344f34aec2 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift @@ -405,6 +405,24 @@ public final class SDK: @unchecked Sendable { quorumSource = .spv } + /// Revert this SDK's proof verification from SPV-synced quorums back to the + /// trusted HTTP quorum service it was created with. Because the provider slot + /// is shared across SDK clones, this also reverts a wallet manager's cloned + /// SDK. Requires the SDK to have been created via the trusted path. + public func restoreTrustedQuorums() throws { + guard let handle else { + throw SDKError.internalError("Cannot restore trusted quorums: SDK handle is nil") + } + let result = dash_sdk_restore_trusted_context_provider(handle) + if result.error != nil { + let error = result.error!.pointee + let message = error.message != nil ? String(cString: error.message!) : "Unknown error" + defer { dash_sdk_error_free(result.error) } + throw SDKError.internalError("Failed to restore trusted quorum provider: \(message)") + } + quorumSource = .trusted + } + /// Run `body` with two optional C-string pointers. Each input string, /// when non-nil, is materialized into a NUL-terminated C buffer that is /// valid for the duration of the call; nil inputs pass through as nil diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift index 12185bd5dcf..26eceabbb18 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift @@ -11,7 +11,7 @@ class AppState: ObservableObject { /// The quorum-key source the current SDK uses for proof verification. /// Starts `.trusted` on every SDK build and flips to `.spv` once - /// `attachSpvIfReady` installs the SPV provider. Drives the app's indicator; + /// `applyQuorumMode` installs the SPV provider. Drives the app's indicator; /// intentionally mirrors `SDK.quorumSource` (this one is the observable the /// UI binds to). @Published private(set) var quorumSource: SDK.QuorumSource = .trusted @@ -49,11 +49,32 @@ class AppState: ObservableObject { } } - /// When SPV-synced quorum construction fails, fall back to the trusted - /// HTTP quorum provider. Applies on the next SDK initialization. - @Published var useTrustedQuorumFallback: Bool { + /// User-selected policy for which quorum source proof verification uses. + enum QuorumMode: String, CaseIterable, Identifiable { + /// Trusted until the SPV masternode list is synced, then SPV. + case auto + /// Force SPV as soon as the SPV client is running (proofs fail closed + /// until synced; no trusted fallback). + case spv + /// Force the trusted HTTP quorum provider. + case trusted + + var id: String { rawValue } + var label: String { + switch self { + case .auto: return "Auto" + case .spv: return "SPV" + case .trusted: return "Trusted" + } + } + } + + /// Which quorum source the user wants. Applied live on change (see + /// `applyQuorumMode`); `quorumSource` reflects what is *actually* installed + /// (SPV may not be ready yet). + @Published var quorumMode: QuorumMode { didSet { - UserDefaults.standard.set(useTrustedQuorumFallback, forKey: "useTrustedQuorumFallback") + UserDefaults.standard.set(quorumMode.rawValue, forKey: "quorumMode") } } @@ -87,11 +108,10 @@ class AppState: ObservableObject { // Persist so SDK.swift can read it (didSet doesn't fire in init) UserDefaults.standard.set(legacyLocal, forKey: "useDockerSetup") } - // Default: ON β€” trusted HTTP quorums back up the SPV path. - self.useTrustedQuorumFallback = - UserDefaults.standard.object(forKey: "useTrustedQuorumFallback") != nil - ? UserDefaults.standard.bool(forKey: "useTrustedQuorumFallback") - : true + // Default: Auto β€” trusted until SPV syncs, then SPV. + self.quorumMode = + UserDefaults.standard.string(forKey: "quorumMode").flatMap(QuorumMode.init(rawValue:)) + ?? .auto } func initializeSDK(modelContext: ModelContext) { @@ -111,7 +131,7 @@ class AppState: ObservableObject { NSLog("πŸ”΅ AppState: Creating SDK for network=\(currentNetwork), docker=\(useDockerSetup)") // Build with the trusted quorum provider. Proof verification is - // switched to SPV-synced quorums later via `attachSpvIfReady`, + // switched to SPV-synced quorums later via `applyQuorumMode`, // once the active wallet manager's SPV masternode list is synced // (the manager can only be created *from* an SDK, so the SDK // must exist first β€” hence attach-after rather than build-from). @@ -138,34 +158,47 @@ class AppState: ObservableObject { } } - /// Switch the SDK's proof verification from the trusted HTTP quorum service - /// to `manager`'s SPV-synced quorum data, once SPV has synced enough for - /// lookups to succeed. Idempotent β€” a no-op once already on SPV. Call - /// whenever the active manager's `spvProgress` changes. + /// Install the quorum source the current `quorumMode` calls for, via a live + /// provider swap on the shared slot (no SDK rebuild). Idempotent. Call + /// whenever `quorumMode` or the manager's `spvProgress` changes. + /// + /// - `.auto`: SPV once the header + masternode lists are fully synced, + /// trusted until then. + /// - `.spv`: SPV as soon as the SPV client is running (proofs fail closed + /// until synced; no trusted fallback). Stays trusted if SPV isn't running. + /// - `.trusted`: always the trusted HTTP quorum provider. /// - /// Policy (`useTrustedQuorumFallback`): - /// - ON (default): stay on trusted until the SPV header + masternode lists - /// are fully synced, then switch to SPV. - /// - OFF: switch to SPV as soon as the SPV client is running (before full - /// sync), so proof verification exercises SPV with no trusted safety net - /// (lookups fail closed until sync catches up). + /// `quorumSource` tracks what is *actually* installed, which may lag the + /// requested mode while SPV isn't ready. @MainActor - func attachSpvIfReady(manager: PlatformWalletManager) { - guard let sdk, quorumSource == .trusted else { return } + func applyQuorumMode(manager: PlatformWalletManager) { + guard let sdk else { return } let progress = manager.spvProgress let running = progress.overallState == .syncing || progress.overallState == .synced let fullySynced = progress.headers?.state == .synced && progress.masternodes?.state == .synced - guard useTrustedQuorumFallback ? fullySynced : running else { return } + + let wantSpv: Bool + switch quorumMode { + case .auto: wantSpv = fullySynced + case .spv: wantSpv = running + case .trusted: wantSpv = false + } do { - try sdk.attachSpvQuorums(from: manager) - quorumSource = .spv - NSLog("βœ… AppState: proof verification now uses SPV-synced quorums") + if wantSpv, quorumSource == .trusted { + try sdk.attachSpvQuorums(from: manager) + quorumSource = .spv + NSLog("βœ… AppState: proof verification now uses SPV-synced quorums") + } else if !wantSpv, quorumSource == .spv { + try sdk.restoreTrustedQuorums() + quorumSource = .trusted + NSLog("↩️ AppState: proof verification reverted to trusted quorums") + } } catch { - NSLog("⚠️ AppState: failed to attach SPV quorums: \(error.localizedDescription)") + NSLog("⚠️ AppState: failed to apply quorum mode: \(error.localizedDescription)") } } @@ -190,7 +223,7 @@ class AppState: ObservableObject { isLoading = true // Create new SDK instance for the network (trusted provider; SPV is - // re-attached by `attachSpvIfReady` once the new network's manager + // re-attached by `applyQuorumMode` once the new network's manager // has synced). let newSDK = try SDK(network: network) sdk = newSDK diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index 2c3ab535ce2..fddb0e867c6 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -175,7 +175,13 @@ struct SwiftExampleAppApp: App { // already on SPV, and re-evaluated for the new manager after a // network switch (which resets `quorumSource` to `.trusted`). .onChange(of: walletManager.spvProgress) { _, _ in - platformState.attachSpvIfReady(manager: walletManager) + platformState.applyQuorumMode(manager: walletManager) + } + // Apply the Quorum Source picker (Auto / SPV / Trusted) live via + // a provider swap on the shared slot β€” no SDK rebuild, so it + // stays consistent with the manager's shared context provider. + .onChange(of: platformState.quorumMode) { _, _ in + platformState.applyQuorumMode(manager: walletManager) } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift index 1e6e16f97ae..ab5a11ebb2e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift @@ -347,8 +347,16 @@ struct OptionsView: View { } } - Toggle("Fallback to Trusted Quorums", isOn: $appState.useTrustedQuorumFallback) - .help("When enabled, proof verification stays on the trusted HTTP quorum provider until the SPV masternode list is synced, then switches to SPV. Disable to switch to SPV as soon as it is running (no trusted fallback; proofs fail until synced). Takes effect on the next SDK build.") + VStack(alignment: .leading, spacing: 4) { + Text("Quorum Source") + Picker("Quorum Source", selection: $appState.quorumMode) { + ForEach(AppState.QuorumMode.allCases) { mode in + Text(mode.label).tag(mode) + } + } + .pickerStyle(.segmented) + } + .help("Which quorum public-key source to use for Platform proof verification, applied live. Auto: trusted until the SPV masternode list syncs, then SPV. SPV: force SPV now (proofs fail closed until synced; no trusted fallback). Trusted: force the trusted HTTP quorum service. The Proof Quorum Source below shows what is actually active.") HStack { Text("Proof Quorum Source") From 9a2289a27032cc3092eb8057432d0bb5846c7078 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 13 Jul 2026 22:41:09 +0700 Subject: [PATCH 11/21] test(sdk,rs-sdk-ffi): cover context-provider clone-sharing + install/restore FFIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rs-sdk: pin the shared context-provider slot β€” a `set_context_provider` on an `Sdk` clone is observed by the original (the v2 behavior the wallet-manager SPV switch relies on; previously untested). - rs-sdk-ffi: cover `dash_sdk_install_context_provider` (swap is observable + both null-pointer guards) and `dash_sdk_restore_trusted_context_provider` (null guard + errors when the SDK has no stored trusted provider). Raises coverage on the new FFI context-provider paths added for the SPV proof-verification switch. Co-Authored-By: Claude Opus 4.8 --- packages/rs-sdk-ffi/src/sdk.rs | 99 ++++++++++++++++++++++++++++++++++ packages/rs-sdk/src/sdk.rs | 69 ++++++++++++++++++++++++ 2 files changed, 168 insertions(+) diff --git a/packages/rs-sdk-ffi/src/sdk.rs b/packages/rs-sdk-ffi/src/sdk.rs index ce8338daebf..f26af1f19a5 100644 --- a/packages/rs-sdk-ffi/src/sdk.rs +++ b/packages/rs-sdk-ffi/src/sdk.rs @@ -909,3 +909,102 @@ pub unsafe extern "C" fn dash_sdk_create_handle_with_mock( } } } + +#[cfg(test)] +mod context_provider_install_tests { + use super::*; + use crate::test_utils::test_utils::{create_mock_sdk_handle, destroy_mock_sdk_handle}; + use dash_sdk::dpp::data_contract::TokenConfiguration; + use dash_sdk::dpp::prelude::{CoreBlockHeight, DataContract, Identifier}; + use dash_sdk::dpp::version::PlatformVersion; + use drive_proof_verifier::{ContextProvider, ContextProviderError}; + + /// Provider whose activation height is a distinctive sentinel, so a swap can + /// be observed by reading it back off the SDK. + struct SentinelProvider(CoreBlockHeight); + impl ContextProvider for SentinelProvider { + fn get_quorum_public_key( + &self, + _quorum_type: u32, + _quorum_hash: [u8; 32], + _height: u32, + ) -> Result<[u8; 48], ContextProviderError> { + Ok([0u8; 48]) + } + fn get_data_contract( + &self, + _id: &Identifier, + _pv: &PlatformVersion, + ) -> Result>, ContextProviderError> { + Ok(None) + } + fn get_token_configuration( + &self, + _id: &Identifier, + ) -> Result, ContextProviderError> { + Ok(None) + } + fn get_platform_activation_height(&self) -> Result { + Ok(self.0) + } + } + + fn provider_handle(p: impl ContextProvider + 'static) -> *mut ContextProviderHandle { + Box::into_raw(Box::new(ContextProviderWrapper::new(p))) as *mut ContextProviderHandle + } + + #[test] + fn install_swaps_provider_and_guards_nulls() { + unsafe { + let sdk = create_mock_sdk_handle(); + + // Null provider handle -> error, nothing installed. + let r = dash_sdk_install_context_provider(sdk, std::ptr::null_mut()); + assert!(!r.error.is_null(), "null provider must error"); + crate::error::dash_sdk_error_free(r.error); + + // Null SDK handle -> error, but the provider box is still reclaimed + // exactly once (no leak / no UB). + let ph = provider_handle(SentinelProvider(111)); + let r = dash_sdk_install_context_provider(std::ptr::null_mut(), ph); + assert!(!r.error.is_null(), "null sdk handle must error"); + crate::error::dash_sdk_error_free(r.error); + + // Success: the swap is observable on the SDK. + const SENTINEL: CoreBlockHeight = 7_777_777; + let ph = provider_handle(SentinelProvider(SENTINEL)); + let r = dash_sdk_install_context_provider(sdk, ph); + assert!(r.error.is_null(), "install should succeed"); + let wrapper = &*(sdk as *const SDKWrapper); + let height = wrapper + .sdk + .context_provider() + .expect("provider present") + .get_platform_activation_height() + .expect("activation height"); + assert_eq!(height, SENTINEL); + + destroy_mock_sdk_handle(sdk); + } + } + + #[test] + fn restore_errors_without_stored_trusted_provider() { + unsafe { + // Null handle -> error. + let r = dash_sdk_restore_trusted_context_provider(std::ptr::null_mut()); + assert!(!r.error.is_null(), "null handle must error"); + crate::error::dash_sdk_error_free(r.error); + + // A mock SDK wrapper stores no trusted provider, so restore errors. + let sdk = create_mock_sdk_handle(); + let r = dash_sdk_restore_trusted_context_provider(sdk); + assert!( + !r.error.is_null(), + "restore without a stored trusted provider must error" + ); + crate::error::dash_sdk_error_free(r.error); + destroy_mock_sdk_handle(sdk); + } + } +} diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index 7b3897e1477..6bb839e8b84 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -1274,6 +1274,75 @@ mod test { /// Testnet Evo masternodes expose the Platform HTTP endpoint on 1443. const TESTNET_PLATFORM_HTTP_PORT: u16 = 1443; + /// Regression guard for the shared context-provider slot. `Sdk::clone` + /// shares the provider slot (via `Arc`) rather than + /// snapshotting it, so a `set_context_provider` on any clone is observed by + /// all of them. This is what lets a wallet manager (which holds an SDK + /// clone) pick up a runtime switch to SPV-synced quorums installed on the + /// app's SDK. A snapshot-per-clone would leave the original on its old + /// provider and fail this test. + #[test] + fn context_provider_is_shared_across_clones() { + use dash_context_provider::{ContextProvider, ContextProviderError}; + use dpp::data_contract::TokenConfiguration; + use dpp::prelude::{CoreBlockHeight, DataContract, Identifier}; + use dpp::version::PlatformVersion; + + /// Provider whose activation height is a distinctive sentinel so it can + /// be told apart from the mock SDK's default provider. + struct SentinelProvider(CoreBlockHeight); + impl ContextProvider for SentinelProvider { + fn get_quorum_public_key( + &self, + _quorum_type: u32, + _quorum_hash: [u8; 32], + _height: u32, + ) -> Result<[u8; 48], ContextProviderError> { + Ok([0u8; 48]) + } + fn get_data_contract( + &self, + _id: &Identifier, + _pv: &PlatformVersion, + ) -> Result>, ContextProviderError> { + Ok(None) + } + fn get_token_configuration( + &self, + _id: &Identifier, + ) -> Result, ContextProviderError> { + Ok(None) + } + fn get_platform_activation_height( + &self, + ) -> Result { + Ok(self.0) + } + } + + const SENTINEL: CoreBlockHeight = 4_242_424; + + let sdk = SdkBuilder::new_mock() + .build() + .expect("mock sdk should build"); + let clone = sdk.clone(); + + // Install the sentinel provider on the CLONE only. + clone.set_context_provider(SentinelProvider(SENTINEL)); + + // The ORIGINAL must observe it β€” proving the slot is shared, not a + // per-clone snapshot. + let height = sdk + .context_provider() + .expect("provider present after set") + .get_platform_activation_height() + .expect("activation height"); + assert_eq!( + height, SENTINEL, + "set_context_provider on a clone must be visible on the original (shared slot)" + ); + } + #[test] fn new_testnet_sources_bootstrap_from_seeds() { let builder = SdkBuilder::new_testnet(); From 584e68cafef7196fa37b5f22b416f1e32a985beb Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 14 Jul 2026 20:17:50 +0700 Subject: [PATCH 12/21] fix(rs-platform-wallet): reverse quorum hash to internal order for SPV lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SPV masternode engine keys its quorum map in internal byte order, but the SDK proof verifier supplies quorum_hash in display (reversed) order β€” the same order the trusted HTTP provider matches against. Building the lookup key without reversing missed every real quorum, so every Platform proof verification failed and, in SPV mode, platform/shielded sync failed. Verified on a synced testnet node: the engine holds 696 type-6 (llmq_25_67) Platform quorums, and an in-lookup probe showed direct_hit=false / reversed_hit=true (requested 0000006b…, stored 1a3680f9…2d000000). After reversing: served=36, missed=0, zero platform/shielded sync failures (was served=0, all misses). An earlier commit on this branch removed the base's `.reverse()` on the mistaken assumption that quorum_hash was internal-order end-to-end; that was the regression. This restores it. Test would have caught this in CI: the renamed regression test `quorum_hash_reversed_to_internal_order_before_lookup` asserts a display-order lookup misses and a reversed lookup hits β€” βœ– before fix, βœ” after. Co-Authored-By: Claude Opus 4.8 --- .../rs-platform-wallet/src/spv/runtime.rs | 63 ++++++++++--------- .../src/spv_context_provider.rs | 38 ++++++++++- 2 files changed, 71 insertions(+), 30 deletions(-) diff --git a/packages/rs-platform-wallet/src/spv/runtime.rs b/packages/rs-platform-wallet/src/spv/runtime.rs index 47655941503..1828acae908 100644 --- a/packages/rs-platform-wallet/src/spv/runtime.rs +++ b/packages/rs-platform-wallet/src/spv/runtime.rs @@ -165,12 +165,16 @@ impl SpvRuntime { ))?; let llmq_type = LLMQType::from(quorum_type as u8); - // `quorum_hash` arrives in internal byte order (drive-abci sends it via - // `to_byte_array()`, verified by the consensus equality check in - // finalize_block_proposal), and the masternode engine keys its quorum - // map in that same internal order. So the lookup key must NOT be - // reversed β€” reversing guarantees a miss on every real quorum. - let qh = QuorumHash::from_byte_array(quorum_hash); + // The masternode engine keys its quorum map in internal byte order, but + // the SDK's proof verifier supplies `quorum_hash` in display (reversed) + // order β€” the same order the trusted HTTP provider matches against. The + // bytes must therefore be reversed before building the lookup key; + // without this every real quorum misses. Verified against a synced + // testnet node: the engine stores the reversed form of each requested + // hash (e.g. requested `0000…7f`, stored `…000000`). + let mut internal_order = quorum_hash; + internal_order.reverse(); + let qh = QuorumHash::from_byte_array(internal_order); let quorum = client .get_quorum_at_height(height, llmq_type, qh) @@ -469,44 +473,47 @@ mod tests { /// Regression guard for the quorum-hash byte order in /// [`SpvRuntime::get_quorum_public_key`]. /// - /// A Platform proof carries `quorum_hash` in internal byte order - /// (drive-abci emits it via `to_byte_array()`), and the masternode engine - /// keys its quorum map β€” `quorum_entry_of_type_for_quorum_hash`, i.e. a - /// `BTreeMap::get` β€” in that same internal order. So the - /// lookup key must be `QuorumHash::from_byte_array(quorum_hash)` with **no** - /// reversal, which is what `get_quorum_public_key` now uses. + /// The masternode engine keys its quorum map β€” `quorum_entry_of_type_for_quorum_hash`, + /// a `BTreeMap::get` β€” in internal byte order, but the SDK + /// proof verifier supplies `quorum_hash` in display (reversed) order (the + /// same order the trusted HTTP provider matches against). So the lookup key + /// must reverse the bytes first, which is what `get_quorum_public_key` does. /// - /// A previous version reversed it (`.reverse()`); this test pins why that - /// was wrong: the reversed key is absent from the map, so every real lookup - /// missed and fell through to fail-closed rejection (silently masked by the - /// trusted-quorum fallback). Re-introducing the reversal fails this test. + /// Verified end-to-end against a synced testnet node: the engine stores the + /// reversed form of each requested hash (requested `0000…`, stored `…0000`), + /// so a non-reversed lookup misses every real quorum and falls through to + /// fail-closed rejection (previously masked by the trusted-quorum fallback). + /// Dropping the reversal fails this test. #[test] - fn quorum_hash_lookup_uses_internal_order_not_reversed() { + fn quorum_hash_reversed_to_internal_order_before_lookup() { use dashcore::hashes::Hash; use dashcore::QuorumHash; use std::collections::BTreeMap; - // 32 distinct bytes so internal order differs from reversed order. - let wire_bytes: [u8; 32] = std::array::from_fn(|i| (i as u8) + 1); + // The hash as the proof verifier supplies it (display order). + let display_bytes: [u8; 32] = std::array::from_fn(|i| (i as u8) + 1); + // The engine keys the quorum under the internal (reversed) order. + let mut internal_bytes = display_bytes; + internal_bytes.reverse(); - // Model the engine's quorum map, keyed exactly as the engine keys it: - // by the `QuorumHash` in internal byte order. let pubkey = [0xABu8; 48]; let mut quorums: BTreeMap = BTreeMap::new(); - quorums.insert(QuorumHash::from_byte_array(wire_bytes), pubkey); + quorums.insert(QuorumHash::from_byte_array(internal_bytes), pubkey); - // What `get_quorum_public_key` does now (no reverse): the key matches. + // What `get_quorum_public_key` does: reverse display -> internal order. + let mut looked_up = display_bytes; + looked_up.reverse(); assert_eq!( - quorums.get(&QuorumHash::from_byte_array(wire_bytes)), + quorums.get(&QuorumHash::from_byte_array(looked_up)), Some(&pubkey), - "internal-order hash must find the quorum" + "reversing the display-order hash must find the internally-keyed quorum" ); - // The old buggy `.reverse()`: flipped key is absent β†’ miss (the bug). + // The regression (no reversal): display-order key is absent β†’ miss. assert_eq!( - quorums.get(&QuorumHash::from_byte_array(wire_bytes).reverse()), + quorums.get(&QuorumHash::from_byte_array(display_bytes)), None, - "reversed hash must NOT find the quorum β€” this was the bug" + "using the hash without reversal must miss β€” this was the regression" ); } } diff --git a/packages/rs-platform-wallet/src/spv_context_provider.rs b/packages/rs-platform-wallet/src/spv_context_provider.rs index 046818df929..3b62a5394dc 100644 --- a/packages/rs-platform-wallet/src/spv_context_provider.rs +++ b/packages/rs-platform-wallet/src/spv_context_provider.rs @@ -33,6 +33,18 @@ use dpp::version::PlatformVersion; use crate::spv::SpvRuntime; +/// Hex-encode the first 8 bytes of a quorum hash for correlation in logs. +/// +/// A prefix is enough to line a served key up with the proof that requested it +/// without dumping the full 32 bytes on every lookup. +fn hex_prefix(bytes: &[u8; 32]) -> String { + use std::fmt::Write; + bytes[..8].iter().fold(String::new(), |mut s, b| { + let _ = write!(s, "{b:02x}"); + s + }) +} + /// Context provider backed by an SPV client's synced masternode data. /// /// Delegates quorum-key lookups to the shared [`SpvRuntime`]; the same runtime @@ -73,14 +85,36 @@ impl ContextProvider for SpvContextProvider { "SPV quorum lookup called outside a Tokio runtime".to_string(), ) })?; - tokio::task::block_in_place(|| { + let result = tokio::task::block_in_place(|| { handle.block_on(self.spv.get_quorum_public_key( quorum_type, quorum_hash, core_chain_locked_height, )) }) - .map_err(|e| ContextProviderError::InvalidQuorum(e.to_string())) + .map_err(|e| ContextProviderError::InvalidQuorum(e.to_string())); + + // The quorum public key is the trust root of every Platform proof this + // SDK verifies; tracing which provider served it lets an operator + // confirm proofs are validated against SPV-synced state rather than the + // trusted HTTP fallback. Hash prefix only, to correlate without noise. + match &result { + Ok(_) => tracing::debug!( + quorum_type, + height = core_chain_locked_height, + quorum_hash = %hex_prefix(&quorum_hash), + "SPV context provider served quorum public key", + ), + Err(e) => tracing::debug!( + quorum_type, + height = core_chain_locked_height, + quorum_hash = %hex_prefix(&quorum_hash), + error = %e, + "SPV context provider quorum lookup missed", + ), + } + + result } fn get_platform_activation_height(&self) -> Result { From 1deb36b5a64e238139716dc3f218cbac0c08758f Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 14 Jul 2026 21:14:38 +0700 Subject: [PATCH 13/21] fix(rs-platform-wallet): guard byte-order via production helper + pin Platform quorum type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses two review blockers on PR #3417: - Byte-order regression test now exercises the production transform. The reversal lives in `quorum_lookup_key`, which `get_quorum_public_key` calls, and the test asserts against that function β€” so dropping the reversal fails the test. Previously the test asserted standalone `BTreeMap` semantics that could not detect a change in the real lookup. - Reject any `quorum_type` other than `network.platform_type()` before the SPV lookup. The proof's `quorum_type` is attacker-controlled and folded into the signature digest; without this check a malicious DAPI endpoint plus a compromised threshold of a lower-threshold quorum (e.g. LLMQ 50/60, which needs only 30 signers, vs Platform's 100/67) could authenticate forged Platform state. Co-Authored-By: Claude Opus 4.8 --- .../rs-platform-wallet/src/spv/runtime.rs | 63 +++++++++++-------- .../src/spv_context_provider.rs | 16 +++++ 2 files changed, 53 insertions(+), 26 deletions(-) diff --git a/packages/rs-platform-wallet/src/spv/runtime.rs b/packages/rs-platform-wallet/src/spv/runtime.rs index 1828acae908..4202e180f95 100644 --- a/packages/rs-platform-wallet/src/spv/runtime.rs +++ b/packages/rs-platform-wallet/src/spv/runtime.rs @@ -24,6 +24,24 @@ use crate::wallet::platform_wallet::PlatformWalletInfo; type SpvClient = DashSpvClient, PeerNetworkManager, DiskStorageManager>; +/// Build the masternode-engine lookup key from a proof-supplied quorum hash. +/// +/// The SDK's proof verifier supplies the quorum hash in display (big-endian) +/// byte order β€” the same order the trusted HTTP provider matches against β€” but +/// the masternode engine keys its quorum map in internal (little-endian) order. +/// The bytes must therefore be reversed before building the [`QuorumHash`] key; +/// without this every real quorum misses. Verified against a synced testnet +/// node: the engine stores the reversed form of each requested hash (e.g. +/// requested `0000…7f`, stored `…000000`). +/// +/// [`SpvRuntime::get_quorum_public_key`] calls this so the byte-order regression +/// test exercises the exact transform the production lookup uses. +fn quorum_lookup_key(display_order: [u8; 32]) -> QuorumHash { + let mut internal_order = display_order; + internal_order.reverse(); + QuorumHash::from_byte_array(internal_order) +} + /// SPV client runtime β€” owns the `DashSpvClient` and drives sync. /// /// Events are dispatched through [`PlatformEventManager`] to all registered @@ -165,16 +183,7 @@ impl SpvRuntime { ))?; let llmq_type = LLMQType::from(quorum_type as u8); - // The masternode engine keys its quorum map in internal byte order, but - // the SDK's proof verifier supplies `quorum_hash` in display (reversed) - // order β€” the same order the trusted HTTP provider matches against. The - // bytes must therefore be reversed before building the lookup key; - // without this every real quorum misses. Verified against a synced - // testnet node: the engine stores the reversed form of each requested - // hash (e.g. requested `0000…7f`, stored `…000000`). - let mut internal_order = quorum_hash; - internal_order.reverse(); - let qh = QuorumHash::from_byte_array(internal_order); + let qh = quorum_lookup_key(quorum_hash); let quorum = client .get_quorum_at_height(height, llmq_type, qh) @@ -402,7 +411,7 @@ mod tests { use key_wallet_manager::WalletManager; use tokio::sync::RwLock; - use super::{classify_spv_broadcast_error, SpvRuntime}; + use super::{classify_spv_broadcast_error, quorum_lookup_key, SpvRuntime}; use crate::broadcaster::BroadcastError; use crate::events::PlatformEventManager; use crate::wallet::platform_wallet::PlatformWalletInfo; @@ -470,20 +479,22 @@ mod tests { } } - /// Regression guard for the quorum-hash byte order in + /// Regression guard for the quorum-hash byte order used by /// [`SpvRuntime::get_quorum_public_key`]. /// + /// This exercises the production transform directly β€” [`quorum_lookup_key`] + /// is the exact function `get_quorum_public_key` calls to build its engine + /// lookup key β€” so dropping (or re-introducing a spurious) reversal there + /// fails this test, rather than the test asserting standalone `BTreeMap` + /// semantics that can't detect a change in the real code. + /// /// The masternode engine keys its quorum map β€” `quorum_entry_of_type_for_quorum_hash`, /// a `BTreeMap::get` β€” in internal byte order, but the SDK - /// proof verifier supplies `quorum_hash` in display (reversed) order (the - /// same order the trusted HTTP provider matches against). So the lookup key - /// must reverse the bytes first, which is what `get_quorum_public_key` does. - /// - /// Verified end-to-end against a synced testnet node: the engine stores the - /// reversed form of each requested hash (requested `0000…`, stored `…0000`), - /// so a non-reversed lookup misses every real quorum and falls through to + /// proof verifier supplies the hash in display (reversed) order. Verified + /// end-to-end against a synced testnet node: the engine stores the reversed + /// form of each requested hash (requested `0000…`, stored `…0000`), so a + /// non-reversed lookup misses every real quorum and falls through to /// fail-closed rejection (previously masked by the trusted-quorum fallback). - /// Dropping the reversal fails this test. #[test] fn quorum_hash_reversed_to_internal_order_before_lookup() { use dashcore::hashes::Hash; @@ -500,16 +511,16 @@ mod tests { let mut quorums: BTreeMap = BTreeMap::new(); quorums.insert(QuorumHash::from_byte_array(internal_bytes), pubkey); - // What `get_quorum_public_key` does: reverse display -> internal order. - let mut looked_up = display_bytes; - looked_up.reverse(); + // The production key builder must land on the internally-keyed quorum. assert_eq!( - quorums.get(&QuorumHash::from_byte_array(looked_up)), + quorums.get(&quorum_lookup_key(display_bytes)), Some(&pubkey), - "reversing the display-order hash must find the internally-keyed quorum" + "quorum_lookup_key must reverse display order to the engine's internal key" ); - // The regression (no reversal): display-order key is absent β†’ miss. + // Sanity: the un-reversed display-order key is absent β€” this is exactly + // the miss that `quorum_lookup_key`'s reversal exists to prevent, so if + // the reversal is removed the assertion above fails. assert_eq!( quorums.get(&QuorumHash::from_byte_array(display_bytes)), None, diff --git a/packages/rs-platform-wallet/src/spv_context_provider.rs b/packages/rs-platform-wallet/src/spv_context_provider.rs index 3b62a5394dc..5f461bbb79b 100644 --- a/packages/rs-platform-wallet/src/spv_context_provider.rs +++ b/packages/rs-platform-wallet/src/spv_context_provider.rs @@ -26,6 +26,7 @@ use std::sync::Arc; use dash_context_provider::ContextProvider; use dash_context_provider::ContextProviderError; +use dashcore::sml::llmq_type::network::NetworkLLMQExt; use dashcore::Network; use dpp::data_contract::TokenConfiguration; use dpp::prelude::{CoreBlockHeight, DataContract, Identifier}; @@ -74,6 +75,21 @@ impl ContextProvider for SpvContextProvider { quorum_hash: [u8; 32], core_chain_locked_height: u32, ) -> Result<[u8; 48], ContextProviderError> { + // Reject any quorum type other than this network's Platform quorum. The + // proof's `quorum_type` is attacker-controlled and is folded into the + // signature digest, so without this check a malicious DAPI endpoint plus + // a compromised threshold of a lower-threshold quorum (e.g. LLMQ 50/60, + // which needs only 30 signers, vs Platform's 100/67) could authenticate + // forged Platform state. Pin the lookup to `network.platform_type()`. + let platform_type = self.network.platform_type(); + if quorum_type != u32::from(u8::from(platform_type)) { + return Err(ContextProviderError::InvalidQuorum(format!( + "quorum type {quorum_type} is not the Platform quorum for {:?} \ + (expected {platform_type:?})", + self.network + ))); + } + // Bridge the sync trait method to the async runtime lookup. Proof // verification always runs inside the SDK's multi-threaded runtime, so // the ambient handle is present; a call from outside a runtime returns From 8263be06c311cfc54a8b2d86e8ea49ad069e6612 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 14 Jul 2026 21:25:08 +0700 Subject: [PATCH 14/21] fix(rs-sdk-ffi): install SPV quorum provider as a composite over trusted Addresses a review blocker on PR #3417: installing the SPV context provider replaced the SDK's entire ContextProvider, so get_data_contract and get_token_configuration returned None and broke every proof that references a contract or token configuration (e.g. token perpetual-distribution verification, which resolves the token config before checking the Tenderdash signature). dash_sdk_install_context_provider now wraps the SPV provider in a CompositeContextProvider that serves the quorum public key and platform activation height from SPV-synced state while delegating contract and token lookups to the SDK's retained trusted provider. When no trusted provider was retained the SPV provider is installed alone (verifies only proofs that need neither). Co-Authored-By: Claude Opus 4.8 --- packages/rs-sdk-ffi/src/context_provider.rs | 56 +++++++++++++++++++++ packages/rs-sdk-ffi/src/sdk.rs | 27 ++++++++-- 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/packages/rs-sdk-ffi/src/context_provider.rs b/packages/rs-sdk-ffi/src/context_provider.rs index bab0fde6227..d5cb66eb38a 100644 --- a/packages/rs-sdk-ffi/src/context_provider.rs +++ b/packages/rs-sdk-ffi/src/context_provider.rs @@ -5,6 +5,10 @@ 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; use crate::context_callbacks::{CallbackContextProvider, ContextProviderCallbacks}; @@ -42,6 +46,58 @@ impl ContextProviderWrapper { } } +/// Context provider that serves the security-critical quorum public key (and +/// platform activation height) from `quorum`, while delegating data-contract +/// and token-configuration lookups to `aux`. +/// +/// Installing an SPV quorum provider must not lose the SDK's ability to resolve +/// contracts and token configurations: those feed proof verification (e.g. +/// token perpetual-distribution verification calls `get_token_configuration` +/// before checking the Tenderdash signature) and the SPV provider cannot supply +/// them. This composite keeps quorum verification on SPV-synced state while +/// contract/token resolution stays on the retained trusted provider. +pub struct CompositeContextProvider { + quorum: Arc, + aux: Arc, +} + +impl CompositeContextProvider { + pub fn new(quorum: Arc, aux: Arc) -> Self { + Self { quorum, aux } + } +} + +impl ContextProvider for CompositeContextProvider { + fn get_quorum_public_key( + &self, + quorum_type: u32, + quorum_hash: [u8; 32], + core_chain_locked_height: u32, + ) -> Result<[u8; 48], ContextProviderError> { + self.quorum + .get_quorum_public_key(quorum_type, quorum_hash, core_chain_locked_height) + } + + fn get_platform_activation_height(&self) -> Result { + self.quorum.get_platform_activation_height() + } + + fn get_data_contract( + &self, + id: &Identifier, + platform_version: &PlatformVersion, + ) -> Result>, ContextProviderError> { + self.aux.get_data_contract(id, platform_version) + } + + fn get_token_configuration( + &self, + token_id: &Identifier, + ) -> Result, ContextProviderError> { + self.aux.get_token_configuration(token_id) + } +} + // Note: Core SDK FFI types are opaque to rs-sdk-ffi and referenced via raw pointers. // Note: Core SDK functions are now provided via callbacks instead of direct linking diff --git a/packages/rs-sdk-ffi/src/sdk.rs b/packages/rs-sdk-ffi/src/sdk.rs index f26af1f19a5..254ecb196f2 100644 --- a/packages/rs-sdk-ffi/src/sdk.rs +++ b/packages/rs-sdk-ffi/src/sdk.rs @@ -10,7 +10,9 @@ use dash_sdk::{Sdk, SdkBuilder}; use std::ffi::CStr; use std::str::FromStr; -use crate::context_provider::{ContextProviderHandle, ContextProviderWrapper, CoreSDKHandle}; +use crate::context_provider::{ + CompositeContextProvider, ContextProviderHandle, ContextProviderWrapper, CoreSDKHandle, +}; use crate::runtime::BigStackRuntime; use crate::types::{DashSDKConfig, FFINetwork, Network, SDKHandle}; use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, FFIError}; @@ -593,10 +595,25 @@ pub unsafe extern "C" fn dash_sdk_install_context_provider( } let sdk_wrapper = &*(sdk_handle as *const SDKWrapper); - // Swaps the SDK's `ArcSwapOption` provider slot in place; the SDK keeps its - // own `Arc` clone. `wrapper` drops at end of scope, releasing this - // function's refcount on the provider. - sdk_wrapper.sdk.set_context_provider(wrapper.provider()); + let spv_provider = wrapper.provider(); + // Install the SPV provider for quorum-key resolution, but keep contract and + // token lookups on the SDK's retained trusted provider. Replacing the whole + // provider would make `get_data_contract`/`get_token_configuration` return + // `None` and break every proof that references a contract or token config + // (e.g. token perpetual-distribution verification). If no trusted provider + // was retained (the SDK was not created trusted), install SPV alone β€” such + // an SDK can only verify proofs that need neither. `wrapper` drops at end of + // scope, releasing this function's refcount on the provider. + match sdk_wrapper.trusted_provider.as_ref() { + Some(trusted) => { + let composite = CompositeContextProvider::new( + spv_provider, + Arc::clone(trusted) as Arc, + ); + sdk_wrapper.sdk.set_context_provider(composite); + } + None => sdk_wrapper.sdk.set_context_provider(spv_provider), + } DashSDKResult::success(std::ptr::null_mut()) } From a53b983d8d2b5d0fa14b66d48ecd895b36e53fed Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 14 Jul 2026 21:25:08 +0700 Subject: [PATCH 15/21] fix(swift-sdk): force SPV install in SPV mode instead of gating on run state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses a review blocker on PR #3417: the Quorum Source picker's `.spv` case computed `wantSpv` from the SPV client's run state, so selecting SPV while the client was stopped left the trusted HTTP provider installed, and a later SPV stop/error actively restored trusted β€” contradicting the UI's promise to force SPV with no trusted fallback. `.spv` now installs the SPV provider unconditionally. This is safe because the SPV lookup fails closed ("SPV Client not started") rather than silently falling back, so proofs simply fail until sync completes instead of being verified against trusted quorums. Auto and Trusted modes are unchanged. Co-Authored-By: Claude Opus 4.8 --- .../SwiftExampleApp/SwiftExampleApp/AppState.swift | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift index 26eceabbb18..dcb0ebf5550 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift @@ -164,8 +164,9 @@ class AppState: ObservableObject { /// /// - `.auto`: SPV once the header + masternode lists are fully synced, /// trusted until then. - /// - `.spv`: SPV as soon as the SPV client is running (proofs fail closed - /// until synced; no trusted fallback). Stays trusted if SPV isn't running. + /// - `.spv`: SPV unconditionally β€” installed even before the SPV client + /// starts. The lookup fails closed ("SPV Client not started" / proofs + /// fail until synced) rather than falling back to trusted. /// - `.trusted`: always the trusted HTTP quorum provider. /// /// `quorumSource` tracks what is *actually* installed, which may lag the @@ -175,15 +176,13 @@ class AppState: ObservableObject { guard let sdk else { return } let progress = manager.spvProgress - let running = - progress.overallState == .syncing || progress.overallState == .synced let fullySynced = progress.headers?.state == .synced && progress.masternodes?.state == .synced let wantSpv: Bool switch quorumMode { case .auto: wantSpv = fullySynced - case .spv: wantSpv = running + case .spv: wantSpv = true case .trusted: wantSpv = false } From a0fa68317fc6cb00a4836d3dd5bbe8d26473e9af Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 15 Jul 2026 07:10:08 +0700 Subject: [PATCH 16/21] fix(rs-platform-wallet): avoid block_in_place panic on a current-thread runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses a review suggestion on PR #3417: `SpvContextProvider::get_quorum_public_key` used `tokio::task::block_in_place`, which panics if the ambient runtime is a current-thread runtime. Proof verification runs on the SDK's multi-threaded runtime today, but a current-thread caller would panic. The lookup now branches on `Handle::runtime_flavor()`: the multi-threaded path keeps `block_in_place` (no extra thread), while a current-thread runtime drives the in-memory lookup on a short-lived current-thread runtime on a dedicated thread. SPV state uses runtime-agnostic `tokio::sync` primitives, so this is safe β€” it never panics and works on both runtime flavors. Co-Authored-By: Claude Opus 4.8 --- .../src/spv_context_provider.rs | 54 +++++++++++++++---- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/packages/rs-platform-wallet/src/spv_context_provider.rs b/packages/rs-platform-wallet/src/spv_context_provider.rs index 5f461bbb79b..35d3182a39a 100644 --- a/packages/rs-platform-wallet/src/spv_context_provider.rs +++ b/packages/rs-platform-wallet/src/spv_context_provider.rs @@ -31,7 +31,9 @@ use dashcore::Network; use dpp::data_contract::TokenConfiguration; use dpp::prelude::{CoreBlockHeight, DataContract, Identifier}; use dpp::version::PlatformVersion; +use tokio::runtime::RuntimeFlavor; +use crate::error::PlatformWalletError; use crate::spv::SpvRuntime; /// Hex-encode the first 8 bytes of a quorum hash for correlation in logs. @@ -91,23 +93,53 @@ impl ContextProvider for SpvContextProvider { } // Bridge the sync trait method to the async runtime lookup. Proof - // verification always runs inside the SDK's multi-threaded runtime, so - // the ambient handle is present; a call from outside a runtime returns - // an error rather than panicking. The lookup is pure in-memory (two - // brief RwLock reads, no network I/O); on write contention with SPV + // verification runs inside a Tokio runtime; a call from outside one + // returns an error rather than panicking. The lookup is pure in-memory + // (two brief RwLock reads, no network I/O); on write contention with SPV // sync it waits (fail-slow) rather than erroring. let handle = tokio::runtime::Handle::try_current().map_err(|_| { ContextProviderError::Generic( "SPV quorum lookup called outside a Tokio runtime".to_string(), ) })?; - let result = tokio::task::block_in_place(|| { - handle.block_on(self.spv.get_quorum_public_key( - quorum_type, - quorum_hash, - core_chain_locked_height, - )) - }) + let result = if handle.runtime_flavor() == RuntimeFlavor::CurrentThread { + // `block_in_place` panics on a current-thread runtime, so drive the + // lookup on a short-lived runtime on a dedicated thread instead. SPV + // state uses runtime-agnostic `tokio::sync` primitives, so this is + // safe. This is not the SDK's normal (multi-threaded) verify path. + let spv = Arc::clone(&self.spv); + std::thread::scope(|scope| { + scope + .spawn(move || { + tokio::runtime::Builder::new_current_thread() + .build() + .map_err(|e| PlatformWalletError::SpvError(e.to_string())) + .and_then(|rt| { + rt.block_on(spv.get_quorum_public_key( + quorum_type, + quorum_hash, + core_chain_locked_height, + )) + }) + }) + .join() + .unwrap_or_else(|_| { + Err(PlatformWalletError::SpvError( + "SPV quorum lookup thread panicked".to_string(), + )) + }) + }) + } else { + // Multi-threaded runtime (the SDK's proof-verify runtime): block the + // current worker without stalling the whole runtime. + tokio::task::block_in_place(|| { + handle.block_on(self.spv.get_quorum_public_key( + quorum_type, + quorum_hash, + core_chain_locked_height, + )) + }) + } .map_err(|e| ContextProviderError::InvalidQuorum(e.to_string())); // The quorum public key is the trust root of every Platform proof this From 6fc782f41b20e591a38c570cac96143bd9bf4c9e Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 15 Jul 2026 14:30:55 +0700 Subject: [PATCH 17/21] fix(rs-platform-wallet): fail closed on current-thread runtime, not a helper-thread bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses a review follow-up on PR #3417: the previous current-thread branch bridged the SPV lookup via a helper thread, which can deadlock β€” proof verification would block the current-thread runtime's only executor in `join()` while the SPV masternode-engine write lock is held by a task on the same runtime, so that writer could never resume to release it. Since the SDK verifies proofs on a multi-threaded runtime, `get_quorum_public_key` now returns an unsupported-runtime error on a current-thread runtime, avoiding both the `block_in_place` panic and the helper-thread deadlock. Co-Authored-By: Claude Opus 4.8 --- .../src/spv_context_provider.rs | 57 +++++++------------ 1 file changed, 19 insertions(+), 38 deletions(-) diff --git a/packages/rs-platform-wallet/src/spv_context_provider.rs b/packages/rs-platform-wallet/src/spv_context_provider.rs index 35d3182a39a..8ed9ded286a 100644 --- a/packages/rs-platform-wallet/src/spv_context_provider.rs +++ b/packages/rs-platform-wallet/src/spv_context_provider.rs @@ -33,7 +33,6 @@ use dpp::prelude::{CoreBlockHeight, DataContract, Identifier}; use dpp::version::PlatformVersion; use tokio::runtime::RuntimeFlavor; -use crate::error::PlatformWalletError; use crate::spv::SpvRuntime; /// Hex-encode the first 8 bytes of a quorum hash for correlation in logs. @@ -102,44 +101,26 @@ impl ContextProvider for SpvContextProvider { "SPV quorum lookup called outside a Tokio runtime".to_string(), ) })?; - let result = if handle.runtime_flavor() == RuntimeFlavor::CurrentThread { - // `block_in_place` panics on a current-thread runtime, so drive the - // lookup on a short-lived runtime on a dedicated thread instead. SPV - // state uses runtime-agnostic `tokio::sync` primitives, so this is - // safe. This is not the SDK's normal (multi-threaded) verify path. - let spv = Arc::clone(&self.spv); - std::thread::scope(|scope| { - scope - .spawn(move || { - tokio::runtime::Builder::new_current_thread() - .build() - .map_err(|e| PlatformWalletError::SpvError(e.to_string())) - .and_then(|rt| { - rt.block_on(spv.get_quorum_public_key( - quorum_type, - quorum_hash, - core_chain_locked_height, - )) - }) - }) - .join() - .unwrap_or_else(|_| { - Err(PlatformWalletError::SpvError( - "SPV quorum lookup thread panicked".to_string(), - )) - }) - }) - } else { - // Multi-threaded runtime (the SDK's proof-verify runtime): block the - // current worker without stalling the whole runtime. - tokio::task::block_in_place(|| { - handle.block_on(self.spv.get_quorum_public_key( - quorum_type, - quorum_hash, - core_chain_locked_height, - )) - }) + // `block_in_place` panics on a current-thread runtime, and bridging via + // a helper thread can deadlock: proof verification would block this + // runtime's only executor in `join()` while the SPV masternode-engine + // write lock is held by a task on the same runtime, so that writer could + // never resume to release it. The SDK verifies proofs on a multi-threaded + // runtime, so fail closed here rather than risk a panic or a deadlock. + if handle.runtime_flavor() == RuntimeFlavor::CurrentThread { + return Err(ContextProviderError::Generic( + "SPV quorum lookup requires a multi-threaded Tokio runtime".to_string(), + )); } + // Multi-threaded runtime: block the current worker without stalling the + // whole runtime. + let result = tokio::task::block_in_place(|| { + handle.block_on(self.spv.get_quorum_public_key( + quorum_type, + quorum_hash, + core_chain_locked_height, + )) + }) .map_err(|e| ContextProviderError::InvalidQuorum(e.to_string())); // The quorum public key is the trust root of every Platform proof this From 3a5e96ee031804f6b1304d69276bf643becd3f4a Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 15 Jul 2026 23:04:59 +0700 Subject: [PATCH 18/21] refactor(sdk): add adaptive SPV context provider --- Cargo.lock | 1 + docs/spv-adaptive-provider/SPEC.md | 57 +++ .../src/main_trusted.rs | 4 +- .../rs-platform-wallet-ffi/src/manager.rs | 53 +++ packages/rs-platform-wallet-ffi/src/spv.rs | 46 --- .../rs-platform-wallet/src/spv/runtime.rs | 42 ++- packages/rs-sdk-ffi/Cargo.toml | 1 + packages/rs-sdk-ffi/src/context_provider.rs | 334 ++++++++++++++++-- packages/rs-sdk-ffi/src/sdk.rs | 243 ++++--------- packages/rs-sdk/examples/read_contract.rs | 4 +- packages/rs-sdk/src/sdk.rs | 154 ++++---- .../PlatformWalletManager.swift | 36 +- .../swift-sdk/Sources/SwiftDashSDK/SDK.swift | 53 +-- .../SwiftExampleApp/AppState.swift | 59 ++-- .../SwiftExampleApp/SwiftExampleAppApp.swift | 18 +- .../SwiftExampleApp/Views/OptionsView.swift | 1 + 16 files changed, 672 insertions(+), 434 deletions(-) create mode 100644 docs/spv-adaptive-provider/SPEC.md diff --git a/Cargo.lock b/Cargo.lock index 8da80c7acfd..f3b5dd14b3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6326,6 +6326,7 @@ dependencies = [ name = "rs-sdk-ffi" version = "4.0.0" dependencies = [ + "arc-swap", "async-trait", "bincode", "bs58", diff --git a/docs/spv-adaptive-provider/SPEC.md b/docs/spv-adaptive-provider/SPEC.md new file mode 100644 index 00000000000..955f16044de --- /dev/null +++ b/docs/spv-adaptive-provider/SPEC.md @@ -0,0 +1,57 @@ +# Adaptive SPV Context Provider + +## Purpose + +Platform proof verification uses one context-provider identity for the lifetime of an SDK. Runtime policy changes update atomic state inside that provider; they never replace the SDK provider. This removes clone-sensitive provider swapping while preserving trusted, SPV, and automatic quorum-source behavior. + +## Design + +`rs-sdk-ffi` owns `AdaptiveContextProvider` so neither `dash-sdk` nor `platform-wallet` depends on the other. It contains: + +- the trusted provider used at SDK construction; +- an initially empty, atomically replaceable SPV source containing an `Arc` and a synchronous readiness callback; +- an `AtomicU8` mode: Auto, SPV, or Trusted. + +The trusted SDK constructor installs `Arc` in `SdkBuilder` exactly once and retains that same `Arc` in `SDKWrapper`. Creating a `PlatformWalletManager` from an SDK automatically creates a `SpvContextProvider` for its `SpvRuntime` and populates the adaptive provider's SPV source. Source population changes only the adaptive provider's internal empty slot; it does not mutate `dash_sdk::Sdk` or replace its context provider. Re-populating the source is rejected so an SDK cannot silently change to a different manager/runtime. + +`SpvRuntime` maintains a lock-free readiness bit from dash-spv progress callbacks. It is true only when both header and masternode-list progress are present and `Synced`, exactly matching the current Swift Auto policy. Start, stop, and run-loop exit clear readiness. The adaptive provider reads this bit synchronously, so quorum lookup does not block on an async lock merely to select a source. + +## Routing + +For `get_quorum_public_key`: + +- Trusted always calls the trusted provider. +- SPV calls only the SPV provider. An absent source or an unready source returns an error; it never falls back to trusted. +- Auto calls trusted while the SPV source is absent or not ready, then calls only SPV once ready. An SPV lookup miss after readiness is an error and never falls back to trusted. + +`get_data_contract` and `get_token_configuration` always call the trusted provider. That provider resolves embedded system contracts and the SDK-populated cache and has no fallback provider, preserving the existing composite provider's trust boundary. + +`get_platform_activation_height` returns the existing per-network constant directly: mainnet 2,132,092; testnet 1,090,319; devnet/regtest 1. + +The SPV provider continues to reject any quorum type other than `network.platform_type()` before lookup, reverse the proof's display-order quorum hash through `SpvRuntime::quorum_lookup_key`, and fail closed outside a multi-thread Tokio runtime. The adaptive layer never retries a failed SPV lookup against trusted state. + +## FFI and Swift + +The SDK FFI exposes mode set/get and active-source inspection. Mode values are validated at the boundary. `PlatformWalletManager.configure(sdk:)` passes the SDK handle to manager creation; manager creation obtains the inner SDK clone and attaches its SPV source before returning. + +Active-source inspection reports Trusted in Trusted mode; SPV in forced-SPV mode once a source exists; and, in Auto, Trusted until the source is ready and SPV afterwards. Forced SPV with no populated source reports Trusted for the indicator even though quorum lookups fail closed, because no SPV source exists to identify as active. + +The install/restore FFIs and Swift `SDK.attachSpvQuorums` / `restoreTrustedQuorums` APIs are removed. Swift exposes a single mode-setting method. `AppState.applyQuorumMode` always calls it once with Auto/SPV/Trusted and then refreshes the active-source indicator; it does not decide routing or attach providers. Existing progress observation remains useful for refreshing the "Proof Quorum Source" indicator as Auto becomes ready. + +## Feasibility and Security Review + +The construction cycle is resolved at the FFI boundary: the SDK creates the fixed adaptive provider first; standard manager creation synchronously borrows the opaque SDK handle, clones its inner `Sdk`, constructs `SpvRuntime`, and then fills the adaptive provider's internal SPV slot before returning. The manager retains the cloned `Sdk`, never the raw SDK handle. `rs-sdk-ffi` only knows a trait-object provider plus readiness callback, while `rs-platform-wallet-ffi` creates both from `platform-wallet`; therefore no Rust dependency cycle is introduced. The advanced raw-inner-SDK manager constructor may remain available, but cannot attach an SPV source because it has no owning SDK handle. + +The provider and readiness callback are published together in one immutable SPV-source object, preventing a provider/readiness mismatch. The slot is write-once: a second manager cannot redirect an SDK's SPV trust root. Mode values use acquire/release atomic ordering and invalid FFI values are rejected. + +Each quorum call snapshots mode, source, and readiness. A concurrent readiness transition may affect the next call, but cannot create a trusted fallback after a call selected SPV. If SPV stops after selection, the SPV lookup fails closed. If readiness becomes true just after Auto selected trusted, that single in-flight call retains the prior trusted selection and subsequent calls use SPV; this is an expected boundary race, not a trust downgrade controlled by proof data. Readiness is explicitly cleared before every start/restart, during stop, and on every run-loop exit including errors. + +Readiness is derived from trusted local sync state, not from whether a requested quorum happens to exist. Consequently an attacker-induced SPV lookup miss after sync cannot trigger trusted retry. Contract/token routing is structurally independent of mode and SPV state, retaining only the trusted provider's cache and embedded-system-contract behavior. + +## Verification + +- Unit-test adaptive routing for all three modes, absent/unready SPV fail-closed behavior, Auto readiness transition, post-readiness SPV miss behavior, and trusted-only contract/token routing. +- Retain the quorum-hash byte-order regression test and the SPV quorum-type/current-thread fail-closed behavior. +- Run `cargo fmt --all -- --check` and `cargo clippy --workspace --all-features`. +- Build the simulator framework, then clean-build `SwiftExampleApp` with `xcodebuild`. +- On a synced testnet device, select SPV and perform proof-verified Platform queries; confirm successful results and the `SPV context provider served quorum public key` log. diff --git a/packages/dash-platform-balance-checker/src/main_trusted.rs b/packages/dash-platform-balance-checker/src/main_trusted.rs index e622555f168..d10fd3ab00f 100644 --- a/packages/dash-platform-balance-checker/src/main_trusted.rs +++ b/packages/dash-platform-balance-checker/src/main_trusted.rs @@ -49,11 +49,9 @@ async fn main() -> Result<()> { // Build SDK with mock core (context provider will handle everything) let sdk = SdkBuilder::new(AddressList::from_iter(addresses)) .with_core("127.0.0.1", 1, "mock", "mock") // Mock values, won't be used + .with_context_provider(context_provider) .build()?; - // Set our trusted context provider - sdk.set_context_provider(context_provider); - // Fetch the identity println!("Fetching identity with proof verification..."); match Identity::fetch(&sdk, identity_id).await { diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 7e553a64bc0..e39dec98879 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -65,6 +65,59 @@ pub unsafe extern "C" fn platform_wallet_manager_create( PlatformWalletFFIResult::ok() } +/// Create a manager from an owning SDK handle and populate that SDK's fixed +/// adaptive provider with the manager's SPV source. +/// +/// The SDK handle is borrowed only for this synchronous call. The manager +/// retains an `Sdk` clone, never the raw handle. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_create_with_sdk( + sdk_handle: *mut rs_sdk_ffi::SDKHandle, + persistence: *const PersistenceCallbacks, + event_handler: *const EventHandlerCallbacks, + out_handle: *mut Handle, +) -> PlatformWalletFFIResult { + check_ptr!(sdk_handle); + check_ptr!(persistence); + check_ptr!(event_handler); + check_ptr!(out_handle); + + let sdk_ptr = rs_sdk_ffi::dash_sdk_get_inner_sdk_ptr(sdk_handle); + if sdk_ptr.is_null() { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "SDK has no inner SDK".to_string(), + ); + } + + let sdk = Arc::new((*(sdk_ptr as *const Sdk)).clone()); + let persister = Arc::new(FFIPersister::new(std::ptr::read(persistence))); + let handler: Arc = + Arc::new(FFIEventHandler::new(std::ptr::read(event_handler))); + + let _runtime_guard = runtime().enter(); + let manager = PlatformWalletManager::new(Arc::clone(&sdk), persister, handler); + let spv = manager.spv_arc(); + let provider = Arc::new( + platform_wallet::spv_context_provider::SpvContextProvider::new( + Arc::clone(&spv), + sdk.network, + ), + ); + let readiness = Arc::new(move || spv.is_ready()); + if let Err(message) = rs_sdk_ffi::dash_sdk_set_spv_source(sdk_handle, provider, readiness) { + drop(_runtime_guard); + runtime().block_on(manager.shutdown()); + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + message, + ); + } + + *out_handle = PLATFORM_WALLET_MANAGER_STORAGE.insert(manager); + PlatformWalletFFIResult::ok() +} + /// Map the C `has_x: bool` + `x` companion-pair idiom to a Rust `Option`. /// /// `has == true` yields `Some(value)` β€” including `Some(0)`, kept distinct diff --git a/packages/rs-platform-wallet-ffi/src/spv.rs b/packages/rs-platform-wallet-ffi/src/spv.rs index 0c7dce46b07..7d03e19d084 100644 --- a/packages/rs-platform-wallet-ffi/src/spv.rs +++ b/packages/rs-platform-wallet-ffi/src/spv.rs @@ -496,49 +496,3 @@ pub unsafe extern "C" fn platform_wallet_manager_spv_clear_storage( unwrap_result_or_return!(result); PlatformWalletFFIResult::ok() } - -/// Install this wallet manager's SPV-synced quorum data as the proof-verification -/// context provider on an already-built Platform SDK, replacing its trusted -/// provider. Subsequent proof-verified queries on that SDK handle resolve quorum -/// public keys live from the locally synced masternode list. -/// -/// Call this only once the manager's SPV client is started and its masternode -/// list is synced (see `platform_wallet_manager_sync_progress`); before that, -/// lookups fail closed (no per-lookup fallback once installed). -/// -/// `dash_sdk::Sdk` shares its context-provider slot across clones, so this -/// single install also switches the manager's own cloned SDK (used for wallet / -/// DashPay sync) to SPV β€” both the app's queries and the manager's background -/// sync verify against the same SPV-synced quorum data. -/// -/// # Safety -/// - `manager_handle` must be a live `PlatformWalletManager` handle. -/// - `sdk_handle` must be a valid `SDKHandle` for the duration of the call. -#[no_mangle] -pub unsafe extern "C" fn platform_wallet_manager_attach_spv_context( - manager_handle: Handle, - sdk_handle: *mut rs_sdk_ffi::SDKHandle, - network: crate::types::FFINetwork, -) -> rs_sdk_ffi::DashSDKResult { - // A shared handle to the manager's SPV runtime β€” the same runtime the SPV - // client writes to during sync. Cloned out of the storage closure so it - // outlives the borrow and backs the provider for as long as it's installed. - let spv = match PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| m.spv_arc()) { - Some(spv) => spv, - None => { - return rs_sdk_ffi::DashSDKResult::error(rs_sdk_ffi::DashSDKError::new( - rs_sdk_ffi::DashSDKErrorCode::InvalidParameter, - "Invalid wallet manager handle".to_string(), - )); - } - }; - - let network: crate::types::Network = network.into(); - let provider = platform_wallet::spv_context_provider::SpvContextProvider::new(spv, network); - let handle = Box::into_raw(Box::new(rs_sdk_ffi::ContextProviderWrapper::new(provider))) - as *mut rs_sdk_ffi::ContextProviderHandle; - - // `dash_sdk_install_context_provider` TAKES ownership of the wrapper box and - // reclaims it exactly once β€” this function must not reclaim it. - rs_sdk_ffi::dash_sdk_install_context_provider(sdk_handle, handle) -} diff --git a/packages/rs-platform-wallet/src/spv/runtime.rs b/packages/rs-platform-wallet/src/spv/runtime.rs index 4202e180f95..cce49d3e599 100644 --- a/packages/rs-platform-wallet/src/spv/runtime.rs +++ b/packages/rs-platform-wallet/src/spv/runtime.rs @@ -1,5 +1,6 @@ //! SPV client runtime β€” manages the DashSpvClient lifecycle. +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use tokio::sync::RwLock; @@ -10,7 +11,7 @@ use dashcore::{QuorumHash, Transaction}; use dash_spv::network::PeerNetworkManager; use dash_spv::storage::{DiskStorageManager, StorageManager}; -use dash_spv::sync::SyncProgress; +use dash_spv::sync::{SyncProgress, SyncState}; use dash_spv::{ClientConfig, DashSpvClient, EventHandler, Hash}; use key_wallet_manager::WalletManager; @@ -53,6 +54,35 @@ pub struct SpvRuntime { last_config: RwLock>, task: Mutex>>, peer_tracker: Arc, + readiness: Arc, +} + +#[derive(Default)] +struct SpvReadiness { + ready: AtomicBool, +} + +impl SpvReadiness { + fn clear(&self) { + self.ready.store(false, Ordering::Release); + } + + fn is_ready(&self) -> bool { + self.ready.load(Ordering::Acquire) + } +} + +impl EventHandler for SpvReadiness { + fn on_progress(&self, progress: &SyncProgress) { + let headers_ready = progress + .headers() + .is_ok_and(|headers| headers.state() == SyncState::Synced); + let masternodes_ready = progress + .masternodes() + .is_ok_and(|masternodes| masternodes.state() == SyncState::Synced); + self.ready + .store(headers_ready && masternodes_ready, Ordering::Release); + } } /// Classify a dash-spv broadcast failure per the /// [`TransactionBroadcaster::broadcast`] contract. @@ -93,6 +123,7 @@ impl SpvRuntime { last_config: RwLock::new(None), task: Mutex::new(None), peer_tracker: Arc::new(PeerTracker::default()), + readiness: Arc::new(SpvReadiness::default()), } } @@ -104,6 +135,7 @@ impl SpvRuntime { return Err(PlatformWalletError::SpvAlreadyRunning); } } + self.readiness.clear(); let network_manager = PeerNetworkManager::new(&config) .await @@ -118,6 +150,7 @@ impl SpvRuntime { let event_handlers: Vec> = vec![ Arc::clone(&self.event_manager) as Arc, Arc::clone(&self.peer_tracker) as Arc, + Arc::clone(&self.readiness) as Arc, ]; let retained_config = config.clone(); @@ -144,6 +177,11 @@ impl SpvRuntime { self.client.try_read().map(|c| c.is_some()).unwrap_or(false) } + /// Whether header and masternode-list synchronization are both complete. + pub fn is_ready(&self) -> bool { + self.readiness.is_ready() + } + /// Broadcast a transaction to all connected SPV peers. /// /// Failures are classified per the [`TransactionBroadcaster::broadcast`] @@ -210,6 +248,7 @@ impl SpvRuntime { .await .map_err(|e| PlatformWalletError::SpvError(e.to_string())); + self.readiness.clear(); let mut client = self.client.write().await; let _ = client.take(); self.peer_tracker.clear(); @@ -219,6 +258,7 @@ impl SpvRuntime { /// Stop SPV sync gracefully. Unlocks the data dir safely pub async fn stop(&self) -> Result<(), PlatformWalletError> { + self.readiness.clear(); let taken = { let mut client = self.client.write().await; client.take() diff --git a/packages/rs-sdk-ffi/Cargo.toml b/packages/rs-sdk-ffi/Cargo.toml index 8030a2e53f9..2a7aaa9b53b 100644 --- a/packages/rs-sdk-ffi/Cargo.toml +++ b/packages/rs-sdk-ffi/Cargo.toml @@ -72,6 +72,7 @@ zeroize = "1.8" # Concurrency once_cell = "1.20" +arc-swap = "1.7.1" # HTTP client for diagnostics reqwest = { version = "0.12", features = ["json", "rustls-tls-native-roots"] } diff --git a/packages/rs-sdk-ffi/src/context_provider.rs b/packages/rs-sdk-ffi/src/context_provider.rs index d5cb66eb38a..0b176f59d85 100644 --- a/packages/rs-sdk-ffi/src/context_provider.rs +++ b/packages/rs-sdk-ffi/src/context_provider.rs @@ -3,8 +3,10 @@ //! This module provides FFI bindings for configuring context providers, //! allowing the Platform SDK to connect to Core SDK for proof verification. +use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::Arc; +use arc_swap::ArcSwapOption; use dash_sdk::dpp::data_contract::TokenConfiguration; use dash_sdk::dpp::prelude::{CoreBlockHeight, DataContract, Identifier}; use dash_sdk::dpp::version::PlatformVersion; @@ -12,6 +14,7 @@ use dash_sdk::error::ContextProviderError; use drive_proof_verifier::ContextProvider; use crate::context_callbacks::{CallbackContextProvider, ContextProviderCallbacks}; +use crate::types::Network; /// Handle for Core SDK that can be passed to Platform SDK /// This matches the definition from dash_spv_ffi.h @@ -28,8 +31,7 @@ pub struct ContextProviderHandle { /// Internal wrapper for context provider /// Adapter wrapping any [`ContextProvider`] as an opaque -/// [`ContextProviderHandle`] for the SDK. Public so sibling FFI crates -/// (e.g. `platform-wallet-ffi`) can install a native Rust provider. +/// [`ContextProviderHandle`] for callback-based SDK configuration. pub struct ContextProviderWrapper { provider: Arc, } @@ -46,40 +48,149 @@ impl ContextProviderWrapper { } } -/// Context provider that serves the security-critical quorum public key (and -/// platform activation height) from `quorum`, while delegating data-contract -/// and token-configuration lookups to `aux`. -/// -/// Installing an SPV quorum provider must not lose the SDK's ability to resolve -/// contracts and token configurations: those feed proof verification (e.g. -/// token perpetual-distribution verification calls `get_token_configuration` -/// before checking the Tenderdash signature) and the SPV provider cannot supply -/// them. This composite keeps quorum verification on SPV-synced state while -/// contract/token resolution stays on the retained trusted provider. -pub struct CompositeContextProvider { - quorum: Arc, - aux: Arc, +/// Runtime policy for quorum public-key resolution. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u8)] +pub enum ContextProviderMode { + Auto = 0, + Spv = 1, + Trusted = 2, } -impl CompositeContextProvider { - pub fn new(quorum: Arc, aux: Arc) -> Self { - Self { quorum, aux } +impl TryFrom for ContextProviderMode { + type Error = (); + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(Self::Auto), + 1 => Ok(Self::Spv), + 2 => Ok(Self::Trusted), + _ => Err(()), + } } } -impl ContextProvider for CompositeContextProvider { +/// Quorum source selected for a lookup at the current instant. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u8)] +pub enum ContextProviderSource { + Trusted = 0, + Spv = 1, +} + +struct SpvContextSource { + provider: Arc, + is_ready: Arc bool + Send + Sync>, +} + +/// Context provider whose identity remains fixed while quorum routing adapts. +pub struct AdaptiveContextProvider { + trusted: Arc, + spv: ArcSwapOption, + mode: AtomicU8, + network: Network, +} + +impl AdaptiveContextProvider { + pub fn new(trusted: Arc, network: Network) -> Self { + Self { + trusted, + spv: ArcSwapOption::empty(), + mode: AtomicU8::new(ContextProviderMode::Auto as u8), + network, + } + } + + /// Populate the SPV source exactly once. + pub fn set_spv_source( + &self, + provider: Arc, + is_ready: Arc bool + Send + Sync>, + ) -> Result<(), &'static str> { + let source = Arc::new(SpvContextSource { provider, is_ready }); + let previous = self + .spv + .compare_and_swap(std::ptr::null::(), Some(source)); + if previous.is_some() { + Err("SPV context source is already populated") + } else { + Ok(()) + } + } + + pub fn set_mode(&self, mode: ContextProviderMode) { + self.mode.store(mode as u8, Ordering::Release); + } + + pub fn mode(&self) -> ContextProviderMode { + ContextProviderMode::try_from(self.mode.load(Ordering::Acquire)) + .expect("context-provider mode is only written from validated values") + } + + pub fn active_source(&self) -> ContextProviderSource { + let spv = self.spv.load(); + match self.mode() { + ContextProviderMode::Trusted => ContextProviderSource::Trusted, + ContextProviderMode::Spv if spv.is_some() => ContextProviderSource::Spv, + ContextProviderMode::Auto if spv.as_ref().is_some_and(|source| (source.is_ready)()) => { + ContextProviderSource::Spv + } + ContextProviderMode::Auto | ContextProviderMode::Spv => ContextProviderSource::Trusted, + } + } + + fn ready_spv_source(&self) -> Result, ContextProviderError> { + let source = self.spv.load_full().ok_or_else(|| { + ContextProviderError::Generic("SPV context source is not populated".to_string()) + })?; + if !(source.is_ready)() { + return Err(ContextProviderError::Generic( + "SPV context source is not ready".to_string(), + )); + } + Ok(source) + } +} + +impl ContextProvider for AdaptiveContextProvider { fn get_quorum_public_key( &self, quorum_type: u32, quorum_hash: [u8; 32], core_chain_locked_height: u32, ) -> Result<[u8; 48], ContextProviderError> { - self.quorum - .get_quorum_public_key(quorum_type, quorum_hash, core_chain_locked_height) + match self.mode() { + ContextProviderMode::Trusted => self.trusted.get_quorum_public_key( + quorum_type, + quorum_hash, + core_chain_locked_height, + ), + ContextProviderMode::Spv => self.ready_spv_source()?.provider.get_quorum_public_key( + quorum_type, + quorum_hash, + core_chain_locked_height, + ), + ContextProviderMode::Auto => match self.spv.load_full() { + Some(source) if (source.is_ready)() => source.provider.get_quorum_public_key( + quorum_type, + quorum_hash, + core_chain_locked_height, + ), + _ => self.trusted.get_quorum_public_key( + quorum_type, + quorum_hash, + core_chain_locked_height, + ), + }, + } } fn get_platform_activation_height(&self) -> Result { - self.quorum.get_platform_activation_height() + match self.network { + Network::Mainnet => Ok(2_132_092), + Network::Testnet => Ok(1_090_319), + Network::Devnet | Network::Regtest => Ok(1), + } } fn get_data_contract( @@ -87,14 +198,14 @@ impl ContextProvider for CompositeContextProvider { id: &Identifier, platform_version: &PlatformVersion, ) -> Result>, ContextProviderError> { - self.aux.get_data_contract(id, platform_version) + self.trusted.get_data_contract(id, platform_version) } fn get_token_configuration( &self, token_id: &Identifier, ) -> Result, ContextProviderError> { - self.aux.get_token_configuration(token_id) + self.trusted.get_token_configuration(token_id) } } @@ -138,3 +249,178 @@ pub unsafe extern "C" fn dash_sdk_context_provider_destroy(handle: *mut ContextP let _ = Box::from_raw(handle as *mut ContextProviderWrapper); } } + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicBool, Ordering}; + 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 drive_proof_verifier::{ContextProvider, ContextProviderError}; + + use super::{AdaptiveContextProvider, ContextProviderMode, ContextProviderSource}; + use crate::types::Network; + + struct TestProvider { + name: &'static str, + key_byte: u8, + fail_quorum: bool, + } + + impl ContextProvider for TestProvider { + fn get_quorum_public_key( + &self, + _quorum_type: u32, + _quorum_hash: [u8; 32], + _height: u32, + ) -> Result<[u8; 48], ContextProviderError> { + if self.fail_quorum { + return Err(ContextProviderError::InvalidQuorum(format!( + "{} quorum miss", + self.name + ))); + } + Ok([self.key_byte; 48]) + } + + fn get_platform_activation_height(&self) -> Result { + Ok(999) + } + + fn get_data_contract( + &self, + _id: &Identifier, + _platform_version: &PlatformVersion, + ) -> Result>, ContextProviderError> { + Err(ContextProviderError::Generic(format!( + "{} contract route", + self.name + ))) + } + + fn get_token_configuration( + &self, + _id: &Identifier, + ) -> Result, ContextProviderError> { + Err(ContextProviderError::Generic(format!( + "{} token route", + self.name + ))) + } + } + + fn provider(name: &'static str, key_byte: u8) -> Arc { + Arc::new(TestProvider { + name, + key_byte, + fail_quorum: false, + }) + } + + fn failing_provider(name: &'static str) -> Arc { + Arc::new(TestProvider { + name, + key_byte: 0, + fail_quorum: true, + }) + } + + fn quorum_key(provider: &AdaptiveContextProvider) -> Result<[u8; 48], ContextProviderError> { + provider.get_quorum_public_key(1, [2; 32], 3) + } + + #[test] + fn routes_quorum_lookups_by_mode_and_readiness() { + let adaptive = AdaptiveContextProvider::new(provider("trusted", 0x11), Network::Testnet); + + assert_eq!(quorum_key(&adaptive).unwrap(), [0x11; 48]); + assert_eq!(adaptive.active_source(), ContextProviderSource::Trusted); + + adaptive.set_mode(ContextProviderMode::Spv); + assert!( + quorum_key(&adaptive).is_err(), + "SPV without a source must fail" + ); + + let ready = Arc::new(AtomicBool::new(false)); + let ready_for_callback = Arc::clone(&ready); + adaptive + .set_spv_source( + provider("spv", 0x22), + Arc::new(move || ready_for_callback.load(Ordering::Acquire)), + ) + .unwrap(); + assert!( + quorum_key(&adaptive).is_err(), + "unready SPV must fail closed" + ); + assert_eq!(adaptive.active_source(), ContextProviderSource::Spv); + + ready.store(true, Ordering::Release); + assert_eq!(quorum_key(&adaptive).unwrap(), [0x22; 48]); + + adaptive.set_mode(ContextProviderMode::Trusted); + assert_eq!(quorum_key(&adaptive).unwrap(), [0x11; 48]); + + adaptive.set_mode(ContextProviderMode::Auto); + ready.store(false, Ordering::Release); + assert_eq!(quorum_key(&adaptive).unwrap(), [0x11; 48]); + assert_eq!(adaptive.active_source(), ContextProviderSource::Trusted); + ready.store(true, Ordering::Release); + assert_eq!(quorum_key(&adaptive).unwrap(), [0x22; 48]); + assert_eq!(adaptive.active_source(), ContextProviderSource::Spv); + } + + #[test] + fn routes_contracts_and_tokens_to_trusted_for_every_mode() { + let adaptive = AdaptiveContextProvider::new(provider("trusted", 0x11), Network::Mainnet); + let ready = Arc::new(|| true); + adaptive + .set_spv_source(provider("spv", 0x22), ready) + .unwrap(); + let id = Identifier::new([7; 32]); + + for mode in [ + ContextProviderMode::Auto, + ContextProviderMode::Spv, + ContextProviderMode::Trusted, + ] { + adaptive.set_mode(mode); + let contract_error = adaptive + .get_data_contract(&id, PlatformVersion::latest()) + .unwrap_err() + .to_string(); + let token_error = adaptive + .get_token_configuration(&id) + .unwrap_err() + .to_string(); + assert!(contract_error.contains("trusted contract route")); + assert!(token_error.contains("trusted token route")); + } + } + + #[test] + fn rejects_replacing_the_spv_source() { + let adaptive = AdaptiveContextProvider::new(provider("trusted", 0x11), Network::Regtest); + adaptive + .set_spv_source(provider("spv", 0x22), Arc::new(|| true)) + .unwrap(); + assert!(adaptive + .set_spv_source(provider("other", 0x33), Arc::new(|| true)) + .is_err()); + } + + #[test] + fn does_not_retry_a_ready_spv_miss_against_trusted() { + let adaptive = AdaptiveContextProvider::new(provider("trusted", 0x11), Network::Testnet); + adaptive + .set_spv_source(failing_provider("spv"), Arc::new(|| true)) + .unwrap(); + adaptive.set_mode(ContextProviderMode::Auto); + + let error = quorum_key(&adaptive).unwrap_err().to_string(); + assert!(error.contains("spv quorum miss")); + } +} diff --git a/packages/rs-sdk-ffi/src/sdk.rs b/packages/rs-sdk-ffi/src/sdk.rs index 254ecb196f2..e023832479b 100644 --- a/packages/rs-sdk-ffi/src/sdk.rs +++ b/packages/rs-sdk-ffi/src/sdk.rs @@ -11,7 +11,8 @@ use std::ffi::CStr; use std::str::FromStr; use crate::context_provider::{ - CompositeContextProvider, ContextProviderHandle, ContextProviderWrapper, CoreSDKHandle, + AdaptiveContextProvider, ContextProviderHandle, ContextProviderMode, ContextProviderSource, + ContextProviderWrapper, CoreSDKHandle, }; use crate::runtime::BigStackRuntime; use crate::types::{DashSDKConfig, FFINetwork, Network, SDKHandle}; @@ -46,6 +47,7 @@ pub(crate) struct SDKWrapper { pub sdk: Sdk, pub runtime: Arc, pub trusted_provider: Option>, + pub adaptive_provider: Option>, } impl SDKWrapper { @@ -54,6 +56,7 @@ impl SDKWrapper { sdk, runtime: Arc::new(BigStackRuntime::new(runtime)), trusted_provider: None, + adaptive_provider: None, } } @@ -67,6 +70,7 @@ impl SDKWrapper { sdk, runtime: Arc::new(BigStackRuntime::new(runtime)), trusted_provider: Some(provider), + adaptive_provider: None, } } @@ -81,6 +85,7 @@ impl SDKWrapper { sdk, runtime, trusted_provider: None, + adaptive_provider: None, } } } @@ -175,6 +180,7 @@ pub unsafe extern "C" fn dash_sdk_create(config: *const DashSDKConfig) -> DashSD sdk, runtime, trusted_provider: None, + adaptive_provider: None, }); let handle = Box::into_raw(wrapper) as *mut SDKHandle; DashSDKResult::success(handle as *mut std::os::raw::c_void) @@ -287,6 +293,7 @@ pub unsafe extern "C" fn dash_sdk_create_extended( sdk, runtime, trusted_provider: None, + adaptive_provider: None, }); let handle = Box::into_raw(wrapper) as *mut SDKHandle; DashSDKResult::success(handle as *mut std::os::raw::c_void) @@ -474,10 +481,16 @@ pub unsafe extern "C" fn dash_sdk_create_trusted(config: *const DashSDKConfig) - // Clone trusted provider for prefetching quorums let provider_for_prefetch = Arc::clone(&trusted_provider); let provider_for_wrapper = Arc::clone(&trusted_provider); - - // Add trusted context provider - info!("dash_sdk_create_trusted: adding trusted context provider to builder"); - let builder = builder.with_context_provider(Arc::clone(&trusted_provider)); + let adaptive_provider = Arc::new(AdaptiveContextProvider::new( + Arc::clone(&trusted_provider) as Arc, + network, + )); + + // Install one adaptive provider for the SDK's entire lifetime. + info!("dash_sdk_create_trusted: adding adaptive context provider to builder"); + let builder = builder.with_context_provider_arc( + Arc::clone(&adaptive_provider) as Arc + ); let builder = match apply_version(builder, config.platform_version) { Ok(b) => b, @@ -519,6 +532,7 @@ pub unsafe extern "C" fn dash_sdk_create_trusted(config: *const DashSDKConfig) - sdk, runtime, trusted_provider: Some(provider_for_wrapper), + adaptive_provider: Some(adaptive_provider), }); let handle = Box::into_raw(wrapper) as *mut SDKHandle; DashSDKResult::success(handle as *mut std::os::raw::c_void) @@ -556,82 +570,39 @@ pub unsafe extern "C" fn dash_sdk_get_inner_sdk_ptr( &wrapper.sdk as *const dash_sdk::Sdk as *const std::os::raw::c_void } -/// Install a native context provider onto an existing SDK, replacing whatever -/// provider it currently uses. Subsequent proof-verified queries on this SDK -/// handle resolve quorum keys through the new provider (the SDK loads the -/// provider fresh per verification). +/// Populate the fixed adaptive provider's SPV source. /// -/// Ownership of `provider` is TAKEN: this function reclaims the -/// `ContextProviderWrapper` box exactly once on every return path. Callers -/// (e.g. `platform-wallet-ffi`) must build it via -/// `Box::into_raw(Box::new(ContextProviderWrapper::new(..)))` and must NOT -/// reclaim it themselves. +/// This changes internal adaptive state only; the SDK context provider itself +/// is never replaced. /// /// # Safety -/// - `sdk_handle` must be a valid `SDKHandle` for the duration of the call (or -/// null, which returns an error). -/// - `provider` must be a `*mut ContextProviderHandle` produced as described -/// above (or null, which returns an error). -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_install_context_provider( +/// `sdk_handle` must be a valid SDK handle for the duration of this call. +pub unsafe fn dash_sdk_set_spv_source( sdk_handle: *mut SDKHandle, - provider: *mut ContextProviderHandle, -) -> DashSDKResult { - if provider.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "Context provider handle is null".to_string(), - )); - } - // Take ownership immediately so the box is reclaimed exactly once on every - // path below (including the null-SDK error return). - let wrapper = Box::from_raw(provider as *mut ContextProviderWrapper); - + provider: Arc, + is_ready: Arc bool + Send + Sync>, +) -> Result<(), String> { if sdk_handle.is_null() { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "SDK handle is null".to_string(), - )); + return Err("SDK handle is null".to_string()); } - - let sdk_wrapper = &*(sdk_handle as *const SDKWrapper); - let spv_provider = wrapper.provider(); - // Install the SPV provider for quorum-key resolution, but keep contract and - // token lookups on the SDK's retained trusted provider. Replacing the whole - // provider would make `get_data_contract`/`get_token_configuration` return - // `None` and break every proof that references a contract or token config - // (e.g. token perpetual-distribution verification). If no trusted provider - // was retained (the SDK was not created trusted), install SPV alone β€” such - // an SDK can only verify proofs that need neither. `wrapper` drops at end of - // scope, releasing this function's refcount on the provider. - match sdk_wrapper.trusted_provider.as_ref() { - Some(trusted) => { - let composite = CompositeContextProvider::new( - spv_provider, - Arc::clone(trusted) as Arc, - ); - sdk_wrapper.sdk.set_context_provider(composite); - } - None => sdk_wrapper.sdk.set_context_provider(spv_provider), - } - - DashSDKResult::success(std::ptr::null_mut()) + let wrapper = &*(sdk_handle as *const SDKWrapper); + let adaptive = wrapper + .adaptive_provider + .as_ref() + .ok_or_else(|| "SDK does not use an adaptive context provider".to_string())?; + adaptive + .set_spv_source(provider, is_ready) + .map_err(str::to_string) } -/// Restore the trusted HTTP quorum provider on an SDK that previously had an -/// SPV provider installed via [`dash_sdk_install_context_provider`]. The -/// trusted provider is the one captured at `dash_sdk_create_trusted` time, so -/// this only works for SDKs created that way (returns an error otherwise). -/// -/// Because the provider slot is shared across `Sdk` clones, this reverts both -/// the app's SDK and any manager clone back to trusted proof verification. +/// Set the adaptive quorum routing mode without replacing the SDK provider. /// /// # Safety -/// - `sdk_handle` must be a valid `SDKHandle` for the duration of the call (or -/// null, which returns an error). +/// `sdk_handle` must be a valid SDK handle for the duration of this call. #[no_mangle] -pub unsafe extern "C" fn dash_sdk_restore_trusted_context_provider( +pub unsafe extern "C" fn dash_sdk_set_context_provider_mode( sdk_handle: *mut SDKHandle, + mode: u8, ) -> DashSDKResult { if sdk_handle.is_null() { return DashSDKResult::error(DashSDKError::new( @@ -639,19 +610,46 @@ pub unsafe extern "C" fn dash_sdk_restore_trusted_context_provider( "SDK handle is null".to_string(), )); } + let mode = match ContextProviderMode::try_from(mode) { + Ok(mode) => mode, + Err(()) => { + return DashSDKResult::error(DashSDKError::new( + DashSDKErrorCode::InvalidParameter, + format!("Invalid context provider mode: {mode}"), + )); + } + }; let wrapper = &*(sdk_handle as *const SDKWrapper); - match &wrapper.trusted_provider { - Some(tp) => { - wrapper.sdk.set_context_provider(Arc::clone(tp)); + match &wrapper.adaptive_provider { + Some(provider) => { + provider.set_mode(mode); DashSDKResult::success(std::ptr::null_mut()) } None => DashSDKResult::error(DashSDKError::new( DashSDKErrorCode::InvalidParameter, - "SDK has no stored trusted quorum provider to restore".to_string(), + "SDK does not use an adaptive context provider".to_string(), )), } } +/// Return the currently selected quorum source for UI diagnostics. +/// +/// # Safety +/// `sdk_handle` must be a valid SDK handle for the duration of this call. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_get_context_provider_source(sdk_handle: *const SDKHandle) -> u8 { + if sdk_handle.is_null() { + return ContextProviderSource::Trusted as u8; + } + let wrapper = &*(sdk_handle as *const SDKWrapper); + wrapper + .adaptive_provider + .as_ref() + .map_or(ContextProviderSource::Trusted as u8, |provider| { + provider.active_source() as u8 + }) +} + /// Register global context provider callbacks /// /// This must be called before creating an SDK instance that needs Core SDK functionality. @@ -926,102 +924,3 @@ pub unsafe extern "C" fn dash_sdk_create_handle_with_mock( } } } - -#[cfg(test)] -mod context_provider_install_tests { - use super::*; - use crate::test_utils::test_utils::{create_mock_sdk_handle, destroy_mock_sdk_handle}; - use dash_sdk::dpp::data_contract::TokenConfiguration; - use dash_sdk::dpp::prelude::{CoreBlockHeight, DataContract, Identifier}; - use dash_sdk::dpp::version::PlatformVersion; - use drive_proof_verifier::{ContextProvider, ContextProviderError}; - - /// Provider whose activation height is a distinctive sentinel, so a swap can - /// be observed by reading it back off the SDK. - struct SentinelProvider(CoreBlockHeight); - impl ContextProvider for SentinelProvider { - fn get_quorum_public_key( - &self, - _quorum_type: u32, - _quorum_hash: [u8; 32], - _height: u32, - ) -> Result<[u8; 48], ContextProviderError> { - Ok([0u8; 48]) - } - fn get_data_contract( - &self, - _id: &Identifier, - _pv: &PlatformVersion, - ) -> Result>, ContextProviderError> { - Ok(None) - } - fn get_token_configuration( - &self, - _id: &Identifier, - ) -> Result, ContextProviderError> { - Ok(None) - } - fn get_platform_activation_height(&self) -> Result { - Ok(self.0) - } - } - - fn provider_handle(p: impl ContextProvider + 'static) -> *mut ContextProviderHandle { - Box::into_raw(Box::new(ContextProviderWrapper::new(p))) as *mut ContextProviderHandle - } - - #[test] - fn install_swaps_provider_and_guards_nulls() { - unsafe { - let sdk = create_mock_sdk_handle(); - - // Null provider handle -> error, nothing installed. - let r = dash_sdk_install_context_provider(sdk, std::ptr::null_mut()); - assert!(!r.error.is_null(), "null provider must error"); - crate::error::dash_sdk_error_free(r.error); - - // Null SDK handle -> error, but the provider box is still reclaimed - // exactly once (no leak / no UB). - let ph = provider_handle(SentinelProvider(111)); - let r = dash_sdk_install_context_provider(std::ptr::null_mut(), ph); - assert!(!r.error.is_null(), "null sdk handle must error"); - crate::error::dash_sdk_error_free(r.error); - - // Success: the swap is observable on the SDK. - const SENTINEL: CoreBlockHeight = 7_777_777; - let ph = provider_handle(SentinelProvider(SENTINEL)); - let r = dash_sdk_install_context_provider(sdk, ph); - assert!(r.error.is_null(), "install should succeed"); - let wrapper = &*(sdk as *const SDKWrapper); - let height = wrapper - .sdk - .context_provider() - .expect("provider present") - .get_platform_activation_height() - .expect("activation height"); - assert_eq!(height, SENTINEL); - - destroy_mock_sdk_handle(sdk); - } - } - - #[test] - fn restore_errors_without_stored_trusted_provider() { - unsafe { - // Null handle -> error. - let r = dash_sdk_restore_trusted_context_provider(std::ptr::null_mut()); - assert!(!r.error.is_null(), "null handle must error"); - crate::error::dash_sdk_error_free(r.error); - - // A mock SDK wrapper stores no trusted provider, so restore errors. - let sdk = create_mock_sdk_handle(); - let r = dash_sdk_restore_trusted_context_provider(sdk); - assert!( - !r.error.is_null(), - "restore without a stored trusted provider must error" - ); - crate::error::dash_sdk_error_free(r.error); - destroy_mock_sdk_handle(sdk); - } - } -} diff --git a/packages/rs-sdk/examples/read_contract.rs b/packages/rs-sdk/examples/read_contract.rs index d2cbae8dcd9..6e3206d96e5 100644 --- a/packages/rs-sdk/examples/read_contract.rs +++ b/packages/rs-sdk/examples/read_contract.rs @@ -88,13 +88,13 @@ fn setup_sdk(config: &Config) -> Sdk { .expect("parse uri"); // Now, we create the Sdk with the wallet and context provider. + let context_provider = Arc::new(context_provider); let sdk = SdkBuilder::new(AddressList::from_iter([address])) + .with_context_provider(Arc::clone(&context_provider)) .build() .expect("cannot build sdk"); - // Reconfigure context provider with Sdk context_provider.set_sdk(Some(sdk.clone())); - sdk.set_context_provider(context_provider); // Return the SDK we created sdk } diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index 6bb839e8b84..8471c354628 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -8,7 +8,6 @@ use crate::mock::{provider::GrpcContextProvider, MockDashPlatformSdk}; use crate::platform::fetch_current_no_parameters::FetchCurrent; use crate::platform::transition::put_settings::PutSettings; use crate::platform::Identifier; -use arc_swap::ArcSwapOption; use dapi_grpc::mock::Mockable; use dapi_grpc::platform::v0::{Proof, ResponseMetadata}; #[cfg(not(target_arch = "wasm32"))] @@ -155,19 +154,8 @@ pub struct Sdk { /// Nonce cache managed exclusively by the SDK. nonce_cache: Arc, - /// Context provider used by the SDK. - /// - /// Wrapped in `Arc` so the slot is **shared across clones**: a - /// [`set_context_provider`](Sdk::set_context_provider) on any clone is - /// observed by all of them. This lets a consumer that hands out SDK clones - /// (e.g. a wallet manager) swap the provider once and have every clone pick - /// it up β€” used to switch proof verification from a trusted quorum service - /// to SPV-synced quorums at runtime. - /// - /// ## Panics - /// - /// Note that setting this to None can panic. - context_provider: Arc>>, + /// Context provider fixed at SDK construction and shared across clones. + context_provider: Arc, /// Protocol version number detected from the network. Shared between clones. protocol_version: Arc, @@ -208,9 +196,6 @@ impl Clone for Sdk { inner: self.inner.clone(), proofs: self.proofs, nonce_cache: Arc::clone(&self.nonce_cache), - // Share the provider slot (not a snapshot): a later - // `set_context_provider` on either the original or this clone is - // seen by both. context_provider: Arc::clone(&self.context_provider), cancel_token: self.cancel_token.clone(), protocol_version: Arc::clone(&self.protocol_version), @@ -469,11 +454,8 @@ impl Sdk { } /// Return [ContextProvider] used by the SDK. - pub fn context_provider(&self) -> Option { - let provider_guard = self.context_provider.load(); - let provider = provider_guard.as_ref().map(Arc::clone); - - provider + pub fn context_provider(&self) -> Option> { + Some(Arc::clone(&self.context_provider)) } /// Returns a mutable reference to the `MockDashPlatformSdk` instance. @@ -587,18 +569,6 @@ impl Sdk { } } - // TODO: If we remove this setter we don't need to use ArcSwap. - // It's good enough to set Context once when you initialize the SDK. - /// Set the [ContextProvider] to use. - /// - /// [ContextProvider] is used to access state information, like data contracts and quorum public keys. - /// - /// Note that this will overwrite any previous context provider. - pub fn set_context_provider(&self, context_provider: C) { - self.context_provider - .swap(Some(Arc::new(Box::new(context_provider)))); - } - /// Returns a future that resolves when the Sdk is cancelled (e.g. shutdown was requested). pub fn cancelled(&self) -> WaitForCancellationFuture<'_> { self.cancel_token.cancelled() @@ -785,7 +755,7 @@ pub struct SdkBuilder { quorum_public_keys_cache_size: NonZeroUsize, /// Context provider used by the SDK. - context_provider: Option>, + context_provider: Option>, /// How many blocks difference is allowed between the last seen metadata height and the height received in response /// metadata. @@ -1010,11 +980,18 @@ impl SdkBuilder { mut self, context_provider: C, ) -> Self { - self.context_provider = Some(Box::new(context_provider)); + self.context_provider = Some(Arc::new(context_provider)); self } + /// Configure an already-shared context provider without adding another + /// allocation around it. + pub fn with_context_provider_arc(mut self, context_provider: Arc) -> Self { + self.context_provider = Some(context_provider); + self + } + /// Set cancellation token that will be used by the Sdk. /// /// Once that cancellation token is cancelled, all pending requests shall terminate. @@ -1123,15 +1100,44 @@ impl SdkBuilder { #[cfg(feature = "mocks")] let dapi = dapi.dump_dir(self.dump_dir.clone()); - #[allow(unused_mut)] // needs to be mutable for #[cfg(feature = "mocks")] - let mut sdk= Sdk{ + #[cfg(feature = "mocks")] + let mut generated_grpc_provider = None; + let context_provider = match self.context_provider { + Some(provider) => provider, + None => { + #[cfg(feature = "mocks")] + if !self.core_ip.is_empty() { + tracing::warn!( + "ContextProvider not set, falling back to a mock one; use SdkBuilder::with_context_provider() to set it up"); + let mut provider = GrpcContextProvider::new(None, + &self.core_ip, self.core_port, &self.core_user, &self.core_password, + self.data_contract_cache_size, self.token_config_cache_size, self.quorum_public_keys_cache_size)?; + if self.dump_dir.is_some() { + provider.set_dump_dir(self.dump_dir.clone()); + } + let provider = Arc::new(provider); + generated_grpc_provider = Some(Arc::clone(&provider)); + provider as Arc + } else { + return Err(Error::Config(concat!( + "context provider is not set, configure it with SdkBuilder::with_context_provider() ", + "or configure Core access with SdkBuilder::with_core() to use mock context provider") + .to_string())); + } + #[cfg(not(feature = "mocks"))] + return Err(Error::Config(concat!( + "context provider is not set, configure it with SdkBuilder::with_context_provider() ", + "or enable `mocks` feature to use mock context provider") + .to_string())); + } + }; + + let sdk= Sdk{ network: self.network, dapi_client_settings, inner:SdkInstance::Dapi { dapi }, proofs:self.proofs, - context_provider: Arc::new(ArcSwapOption::new( - self.context_provider.map(Arc::new), - )), + context_provider, cancel_token: self.cancel_token, nonce_cache: Default::default(), // Seed atomic with the initial version; whether the version is @@ -1145,36 +1151,10 @@ impl SdkBuilder { #[cfg(feature = "mocks")] dump_dir: self.dump_dir, }; - // if context provider is not set correctly (is None), it means we need to fall back to core wallet - if sdk.context_provider.load().is_none() { - #[cfg(feature = "mocks")] - if !self.core_ip.is_empty() { - tracing::warn!( - "ContextProvider not set, falling back to a mock one; use SdkBuilder::with_context_provider() to set it up"); - let mut context_provider = GrpcContextProvider::new(None, - &self.core_ip, self.core_port, &self.core_user, &self.core_password, - self.data_contract_cache_size, self.token_config_cache_size, self.quorum_public_keys_cache_size)?; - #[cfg(feature = "mocks")] - if sdk.dump_dir.is_some() { - context_provider.set_dump_dir(sdk.dump_dir.clone()); - } - // We have cyclical dependency Sdk <-> GrpcContextProvider, so we just do some - // workaround using additional Arc. - let context_provider= Arc::new(context_provider); - sdk.context_provider.swap(Some(Arc::new(Box::new(context_provider.clone())))); - context_provider.set_sdk(Some(sdk.clone())); - } else{ - return Err(Error::Config(concat!( - "context provider is not set, configure it with SdkBuilder::with_context_provider() ", - "or configure Core access with SdkBuilder::with_core() to use mock context provider") - .to_string())); - } - #[cfg(not(feature = "mocks"))] - return Err(Error::Config(concat!( - "context provider is not set, configure it with SdkBuilder::with_context_provider() ", - "or enable `mocks` feature to use mock context provider") - .to_string())); - }; + #[cfg(feature = "mocks")] + if let Some(provider) = generated_grpc_provider { + provider.set_sdk(Some(sdk.clone())); + } sdk }, @@ -1188,7 +1168,7 @@ impl SdkBuilder { if let Some(ref dump_dir) = self.dump_dir { cp.quorum_keys_dir(Some(dump_dir.clone())); } - Box::new(cp) + Arc::new(cp) } ); let mock_sdk = MockDashPlatformSdk::new(Arc::clone(&dapi)); @@ -1206,7 +1186,7 @@ impl SdkBuilder { nonce_cache: Default::default(), protocol_version: Arc::new(atomic::AtomicU32::new(initial_version.protocol_version)), version_pinned: self.version_pinned, - context_provider: Arc::new(ArcSwapOption::new(Some(Arc::new(context_provider)))), + context_provider, cancel_token: self.cancel_token, metadata_last_seen_height: Arc::new(atomic::AtomicU64::new(0)), metadata_height_tolerance: self.metadata_height_tolerance, @@ -1274,15 +1254,9 @@ mod test { /// Testnet Evo masternodes expose the Platform HTTP endpoint on 1443. const TESTNET_PLATFORM_HTTP_PORT: u16 = 1443; - /// Regression guard for the shared context-provider slot. `Sdk::clone` - /// shares the provider slot (via `Arc`) rather than - /// snapshotting it, so a `set_context_provider` on any clone is observed by - /// all of them. This is what lets a wallet manager (which holds an SDK - /// clone) pick up a runtime switch to SPV-synced quorums installed on the - /// app's SDK. A snapshot-per-clone would leave the original on its old - /// provider and fail this test. + /// SDK clones retain the exact context-provider identity selected at build. #[test] - fn context_provider_is_shared_across_clones() { + fn context_provider_identity_is_shared_across_clones() { use dash_context_provider::{ContextProvider, ContextProviderError}; use dpp::data_contract::TokenConfiguration; use dpp::prelude::{CoreBlockHeight, DataContract, Identifier}; @@ -1323,24 +1297,18 @@ mod test { const SENTINEL: CoreBlockHeight = 4_242_424; let sdk = SdkBuilder::new_mock() + .with_context_provider(SentinelProvider(SENTINEL)) .build() .expect("mock sdk should build"); let clone = sdk.clone(); - // Install the sentinel provider on the CLONE only. - clone.set_context_provider(SentinelProvider(SENTINEL)); - - // The ORIGINAL must observe it β€” proving the slot is shared, not a - // per-clone snapshot. - let height = sdk - .context_provider() - .expect("provider present after set") + let original_provider = sdk.context_provider().expect("provider present"); + let clone_provider = clone.context_provider().expect("provider present on clone"); + assert!(Arc::ptr_eq(&original_provider, &clone_provider)); + let height = original_provider .get_platform_activation_height() .expect("activation height"); - assert_eq!( - height, SENTINEL, - "set_context_provider on a clone must be visible on the original (shared slot)" - ); + assert_eq!(height, SENTINEL); } #[test] diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index a06c6168eeb..629bc342ab7 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -254,6 +254,7 @@ public class PlatformWalletManager: ObservableObject { // network, matching the per-network manager design. try configure( sdkPointer: UnsafeRawPointer(innerSdkPtr), + sdkHandle: sdkHandle, modelContainer: modelContainer, network: sdk.network ) @@ -264,6 +265,20 @@ public class PlatformWalletManager: ObservableObject { sdkPointer: UnsafeRawPointer, modelContainer: ModelContainer? = nil, network: Network? = nil + ) throws { + try configure( + sdkPointer: sdkPointer, + sdkHandle: nil, + modelContainer: modelContainer, + network: network + ) + } + + private func configure( + sdkPointer: UnsafeRawPointer, + sdkHandle: OpaquePointer?, + modelContainer: ModelContainer?, + network: Network? ) throws { var handle: Handle = NULL_HANDLE @@ -284,12 +299,21 @@ public class PlatformWalletManager: ObservableObject { let eventHandler = PlatformWalletEventHandler(manager: self) var eventHandlerCallbacks = eventHandler.makeCallbacks() - try platform_wallet_manager_create( - sdkPointer, - &persistence, - &eventHandlerCallbacks, - &handle - ).check() + if let sdkHandle { + try platform_wallet_manager_create_with_sdk( + sdkHandle, + &persistence, + &eventHandlerCallbacks, + &handle + ).check() + } else { + try platform_wallet_manager_create( + sdkPointer, + &persistence, + &eventHandlerCallbacks, + &handle + ).check() + } self.handle = handle self.persistenceHandler = handler diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift index 4344f34aec2..bcac09b07e6 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift @@ -369,58 +369,33 @@ public final class SDK: @unchecked Sendable { /// Which quorum-key source this SDK's proof verification currently uses. public enum QuorumSource: Sendable { - /// Centralized trusted HTTP quorum service β€” how every `SDK` starts. case trusted - /// SPV-synced masternode quorum data, installed via `attachSpvQuorums`. case spv } - /// The quorum source proof verification currently uses. Starts `.trusted` - /// and flips to `.spv` after a successful `attachSpvQuorums(from:)`. - public private(set) var quorumSource: QuorumSource = .trusted - - /// Swap this SDK's proof-verification context provider to use - /// `walletManager`'s SPV-synced quorum data instead of the trusted HTTP - /// service. After this returns, proof-verified queries on this SDK verify - /// quorum signatures against the locally synced masternode list. - /// - /// Call only once the manager's SPV client is started and its masternode - /// list is synced β€” before that, lookups fail closed (there is no per-lookup - /// fallback to trusted once SPV is installed). - /// - /// `@MainActor`: reads `walletManager.handle`, which is main-actor isolated. - @MainActor - public func attachSpvQuorums(from walletManager: PlatformWalletManager) throws { - guard let handle else { - throw SDKError.internalError("Cannot attach SPV quorums: SDK handle is nil") - } - let result = platform_wallet_manager_attach_spv_context( - walletManager.handle, handle, network.ffiValue) - if result.error != nil { - let error = result.error!.pointee - let message = error.message != nil ? String(cString: error.message!) : "Unknown error" - defer { dash_sdk_error_free(result.error) } - throw SDKError.internalError("Failed to attach SPV quorum provider: \(message)") - } - quorumSource = .spv + /// Policy used by the SDK's fixed adaptive context provider. + public enum QuorumMode: UInt8, Sendable { + case auto = 0 + case spv = 1 + case trusted = 2 } - /// Revert this SDK's proof verification from SPV-synced quorums back to the - /// trusted HTTP quorum service it was created with. Because the provider slot - /// is shared across SDK clones, this also reverts a wallet manager's cloned - /// SDK. Requires the SDK to have been created via the trusted path. - public func restoreTrustedQuorums() throws { + /// The quorum source selected at the most recent mode application. + public private(set) var quorumSource: QuorumSource = .trusted + + /// Update quorum routing policy without replacing the SDK context provider. + public func setQuorumMode(_ mode: QuorumMode) throws { guard let handle else { - throw SDKError.internalError("Cannot restore trusted quorums: SDK handle is nil") + throw SDKError.internalError("Cannot set quorum mode: SDK handle is nil") } - let result = dash_sdk_restore_trusted_context_provider(handle) + let result = dash_sdk_set_context_provider_mode(handle, mode.rawValue) if result.error != nil { let error = result.error!.pointee let message = error.message != nil ? String(cString: error.message!) : "Unknown error" defer { dash_sdk_error_free(result.error) } - throw SDKError.internalError("Failed to restore trusted quorum provider: \(message)") + throw SDKError.internalError("Failed to set quorum mode: \(message)") } - quorumSource = .trusted + quorumSource = dash_sdk_get_context_provider_source(handle) == 1 ? .spv : .trusted } /// Run `body` with two optional C-string pointers. Each input string, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift index dcb0ebf5550..db0c40c0ce7 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift @@ -10,8 +10,8 @@ class AppState: ObservableObject { @Published var errorMessage = "" /// The quorum-key source the current SDK uses for proof verification. - /// Starts `.trusted` on every SDK build and flips to `.spv` once - /// `applyQuorumMode` installs the SPV provider. Drives the app's indicator; + /// Starts `.trusted` on every SDK build and tracks the adaptive provider's + /// current routing choice. Drives the app's indicator; /// intentionally mirrors `SDK.quorumSource` (this one is the observable the /// UI binds to). @Published private(set) var quorumSource: SDK.QuorumSource = .trusted @@ -70,8 +70,8 @@ class AppState: ObservableObject { } /// Which quorum source the user wants. Applied live on change (see - /// `applyQuorumMode`); `quorumSource` reflects what is *actually* installed - /// (SPV may not be ready yet). + /// `applyQuorumMode`); `quorumSource` reflects the adaptive provider's + /// current routing choice (SPV may not be ready yet). @Published var quorumMode: QuorumMode { didSet { UserDefaults.standard.set(quorumMode.rawValue, forKey: "quorumMode") @@ -130,11 +130,9 @@ class AppState: ObservableObject { SDK.enableLogging(level: .debug) NSLog("πŸ”΅ AppState: Creating SDK for network=\(currentNetwork), docker=\(useDockerSetup)") - // Build with the trusted quorum provider. Proof verification is - // switched to SPV-synced quorums later via `applyQuorumMode`, - // once the active wallet manager's SPV masternode list is synced - // (the manager can only be created *from* an SDK, so the SDK - // must exist first β€” hence attach-after rather than build-from). + // Build with one adaptive provider backed initially by trusted + // quorum data. The wallet manager populates its SPV source when + // it is configured from this SDK. let newSDK = try SDK(network: currentNetwork) sdk = newSDK quorumSource = .trusted @@ -158,9 +156,9 @@ class AppState: ObservableObject { } } - /// Install the quorum source the current `quorumMode` calls for, via a live - /// provider swap on the shared slot (no SDK rebuild). Idempotent. Call - /// whenever `quorumMode` or the manager's `spvProgress` changes. + /// Apply the current policy to the SDK's fixed adaptive provider. Call when + /// `quorumMode` changes and when SPV progress changes so the active-source + /// indicator is refreshed as Auto becomes ready. /// /// - `.auto`: SPV once the header + masternode lists are fully synced, /// trusted until then. @@ -169,33 +167,21 @@ class AppState: ObservableObject { /// fail until synced) rather than falling back to trusted. /// - `.trusted`: always the trusted HTTP quorum provider. /// - /// `quorumSource` tracks what is *actually* installed, which may lag the - /// requested mode while SPV isn't ready. + /// `quorumSource` tracks the provider's current routing choice, which may + /// lag the requested mode while SPV isn't ready. @MainActor - func applyQuorumMode(manager: PlatformWalletManager) { + func applyQuorumMode() { guard let sdk else { return } - let progress = manager.spvProgress - let fullySynced = - progress.headers?.state == .synced && progress.masternodes?.state == .synced - - let wantSpv: Bool - switch quorumMode { - case .auto: wantSpv = fullySynced - case .spv: wantSpv = true - case .trusted: wantSpv = false - } - do { - if wantSpv, quorumSource == .trusted { - try sdk.attachSpvQuorums(from: manager) - quorumSource = .spv - NSLog("βœ… AppState: proof verification now uses SPV-synced quorums") - } else if !wantSpv, quorumSource == .spv { - try sdk.restoreTrustedQuorums() - quorumSource = .trusted - NSLog("↩️ AppState: proof verification reverted to trusted quorums") + let sdkMode: SDK.QuorumMode + switch quorumMode { + case .auto: sdkMode = .auto + case .spv: sdkMode = .spv + case .trusted: sdkMode = .trusted } + try sdk.setQuorumMode(sdkMode) + quorumSource = sdk.quorumSource } catch { NSLog("⚠️ AppState: failed to apply quorum mode: \(error.localizedDescription)") } @@ -221,9 +207,8 @@ class AppState: ObservableObject { do { isLoading = true - // Create new SDK instance for the network (trusted provider; SPV is - // re-attached by `applyQuorumMode` once the new network's manager - // has synced). + // Create a new SDK instance for the network. Its manager populates + // the adaptive provider's SPV source during configuration. let newSDK = try SDK(network: network) sdk = newSDK quorumSource = .trusted diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index fddb0e867c6..4fbed9e07d9 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -168,20 +168,14 @@ struct SwiftExampleAppApp: App { .onChange(of: platformState.walletScopedServicesRebindTick) { _, _ in rebindWalletScopedServices() } - // Switch Platform proof verification from the trusted quorum - // service to the active manager's SPV-synced quorum data once - // SPV has synced. Driven off the manager's published sync - // progress (also powers the sync UI). Idempotent β€” a no-op once - // already on SPV, and re-evaluated for the new manager after a - // network switch (which resets `quorumSource` to `.trusted`). + // Refresh adaptive-provider policy and the active-source + // indicator as SPV readiness changes. .onChange(of: walletManager.spvProgress) { _, _ in - platformState.applyQuorumMode(manager: walletManager) + platformState.applyQuorumMode() } - // Apply the Quorum Source picker (Auto / SPV / Trusted) live via - // a provider swap on the shared slot β€” no SDK rebuild, so it - // stays consistent with the manager's shared context provider. + // Apply the Quorum Source picker to the fixed adaptive provider. .onChange(of: platformState.quorumMode) { _, _ in - platformState.applyQuorumMode(manager: walletManager) + platformState.applyQuorumMode() } } } @@ -201,6 +195,7 @@ struct SwiftExampleAppApp: App { } do { try walletManagerStore.activate(network: network, sdk: sdk) + platformState.applyQuorumMode() } catch { SDKLogger.error( "Failed to activate wallet manager for " @@ -347,6 +342,7 @@ struct SwiftExampleAppApp: App { network: platformState.currentNetwork, sdk: sdk ) + platformState.applyQuorumMode() let restoredCount = walletManager.wallets.count if restoredCount > 0 { SDKLogger.log( diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift index ab5a11ebb2e..58c0aa027d0 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift @@ -309,6 +309,7 @@ struct OptionsView: View { try? walletManagerStore.activate( network: .devnet, sdk: sdk ) + appState.applyQuorumMode() } // Drive `rebindWalletScopedServices` // via the App scene's observer. From ba2fbefac258bd69268ec8a722cec31ba1f1b513 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 15 Jul 2026 23:11:53 +0700 Subject: [PATCH 19/21] docs: remove adaptive SPV provider spec --- docs/spv-adaptive-provider/SPEC.md | 57 ------------------------------ 1 file changed, 57 deletions(-) delete mode 100644 docs/spv-adaptive-provider/SPEC.md diff --git a/docs/spv-adaptive-provider/SPEC.md b/docs/spv-adaptive-provider/SPEC.md deleted file mode 100644 index 955f16044de..00000000000 --- a/docs/spv-adaptive-provider/SPEC.md +++ /dev/null @@ -1,57 +0,0 @@ -# Adaptive SPV Context Provider - -## Purpose - -Platform proof verification uses one context-provider identity for the lifetime of an SDK. Runtime policy changes update atomic state inside that provider; they never replace the SDK provider. This removes clone-sensitive provider swapping while preserving trusted, SPV, and automatic quorum-source behavior. - -## Design - -`rs-sdk-ffi` owns `AdaptiveContextProvider` so neither `dash-sdk` nor `platform-wallet` depends on the other. It contains: - -- the trusted provider used at SDK construction; -- an initially empty, atomically replaceable SPV source containing an `Arc` and a synchronous readiness callback; -- an `AtomicU8` mode: Auto, SPV, or Trusted. - -The trusted SDK constructor installs `Arc` in `SdkBuilder` exactly once and retains that same `Arc` in `SDKWrapper`. Creating a `PlatformWalletManager` from an SDK automatically creates a `SpvContextProvider` for its `SpvRuntime` and populates the adaptive provider's SPV source. Source population changes only the adaptive provider's internal empty slot; it does not mutate `dash_sdk::Sdk` or replace its context provider. Re-populating the source is rejected so an SDK cannot silently change to a different manager/runtime. - -`SpvRuntime` maintains a lock-free readiness bit from dash-spv progress callbacks. It is true only when both header and masternode-list progress are present and `Synced`, exactly matching the current Swift Auto policy. Start, stop, and run-loop exit clear readiness. The adaptive provider reads this bit synchronously, so quorum lookup does not block on an async lock merely to select a source. - -## Routing - -For `get_quorum_public_key`: - -- Trusted always calls the trusted provider. -- SPV calls only the SPV provider. An absent source or an unready source returns an error; it never falls back to trusted. -- Auto calls trusted while the SPV source is absent or not ready, then calls only SPV once ready. An SPV lookup miss after readiness is an error and never falls back to trusted. - -`get_data_contract` and `get_token_configuration` always call the trusted provider. That provider resolves embedded system contracts and the SDK-populated cache and has no fallback provider, preserving the existing composite provider's trust boundary. - -`get_platform_activation_height` returns the existing per-network constant directly: mainnet 2,132,092; testnet 1,090,319; devnet/regtest 1. - -The SPV provider continues to reject any quorum type other than `network.platform_type()` before lookup, reverse the proof's display-order quorum hash through `SpvRuntime::quorum_lookup_key`, and fail closed outside a multi-thread Tokio runtime. The adaptive layer never retries a failed SPV lookup against trusted state. - -## FFI and Swift - -The SDK FFI exposes mode set/get and active-source inspection. Mode values are validated at the boundary. `PlatformWalletManager.configure(sdk:)` passes the SDK handle to manager creation; manager creation obtains the inner SDK clone and attaches its SPV source before returning. - -Active-source inspection reports Trusted in Trusted mode; SPV in forced-SPV mode once a source exists; and, in Auto, Trusted until the source is ready and SPV afterwards. Forced SPV with no populated source reports Trusted for the indicator even though quorum lookups fail closed, because no SPV source exists to identify as active. - -The install/restore FFIs and Swift `SDK.attachSpvQuorums` / `restoreTrustedQuorums` APIs are removed. Swift exposes a single mode-setting method. `AppState.applyQuorumMode` always calls it once with Auto/SPV/Trusted and then refreshes the active-source indicator; it does not decide routing or attach providers. Existing progress observation remains useful for refreshing the "Proof Quorum Source" indicator as Auto becomes ready. - -## Feasibility and Security Review - -The construction cycle is resolved at the FFI boundary: the SDK creates the fixed adaptive provider first; standard manager creation synchronously borrows the opaque SDK handle, clones its inner `Sdk`, constructs `SpvRuntime`, and then fills the adaptive provider's internal SPV slot before returning. The manager retains the cloned `Sdk`, never the raw SDK handle. `rs-sdk-ffi` only knows a trait-object provider plus readiness callback, while `rs-platform-wallet-ffi` creates both from `platform-wallet`; therefore no Rust dependency cycle is introduced. The advanced raw-inner-SDK manager constructor may remain available, but cannot attach an SPV source because it has no owning SDK handle. - -The provider and readiness callback are published together in one immutable SPV-source object, preventing a provider/readiness mismatch. The slot is write-once: a second manager cannot redirect an SDK's SPV trust root. Mode values use acquire/release atomic ordering and invalid FFI values are rejected. - -Each quorum call snapshots mode, source, and readiness. A concurrent readiness transition may affect the next call, but cannot create a trusted fallback after a call selected SPV. If SPV stops after selection, the SPV lookup fails closed. If readiness becomes true just after Auto selected trusted, that single in-flight call retains the prior trusted selection and subsequent calls use SPV; this is an expected boundary race, not a trust downgrade controlled by proof data. Readiness is explicitly cleared before every start/restart, during stop, and on every run-loop exit including errors. - -Readiness is derived from trusted local sync state, not from whether a requested quorum happens to exist. Consequently an attacker-induced SPV lookup miss after sync cannot trigger trusted retry. Contract/token routing is structurally independent of mode and SPV state, retaining only the trusted provider's cache and embedded-system-contract behavior. - -## Verification - -- Unit-test adaptive routing for all three modes, absent/unready SPV fail-closed behavior, Auto readiness transition, post-readiness SPV miss behavior, and trusted-only contract/token routing. -- Retain the quorum-hash byte-order regression test and the SPV quorum-type/current-thread fail-closed behavior. -- Run `cargo fmt --all -- --check` and `cargo clippy --workspace --all-features`. -- Build the simulator framework, then clean-build `SwiftExampleApp` with `xcodebuild`. -- On a synced testnet device, select SPV and perform proof-verified Platform queries; confirm successful results and the `SPV context provider served quorum public key` log. From d4046ca6d870be73b848ddd9202ccf16668251e2 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 16 Jul 2026 16:29:52 +0700 Subject: [PATCH 20/21] refactor(sdk): separate adaptive and SPV providers --- packages/rs-platform-wallet-ffi/src/handle.rs | 61 ++- .../rs-platform-wallet-ffi/src/manager.rs | 60 +-- packages/rs-platform-wallet-ffi/src/spv.rs | 7 + .../rs-platform-wallet/src/spv/runtime.rs | 100 +++-- .../src/spv_context_provider.rs | 78 ++-- packages/rs-sdk-ffi/src/context_provider.rs | 365 ++++++++++++++---- packages/rs-sdk-ffi/src/sdk.rs | 236 ++++++----- .../swift-sdk/Sources/SwiftDashSDK/SDK.swift | 38 +- .../SwiftExampleApp/AppState.swift | 41 +- .../SwiftExampleApp/WalletManagerStore.swift | 2 +- 10 files changed, 684 insertions(+), 304 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/handle.rs b/packages/rs-platform-wallet-ffi/src/handle.rs index 7e411903a3a..49bef164007 100644 --- a/packages/rs-platform-wallet-ffi/src/handle.rs +++ b/packages/rs-platform-wallet-ffi/src/handle.rs @@ -1,7 +1,9 @@ use once_cell::sync::Lazy; use parking_lot::RwLock; use std::collections::HashMap; +use std::ops::Deref; use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; /// Handle type for FFI objects pub type Handle = u64; @@ -120,13 +122,66 @@ pub static PLATFORM_ADDRESS_WALLET_STORAGE: Lazy< HandleStorage, > = Lazy::new(HandleStorage::new); +/// FFI-owned manager plus the optional lease that connects its running SPV +/// runtime to an SDK's fixed SPV-capable context provider. +pub struct PlatformWalletManagerHandle { + manager: platform_wallet::PlatformWalletManager, + spv_source: Option>, + spv_source_controller: Option>, + spv_source_lease: parking_lot::Mutex>, +} + +impl PlatformWalletManagerHandle { + pub(crate) fn new( + manager: platform_wallet::PlatformWalletManager, + spv_source: Option>, + spv_source_controller: Option>, + ) -> Self { + Self { + manager, + spv_source, + spv_source_controller, + spv_source_lease: parking_lot::Mutex::new(None), + } + } + + pub(crate) fn acquire_spv_source(&self) -> Result<(), String> { + let Some(controller) = &self.spv_source_controller else { + return Ok(()); + }; + let source = self + .spv_source + .as_ref() + .ok_or_else(|| "SPV source controller has no quorum source".to_string())?; + let readiness_source = Arc::clone(source); + let quorum_source: Arc = + Arc::clone(source) as Arc; + let lease = controller + .acquire(quorum_source, Arc::new(move || readiness_source.is_ready())) + .map_err(str::to_string)?; + *self.spv_source_lease.lock() = Some(lease); + Ok(()) + } + + pub(crate) fn release_spv_source(&self) { + let _ = self.spv_source_lease.lock().take(); + } +} + +impl Deref for PlatformWalletManagerHandle { + type Target = platform_wallet::PlatformWalletManager; + + fn deref(&self) -> &Self::Target { + &self.manager + } +} + /// Storage for PlatformWalletManager handles. /// /// The manager is generic over the persister type; the FFI binds it /// to the callback-based [`FFIPersister`](crate::persistence::FFIPersister). -pub static PLATFORM_WALLET_MANAGER_STORAGE: Lazy< - HandleStorage>, -> = Lazy::new(HandleStorage::new); +pub static PLATFORM_WALLET_MANAGER_STORAGE: Lazy> = + Lazy::new(HandleStorage::new); /// Storage for PlatformWallet handles pub static PLATFORM_WALLET_STORAGE: Lazy< diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index e39dec98879..d4074ee2435 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -59,14 +59,15 @@ pub unsafe extern "C" fn platform_wallet_manager_create( let _runtime_guard = runtime().enter(); let manager = PlatformWalletManager::new(sdk, persister, handler); - let handle = PLATFORM_WALLET_MANAGER_STORAGE.insert(manager); + let handle = PLATFORM_WALLET_MANAGER_STORAGE + .insert(PlatformWalletManagerHandle::new(manager, None, None)); *out_handle = handle; PlatformWalletFFIResult::ok() } -/// Create a manager from an owning SDK handle and populate that SDK's fixed -/// adaptive provider with the manager's SPV source. +/// Create a manager from an owning SDK handle and prepare an SPV source for +/// that SDK's fixed SPV-capable provider. /// /// The SDK handle is borrowed only for this synchronous call. The manager /// retains an `Sdk` clone, never the raw handle. @@ -98,23 +99,27 @@ pub unsafe extern "C" fn platform_wallet_manager_create_with_sdk( let _runtime_guard = runtime().enter(); let manager = PlatformWalletManager::new(Arc::clone(&sdk), persister, handler); let spv = manager.spv_arc(); - let provider = Arc::new( - platform_wallet::spv_context_provider::SpvContextProvider::new( - Arc::clone(&spv), - sdk.network, - ), - ); - let readiness = Arc::new(move || spv.is_ready()); - if let Err(message) = rs_sdk_ffi::dash_sdk_set_spv_source(sdk_handle, provider, readiness) { - drop(_runtime_guard); - runtime().block_on(manager.shutdown()); - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorInvalidParameter, - message, - ); - } + let provider = Arc::new(platform_wallet::spv_context_provider::SpvQuorumSource::new( + Arc::clone(&spv), + sdk.network, + )); + let controller = match rs_sdk_ffi::dash_sdk_spv_source_controller(sdk_handle) { + Ok(controller) => controller, + Err(message) => { + drop(_runtime_guard); + runtime().block_on(manager.shutdown()); + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + message, + ); + } + }; - *out_handle = PLATFORM_WALLET_MANAGER_STORAGE.insert(manager); + *out_handle = PLATFORM_WALLET_MANAGER_STORAGE.insert(PlatformWalletManagerHandle::new( + manager, + controller.as_ref().map(|_| provider), + controller, + )); PlatformWalletFFIResult::ok() } @@ -407,15 +412,14 @@ pub unsafe extern "C" fn platform_wallet_manager_destroy( handle: Handle, ) -> PlatformWalletFFIResult { if let Some(manager) = PLATFORM_WALLET_MANAGER_STORAGE.remove(handle) { - // Run the full lifecycle shutdown to completion, not just the - // platform-address sync. Every background task (identity sync, - // shielded sync, the wallet-event adapter) can fire callbacks - // through the host-owned `context` pointer; once `destroy` - // returns the host may free that context, so no task may be - // left alive to fire a callback against freed memory. - // `shutdown()` is idempotent, so this is safe even if the host - // already stopped some sync managers before calling destroy. - runtime().block_on(manager.shutdown()); + // Stop SPV first because its event manager retains host callback + // pointers. Then detach this manager's matching source lease before + // quiescing every remaining callback-capable task. + runtime().block_on(async { + let _ = manager.spv().stop().await; + manager.release_spv_source(); + manager.shutdown().await; + }); } PlatformWalletFFIResult::ok() } diff --git a/packages/rs-platform-wallet-ffi/src/spv.rs b/packages/rs-platform-wallet-ffi/src/spv.rs index 7d03e19d084..1ccf655b9b1 100644 --- a/packages/rs-platform-wallet-ffi/src/spv.rs +++ b/packages/rs-platform-wallet-ffi/src/spv.rs @@ -2,6 +2,7 @@ use std::ffi::{CStr, CString}; use std::os::raw::c_char; +use std::sync::Arc; use dashcore::sml::llmq_type::LlmqDevnetParams; use platform_wallet::spv::{ @@ -457,6 +458,11 @@ pub unsafe extern "C" fn platform_wallet_manager_spv_start( }; if start_result.is_ok() { + if let Err(message) = manager.acquire_spv_source() { + let spv_to_stop = Arc::clone(&spv); + let _ = block_on_worker(async move { spv_to_stop.stop().await }); + return Err(platform_wallet::PlatformWalletError::SpvError(message)); + } let _guard = runtime().enter(); spv.spawn_run_loop(); } @@ -479,6 +485,7 @@ pub unsafe extern "C" fn platform_wallet_manager_spv_stop( runtime().block_on(async { let _ = manager.spv().stop().await; }); + manager.release_spv_source(); }); unwrap_option_or_return!(option); PlatformWalletFFIResult::ok() diff --git a/packages/rs-platform-wallet/src/spv/runtime.rs b/packages/rs-platform-wallet/src/spv/runtime.rs index cce49d3e599..a1e9d7df648 100644 --- a/packages/rs-platform-wallet/src/spv/runtime.rs +++ b/packages/rs-platform-wallet/src/spv/runtime.rs @@ -1,11 +1,11 @@ //! SPV client runtime β€” manages the DashSpvClient lifecycle. -use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use tokio::sync::RwLock; use tokio::task::JoinHandle; +use dashcore::sml::llmq_entry_verification::LLMQEntryVerificationStatus; use dashcore::sml::llmq_type::LLMQType; use dashcore::{QuorumHash, Transaction}; @@ -43,6 +43,19 @@ fn quorum_lookup_key(display_order: [u8; 32]) -> QuorumHash { QuorumHash::from_byte_array(internal_order) } +fn verified_quorum_public_key( + status: &LLMQEntryVerificationStatus, + public_key: [u8; 48], +) -> Result<[u8; 48], PlatformWalletError> { + if matches!(status, LLMQEntryVerificationStatus::Verified) { + Ok(public_key) + } else { + Err(PlatformWalletError::SpvError(format!( + "quorum entry is not cryptographically verified: {status:?}" + ))) + } +} + /// SPV client runtime β€” owns the `DashSpvClient` and drives sync. /// /// Events are dispatched through [`PlatformEventManager`] to all registered @@ -54,35 +67,6 @@ pub struct SpvRuntime { last_config: RwLock>, task: Mutex>>, peer_tracker: Arc, - readiness: Arc, -} - -#[derive(Default)] -struct SpvReadiness { - ready: AtomicBool, -} - -impl SpvReadiness { - fn clear(&self) { - self.ready.store(false, Ordering::Release); - } - - fn is_ready(&self) -> bool { - self.ready.load(Ordering::Acquire) - } -} - -impl EventHandler for SpvReadiness { - fn on_progress(&self, progress: &SyncProgress) { - let headers_ready = progress - .headers() - .is_ok_and(|headers| headers.state() == SyncState::Synced); - let masternodes_ready = progress - .masternodes() - .is_ok_and(|masternodes| masternodes.state() == SyncState::Synced); - self.ready - .store(headers_ready && masternodes_ready, Ordering::Release); - } } /// Classify a dash-spv broadcast failure per the /// [`TransactionBroadcaster::broadcast`] contract. @@ -123,7 +107,6 @@ impl SpvRuntime { last_config: RwLock::new(None), task: Mutex::new(None), peer_tracker: Arc::new(PeerTracker::default()), - readiness: Arc::new(SpvReadiness::default()), } } @@ -135,8 +118,6 @@ impl SpvRuntime { return Err(PlatformWalletError::SpvAlreadyRunning); } } - self.readiness.clear(); - let network_manager = PeerNetworkManager::new(&config) .await .map_err(|e| PlatformWalletError::SpvError(e.to_string()))?; @@ -150,7 +131,6 @@ impl SpvRuntime { let event_handlers: Vec> = vec![ Arc::clone(&self.event_manager) as Arc, Arc::clone(&self.peer_tracker) as Arc, - Arc::clone(&self.readiness) as Arc, ]; let retained_config = config.clone(); @@ -177,9 +157,17 @@ impl SpvRuntime { self.client.try_read().map(|c| c.is_some()).unwrap_or(false) } - /// Whether header and masternode-list synchronization are both complete. - pub fn is_ready(&self) -> bool { - self.readiness.is_ready() + /// Whether dash-spv's current progress reports both header and + /// masternode-list synchronization complete. + pub async fn is_ready(&self) -> bool { + self.sync_progress().await.is_some_and(|progress| { + progress + .headers() + .is_ok_and(|headers| headers.state() == SyncState::Synced) + && progress + .masternodes() + .is_ok_and(|masternodes| masternodes.state() == SyncState::Synced) + }) } /// Broadcast a transaction to all connected SPV peers. @@ -228,7 +216,10 @@ impl SpvRuntime { .await .map_err(|e| PlatformWalletError::SpvError(e.to_string()))?; - Ok(*quorum.quorum_entry.quorum_public_key.as_ref()) + verified_quorum_public_key( + &quorum.verified, + *quorum.quorum_entry.quorum_public_key.as_ref(), + ) } /// Drive the sync loop of an already-[`start`]ed client until [`stop`] @@ -248,7 +239,6 @@ impl SpvRuntime { .await .map_err(|e| PlatformWalletError::SpvError(e.to_string())); - self.readiness.clear(); let mut client = self.client.write().await; let _ = client.take(); self.peer_tracker.clear(); @@ -258,7 +248,6 @@ impl SpvRuntime { /// Stop SPV sync gracefully. Unlocks the data dir safely pub async fn stop(&self) -> Result<(), PlatformWalletError> { - self.readiness.clear(); let taken = { let mut client = self.client.write().await; client.take() @@ -447,11 +436,17 @@ mod tests { use std::sync::Arc; use dash_spv::error::{NetworkError, SpvError}; + use dashcore::sml::llmq_entry_verification::{ + LLMQEntryVerificationSkipStatus, LLMQEntryVerificationStatus, + }; + use dashcore::sml::quorum_validation_error::QuorumValidationError; use dashcore::Network; use key_wallet_manager::WalletManager; use tokio::sync::RwLock; - use super::{classify_spv_broadcast_error, quorum_lookup_key, SpvRuntime}; + use super::{ + classify_spv_broadcast_error, quorum_lookup_key, verified_quorum_public_key, SpvRuntime, + }; use crate::broadcaster::BroadcastError; use crate::events::PlatformEventManager; use crate::wallet::platform_wallet::PlatformWalletInfo; @@ -567,4 +562,27 @@ mod tests { "using the hash without reversal must miss β€” this was the regression" ); } + + #[test] + fn only_verified_quorums_can_supply_a_public_key() { + let public_key = [0xAB; 48]; + assert_eq!( + verified_quorum_public_key(&LLMQEntryVerificationStatus::Verified, public_key).unwrap(), + public_key + ); + + for status in [ + LLMQEntryVerificationStatus::Unknown, + LLMQEntryVerificationStatus::Skipped( + LLMQEntryVerificationSkipStatus::NotMarkedForVerification, + ), + LLMQEntryVerificationStatus::Invalid(QuorumValidationError::InvalidQuorumPublicKey), + ] { + let error = verified_quorum_public_key(&status, public_key) + .unwrap_err() + .to_string(); + assert!(error.contains("not cryptographically verified")); + assert!(error.contains(&format!("{status:?}"))); + } + } } diff --git a/packages/rs-platform-wallet/src/spv_context_provider.rs b/packages/rs-platform-wallet/src/spv_context_provider.rs index 8ed9ded286a..02b008a5a8f 100644 --- a/packages/rs-platform-wallet/src/spv_context_provider.rs +++ b/packages/rs-platform-wallet/src/spv_context_provider.rs @@ -1,11 +1,11 @@ //! SPV-based Context Provider //! -//! Thin [`ContextProvider`] that resolves Platform proof quorum public keys +//! Thin quorum source that resolves Platform proof quorum public keys //! from the SPV runtime owned by the [`PlatformWalletManager`]. //! //! # Architecture //! -//! [`SpvContextProvider`] holds a shared [`Arc`] β€” a live reference +//! [`SpvQuorumSource`] holds a shared [`Arc`] β€” a live reference //! to the same runtime the SPV client writes to during sync β€” and delegates //! every lookup to [`SpvRuntime::get_quorum_public_key`], which reads the //! in-memory masternode list engine. No quorum data is stored here. @@ -15,13 +15,14 @@ //! runtime (`rs-sdk-ffi`'s `BigStackRuntime::block_on`), so the bridge uses //! [`tokio::task::block_in_place`] (avoids the nested-runtime panic) plus the //! ambient [`Handle::try_current`](tokio::runtime::Handle::try_current) of that -//! verify runtime. The provider is constructed at the FFI SDK-create call, -//! which runs off any runtime, so the handle is resolved at call time (and a -//! call from outside a runtime returns an error rather than panicking). +//! verify runtime. The source is constructed with the wallet manager, outside +//! any runtime, so the handle is resolved at call time (and a call from outside +//! a runtime returns an error rather than panicking). //! //! [`PlatformWalletManager`]: crate::manager::PlatformWalletManager //! [`SpvRuntime::get_quorum_public_key`]: crate::spv::SpvRuntime::get_quorum_public_key +use std::future::Future; use std::sync::Arc; use dash_context_provider::ContextProvider; @@ -51,13 +52,13 @@ fn hex_prefix(bytes: &[u8; 32]) -> String { /// /// Delegates quorum-key lookups to the shared [`SpvRuntime`]; the same runtime /// the SPV client populates during sync is read live for each proof. -pub struct SpvContextProvider { +pub struct SpvQuorumSource { spv: Arc, network: Network, } -impl SpvContextProvider { - /// Create a new SPV context provider. +impl SpvQuorumSource { + /// Create a new SPV quorum source. /// /// # Arguments /// @@ -67,9 +68,32 @@ impl SpvContextProvider { pub fn new(spv: Arc, network: Network) -> Self { Self { spv, network } } + + fn block_on_runtime(&self, future: F) -> Result + where + F: Future, + { + let handle = tokio::runtime::Handle::try_current().map_err(|_| { + ContextProviderError::Generic( + "SPV context provider called outside a Tokio runtime".to_string(), + ) + })?; + if handle.runtime_flavor() == RuntimeFlavor::CurrentThread { + return Err(ContextProviderError::Generic( + "SPV context provider requires a multi-threaded Tokio runtime".to_string(), + )); + } + Ok(tokio::task::block_in_place(|| handle.block_on(future))) + } + + /// Query dash-spv's existing progress snapshot without mirroring sync + /// state in a parallel readiness flag. + pub fn is_ready(&self) -> Result { + self.block_on_runtime(self.spv.is_ready()) + } } -impl ContextProvider for SpvContextProvider { +impl ContextProvider for SpvQuorumSource { fn get_quorum_public_key( &self, quorum_type: u32, @@ -96,32 +120,13 @@ impl ContextProvider for SpvContextProvider { // returns an error rather than panicking. The lookup is pure in-memory // (two brief RwLock reads, no network I/O); on write contention with SPV // sync it waits (fail-slow) rather than erroring. - let handle = tokio::runtime::Handle::try_current().map_err(|_| { - ContextProviderError::Generic( - "SPV quorum lookup called outside a Tokio runtime".to_string(), - ) - })?; - // `block_in_place` panics on a current-thread runtime, and bridging via - // a helper thread can deadlock: proof verification would block this - // runtime's only executor in `join()` while the SPV masternode-engine - // write lock is held by a task on the same runtime, so that writer could - // never resume to release it. The SDK verifies proofs on a multi-threaded - // runtime, so fail closed here rather than risk a panic or a deadlock. - if handle.runtime_flavor() == RuntimeFlavor::CurrentThread { - return Err(ContextProviderError::Generic( - "SPV quorum lookup requires a multi-threaded Tokio runtime".to_string(), - )); - } - // Multi-threaded runtime: block the current worker without stalling the - // whole runtime. - let result = tokio::task::block_in_place(|| { - handle.block_on(self.spv.get_quorum_public_key( + let result = self + .block_on_runtime(self.spv.get_quorum_public_key( quorum_type, quorum_hash, core_chain_locked_height, - )) - }) - .map_err(|e| ContextProviderError::InvalidQuorum(e.to_string())); + ))? + .map_err(|e| ContextProviderError::InvalidQuorum(e.to_string())); // The quorum public key is the trust root of every Platform proof this // SDK verifies; tracing which provider served it lets an operator @@ -163,8 +168,9 @@ impl ContextProvider for SpvContextProvider { _data_contract_id: &Identifier, _platform_version: &PlatformVersion, ) -> Result>, ContextProviderError> { - // Data contract lookup is handled by the SDK's contract cache, - // not the SPV layer. + // This provider is a quorum-only SPV source. A full SDK provider must + // compose it with the local contract cache and embedded system + // contracts; installing this source alone cannot resolve contracts. Ok(None) } @@ -172,8 +178,8 @@ impl ContextProvider for SpvContextProvider { &self, _token_id: &Identifier, ) -> Result, ContextProviderError> { - // Token configuration lookup is handled by the SDK's contract cache, - // not the SPV layer. + // Token configuration is supplied by the same auxiliary local resolver + // as data contracts, never by the SPV quorum source itself. Ok(None) } } diff --git a/packages/rs-sdk-ffi/src/context_provider.rs b/packages/rs-sdk-ffi/src/context_provider.rs index 0b176f59d85..0687f290239 100644 --- a/packages/rs-sdk-ffi/src/context_provider.rs +++ b/packages/rs-sdk-ffi/src/context_provider.rs @@ -3,8 +3,8 @@ //! This module provides FFI bindings for configuring context providers, //! allowing the Platform SDK to connect to Core SDK for proof verification. -use std::sync::atomic::{AtomicU8, Ordering}; -use std::sync::Arc; +use std::sync::atomic::{AtomicU64, AtomicU8, Ordering}; +use std::sync::{Arc, Weak}; use arc_swap::ArcSwapOption; use dash_sdk::dpp::data_contract::TokenConfiguration; @@ -73,51 +73,210 @@ impl TryFrom for ContextProviderMode { /// Quorum source selected for a lookup at the current instant. #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[repr(u8)] -pub enum ContextProviderSource { +pub enum AdaptiveContextProviderSource { Trusted = 0, Spv = 1, } struct SpvContextSource { + lease_id: u64, provider: Arc, - is_ready: Arc bool + Send + Sync>, + is_ready: Arc Result + Send + Sync>, +} + +/// Owns the currently active manager-backed SPV source without changing the +/// SDK's context-provider identity. +pub struct SpvSourceController { + source: ArcSwapOption, + next_lease_id: AtomicU64, +} + +impl Default for SpvSourceController { + fn default() -> Self { + Self { + source: ArcSwapOption::empty(), + next_lease_id: AtomicU64::new(1), + } + } +} + +impl SpvSourceController { + /// Acquire the SPV source for one running wallet manager. + /// + /// Only an empty controller can be acquired. The returned lease clears the + /// source on drop if it still owns the matching generation. + pub fn acquire( + self: &Arc, + provider: Arc, + is_ready: Arc Result + Send + Sync>, + ) -> Result { + let lease_id = self.next_lease_id.fetch_add(1, Ordering::Relaxed); + let source = Arc::new(SpvContextSource { + lease_id, + provider, + is_ready, + }); + let previous = self + .source + .compare_and_swap(std::ptr::null::(), Some(source)); + if previous.is_some() { + Err("another wallet manager owns the SPV context source") + } else { + Ok(SpvSourceLease { + controller: Arc::downgrade(self), + lease_id, + }) + } + } + + fn release(&self, lease_id: u64) { + self.source.rcu(|current| match current { + Some(source) if source.lease_id == lease_id => None, + _ => current.clone(), + }); + } + + fn source(&self) -> Result, ContextProviderError> { + self.source.load_full().ok_or_else(|| { + ContextProviderError::Generic("SPV context source is not active".to_string()) + }) + } + + fn ready_source(&self) -> Result, ContextProviderError> { + let source = self.source()?; + if !(source.is_ready)()? { + return Err(ContextProviderError::Generic( + "SPV context source is not ready".to_string(), + )); + } + Ok(source) + } +} + +/// Conditional ownership token for a manager-backed SPV source. +pub struct SpvSourceLease { + controller: Weak, + lease_id: u64, +} + +impl Drop for SpvSourceLease { + fn drop(&mut self) { + if let Some(controller) = self.controller.upgrade() { + controller.release(self.lease_id); + } + } +} + +/// Contract and token resolver shared by fixed SPV and adaptive providers. +/// +/// This wrapper intentionally exposes no quorum-key method, so the production +/// SPV provider cannot accidentally fall back to trusted quorum data. +pub(crate) struct AuxiliaryContextProvider { + provider: Arc, +} + +impl AuxiliaryContextProvider { + pub(crate) fn new(provider: Arc) -> Self { + Self { provider } + } + + fn get_data_contract( + &self, + id: &Identifier, + platform_version: &PlatformVersion, + ) -> Result>, ContextProviderError> { + self.provider.get_data_contract(id, platform_version) + } + + fn get_token_configuration( + &self, + token_id: &Identifier, + ) -> Result, ContextProviderError> { + self.provider.get_token_configuration(token_id) + } +} + +/// Fixed production provider: quorum keys come only from the active SPV +/// source, while contracts and tokens come from the local auxiliary resolver. +pub struct SpvContextProvider { + spv: Arc, + auxiliary: Arc, + network: Network, +} + +impl SpvContextProvider { + pub(crate) fn new( + spv: Arc, + auxiliary: Arc, + network: Network, + ) -> Self { + Self { + spv, + auxiliary, + 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> { + self.spv.ready_source()?.provider.get_quorum_public_key( + quorum_type, + quorum_hash, + core_chain_locked_height, + ) + } + + fn get_platform_activation_height(&self) -> Result { + activation_height(self.network) + } + + fn get_data_contract( + &self, + id: &Identifier, + platform_version: &PlatformVersion, + ) -> Result>, ContextProviderError> { + self.auxiliary.get_data_contract(id, platform_version) + } + + fn get_token_configuration( + &self, + token_id: &Identifier, + ) -> Result, ContextProviderError> { + self.auxiliary.get_token_configuration(token_id) + } } /// Context provider whose identity remains fixed while quorum routing adapts. pub struct AdaptiveContextProvider { trusted: Arc, - spv: ArcSwapOption, + spv: Arc, + auxiliary: Arc, mode: AtomicU8, network: Network, } impl AdaptiveContextProvider { - pub fn new(trusted: Arc, network: Network) -> Self { + pub(crate) fn new( + trusted: Arc, + spv: Arc, + auxiliary: Arc, + network: Network, + ) -> Self { Self { trusted, - spv: ArcSwapOption::empty(), + spv, + auxiliary, mode: AtomicU8::new(ContextProviderMode::Auto as u8), network, } } - /// Populate the SPV source exactly once. - pub fn set_spv_source( - &self, - provider: Arc, - is_ready: Arc bool + Send + Sync>, - ) -> Result<(), &'static str> { - let source = Arc::new(SpvContextSource { provider, is_ready }); - let previous = self - .spv - .compare_and_swap(std::ptr::null::(), Some(source)); - if previous.is_some() { - Err("SPV context source is already populated") - } else { - Ok(()) - } - } - pub fn set_mode(&self, mode: ContextProviderMode) { self.mode.store(mode as u8, Ordering::Release); } @@ -127,29 +286,16 @@ impl AdaptiveContextProvider { .expect("context-provider mode is only written from validated values") } - pub fn active_source(&self) -> ContextProviderSource { - let spv = self.spv.load(); + pub fn active_source(&self) -> AdaptiveContextProviderSource { match self.mode() { - ContextProviderMode::Trusted => ContextProviderSource::Trusted, - ContextProviderMode::Spv if spv.is_some() => ContextProviderSource::Spv, - ContextProviderMode::Auto if spv.as_ref().is_some_and(|source| (source.is_ready)()) => { - ContextProviderSource::Spv + ContextProviderMode::Trusted => AdaptiveContextProviderSource::Trusted, + ContextProviderMode::Spv => AdaptiveContextProviderSource::Spv, + ContextProviderMode::Auto if self.spv.ready_source().is_ok() => { + AdaptiveContextProviderSource::Spv } - ContextProviderMode::Auto | ContextProviderMode::Spv => ContextProviderSource::Trusted, + ContextProviderMode::Auto => AdaptiveContextProviderSource::Trusted, } } - - fn ready_spv_source(&self) -> Result, ContextProviderError> { - let source = self.spv.load_full().ok_or_else(|| { - ContextProviderError::Generic("SPV context source is not populated".to_string()) - })?; - if !(source.is_ready)() { - return Err(ContextProviderError::Generic( - "SPV context source is not ready".to_string(), - )); - } - Ok(source) - } } impl ContextProvider for AdaptiveContextProvider { @@ -165,18 +311,18 @@ impl ContextProvider for AdaptiveContextProvider { quorum_hash, core_chain_locked_height, ), - ContextProviderMode::Spv => self.ready_spv_source()?.provider.get_quorum_public_key( + ContextProviderMode::Spv => self.spv.ready_source()?.provider.get_quorum_public_key( quorum_type, quorum_hash, core_chain_locked_height, ), - ContextProviderMode::Auto => match self.spv.load_full() { - Some(source) if (source.is_ready)() => source.provider.get_quorum_public_key( + ContextProviderMode::Auto => match self.spv.ready_source() { + Ok(source) => source.provider.get_quorum_public_key( quorum_type, quorum_hash, core_chain_locked_height, ), - _ => self.trusted.get_quorum_public_key( + Err(_) => self.trusted.get_quorum_public_key( quorum_type, quorum_hash, core_chain_locked_height, @@ -186,11 +332,7 @@ impl ContextProvider for AdaptiveContextProvider { } fn get_platform_activation_height(&self) -> Result { - match self.network { - Network::Mainnet => Ok(2_132_092), - Network::Testnet => Ok(1_090_319), - Network::Devnet | Network::Regtest => Ok(1), - } + activation_height(self.network) } fn get_data_contract( @@ -198,14 +340,22 @@ impl ContextProvider for AdaptiveContextProvider { id: &Identifier, platform_version: &PlatformVersion, ) -> Result>, ContextProviderError> { - self.trusted.get_data_contract(id, platform_version) + self.auxiliary.get_data_contract(id, platform_version) } fn get_token_configuration( &self, token_id: &Identifier, ) -> Result, ContextProviderError> { - self.trusted.get_token_configuration(token_id) + self.auxiliary.get_token_configuration(token_id) + } +} + +fn activation_height(network: Network) -> Result { + match network { + Network::Mainnet => Ok(2_132_092), + Network::Testnet => Ok(1_090_319), + Network::Devnet | Network::Regtest => Ok(1), } } @@ -260,7 +410,10 @@ mod tests { use dash_sdk::dpp::version::PlatformVersion; use drive_proof_verifier::{ContextProvider, ContextProviderError}; - use super::{AdaptiveContextProvider, ContextProviderMode, ContextProviderSource}; + use super::{ + AdaptiveContextProvider, AdaptiveContextProviderSource, AuxiliaryContextProvider, + ContextProviderMode, SpvContextProvider, SpvSourceController, + }; use crate::types::Network; struct TestProvider { @@ -331,12 +484,27 @@ mod tests { provider.get_quorum_public_key(1, [2; 32], 3) } + fn adaptive( + trusted: Arc, + network: Network, + ) -> (AdaptiveContextProvider, Arc) { + let spv = Arc::new(SpvSourceController::default()); + let auxiliary = Arc::new(AuxiliaryContextProvider::new(Arc::clone(&trusted))); + ( + AdaptiveContextProvider::new(trusted, Arc::clone(&spv), auxiliary, network), + spv, + ) + } + #[test] fn routes_quorum_lookups_by_mode_and_readiness() { - let adaptive = AdaptiveContextProvider::new(provider("trusted", 0x11), Network::Testnet); + let (adaptive, spv) = adaptive(provider("trusted", 0x11), Network::Testnet); assert_eq!(quorum_key(&adaptive).unwrap(), [0x11; 48]); - assert_eq!(adaptive.active_source(), ContextProviderSource::Trusted); + assert_eq!( + adaptive.active_source(), + AdaptiveContextProviderSource::Trusted + ); adaptive.set_mode(ContextProviderMode::Spv); assert!( @@ -346,17 +514,17 @@ mod tests { let ready = Arc::new(AtomicBool::new(false)); let ready_for_callback = Arc::clone(&ready); - adaptive - .set_spv_source( + let _lease = spv + .acquire( provider("spv", 0x22), - Arc::new(move || ready_for_callback.load(Ordering::Acquire)), + Arc::new(move || Ok(ready_for_callback.load(Ordering::Acquire))), ) .unwrap(); assert!( quorum_key(&adaptive).is_err(), "unready SPV must fail closed" ); - assert_eq!(adaptive.active_source(), ContextProviderSource::Spv); + assert_eq!(adaptive.active_source(), AdaptiveContextProviderSource::Spv); ready.store(true, Ordering::Release); assert_eq!(quorum_key(&adaptive).unwrap(), [0x22; 48]); @@ -367,18 +535,20 @@ mod tests { adaptive.set_mode(ContextProviderMode::Auto); ready.store(false, Ordering::Release); assert_eq!(quorum_key(&adaptive).unwrap(), [0x11; 48]); - assert_eq!(adaptive.active_source(), ContextProviderSource::Trusted); + assert_eq!( + adaptive.active_source(), + AdaptiveContextProviderSource::Trusted + ); ready.store(true, Ordering::Release); assert_eq!(quorum_key(&adaptive).unwrap(), [0x22; 48]); - assert_eq!(adaptive.active_source(), ContextProviderSource::Spv); + assert_eq!(adaptive.active_source(), AdaptiveContextProviderSource::Spv); } #[test] fn routes_contracts_and_tokens_to_trusted_for_every_mode() { - let adaptive = AdaptiveContextProvider::new(provider("trusted", 0x11), Network::Mainnet); - let ready = Arc::new(|| true); - adaptive - .set_spv_source(provider("spv", 0x22), ready) + let (adaptive, spv) = adaptive(provider("trusted", 0x11), Network::Mainnet); + let _lease = spv + .acquire(provider("spv", 0x22), Arc::new(|| Ok(true))) .unwrap(); let id = Identifier::new([7; 32]); @@ -402,25 +572,68 @@ mod tests { } #[test] - fn rejects_replacing_the_spv_source() { - let adaptive = AdaptiveContextProvider::new(provider("trusted", 0x11), Network::Regtest); - adaptive - .set_spv_source(provider("spv", 0x22), Arc::new(|| true)) + fn source_lease_prevents_hijacking_and_releases_on_drop() { + let controller = Arc::new(SpvSourceController::default()); + let lease = controller + .acquire(provider("spv", 0x22), Arc::new(|| Ok(true))) .unwrap(); - assert!(adaptive - .set_spv_source(provider("other", 0x33), Arc::new(|| true)) + assert!(controller + .acquire(provider("other", 0x33), Arc::new(|| Ok(true))) .is_err()); + + drop(lease); + let _replacement = controller + .acquire(provider("other", 0x33), Arc::new(|| Ok(true))) + .unwrap(); + assert_eq!( + controller + .ready_source() + .unwrap() + .provider + .get_quorum_public_key(1, [2; 32], 3) + .unwrap(), + [0x33; 48] + ); } #[test] fn does_not_retry_a_ready_spv_miss_against_trusted() { - let adaptive = AdaptiveContextProvider::new(provider("trusted", 0x11), Network::Testnet); - adaptive - .set_spv_source(failing_provider("spv"), Arc::new(|| true)) + let (adaptive, spv) = adaptive(provider("trusted", 0x11), Network::Testnet); + let _lease = spv + .acquire(failing_provider("spv"), Arc::new(|| Ok(true))) .unwrap(); adaptive.set_mode(ContextProviderMode::Auto); let error = quorum_key(&adaptive).unwrap_err().to_string(); assert!(error.contains("spv quorum miss")); } + + #[test] + fn fixed_spv_never_routes_quorums_to_the_auxiliary_provider() { + let auxiliary_provider = provider("auxiliary", 0x11); + let auxiliary = Arc::new(AuxiliaryContextProvider::new(auxiliary_provider)); + let controller = Arc::new(SpvSourceController::default()); + let fixed = SpvContextProvider::new(Arc::clone(&controller), auxiliary, Network::Testnet); + + assert!(fixed.get_quorum_public_key(1, [2; 32], 3).is_err()); + let _lease = controller + .acquire(provider("spv", 0x22), Arc::new(|| Ok(true))) + .unwrap(); + assert_eq!( + fixed.get_quorum_public_key(1, [2; 32], 3).unwrap(), + [0x22; 48] + ); + + let id = Identifier::new([7; 32]); + assert!(fixed + .get_data_contract(&id, PlatformVersion::latest()) + .unwrap_err() + .to_string() + .contains("auxiliary contract route")); + assert!(fixed + .get_token_configuration(&id) + .unwrap_err() + .to_string() + .contains("auxiliary token route")); + } } diff --git a/packages/rs-sdk-ffi/src/sdk.rs b/packages/rs-sdk-ffi/src/sdk.rs index e023832479b..f3044005cab 100644 --- a/packages/rs-sdk-ffi/src/sdk.rs +++ b/packages/rs-sdk-ffi/src/sdk.rs @@ -2,7 +2,7 @@ use std::sync::{Arc, OnceLock}; use tokio::runtime::Runtime; -use tracing::{debug, error, info, warn}; +use tracing::{error, info, warn}; use dash_sdk::dpp::serialization::PlatformDeserializableWithPotentialValidationFromVersionedStructure; use dash_sdk::sdk::AddressList; @@ -11,8 +11,9 @@ use std::ffi::CStr; use std::str::FromStr; use crate::context_provider::{ - AdaptiveContextProvider, ContextProviderHandle, ContextProviderMode, ContextProviderSource, - ContextProviderWrapper, CoreSDKHandle, + AdaptiveContextProvider, AdaptiveContextProviderSource, AuxiliaryContextProvider, + ContextProviderHandle, ContextProviderMode, ContextProviderWrapper, CoreSDKHandle, + SpvContextProvider, SpvSourceController, }; use crate::runtime::BigStackRuntime; use crate::types::{DashSDKConfig, FFINetwork, Network, SDKHandle}; @@ -48,6 +49,7 @@ pub(crate) struct SDKWrapper { pub runtime: Arc, pub trusted_provider: Option>, pub adaptive_provider: Option>, + pub spv_source_controller: Option>, } impl SDKWrapper { @@ -57,6 +59,7 @@ impl SDKWrapper { runtime: Arc::new(BigStackRuntime::new(runtime)), trusted_provider: None, adaptive_provider: None, + spv_source_controller: None, } } @@ -71,6 +74,7 @@ impl SDKWrapper { runtime: Arc::new(BigStackRuntime::new(runtime)), trusted_provider: Some(provider), adaptive_provider: None, + spv_source_controller: None, } } @@ -86,6 +90,7 @@ impl SDKWrapper { runtime, trusted_provider: None, adaptive_provider: None, + spv_source_controller: None, } } } @@ -181,6 +186,7 @@ pub unsafe extern "C" fn dash_sdk_create(config: *const DashSDKConfig) -> DashSD runtime, trusted_provider: None, adaptive_provider: None, + spv_source_controller: None, }); let handle = Box::into_raw(wrapper) as *mut SDKHandle; DashSDKResult::success(handle as *mut std::os::raw::c_void) @@ -294,6 +300,7 @@ pub unsafe extern "C" fn dash_sdk_create_extended( runtime, trusted_provider: None, adaptive_provider: None, + spv_source_controller: None, }); let handle = Box::into_raw(wrapper) as *mut SDKHandle; DashSDKResult::success(handle as *mut std::os::raw::c_void) @@ -302,18 +309,54 @@ pub unsafe extern "C" fn dash_sdk_create_extended( } } -/// Create a new SDK instance with trusted setup +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum FixedContextProviderKind { + Trusted, + Spv, + Adaptive, +} + +struct FixedProviderParts { + provider: Arc, + adaptive: Option>, + spv_source_controller: Option>, +} + +/// Create an SDK with a fixed trusted context provider. +/// +/// # Safety +/// `config` must be valid for the duration of the call. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_create_trusted(config: *const DashSDKConfig) -> DashSDKResult { + dash_sdk_create_with_fixed_provider(config, FixedContextProviderKind::Trusted) +} + +/// Create an SDK with a fixed production SPV context provider. /// -/// This creates an SDK with a trusted context provider that fetches quorum keys and -/// data contracts from trusted endpoints instead of requiring proof verification. +/// Quorum lookups fail closed until a running wallet manager leases its SPV +/// source. Contracts and tokens use the local auxiliary cache and embedded +/// system contracts. /// /// # Safety -/// - `config` must be a valid pointer to a DashSDKConfig structure +/// `config` must be valid for the duration of the call. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_create_spv(config: *const DashSDKConfig) -> DashSDKResult { + dash_sdk_create_with_fixed_provider(config, FixedContextProviderKind::Spv) +} + +/// Create an SDK with a fixed adaptive context provider. +/// /// # Safety -/// - `config` must be a valid pointer to a DashSDKConfig structure for the duration of the call. -/// - The returned handle inside `DashSDKResult` must be destroyed using the SDK destroy function to avoid leaks. +/// `config` must be valid for the duration of the call. #[no_mangle] -pub unsafe extern "C" fn dash_sdk_create_trusted(config: *const DashSDKConfig) -> DashSDKResult { +pub unsafe extern "C" fn dash_sdk_create_adaptive(config: *const DashSDKConfig) -> DashSDKResult { + dash_sdk_create_with_fixed_provider(config, FixedContextProviderKind::Adaptive) +} + +unsafe fn dash_sdk_create_with_fixed_provider( + config: *const DashSDKConfig, + provider_kind: FixedContextProviderKind, +) -> DashSDKResult { if config.is_null() { return DashSDKResult::error(DashSDKError::new( DashSDKErrorCode::InvalidParameter, @@ -334,10 +377,7 @@ pub unsafe extern "C" fn dash_sdk_create_trusted(config: *const DashSDKConfig) - } }; - info!( - ?network, - "dash_sdk_create_trusted: creating trusted context provider" - ); + info!(?network, ?provider_kind, "creating SDK context provider"); // Create trusted context provider. Resolution order for the quorum // lookup base URL: @@ -363,7 +403,7 @@ pub unsafe extern "C" fn dash_sdk_create_trusted(config: *const DashSDKConfig) - let trusted_provider = if let Some(quorum_url) = explicit_quorum_url { info!( quorum_url = %quorum_url, - "dash_sdk_create_trusted: using caller-provided quorum URL" + "using caller-provided quorum URL" ); match rs_sdk_trusted_context_provider::TrustedHttpContextProvider::new_with_url( network, @@ -372,7 +412,7 @@ pub unsafe extern "C" fn dash_sdk_create_trusted(config: *const DashSDKConfig) - ) { Ok(provider) => Arc::new(provider), Err(e) => { - error!(error = %e, "dash_sdk_create_trusted: failed to create context provider from override URL"); + error!(error = %e, "failed to create context provider from override URL"); return DashSDKResult::error(DashSDKError::new( DashSDKErrorCode::InternalError, format!("Failed to create context provider: {}", e), @@ -380,18 +420,18 @@ pub unsafe extern "C" fn dash_sdk_create_trusted(config: *const DashSDKConfig) - } } } else if matches!(network, Network::Regtest) { - info!("dash_sdk_create_trusted: using local quorum sidecar for regtest"); + info!("using local quorum sidecar for regtest"); match rs_sdk_trusted_context_provider::TrustedHttpContextProvider::new_with_url( network, "http://127.0.0.1:22444".to_string(), std::num::NonZeroUsize::new(100).unwrap(), ) { Ok(provider) => { - info!("dash_sdk_create_trusted: local trusted context provider created"); + info!("local context provider created"); Arc::new(provider) } Err(e) => { - error!(error = %e, "dash_sdk_create_trusted: failed to create local context provider"); + error!(error = %e, "failed to create local context provider"); return DashSDKResult::error(DashSDKError::new( DashSDKErrorCode::InternalError, format!("Failed to create local context provider: {}", e), @@ -405,11 +445,11 @@ pub unsafe extern "C" fn dash_sdk_create_trusted(config: *const DashSDKConfig) - std::num::NonZeroUsize::new(100).unwrap(), // Cache size ) { Ok(provider) => { - info!("dash_sdk_create_trusted: trusted context provider created"); + info!("trusted context provider created"); Arc::new(provider) } Err(e) => { - error!(error = %e, "dash_sdk_create_trusted: failed to create trusted context provider"); + error!(error = %e, "failed to create trusted context provider"); return DashSDKResult::error(DashSDKError::new( DashSDKErrorCode::InternalError, format!("Failed to create trusted context provider: {}", e), @@ -418,19 +458,16 @@ pub unsafe extern "C" fn dash_sdk_create_trusted(config: *const DashSDKConfig) - } }; - // Parse DAPI addresses - for trusted setup, we always need real addresses. + // Parse DAPI addresses. Every fixed-provider SDK needs real addresses. // Devnet/regtest have no built-in defaults; callers must supply // `dapi_addresses` (and typically `quorum_url`) for those networks. let builder = if config.dapi_addresses.is_null() { - info!("dash_sdk_create_trusted: no DAPI addresses provided, using defaults for network"); + info!("no DAPI addresses provided, using defaults for network"); match network { Network::Testnet => SdkBuilder::new_testnet(), Network::Mainnet => SdkBuilder::new_mainnet(), _ => { - error!( - ?network, - "dash_sdk_create_trusted: no DAPI addresses for network" - ); + error!(?network, "no DAPI addresses for network"); return DashSDKResult::error(DashSDKError::new( DashSDKErrorCode::InvalidParameter, format!("DAPI addresses not available for network: {:?}", network), @@ -449,24 +486,21 @@ pub unsafe extern "C" fn dash_sdk_create_trusted(config: *const DashSDKConfig) - }; if addresses_str.is_empty() { - error!("dash_sdk_create_trusted: empty DAPI addresses provided"); + error!("empty DAPI addresses provided"); return DashSDKResult::error(DashSDKError::new( DashSDKErrorCode::InvalidParameter, - "DAPI addresses cannot be empty for trusted setup".to_string(), + "DAPI addresses cannot be empty".to_string(), )); } else { - info!( - addresses = addresses_str, - "dash_sdk_create_trusted: using provided DAPI addresses" - ); + info!(addresses = addresses_str, "using provided DAPI addresses"); // Parse the address list let address_list = match AddressList::from_str(addresses_str) { Ok(list) => { - info!("dash_sdk_create_trusted: successfully parsed addresses"); + info!("successfully parsed DAPI addresses"); list } Err(e) => { - error!(error = %e, "dash_sdk_create_trusted: failed to parse addresses"); + error!(error = %e, "failed to parse DAPI addresses"); return DashSDKResult::error(DashSDKError::new( DashSDKErrorCode::InvalidParameter, format!("Failed to parse DAPI addresses: {}", e), @@ -478,19 +512,50 @@ pub unsafe extern "C" fn dash_sdk_create_trusted(config: *const DashSDKConfig) - } }; - // Clone trusted provider for prefetching quorums - let provider_for_prefetch = Arc::clone(&trusted_provider); let provider_for_wrapper = Arc::clone(&trusted_provider); - let adaptive_provider = Arc::new(AdaptiveContextProvider::new( - Arc::clone(&trusted_provider) as Arc, - network, - )); - - // Install one adaptive provider for the SDK's entire lifetime. - info!("dash_sdk_create_trusted: adding adaptive context provider to builder"); - let builder = builder.with_context_provider_arc( - Arc::clone(&adaptive_provider) as Arc + let trusted_context = + Arc::clone(&trusted_provider) as Arc; + let auxiliary = Arc::new(AuxiliaryContextProvider::new(Arc::clone(&trusted_context))); + let provider_parts = match provider_kind { + FixedContextProviderKind::Trusted => FixedProviderParts { + provider: trusted_context, + adaptive: None, + spv_source_controller: None, + }, + FixedContextProviderKind::Spv => { + let controller = Arc::new(SpvSourceController::default()); + let provider = Arc::new(SpvContextProvider::new( + Arc::clone(&controller), + auxiliary, + network, + )); + FixedProviderParts { + provider, + adaptive: None, + spv_source_controller: Some(controller), + } + } + FixedContextProviderKind::Adaptive => { + let controller = Arc::new(SpvSourceController::default()); + let provider = Arc::new(AdaptiveContextProvider::new( + Arc::clone(&trusted_context), + Arc::clone(&controller), + auxiliary, + network, + )); + FixedProviderParts { + provider: Arc::clone(&provider) as Arc, + adaptive: Some(provider), + spv_source_controller: Some(controller), + } + } + }; + + info!( + ?provider_kind, + "adding fixed context provider to SDK builder" ); + let builder = builder.with_context_provider_arc(provider_parts.provider); let builder = match apply_version(builder, config.platform_version) { Ok(b) => b, @@ -502,37 +567,28 @@ pub unsafe extern "C" fn dash_sdk_create_trusted(config: *const DashSDKConfig) - match sdk_result { Ok(sdk) => { - // Prefetch quorums for trusted setup - info!("dash_sdk_create_trusted: SDK built, prefetching quorums..."); - - let runtime_clone = runtime.handle().clone(); - runtime_clone.spawn(async move { - // First, try a simple HTTP test - debug!("dash_sdk_create_trusted: testing basic HTTP connectivity"); - match reqwest::get("https://www.google.com").await { - Ok(_) => debug!("dash_sdk_create_trusted: basic HTTP test successful (Google)"), - Err(e) => warn!(error = %e, "dash_sdk_create_trusted: basic HTTP test failed"), - } - - // Try the quorums endpoint directly - debug!("dash_sdk_create_trusted: testing quorums endpoint directly"); - match reqwest::get("https://quorums.testnet.networks.dash.org/quorums").await { - Ok(resp) => debug!(status = %resp.status(), "dash_sdk_create_trusted: direct quorums endpoint test successful"), - Err(e) => warn!(error = %e, "dash_sdk_create_trusted: direct quorums endpoint test failed"), - } - - // Now try through the provider - match provider_for_prefetch.update_quorum_caches().await { - Ok(_) => info!("dash_sdk_create_trusted: successfully prefetched quorums"), - Err(e) => warn!(error = %e, "dash_sdk_create_trusted: failed to prefetch quorums; continuing"), - } - }); + // A fixed SPV provider never contacts the trusted quorum endpoint. + // Trusted and Adaptive may use trusted quorum routing, so retain + // their existing asynchronous cache prefetch. + if provider_kind != FixedContextProviderKind::Spv { + let provider_for_prefetch = Arc::clone(&trusted_provider); + let runtime_clone = runtime.handle().clone(); + runtime_clone.spawn(async move { + match provider_for_prefetch.update_quorum_caches().await { + Ok(_) => info!("successfully prefetched trusted quorums"), + Err(e) => { + warn!(error = %e, "failed to prefetch trusted quorums; continuing") + } + } + }); + } let wrapper = Box::new(SDKWrapper { sdk, runtime, trusted_provider: Some(provider_for_wrapper), - adaptive_provider: Some(adaptive_provider), + adaptive_provider: provider_parts.adaptive, + spv_source_controller: provider_parts.spv_source_controller, }); let handle = Box::into_raw(wrapper) as *mut SDKHandle; DashSDKResult::success(handle as *mut std::os::raw::c_void) @@ -570,29 +626,18 @@ pub unsafe extern "C" fn dash_sdk_get_inner_sdk_ptr( &wrapper.sdk as *const dash_sdk::Sdk as *const std::os::raw::c_void } -/// Populate the fixed adaptive provider's SPV source. -/// -/// This changes internal adaptive state only; the SDK context provider itself -/// is never replaced. +/// Borrow the source controller retained by a fixed SPV-capable provider. /// /// # Safety /// `sdk_handle` must be a valid SDK handle for the duration of this call. -pub unsafe fn dash_sdk_set_spv_source( +pub unsafe fn dash_sdk_spv_source_controller( sdk_handle: *mut SDKHandle, - provider: Arc, - is_ready: Arc bool + Send + Sync>, -) -> Result<(), String> { +) -> Result>, String> { if sdk_handle.is_null() { return Err("SDK handle is null".to_string()); } let wrapper = &*(sdk_handle as *const SDKWrapper); - let adaptive = wrapper - .adaptive_provider - .as_ref() - .ok_or_else(|| "SDK does not use an adaptive context provider".to_string())?; - adaptive - .set_spv_source(provider, is_ready) - .map_err(str::to_string) + Ok(wrapper.spv_source_controller.clone()) } /// Set the adaptive quorum routing mode without replacing the SDK provider. @@ -639,15 +684,18 @@ pub unsafe extern "C" fn dash_sdk_set_context_provider_mode( #[no_mangle] pub unsafe extern "C" fn dash_sdk_get_context_provider_source(sdk_handle: *const SDKHandle) -> u8 { if sdk_handle.is_null() { - return ContextProviderSource::Trusted as u8; + return AdaptiveContextProviderSource::Trusted as u8; } let wrapper = &*(sdk_handle as *const SDKWrapper); - wrapper - .adaptive_provider - .as_ref() - .map_or(ContextProviderSource::Trusted as u8, |provider| { - provider.active_source() as u8 - }) + if let Some(provider) = &wrapper.adaptive_provider { + // Evaluate readiness inside the SDK's multi-thread runtime so callers + // outside Tokio (including Swift UI code) receive the live Auto source. + wrapper.runtime.block_on(async { provider.active_source() }) as u8 + } else if wrapper.spv_source_controller.is_some() { + AdaptiveContextProviderSource::Spv as u8 + } else { + AdaptiveContextProviderSource::Trusted as u8 + } } /// Register global context provider callbacks diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift index bcac09b07e6..bedddd8be2c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift @@ -74,6 +74,16 @@ public final class SDK: @unchecked Sendable { case trace = 4 } + /// Fixed context provider installed when the Rust SDK is constructed. + public enum ContextProviderKind: Sendable { + /// Trusted HTTP quorum keys plus local contract/token resolution. + case trusted + /// SPV-only quorum keys plus local contract/token resolution. + case spv + /// Runtime-selectable Trusted/SPV/Auto quorum routing. + case adaptive + } + /// Enable logging for gRPC and SDK operations /// This will log all network requests, including endpoints being contacted public static func enableLogging(level: LogLevel = .debug) { @@ -240,11 +250,12 @@ public final class SDK: @unchecked Sendable { return active.map(\.dapiUrl).joined(separator: ",") } - /// Create a new SDK instance with trusted setup + /// Create a new SDK instance with a fixed context-provider policy. /// - /// This uses a trusted context provider that fetches quorum keys and - /// data contracts from trusted HTTP endpoints instead of requiring proof verification. - /// This is suitable for mobile applications where proof verification would be resource-intensive. + /// `contextProvider` selects one provider identity for the SDK's lifetime. + /// Production SPV never falls back to trusted quorum keys; Adaptive is + /// intended for examples and deployments that explicitly need live policy + /// changes. /// /// `platformVersion`: /// - `0` (default) β€” let the Rust SDK seed at the per-network minimum @@ -255,7 +266,11 @@ public final class SDK: @unchecked Sendable { /// testnet floor 12 = V1), so this picks the right wire without a /// Swift-side networkβ†’version map. /// - non-zero β€” pin the SDK to this exact `PlatformVersion`. - public init(network: Network, platformVersion: UInt32 = 0) throws { + public init( + network: Network, + platformVersion: UInt32 = 0, + contextProvider: ContextProviderKind = .trusted + ) throws { var config = DashSDKConfig() config.network = network.ffiValue config.dapi_addresses = nil @@ -270,8 +285,8 @@ public final class SDK: @unchecked Sendable { // versions. A non-zero value is an explicit pin via `with_version`. config.platform_version = platformVersion - // Create SDK with trusted setup. DAPI / quorum-URL overrides come from - // UserDefaults and apply on: + // Create the SDK with the selected provider. DAPI / quorum-URL overrides + // come from UserDefaults and apply on: // // * Regtest unconditionally β€” the Rust side has no built-in DAPI // defaults for it, so we must supply addresses every time @@ -344,7 +359,14 @@ public final class SDK: @unchecked Sendable { var mutableConfig = config if let addressesCStr { mutableConfig.dapi_addresses = addressesCStr } if let quorumCStr { mutableConfig.quorum_url = quorumCStr } - return dash_sdk_create_trusted(&mutableConfig) + switch contextProvider { + case .trusted: + return dash_sdk_create_trusted(&mutableConfig) + case .spv: + return dash_sdk_create_spv(&mutableConfig) + case .adaptive: + return dash_sdk_create_adaptive(&mutableConfig) + } } // Check for errors diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift index db0c40c0ce7..6303bf34869 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift @@ -10,8 +10,8 @@ class AppState: ObservableObject { @Published var errorMessage = "" /// The quorum-key source the current SDK uses for proof verification. - /// Starts `.trusted` on every SDK build and tracks the adaptive provider's - /// current routing choice. Drives the app's indicator; + /// Tracks the adaptive provider's current routing choice after the saved + /// mode is applied. Drives the app's indicator; /// intentionally mirrors `SDK.quorumSource` (this one is the observable the /// UI binds to). @Published private(set) var quorumSource: SDK.QuorumSource = .trusted @@ -131,11 +131,15 @@ class AppState: ObservableObject { NSLog("πŸ”΅ AppState: Creating SDK for network=\(currentNetwork), docker=\(useDockerSetup)") // Build with one adaptive provider backed initially by trusted - // quorum data. The wallet manager populates its SPV source when - // it is configured from this SDK. - let newSDK = try SDK(network: currentNetwork) + // quorum data. The wallet manager leases its SPV source after + // synchronization starts successfully. + let newSDK = try SDK(network: currentNetwork, contextProvider: .adaptive) + // Apply strict SPV before publishing the SDK or starting any + // proven query. With no active SPV source yet, strict mode + // fails closed until manager startup acquires its lease. + try newSDK.setQuorumMode(sdkQuorumMode) sdk = newSDK - quorumSource = .trusted + quorumSource = newSDK.quorumSource NSLog("βœ… AppState: SDK created successfully") // Eagerly learn the network's protocol version so @@ -174,13 +178,7 @@ class AppState: ObservableObject { guard let sdk else { return } do { - let sdkMode: SDK.QuorumMode - switch quorumMode { - case .auto: sdkMode = .auto - case .spv: sdkMode = .spv - case .trusted: sdkMode = .trusted - } - try sdk.setQuorumMode(sdkMode) + try sdk.setQuorumMode(sdkQuorumMode) quorumSource = sdk.quorumSource } catch { NSLog("⚠️ AppState: failed to apply quorum mode: \(error.localizedDescription)") @@ -207,11 +205,12 @@ class AppState: ObservableObject { do { isLoading = true - // Create a new SDK instance for the network. Its manager populates - // the adaptive provider's SPV source during configuration. - let newSDK = try SDK(network: network) + // Create a new SDK instance for the network. Its manager leases + // the adaptive provider's SPV source after SPV starts. + let newSDK = try SDK(network: network, contextProvider: .adaptive) + try newSDK.setQuorumMode(sdkQuorumMode) sdk = newSDK - quorumSource = .trusted + quorumSource = newSDK.quorumSource // Eagerly learn the new network's protocol version (see // `initializeSDK`). Non-fatal: the SDK still ratchets from @@ -230,6 +229,14 @@ class AppState: ObservableObject { } } + private var sdkQuorumMode: SDK.QuorumMode { + switch quorumMode { + case .auto: return .auto + case .spv: return .spv + case .trusted: return .trusted + } + } + /// Kick off a network protocol-version refresh for `sdk` without /// blocking UI readiness. /// diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift index 4d647f9ed96..a2f832b3a1e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift @@ -172,7 +172,7 @@ final class WalletManagerStore: ObservableObject { if let existing = managers[network] { return existing } - let sdk = try SDK(network: network) + let sdk = try SDK(network: network, contextProvider: .adaptive) try activate(network: network, sdk: sdk, makeActive: false) guard let manager = managers[network] else { throw PlatformWalletError.invalidParameter( From 4961a0e7c57229ec2e5fed84cb5cedecc4c592de Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 16 Jul 2026 16:41:11 +0700 Subject: [PATCH 21/21] chore(sdk): remove unused HTTP dependency --- Cargo.lock | 3 --- packages/rs-sdk-ffi/Cargo.toml | 3 --- 2 files changed, 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f3b5dd14b3b..2eef6e0cc2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3499,7 +3499,6 @@ dependencies = [ "hyper", "hyper-util", "rustls", - "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", @@ -6044,7 +6043,6 @@ dependencies = [ "pin-project-lite", "quinn", "rustls", - "rustls-native-certs", "rustls-pki-types", "serde", "serde_json", @@ -6345,7 +6343,6 @@ dependencies = [ "log", "once_cell", "platform-encryption", - "reqwest 0.12.28", "rs-sdk-trusted-context-provider", "serde", "serde_json", diff --git a/packages/rs-sdk-ffi/Cargo.toml b/packages/rs-sdk-ffi/Cargo.toml index 2a7aaa9b53b..18c38d687ef 100644 --- a/packages/rs-sdk-ffi/Cargo.toml +++ b/packages/rs-sdk-ffi/Cargo.toml @@ -74,9 +74,6 @@ zeroize = "1.8" once_cell = "1.20" arc-swap = "1.7.1" -# HTTP client for diagnostics -reqwest = { version = "0.12", features = ["json", "rustls-tls-native-roots"] } - [build-dependencies] cbindgen = "0.27"