From f4703d6daf3d82078f5e3f79be3588328eabe394 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 31 Mar 2026 14:17:44 +0300 Subject: [PATCH 01/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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" From 984237a19c2791bf69885a26730b624992e9dfb6 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 20 Oct 2026 14:34:17 +0700 Subject: [PATCH 22/38] fix(rs-sdk-ffi): forward quorum-key fetches and contract registration through SPV-capable providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v5.1-dev's trusted provider no longer fetches a missing quorum key inside its synchronous lookup; the SDK asks the provider's fetch_quorum_public_key and verifies the response again. The adaptive provider did not forward that call, so in Auto (SPV not ready) and Trusted modes any quorum newer than the prefetched cache failed verification. It now forwards the fetch whenever it routes to the trusted provider, and returns None on the SPV route, so strict SPV still never accepts a trusted key. The SDK and platform-wallet now cache contracts they learn about through register_data_contract. The SPV and adaptive providers did not forward it to the local resolver they read contracts from, so those contracts could not be used to verify later proofs. Both forward it now. Test would have caught this: ✖ before the fix (fetches_missing_quorum_keys_only_on_the_trusted_route and registers_contracts_with_the_local_resolver fail), ✔ after. Co-Authored-By: Claude Opus 5.5 --- packages/rs-sdk-ffi/src/context_provider.rs | 136 +++++++++++++++++++- packages/rs-sdk-ffi/src/sdk.rs | 2 +- 2 files changed, 135 insertions(+), 3 deletions(-) diff --git a/packages/rs-sdk-ffi/src/context_provider.rs b/packages/rs-sdk-ffi/src/context_provider.rs index 0687f290239..4a24dd66c61 100644 --- a/packages/rs-sdk-ffi/src/context_provider.rs +++ b/packages/rs-sdk-ffi/src/context_provider.rs @@ -11,7 +11,7 @@ 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 drive_proof_verifier::{ContextProvider, QuorumKeyFuture}; use crate::context_callbacks::{CallbackContextProvider, ContextProviderCallbacks}; use crate::types::Network; @@ -188,6 +188,10 @@ impl AuxiliaryContextProvider { self.provider.get_data_contract(id, platform_version) } + fn register_data_contract(&self, contract: Arc) { + self.provider.register_data_contract(contract) + } + fn get_token_configuration( &self, token_id: &Identifier, @@ -244,6 +248,10 @@ impl ContextProvider for SpvContextProvider { self.auxiliary.get_data_contract(id, platform_version) } + fn register_data_contract(&self, contract: Arc) { + self.auxiliary.register_data_contract(contract) + } + fn get_token_configuration( &self, token_id: &Identifier, @@ -331,6 +339,24 @@ impl ContextProvider for AdaptiveContextProvider { } } + /// Only the trusted route can fetch a key missing from its cache. The SPV + /// route never fetches, so strict SPV never falls back to trusted keys. + fn fetch_quorum_public_key( + &self, + quorum_type: u32, + quorum_hash: [u8; 32], + core_chain_locked_height: u32, + ) -> Option { + match self.active_source() { + AdaptiveContextProviderSource::Trusted => self.trusted.fetch_quorum_public_key( + quorum_type, + quorum_hash, + core_chain_locked_height, + ), + AdaptiveContextProviderSource::Spv => None, + } + } + fn get_platform_activation_height(&self) -> Result { activation_height(self.network) } @@ -343,6 +369,10 @@ impl ContextProvider for AdaptiveContextProvider { self.auxiliary.get_data_contract(id, platform_version) } + fn register_data_contract(&self, contract: Arc) { + self.auxiliary.register_data_contract(contract) + } + fn get_token_configuration( &self, token_id: &Identifier, @@ -408,7 +438,7 @@ mod tests { 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 drive_proof_verifier::{ContextProvider, ContextProviderError, QuorumKeyFuture}; use super::{ AdaptiveContextProvider, AdaptiveContextProviderSource, AuxiliaryContextProvider, @@ -438,6 +468,16 @@ mod tests { Ok([self.key_byte; 48]) } + fn fetch_quorum_public_key( + &self, + _quorum_type: u32, + _quorum_hash: [u8; 32], + _height: u32, + ) -> Option { + let key = [self.key_byte; 48]; + Some(Box::pin(async move { Ok(Some(key)) })) + } + fn get_platform_activation_height(&self) -> Result { Ok(999) } @@ -636,4 +676,96 @@ mod tests { .to_string() .contains("auxiliary token route")); } + + /// The SDK fetches a quorum key missing from the trusted cache through + /// `fetch_quorum_public_key`. That fetch must follow the lookup's route: + /// a trusted route fetches from the trusted service, an SPV route never + /// does, or strict SPV would accept a trusted key after an SPV miss. + #[tokio::test] + async fn fetches_missing_quorum_keys_only_on_the_trusted_route() { + let (adaptive, spv) = adaptive(provider("trusted", 0x11), Network::Testnet); + let fetch = + |adaptive: &AdaptiveContextProvider| adaptive.fetch_quorum_public_key(1, [2; 32], 3); + + let fetched = fetch(&adaptive).expect("Auto without SPV fetches from trusted"); + assert_eq!(fetched.await.unwrap(), Some([0x11; 48])); + + adaptive.set_mode(ContextProviderMode::Spv); + assert!( + fetch(&adaptive).is_none(), + "SPV without a source must not fetch" + ); + + let _lease = spv + .acquire(provider("spv", 0x22), Arc::new(|| Ok(true))) + .unwrap(); + assert!(fetch(&adaptive).is_none(), "SPV must not fetch"); + + adaptive.set_mode(ContextProviderMode::Auto); + assert!( + fetch(&adaptive).is_none(), + "Auto on ready SPV must not fetch" + ); + + adaptive.set_mode(ContextProviderMode::Trusted); + let fetched = fetch(&adaptive).expect("Trusted fetches"); + assert_eq!(fetched.await.unwrap(), Some([0x11; 48])); + } + + /// Contracts the SDK learns about (for example after a contract fetch) + /// must reach the local resolver that SPV and Adaptive read contracts + /// from; otherwise proofs over those contracts cannot be verified. + #[test] + fn registers_contracts_with_the_local_resolver() { + use dash_sdk::dpp::data_contract::accessors::v0::{ + DataContractV0Getters, DataContractV0Setters, + }; + use dash_sdk::dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + + let contract = Arc::new( + load_system_data_contract(SystemDataContract::DPNS, PlatformVersion::latest()) + .expect("DPNS contract"), + ); + let mut unknown = Arc::unwrap_or_clone(Arc::clone(&contract)); + unknown.set_id(Identifier::new([0x5a; 32])); + let unknown = Arc::new(unknown); + let id = unknown.id(); + + let trusted: Arc = Arc::new( + crate::sdk::build_trusted_provider( + Network::Regtest, + Some("http://127.0.0.1:1".to_string()), + ) + .unwrap_or_else(|_| panic!("trusted provider")), + ); + let auxiliary = Arc::new(AuxiliaryContextProvider::new(Arc::clone(&trusted))); + let controller = Arc::new(SpvSourceController::default()); + let fixed = SpvContextProvider::new( + Arc::clone(&controller), + Arc::clone(&auxiliary), + Network::Regtest, + ); + let adaptive = + AdaptiveContextProvider::new(trusted, controller, auxiliary, Network::Regtest); + + assert!(fixed + .get_data_contract(&id, PlatformVersion::latest()) + .unwrap() + .is_none()); + fixed.register_data_contract(Arc::clone(&unknown)); + assert_eq!( + fixed + .get_data_contract(&id, PlatformVersion::latest()) + .unwrap() + .map(|contract| contract.id()), + Some(id) + ); + assert_eq!( + adaptive + .get_data_contract(&id, PlatformVersion::latest()) + .unwrap() + .map(|contract| contract.id()), + Some(id) + ); + } } diff --git a/packages/rs-sdk-ffi/src/sdk.rs b/packages/rs-sdk-ffi/src/sdk.rs index ef38426f584..090fc7ee3c7 100644 --- a/packages/rs-sdk-ffi/src/sdk.rs +++ b/packages/rs-sdk-ffi/src/sdk.rs @@ -568,7 +568,7 @@ const TRUSTED_PROVIDER_CACHE_SIZE: std::num::NonZeroUsize = match std::num::NonZ /// fetched asynchronously by the SDK, which then verifies the response it /// already holds again: proof verification never blocks on the network, and /// concurrent misses share one refresh. -fn build_trusted_provider( +pub(crate) fn build_trusted_provider( network: Network, quorum_url: Option, ) -> Result { From 6eaa7a5f4fe125908eb2a008675292ca8d754a42 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 20 Oct 2026 14:35:03 +0700 Subject: [PATCH 23/38] fix(sdk): import Arc in the read_contract example The example shares its context provider with the SDK through an Arc but never imported it, so `cargo test --all-targets` failed to build it. No test: compile-only fix with no behaviour change. Co-Authored-By: Claude Opus 5.5 --- packages/rs-sdk/examples/read_contract.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rs-sdk/examples/read_contract.rs b/packages/rs-sdk/examples/read_contract.rs index 6e3206d96e5..80fc64e4bb8 100644 --- a/packages/rs-sdk/examples/read_contract.rs +++ b/packages/rs-sdk/examples/read_contract.rs @@ -1,4 +1,4 @@ -use std::{num::NonZeroUsize, str::FromStr}; +use std::{num::NonZeroUsize, str::FromStr, sync::Arc}; use clap::Parser; use dash_sdk::{mock::provider::GrpcContextProvider, platform::Fetch, Sdk, SdkBuilder}; From 44c17ea9872cf6f034dc1a534e7ed5b077388ce4 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 20 Oct 2026 14:38:35 +0700 Subject: [PATCH 24/38] fix(sdk-ffi): do not ban nodes while the SPV quorum source is unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v5.1-dev's SDK bans the node whose proof failed for any quorum-key error other than QuorumSourceUnavailable. Strict SPV reported an inactive or still-syncing SPV source as a Generic error, so during sync every response banned the node that sent it. The source controller now reports both states as QuorumSourceUnavailable, and SpvQuorumSource maps SPV lookup failures through PlatformWalletError::quorum_lookup_context_error, so an unverified quorum is not held against the node either. Test would have caught this: ✖ before the fix (reports_an_unavailable_spv_source_without_blaming_the_node fails), ✔ after. The unverified-quorum mapping is covered by platform-wallet's own quorum_lookup_context_error test; reaching that status through SpvQuorumSource needs a synced dash-spv engine. Co-Authored-By: Claude Opus 5.5 --- .../src/spv_context_provider.rs | 3 +- packages/rs-sdk-ffi/src/context_provider.rs | 41 ++++++++++++++++++- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet/src/spv_context_provider.rs b/packages/rs-platform-wallet/src/spv_context_provider.rs index 02b008a5a8f..66d3d471f30 100644 --- a/packages/rs-platform-wallet/src/spv_context_provider.rs +++ b/packages/rs-platform-wallet/src/spv_context_provider.rs @@ -126,7 +126,7 @@ impl ContextProvider for SpvQuorumSource { quorum_hash, core_chain_locked_height, ))? - .map_err(|e| ContextProviderError::InvalidQuorum(e.to_string())); + .map_err(|e| e.quorum_lookup_context_error()); // The quorum public key is the trust root of every Platform proof this // SDK verifies; tracing which provider served it lets an operator @@ -183,3 +183,4 @@ impl ContextProvider for SpvQuorumSource { Ok(None) } } + diff --git a/packages/rs-sdk-ffi/src/context_provider.rs b/packages/rs-sdk-ffi/src/context_provider.rs index 4a24dd66c61..1f921420488 100644 --- a/packages/rs-sdk-ffi/src/context_provider.rs +++ b/packages/rs-sdk-ffi/src/context_provider.rs @@ -136,16 +136,22 @@ impl SpvSourceController { }); } + /// An inactive or unready source is reported as + /// [`ContextProviderError::QuorumSourceUnavailable`]: it is this side's + /// state, so the SDK must not ban the node whose proof could not be + /// checked. fn source(&self) -> Result, ContextProviderError> { self.source.load_full().ok_or_else(|| { - ContextProviderError::Generic("SPV context source is not active".to_string()) + ContextProviderError::QuorumSourceUnavailable( + "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( + return Err(ContextProviderError::QuorumSourceUnavailable( "SPV context source is not ready".to_string(), )); } @@ -768,4 +774,35 @@ mod tests { Some(id) ); } + + /// Strict SPV fails closed while the SPV source is inactive or still + /// syncing. That is the local source's state, not the responding node's + /// fault, so the error must be `QuorumSourceUnavailable`: the SDK bans + /// nodes for any other quorum-key failure. + #[test] + fn reports_an_unavailable_spv_source_without_blaming_the_node() { + let auxiliary = Arc::new(AuxiliaryContextProvider::new(provider("auxiliary", 0x11))); + let controller = Arc::new(SpvSourceController::default()); + let fixed = SpvContextProvider::new(Arc::clone(&controller), auxiliary, Network::Testnet); + let (adaptive, adaptive_controller) = adaptive(provider("trusted", 0x11), Network::Testnet); + adaptive.set_mode(ContextProviderMode::Spv); + + let unavailable = |result: Result<[u8; 48], ContextProviderError>| { + matches!( + result, + Err(ContextProviderError::QuorumSourceUnavailable(_)) + ) + }; + assert!(unavailable(fixed.get_quorum_public_key(1, [2; 32], 3))); + assert!(unavailable(quorum_key(&adaptive))); + + let _lease = controller + .acquire(provider("spv", 0x22), Arc::new(|| Ok(false))) + .unwrap(); + let _adaptive_lease = adaptive_controller + .acquire(provider("spv", 0x22), Arc::new(|| Ok(false))) + .unwrap(); + assert!(unavailable(fixed.get_quorum_public_key(1, [2; 32], 3))); + assert!(unavailable(quorum_key(&adaptive))); + } } From 4438fc9a31be017228ea7ac9ea446b4eea876fc0 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 20 Oct 2026 14:38:35 +0700 Subject: [PATCH 25/38] test(platform-wallet): exercise the SPV quorum bridge on every Tokio runtime flavor The SPV quorum source bridges the synchronous ContextProvider call to the async SPV runtime. Outside a runtime and on a current-thread runtime it must refuse rather than panic in block_in_place or starve the SPV writer; on a multi-threaded runtime it must reach the SPV runtime. It must also refuse quorum types other than the network's Platform quorum. Each case now has a test built on an unstarted SpvRuntime. Co-Authored-By: Claude Opus 5.5 --- .../src/spv_context_provider.rs | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/packages/rs-platform-wallet/src/spv_context_provider.rs b/packages/rs-platform-wallet/src/spv_context_provider.rs index 66d3d471f30..6a6db68990f 100644 --- a/packages/rs-platform-wallet/src/spv_context_provider.rs +++ b/packages/rs-platform-wallet/src/spv_context_provider.rs @@ -184,3 +184,90 @@ impl ContextProvider for SpvQuorumSource { } } +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use dash_context_provider::{ContextProvider, ContextProviderError}; + use dashcore::sml::llmq_type::network::NetworkLLMQExt; + use dashcore::Network; + use key_wallet_manager::WalletManager; + use tokio::sync::RwLock; + + use super::SpvQuorumSource; + use crate::events::PlatformEventManager; + use crate::spv::SpvRuntime; + use crate::wallet::platform_wallet::PlatformWalletInfo; + + const NETWORK: Network = Network::Testnet; + + fn unstarted_source() -> SpvQuorumSource { + let wallet_manager = Arc::new(RwLock::new(WalletManager::::new( + NETWORK, + ))); + let runtime = SpvRuntime::new(wallet_manager, Arc::new(PlatformEventManager::new(vec![]))); + SpvQuorumSource::new(Arc::new(runtime), NETWORK) + } + + fn platform_quorum_type() -> u32 { + u32::from(u8::from(NETWORK.platform_type())) + } + + /// Proof verification calls the provider synchronously. Outside a Tokio + /// runtime nothing can drive the SPV lookup, so it must fail instead of + /// panicking. + #[test] + fn should_refuse_a_lookup_outside_a_tokio_runtime() { + let error = unstarted_source().is_ready().unwrap_err(); + assert!( + error.to_string().contains("outside a Tokio runtime"), + "{error}" + ); + } + + /// `block_in_place` panics on a current-thread runtime, and blocking that + /// thread any other way would starve the SPV writer the lookup waits on, + /// so the bridge must refuse. + #[test] + fn should_refuse_a_lookup_on_a_current_thread_runtime() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current-thread runtime"); + let source = unstarted_source(); + let error = runtime.block_on(async { source.is_ready() }).unwrap_err(); + assert!( + error.to_string().contains("multi-threaded Tokio runtime"), + "{error}" + ); + } + + /// On the multi-threaded runtime the SDK verifies proofs on, the bridge + /// reaches the SPV runtime: an unstarted client is not ready and serves no + /// key. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn should_reach_the_spv_runtime_from_a_multi_thread_runtime() { + let source = unstarted_source(); + assert!(!source.is_ready().expect("readiness")); + let error = source + .get_quorum_public_key(platform_quorum_type(), [7; 32], 1) + .unwrap_err(); + assert!(error.to_string().contains("not started"), "{error}"); + } + + /// The proof names its own quorum type, so only the network's Platform + /// quorum type may be looked up: a lower-threshold quorum must never + /// vouch for Platform state. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn should_reject_a_quorum_type_other_than_the_platform_one() { + let other_type = platform_quorum_type() + 1; + let error = unstarted_source() + .get_quorum_public_key(other_type, [7; 32], 1) + .unwrap_err(); + assert!( + matches!(error, ContextProviderError::InvalidQuorum(ref message) + if message.contains("not the Platform quorum")), + "{error}" + ); + } +} From 053c198e9080fd97aaa34d38e1dca4d2fb5678c2 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 20 Oct 2026 15:27:08 +0700 Subject: [PATCH 26/38] fix(swift-example-app): move the wallet manager onto the SDK a Docker toggle rebuilds Toggling "Use Docker Setup" on regtest rebuilt the SDK twice (from AppState.useDockerSetup's didSet and from the toggle's onChange) and never re-activated the wallet manager. The manager kept its clone of the old SDK: the old DAPI endpoints and the old adaptive provider, so a quorum-mode change reached the new SDK but not the manager's proof verification. - The devnet rebuild sequence (stop SPV, rebuild, activate the manager, apply the quorum mode, rebind the wallet-scoped services) is now one helper. The devnet flow and the Docker toggle both use it. Activation errors are logged and shown instead of dropped by try?. - The Docker toggle writes the setting only after SPV has stopped, as the network picker does. A failed stop leaves the setting and the SDK unchanged. Toggling Docker now stops SPV; the help text says to restart it from the Wallet tab. - AppState.useDockerSetup no longer rebuilds the SDK itself. - WalletManagerStore keeps the SDK its cached manager was built against and compares identity: comparing raw handles let a new SDK allocated at a freed SDK's address pass as the cached one. No test: SwiftExampleApp has no UI test harness for Options flows. Verified by the CI Swift build; manual check on the simulator is to toggle Docker on regtest and see one manager_cache_invalidated then manager_materialized in the logs. Co-Authored-By: Claude Opus 5.5 --- .../SwiftExampleApp/AppState.swift | 9 +- .../SwiftExampleApp/Views/OptionsView.swift | 146 +++++++++++------- .../SwiftExampleApp/WalletManagerStore.swift | 15 +- 3 files changed, 101 insertions(+), 69 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift index a75ed1f8237..4df5cea0cc4 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift @@ -53,12 +53,9 @@ class AppState: ObservableObject { UserDefaults.standard.set(useDockerSetup, forKey: "useLocalhostPlatform") UserDefaults.standard.set(useDockerSetup, forKey: "useLocalhostCore") UserDefaults.standard.set(useDockerSetup, forKey: "useLocalhost") - // Docker endpoints only matter on regtest. The Options picker turns - // Docker off after it has published the next network, whose own - // `currentNetwork` rebuild then picks the flag up; rebuilding here - // too would race that rebuild. - guard currentNetwork == .regtest else { return } - Task { await switchNetwork(to: currentNetwork) } + // The SDK reads these keys when it is built. OptionsView's Docker + // toggle rebuilds it, and moves the wallet manager onto it, after + // changing this. } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift index dfeb8bc0c85..4700d555cc3 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift @@ -181,12 +181,10 @@ struct OptionsView: View { appState.currentNetwork = newNetwork devnetRebuildPending = false - // Auto-disable Docker when leaving Local, after - // the network is published and in the same - // main-actor turn: `useDockerSetup` then skips - // its own SDK rebuild (the network's rebuild - // picks the flag up), and the Docker toggle's - // `onChange` cannot start a second switch. + // Auto-disable Docker when leaving Local, in + // the same main-actor turn the network is + // published, so the new network's SDK build + // reads the cleared flag. if newNetwork != .regtest && appState.useDockerSetup { appState.useDockerSetup = false } @@ -222,29 +220,36 @@ struct OptionsView: View { ) if appState.currentNetwork == .regtest { - Toggle("Use Docker Setup", isOn: $appState.useDockerSetup) - // A network switch away from regtest keeps this - // section on screen while its stop runs; the - // toggle stays off-limits until the switch ends. - .disabled(isSwitchingNetwork) - .onChange(of: appState.useDockerSetup) { _, _ in - // A switch in progress owns `isSwitchingNetwork` - // and rebuilds the SDK itself; taking and - // releasing the flag here would re-enable the - // picker while that switch still runs. - guard !isSwitchingNetwork else { return } - // Toggling Docker rebuilds the SDK and flips - // the peer override; cancel any in-flight - // start so it can't proceed with stale peer - // config against the rebuilt SDK. + Toggle("Use Docker Setup", isOn: Binding( + get: { appState.useDockerSetup }, + set: { useDocker in + guard useDocker != appState.useDockerSetup, + !isSwitchingNetwork else { return } + // Docker changes the SDK's endpoints and the + // SPV peers. The setting is written only after + // SPV has stopped, so a running client never + // disagrees with it; a failed stop leaves it + // unchanged. Cancel any in-flight start first: + // it captured the old peer config. walletManagerStore.invalidatePendingSpvStarts() isSwitchingNetwork = true + let network = appState.currentNetwork Task { - await appState.switchNetwork(to: appState.currentNetwork) - await MainActor.run { isSwitchingNetwork = false } + defer { isSwitchingNetwork = false } + _ = await rebuildSdk( + on: network, + failurePrefix: "Failed to stop SPV, so the Docker setting was not changed" + ) { + appState.useDockerSetup = useDocker + } } } - .help("Connect to local dashmate Docker network.") + )) + // A network switch away from regtest keeps this + // section on screen while its stop runs; the + // toggle stays off-limits until the switch ends. + .disabled(isSwitchingNetwork) + .help("Connect to local dashmate Docker network. Stops SPV; restart it from the Wallet tab.") if appState.useDockerSetup { TextField("Faucet RPC Password", text: $faucetPassword) @@ -361,41 +366,12 @@ struct OptionsView: View { isSwitchingNetwork = true Task { defer { isSwitchingNetwork = false } - guard await stopSpvForReconfiguration( + let outcome = await rebuildSdk( + on: .devnet, failurePrefix: "Failed to stop SPV, so the devnet settings were not applied" - ) else { + ) + if outcome == .stopFailed { devnetRebuildPending = true - return - } - // Re-checked after the stop's suspension. - guard appState.currentNetwork == .devnet else { return } - await appState.switchNetwork(to: .devnet) - // `switchNetwork` rebuilds `appState.sdk` but - // doesn't refresh per-network managers (the - // app-level `.onChange(of: currentNetwork)` - // observer doesn't fire when the value stays - // `.devnet`). Re-`activate` here so the - // store's stale-handle check fires and the - // cached `PlatformWalletManager` rebuilds - // against the new SDK clone — otherwise - // wallet-manager-routed work keeps talking - // to the old DAPI / quorum endpoints. - await MainActor.run { - if let sdk = appState.sdk { - try? walletManagerStore.activate( - network: .devnet, sdk: sdk - ) - appState.applyQuorumMode() - } - // Drive `rebindWalletScopedServices` - // via the App scene's observer. - // Neither `currentNetwork` nor the - // wallet ID set changes here, so - // PlatformBalanceSyncService and - // ShieldedService would otherwise - // keep referencing the now-stale - // manager. - appState.walletScopedServicesRebindTick &+= 1 } } } @@ -685,6 +661,62 @@ struct OptionsView: View { } } + private enum SdkRebuildOutcome { + /// SPV did not stop; nothing was applied or rebuilt. + case stopFailed + /// The network changed while SPV stopped; nothing was rebuilt. + case networkChanged + /// The rebuild ran. Its failures were already shown to the user. + case finished + } + + /// Rebuild the SDK for `network` without changing networks, and move the + /// wallet manager and the wallet-scoped services onto it. + /// + /// SPV stops first. `apply` then runs, so settings the rebuild reads never + /// disagree with a running SPV client. `switchNetwork` alone would leave + /// the cached manager on its clone of the old SDK (old endpoints, old + /// quorum policy), and the app-level `currentNetwork` observer does not + /// fire for a same-network rebuild. Callers own + /// `invalidatePendingSpvStarts()` and `isSwitchingNetwork`. + @MainActor + private func rebuildSdk( + on network: Network, + failurePrefix: String, + apply: () -> Void = {} + ) async -> SdkRebuildOutcome { + guard await stopSpvForReconfiguration(failurePrefix: failurePrefix) else { + return .stopFailed + } + // Re-checked after the stop's suspension. + guard appState.currentNetwork == network else { return .networkChanged } + apply() + await appState.switchNetwork(to: network) + // `switchNetwork` reports its own failure and leaves no SDK. + guard let sdk = appState.sdk else { return .finished } + do { + try walletManagerStore.activate(network: network, sdk: sdk) + } catch { + SDKLogger.event( + "manager_activation_failed", + category: .lifecycle, + severity: .error, + fields: ["network": .publicText(network.displayName)], + error: error + ) + appState.showError( + message: "The SDK was rebuilt, but the wallet manager could not move to it: \(error.localizedDescription)" + ) + return .finished + } + appState.applyQuorumMode() + // Drive `rebindWalletScopedServices` through the App scene's + // observer: neither `currentNetwork` nor the wallet ID set changes + // here, so the wallet-scoped services would keep the old manager. + appState.walletScopedServicesRebindTick &+= 1 + return .finished + } + /// Stop SPV before a flow replaces or reconfigures it. Returns false, /// after showing `failurePrefix` with the error, only when the stop may /// have left the old run loop behind. A manager that is not configured, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift index ed413a695fb..e7243b8baf9 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift @@ -112,7 +112,7 @@ final class WalletManagerStore: ObservableObject { /// of each network; lookup is O(1). private var managers: [Network: PlatformWalletManager] = [:] - /// SDK handle pointer the cached manager was configured against. + /// SDK the cached manager was configured against. /// Used in `activate()` to detect a stale cache when `AppState` /// rebuilds the SDK (network switch / `platformDAPIAddresses` or /// `platformQuorumURL` change in Options). On mismatch we tear @@ -120,7 +120,11 @@ final class WalletManagerStore: ObservableObject { /// otherwise the cached manager keeps using its own Sdk clone /// with stale DAPI / quorum endpoints, and proof verification /// fails forever ("no available addresses to use"). - private var managerSdkHandles: [Network: OpaquePointer] = [:] + /// + /// Held strongly and compared by identity: comparing raw handles + /// would let a new SDK allocated at a freed SDK's address pass as + /// the cached one. + private var managerSdks: [Network: SDK] = [:] /// SwiftData container shared across every manager. Each /// manager's persistence handler narrows its `loadWalletList` @@ -160,8 +164,7 @@ final class WalletManagerStore: ObservableObject { // failing proof verification. Drop it so the rebuild below // picks up the fresh SDK. if let existing = managers[network] { - let cachedHandle = managerSdkHandles[network] - if cachedHandle == sdk.handle { + if managerSdks[network] === sdk { if makeActive && existing !== activeManager { // Swapping to a different warm manager supersedes any // in-flight SPV start against the outgoing one. @@ -188,7 +191,7 @@ final class WalletManagerStore: ObservableObject { // now-stale cached manager, but it'll be replaced before // any caller observes it (this method is synchronous). managers[network] = nil - managerSdkHandles[network] = nil + managerSdks[network] = nil } let manager = PlatformWalletManager() try manager.configure(sdk: sdk, modelContainer: modelContainer) @@ -210,7 +213,7 @@ final class WalletManagerStore: ObservableObject { ) } managers[network] = manager - managerSdkHandles[network] = sdk.handle + managerSdks[network] = sdk if makeActive { // A freshly (re)built active manager supersedes any in-flight // SPV start against the previous one (defensive backstop; the From e38a3d97a3b8ba5e65404ba7a811c31ff037ec93 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 20 Oct 2026 15:27:52 +0700 Subject: [PATCH 27/38] fix(swift-sdk): report the quorum source of a strict SPV SDK SDK.quorumSource was a stored property set only after a successful setQuorumMode. A strict SPV SDK rejects mode changes, so it reported .trusted for its whole lifetime. It now reads the native provider's source on every access, which also tracks an adaptive SDK in Auto mode as SPV becomes ready. The new SwiftDashSDKTests case builds a strict SPV SDK on regtest and expects .spv. Not run locally: this host has no Xcode, so CI's Swift job is the first run. Against the old code it fails by construction, since the stored default was .trusted and nothing assigned it. Co-Authored-By: Claude Opus 5.5 --- .../swift-sdk/Sources/SwiftDashSDK/SDK.swift | 10 +++++++--- .../SDKQuorumSourceTests.swift | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SDKQuorumSourceTests.swift diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift index c736557416d..bb50ed5ede6 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift @@ -403,8 +403,13 @@ public final class SDK: @unchecked Sendable { case trusted = 2 } - /// The quorum source selected at the most recent mode application. - public private(set) var quorumSource: QuorumSource = .trusted + /// The quorum source proof verification uses right now: fixed for a + /// Trusted or SPV SDK, and following SPV readiness for an Adaptive SDK in + /// Auto mode. + public var quorumSource: QuorumSource { + guard let handle else { return .trusted } + return dash_sdk_get_context_provider_source(handle) == 1 ? .spv : .trusted + } /// Update quorum routing policy without replacing the SDK context provider. public func setQuorumMode(_ mode: QuorumMode) throws { @@ -418,7 +423,6 @@ public final class SDK: @unchecked Sendable { defer { dash_sdk_error_free(result.error) } throw SDKError.internalError("Failed to set quorum mode: \(message)") } - quorumSource = dash_sdk_get_context_provider_source(handle) == 1 ? .spv : .trusted } /// Offline SDK over the FFI mock (`dash_sdk_create_handle_with_mock`), for diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SDKQuorumSourceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SDKQuorumSourceTests.swift new file mode 100644 index 00000000000..bbbb1ec7c11 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SDKQuorumSourceTests.swift @@ -0,0 +1,18 @@ +import XCTest + +@testable import SwiftDashSDK + +/// `SDK.quorumSource` must report the source the native provider actually +/// uses. A strict SPV SDK can never switch modes, so a value remembered from +/// the last mode change would show `.trusted` for its whole lifetime. +/// +/// Regtest needs no network to build: its DAPI and quorum endpoints are +/// loopback defaults, and a strict SPV SDK does not prefetch trusted quorums. +final class SDKQuorumSourceTests: XCTestCase { + + func testShouldReportSpvForAStrictSpvSdk() throws { + let sdk = try SDK(network: .regtest, contextProvider: .spv) + + XCTAssertEqual(sdk.quorumSource, .spv) + } +} From 0cb49ba6de3207440d59628d8b131b60d009bf23 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 20 Oct 2026 17:05:09 +0700 Subject: [PATCH 28/38] feat(sdk): add context providers that route quorum keys between SPV and a trusted source dash-context-provider gains a `routing` module with the providers the iOS and Android SDKs build on, so they live in a library instead of the FFI crate: - SpvContextProvider takes quorum keys only from an SPV client. - AdaptiveContextProvider routes each lookup to the SPV client or a trusted provider by a mode (Auto, Spv, Trusted) that can change at run time. A missing key is fetched only on the trusted route. - Both read contracts and token configurations from a ContractSource, which has no quorum keys. - SpvSourceSlot holds the SPV client: whoever runs it claims the slot, then publishes its quorum lookups through the lease. Only one lease holds a slot at a time. Every "no usable SPV source" outcome (none published, not synced, readiness unknown) is QuorumSourceUnavailable, so the SDK does not ban the node whose proof it could not check. platform_activation_height gives the one activation-height table. Co-Authored-By: Claude Opus 5.5 --- packages/rs-context-provider/Cargo.toml | 6 +- packages/rs-context-provider/src/lib.rs | 11 +- packages/rs-context-provider/src/provider.rs | 11 + packages/rs-context-provider/src/routing.rs | 801 +++++++++++++++++++ 4 files changed, 825 insertions(+), 4 deletions(-) create mode 100644 packages/rs-context-provider/src/routing.rs diff --git a/packages/rs-context-provider/Cargo.toml b/packages/rs-context-provider/Cargo.toml index 1d288cf6b96..dfcfdf01ba5 100644 --- a/packages/rs-context-provider/Cargo.toml +++ b/packages/rs-context-provider/Cargo.toml @@ -4,9 +4,10 @@ version.workspace = true edition = "2021" authors = ["sam@dash.org"] license = "MIT" -description = "Context provider traits for Dash Platform SDK" +description = "Context provider traits and quorum-source routing for Dash Platform SDK" [dependencies] +arc-swap = "1.7.1" dash-async = { path = "../rs-dash-async" } dpp = { path = "../rs-dpp", default-features = false } drive = { path = "../rs-drive", default-features = false, features = [ @@ -16,5 +17,8 @@ thiserror = "1.0" hex = { version = "0.4", optional = true } serde_json = { version = "1.0", optional = true } +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt"] } + [features] mocks = ["hex", "serde_json", "dpp/serde-conversion"] diff --git a/packages/rs-context-provider/src/lib.rs b/packages/rs-context-provider/src/lib.rs index 14a01f38315..8f0644828fe 100644 --- a/packages/rs-context-provider/src/lib.rs +++ b/packages/rs-context-provider/src/lib.rs @@ -1,13 +1,18 @@ -//! Context provider traits for Dash Platform SDK +//! Context providers for Dash Platform SDK //! //! This crate provides the core traits for context providers that are used -//! to fetch network state, data contracts, and quorum public keys. +//! to fetch network state, data contracts, and quorum public keys, and the +//! [`routing`] providers that choose between an SPV client and a trusted +//! provider for quorum keys. pub mod error; pub mod provider; +pub mod routing; pub use error::ContextProviderError; -pub use provider::{ContextProvider, DataContractProvider, QuorumKeyFuture}; +pub use provider::{ + platform_activation_height, ContextProvider, DataContractProvider, QuorumKeyFuture, +}; #[cfg(feature = "mocks")] pub use provider::MockContextProvider; diff --git a/packages/rs-context-provider/src/provider.rs b/packages/rs-context-provider/src/provider.rs index 9e31f4bffaf..8b62774e6c2 100644 --- a/packages/rs-context-provider/src/provider.rs +++ b/packages/rs-context-provider/src/provider.rs @@ -1,4 +1,5 @@ use crate::error::ContextProviderError; +use dpp::dashcore::Network; use dpp::data_contract::TokenConfiguration; use dpp::prelude::{CoreBlockHeight, DataContract, Identifier}; use dpp::version::PlatformVersion; @@ -19,6 +20,16 @@ use { pub type QuorumKeyFuture = Pin, ContextProviderError>> + Send + 'static>>; +/// The Core height at which Platform activated (its L1 locked height) on +/// `network`. +pub fn platform_activation_height(network: Network) -> CoreBlockHeight { + match network { + Network::Mainnet => 2_132_092, + Network::Testnet => 1_090_319, + Network::Devnet | Network::Regtest => 1, + } +} + /// Interface between the Sdk and state of the application. /// /// ContextProvider is called by the [FromProof](crate::FromProof) trait (and similar) to get information about diff --git a/packages/rs-context-provider/src/routing.rs b/packages/rs-context-provider/src/routing.rs new file mode 100644 index 00000000000..80e3c426a0b --- /dev/null +++ b/packages/rs-context-provider/src/routing.rs @@ -0,0 +1,801 @@ +//! Context providers that choose where quorum keys come from. +//! +//! [`SpvContextProvider`] takes quorum keys only from an SPV client. +//! [`AdaptiveContextProvider`] routes each lookup to an SPV client or to a +//! trusted provider by a mode that can change while the SDK runs. Both read +//! data contracts and token configurations from a [`ContractSource`], which has +//! no quorum keys to fall back on. +//! +//! The SPV client is not known when the SDK is built. Each provider owns an +//! [`SpvSourceSlot`] slot; whoever runs the SPV client claims it with +//! [`SpvSourceSlot::claim`] and publishes its quorum keys through the +//! returned [`SpvSourceLease`]. Until then, and once the lease is dropped, an +//! SPV lookup fails with [`ContextProviderError::QuorumSourceUnavailable`]. + +use std::sync::atomic::{AtomicU64, AtomicU8, Ordering}; +use std::sync::{Arc, Weak}; + +use arc_swap::ArcSwapOption; +use dpp::dashcore::Network; +use dpp::data_contract::TokenConfiguration; +use dpp::prelude::{CoreBlockHeight, DataContract, Identifier}; +use dpp::version::PlatformVersion; + +use crate::{platform_activation_height, ContextProvider, ContextProviderError, QuorumKeyFuture}; + +/// The data contracts and token configurations a provider resolves locally. +/// +/// It has no quorum keys, so a provider reading contracts from it cannot answer +/// a quorum lookup from the same place. +pub trait ContractSource: Send + Sync { + /// The data contract with `id`, if this source has it. + fn get_data_contract( + &self, + id: &Identifier, + platform_version: &PlatformVersion, + ) -> Result>, ContextProviderError>; + + /// The token configuration of `token_id`, if this source has it. + fn get_token_configuration( + &self, + token_id: &Identifier, + ) -> Result, ContextProviderError>; + + /// Keep `contract` so later proofs over it can be verified. + fn register_data_contract(&self, contract: Arc); +} + +/// Where an [`AdaptiveContextProvider`] takes quorum keys from. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u8)] +pub enum ContextProviderMode { + /// SPV once its source is published and synced, trusted until then. + Auto = 0, + /// SPV only; lookups fail until its source is published and synced. + Spv = 1, + /// The trusted provider only. + Trusted = 2, +} + +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(()), + } + } +} + +/// The source an [`AdaptiveContextProvider`] would ask right now. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u8)] +pub enum AdaptiveContextProviderSource { + /// The trusted provider. + Trusted = 0, + /// The SPV client. + Spv = 1, +} + +/// Whether a published SPV client is synced enough to answer quorum lookups. +pub type Readiness = Arc Result + Send + Sync>; + +/// A published SPV client: its quorum lookups and whether it is synced. +#[derive(Clone)] +struct SpvContextSource { + provider: Arc, + is_ready: Readiness, +} + +/// One lease's hold on the slot; `source` is set once the holder publishes. +struct SlotClaim { + lease_id: u64, + source: Option, +} + +/// The slot an SPV client publishes its quorum keys into. +/// +/// Only one lease holds the slot at a time, so two SPV clients can never both +/// answer for the same SDK. +pub struct SpvSourceSlot { + claim: ArcSwapOption, + next_lease_id: AtomicU64, +} + +impl Default for SpvSourceSlot { + fn default() -> Self { + Self { + claim: ArcSwapOption::empty(), + next_lease_id: AtomicU64::new(1), + } + } +} + +/// Returned by [`SpvSourceSlot::claim`] when another lease holds the +/// slot. +#[derive(Debug, thiserror::Error)] +#[error("another SPV client already serves this SDK's quorum keys")] +pub struct SpvSourceSlotTaken; + +impl SpvSourceSlot { + /// Claim the slot. Only an empty slot can be claimed; the lease empties it + /// again when dropped. + pub fn claim(self: &Arc) -> Result { + let lease_id = self.next_lease_id.fetch_add(1, Ordering::Relaxed); + let claim = Arc::new(SlotClaim { + lease_id, + source: None, + }); + let previous = self + .claim + .compare_and_swap(std::ptr::null::(), Some(claim)); + if previous.is_some() { + return Err(SpvSourceSlotTaken); + } + Ok(SpvSourceLease { + slot: Arc::downgrade(self), + lease_id, + }) + } + + fn publish(&self, lease_id: u64, source: SpvContextSource) { + self.claim.rcu(|current| match current { + Some(claim) if claim.lease_id == lease_id => Some(Arc::new(SlotClaim { + lease_id, + source: Some(source.clone()), + })), + _ => current.clone(), + }); + } + + fn release(&self, lease_id: u64) { + self.claim.rcu(|current| match current { + Some(claim) if claim.lease_id == lease_id => None, + _ => current.clone(), + }); + } + + /// The published source, if it is synced. + /// + /// Every "no usable source" outcome, an unknown readiness included, is + /// [`ContextProviderError::QuorumSourceUnavailable`]: it is this side's + /// state, so the SDK must not ban the node whose proof it could not check. + fn ready_source(&self) -> Result { + let source = self + .claim + .load_full() + .and_then(|claim| claim.source.clone()) + .ok_or_else(|| { + ContextProviderError::QuorumSourceUnavailable( + "SPV context source is not active".to_string(), + ) + })?; + match (source.is_ready)() { + Ok(true) => Ok(source), + Ok(false) => Err(ContextProviderError::QuorumSourceUnavailable( + "SPV context source is not ready".to_string(), + )), + Err(error) => Err(ContextProviderError::QuorumSourceUnavailable(format!( + "SPV context source readiness is unknown: {error}" + ))), + } + } + + fn is_ready(&self) -> bool { + self.ready_source().is_ok() + } +} + +/// A claim on an [`SpvSourceSlot`] slot. Dropping it empties the slot, +/// unless a later lease holds it by then. +pub struct SpvSourceLease { + slot: Weak, + lease_id: u64, +} + +impl SpvSourceLease { + /// Serve `provider`'s quorum keys while `is_ready` reports it synced. + pub fn publish(&self, provider: Arc, is_ready: Readiness) { + if let Some(slot) = self.slot.upgrade() { + slot.publish(self.lease_id, SpvContextSource { provider, is_ready }); + } + } +} + +impl Drop for SpvSourceLease { + fn drop(&mut self) { + if let Some(slot) = self.slot.upgrade() { + slot.release(self.lease_id); + } + } +} + +/// Strict SPV: quorum keys come only from the SPV client published into +/// [`Self::source_slot`]; contracts and tokens come from a [`ContractSource`]. +pub struct SpvContextProvider { + spv: Arc, + contracts: Arc, + network: Network, +} + +impl SpvContextProvider { + /// A provider whose quorum lookups fail until an SPV client is published + /// into its slot. + pub fn new(contracts: Arc, network: Network) -> Self { + Self { + spv: Arc::default(), + contracts, + network, + } + } + + /// The slot an SPV client publishes its quorum keys into. + pub fn source_slot(&self) -> Arc { + Arc::clone(&self.spv) + } +} + +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 { + Ok(platform_activation_height(self.network)) + } + + fn get_data_contract( + &self, + id: &Identifier, + platform_version: &PlatformVersion, + ) -> Result>, ContextProviderError> { + self.contracts.get_data_contract(id, platform_version) + } + + fn register_data_contract(&self, contract: Arc) { + self.contracts.register_data_contract(contract) + } + + fn get_token_configuration( + &self, + token_id: &Identifier, + ) -> Result, ContextProviderError> { + self.contracts.get_token_configuration(token_id) + } +} + +/// Routes each quorum lookup to an SPV client or a trusted provider by +/// [`ContextProviderMode`]; contracts and tokens come from a +/// [`ContractSource`]. +pub struct AdaptiveContextProvider { + trusted: Arc, + spv: Arc, + contracts: Arc, + mode: AtomicU8, + network: Network, +} + +impl AdaptiveContextProvider { + /// A provider in [`ContextProviderMode::Auto`] that answers from `trusted` + /// until an SPV client is published into its slot and synced. + pub fn new( + trusted: Arc, + contracts: Arc, + network: Network, + ) -> Self { + Self { + trusted, + spv: Arc::default(), + contracts, + mode: AtomicU8::new(ContextProviderMode::Auto as u8), + network, + } + } + + /// The slot an SPV client publishes its quorum keys into. + pub fn source_slot(&self) -> Arc { + Arc::clone(&self.spv) + } + + /// Change where later lookups go. + pub fn set_mode(&self, mode: ContextProviderMode) { + self.mode.store(mode as u8, Ordering::Release); + } + + /// Where lookups go. + pub fn mode(&self) -> ContextProviderMode { + ContextProviderMode::try_from(self.mode.load(Ordering::Acquire)) + .expect("context-provider mode is only written from validated values") + } + + /// The source a lookup would ask right now. + pub fn active_source(&self) -> AdaptiveContextProviderSource { + match self.mode() { + ContextProviderMode::Trusted => AdaptiveContextProviderSource::Trusted, + ContextProviderMode::Spv => AdaptiveContextProviderSource::Spv, + ContextProviderMode::Auto if self.spv.is_ready() => AdaptiveContextProviderSource::Spv, + ContextProviderMode::Auto => AdaptiveContextProviderSource::Trusted, + } + } +} + +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> { + match self.mode() { + ContextProviderMode::Trusted => self.trusted.get_quorum_public_key( + quorum_type, + quorum_hash, + core_chain_locked_height, + ), + ContextProviderMode::Spv => self.spv.ready_source()?.provider.get_quorum_public_key( + quorum_type, + quorum_hash, + core_chain_locked_height, + ), + ContextProviderMode::Auto => match self.spv.ready_source() { + Ok(source) => source.provider.get_quorum_public_key( + quorum_type, + quorum_hash, + core_chain_locked_height, + ), + Err(_) => self.trusted.get_quorum_public_key( + quorum_type, + quorum_hash, + core_chain_locked_height, + ), + }, + } + } + + /// Only the trusted route can fetch a key missing from its cache. The SPV + /// route never fetches, so SPV mode never falls back to trusted keys. + fn fetch_quorum_public_key( + &self, + quorum_type: u32, + quorum_hash: [u8; 32], + core_chain_locked_height: u32, + ) -> Option { + match self.active_source() { + AdaptiveContextProviderSource::Trusted => self.trusted.fetch_quorum_public_key( + quorum_type, + quorum_hash, + core_chain_locked_height, + ), + AdaptiveContextProviderSource::Spv => None, + } + } + + fn get_platform_activation_height(&self) -> Result { + Ok(platform_activation_height(self.network)) + } + + fn get_data_contract( + &self, + id: &Identifier, + platform_version: &PlatformVersion, + ) -> Result>, ContextProviderError> { + self.contracts.get_data_contract(id, platform_version) + } + + fn register_data_contract(&self, contract: Arc) { + self.contracts.register_data_contract(contract) + } + + fn get_token_configuration( + &self, + token_id: &Identifier, + ) -> Result, ContextProviderError> { + self.contracts.get_token_configuration(token_id) + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + + use dpp::dashcore::Network; + use dpp::data_contract::TokenConfiguration; + use dpp::prelude::{CoreBlockHeight, DataContract, Identifier}; + use dpp::version::PlatformVersion; + + use super::{ + AdaptiveContextProvider, AdaptiveContextProviderSource, ContextProviderMode, + ContractSource, SpvContextProvider, SpvSourceLease, SpvSourceSlot, + }; + use crate::{ContextProvider, ContextProviderError, QuorumKeyFuture}; + + /// A quorum source answering every lookup with `[key_byte; 48]`, or failing. + struct TestQuorums { + name: &'static str, + key_byte: u8, + fail: bool, + } + + impl ContextProvider for TestQuorums { + fn get_quorum_public_key( + &self, + _quorum_type: u32, + _quorum_hash: [u8; 32], + _height: u32, + ) -> Result<[u8; 48], ContextProviderError> { + if self.fail { + return Err(ContextProviderError::InvalidQuorum(format!( + "{} quorum miss", + self.name + ))); + } + Ok([self.key_byte; 48]) + } + + fn fetch_quorum_public_key( + &self, + _quorum_type: u32, + _quorum_hash: [u8; 32], + _height: u32, + ) -> Option { + let key = [self.key_byte; 48]; + Some(Box::pin(async move { Ok(Some(key)) })) + } + + 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 + ))) + } + } + + /// A contract source whose reads fail with a recognisable message. + struct TestContracts; + + impl ContractSource for TestContracts { + fn get_data_contract( + &self, + _id: &Identifier, + _platform_version: &PlatformVersion, + ) -> Result>, ContextProviderError> { + Err(ContextProviderError::Generic( + "local contract route".to_string(), + )) + } + + fn get_token_configuration( + &self, + _token_id: &Identifier, + ) -> Result, ContextProviderError> { + Err(ContextProviderError::Generic( + "local token route".to_string(), + )) + } + + fn register_data_contract(&self, _contract: Arc) {} + } + + fn quorums(name: &'static str, key_byte: u8) -> Arc { + Arc::new(TestQuorums { + name, + key_byte, + fail: false, + }) + } + + fn failing_quorums(name: &'static str) -> Arc { + Arc::new(TestQuorums { + name, + key_byte: 0, + fail: true, + }) + } + + fn adaptive() -> AdaptiveContextProvider { + AdaptiveContextProvider::new( + quorums("trusted", 0x11), + Arc::new(TestContracts), + Network::Testnet, + ) + } + + fn quorum_key(provider: &impl ContextProvider) -> Result<[u8; 48], ContextProviderError> { + provider.get_quorum_public_key(1, [2; 32], 3) + } + + /// Claim `slot` and publish `provider` into it, with `ready` as its + /// readiness. + fn publish( + slot: &Arc, + provider: Arc, + ready: bool, + ) -> SpvSourceLease { + let lease = slot.claim().expect("empty slot"); + lease.publish(provider, Arc::new(move || Ok(ready))); + lease + } + + fn is_unavailable(result: Result<[u8; 48], ContextProviderError>) -> bool { + matches!( + result, + Err(ContextProviderError::QuorumSourceUnavailable(_)) + ) + } + + #[test] + fn should_route_quorum_lookups_by_mode_and_readiness() { + let adaptive = adaptive(); + let slot = adaptive.source_slot(); + + assert_eq!(quorum_key(&adaptive).unwrap(), [0x11; 48]); + assert_eq!( + adaptive.active_source(), + AdaptiveContextProviderSource::Trusted + ); + + adaptive.set_mode(ContextProviderMode::Spv); + assert!(is_unavailable(quorum_key(&adaptive))); + + let ready = Arc::new(AtomicBool::new(false)); + let ready_for_callback = Arc::clone(&ready); + let lease = slot.claim().unwrap(); + lease.publish( + quorums("spv", 0x22), + Arc::new(move || Ok(ready_for_callback.load(Ordering::Acquire))), + ); + assert!( + is_unavailable(quorum_key(&adaptive)), + "unready SPV must fail closed" + ); + assert_eq!(adaptive.active_source(), AdaptiveContextProviderSource::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(), + AdaptiveContextProviderSource::Trusted + ); + ready.store(true, Ordering::Release); + assert_eq!(quorum_key(&adaptive).unwrap(), [0x22; 48]); + assert_eq!(adaptive.active_source(), AdaptiveContextProviderSource::Spv); + } + + #[test] + fn should_read_contracts_and_tokens_from_the_contract_source_in_every_mode() { + let adaptive = adaptive(); + let _lease = publish(&adaptive.source_slot(), quorums("spv", 0x22), true); + 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("local contract route")); + assert!(token_error.contains("local token route")); + } + } + + #[test] + fn should_let_one_lease_hold_the_slot_until_it_is_dropped() { + let slot = Arc::new(SpvSourceSlot::default()); + let lease = publish(&slot, quorums("spv", 0x22), true); + assert!(slot.claim().is_err()); + + drop(lease); + let _replacement = publish(&slot, quorums("other", 0x33), true); + assert_eq!( + slot.ready_source() + .unwrap() + .provider + .get_quorum_public_key(1, [2; 32], 3) + .unwrap(), + [0x33; 48] + ); + } + + #[test] + fn should_not_retry_a_ready_spv_miss_against_trusted() { + let adaptive = adaptive(); + let _lease = publish(&adaptive.source_slot(), failing_quorums("spv"), true); + + let error = quorum_key(&adaptive).unwrap_err().to_string(); + assert!(error.contains("spv quorum miss")); + } + + #[test] + fn should_answer_strict_spv_lookups_only_from_the_published_source() { + let fixed = SpvContextProvider::new(Arc::new(TestContracts), Network::Testnet); + let slot = fixed.source_slot(); + + assert!(is_unavailable(quorum_key(&fixed))); + let lease = slot.claim().unwrap(); + assert!( + is_unavailable(quorum_key(&fixed)), + "a claimed slot serves nothing until it is published" + ); + lease.publish(quorums("spv", 0x22), Arc::new(|| Ok(true))); + assert_eq!(quorum_key(&fixed).unwrap(), [0x22; 48]); + assert!(fixed.fetch_quorum_public_key(1, [2; 32], 3).is_none()); + + let id = Identifier::new([7; 32]); + assert!(fixed + .get_data_contract(&id, PlatformVersion::latest()) + .unwrap_err() + .to_string() + .contains("local contract route")); + assert!(fixed + .get_token_configuration(&id) + .unwrap_err() + .to_string() + .contains("local token route")); + } + + /// The SDK fetches a quorum key missing from the trusted cache through + /// `fetch_quorum_public_key`. That fetch must follow the lookup's route: + /// a trusted route fetches from the trusted service, an SPV route never + /// does, or SPV mode would accept a trusted key after an SPV miss. + #[tokio::test] + async fn should_fetch_missing_quorum_keys_only_on_the_trusted_route() { + let adaptive = adaptive(); + let fetch = + |adaptive: &AdaptiveContextProvider| adaptive.fetch_quorum_public_key(1, [2; 32], 3); + + let fetched = fetch(&adaptive).expect("Auto without SPV fetches from trusted"); + assert_eq!(fetched.await.unwrap(), Some([0x11; 48])); + + adaptive.set_mode(ContextProviderMode::Spv); + assert!( + fetch(&adaptive).is_none(), + "SPV without a source must not fetch" + ); + + let _lease = publish(&adaptive.source_slot(), quorums("spv", 0x22), true); + assert!(fetch(&adaptive).is_none(), "SPV must not fetch"); + + adaptive.set_mode(ContextProviderMode::Auto); + assert!( + fetch(&adaptive).is_none(), + "Auto on ready SPV must not fetch" + ); + + adaptive.set_mode(ContextProviderMode::Trusted); + let fetched = fetch(&adaptive).expect("Trusted fetches"); + assert_eq!(fetched.await.unwrap(), Some([0x11; 48])); + } + + /// While the SPV source is missing, unready, or cannot say whether it is + /// ready, the failure is this side's, so it must be + /// `QuorumSourceUnavailable`: the SDK bans nodes for any other quorum-key + /// error. + #[test] + fn should_report_an_unusable_spv_source_without_blaming_the_node() { + let fixed = SpvContextProvider::new(Arc::new(TestContracts), Network::Testnet); + let adaptive = adaptive(); + adaptive.set_mode(ContextProviderMode::Spv); + + assert!(is_unavailable(quorum_key(&fixed))); + assert!(is_unavailable(quorum_key(&adaptive))); + + let unready = publish(&fixed.source_slot(), quorums("spv", 0x22), false); + let adaptive_unready = publish(&adaptive.source_slot(), quorums("spv", 0x22), false); + assert!(is_unavailable(quorum_key(&fixed))); + assert!(is_unavailable(quorum_key(&adaptive))); + drop((unready, adaptive_unready)); + + let failing_readiness = |slot: &Arc| { + let lease = slot.claim().unwrap(); + lease.publish( + quorums("spv", 0x22), + Arc::new(|| Err(ContextProviderError::Generic("no runtime".to_string()))), + ); + lease + }; + let _fixed_lease = failing_readiness(&fixed.source_slot()); + let _adaptive_lease = failing_readiness(&adaptive.source_slot()); + assert!(is_unavailable(quorum_key(&fixed))); + assert!(is_unavailable(quorum_key(&adaptive))); + } + + /// The empty-slot swap is the only thing keeping two SPV clients from + /// answering for one SDK, so concurrent claims must yield exactly one + /// lease. + #[test] + fn should_grant_exactly_one_of_concurrent_claims() { + let slot = Arc::new(SpvSourceSlot::default()); + let barrier = Arc::new(std::sync::Barrier::new(8)); + let claims: Vec<_> = (0..8) + .map(|_| { + let slot = Arc::clone(&slot); + let barrier = Arc::clone(&barrier); + std::thread::spawn(move || { + barrier.wait(); + slot.claim().ok() + }) + }) + .collect(); + let leases: Vec<_> = claims + .into_iter() + .filter_map(|claim| claim.join().expect("claim thread")) + .collect(); + assert_eq!(leases.len(), 1); + } + + /// An SDK may be destroyed before the wallet manager holding its slot's + /// lease; publishing through or dropping that lease must then do nothing. + #[test] + fn should_ignore_a_lease_whose_slot_is_gone() { + let slot = Arc::new(SpvSourceSlot::default()); + let lease = slot.claim().expect("empty slot"); + drop(slot); + + lease.publish(quorums("spv", 0x22), Arc::new(|| Ok(true))); + drop(lease); + } + + /// Auto mode treats an SPV client that cannot say whether it is synced as + /// not ready, and answers from the trusted provider. + #[test] + fn should_fall_back_to_trusted_in_auto_mode_when_readiness_is_unknown() { + let adaptive = adaptive(); + let lease = adaptive.source_slot().claim().expect("empty slot"); + lease.publish( + quorums("spv", 0x22), + Arc::new(|| Err(ContextProviderError::Generic("no runtime".to_string()))), + ); + + assert_eq!( + adaptive.active_source(), + AdaptiveContextProviderSource::Trusted + ); + assert_eq!(quorum_key(&adaptive).expect("trusted key"), [0x11; 48]); + } +} From ec916538b8a4dcf488eff821185fd632116a01a8 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 20 Oct 2026 17:05:09 +0700 Subject: [PATCH 29/38] refactor(sdk): extract the trusted provider's local contract cache LocalContextResolver holds what TrustedHttpContextProvider served without a network: the known contracts, the known token configurations and the feature- and protocol-gated system contracts. The trusted provider keeps one behind an Arc and delegates to it, so its public methods, its lookup order (known, system, then the fallback provider) and the sharing between clones are unchanged; the existing tests pass as they were. A provider without a trusted quorum endpoint can now use the resolver on its own; it implements ContractSource. The trusted provider takes its activation height from dash-context-provider's shared table, whose values are the same. Co-Authored-By: Claude Opus 5.5 --- .../src/lib.rs | 2 + .../src/local.rs | 420 ++++++++++++++++++ .../src/provider.rs | 302 +++---------- 3 files changed, 486 insertions(+), 238 deletions(-) create mode 100644 packages/rs-sdk-trusted-context-provider/src/local.rs diff --git a/packages/rs-sdk-trusted-context-provider/src/lib.rs b/packages/rs-sdk-trusted-context-provider/src/lib.rs index 6512ed5140a..2f1cedb000b 100644 --- a/packages/rs-sdk-trusted-context-provider/src/lib.rs +++ b/packages/rs-sdk-trusted-context-provider/src/lib.rs @@ -9,11 +9,13 @@ //! - **Devnet**: Uses `https://quorums..networks.dash.org/` pub mod error; +pub mod local; pub mod provider; mod quorum_refresh; pub mod types; pub use error::TrustedContextProviderError; +pub use local::LocalContextResolver; pub use provider::TrustedHttpContextProvider; use dpp::dashcore::Network; diff --git a/packages/rs-sdk-trusted-context-provider/src/local.rs b/packages/rs-sdk-trusted-context-provider/src/local.rs new file mode 100644 index 00000000000..89802b1c6bd --- /dev/null +++ b/packages/rs-sdk-trusted-context-provider/src/local.rs @@ -0,0 +1,420 @@ +//! Contracts and token configurations a context provider answers for +//! without a network. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, PoisonError}; + +use dash_context_provider::routing::ContractSource; +use dash_context_provider::ContextProviderError; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::TokenConfiguration; +use dpp::prelude::{DataContract, Identifier}; +#[cfg(any( + feature = "dpns-contract", + feature = "dashpay-contract", + feature = "withdrawals-contract", + feature = "wallet-utils-contract", + feature = "token-history-contract", + feature = "keywords-contract", + feature = "document-history-contract", + feature = "app-connect-contract", + feature = "moderation-charters-contract", + feature = "all-system-contracts" +))] +use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; +#[cfg(any(feature = "app-connect-contract", feature = "all-system-contracts"))] +use dpp::version::feature_initial_protocol_versions::APP_CONNECT_CONTRACT_INITIAL_PROTOCOL_VERSION; +#[cfg(any( + feature = "moderation-charters-contract", + feature = "all-system-contracts" +))] +use dpp::version::feature_initial_protocol_versions::MODERATION_CHARTERS_CONTRACT_INITIAL_PROTOCOL_VERSION; +use dpp::version::PlatformVersion; + +/// The data contracts and token configurations a context provider can serve +/// without a network: the ones registered with it, and the system contracts +/// this crate is built with, each from the protocol version that introduced it. +/// +/// It has no quorum keys. A provider that verifies proofs pairs it with a +/// quorum source; [`TrustedHttpContextProvider`](crate::TrustedHttpContextProvider) +/// holds one, and a provider without a trusted quorum endpoint can use one on +/// its own. Share it through an `Arc`. +#[derive(Debug, Default)] +pub struct LocalContextResolver { + /// Contracts served before the system contracts. + known_contracts: Mutex>>, + /// Token configurations needed for proof verification. + known_token_configurations: Mutex>, +} + +impl LocalContextResolver { + /// An empty resolver; it serves only the system contracts. + pub fn new() -> Self { + Self::default() + } + + /// Add a data contract to the known contracts cache + pub fn add_known_contract(&self, contract: DataContract) { + self.add_known_data_contract(Arc::new(contract)); + } + + /// Add a shared data contract to the known contracts cache. + pub fn add_known_data_contract(&self, contract: Arc) { + let id = contract.id(); + let mut known = self + .known_contracts + .lock() + .unwrap_or_else(PoisonError::into_inner); + known.insert(id, contract); + } + + /// Insert a contract only when its version is strictly newer, returning + /// the retained entry. Equal versions preserve the existing body. + pub fn add_known_contract_if_newer(&self, contract: DataContract) -> Arc { + let id = contract.id(); + let mut known = self + .known_contracts + .lock() + .unwrap_or_else(PoisonError::into_inner); + if let Some(existing) = known.get(&id) { + if existing.version() >= contract.version() { + return existing.clone(); + } + } + let contract = Arc::new(contract); + known.insert(id, contract.clone()); + contract + } + + /// Get a data contract from the known contracts cache + /// Returns None if the contract is not in the cache + pub fn get_known_contract(&self, id: &Identifier) -> Option> { + let known = self + .known_contracts + .lock() + .unwrap_or_else(PoisonError::into_inner); + known.get(id).cloned() + } + + /// Remove a data contract from the known contracts cache + /// Returns true if the contract was present and removed, false otherwise + pub fn remove_known_contract(&self, id: &Identifier) -> bool { + let mut known = self + .known_contracts + .lock() + .unwrap_or_else(PoisonError::into_inner); + known.remove(id).is_some() + } + + /// Add multiple data contracts to the known contracts cache + pub fn add_known_contracts(&self, contracts: Vec) { + let mut known = self + .known_contracts + .lock() + .unwrap_or_else(PoisonError::into_inner); + for contract in contracts { + let id = contract.id(); + known.insert(id, Arc::new(contract)); + } + } + + /// Add a token configuration to the known token configurations cache + pub fn add_known_token_configuration(&self, token_id: Identifier, config: TokenConfiguration) { + let mut known = self + .known_token_configurations + .lock() + .unwrap_or_else(PoisonError::into_inner); + known.insert(token_id, config); + } + + /// Add multiple token configurations to the known token configurations cache + pub fn add_known_token_configurations(&self, configs: Vec<(Identifier, TokenConfiguration)>) { + let mut known = self + .known_token_configurations + .lock() + .unwrap_or_else(PoisonError::into_inner); + for (token_id, config) in configs { + known.insert(token_id, config); + } + } + + /// The contract registered under `id`, else the system contract with that + /// id if `platform_version` has it, else `None`. + pub fn data_contract( + &self, + id: &Identifier, + platform_version: &PlatformVersion, + ) -> Result>, ContextProviderError> { + // First check known contracts cache + if let Some(contract) = self.get_known_contract(id) { + return Ok(Some(contract)); + } + + // Check if this is a system data contract and the corresponding feature is enabled + #[cfg(any( + feature = "dpns-contract", + feature = "dashpay-contract", + feature = "withdrawals-contract", + feature = "wallet-utils-contract", + feature = "token-history-contract", + feature = "keywords-contract", + feature = "document-history-contract", + feature = "app-connect-contract", + feature = "moderation-charters-contract", + feature = "all-system-contracts" + ))] + { + // Check each system contract if its feature is enabled + #[cfg(any(feature = "dpns-contract", feature = "all-system-contracts"))] + if *id == SystemDataContract::DPNS.id() { + return load_system_data_contract(SystemDataContract::DPNS, platform_version) + .map(|contract| Some(Arc::new(contract))) + .map_err(|e| { + ContextProviderError::Generic(format!( + "Failed to load DPNS contract: {}", + e + )) + }); + } + + #[cfg(any(feature = "dashpay-contract", feature = "all-system-contracts"))] + if *id == SystemDataContract::Dashpay.id() { + return load_system_data_contract(SystemDataContract::Dashpay, platform_version) + .map(|contract| Some(Arc::new(contract))) + .map_err(|e| { + ContextProviderError::Generic(format!( + "Failed to load Dashpay contract: {}", + e + )) + }); + } + + #[cfg(any(feature = "withdrawals-contract", feature = "all-system-contracts"))] + if *id == SystemDataContract::Withdrawals.id() { + return load_system_data_contract( + SystemDataContract::Withdrawals, + platform_version, + ) + .map(|contract| Some(Arc::new(contract))) + .map_err(|e| { + ContextProviderError::Generic(format!( + "Failed to load Withdrawals contract: {}", + e + )) + }); + } + + #[cfg(any(feature = "wallet-utils-contract", feature = "all-system-contracts"))] + if *id == SystemDataContract::WalletUtils.id() { + return load_system_data_contract( + SystemDataContract::WalletUtils, + platform_version, + ) + .map(|contract| Some(Arc::new(contract))) + .map_err(|e| { + ContextProviderError::Generic(format!( + "Failed to load WalletUtils contract: {}", + e + )) + }); + } + + #[cfg(any(feature = "token-history-contract", feature = "all-system-contracts"))] + if *id == SystemDataContract::TokenHistory.id() { + return load_system_data_contract( + SystemDataContract::TokenHistory, + platform_version, + ) + .map(|contract| Some(Arc::new(contract))) + .map_err(|e| { + ContextProviderError::Generic(format!( + "Failed to load TokenHistory contract: {}", + e + )) + }); + } + + #[cfg(any(feature = "keywords-contract", feature = "all-system-contracts"))] + if *id == SystemDataContract::KeywordSearch.id() { + return load_system_data_contract( + SystemDataContract::KeywordSearch, + platform_version, + ) + .map(|contract| Some(Arc::new(contract))) + .map_err(|e| { + ContextProviderError::Generic(format!( + "Failed to load KeywordSearch contract: {}", + e + )) + }); + } + + #[cfg(any( + feature = "document-history-contract", + feature = "all-system-contracts" + ))] + if *id == SystemDataContract::DocumentHistory.id() { + return load_system_data_contract( + SystemDataContract::DocumentHistory, + platform_version, + ) + .map(|contract| Some(Arc::new(contract))) + .map_err(|e| { + ContextProviderError::Generic(format!( + "Failed to load DocumentHistory contract: {}", + e + )) + }); + } + + #[cfg(any(feature = "app-connect-contract", feature = "all-system-contracts"))] + // Below protocol version 14 the app-connect contract is + // absent, so the lookup returns `None`. + if *id == SystemDataContract::AppConnect.id() + && platform_version.protocol_version + >= APP_CONNECT_CONTRACT_INITIAL_PROTOCOL_VERSION + { + return load_system_data_contract(SystemDataContract::AppConnect, platform_version) + .map(|contract| Some(Arc::new(contract))) + .map_err(|e| { + ContextProviderError::Generic(format!( + "Failed to load AppConnect contract: {}", + e + )) + }); + } + + #[cfg(any( + feature = "moderation-charters-contract", + feature = "all-system-contracts" + ))] + // Below protocol version 14 the moderation charters contract is absent too. + if *id == SystemDataContract::ModerationCharters.id() + && platform_version.protocol_version + >= MODERATION_CHARTERS_CONTRACT_INITIAL_PROTOCOL_VERSION + { + return load_system_data_contract( + SystemDataContract::ModerationCharters, + platform_version, + ) + .map(|contract| Some(Arc::new(contract))) + .map_err(|e| { + ContextProviderError::Generic(format!( + "Failed to load ModerationCharters contract: {}", + e + )) + }); + } + } + + // Only the system contracts read the version, and a build may have none. + let _ = platform_version; + Ok(None) + } + + /// The token configuration registered under `token_id`, if any. + pub fn token_configuration(&self, token_id: &Identifier) -> Option { + let known = self + .known_token_configurations + .lock() + .unwrap_or_else(PoisonError::into_inner); + known.get(token_id).cloned() + } +} + +impl ContractSource for LocalContextResolver { + fn get_data_contract( + &self, + id: &Identifier, + platform_version: &PlatformVersion, + ) -> Result>, ContextProviderError> { + self.data_contract(id, platform_version) + } + + fn get_token_configuration( + &self, + token_id: &Identifier, + ) -> Result, ContextProviderError> { + Ok(self.token_configuration(token_id)) + } + + fn register_data_contract(&self, contract: Arc) { + self.add_known_data_contract(contract) + } +} + +#[cfg(all(test, any(feature = "dpns-contract", feature = "all-system-contracts")))] +mod tests { + use std::sync::Arc; + + use dpp::data_contract::accessors::v0::{DataContractV0Getters, DataContractV0Setters}; + use dpp::prelude::{DataContract, Identifier}; + use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dpp::version::PlatformVersion; + + use super::LocalContextResolver; + + /// The DPNS contract under an id no system contract has. + fn app_contract() -> DataContract { + let mut contract = + load_system_data_contract(SystemDataContract::DPNS, PlatformVersion::latest()) + .expect("DPNS contract"); + contract.set_id(Identifier::new([0x5a; 32])); + contract + } + + #[test] + fn should_serve_registered_and_system_contracts_without_a_network() { + let resolver = LocalContextResolver::new(); + let contract = app_contract(); + let id = contract.id(); + let version = PlatformVersion::latest(); + + assert!(resolver.data_contract(&id, version).unwrap().is_none()); + resolver.add_known_data_contract(Arc::new(contract)); + assert_eq!( + resolver + .data_contract(&id, version) + .unwrap() + .map(|contract| contract.id()), + Some(id) + ); + + let dpns = SystemDataContract::DPNS.id(); + assert_eq!( + resolver + .data_contract(&dpns, version) + .unwrap() + .map(|contract| contract.id()), + Some(dpns) + ); + } + + /// Token proofs are verified against the token configuration the + /// resolver holds, so a registered configuration must be served back. + #[test] + fn should_serve_a_registered_token_configuration() { + use dpp::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; + use dpp::data_contract::TokenConfiguration; + + let resolver = LocalContextResolver::new(); + let token_id = Identifier::new([0x7c; 32]); + assert!(resolver.token_configuration(&token_id).is_none()); + + let config = TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive()); + resolver.add_known_token_configuration(token_id, config.clone()); + assert_eq!(resolver.token_configuration(&token_id), Some(config)); + } + + #[test] + fn should_keep_a_newer_contract_when_an_older_one_is_offered() { + let resolver = LocalContextResolver::new(); + let mut newer = app_contract(); + newer.set_version(2); + let older = app_contract(); + + resolver.add_known_contract_if_newer(newer); + let kept = resolver.add_known_contract_if_newer(older); + assert_eq!(kept.version(), 2); + } +} diff --git a/packages/rs-sdk-trusted-context-provider/src/provider.rs b/packages/rs-sdk-trusted-context-provider/src/provider.rs index 9e68e6ebf8a..fa393522f94 100644 --- a/packages/rs-sdk-trusted-context-provider/src/provider.rs +++ b/packages/rs-sdk-trusted-context-provider/src/provider.rs @@ -1,39 +1,20 @@ use crate::error::TrustedContextProviderError; use crate::get_quorum_base_url; +use crate::local::LocalContextResolver; use crate::quorum_refresh::{ contained, sleep, FetchedQuorums, QuorumRefreshes, QUORUM_LIST_REQUEST_TIMEOUT, }; use crate::types::{PreviousQuorumsResponse, QuorumData, QuorumsResponse}; use arc_swap::ArcSwap; +use dash_context_provider::platform_activation_height; use dash_context_provider::{ContextProvider, ContextProviderError, QuorumKeyFuture}; use dpp::bls::PublicKey; -use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::prelude::{CoreBlockHeight, DataContract, Identifier}; // QuorumHash is just [u8; 32] type QuorumHash = [u8; 32]; use dpp::dashcore::Network; use dpp::data_contract::TokenConfiguration; -#[cfg(any( - feature = "dpns-contract", - feature = "dashpay-contract", - feature = "withdrawals-contract", - feature = "wallet-utils-contract", - feature = "token-history-contract", - feature = "keywords-contract", - feature = "document-history-contract", - feature = "app-connect-contract", - feature = "moderation-charters-contract", - feature = "all-system-contracts" -))] -use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; -#[cfg(any(feature = "app-connect-contract", feature = "all-system-contracts"))] -use dpp::version::feature_initial_protocol_versions::APP_CONNECT_CONTRACT_INITIAL_PROTOCOL_VERSION; -#[cfg(any( - feature = "moderation-charters-contract", - feature = "all-system-contracts" -))] -use dpp::version::feature_initial_protocol_versions::MODERATION_CHARTERS_CONTRACT_INITIAL_PROTOCOL_VERSION; use dpp::version::PlatformVersion; use futures::future::{BoxFuture, FutureExt}; @@ -42,7 +23,6 @@ use reqwest::Client; use serde::Deserialize; #[cfg(test)] use std::cell::RefCell; -use std::collections::HashMap; use std::error::Error as StdError; #[cfg(all( not(target_arch = "wasm32"), @@ -91,11 +71,9 @@ pub struct TrustedHttpContextProvider { /// Optional fallback provider for data contracts and token configurations fallback_provider: Option>, - /// Known contracts cache - contracts that are pre-loaded and can be served immediately - known_contracts: Arc>>>, - - /// Known token configurations cache - token configs that are pre-loaded for proof verification - known_token_configurations: Arc>>, + /// Known contracts, token configurations and system contracts, shared by + /// clones and by whoever holds [`Self::local_resolver`]. + local: Arc, /// Whether to refetch quorums if not found in cache refetch_if_not_found: bool, @@ -281,8 +259,7 @@ impl TrustedHttpContextProvider { last_current_quorums: Arc::new(ArcSwap::new(Arc::new(None))), last_previous_quorums: Arc::new(ArcSwap::new(Arc::new(None))), fallback_provider: None, - known_contracts: Arc::new(Mutex::new(HashMap::new())), - known_token_configurations: Arc::new(Mutex::new(HashMap::new())), + local: Arc::default(), refetch_if_not_found: true, quorum_refreshes: Arc::default(), }) @@ -296,12 +273,7 @@ impl TrustedHttpContextProvider { /// Set known contracts that will be served immediately without fallback pub fn with_known_contracts(self, contracts: Vec) -> Self { - let mut known = self.known_contracts.lock().unwrap(); - for contract in contracts { - let id = contract.id(); - known.insert(id, Arc::new(contract)); - } - drop(known); + self.local.add_known_contracts(contracts); self } @@ -313,61 +285,46 @@ impl TrustedHttpContextProvider { /// Add a data contract to the known contracts cache pub fn add_known_contract(&self, contract: DataContract) { - let id = contract.id(); - let mut known = self.known_contracts.lock().unwrap(); - known.insert(id, Arc::new(contract)); + self.local.add_known_contract(contract); } /// Insert a contract only when its version is strictly newer, returning /// the retained entry. Equal versions preserve the existing body. pub fn add_known_contract_if_newer(&self, contract: DataContract) -> Arc { - let id = contract.id(); - let mut known = self.known_contracts.lock().unwrap(); - if let Some(existing) = known.get(&id) { - if existing.version() >= contract.version() { - return existing.clone(); - } - } - let contract = Arc::new(contract); - known.insert(id, contract.clone()); - contract + self.local.add_known_contract_if_newer(contract) } /// Get a data contract from the known contracts cache /// Returns None if the contract is not in the cache pub fn get_known_contract(&self, id: &Identifier) -> Option> { - let known = self.known_contracts.lock().unwrap(); - known.get(id).cloned() + self.local.get_known_contract(id) } /// Remove a data contract from the known contracts cache /// Returns true if the contract was present and removed, false otherwise pub fn remove_known_contract(&self, id: &Identifier) -> bool { - let mut known = self.known_contracts.lock().unwrap(); - known.remove(id).is_some() + self.local.remove_known_contract(id) } /// Add multiple data contracts to the known contracts cache pub fn add_known_contracts(&self, contracts: Vec) { - let mut known = self.known_contracts.lock().unwrap(); - for contract in contracts { - let id = contract.id(); - known.insert(id, Arc::new(contract)); - } + self.local.add_known_contracts(contracts); } /// Add a token configuration to the known token configurations cache pub fn add_known_token_configuration(&self, token_id: Identifier, config: TokenConfiguration) { - let mut known = self.known_token_configurations.lock().unwrap(); - known.insert(token_id, config); + self.local.add_known_token_configuration(token_id, config); } /// Add multiple token configurations to the known token configurations cache pub fn add_known_token_configurations(&self, configs: Vec<(Identifier, TokenConfiguration)>) { - let mut known = self.known_token_configurations.lock().unwrap(); - for (token_id, config) in configs { - known.insert(token_id, config); - } + self.local.add_known_token_configurations(configs); + } + + /// The contracts and token configurations this provider serves without a + /// network. The returned resolver shares this provider's caches. + pub fn local_resolver(&self) -> Arc { + Arc::clone(&self.local) } /// Update the quorum caches by fetching current and previous quorums. @@ -1032,168 +989,9 @@ impl ContextProvider for TrustedHttpContextProvider { id: &Identifier, platform_version: &PlatformVersion, ) -> Result>, ContextProviderError> { - // First check known contracts cache - let known = self.known_contracts.lock().unwrap(); - if let Some(contract) = known.get(id) { - return Ok(Some(contract.clone())); - } - drop(known); - - // Check if this is a system data contract and the corresponding feature is enabled - #[cfg(any( - feature = "dpns-contract", - feature = "dashpay-contract", - feature = "withdrawals-contract", - feature = "wallet-utils-contract", - feature = "token-history-contract", - feature = "keywords-contract", - feature = "document-history-contract", - feature = "app-connect-contract", - feature = "moderation-charters-contract", - feature = "all-system-contracts" - ))] - { - // Check each system contract if its feature is enabled - #[cfg(any(feature = "dpns-contract", feature = "all-system-contracts"))] - if *id == SystemDataContract::DPNS.id() { - return load_system_data_contract(SystemDataContract::DPNS, platform_version) - .map(|contract| Some(Arc::new(contract))) - .map_err(|e| { - ContextProviderError::Generic(format!( - "Failed to load DPNS contract: {}", - e - )) - }); - } - - #[cfg(any(feature = "dashpay-contract", feature = "all-system-contracts"))] - if *id == SystemDataContract::Dashpay.id() { - return load_system_data_contract(SystemDataContract::Dashpay, platform_version) - .map(|contract| Some(Arc::new(contract))) - .map_err(|e| { - ContextProviderError::Generic(format!( - "Failed to load Dashpay contract: {}", - e - )) - }); - } - - #[cfg(any(feature = "withdrawals-contract", feature = "all-system-contracts"))] - if *id == SystemDataContract::Withdrawals.id() { - return load_system_data_contract( - SystemDataContract::Withdrawals, - platform_version, - ) - .map(|contract| Some(Arc::new(contract))) - .map_err(|e| { - ContextProviderError::Generic(format!( - "Failed to load Withdrawals contract: {}", - e - )) - }); - } - - #[cfg(any(feature = "wallet-utils-contract", feature = "all-system-contracts"))] - if *id == SystemDataContract::WalletUtils.id() { - return load_system_data_contract( - SystemDataContract::WalletUtils, - platform_version, - ) - .map(|contract| Some(Arc::new(contract))) - .map_err(|e| { - ContextProviderError::Generic(format!( - "Failed to load WalletUtils contract: {}", - e - )) - }); - } - - #[cfg(any(feature = "token-history-contract", feature = "all-system-contracts"))] - if *id == SystemDataContract::TokenHistory.id() { - return load_system_data_contract( - SystemDataContract::TokenHistory, - platform_version, - ) - .map(|contract| Some(Arc::new(contract))) - .map_err(|e| { - ContextProviderError::Generic(format!( - "Failed to load TokenHistory contract: {}", - e - )) - }); - } - - #[cfg(any(feature = "keywords-contract", feature = "all-system-contracts"))] - if *id == SystemDataContract::KeywordSearch.id() { - return load_system_data_contract( - SystemDataContract::KeywordSearch, - platform_version, - ) - .map(|contract| Some(Arc::new(contract))) - .map_err(|e| { - ContextProviderError::Generic(format!( - "Failed to load KeywordSearch contract: {}", - e - )) - }); - } - - #[cfg(any( - feature = "document-history-contract", - feature = "all-system-contracts" - ))] - if *id == SystemDataContract::DocumentHistory.id() { - return load_system_data_contract( - SystemDataContract::DocumentHistory, - platform_version, - ) - .map(|contract| Some(Arc::new(contract))) - .map_err(|e| { - ContextProviderError::Generic(format!( - "Failed to load DocumentHistory contract: {}", - e - )) - }); - } - - #[cfg(any(feature = "app-connect-contract", feature = "all-system-contracts"))] - // Below protocol version 14 the app-connect contract is - // absent, so the lookup falls through to the fallback provider (or `None`). - if *id == SystemDataContract::AppConnect.id() - && platform_version.protocol_version - >= APP_CONNECT_CONTRACT_INITIAL_PROTOCOL_VERSION - { - return load_system_data_contract(SystemDataContract::AppConnect, platform_version) - .map(|contract| Some(Arc::new(contract))) - .map_err(|e| { - ContextProviderError::Generic(format!( - "Failed to load AppConnect contract: {}", - e - )) - }); - } - - #[cfg(any( - feature = "moderation-charters-contract", - feature = "all-system-contracts" - ))] - // Below protocol version 14 the moderation charters contract is absent too. - if *id == SystemDataContract::ModerationCharters.id() - && platform_version.protocol_version - >= MODERATION_CHARTERS_CONTRACT_INITIAL_PROTOCOL_VERSION - { - return load_system_data_contract( - SystemDataContract::ModerationCharters, - platform_version, - ) - .map(|contract| Some(Arc::new(contract))) - .map_err(|e| { - ContextProviderError::Generic(format!( - "Failed to load ModerationCharters contract: {}", - e - )) - }); - } + // Known contracts first, then the system contracts. + if let Some(contract) = self.local.data_contract(id, platform_version)? { + return Ok(Some(contract)); } // If not found in known contracts or system contracts, delegate to fallback provider if available @@ -1206,9 +1004,7 @@ impl ContextProvider for TrustedHttpContextProvider { } fn register_data_contract(&self, contract: Arc) { - let id = contract.id(); - let mut known = self.known_contracts.lock().unwrap(); - known.insert(id, contract); + self.local.add_known_data_contract(contract); } fn get_token_configuration( @@ -1216,11 +1012,9 @@ impl ContextProvider for TrustedHttpContextProvider { token_id: &Identifier, ) -> Result, ContextProviderError> { // First check known token configurations cache - let known = self.known_token_configurations.lock().unwrap(); - if let Some(config) = known.get(token_id) { - return Ok(Some(config.clone())); + if let Some(config) = self.local.token_configuration(token_id) { + return Ok(Some(config)); } - drop(known); // Delegate to fallback provider if available if let Some(ref provider) = self.fallback_provider { @@ -1232,12 +1026,7 @@ impl ContextProvider for TrustedHttpContextProvider { } fn get_platform_activation_height(&self) -> Result { - // Return the L1 locked height for each network - match self.network { - Network::Mainnet => Ok(2132092), // Mainnet L1 locked height - Network::Testnet => Ok(1090319), // Testnet L1 locked height - Network::Devnet | Network::Regtest => Ok(1), // Devnet/Regtest activation height - } + Ok(platform_activation_height(self.network)) } } @@ -2433,6 +2222,41 @@ mod tests { } } + /// Clones, the detached copy a quorum refresh runs on, and the resolver + /// handed out by `local_resolver` must all see one contract cache: the SDK + /// registers contracts through one handle and verifies proofs through + /// another. + #[cfg(any(feature = "dpns-contract", feature = "all-system-contracts"))] + #[test] + fn should_share_one_contract_cache_across_clones_and_the_local_resolver() { + use dpp::data_contract::accessors::v0::{DataContractV0Getters, DataContractV0Setters}; + use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + + let provider = TrustedHttpContextProvider::new_with_url( + Network::Regtest, + "http://127.0.0.1:1".to_string(), + NonZeroUsize::new(1).expect("non-zero cache size"), + ) + .expect("offline provider"); + let mut contract = + load_system_data_contract(SystemDataContract::DPNS, PlatformVersion::latest()) + .expect("DPNS contract"); + contract.set_id(Identifier::new([0x5b; 32])); + let id = contract.id(); + + provider.local_resolver().add_known_contract(contract); + + for handle in [provider.clone(), provider.detached()] { + assert!(handle.get_known_contract(&id).is_some()); + assert!(handle + .get_data_contract(&id, PlatformVersion::latest()) + .unwrap() + .is_some()); + } + assert!(provider.remove_known_contract(&id)); + assert!(provider.local_resolver().get_known_contract(&id).is_none()); + } + #[test] fn test_known_contracts() { use dpp::version::PlatformVersion; @@ -2463,6 +2287,7 @@ mod tests { #[test] fn should_serve_app_connect_only_from_protocol_14() { use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::system_data_contracts::SystemDataContract; use dpp::version::PlatformVersion; // A numeric loopback URL avoids DNS; contract lookups make no HTTP requests. @@ -2495,6 +2320,7 @@ mod tests { #[test] fn should_serve_moderation_charters_only_from_protocol_14() { use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::system_data_contracts::SystemDataContract; use dpp::version::PlatformVersion; // A numeric loopback URL avoids DNS; contract lookups make no HTTP requests. From 57bd3bf9e581841eaec3044d379dec412340a5cf Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 20 Oct 2026 17:05:24 +0700 Subject: [PATCH 30/38] fix: build strict SPV without the trusted quorum service and hold the SPV lease for the manager's life Strict SPV: - dash_sdk_create_spv no longer builds the trusted HTTP provider. It failed for reasons unrelated to SPV: devnet without a quorum URL, or an unresolvable quorum host on native targets. It reads contracts from a standalone LocalContextResolver and ignores quorum_url. - The document FFIs and dash_sdk_add_known_contracts read and fill the SDK's local contract cache (SDKWrapper.local_contracts) instead of the trusted provider. A strict SPV SDK has no trusted provider, so it could not run document operations or preload contracts. Trusted and Adaptive share the trusted provider's own cache, so preloaded contracts stay visible to proof verification. Provider routing: - rs-sdk-ffi builds the providers from dash-context-provider::routing and keeps only construction and marshalling. - PlatformWalletManager serves its SPV quorum keys into the SDK's slot from creation (platform_wallet_manager_create_with_sdk claims the slot first, so a refusal never takes the host's callback contexts) until shutdown or drop, which release it before SPV stops. SPV start and stop no longer touch the lease, so a manager whose SPV stopped without releasing it (cleared storage, a failed run, start racing stop) is no longer refused by its own stale lease on the next start. PlatformWalletManagerHandle is gone; SPV start, stop and destroy are upstream's code again. - The source counts as ready only while sync is running and synced: a failed sync keeps its last "synced" progress. A lookup that fails because the client stopped, and the runtime-bridge refusals, are QuorumSourceUnavailable, so the SDK does not ban a node for this side's state. Tests: should_build_a_strict_spv_sdk_without_a_quorum_service fails on the previous construction ("Devnet name must be provided for devnet network") and passes now. Cache sharing, end-to-end preloading on a strict SPV SDK, the manager's lease lifetime and the error classes are covered. The lease wedge and the failed-sync readiness gap need a started dash-spv client to reproduce and have no test here. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 5 +- packages/rs-platform-wallet-ffi/Cargo.toml | 1 + packages/rs-platform-wallet-ffi/src/handle.rs | 61 +- .../rs-platform-wallet-ffi/src/manager.rs | 149 ++-- .../src/shielded_sync.rs | 6 +- packages/rs-platform-wallet-ffi/src/spv.rs | 10 +- .../rs-platform-wallet/src/manager/mod.rs | 18 + .../src/manager/spv_quorums.rs | 135 ++++ .../rs-platform-wallet/src/spv/runtime.rs | 10 +- .../src/spv_context_provider.rs | 39 +- packages/rs-sdk-ffi/Cargo.toml | 2 +- packages/rs-sdk-ffi/src/context_provider.rs | 733 +----------------- packages/rs-sdk-ffi/src/document/create.rs | 11 +- packages/rs-sdk-ffi/src/document/delete.rs | 21 +- packages/rs-sdk-ffi/src/document/price.rs | 33 +- packages/rs-sdk-ffi/src/document/purchase.rs | 33 +- packages/rs-sdk-ffi/src/document/put.rs | 33 +- .../rs-sdk-ffi/src/document/queries/fetch.rs | 13 +- packages/rs-sdk-ffi/src/document/replace.rs | 27 +- packages/rs-sdk-ffi/src/document/transfer.rs | 21 +- packages/rs-sdk-ffi/src/document/util.rs | 35 +- .../rs-sdk-ffi/src/evonode/queries/status.rs | 3 +- packages/rs-sdk-ffi/src/sdk.rs | 373 ++++++--- packages/rs-sdk-ffi/src/types.rs | 13 +- 24 files changed, 664 insertions(+), 1121 deletions(-) create mode 100644 packages/rs-platform-wallet/src/manager/spv_quorums.rs diff --git a/Cargo.lock b/Cargo.lock index eac41cac0a7..a1839a7e25d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1814,12 +1814,14 @@ dependencies = [ name = "dash-context-provider" version = "5.0.0-rc.4" dependencies = [ + "arc-swap", "dash-async", "dpp", "drive", "hex", "serde_json", "thiserror 1.0.69", + "tokio", ] [[package]] @@ -5559,6 +5561,7 @@ dependencies = [ "async-trait", "bs58", "cbindgen 0.27.0", + "dash-context-provider", "dash-network", "dash-sdk", "dashcore", @@ -6709,11 +6712,11 @@ dependencies = [ name = "rs-sdk-ffi" version = "5.0.0-rc.4" dependencies = [ - "arc-swap", "async-trait", "bs58", "cbindgen 0.27.0", "dash-async", + "dash-context-provider", "dash-network", "dash-sdk", "dotenvy", diff --git a/packages/rs-platform-wallet-ffi/Cargo.toml b/packages/rs-platform-wallet-ffi/Cargo.toml index a3f9b175658..3572ead4157 100644 --- a/packages/rs-platform-wallet-ffi/Cargo.toml +++ b/packages/rs-platform-wallet-ffi/Cargo.toml @@ -21,6 +21,7 @@ dash-sdk = { path = "../rs-sdk", features = ["wallet"] } # entry points can accept iOS-side keychain-backed signers without # duplicating the vtable plumbing. See `signer.rs` in rs-sdk-ffi. rs-sdk-ffi = { path = "../rs-sdk-ffi" } +dash-context-provider = { path = "../rs-context-provider" } # FFI utilities once_cell = "1.19" diff --git a/packages/rs-platform-wallet-ffi/src/handle.rs b/packages/rs-platform-wallet-ffi/src/handle.rs index dc4b67eda88..ad672e0d431 100644 --- a/packages/rs-platform-wallet-ffi/src/handle.rs +++ b/packages/rs-platform-wallet-ffi/src/handle.rs @@ -1,9 +1,7 @@ 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; @@ -144,66 +142,13 @@ 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> = - Lazy::new(HandleStorage::new); +pub static PLATFORM_WALLET_MANAGER_STORAGE: Lazy< + HandleStorage>, +> = 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 e11a6bec55b..68c21d57c3a 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -20,6 +20,7 @@ use crate::runtime::runtime; use crate::types::{FFINetwork, Network}; use crate::{unwrap_option_or_return, unwrap_result_or_return}; +use dash_context_provider::routing::SpvSourceLease; use dash_sdk::Sdk; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use platform_wallet::PlatformWalletManager; @@ -179,6 +180,75 @@ pub unsafe extern "C" fn platform_wallet_manager_create_with_extensions( ) } +/// Create a manager from an owning SDK handle, with the persistence and event +/// extensions of [`platform_wallet_manager_create_with_extensions`]. +/// +/// When the SDK was created with an SPV-capable context provider +/// (`dash_sdk_create_spv` or `dash_sdk_create_adaptive`), the manager serves +/// that provider's SPV quorum keys from its own SPV client for as long as the +/// manager lives. Only one manager can serve an SDK; a second one fails here. +/// +/// The SDK handle is borrowed only for this synchronous call. The manager +/// retains an `Sdk` clone, never the raw handle. Context ownership follows +/// [`platform_wallet_manager_create`]. +#[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, + persistence_capabilities: *const PersistenceCapabilitiesFFI, + persistence_extension: *const PersistenceCallbacksExtension, + event_extension: *const EventHandlerCallbacksExtension, + out_handle: *mut Handle, +) -> PlatformWalletFFIResult { + check_ptr!(sdk_handle); + check_ptr!(persistence_capabilities); + check_ptr!(persistence_extension); + check_ptr!(event_extension); + + 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(), + ); + } + // Claimed before the manager exists, so a refusal never takes ownership + // of the host's callback contexts. + let spv_quorum_lease = match rs_sdk_ffi::dash_sdk_spv_source_slot(sdk_handle) { + Ok(Some(slot)) => match slot.claim() { + Ok(lease) => Some(lease), + Err(error) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + error.to_string(), + ); + } + }, + Ok(None) => None, + Err(message) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + message, + ); + } + }; + + let declaration = persistence_capabilities_declaration(&*persistence_capabilities); + let persistence_extensions = persistence_extension_callbacks(persistence_extension); + let dpns_event_callback = event_extension_dpns_callback(event_extension); + platform_wallet_manager_create_impl( + sdk_ptr, + spv_quorum_lease, + persistence, + event_handler, + declaration, + persistence_extensions, + dpns_event_callback, + out_handle, + ) +} + /// Read one negotiated slot out of a size/version-tagged extension struct /// — the single authority for the gate every reader below applies. A slot /// is read only when the host's declared `struct_size` proves it was @@ -322,7 +392,7 @@ unsafe fn event_extension_dpns_callback( #[allow(clippy::too_many_arguments)] unsafe fn platform_wallet_manager_create_impl( sdk_ptr: *const c_void, - spv_source_controller: Option>, + spv_quorum_lease: Option, persistence: *const PersistenceCallbacks, event_handler: *const EventHandlerCallbacks, declared_capabilities: PersistenceCapabilities, @@ -385,82 +455,16 @@ unsafe fn platform_wallet_manager_create_impl( // return and leaves the spawned task running on that runtime. let _runtime_guard = runtime().enter(); - let network = sdk.network; let manager = PlatformWalletManager::new(sdk, persister, handler); - let spv_source = spv_source_controller.as_ref().map(|_| { - Arc::new(platform_wallet::spv_context_provider::SpvQuorumSource::new( - manager.spv_arc(), - network, - )) - }); - let handle = PLATFORM_WALLET_MANAGER_STORAGE.insert(PlatformWalletManagerHandle::new( - manager, - spv_source, - spv_source_controller, - )); + if let Some(lease) = spv_quorum_lease { + manager.serve_spv_quorums(lease); + } + let handle = PLATFORM_WALLET_MANAGER_STORAGE.insert(manager); *out_handle = handle; PlatformWalletFFIResult::ok() } -/// Create a manager from an owning SDK handle, with the persistence and event -/// extensions of [`platform_wallet_manager_create_with_extensions`]. -/// -/// When the SDK was created with an SPV-capable context provider -/// (`dash_sdk_create_spv` or `dash_sdk_create_adaptive`), the manager also -/// prepares an SPV quorum source for it. The SDK uses that source only while -/// this manager's SPV client runs. -/// -/// The SDK handle is borrowed only for this synchronous call. The manager -/// retains an `Sdk` clone, never the raw handle. Context ownership follows -/// [`platform_wallet_manager_create`]. -#[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, - persistence_capabilities: *const PersistenceCapabilitiesFFI, - persistence_extension: *const PersistenceCallbacksExtension, - event_extension: *const EventHandlerCallbacksExtension, - out_handle: *mut Handle, -) -> PlatformWalletFFIResult { - check_ptr!(sdk_handle); - check_ptr!(persistence_capabilities); - check_ptr!(persistence_extension); - check_ptr!(event_extension); - - 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 spv_source_controller = match rs_sdk_ffi::dash_sdk_spv_source_controller(sdk_handle) { - Ok(controller) => controller, - Err(message) => { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorInvalidParameter, - message, - ); - } - }; - - let declaration = persistence_capabilities_declaration(&*persistence_capabilities); - let persistence_extensions = persistence_extension_callbacks(persistence_extension); - let dpns_event_callback = event_extension_dpns_callback(event_extension); - platform_wallet_manager_create_impl( - sdk_ptr, - spv_source_controller, - persistence, - event_handler, - declaration, - persistence_extensions, - dpns_event_callback, - out_handle, - ) -} - /// Query the versioned persistence capabilities of a live manager. /// /// The returned mask is derived from the callback vtable copied at manager @@ -817,9 +821,6 @@ pub unsafe extern "C" fn platform_wallet_manager_destroy( handle: Handle, ) -> PlatformWalletFFIResult { if let Some(manager) = PLATFORM_WALLET_MANAGER_STORAGE.remove(handle) { - // Detach this manager's SPV quorum source first, so the SDK stops - // reading a runtime that is shutting down. - manager.release_spv_source(); // Run the full lifecycle shutdown to completion, not just the // platform-address sync. `shutdown()` is idempotent, so this is // safe even if the host already stopped some sync managers diff --git a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs index 0f2af5531ab..dfda7eeb4c1 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_sync.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_sync.rs @@ -869,16 +869,14 @@ mod local_balance_tests { coordinator.mark_hydrated(wallet_id, true).await; (wallet_id, coordinator) }); - let handle = PLATFORM_WALLET_MANAGER_STORAGE - .insert(PlatformWalletManagerHandle::new(manager, None, None)); + let handle = PLATFORM_WALLET_MANAGER_STORAGE.insert(manager); (handle, wallet_id, coordinator, path) } #[test] fn should_allow_registry_writer_and_stop_while_local_balance_snapshot_is_parked() { let (handle, wallet_id, coordinator, path) = bound_manager(); - let unrelated_handle = PLATFORM_WALLET_MANAGER_STORAGE - .insert(PlatformWalletManagerHandle::new(mock_manager(), None, None)); + let unrelated_handle = PLATFORM_WALLET_MANAGER_STORAGE.insert(mock_manager()); // Model a scan owning the real store lock. Once the FFI snapshot has // cloned this coordinator, it cannot finish until this guard drops. let store_guard = runtime().block_on(coordinator.store().write()); diff --git a/packages/rs-platform-wallet-ffi/src/spv.rs b/packages/rs-platform-wallet-ffi/src/spv.rs index 9ed8cbbda88..33f6ad9f9dd 100644 --- a/packages/rs-platform-wallet-ffi/src/spv.rs +++ b/packages/rs-platform-wallet-ffi/src/spv.rs @@ -2,7 +2,6 @@ use std::ffi::{CStr, CString}; use std::os::raw::c_char; -use std::sync::Arc; use dashcore::hashes::Hash; use dashcore::sml::llmq_type::LlmqDevnetParams; @@ -567,11 +566,6 @@ 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(); } @@ -601,9 +595,7 @@ pub unsafe extern "C" fn platform_wallet_manager_spv_stop( // start issued after it is refused until a later stop joins the teardown. let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { let spv = manager.spv_arc(); - let result = block_on_worker(async move { spv.stop().await }); - manager.release_spv_source(); - result + block_on_worker(async move { spv.stop().await }) }); let result = unwrap_option_or_return!(option); unwrap_result_or_return!(result); diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 6ea8ba9b5fd..48a72b3f0c6 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -11,6 +11,8 @@ mod persistence_load; pub mod platform_address_sync; #[cfg(feature = "shielded")] pub mod shielded_sync; +#[cfg(feature = "spv-context")] +mod spv_quorums; pub mod startup; mod wallet_lifecycle; @@ -478,6 +480,11 @@ pub struct PlatformWalletManager { pub(super) in_broadcast_fences: std::sync::Mutex< std::collections::BTreeMap>, >, + /// The SDK provider slot this manager serves SPV quorum keys into, held + /// from [`Self::serve_spv_quorums`] until [`Self::shutdown`]. + #[cfg(feature = "spv-context")] + pub(super) spv_quorum_lease: + std::sync::Mutex>, } /// Keep `record`'s coverage only for the DashPay receival accounts `core` @@ -658,6 +665,8 @@ impl PlatformWalletManager

{ sync_fault, durable_cursors, in_broadcast_fences: std::sync::Mutex::new(std::collections::BTreeMap::new()), + #[cfg(feature = "spv-context")] + spv_quorum_lease: std::sync::Mutex::new(None), } } @@ -1133,6 +1142,11 @@ impl PlatformWalletManager

{ /// /// [`WorkerStatus`]: dash_async::WorkerStatus pub async fn shutdown(&self) -> ShutdownReport { + // Stop serving SPV quorum keys before SPV stops, whether or not the + // stop is clean: a stopping client must not answer proof lookups. + #[cfg(feature = "spv-context")] + self.withdraw_spv_quorums(); + // SPV first: it is the event source feeding everything below. Its // `stop` bounds the wait for teardown but never aborts it, so a // timeout means SPV work is still live. Its outcome lands in the @@ -1286,6 +1300,10 @@ impl PlatformWalletManager

{ /// fields can no longer be moved out. impl Drop for PlatformWalletManager

{ fn drop(&mut self) { + // As in `shutdown`: stop serving SPV quorum keys first. Until the lease + // goes, the SDK's slot keeps this manager's SPV runtime alive. + #[cfg(feature = "spv-context")] + self.withdraw_spv_quorums(); // Cancel and detach, never `abort`: the task observes the token at its // next `recv` and exits after committing whatever its drain claimed the // persister for. Aborting stops it at whatever await it is parked on, diff --git a/packages/rs-platform-wallet/src/manager/spv_quorums.rs b/packages/rs-platform-wallet/src/manager/spv_quorums.rs new file mode 100644 index 00000000000..c4f0a62852b --- /dev/null +++ b/packages/rs-platform-wallet/src/manager/spv_quorums.rs @@ -0,0 +1,135 @@ +//! Serving the manager's SPV quorum keys to its SDK's context provider. + +use std::sync::{Arc, PoisonError}; + +use dash_context_provider::routing::SpvSourceLease; +use dash_context_provider::ContextProvider; + +use super::PlatformWalletManager; +use crate::changeset::PlatformWalletPersistence; +use crate::spv_context_provider::SpvQuorumSource; + +impl PlatformWalletManager

{ + /// Serve this manager's SPV quorum keys through `lease` until + /// [`Self::shutdown`]. + /// + /// The keys come from the manager's own SPV client, read live on every + /// lookup, and only while that client is running and synced; otherwise + /// the SDK's lookups fail closed. Starting and stopping SPV therefore + /// needs nothing more here. + /// + /// `lease` is claimed by the caller from the SDK's slot before this + /// manager is built, so a slot another manager holds is refused before + /// anything else is set up. + pub fn serve_spv_quorums(&self, lease: SpvSourceLease) { + let source = Arc::new(SpvQuorumSource::new( + Arc::clone(&self.spv_manager), + self.sdk.network, + )); + let readiness = Arc::clone(&source); + lease.publish( + source as Arc, + Arc::new(move || readiness.is_ready()), + ); + *self + .spv_quorum_lease + .lock() + .unwrap_or_else(PoisonError::into_inner) = Some(lease); + } + + /// Stop serving SPV quorum keys; the SDK's slot is free again. + pub(super) fn withdraw_spv_quorums(&self) { + let lease = self + .spv_quorum_lease + .lock() + .unwrap_or_else(PoisonError::into_inner) + .take(); + drop(lease); + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use dash_context_provider::routing::{ContractSource, SpvContextProvider}; + use dash_context_provider::{ContextProvider, ContextProviderError}; + use dashcore::sml::llmq_type::network::NetworkLLMQExt; + use dpp::data_contract::TokenConfiguration; + use dpp::prelude::{DataContract, Identifier}; + use dpp::version::PlatformVersion; + + use crate::events::{EventHandler, PlatformEventHandler}; + use crate::wallet::persister::NoPlatformPersistence; + use crate::PlatformWalletManager; + + struct NoopEventHandler; + impl EventHandler for NoopEventHandler {} + impl PlatformEventHandler for NoopEventHandler {} + + struct NoContracts; + impl ContractSource for NoContracts { + fn get_data_contract( + &self, + _id: &Identifier, + _platform_version: &PlatformVersion, + ) -> Result>, ContextProviderError> { + Ok(None) + } + + fn get_token_configuration( + &self, + _token_id: &Identifier, + ) -> Result, ContextProviderError> { + Ok(None) + } + + fn register_data_contract(&self, _contract: Arc) {} + } + + fn manager() -> PlatformWalletManager { + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + PlatformWalletManager::new( + sdk, + Arc::new(NoPlatformPersistence), + Arc::new(NoopEventHandler) as Arc, + ) + } + + /// A manager holds the SDK's slot from the moment it serves until it shuts + /// down. Meanwhile no other manager can claim it, and a lookup fails + /// closed while the manager's SPV client is not running, without blaming + /// the node. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn should_hold_the_slot_from_serving_until_shutdown() { + let manager = manager(); + let network = manager.sdk.network; + let provider = SpvContextProvider::new(Arc::new(NoContracts), network); + let slot = provider.source_slot(); + + manager.serve_spv_quorums(slot.claim().expect("empty slot")); + assert!( + slot.claim().is_err(), + "a second manager must not serve the same SDK" + ); + + let platform_type = u32::from(u8::from(network.platform_type())); + let lookup = provider.get_quorum_public_key(platform_type, [7; 32], 1); + // "not ready" rather than "not active": the manager published its own + // source, which reports its stopped client as unsynced. + assert!( + matches!( + lookup, + Err(ContextProviderError::QuorumSourceUnavailable(ref message)) + if message.contains("not ready") + ), + "{lookup:?}" + ); + + let _report = manager.shutdown().await; + assert!( + slot.claim().is_ok(), + "shutdown must free the slot for the next manager" + ); + } +} diff --git a/packages/rs-platform-wallet/src/spv/runtime.rs b/packages/rs-platform-wallet/src/spv/runtime.rs index 6920c716853..095c67a44ce 100644 --- a/packages/rs-platform-wallet/src/spv/runtime.rs +++ b/packages/rs-platform-wallet/src/spv/runtime.rs @@ -260,9 +260,15 @@ impl SpvRuntime { self.client.try_read().map(|c| c.is_some()).unwrap_or(false) } - /// Whether dash-spv's current progress reports both header and - /// masternode-list synchronization complete. + /// Whether sync is running and dash-spv's current progress reports both + /// header and masternode-list synchronization complete. + /// + /// A failed sync keeps its last progress, so "synced" alone would vouch + /// for a masternode list that no longer follows the chain. pub async fn is_ready(&self) -> bool { + if !self.is_running() { + return false; + } self.sync_progress().await.is_some_and(|progress| { progress .headers() diff --git a/packages/rs-platform-wallet/src/spv_context_provider.rs b/packages/rs-platform-wallet/src/spv_context_provider.rs index 6a6db68990f..859524b1ec9 100644 --- a/packages/rs-platform-wallet/src/spv_context_provider.rs +++ b/packages/rs-platform-wallet/src/spv_context_provider.rs @@ -25,6 +25,7 @@ use std::future::Future; use std::sync::Arc; +use dash_context_provider::platform_activation_height; use dash_context_provider::ContextProvider; use dash_context_provider::ContextProviderError; use dashcore::sml::llmq_type::network::NetworkLLMQExt; @@ -73,13 +74,15 @@ impl SpvQuorumSource { where F: Future, { + // Both refusals are this process's state, not the node's, so they are + // `QuorumSourceUnavailable`: the SDK must not ban a node for them. let handle = tokio::runtime::Handle::try_current().map_err(|_| { - ContextProviderError::Generic( + ContextProviderError::QuorumSourceUnavailable( "SPV context provider called outside a Tokio runtime".to_string(), ) })?; if handle.runtime_flavor() == RuntimeFlavor::CurrentThread { - return Err(ContextProviderError::Generic( + return Err(ContextProviderError::QuorumSourceUnavailable( "SPV context provider requires a multi-threaded Tokio runtime".to_string(), )); } @@ -126,7 +129,15 @@ impl ContextProvider for SpvQuorumSource { quorum_hash, core_chain_locked_height, ))? - .map_err(|e| e.quorum_lookup_context_error()); + .map_err(|e| { + // A client that stopped or failed between the readiness check + // and this lookup is local state too. + if self.spv.is_running() { + e.quorum_lookup_context_error() + } else { + ContextProviderError::QuorumSourceUnavailable(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 @@ -152,15 +163,7 @@ impl ContextProvider for SpvQuorumSource { } fn get_platform_activation_height(&self) -> Result { - // Match the values the trusted HTTP provider ships (the L1 locked - // height per network) so proof verification behaves identically - // whether quorum keys come from SPV or the trusted service. See - // `rs-sdk-trusted-context-provider`'s `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), - } + Ok(platform_activation_height(self.network)) } fn get_data_contract( @@ -220,7 +223,8 @@ mod tests { fn should_refuse_a_lookup_outside_a_tokio_runtime() { let error = unstarted_source().is_ready().unwrap_err(); assert!( - error.to_string().contains("outside a Tokio runtime"), + matches!(error, ContextProviderError::QuorumSourceUnavailable(ref message) + if message.contains("outside a Tokio runtime")), "{error}" ); } @@ -237,7 +241,8 @@ mod tests { let source = unstarted_source(); let error = runtime.block_on(async { source.is_ready() }).unwrap_err(); assert!( - error.to_string().contains("multi-threaded Tokio runtime"), + matches!(error, ContextProviderError::QuorumSourceUnavailable(ref message) + if message.contains("multi-threaded Tokio runtime")), "{error}" ); } @@ -252,7 +257,11 @@ mod tests { let error = source .get_quorum_public_key(platform_quorum_type(), [7; 32], 1) .unwrap_err(); - assert!(error.to_string().contains("not started"), "{error}"); + assert!( + matches!(error, ContextProviderError::QuorumSourceUnavailable(ref message) + if message.contains("not started")), + "{error}" + ); } /// The proof names its own quorum type, so only the network's Platform diff --git a/packages/rs-sdk-ffi/Cargo.toml b/packages/rs-sdk-ffi/Cargo.toml index ad01eb5be15..66a91b1c2b8 100644 --- a/packages/rs-sdk-ffi/Cargo.toml +++ b/packages/rs-sdk-ffi/Cargo.toml @@ -16,6 +16,7 @@ dash-sdk = { path = "../rs-sdk", features = [ "dashpay-contract", ] } drive-proof-verifier = { path = "../rs-drive-proof-verifier" } +dash-context-provider = { path = "../rs-context-provider" } rs-sdk-trusted-context-provider = { path = "../rs-sdk-trusted-context-provider", features = [ "dpns-contract", "dashpay-contract", @@ -75,7 +76,6 @@ zeroize = "1.8" # Concurrency once_cell = "1.20" -arc-swap = "1.7.1" [build-dependencies] cbindgen = "0.27" diff --git a/packages/rs-sdk-ffi/src/context_provider.rs b/packages/rs-sdk-ffi/src/context_provider.rs index 1f921420488..66db6893d0f 100644 --- a/packages/rs-sdk-ffi/src/context_provider.rs +++ b/packages/rs-sdk-ffi/src/context_provider.rs @@ -3,18 +3,11 @@ //! 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::{AtomicU64, AtomicU8, Ordering}; -use std::sync::{Arc, Weak}; +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; -use dash_sdk::error::ContextProviderError; -use drive_proof_verifier::{ContextProvider, QuorumKeyFuture}; +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 @@ -30,9 +23,7 @@ pub struct ContextProviderHandle { } /// Internal wrapper for context provider -/// Adapter wrapping any [`ContextProvider`] as an opaque -/// [`ContextProviderHandle`] for callback-based SDK configuration. -pub struct ContextProviderWrapper { +pub(crate) struct ContextProviderWrapper { provider: Arc, } @@ -48,353 +39,6 @@ impl ContextProviderWrapper { } } -/// 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 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(()), - } - } -} - -/// Quorum source selected for a lookup at the current instant. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[repr(u8)] -pub enum AdaptiveContextProviderSource { - Trusted = 0, - Spv = 1, -} - -struct SpvContextSource { - lease_id: u64, - provider: Arc, - 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(), - }); - } - - /// An inactive or unready source is reported as - /// [`ContextProviderError::QuorumSourceUnavailable`]: it is this side's - /// state, so the SDK must not ban the node whose proof could not be - /// checked. - fn source(&self) -> Result, ContextProviderError> { - self.source.load_full().ok_or_else(|| { - ContextProviderError::QuorumSourceUnavailable( - "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::QuorumSourceUnavailable( - "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 register_data_contract(&self, contract: Arc) { - self.provider.register_data_contract(contract) - } - - 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 register_data_contract(&self, contract: Arc) { - self.auxiliary.register_data_contract(contract) - } - - 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: Arc, - auxiliary: Arc, - mode: AtomicU8, - network: Network, -} - -impl AdaptiveContextProvider { - pub(crate) fn new( - trusted: Arc, - spv: Arc, - auxiliary: Arc, - network: Network, - ) -> Self { - Self { - trusted, - spv, - auxiliary, - mode: AtomicU8::new(ContextProviderMode::Auto as u8), - network, - } - } - - 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) -> AdaptiveContextProviderSource { - match self.mode() { - ContextProviderMode::Trusted => AdaptiveContextProviderSource::Trusted, - ContextProviderMode::Spv => AdaptiveContextProviderSource::Spv, - ContextProviderMode::Auto if self.spv.ready_source().is_ok() => { - AdaptiveContextProviderSource::Spv - } - ContextProviderMode::Auto => AdaptiveContextProviderSource::Trusted, - } - } -} - -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> { - match self.mode() { - ContextProviderMode::Trusted => self.trusted.get_quorum_public_key( - quorum_type, - quorum_hash, - core_chain_locked_height, - ), - ContextProviderMode::Spv => self.spv.ready_source()?.provider.get_quorum_public_key( - quorum_type, - quorum_hash, - core_chain_locked_height, - ), - ContextProviderMode::Auto => match self.spv.ready_source() { - Ok(source) => source.provider.get_quorum_public_key( - quorum_type, - quorum_hash, - core_chain_locked_height, - ), - Err(_) => self.trusted.get_quorum_public_key( - quorum_type, - quorum_hash, - core_chain_locked_height, - ), - }, - } - } - - /// Only the trusted route can fetch a key missing from its cache. The SPV - /// route never fetches, so strict SPV never falls back to trusted keys. - fn fetch_quorum_public_key( - &self, - quorum_type: u32, - quorum_hash: [u8; 32], - core_chain_locked_height: u32, - ) -> Option { - match self.active_source() { - AdaptiveContextProviderSource::Trusted => self.trusted.fetch_quorum_public_key( - quorum_type, - quorum_hash, - core_chain_locked_height, - ), - AdaptiveContextProviderSource::Spv => None, - } - } - - 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 register_data_contract(&self, contract: Arc) { - self.auxiliary.register_data_contract(contract) - } - - fn get_token_configuration( - &self, - token_id: &Identifier, - ) -> Result, ContextProviderError> { - 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), - } -} - // 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 @@ -435,374 +79,3 @@ 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, QuorumKeyFuture}; - - use super::{ - AdaptiveContextProvider, AdaptiveContextProviderSource, AuxiliaryContextProvider, - ContextProviderMode, SpvContextProvider, SpvSourceController, - }; - 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 fetch_quorum_public_key( - &self, - _quorum_type: u32, - _quorum_hash: [u8; 32], - _height: u32, - ) -> Option { - let key = [self.key_byte; 48]; - Some(Box::pin(async move { Ok(Some(key)) })) - } - - 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) - } - - 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, spv) = adaptive(provider("trusted", 0x11), Network::Testnet); - - assert_eq!(quorum_key(&adaptive).unwrap(), [0x11; 48]); - assert_eq!( - adaptive.active_source(), - AdaptiveContextProviderSource::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); - let _lease = spv - .acquire( - provider("spv", 0x22), - 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(), AdaptiveContextProviderSource::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(), - AdaptiveContextProviderSource::Trusted - ); - ready.store(true, Ordering::Release); - assert_eq!(quorum_key(&adaptive).unwrap(), [0x22; 48]); - assert_eq!(adaptive.active_source(), AdaptiveContextProviderSource::Spv); - } - - #[test] - fn routes_contracts_and_tokens_to_trusted_for_every_mode() { - 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]); - - 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 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!(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, 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")); - } - - /// The SDK fetches a quorum key missing from the trusted cache through - /// `fetch_quorum_public_key`. That fetch must follow the lookup's route: - /// a trusted route fetches from the trusted service, an SPV route never - /// does, or strict SPV would accept a trusted key after an SPV miss. - #[tokio::test] - async fn fetches_missing_quorum_keys_only_on_the_trusted_route() { - let (adaptive, spv) = adaptive(provider("trusted", 0x11), Network::Testnet); - let fetch = - |adaptive: &AdaptiveContextProvider| adaptive.fetch_quorum_public_key(1, [2; 32], 3); - - let fetched = fetch(&adaptive).expect("Auto without SPV fetches from trusted"); - assert_eq!(fetched.await.unwrap(), Some([0x11; 48])); - - adaptive.set_mode(ContextProviderMode::Spv); - assert!( - fetch(&adaptive).is_none(), - "SPV without a source must not fetch" - ); - - let _lease = spv - .acquire(provider("spv", 0x22), Arc::new(|| Ok(true))) - .unwrap(); - assert!(fetch(&adaptive).is_none(), "SPV must not fetch"); - - adaptive.set_mode(ContextProviderMode::Auto); - assert!( - fetch(&adaptive).is_none(), - "Auto on ready SPV must not fetch" - ); - - adaptive.set_mode(ContextProviderMode::Trusted); - let fetched = fetch(&adaptive).expect("Trusted fetches"); - assert_eq!(fetched.await.unwrap(), Some([0x11; 48])); - } - - /// Contracts the SDK learns about (for example after a contract fetch) - /// must reach the local resolver that SPV and Adaptive read contracts - /// from; otherwise proofs over those contracts cannot be verified. - #[test] - fn registers_contracts_with_the_local_resolver() { - use dash_sdk::dpp::data_contract::accessors::v0::{ - DataContractV0Getters, DataContractV0Setters, - }; - use dash_sdk::dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; - - let contract = Arc::new( - load_system_data_contract(SystemDataContract::DPNS, PlatformVersion::latest()) - .expect("DPNS contract"), - ); - let mut unknown = Arc::unwrap_or_clone(Arc::clone(&contract)); - unknown.set_id(Identifier::new([0x5a; 32])); - let unknown = Arc::new(unknown); - let id = unknown.id(); - - let trusted: Arc = Arc::new( - crate::sdk::build_trusted_provider( - Network::Regtest, - Some("http://127.0.0.1:1".to_string()), - ) - .unwrap_or_else(|_| panic!("trusted provider")), - ); - let auxiliary = Arc::new(AuxiliaryContextProvider::new(Arc::clone(&trusted))); - let controller = Arc::new(SpvSourceController::default()); - let fixed = SpvContextProvider::new( - Arc::clone(&controller), - Arc::clone(&auxiliary), - Network::Regtest, - ); - let adaptive = - AdaptiveContextProvider::new(trusted, controller, auxiliary, Network::Regtest); - - assert!(fixed - .get_data_contract(&id, PlatformVersion::latest()) - .unwrap() - .is_none()); - fixed.register_data_contract(Arc::clone(&unknown)); - assert_eq!( - fixed - .get_data_contract(&id, PlatformVersion::latest()) - .unwrap() - .map(|contract| contract.id()), - Some(id) - ); - assert_eq!( - adaptive - .get_data_contract(&id, PlatformVersion::latest()) - .unwrap() - .map(|contract| contract.id()), - Some(id) - ); - } - - /// Strict SPV fails closed while the SPV source is inactive or still - /// syncing. That is the local source's state, not the responding node's - /// fault, so the error must be `QuorumSourceUnavailable`: the SDK bans - /// nodes for any other quorum-key failure. - #[test] - fn reports_an_unavailable_spv_source_without_blaming_the_node() { - let auxiliary = Arc::new(AuxiliaryContextProvider::new(provider("auxiliary", 0x11))); - let controller = Arc::new(SpvSourceController::default()); - let fixed = SpvContextProvider::new(Arc::clone(&controller), auxiliary, Network::Testnet); - let (adaptive, adaptive_controller) = adaptive(provider("trusted", 0x11), Network::Testnet); - adaptive.set_mode(ContextProviderMode::Spv); - - let unavailable = |result: Result<[u8; 48], ContextProviderError>| { - matches!( - result, - Err(ContextProviderError::QuorumSourceUnavailable(_)) - ) - }; - assert!(unavailable(fixed.get_quorum_public_key(1, [2; 32], 3))); - assert!(unavailable(quorum_key(&adaptive))); - - let _lease = controller - .acquire(provider("spv", 0x22), Arc::new(|| Ok(false))) - .unwrap(); - let _adaptive_lease = adaptive_controller - .acquire(provider("spv", 0x22), Arc::new(|| Ok(false))) - .unwrap(); - assert!(unavailable(fixed.get_quorum_public_key(1, [2; 32], 3))); - assert!(unavailable(quorum_key(&adaptive))); - } -} diff --git a/packages/rs-sdk-ffi/src/document/create.rs b/packages/rs-sdk-ffi/src/document/create.rs index b1f35c5d640..b6da9577e01 100644 --- a/packages/rs-sdk-ffi/src/document/create.rs +++ b/packages/rs-sdk-ffi/src/document/create.rs @@ -10,7 +10,6 @@ use dash_sdk::dpp::document::{Document, DocumentV0}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::platform_value::Value; use dash_sdk::dpp::prelude::Identifier; -use drive_proof_verifier::ContextProvider; use std::collections::BTreeMap; use std::ffi::CStr; use std::os::raw::c_char; @@ -125,23 +124,23 @@ pub unsafe extern "C" fn dash_sdk_document_create( let owner_id = Identifier::from_string(owner_id_str, Encoding::Base58) .map_err(|e| FFIError::InternalError(format!("Invalid owner identity ID: {}", e)))?; - // Get contract from trusted context provider - let data_contract = if let Some(ref provider) = wrapper.trusted_provider { + // Get contract from the SDK's local contract cache + let data_contract = if let Some(ref provider) = wrapper.local_contracts { let platform_version = wrapper.sdk.version(); provider - .get_data_contract(&contract_id, platform_version) + .data_contract(&contract_id, platform_version) .map_err(|e| { FFIError::InternalError(format!("Failed to get contract from context: {}", e)) })? .ok_or_else(|| { FFIError::InternalError(format!( - "Contract {} not found in trusted context", + "Contract {} not found in the local contract cache", contract_id_str )) })? } else { return Err(FFIError::InternalError( - "No trusted context provider configured".to_string(), + "No local contract cache configured".to_string(), )); }; diff --git a/packages/rs-sdk-ffi/src/document/delete.rs b/packages/rs-sdk-ffi/src/document/delete.rs index fc5ac499856..85c5f87257d 100644 --- a/packages/rs-sdk-ffi/src/document/delete.rs +++ b/packages/rs-sdk-ffi/src/document/delete.rs @@ -5,7 +5,6 @@ use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::prelude::{Identifier, UserFeeIncrease}; use dash_sdk::platform::documents::transitions::DocumentDeleteTransitionBuilder; use dash_sdk::platform::IdentityPublicKey; -use drive_proof_verifier::ContextProvider; use std::ffi::CStr; use std::os::raw::c_char; use tracing::{debug, error, info}; @@ -96,23 +95,23 @@ pub unsafe extern "C" fn dash_sdk_document_delete( let contract_id = Identifier::from_string(contract_id_str, Encoding::Base58) .map_err(|e| FFIError::InternalError(format!("Invalid contract ID: {}", e)))?; - // Get contract from trusted context provider - let data_contract = if let Some(ref provider) = wrapper.trusted_provider { + // Get contract from the SDK's local contract cache + let data_contract = if let Some(ref provider) = wrapper.local_contracts { let platform_version = wrapper.sdk.version(); provider - .get_data_contract(&contract_id, platform_version) + .data_contract(&contract_id, platform_version) .map_err(|e| { FFIError::InternalError(format!("Failed to get contract from context: {}", e)) })? .ok_or_else(|| { FFIError::InternalError(format!( - "Contract {} not found in trusted context", + "Contract {} not found in the local contract cache", contract_id_str )) })? } else { return Err(FFIError::InternalError( - "No trusted context provider configured".to_string(), + "No local contract cache configured".to_string(), )); }; @@ -279,23 +278,23 @@ pub unsafe extern "C" fn dash_sdk_document_delete_and_wait( let contract_id = Identifier::from_string(contract_id_str, Encoding::Base58) .map_err(|e| FFIError::InternalError(format!("Invalid contract ID: {}", e)))?; - // Get contract from trusted context provider - let data_contract = if let Some(ref provider) = wrapper.trusted_provider { + // Get contract from the SDK's local contract cache + let data_contract = if let Some(ref provider) = wrapper.local_contracts { let platform_version = wrapper.sdk.version(); provider - .get_data_contract(&contract_id, platform_version) + .data_contract(&contract_id, platform_version) .map_err(|e| { FFIError::InternalError(format!("Failed to get contract from context: {}", e)) })? .ok_or_else(|| { FFIError::InternalError(format!( - "Contract {} not found in trusted context", + "Contract {} not found in the local contract cache", contract_id_str )) })? } else { return Err(FFIError::InternalError( - "No trusted context provider configured".to_string(), + "No local contract cache configured".to_string(), )); }; diff --git a/packages/rs-sdk-ffi/src/document/price.rs b/packages/rs-sdk-ffi/src/document/price.rs index 4581bb1d451..06d7ca5796e 100644 --- a/packages/rs-sdk-ffi/src/document/price.rs +++ b/packages/rs-sdk-ffi/src/document/price.rs @@ -16,7 +16,6 @@ use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::prelude::{Identifier, UserFeeIncrease}; use dash_sdk::platform::documents::transitions::DocumentSetPriceTransitionBuilder; use dash_sdk::platform::IdentityPublicKey; -use drive_proof_verifier::ContextProvider; use std::ffi::CStr; use std::os::raw::c_char; @@ -83,23 +82,23 @@ pub unsafe extern "C" fn dash_sdk_document_update_price_of_document( FFIError::InternalError(format!("Failed to increment document revision: {}", e)) })?; - // Get contract from trusted context provider - let data_contract = if let Some(ref provider) = wrapper.trusted_provider { + // Get contract from the SDK's local contract cache + let data_contract = if let Some(ref provider) = wrapper.local_contracts { let platform_version = wrapper.sdk.version(); provider - .get_data_contract(&contract_id, platform_version) + .data_contract(&contract_id, platform_version) .map_err(|e| { FFIError::InternalError(format!("Failed to get contract from context: {}", e)) })? .ok_or_else(|| { FFIError::InternalError(format!( - "Contract {} not found in trusted context", + "Contract {} not found in the local contract cache", contract_id_str )) })? } else { return Err(FFIError::InternalError( - "No trusted context provider configured".to_string(), + "No local contract cache configured".to_string(), )); }; // Convert FFI types to Rust types @@ -223,23 +222,23 @@ pub unsafe extern "C" fn dash_sdk_document_update_price_of_document_and_wait( FFIError::InternalError(format!("Failed to increment document revision: {}", e)) })?; - // Get contract from trusted context provider - let data_contract = if let Some(ref provider) = wrapper.trusted_provider { + // Get contract from the SDK's local contract cache + let data_contract = if let Some(ref provider) = wrapper.local_contracts { let platform_version = wrapper.sdk.version(); provider - .get_data_contract(&contract_id, platform_version) + .data_contract(&contract_id, platform_version) .map_err(|e| { FFIError::InternalError(format!("Failed to get contract from context: {}", e)) })? .ok_or_else(|| { FFIError::InternalError(format!( - "Contract {} not found in trusted context", + "Contract {} not found in the local contract cache", contract_id_str )) })? } else { return Err(FFIError::InternalError( - "No trusted context provider configured".to_string(), + "No local contract cache configured".to_string(), )); }; // Convert FFI types to Rust types @@ -571,15 +570,15 @@ mod tests { ) }; - // Mock SDK doesn't have trusted provider, so it will fail + // Mock SDK has no local contract cache, so it will fail assert!(!result.error.is_null()); unsafe { let error = &*result.error; assert_eq!(error.code, DashSDKErrorCode::InternalError); let error_msg = CStr::from_ptr(error.message).to_str().unwrap(); assert!( - error_msg.contains("trusted context provider"), - "Expected trusted provider error, got: '{}'", + error_msg.contains("No local contract cache configured"), + "Expected missing local contract cache error, got: '{}'", error_msg ); } @@ -625,15 +624,15 @@ mod tests { ) }; - // Mock SDK doesn't have trusted provider, so it will fail + // Mock SDK has no local contract cache, so it will fail assert!(!result.error.is_null()); unsafe { let error = &*result.error; assert_eq!(error.code, DashSDKErrorCode::InternalError); let error_msg = CStr::from_ptr(error.message).to_str().unwrap(); assert!( - error_msg.contains("trusted context provider"), - "Expected trusted provider error, got: '{}'", + error_msg.contains("No local contract cache configured"), + "Expected missing local contract cache error, got: '{}'", error_msg ); } diff --git a/packages/rs-sdk-ffi/src/document/purchase.rs b/packages/rs-sdk-ffi/src/document/purchase.rs index 5007c25309e..a1f7e6c9be8 100644 --- a/packages/rs-sdk-ffi/src/document/purchase.rs +++ b/packages/rs-sdk-ffi/src/document/purchase.rs @@ -15,7 +15,6 @@ use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::prelude::{Identifier, UserFeeIncrease}; use dash_sdk::platform::documents::transitions::DocumentPurchaseTransitionBuilder; use dash_sdk::platform::IdentityPublicKey; -use drive_proof_verifier::ContextProvider; use std::ffi::CStr; use std::os::raw::c_char; @@ -99,23 +98,23 @@ pub unsafe extern "C" fn dash_sdk_document_purchase( FFIError::InternalError(format!("Failed to increment document revision: {}", e)) })?; - // Get contract from trusted context provider - let data_contract = if let Some(ref provider) = wrapper.trusted_provider { + // Get contract from the SDK's local contract cache + let data_contract = if let Some(ref provider) = wrapper.local_contracts { let platform_version = wrapper.sdk.version(); provider - .get_data_contract(&contract_id, platform_version) + .data_contract(&contract_id, platform_version) .map_err(|e| { FFIError::InternalError(format!("Failed to get contract from context: {}", e)) })? .ok_or_else(|| { FFIError::InternalError(format!( - "Contract {} not found in trusted context", + "Contract {} not found in the local contract cache", contract_id_str )) })? } else { return Err(FFIError::InternalError( - "No trusted context provider configured".to_string(), + "No local contract cache configured".to_string(), )); }; // Convert FFI types to Rust types @@ -269,23 +268,23 @@ pub unsafe extern "C" fn dash_sdk_document_purchase_and_wait( FFIError::InternalError(format!("Failed to increment document revision: {}", e)) })?; - // Get contract from trusted context provider - let data_contract = if let Some(ref provider) = wrapper.trusted_provider { + // Get contract from the SDK's local contract cache + let data_contract = if let Some(ref provider) = wrapper.local_contracts { let platform_version = wrapper.sdk.version(); provider - .get_data_contract(&contract_id, platform_version) + .data_contract(&contract_id, platform_version) .map_err(|e| { FFIError::InternalError(format!("Failed to get contract from context: {}", e)) })? .ok_or_else(|| { FFIError::InternalError(format!( - "Contract {} not found in trusted context", + "Contract {} not found in the local contract cache", contract_id_str )) })? } else { return Err(FFIError::InternalError( - "No trusted context provider configured".to_string(), + "No local contract cache configured".to_string(), )); }; // Convert FFI types to Rust types @@ -629,15 +628,15 @@ mod tests { ) }; - // Mock SDK doesn't have trusted provider, so it will fail + // Mock SDK has no local contract cache, so it will fail assert!(!result.error.is_null()); unsafe { let error = &*result.error; assert_eq!(error.code, DashSDKErrorCode::InternalError); let error_msg = CStr::from_ptr(error.message).to_str().unwrap(); assert!( - error_msg.contains("trusted context provider"), - "Expected trusted provider error, got: '{}'", + error_msg.contains("No local contract cache configured"), + "Expected missing local contract cache error, got: '{}'", error_msg ); } @@ -685,15 +684,15 @@ mod tests { ) }; - // Mock SDK doesn't have trusted provider, so it will fail + // Mock SDK has no local contract cache, so it will fail assert!(!result.error.is_null()); unsafe { let error = &*result.error; assert_eq!(error.code, DashSDKErrorCode::InternalError); let error_msg = CStr::from_ptr(error.message).to_str().unwrap(); assert!( - error_msg.contains("trusted context provider"), - "Expected trusted provider error, got: '{}'", + error_msg.contains("No local contract cache configured"), + "Expected missing local contract cache error, got: '{}'", error_msg ); } diff --git a/packages/rs-sdk-ffi/src/document/put.rs b/packages/rs-sdk-ffi/src/document/put.rs index a8e4e871add..ac807217085 100644 --- a/packages/rs-sdk-ffi/src/document/put.rs +++ b/packages/rs-sdk-ffi/src/document/put.rs @@ -7,7 +7,6 @@ use dash_sdk::platform::documents::transitions::{ DocumentCreateTransitionBuilder, DocumentReplaceTransitionBuilder, }; use dash_sdk::platform::IdentityPublicKey; -use drive_proof_verifier::ContextProvider; use std::ffi::CStr; use std::os::raw::c_char; @@ -79,23 +78,23 @@ pub unsafe extern "C" fn dash_sdk_document_put_to_platform( let contract_id = Identifier::from_string(contract_id_str, Encoding::Base58) .map_err(|e| FFIError::InternalError(format!("Invalid contract ID: {}", e)))?; - // Get contract from trusted context provider - let data_contract = if let Some(ref provider) = wrapper.trusted_provider { + // Get contract from the SDK's local contract cache + let data_contract = if let Some(ref provider) = wrapper.local_contracts { let platform_version = wrapper.sdk.version(); provider - .get_data_contract(&contract_id, platform_version) + .data_contract(&contract_id, platform_version) .map_err(|e| { FFIError::InternalError(format!("Failed to get contract from context: {}", e)) })? .ok_or_else(|| { FFIError::InternalError(format!( - "Contract {} not found in trusted context", + "Contract {} not found in the local contract cache", contract_id_str )) })? } else { return Err(FFIError::InternalError( - "No trusted context provider configured".to_string(), + "No local contract cache configured".to_string(), )); }; @@ -249,23 +248,23 @@ pub unsafe extern "C" fn dash_sdk_document_put_to_platform_and_wait( let contract_id = Identifier::from_string(contract_id_str, Encoding::Base58) .map_err(|e| FFIError::InternalError(format!("Invalid contract ID: {}", e)))?; - // Get contract from trusted context provider - let data_contract = if let Some(ref provider) = wrapper.trusted_provider { + // Get contract from the SDK's local contract cache + let data_contract = if let Some(ref provider) = wrapper.local_contracts { let platform_version = wrapper.sdk.version(); provider - .get_data_contract(&contract_id, platform_version) + .data_contract(&contract_id, platform_version) .map_err(|e| { FFIError::InternalError(format!("Failed to get contract from context: {}", e)) })? .ok_or_else(|| { FFIError::InternalError(format!( - "Contract {} not found in trusted context", + "Contract {} not found in the local contract cache", contract_id_str )) })? } else { return Err(FFIError::InternalError( - "No trusted context provider configured".to_string(), + "No local contract cache configured".to_string(), )); }; @@ -600,15 +599,15 @@ mod tests { ) }; - // Mock SDK doesn't have trusted provider, so it will fail + // Mock SDK has no local contract cache, so it will fail assert!(!result.error.is_null()); unsafe { let error = &*result.error; assert_eq!(error.code, DashSDKErrorCode::InternalError); let error_msg = CStr::from_ptr(error.message).to_str().unwrap(); assert!( - error_msg.contains("trusted context provider"), - "Expected trusted provider error, got: '{}'", + error_msg.contains("No local contract cache configured"), + "Expected missing local contract cache error, got: '{}'", error_msg ); } @@ -658,15 +657,15 @@ mod tests { ) }; - // Mock SDK doesn't have trusted provider, so it will fail + // Mock SDK has no local contract cache, so it will fail assert!(!result.error.is_null()); unsafe { let error = &*result.error; assert_eq!(error.code, DashSDKErrorCode::InternalError); let error_msg = CStr::from_ptr(error.message).to_str().unwrap(); assert!( - error_msg.contains("trusted context provider"), - "Expected trusted provider error, got: '{}'", + error_msg.contains("No local contract cache configured"), + "Expected missing local contract cache error, got: '{}'", error_msg ); } diff --git a/packages/rs-sdk-ffi/src/document/queries/fetch.rs b/packages/rs-sdk-ffi/src/document/queries/fetch.rs index 9877afe19de..153b0c0b9a8 100644 --- a/packages/rs-sdk-ffi/src/document/queries/fetch.rs +++ b/packages/rs-sdk-ffi/src/document/queries/fetch.rs @@ -4,7 +4,6 @@ use dash_sdk::dpp::document::Document; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::prelude::{DataContract, Identifier}; use dash_sdk::platform::{DocumentQuery, Fetch}; -use drive_proof_verifier::ContextProvider; use std::ffi::CStr; use std::os::raw::c_char; @@ -12,7 +11,7 @@ use crate::sdk::SDKWrapper; use crate::types::{DataContractHandle, DocumentHandle, SDKHandle}; use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, FFIError}; -/// Fetch a document by ID using contract ID (gets contract from trusted provider) +/// Fetch a document by ID using contract ID (gets contract from the local contract cache) #[no_mangle] pub unsafe extern "C" fn dash_sdk_document_fetch_by_contract_id( sdk_handle: *mut SDKHandle, @@ -53,23 +52,23 @@ pub unsafe extern "C" fn dash_sdk_document_fetch_by_contract_id( let contract_id = Identifier::from_string(contract_id_str, Encoding::Base58) .map_err(|e| FFIError::InternalError(format!("Invalid contract ID: {}", e)))?; - // Get contract from trusted context provider - let data_contract = if let Some(ref provider) = wrapper.trusted_provider { + // Get contract from the SDK's local contract cache + let data_contract = if let Some(ref provider) = wrapper.local_contracts { let platform_version = wrapper.sdk.version(); provider - .get_data_contract(&contract_id, platform_version) + .data_contract(&contract_id, platform_version) .map_err(|e| { FFIError::InternalError(format!("Failed to get contract from context: {}", e)) })? .ok_or_else(|| { FFIError::InternalError(format!( - "Contract {} not found in trusted context", + "Contract {} not found in the local contract cache", contract_id_str )) })? } else { return Err(FFIError::InternalError( - "No trusted context provider configured".to_string(), + "No local contract cache configured".to_string(), )); }; diff --git a/packages/rs-sdk-ffi/src/document/replace.rs b/packages/rs-sdk-ffi/src/document/replace.rs index b1763005a9e..6b598b1ab90 100644 --- a/packages/rs-sdk-ffi/src/document/replace.rs +++ b/packages/rs-sdk-ffi/src/document/replace.rs @@ -16,7 +16,6 @@ use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::prelude::{Identifier, UserFeeIncrease}; use dash_sdk::platform::documents::transitions::DocumentReplaceTransitionBuilder; use dash_sdk::platform::IdentityPublicKey; -use drive_proof_verifier::ContextProvider; use std::ffi::CStr; use std::os::raw::c_char; use tracing::{debug, error, info}; @@ -77,23 +76,23 @@ pub unsafe extern "C" fn dash_sdk_document_replace_on_platform( let contract_id = Identifier::from_string(contract_id_str, Encoding::Base58) .map_err(|e| FFIError::InternalError(format!("Invalid contract ID: {}", e)))?; - // Get contract from trusted context provider - let data_contract = if let Some(ref provider) = wrapper.trusted_provider { + // Get contract from the SDK's local contract cache + let data_contract = if let Some(ref provider) = wrapper.local_contracts { let platform_version = wrapper.sdk.version(); provider - .get_data_contract(&contract_id, platform_version) + .data_contract(&contract_id, platform_version) .map_err(|e| { FFIError::InternalError(format!("Failed to get contract from context: {}", e)) })? .ok_or_else(|| { FFIError::InternalError(format!( - "Contract {} not found in trusted context", + "Contract {} not found in the local contract cache", contract_id_str )) })? } else { return Err(FFIError::InternalError( - "No trusted context provider configured".to_string(), + "No local contract cache configured".to_string(), )); }; @@ -250,23 +249,23 @@ pub unsafe extern "C" fn dash_sdk_document_replace_on_platform_and_wait( FFIError::InternalError(format!("Failed to increment document revision: {}", e)) })?; - // Get contract from trusted context provider - let data_contract = if let Some(ref provider) = wrapper.trusted_provider { + // Get contract from the SDK's local contract cache + let data_contract = if let Some(ref provider) = wrapper.local_contracts { let platform_version = wrapper.sdk.version(); provider - .get_data_contract(&contract_id, platform_version) + .data_contract(&contract_id, platform_version) .map_err(|e| { FFIError::InternalError(format!("Failed to get contract from context: {}", e)) })? .ok_or_else(|| { FFIError::InternalError(format!( - "Contract {} not found in trusted context", + "Contract {} not found in the local contract cache", contract_id_str )) })? } else { return Err(FFIError::InternalError( - "No trusted context provider configured".to_string(), + "No local contract cache configured".to_string(), )); }; @@ -726,15 +725,15 @@ mod tests { ) }; - // Mock SDK doesn't have trusted provider, so it will fail + // Mock SDK has no local contract cache, so it will fail assert!(!result.error.is_null()); unsafe { let error = &*result.error; assert_eq!(error.code, DashSDKErrorCode::InternalError); let error_msg = CStr::from_ptr(error.message).to_str().unwrap(); assert!( - error_msg.contains("trusted context provider"), - "Expected trusted provider error, got: '{}'", + error_msg.contains("No local contract cache configured"), + "Expected missing local contract cache error, got: '{}'", error_msg ); } diff --git a/packages/rs-sdk-ffi/src/document/transfer.rs b/packages/rs-sdk-ffi/src/document/transfer.rs index 6c4849fbe4f..94756aa0d9c 100644 --- a/packages/rs-sdk-ffi/src/document/transfer.rs +++ b/packages/rs-sdk-ffi/src/document/transfer.rs @@ -6,7 +6,6 @@ use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::prelude::{Identifier, UserFeeIncrease}; use dash_sdk::platform::documents::transitions::DocumentTransferTransitionBuilder; use dash_sdk::platform::IdentityPublicKey; -use drive_proof_verifier::ContextProvider; use std::ffi::CStr; use std::os::raw::c_char; @@ -110,23 +109,23 @@ pub unsafe extern "C" fn dash_sdk_document_transfer_to_identity( FFIError::InternalError(format!("Failed to increment document revision: {}", e)) })?; - // Get contract from trusted context provider - let data_contract = if let Some(ref provider) = wrapper.trusted_provider { + // Get contract from the SDK's local contract cache + let data_contract = if let Some(ref provider) = wrapper.local_contracts { let platform_version = wrapper.sdk.version(); provider - .get_data_contract(&contract_id, platform_version) + .data_contract(&contract_id, platform_version) .map_err(|e| { FFIError::InternalError(format!("Failed to get contract from context: {}", e)) })? .ok_or_else(|| { FFIError::InternalError(format!( - "Contract {} not found in trusted context", + "Contract {} not found in the local contract cache", contract_id_str )) })? } else { return Err(FFIError::InternalError( - "No trusted context provider configured".to_string(), + "No local contract cache configured".to_string(), )); }; @@ -279,23 +278,23 @@ pub unsafe extern "C" fn dash_sdk_document_transfer_to_identity_and_wait( FFIError::InternalError(format!("Failed to increment document revision: {}", e)) })?; - // Get contract from trusted context provider - let data_contract = if let Some(ref provider) = wrapper.trusted_provider { + // Get contract from the SDK's local contract cache + let data_contract = if let Some(ref provider) = wrapper.local_contracts { let platform_version = wrapper.sdk.version(); provider - .get_data_contract(&contract_id, platform_version) + .data_contract(&contract_id, platform_version) .map_err(|e| { FFIError::InternalError(format!("Failed to get contract from context: {}", e)) })? .ok_or_else(|| { FFIError::InternalError(format!( - "Contract {} not found in trusted context", + "Contract {} not found in the local contract cache", contract_id_str )) })? } else { return Err(FFIError::InternalError( - "No trusted context provider configured".to_string(), + "No local contract cache configured".to_string(), )); }; diff --git a/packages/rs-sdk-ffi/src/document/util.rs b/packages/rs-sdk-ffi/src/document/util.rs index af99fee7c35..a6554a8d6ce 100644 --- a/packages/rs-sdk-ffi/src/document/util.rs +++ b/packages/rs-sdk-ffi/src/document/util.rs @@ -6,7 +6,6 @@ use dash_sdk::dpp::document::{Document, DocumentV0Setters}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::platform_value::Value; use dash_sdk::dpp::prelude::Identifier; -use drive_proof_verifier::ContextProvider; use std::collections::BTreeMap; use std::ffi::CStr; use std::os::raw::c_char; @@ -38,7 +37,7 @@ pub unsafe extern "C" fn dash_sdk_document_free(handle: *mut DocumentHandle) { /// `dash_sdk_document_create` sanitizes them: base58 or hex strings become /// identifiers, hex or base64 strings become byte arrays and integers narrow /// to their declared width, typed array elements included. The data contract -/// comes from the trusted context provider, the same lookup +/// comes from the SDK's local contract cache, the same lookup /// `dash_sdk_document_replace_on_platform` makes. On any error the document /// is left unchanged. /// @@ -145,7 +144,7 @@ pub unsafe extern "C" fn dash_sdk_document_set_properties( } /// Sanitizes `properties` against the `document_type_name` type of the -/// contract the trusted context provider holds under `data_contract_id`. +/// contract the SDK's local contract cache holds under `data_contract_id`. fn sanitize_properties_for_document_type( wrapper: &SDKWrapper, data_contract_id: &str, @@ -155,18 +154,19 @@ fn sanitize_properties_for_document_type( let contract_id = Identifier::from_string(data_contract_id, Encoding::Base58) .map_err(|e| FFIError::InternalError(format!("Invalid contract ID: {}", e)))?; - let provider = wrapper.trusted_provider.as_ref().ok_or_else(|| { - FFIError::InternalError("No trusted context provider configured".to_string()) - })?; + let provider = wrapper + .local_contracts + .as_ref() + .ok_or_else(|| FFIError::InternalError("No local contract cache configured".to_string()))?; let data_contract = provider - .get_data_contract(&contract_id, wrapper.sdk.version()) + .data_contract(&contract_id, wrapper.sdk.version()) .map_err(|e| { FFIError::InternalError(format!("Failed to get contract from context: {}", e)) })? .ok_or_else(|| { FFIError::InternalError(format!( - "Contract {} not found in trusted context", + "Contract {} not found in the local contract cache", data_contract_id )) })?; @@ -185,15 +185,13 @@ mod tests { use super::*; use crate::dash_sdk_error_free; use crate::test_utils::test_utils::destroy_mock_sdk_handle; - use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::data_contract::DataContractFactory; use dash_sdk::dpp::document::{DocumentV0, DocumentV0Getters}; use dash_sdk::dpp::platform_value::platform_value; use dash_sdk::dpp::prelude::DataContract; use dash_sdk::dpp::version::PlatformVersion; - use rs_sdk_trusted_context_provider::TrustedHttpContextProvider; + use rs_sdk_trusted_context_provider::LocalContextResolver; use std::ffi::CString; - use std::num::NonZeroUsize; use std::sync::Arc; /// A contract with one `note` type: an identifier, a byte array and a @@ -245,19 +243,12 @@ mod tests { .clone() } - /// A mock SDK handle whose trusted context provider serves `contracts`. - /// The provider's quorum URL is an address literal, so nothing resolves - /// or connects. + /// A mock SDK handle whose local contract cache serves `contracts`. fn sdk_handle_with_known_contracts(contracts: Vec) -> *mut SDKHandle { - let provider = TrustedHttpContextProvider::new_with_url( - Network::Regtest, - "http://127.0.0.1:22444".to_string(), - NonZeroUsize::new(1).expect("non-zero cache size"), - ) - .expect("offline trusted context provider") - .with_known_contracts(contracts); + let local_contracts = LocalContextResolver::new(); + local_contracts.add_known_contracts(contracts); let mut wrapper = SDKWrapper::new_mock(); - wrapper.trusted_provider = Some(Arc::new(provider)); + wrapper.local_contracts = Some(Arc::new(local_contracts)); Box::into_raw(Box::new(wrapper)) as *mut SDKHandle } diff --git a/packages/rs-sdk-ffi/src/evonode/queries/status.rs b/packages/rs-sdk-ffi/src/evonode/queries/status.rs index f80b7c0af1e..87a3e472079 100644 --- a/packages/rs-sdk-ffi/src/evonode/queries/status.rs +++ b/packages/rs-sdk-ffi/src/evonode/queries/status.rs @@ -419,7 +419,8 @@ mod tests { runtime: Arc::new(crate::runtime::BigStackRuntime::build_shared().expect("runtime")), trusted_provider: None, adaptive_provider: None, - spv_source_controller: None, + spv_source_slot: None, + local_contracts: None, }); let handle = Box::into_raw(wrapper) as *mut SDKHandle; diff --git a/packages/rs-sdk-ffi/src/sdk.rs b/packages/rs-sdk-ffi/src/sdk.rs index 090fc7ee3c7..bafb0e40625 100644 --- a/packages/rs-sdk-ffi/src/sdk.rs +++ b/packages/rs-sdk-ffi/src/sdk.rs @@ -10,11 +10,14 @@ use dash_sdk::{Sdk, SdkBuilder}; use std::ffi::CStr; use std::str::FromStr; -use crate::context_provider::{ - AdaptiveContextProvider, AdaptiveContextProviderSource, AuxiliaryContextProvider, - ContextProviderHandle, ContextProviderMode, ContextProviderWrapper, CoreSDKHandle, - SpvContextProvider, SpvSourceController, +use dash_context_provider::routing::{ + AdaptiveContextProvider, AdaptiveContextProviderSource, ContextProviderMode, ContractSource, + SpvContextProvider, SpvSourceSlot, }; +use dash_context_provider::ContextProvider; +use rs_sdk_trusted_context_provider::{LocalContextResolver, TrustedHttpContextProvider}; + +use crate::context_provider::{ContextProviderHandle, ContextProviderWrapper, CoreSDKHandle}; use crate::runtime::BigStackRuntime; use crate::types::{DashSDKConfig, FFINetwork, Network, SDKHandle}; use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, FFIError}; @@ -58,9 +61,13 @@ fn apply_version(builder: SdkBuilder, platform_version: u32) -> Result, - pub trusted_provider: Option>, + pub trusted_provider: Option>, pub adaptive_provider: Option>, - pub spv_source_controller: Option>, + pub spv_source_slot: Option>, + /// The contracts and token configurations this SDK's provider reads, for + /// the FFIs that look contracts up or preload them. `None` when the + /// provider is the caller's own. + pub local_contracts: Option>, } impl SDKWrapper { @@ -70,7 +77,8 @@ impl SDKWrapper { runtime: Arc::new(BigStackRuntime::new(runtime)), trusted_provider: None, adaptive_provider: None, - spv_source_controller: None, + spv_source_slot: None, + local_contracts: None, } } @@ -78,14 +86,15 @@ impl SDKWrapper { fn new_with_trusted_provider( sdk: Sdk, runtime: Runtime, - provider: Arc, + provider: Arc, ) -> Self { SDKWrapper { sdk, runtime: Arc::new(BigStackRuntime::new(runtime)), + local_contracts: Some(provider.local_resolver()), trusted_provider: Some(provider), adaptive_provider: None, - spv_source_controller: None, + spv_source_slot: None, } } @@ -101,7 +110,8 @@ impl SDKWrapper { runtime, trusted_provider: None, adaptive_provider: None, - spv_source_controller: None, + spv_source_slot: None, + local_contracts: None, } } } @@ -197,7 +207,8 @@ pub unsafe extern "C" fn dash_sdk_create(config: *const DashSDKConfig) -> DashSD runtime, trusted_provider: None, adaptive_provider: None, - spv_source_controller: None, + spv_source_slot: None, + local_contracts: None, }); let handle = Box::into_raw(wrapper) as *mut SDKHandle; DashSDKResult::success(handle as *mut std::os::raw::c_void) @@ -311,7 +322,8 @@ pub unsafe extern "C" fn dash_sdk_create_extended( runtime, trusted_provider: None, adaptive_provider: None, - spv_source_controller: None, + spv_source_slot: None, + local_contracts: None, }); let handle = Box::into_raw(wrapper) as *mut SDKHandle; DashSDKResult::success(handle as *mut std::os::raw::c_void) @@ -328,9 +340,11 @@ enum FixedContextProviderKind { } struct FixedProviderParts { - provider: Arc, + provider: Arc, + trusted: Option>, adaptive: Option>, - spv_source_controller: Option>, + spv_source_slot: Option>, + local_contracts: Arc, } /// Create an SDK with a fixed trusted context provider. @@ -344,9 +358,10 @@ pub unsafe extern "C" fn dash_sdk_create_trusted(config: *const DashSDKConfig) - /// Create an SDK with a fixed production SPV context provider. /// -/// 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. +/// Quorum lookups fail closed until a wallet manager publishes its SPV client +/// and the client has synced. Contracts and tokens come from a local cache and +/// the embedded system contracts. The trusted quorum service is never built, +/// so `quorum_url` is ignored. /// /// # Safety /// `config` must be valid for the duration of the call. @@ -390,27 +405,6 @@ unsafe fn dash_sdk_create_with_fixed_provider( info!(?network, ?provider_kind, "creating SDK context provider"); - // Create the trusted context provider; see `build_trusted_provider` for - // how the quorum lookup base URL is chosen. - let explicit_quorum_url: Option = if config.quorum_url.is_null() { - None - } else { - match unsafe { CStr::from_ptr(config.quorum_url) }.to_str() { - Ok(s) if !s.is_empty() => Some(s.to_string()), - Ok(_) => None, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!("Invalid quorum URL string: {}", e), - )) - } - } - }; - let trusted_provider = match build_trusted_provider(network, explicit_quorum_url) { - Ok(provider) => Arc::new(provider), - Err(error) => return DashSDKResult::error(error), - }; - // 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. @@ -465,43 +459,9 @@ unsafe fn dash_sdk_create_with_fixed_provider( } }; - let provider_for_wrapper = Arc::clone(&trusted_provider); - 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), - } - } + let provider_parts = match fixed_provider_parts(config, network, provider_kind) { + Ok(parts) => parts, + Err(error) => return DashSDKResult::error(error), }; info!( @@ -520,11 +480,10 @@ unsafe fn dash_sdk_create_with_fixed_provider( match sdk_result { Ok(sdk) => { - // 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); + // Trusted and Adaptive may route quorum lookups to the trusted + // service, so warm its cache; strict SPV has no trusted provider. + if let Some(trusted) = &provider_parts.trusted { + let provider_for_prefetch = Arc::clone(trusted); let runtime_clone = runtime.handle().clone(); runtime_clone.spawn(async move { // Quorum key fetches that come in while this runs share it. @@ -540,9 +499,10 @@ unsafe fn dash_sdk_create_with_fixed_provider( let wrapper = Box::new(SDKWrapper { sdk, runtime, - trusted_provider: Some(provider_for_wrapper), + trusted_provider: provider_parts.trusted, adaptive_provider: provider_parts.adaptive, - spv_source_controller: provider_parts.spv_source_controller, + spv_source_slot: provider_parts.spv_source_slot, + local_contracts: Some(provider_parts.local_contracts), }); let handle = Box::into_raw(wrapper) as *mut SDKHandle; DashSDKResult::success(handle as *mut std::os::raw::c_void) @@ -551,6 +511,76 @@ unsafe fn dash_sdk_create_with_fixed_provider( } } +/// Build the context provider of `kind` and the pieces the SDK wrapper keeps. +/// +/// Trusted and Adaptive read contracts from the trusted provider's own local +/// cache, so contracts preloaded through the wrapper and contracts the +/// provider verifies proofs with are one set. Strict SPV gets a standalone +/// cache and never builds the trusted provider. +unsafe fn fixed_provider_parts( + config: &DashSDKConfig, + network: Network, + kind: FixedContextProviderKind, +) -> Result { + if kind == FixedContextProviderKind::Spv { + let local_contracts = Arc::new(LocalContextResolver::new()); + let provider = Arc::new(SpvContextProvider::new( + Arc::clone(&local_contracts) as Arc, + network, + )); + return Ok(FixedProviderParts { + spv_source_slot: Some(provider.source_slot()), + provider, + trusted: None, + adaptive: None, + local_contracts, + }); + } + + // See `build_trusted_provider` for how the quorum lookup base URL is chosen. + let trusted = Arc::new(build_trusted_provider( + network, + explicit_quorum_url(config)?, + )?); + let local_contracts = trusted.local_resolver(); + if kind == FixedContextProviderKind::Trusted { + return Ok(FixedProviderParts { + provider: Arc::clone(&trusted) as Arc, + trusted: Some(trusted), + adaptive: None, + spv_source_slot: None, + local_contracts, + }); + } + let adaptive = Arc::new(AdaptiveContextProvider::new( + Arc::clone(&trusted) as Arc, + Arc::clone(&local_contracts) as Arc, + network, + )); + Ok(FixedProviderParts { + provider: Arc::clone(&adaptive) as Arc, + trusted: Some(trusted), + spv_source_slot: Some(adaptive.source_slot()), + adaptive: Some(adaptive), + local_contracts, + }) +} + +/// The caller's quorum lookup base URL; an empty string means none. +unsafe fn explicit_quorum_url(config: &DashSDKConfig) -> Result, DashSDKError> { + if config.quorum_url.is_null() { + return Ok(None); + } + match CStr::from_ptr(config.quorum_url).to_str() { + Ok(s) if !s.is_empty() => Ok(Some(s.to_string())), + Ok(_) => Ok(None), + Err(e) => Err(DashSDKError::new( + DashSDKErrorCode::InvalidParameter, + format!("Invalid quorum URL string: {}", e), + )), + } +} + /// Quorum and contract cache size of the trusted context provider. const TRUSTED_PROVIDER_CACHE_SIZE: std::num::NonZeroUsize = match std::num::NonZeroUsize::new(100) { Some(size) => size, @@ -571,7 +601,7 @@ const TRUSTED_PROVIDER_CACHE_SIZE: std::num::NonZeroUsize = match std::num::NonZ pub(crate) fn build_trusted_provider( network: Network, quorum_url: Option, -) -> Result { +) -> Result { let cache_size = TRUSTED_PROVIDER_CACHE_SIZE; let provider = if let Some(quorum_url) = quorum_url { info!( @@ -656,18 +686,19 @@ 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 } -/// Borrow the source controller retained by a fixed SPV-capable provider. +/// The SPV source slot of an SDK built with a fixed SPV-capable provider, if +/// any. The returned `Arc` shares the slot with the provider. /// /// # Safety /// `sdk_handle` must be a valid SDK handle for the duration of this call. -pub unsafe fn dash_sdk_spv_source_controller( +pub unsafe fn dash_sdk_spv_source_slot( sdk_handle: *mut SDKHandle, -) -> Result>, String> { +) -> Result>, String> { if sdk_handle.is_null() { return Err("SDK handle is null".to_string()); } let wrapper = &*(sdk_handle as *const SDKWrapper); - Ok(wrapper.spv_source_controller.clone()) + Ok(wrapper.spv_source_slot.clone()) } /// Set the adaptive quorum routing mode without replacing the SDK provider. @@ -721,7 +752,7 @@ pub unsafe extern "C" fn dash_sdk_get_context_provider_source(sdk_handle: *const // 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() { + } else if wrapper.spv_source_slot.is_some() { AdaptiveContextProviderSource::Spv as u8 } else { AdaptiveContextProviderSource::Trusted as u8 @@ -839,13 +870,14 @@ pub unsafe extern "C" fn dash_sdk_get_network(handle: *const SDKHandle) -> FFINe wrapper.sdk.network.into() } -/// Add known contracts to the SDK's trusted context provider +/// Add known contracts to the SDK's local contract cache /// -/// This allows pre-loading data contracts into the trusted provider's cache, -/// avoiding network calls for these contracts. +/// This preloads data contracts into the cache the SDK's context provider +/// reads, avoiding network calls for these contracts. /// /// # Safety -/// - `handle` must be a valid SDK handle created with dash_sdk_create_trusted +/// - `handle` must be a valid SDK handle created with `dash_sdk_create_trusted`, +/// `dash_sdk_create_spv` or `dash_sdk_create_adaptive` /// - `contract_ids` must be a valid comma-separated list of contract IDs /// - `serialized_contracts` must be a valid pointer to an array of serialized contract data /// - `contract_lengths` must be a valid pointer to an array of contract data lengths @@ -874,13 +906,12 @@ pub unsafe extern "C" fn dash_sdk_add_known_contracts( let wrapper = &*(handle as *const SDKWrapper); - // Check if this SDK has a trusted provider - let provider = match &wrapper.trusted_provider { - Some(p) => p.clone(), + let local_contracts = match &wrapper.local_contracts { + Some(local_contracts) => Arc::clone(local_contracts), None => { return DashSDKResult::error(DashSDKError::new( DashSDKErrorCode::InvalidState, - "SDK does not have a trusted context provider. Use dash_sdk_create_trusted to create an SDK with trusted provider.".to_string(), + "SDK has no local contract cache. Create it with dash_sdk_create_trusted, dash_sdk_create_spv or dash_sdk_create_adaptive.".to_string(), )); } }; @@ -937,10 +968,10 @@ pub unsafe extern "C" fn dash_sdk_add_known_contracts( } // Add all contracts to the provider - provider.add_known_contracts(contracts); + local_contracts.add_known_contracts(contracts); eprintln!( - "✅ Added {} known contracts to trusted provider", + "✅ Added {} known contracts to the local contract cache", contract_count ); @@ -1026,3 +1057,149 @@ mod trusted_provider_tests { } } } + +#[cfg(test)] +mod fixed_provider_tests { + use super::*; + use crate::dash_sdk_error_free; + use dash_sdk::dpp::data_contract::accessors::v0::{ + DataContractV0Getters, DataContractV0Setters, + }; + use dash_sdk::dpp::platform_value::string_encoding::Encoding; + use dash_sdk::dpp::prelude::{DataContract, Identifier}; + use dash_sdk::dpp::serialization::PlatformSerializableWithPlatformVersion; + use dash_sdk::dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dash_sdk::dpp::version::PlatformVersion; + use std::ffi::CString; + + fn config(network: Network, dapi: &CString, quorum_url: Option<&CString>) -> DashSDKConfig { + DashSDKConfig { + network: network.into(), + dapi_addresses: dapi.as_ptr(), + skip_asset_lock_proof_verification: false, + request_retry_count: 1, + request_timeout_ms: 1_000, + quorum_url: quorum_url.map_or(std::ptr::null(), |url| url.as_ptr()), + platform_version: 0, + } + } + + /// The DPNS contract under an id no system contract has. + fn app_contract(id_byte: u8) -> DataContract { + let mut contract = + load_system_data_contract(SystemDataContract::DPNS, PlatformVersion::latest()) + .expect("DPNS contract"); + contract.set_id(Identifier::new([id_byte; 32])); + contract + } + + /// A strict SPV SDK on devnet with no quorum URL. Nothing connects; the + /// DAPI address is only parsed. + fn strict_spv_sdk_without_a_quorum_service() -> *mut SDKHandle { + let dapi = CString::new("http://127.0.0.1:1").expect("address"); + let config = config(Network::Devnet, &dapi, None); + + let result = unsafe { dash_sdk_create_spv(&config) }; + if !result.error.is_null() { + let message = unsafe { CStr::from_ptr((*result.error).message) } + .to_string_lossy() + .into_owned(); + unsafe { dash_sdk_error_free(result.error) }; + panic!("strict SPV SDK failed to build: {message}"); + } + result.data as *mut SDKHandle + } + + /// Strict SPV never asks the trusted quorum service, so building it must + /// not need one: a devnet SDK without a quorum URL builds, and it holds no + /// trusted provider. + #[test] + fn should_build_a_strict_spv_sdk_without_a_quorum_service() { + let handle = strict_spv_sdk_without_a_quorum_service(); + let wrapper = unsafe { &*(handle as *const SDKWrapper) }; + assert!(wrapper.trusted_provider.is_none()); + assert!(wrapper.spv_source_slot.is_some()); + assert!(wrapper.local_contracts.is_some()); + unsafe { dash_sdk_destroy(handle) }; + } + + /// Contracts preloaded through the FFI and contracts the provider verifies + /// proofs with must be one set, for every provider kind; otherwise a + /// preloaded contract is invisible to proof verification, and a contract + /// the SDK registers is invisible to the document FFIs. + #[test] + fn should_share_one_contract_cache_between_preloading_and_proofs() { + let dapi = CString::new("http://127.0.0.1:1").expect("address"); + let quorum_url = CString::new("http://127.0.0.1:1").expect("quorum url"); + for kind in [ + FixedContextProviderKind::Trusted, + FixedContextProviderKind::Spv, + FixedContextProviderKind::Adaptive, + ] { + let config = config(Network::Regtest, &dapi, Some(&quorum_url)); + let Ok(parts) = (unsafe { fixed_provider_parts(&config, Network::Regtest, kind) }) + else { + panic!("{kind:?} provider failed to build"); + }; + + let preloaded = app_contract(0x61); + let preloaded_id = preloaded.id(); + parts.local_contracts.add_known_contract(preloaded); + assert!( + parts + .provider + .get_data_contract(&preloaded_id, PlatformVersion::latest()) + .expect("lookup") + .is_some(), + "{kind:?} provider must see a preloaded contract" + ); + + let registered = Arc::new(app_contract(0x62)); + let registered_id = registered.id(); + parts.provider.register_data_contract(registered); + assert!( + parts + .local_contracts + .get_known_contract(®istered_id) + .is_some(), + "{kind:?} local cache must see a registered contract" + ); + } + } + + /// `dash_sdk_add_known_contracts` must reach the cache a strict SPV + /// provider verifies proofs with; it has no trusted provider to fill. + #[test] + fn should_preload_contracts_into_a_strict_spv_sdk() { + let handle = strict_spv_sdk_without_a_quorum_service(); + let wrapper = unsafe { &*(handle as *const SDKWrapper) }; + let version = wrapper.sdk.version(); + let mut contract = + load_system_data_contract(SystemDataContract::DPNS, version).expect("DPNS contract"); + contract.set_id(Identifier::new([0x63; 32])); + let id = contract.id(); + let bytes = contract + .serialize_to_bytes_with_platform_version(version) + .expect("serialized contract"); + let ids = CString::new(id.to_string(Encoding::Base58)).expect("contract id"); + let pointers = [bytes.as_ptr()]; + let lengths = [bytes.len()]; + + let added = unsafe { + dash_sdk_add_known_contracts( + handle, + ids.as_ptr(), + pointers.as_ptr(), + lengths.as_ptr(), + 1, + ) + }; + assert!(added.error.is_null(), "preloading must succeed"); + let provider = wrapper.sdk.context_provider().expect("provider"); + assert!(provider + .get_data_contract(&id, version) + .expect("lookup") + .is_some()); + unsafe { dash_sdk_destroy(handle) }; + } +} diff --git a/packages/rs-sdk-ffi/src/types.rs b/packages/rs-sdk-ffi/src/types.rs index 913d25148c2..def76ccac45 100644 --- a/packages/rs-sdk-ffi/src/types.rs +++ b/packages/rs-sdk-ffi/src/types.rs @@ -83,12 +83,13 @@ pub struct DashSDKConfig { /// default endpoint derived from `network` (mainnet/testnet only — devnet /// needs an explicit URL, regtest defaults to the local sidecar). /// - /// **Only honored on the `dash_sdk_create_trusted` path** — that's the - /// path that builds a `TrustedHttpContextProvider`, which is the - /// component that actually performs quorum lookups. The callback-based - /// path (`dash_sdk_create_with_callbacks`) uses `CallbackContextProvider` - /// and ignores this field entirely; non-null values there are silently - /// dropped. + /// **Honored only by `dash_sdk_create_trusted` and + /// `dash_sdk_create_adaptive`**, the paths that build a + /// `TrustedHttpContextProvider`, the component that performs trusted + /// quorum lookups. `dash_sdk_create_spv` never contacts the trusted + /// quorum service, and the callback-based path + /// (`dash_sdk_create_with_callbacks`) uses `CallbackContextProvider`; + /// both ignore this field, and non-null values there are silently dropped. /// /// Same lifetime contract as `dapi_addresses`: borrowed, copied /// immediately, caller may free after the FFI call returns. From 23c2dd5c601b766ec2d8f7587d0b8456b79f30eb Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 22 Oct 2026 23:03:45 +0700 Subject: [PATCH 31/38] fix(platform-wallet): reject quorum types that name no LLMQ type in the SPV lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SpvRuntime::get_quorum_public_key_with_status` narrowed its u32 `quorum_type` with `as u8`, so 262 wrapped to 6 and returned the held type 6 quorum's key, and values naming no LLMQ type (0, 7-99, 108-255) all searched the quorums dash-spv files under `LlmqtypeUnknown`. It now converts with `u8::try_from`, rejects `LlmqtypeUnknown`, and fails with `PlatformWalletError::InvalidParameter` before taking the client lock. `quorum_lookup_context_error` maps that to `Generic`, so it fails closed. `SpvQuorumSource` already rejects non-Platform types; the runtime no longer relies on every caller doing so. Addresses thepastaclaw's review of 57bd3bf9e (runtime.rs:448-454). Test would have caught this in CI: should_reject_a_quorum_type_above_one_byte ✖ before the fix: type 262 returned Ok with the type 6 key and status Verified; type 0 returned SpvError "type 0_Unknown ... not found" ✔ after the fix (cargo test -p platform-wallet --all-features --lib). Co-Authored-By: Claude Opus 5.5 --- .../rs-platform-wallet/src/spv/runtime.rs | 60 +++++++++++++++++-- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/packages/rs-platform-wallet/src/spv/runtime.rs b/packages/rs-platform-wallet/src/spv/runtime.rs index 095c67a44ce..99ca2f48740 100644 --- a/packages/rs-platform-wallet/src/spv/runtime.rs +++ b/packages/rs-platform-wallet/src/spv/runtime.rs @@ -398,9 +398,11 @@ impl SpvRuntime { /// `Skipped`) fails with [`PlatformWalletError::QuorumNotVerified`]; that /// is usually transient, for example a freshly mined quorum whose /// work-block list has not arrived yet. A quorum dash-spv does not hold, - /// or found `Invalid`, fails with [`PlatformWalletError::SpvError`]. - /// [`PlatformWalletError::quorum_lookup_context_error`] maps either to - /// the SDK error class a context provider reports. + /// or found `Invalid`, fails with [`PlatformWalletError::SpvError`], and a + /// `quorum_type` that names no LLMQ type with + /// [`PlatformWalletError::InvalidParameter`]. + /// [`PlatformWalletError::quorum_lookup_context_error`] maps each to the + /// SDK error class a context provider reports. /// /// A caller that accepts unverified keys uses /// [`Self::get_quorum_public_key_with_status`]. @@ -439,18 +441,30 @@ impl SpvRuntime { /// other status is a key dash-spv holds but has not verified, which must /// not verify Platform proofs; [`Self::get_quorum_public_key`] enforces /// that. + /// + /// A `quorum_type` that names no LLMQ type, any value above 255 + /// included, fails with [`PlatformWalletError::InvalidParameter`] + /// rather than aliasing another quorum type. pub async fn get_quorum_public_key_with_status( &self, quorum_type: u32, quorum_hash: [u8; 32], height: u32, ) -> Result { + let llmq_type = match u8::try_from(quorum_type).map(LLMQType::from) { + Ok(LLMQType::LlmqtypeUnknown) | Err(_) => { + return Err(PlatformWalletError::InvalidParameter(format!( + "quorum type {quorum_type} is not an LLMQ type" + ))); + } + Ok(llmq_type) => llmq_type, + }; + let client_guard = self.client.read().await; let client = client_guard.as_ref().ok_or(PlatformWalletError::SpvError( "SPV Client not started".to_string(), ))?; - let llmq_type = LLMQType::from(quorum_type as u8); let qh = quorum_lookup_key(quorum_hash); let quorum = client @@ -1451,6 +1465,44 @@ mod tests { ); } + /// A caller-supplied type that names no LLMQ type must fail instead of + /// aliasing another quorum family: 262 would otherwise wrap onto the held + /// type 6 quorum and hand back its key, and 7 or 255 would search the + /// quorums dash-spv files under an unknown type. + #[tokio::test] + async fn should_reject_a_quorum_type_above_one_byte() { + use dashcore::sml::llmq_entry_verification::LLMQEntryVerificationStatus; + + let (runtime, _storage) = offline_runtime().await; + hold_quorum(&runtime, LLMQEntryVerificationStatus::Verified).await; + + for quorum_type in [0, 7, 255, 6 + 256, u32::MAX] { + let with_status = runtime + .get_quorum_public_key_with_status( + quorum_type, + QUORUM_FIXTURE_PLATFORM_HASH, + QUORUM_FIXTURE_HEIGHT, + ) + .await; + assert!( + matches!(with_status, Err(PlatformWalletError::InvalidParameter(_))), + "quorum type {quorum_type} must be rejected, got {with_status:?}" + ); + + let verified = runtime + .get_quorum_public_key( + quorum_type, + QUORUM_FIXTURE_PLATFORM_HASH, + QUORUM_FIXTURE_HEIGHT, + ) + .await; + assert!( + matches!(verified, Err(PlatformWalletError::InvalidParameter(_))), + "quorum type {quorum_type} must be rejected, got {verified:?}" + ); + } + } + /// Drive the production startup path and wait for upstream `run` to return. async fn run_offline(runtime: &Arc) { runtime.spawn_run_loop(); From 0e7dab312feb7afacb34da86ade8320f77d91bac Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 22 Oct 2026 23:03:49 +0700 Subject: [PATCH 32/38] fix(sdk): keep the newer contract when preloading known contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `LocalContextResolver::add_known_contracts` replaced cached entries unconditionally. Swift and Kotlin preload stored contracts through `dash_sdk_add_known_contracts` into the same cache proof verification reads, so a stale stored copy could replace a newer contract registered after a proven fetch, and later proofs and document operations would use the obsolete schema. Each contract now goes through `add_known_contract_if_newer`, which takes the cache lock per contract: an older or equal version is ignored and the cached body kept, and within one batch the highest version wins. `TrustedHttpContextProvider::add_known_contracts` and `with_known_contracts` delegate here; public signatures are unchanged. No caller of the bulk path relied on overwriting (the FFI preload, the JNI bridge, a test helper; nothing calls `with_known_contracts`). `add_known_contract`, `add_known_data_contract` and `register_data_contract` still overwrite: registration is authoritative on purpose, e.g. the moderation flow registers the exact contract a document was serialized with. Addresses thepastaclaw's review of 57bd3bf9e (local.rs:109-118). Test would have caught this in CI: should_not_downgrade_a_cached_contract_when_preloading_contracts ✖ before the fix: "an older preload downgraded the cache" (left: 1, right: 2) ✔ after the fix (cargo test -p rs-sdk-trusted-context-provider --all-features; the test module needs a system-contract feature). Co-Authored-By: Claude Opus 5.5 --- packages/rs-sdk-ffi/src/sdk.rs | 4 +- .../src/local.rs | 73 +++++++++++++++++-- .../src/provider.rs | 6 +- 3 files changed, 73 insertions(+), 10 deletions(-) diff --git a/packages/rs-sdk-ffi/src/sdk.rs b/packages/rs-sdk-ffi/src/sdk.rs index bafb0e40625..447af28d43a 100644 --- a/packages/rs-sdk-ffi/src/sdk.rs +++ b/packages/rs-sdk-ffi/src/sdk.rs @@ -873,7 +873,9 @@ pub unsafe extern "C" fn dash_sdk_get_network(handle: *const SDKHandle) -> FFINe /// Add known contracts to the SDK's local contract cache /// /// This preloads data contracts into the cache the SDK's context provider -/// reads, avoiding network calls for these contracts. +/// reads, avoiding network calls for these contracts. A contract not newer +/// than the cached one with the same id is ignored, so a stale stored copy +/// cannot replace a newer contract. /// /// # Safety /// - `handle` must be a valid SDK handle created with `dash_sdk_create_trusted`, diff --git a/packages/rs-sdk-trusted-context-provider/src/local.rs b/packages/rs-sdk-trusted-context-provider/src/local.rs index 89802b1c6bd..57ba526a5d9 100644 --- a/packages/rs-sdk-trusted-context-provider/src/local.rs +++ b/packages/rs-sdk-trusted-context-provider/src/local.rs @@ -106,15 +106,12 @@ impl LocalContextResolver { known.remove(id).is_some() } - /// Add multiple data contracts to the known contracts cache + /// Add multiple data contracts to the known contracts cache, each through + /// [`Self::add_known_contract_if_newer`]. A copy preloaded from storage + /// never replaces a newer version the cache already holds. pub fn add_known_contracts(&self, contracts: Vec) { - let mut known = self - .known_contracts - .lock() - .unwrap_or_else(PoisonError::into_inner); for contract in contracts { - let id = contract.id(); - known.insert(id, Arc::new(contract)); + self.add_known_contract_if_newer(contract); } } @@ -347,6 +344,7 @@ impl ContractSource for LocalContextResolver { mod tests { use std::sync::Arc; + use dash_context_provider::routing::ContractSource; use dpp::data_contract::accessors::v0::{DataContractV0Getters, DataContractV0Setters}; use dpp::prelude::{DataContract, Identifier}; use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; @@ -417,4 +415,65 @@ mod tests { let kept = resolver.add_known_contract_if_newer(older); assert_eq!(kept.version(), 2); } + + /// Apps preload contracts from their own storage into the cache proof + /// verification reads. A stored copy older than the one the cache holds, + /// for example one registered after a proven fetch, must not replace it, + /// or later proofs and documents would use the obsolete schema. + #[test] + fn should_not_downgrade_a_cached_contract_when_preloading_contracts() { + let resolver = LocalContextResolver::new(); + let mut registered = app_contract(); + registered.set_version(2); + let id = registered.id(); + resolver.register_data_contract(Arc::new(registered)); + let cached = resolver + .get_known_contract(&id) + .expect("registered contract"); + + let mut older = app_contract(); + older.set_version(1); + resolver.add_known_contracts(vec![older]); + let after_older = resolver.get_known_contract(&id).expect("cached contract"); + assert_eq!( + after_older.version(), + 2, + "an older preload downgraded the cache" + ); + + let mut same_version = app_contract(); + same_version.set_version(2); + resolver.add_known_contracts(vec![same_version]); + assert!( + Arc::ptr_eq( + &cached, + &resolver.get_known_contract(&id).expect("cached contract") + ), + "an equal version must keep the cached body" + ); + + let mut newer = app_contract(); + newer.set_version(3); + resolver.add_known_contracts(vec![newer]); + assert_eq!( + resolver + .get_known_contract(&id) + .map(|contract| contract.version()), + Some(3), + "a newer preload must still replace the cached contract" + ); + + let mut newest = app_contract(); + newest.set_version(5); + let mut stale = app_contract(); + stale.set_version(4); + resolver.add_known_contracts(vec![newest, stale]); + assert_eq!( + resolver + .get_known_contract(&id) + .map(|contract| contract.version()), + Some(5), + "a later, older copy in the same batch must not win" + ); + } } diff --git a/packages/rs-sdk-trusted-context-provider/src/provider.rs b/packages/rs-sdk-trusted-context-provider/src/provider.rs index fa393522f94..55d80355c2a 100644 --- a/packages/rs-sdk-trusted-context-provider/src/provider.rs +++ b/packages/rs-sdk-trusted-context-provider/src/provider.rs @@ -271,7 +271,8 @@ impl TrustedHttpContextProvider { self } - /// Set known contracts that will be served immediately without fallback + /// Set known contracts that will be served immediately without fallback. + /// A contract not newer than a cached one with the same id is ignored. pub fn with_known_contracts(self, contracts: Vec) -> Self { self.local.add_known_contracts(contracts); self @@ -306,7 +307,8 @@ impl TrustedHttpContextProvider { self.local.remove_known_contract(id) } - /// Add multiple data contracts to the known contracts cache + /// Add multiple data contracts to the known contracts cache. A contract + /// not newer than a cached one with the same id is ignored. pub fn add_known_contracts(&self, contracts: Vec) { self.local.add_known_contracts(contracts); } From 4c882fdb05d0aa4d11eb6693c9095c0c118b204e Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 22 Oct 2026 23:04:04 +0700 Subject: [PATCH 33/38] fix(swift-example-app): move the wallet manager onto the new network's SDK on a network switch Picking a network published `currentNetwork`, whose didSet started an unawaited SDK rebuild, while the App scene's `.onChange(of: currentNetwork)` activated the wallet manager with whatever `platformState.sdk` held at that moment, usually the previous network's. The manager kept that SDK's clone, adaptive provider and SPV source slot, and nothing activated it again after the rebuild. A network switch now runs as one main-actor sequence, `rebuildSdkAndMoveWalletManager`, which the Docker toggle and devnet edits already used through OptionsView's `rebuildSdk`: stop SPV, re-check the network, apply (service resets, publish the network, Docker auto-disable), build the SDK, activate the manager with the SDK the build returned, apply the quorum mode, then bump `walletScopedServicesRebindTick` so the services rebind against the new manager. Each step runs only if the previous one succeeded. - `currentNetwork` is `private(set)`; `publishCurrentNetwork(_:)` only publishes, and its didSet only persists. A `$appState.currentNetwork` binding can no longer bypass the rebuild. - `switchNetwork(to:)` returns the SDK it published. - The App no longer observes `currentNetwork`; `activateManager(for:)` is removed (its `manager_activated` event too; the store still logs `manager_materialized`). - The picker still stops SPV before publishing and keeps its `invalidatePendingSpvStarts()` / `isSwitchingNetwork` guards; `isSwitchingNetwork` now stays set until the manager has moved. SdkRebuildSequenceTests pins the order, that activation receives the rebuilt SDK, and the early exits (failed stop, network changed during the stop, failed build, failed activation), for a network switch and a same-network rebuild. Not shown red: Swift does not build on the dev host (no Xcode), and the old code had no single sequence a unit test could drive; the race spanned a didSet Task and a SwiftUI onChange. The sequence and tests type-check with Swift 6.1.2 under -warnings-as-errors against stand-in modules; CI's Swift job is the only real build. Addresses thepastaclaw's blocking finding on 57bd3bf9e (AppState.swift:19-24). Co-Authored-By: Claude Opus 5.5 --- .../SwiftExampleApp/AppState.swift | 41 ++-- .../SwiftExampleApp/SdkRebuildSequence.swift | 54 +++++ .../SwiftExampleApp/SwiftExampleAppApp.swift | 65 +----- .../SwiftExampleApp/Views/OptionsView.swift | 146 ++++++-------- .../SwiftExampleApp/WalletManagerStore.swift | 12 +- .../SdkRebuildSequenceTests.swift | 188 ++++++++++++++++++ 6 files changed, 343 insertions(+), 163 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SdkRebuildSequence.swift create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/SdkRebuildSequenceTests.swift diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift index 4df5cea0cc4..1b0f863e084 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift @@ -16,15 +16,20 @@ class AppState: ObservableObject { /// UI binds to). @Published private(set) var quorumSource: SDK.QuorumSource = .trusted - @Published var currentNetwork: Network { + @Published private(set) var currentNetwork: Network { didSet { UserDefaults.standard.set(Int(currentNetwork.rawValue), forKey: "currentNetwork") - Task { - await switchNetwork(to: currentNetwork) - } } } + /// Publish `network` as the current network. Publishing rebuilds + /// nothing: call it from the `apply` step of + /// `rebuildSdkAndMoveWalletManager`, which then builds that network's + /// SDK and moves the wallet manager onto it. + func publishCurrentNetwork(_ network: Network) { + currentNetwork = network + } + @Published var dataStatistics: (identities: Int, documents: Int, contracts: Int, tokenBalances: Int)? /// The connected network's protocol version, learned by @@ -35,15 +40,15 @@ class AppState: ObservableObject { /// fall back to the currently-active network behavior when `nil`. @Published var platformProtocolVersion: UInt32? - /// Monotonic tick incremented when a wallet-scoped service rebind - /// is needed but neither of the standard triggers - /// (`currentNetwork.onChange`, `wallets.keys.onChange`) will fire. - /// Concretely: a devnet→devnet SDK rebuild from OptionsView swaps - /// the cached `PlatformWalletManager` but leaves the network and - /// wallet ID set unchanged, so `PlatformBalanceSyncService` and - /// `ShieldedService` keep their references to the old manager. - /// SwiftExampleAppApp observes this tick to re-run - /// `rebindWalletScopedServices()` in that edge case. + /// Monotonic tick bumped after OptionsView moves the wallet manager + /// onto a rebuilt SDK: a network switch, the Docker toggle or a devnet + /// edit. SwiftExampleAppApp observes it to re-run + /// `rebindWalletScopedServices()` against the new manager. A network + /// change is published before the wallet manager moves onto the new + /// network's SDK, and a same-network rebuild changes neither the + /// network nor the wallet ID set, so neither can trigger the rebind; + /// `PlatformBalanceSyncService` and `ShieldedService` would otherwise + /// keep the old manager. @Published var walletScopedServicesRebindTick: Int = 0 @Published var useDockerSetup: Bool { @@ -200,8 +205,12 @@ class AppState: ObservableObject { showError = true } - func switchNetwork(to network: Network) async { - guard let modelContext = modelContext else { return } + /// Build and publish an SDK for `network`. Returns that SDK, or `nil` + /// before `initializeSDK` has run or after a build failure, which it + /// shows and which also clears `sdk`. The wallet manager stays on its + /// old SDK either way; `rebuildSdkAndMoveWalletManager` moves it. + func switchNetwork(to network: Network) async -> SDK? { + guard let modelContext = modelContext else { return nil } // Identities, contracts, documents, and token balances are // scoped per-network inside SwiftData. `@Query` consumers @@ -231,11 +240,13 @@ class AppState: ObservableObject { await loadKnownContractsIntoSDK(sdk: newSDK, modelContext: modelContext) isLoading = false + return newSDK } catch { sdk = nil showError(message: "Failed to switch network: \(error.localizedDescription)") NSLog("❌ AppState.switchNetwork: \(error)") isLoading = false + return nil } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SdkRebuildSequence.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SdkRebuildSequence.swift new file mode 100644 index 00000000000..4c222a4d385 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SdkRebuildSequence.swift @@ -0,0 +1,54 @@ +import SwiftDashSDK + +/// How an SDK rebuild started from Options ended. +enum SdkRebuildOutcome: Equatable { + /// SPV did not stop; nothing was applied or rebuilt. + case stopFailed + /// The network changed while SPV stopped; nothing was applied or rebuilt. + case networkChanged + /// The rebuild ran. Its failures were already shown to the user. + case finished +} + +/// Stop SPV, rebuild the SDK for `target`, and move the wallet manager and the +/// wallet-scoped services onto that SDK, as one main-actor sequence. Options +/// runs every SDK rebuild through it: a network switch, the Docker toggle and +/// devnet edits. +/// +/// Each step runs only if the one before it succeeded: +/// +/// 1. `stopSpv`. A running SPV client must never disagree with the settings +/// the new SDK is built from. +/// 2. The app must still be on `network`, re-checked because the stop +/// suspends. +/// 3. `apply` changes what the new SDK reads when it is built, such as the +/// published network or the Docker endpoints. +/// 4. `rebuildSdk` builds and publishes the SDK for `target`, or returns `nil` +/// after reporting its failure. +/// 5. `activateManager` receives the SDK step 4 returned. The manager takes +/// its SDK clone, and the adaptive provider's SPV source slot, from that +/// SDK, so it must never be given an SDK read before the rebuild +/// finished. It returns false after reporting a failure. +/// 6. `rebindWalletScopedServices` runs last because it binds the services to +/// the active manager. +/// +/// Generic over the SDK type so tests can drive it without building one. +@MainActor +func rebuildSdkAndMoveWalletManager( + from network: Network, + to target: Network, + stopSpv: @MainActor () async -> Bool, + currentNetwork: @MainActor () -> Network, + apply: @MainActor () -> Void, + rebuildSdk: @MainActor (Network) async -> Sdk?, + activateManager: @MainActor (Network, Sdk) -> Bool, + rebindWalletScopedServices: @MainActor () -> Void +) async -> SdkRebuildOutcome { + guard await stopSpv() else { return .stopFailed } + guard currentNetwork() == network else { return .networkChanged } + apply() + guard let sdk = await rebuildSdk(target) else { return .finished } + guard activateManager(target, sdk) else { return .finished } + rebindWalletScopedServices() + return .finished +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index d9114857eac..6625c5757f2 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -170,27 +170,13 @@ struct SwiftExampleAppApp: App { })) { _, _ in rebindWalletScopedServices() } - // Network switch: activate the per-network manager - // first (the store lazy-creates one configured with - // a fresh SDK if this is the first time we see this - // network), then rebind the wallet-scoped services - // against it. Order matters — `rebindWalletScopedServices` - // reads `walletManager.firstWallet`, which has to - // resolve to the new network's manager before it - // runs. - .onChange(of: platformState.currentNetwork) { _, newNetwork in - activateManager(for: newNetwork) - rebindWalletScopedServices() - } - // Devnet→devnet rebuild from OptionsView: when the user - // edits the quorum URL / devnet name the SDK is rebuilt - // and `WalletManagerStore.activate` swaps the cached - // `PlatformWalletManager`, but neither of the two - // observers above fires (network stays `.devnet`; - // wallet ID set stays identical after persistor reload). - // PlatformBalanceSyncService and ShieldedService would - // keep retaining the old manager. Listen for the explicit - // tick OptionsView publishes after the activate completes. + // SDK rebuilds from OptionsView: a network switch, the + // Docker toggle or a devnet edit. OptionsView rebuilds the + // SDK, moves the wallet manager onto it, then bumps this + // tick, so the services rebind against the new manager. + // The network itself is not observed here: it is published + // before the wallet manager moves onto the new network's + // SDK, and `rebindWalletScopedServices` reads `walletManager`. .onChange(of: platformState.walletScopedServicesRebindTick) { _, _ in rebindWalletScopedServices() } @@ -206,43 +192,6 @@ struct SwiftExampleAppApp: App { } } - /// Lazy-create + cache a `PlatformWalletManager` for `network`, - /// configured against `platformState.sdk`. No-ops on the - /// already-active network. Called from bootstrap and from - /// `currentNetwork.onChange`. - @MainActor - private func activateManager(for network: Network) { - guard let sdk = platformState.sdk else { - SDKLogger.event( - "manager_activation_skipped", - category: .lifecycle, - severity: .warning, - fields: [ - "network": .publicText(network.displayName), - "reason": .publicText("sdk_unavailable"), - ] - ) - return - } - do { - try walletManagerStore.activate(network: network, sdk: sdk) - platformState.applyQuorumMode() - SDKLogger.event( - "manager_activated", - category: .lifecycle, - fields: ["network": .publicText(network.displayName)] - ) - } catch { - SDKLogger.event( - "manager_activation_failed", - category: .lifecycle, - severity: .error, - fields: ["network": .publicText(network.displayName)], - error: error - ) - } - } - /// Drive manager-wide BLAST sync state from the set of loaded /// wallets. With no wallets present on the active manager, sync /// is stopped and the per-wallet `PlatformBalanceSyncService` diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift index 4700d555cc3..ca2f38c54ee 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift @@ -147,50 +147,40 @@ struct OptionsView: View { // can't revive it. walletManagerStore.invalidatePendingSpvStarts() isSwitchingNetwork = true + let network = appState.currentNetwork Task { + defer { isSwitchingNetwork = false } // Devnet's SPV peers come from // `{platformQuorumURL}/masternodes` // — no UserDefaults state to seed // here. See `CoreSpvLauncher.peerOverride` // for the devnet branch. - // Stop the old network's SPV and reset its - // per-network services before publishing the - // new network. The stop can take tens of - // seconds with the main actor free; published - // first, the network change would let the - // `currentNetwork` observer configure the new - // manager and bind these services meanwhile, - // and the resets would then wipe that binding. + // The old network's SPV stops before the new + // network is published. An incomplete stop can + // leave the old run loop parked; the app then + // stays on this network, so picking again + // retries the stop. The manager moves only + // once the new network's SDK exists, and the + // wallet-scoped services rebind after it. // TODO(platform-wallet): Once // PlatformWalletManager supports network // switching cleanly, call into it here. - // An incomplete stop can leave the old run - // loop parked; stay on this network so - // picking again retries the stop. - guard await stopSpvForReconfiguration( - failurePrefix: "Failed to stop SPV, staying on \(appState.currentNetwork.displayName)" - ) else { - isSwitchingNetwork = false - return - } - platformBalanceSyncService.reset() - shieldedService.reset() - - // Update platform state (which will trigger SDK switch) - appState.currentNetwork = newNetwork - devnetRebuildPending = false - - // Auto-disable Docker when leaving Local, in - // the same main-actor turn the network is - // published, so the new network's SDK build - // reads the cleared flag. - if newNetwork != .regtest && appState.useDockerSetup { - appState.useDockerSetup = false - } - - await MainActor.run { - isSwitchingNetwork = false + _ = await rebuildSdk( + on: network, + switchingTo: newNetwork, + failurePrefix: "Failed to stop SPV, staying on \(network.displayName)" + ) { + platformBalanceSyncService.reset() + shieldedService.reset() + appState.publishCurrentNetwork(newNetwork) + devnetRebuildPending = false + // Auto-disable Docker when leaving Local + // before the new network's SDK is built, + // so the build reads the cleared flag. + if newNetwork != .regtest && appState.useDockerSetup { + appState.useDockerSetup = false + } } } } @@ -377,8 +367,8 @@ struct OptionsView: View { } } else { Toggle("Use Custom SPV Peers", isOn: $customSpvPeersEnabled) - // Its peer seed is network-specific, and during a - // switch `currentNetwork` is still the old one. + // Its peer seed is network-specific, and + // `currentNetwork` changes partway through a switch. .disabled(isSwitchingNetwork) .onChange(of: customSpvPeersEnabled) { _, isOn in // When enabling, seed the peers field with @@ -661,60 +651,50 @@ struct OptionsView: View { } } - private enum SdkRebuildOutcome { - /// SPV did not stop; nothing was applied or rebuilt. - case stopFailed - /// The network changed while SPV stopped; nothing was rebuilt. - case networkChanged - /// The rebuild ran. Its failures were already shown to the user. - case finished - } - - /// Rebuild the SDK for `network` without changing networks, and move the - /// wallet manager and the wallet-scoped services onto it. + /// Rebuild the SDK for `newNetwork` (by default `network`, the network the + /// flow started on), and move the wallet manager and the wallet-scoped + /// services onto it. See `rebuildSdkAndMoveWalletManager` for the order. /// - /// SPV stops first. `apply` then runs, so settings the rebuild reads never - /// disagree with a running SPV client. `switchNetwork` alone would leave - /// the cached manager on its clone of the old SDK (old endpoints, old - /// quorum policy), and the app-level `currentNetwork` observer does not - /// fire for a same-network rebuild. Callers own + /// `switchNetwork` alone would leave the cached manager on its clone of + /// the old SDK (old endpoints, old quorum policy, the old adaptive + /// provider's SPV source slot). Callers own /// `invalidatePendingSpvStarts()` and `isSwitchingNetwork`. @MainActor private func rebuildSdk( on network: Network, + switchingTo newNetwork: Network? = nil, failurePrefix: String, - apply: () -> Void = {} + apply: @MainActor () -> Void = {} ) async -> SdkRebuildOutcome { - guard await stopSpvForReconfiguration(failurePrefix: failurePrefix) else { - return .stopFailed - } - // Re-checked after the stop's suspension. - guard appState.currentNetwork == network else { return .networkChanged } - apply() - await appState.switchNetwork(to: network) - // `switchNetwork` reports its own failure and leaves no SDK. - guard let sdk = appState.sdk else { return .finished } - do { - try walletManagerStore.activate(network: network, sdk: sdk) - } catch { - SDKLogger.event( - "manager_activation_failed", - category: .lifecycle, - severity: .error, - fields: ["network": .publicText(network.displayName)], - error: error - ) - appState.showError( - message: "The SDK was rebuilt, but the wallet manager could not move to it: \(error.localizedDescription)" - ) - return .finished - } - appState.applyQuorumMode() - // Drive `rebindWalletScopedServices` through the App scene's - // observer: neither `currentNetwork` nor the wallet ID set changes - // here, so the wallet-scoped services would keep the old manager. - appState.walletScopedServicesRebindTick &+= 1 - return .finished + await rebuildSdkAndMoveWalletManager( + from: network, + to: newNetwork ?? network, + stopSpv: { await stopSpvForReconfiguration(failurePrefix: failurePrefix) }, + currentNetwork: { appState.currentNetwork }, + apply: apply, + rebuildSdk: { await appState.switchNetwork(to: $0) }, + activateManager: { (target: Network, sdk: SDK) -> Bool in + do { + try walletManagerStore.activate(network: target, sdk: sdk) + } catch { + SDKLogger.event( + "manager_activation_failed", + category: .lifecycle, + severity: .error, + fields: ["network": .publicText(target.displayName)], + error: error + ) + appState.showError( + message: "The SDK was rebuilt, but the wallet manager could not move to it: \(error.localizedDescription)" + ) + return false + } + appState.applyQuorumMode() + return true + }, + // The App scene's tick observer runs `rebindWalletScopedServices`. + rebindWalletScopedServices: { appState.walletScopedServicesRebindTick &+= 1 } + ) } /// Stop SPV before a flow replaces or reconfigures it. Returns false, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift index e7243b8baf9..7adfde74350 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift @@ -59,13 +59,11 @@ final class WalletManagerStore: ObservableObject { /// re-checks it (alongside manager identity) after its off-main /// peer resolution; a mismatch means the start is stale and must /// not revive a stopped or replaced manager. Identity alone is not - /// enough because of the stop→activate gap: OptionsView's picker - /// stops the old manager synchronously but the store only swaps - /// `activeManager` later (from the app-level `onChange`), and the - /// devnet rebuild widens that gap with an `await` on the SDK - /// rebuild. This token is invalidated at the *earliest* synchronous - /// point of each such path, closing the window. See the guard in - /// `CoreSpvLauncher.start`. + /// enough because of the stop→activate gap: OptionsView stops the + /// old manager, then awaits the SDK rebuild, and the store swaps + /// `activeManager` only after that. This token is invalidated at + /// the *earliest* synchronous point of each such path, closing the + /// window. See the guard in `CoreSpvLauncher.start`. /// /// Not `@Published` — nothing renders off it; it's read /// synchronously by start call sites and their revalidation diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/SdkRebuildSequenceTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/SdkRebuildSequenceTests.swift new file mode 100644 index 00000000000..af4cf21f42f --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/SdkRebuildSequenceTests.swift @@ -0,0 +1,188 @@ +import XCTest +@testable import SwiftDashSDK +@testable import SwiftExampleApp + +/// Pins the order of `rebuildSdkAndMoveWalletManager`, the sequence every +/// SDK rebuild from Options runs: stop SPV, apply the new settings, build +/// the SDK, move the wallet manager onto exactly that SDK, then rebind the +/// wallet-scoped services. A manager activated with the SDK published +/// before the rebuild keeps the previous network's SDK clone and its +/// adaptive provider's SPV source slot. +/// +/// The steps are injected, so no real SDK or `PlatformWalletManager` is +/// built; `FakeSdk` stands in for the SDK and is compared by identity. +@MainActor +final class SdkRebuildSequenceTests: XCTestCase { + + private final class FakeSdk {} + + private enum Step: Equatable { + case stopSpv + case apply + case rebuildSdk(Network) + case activateManager(Network) + case rebindWalletScopedServices + } + + /// Records the steps and models the app's published network and SDK. + private final class AppModel { + var steps: [Step] = [] + var currentNetwork: Network + var publishedSdk = FakeSdk() + var activatedSdk: FakeSdk? + + init(currentNetwork: Network) { + self.currentNetwork = currentNetwork + } + } + + private func run( + _ app: AppModel, + from network: Network, + to target: Network, + stopSucceeds: Bool = true, + networkAfterStop: Network? = nil, + rebuildSucceeds: Bool = true, + activationSucceeds: Bool = true, + apply: @MainActor () -> Void = {} + ) async -> SdkRebuildOutcome { + await rebuildSdkAndMoveWalletManager( + from: network, + to: target, + stopSpv: { + app.steps.append(.stopSpv) + if let networkAfterStop { + app.currentNetwork = networkAfterStop + } + return stopSucceeds + }, + currentNetwork: { app.currentNetwork }, + apply: { + app.steps.append(.apply) + apply() + }, + rebuildSdk: { (network: Network) -> FakeSdk? in + app.steps.append(.rebuildSdk(network)) + guard rebuildSucceeds else { return nil } + let sdk = FakeSdk() + app.publishedSdk = sdk + return sdk + }, + activateManager: { (network: Network, sdk: FakeSdk) -> Bool in + app.steps.append(.activateManager(network)) + app.activatedSdk = sdk + return activationSucceeds + }, + rebindWalletScopedServices: { + app.steps.append(.rebindWalletScopedServices) + } + ) + } + + /// A network switch publishes the new network after SPV stops, builds + /// that network's SDK, and moves the manager onto that SDK before the + /// services rebind. + func testNetworkSwitchMovesTheManagerOntoTheNewNetworksSdk() async { + let app = AppModel(currentNetwork: .testnet) + let previousSdk = app.publishedSdk + + let outcome = await run(app, from: .testnet, to: .regtest) { + app.currentNetwork = .regtest + } + + XCTAssertEqual(outcome, .finished) + XCTAssertEqual( + app.steps, + [ + .stopSpv, + .apply, + .rebuildSdk(.regtest), + .activateManager(.regtest), + .rebindWalletScopedServices, + ] + ) + XCTAssertTrue( + app.activatedSdk === app.publishedSdk, + "the manager must move onto the SDK the rebuild published" + ) + XCTAssertFalse( + app.activatedSdk === previousSdk, + "the manager must not stay on the previous network's SDK" + ) + } + + /// The Docker toggle and devnet edits rebuild on the same network. The + /// network and the wallet ID set stay unchanged, so only this sequence + /// rebinds the services, after the manager moved onto the new SDK. + func testSameNetworkRebuildMovesTheManagerOntoTheNewSdk() async { + let app = AppModel(currentNetwork: .regtest) + let previousSdk = app.publishedSdk + + let outcome = await run(app, from: .regtest, to: .regtest) + + XCTAssertEqual(outcome, .finished) + XCTAssertEqual( + app.steps, + [ + .stopSpv, + .apply, + .rebuildSdk(.regtest), + .activateManager(.regtest), + .rebindWalletScopedServices, + ] + ) + XCTAssertTrue(app.activatedSdk === app.publishedSdk) + XCTAssertFalse(app.activatedSdk === previousSdk) + } + + /// A failed stop may leave the old run loop parked. Nothing changes, so + /// the app stays on its network and picking again retries the stop. + func testFailedStopAppliesAndRebuildsNothing() async { + let app = AppModel(currentNetwork: .testnet) + + let outcome = await run(app, from: .testnet, to: .regtest, stopSucceeds: false) { + app.currentNetwork = .regtest + } + + XCTAssertEqual(outcome, .stopFailed) + XCTAssertEqual(app.steps, [.stopSpv]) + XCTAssertEqual(app.currentNetwork, .testnet) + } + + /// The stop suspends; a flow whose network was replaced meanwhile must + /// not rebuild for a network the app has left. + func testNetworkChangedDuringTheStopRebuildsNothing() async { + let app = AppModel(currentNetwork: .testnet) + + let outcome = await run(app, from: .testnet, to: .testnet, networkAfterStop: .devnet) + + XCTAssertEqual(outcome, .networkChanged) + XCTAssertEqual(app.steps, [.stopSpv]) + } + + /// With no new SDK there is nothing to move the manager onto, and the + /// services must not rebind against the manager left behind. + func testFailedRebuildSkipsActivationAndRebind() async { + let app = AppModel(currentNetwork: .testnet) + + let outcome = await run(app, from: .testnet, to: .regtest, rebuildSucceeds: false) + + XCTAssertEqual(outcome, .finished) + XCTAssertEqual(app.steps, [.stopSpv, .apply, .rebuildSdk(.regtest)]) + XCTAssertNil(app.activatedSdk) + } + + /// The services bind to the active manager. If it did not move, a rebind + /// would attach them to the previous one. + func testFailedActivationSkipsRebind() async { + let app = AppModel(currentNetwork: .regtest) + + let outcome = await run(app, from: .regtest, to: .regtest, activationSucceeds: false) + + XCTAssertEqual(outcome, .finished) + XCTAssertEqual( + app.steps, + [.stopSpv, .apply, .rebuildSdk(.regtest), .activateManager(.regtest)] + ) + } +} From 308d5c62c69215c644645bd94e37d7eceaa257f1 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 22 Oct 2026 23:04:04 +0700 Subject: [PATCH 34/38] fix(swift-example-app): show errors reported through AppState `AppState.showError(message:)` set `showError` and `errorMessage`, but no view presented them, so every caller's message was dropped: a failed SPV stop before a network switch or SDK rebuild, a failed SDK build, a wallet manager that could not move to the rebuilt SDK, and identity refresh failures. ContentView now shows them in an alert. No test: SwiftUI alert presentation has no unit-test harness in SwiftExampleAppTests, and Swift does not build on the dev host. Co-Authored-By: Claude Opus 5.5 --- .../SwiftExampleApp/SwiftExampleApp/ContentView.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift index 994f45f7671..da6717461ce 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift @@ -201,6 +201,14 @@ struct ContentView: View { } message: { message in Text(message) } + // Failures reported through `AppState.showError`, for example an + // SPV stop, SDK build or wallet-manager move that failed while + // Options rebuilt the SDK. + .alert("Error", isPresented: $platformState.showError) { + Button("OK", role: .cancel) {} + } message: { + Text(platformState.errorMessage) + } } } From a93bb63e7fb102fbbc70699c84d4fad939ec54b5 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 23 Oct 2026 00:36:48 +0700 Subject: [PATCH 35/38] fix(swift-example-app): publish a rebuilt SDK and its network only after the wallet manager moved onto it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rebuildSdkAndMoveWalletManager` published the target network (and endpoint settings) before the fallible SDK build and manager move, and `AppState.switchNetwork` moved the DataManager scope and cleared `sdk` on failure. A failed build or move left the selected network out of step with the active manager; for example, after a failed switch to devnet the app showed devnet while the testnet manager stayed active, and a Core start could pair the devnet configuration with that manager. Publication is now transactional rather than gated: - The sequence stops SPV, re-checks the network, runs `prepare` (settings the build reads, e.g. the Docker flag), builds the SDK without publishing it, moves the manager onto it, then `publish`es the network and the SDK together and bumps the rebind tick. If the build or the move fails, `rollBack` undoes `prepare` and nothing is published, so the selected network, data scope, published SDK and active manager still match. Outcomes: stopFailed / networkChanged / failed / rebuilt. - `AppState.switchNetwork(to:)` is split into `buildSdk(for:)` (builds, applies the quorum mode, loads stored contracts, publishes nothing) and `publish(_:for:)` (DataManager scope, `currentNetwork`, `sdk`, quorum source, protocol-version refresh, quorum mode). `currentNetwork` changes only with the SDK. - The picker turns Docker off in `prepare` when leaving Local and back on in `rollBack`; the Docker toggle writes and restores its flag the same way; the service resets move to just before a successful publish; a failed devnet rebuild stays pending for the next Options close. - `WalletManagerStore.activate` keeps the stale cached manager until the replacement's `configure` succeeds, as its doc already said, so a failed move leaves the store tracking the manager that stays active. - A failed switch to Devnet (missing or unreachable Quorum URL) used to land on devnet, where the devnet fields are; it now stays on the old network, so the fields show there after such a failure until the next completed switch or until Options closes. - Error messages say SPV stays stopped until restarted. Gating wallet and SPV operations until a matching manager is active was rejected: it needs a guard at every manager consumer, and the mismatched state would still exist. Addresses thepastaclaw's blocking finding on a30f2b602 (SdkRebuildSequence.swift:47-51). Test would have caught this: SdkRebuildSequenceTests run locally with a stand-in XCTest module (no Xcode on the dev host; Swift 6.1.2, -warnings-as-errors). ✖ before the fix, a picker-shaped run of the old sequence (publish in `apply`, then a failed build / failed move): currentNetwork was devnet instead of testnet, and after a failed move the published SDK had changed too. ✔ after the fix: testFailedBuildDuringANetworkSwitchKeepsTheSelectedNetwork and testFailedActivationDuringANetworkSwitchKeepsTheSelectedNetwork pass, with the other four (6/6). Not tested: the WalletManagerStore cache change (configure needs a real SDK; no injection seam) and the devnet-fields UI. CI's Swift job is the only real build. Co-Authored-By: Claude Opus 5.5 --- .../swift-sdk/Sources/SwiftDashSDK/SDK.swift | 4 +- .../SwiftExampleApp/AppState.swift | 93 ++++++------- .../SwiftExampleApp/SdkRebuildSequence.swift | 61 +++++---- .../SwiftExampleApp/SwiftExampleAppApp.swift | 12 +- .../SwiftExampleApp/Views/OptionsView.swift | 125 +++++++++++------- .../SwiftExampleApp/WalletManagerStore.swift | 16 +-- .../SdkRebuildSequenceTests.swift | 117 ++++++++-------- 7 files changed, 235 insertions(+), 193 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift index bb50ed5ede6..d8ee88c54b6 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift @@ -343,8 +343,8 @@ public final class SDK: @unchecked Sendable { } else { // Quorum URL unset, or /masternodes unreachable / wrong shape. // Fall through with nil; Rust will refuse to build the SDK - // and the resulting error surfaces in the iOS UI as - // "Disconnected", prompting the user to fix the Quorum URL. + // and the build error reaches the caller, which can prompt the + // user to fix the Quorum URL. overrideAddresses = nil } } else if useOverrideAddresses { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift index 1b0f863e084..21926142991 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift @@ -16,20 +16,13 @@ class AppState: ObservableObject { /// UI binds to). @Published private(set) var quorumSource: SDK.QuorumSource = .trusted + /// Changes only with the SDK, through `publish(_:for:)`. @Published private(set) var currentNetwork: Network { didSet { UserDefaults.standard.set(Int(currentNetwork.rawValue), forKey: "currentNetwork") } } - /// Publish `network` as the current network. Publishing rebuilds - /// nothing: call it from the `apply` step of - /// `rebuildSdkAndMoveWalletManager`, which then builds that network's - /// SDK and moves the wallet manager onto it. - func publishCurrentNetwork(_ network: Network) { - currentNetwork = network - } - @Published var dataStatistics: (identities: Int, documents: Int, contracts: Int, tokenBalances: Int)? /// The connected network's protocol version, learned by @@ -41,14 +34,12 @@ class AppState: ObservableObject { @Published var platformProtocolVersion: UInt32? /// Monotonic tick bumped after OptionsView moves the wallet manager - /// onto a rebuilt SDK: a network switch, the Docker toggle or a devnet - /// edit. SwiftExampleAppApp observes it to re-run - /// `rebindWalletScopedServices()` against the new manager. A network - /// change is published before the wallet manager moves onto the new - /// network's SDK, and a same-network rebuild changes neither the - /// network nor the wallet ID set, so neither can trigger the rebind; - /// `PlatformBalanceSyncService` and `ShieldedService` would otherwise - /// keep the old manager. + /// onto a rebuilt SDK and publishes it: a network switch, the Docker + /// toggle or a devnet edit. SwiftExampleAppApp observes it to re-run + /// `rebindWalletScopedServices()` against the new manager. A + /// same-network rebuild changes neither the network nor the wallet ID + /// set, so without the tick `PlatformBalanceSyncService` and + /// `ShieldedService` would keep the old manager. @Published var walletScopedServicesRebindTick: Int = 0 @Published var useDockerSetup: Bool { @@ -205,51 +196,49 @@ class AppState: ObservableObject { showError = true } - /// Build and publish an SDK for `network`. Returns that SDK, or `nil` - /// before `initializeSDK` has run or after a build failure, which it - /// shows and which also clears `sdk`. The wallet manager stays on its - /// old SDK either way; `rebuildSdkAndMoveWalletManager` moves it. - func switchNetwork(to network: Network) async -> SDK? { - guard let modelContext = modelContext else { return nil } - - // Identities, contracts, documents, and token balances are - // scoped per-network inside SwiftData. `@Query` consumers - // filter by `network` and update reactively once we swap - // the DataManager's scope below — nothing to clear here. - - // Update DataManager's current network - dataManager?.currentNetwork = network - - // Re-initialize SDK with new network + /// Build an SDK for `network`, with the current quorum mode applied and + /// the stored contracts loaded, without publishing it. Returns `nil` + /// after showing the failure. `rebuildSdkAndMoveWalletManager` publishes + /// the SDK, with its network, once the wallet manager has moved onto it. + func buildSdk(for network: Network) async -> SDK? { + isLoading = true + defer { isLoading = false } do { - isLoading = true - - // 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 = newSDK.quorumSource - - // Eagerly learn the new network's protocol version (see - // `initializeSDK`). Non-fatal: the SDK still ratchets from - // metadata if this fails. - refreshProtocolVersion(for: newSDK) - - // Load known contracts into the SDK's trusted provider - await loadKnownContractsIntoSDK(sdk: newSDK, modelContext: modelContext) - - isLoading = false + if let modelContext { + await loadKnownContractsIntoSDK(sdk: newSDK, modelContext: modelContext) + } return newSDK } catch { - sdk = nil - showError(message: "Failed to switch network: \(error.localizedDescription)") - NSLog("❌ AppState.switchNetwork: \(error)") - isLoading = false + showError( + message: "Could not build the SDK for \(network.displayName), so the app stays on its current SDK. SPV stays stopped until you restart it from the Wallet tab. \(error.localizedDescription)" + ) + NSLog("❌ AppState.buildSdk: \(error)") return nil } } + /// Make `sdk`, built for `network`, the current SDK and `network` the + /// current network, together. Call it from the `publish` step of + /// `rebuildSdkAndMoveWalletManager`, after the wallet manager moved onto + /// `sdk`, so the selected network, the SDK and the manager change in one + /// main-actor turn. + func publish(_ sdk: SDK, for network: Network) { + // Identities, contracts, documents and token balances are scoped + // per network inside SwiftData; `@Query` consumers filter by + // `network` and follow the DataManager's scope. + dataManager?.currentNetwork = network + currentNetwork = network + self.sdk = sdk + quorumSource = sdk.quorumSource + // Eagerly learn the network's protocol version (see + // `initializeSDK`). Non-fatal: the SDK still ratchets from metadata + // if this fails. + refreshProtocolVersion(for: sdk) + applyQuorumMode() + } + private var sdkQuorumMode: SDK.QuorumMode { switch quorumMode { case .auto: return .auto diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SdkRebuildSequence.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SdkRebuildSequence.swift index 4c222a4d385..5c6c6758486 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SdkRebuildSequence.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SdkRebuildSequence.swift @@ -2,18 +2,21 @@ import SwiftDashSDK /// How an SDK rebuild started from Options ended. enum SdkRebuildOutcome: Equatable { - /// SPV did not stop; nothing was applied or rebuilt. + /// SPV did not stop; nothing changed. case stopFailed - /// The network changed while SPV stopped; nothing was applied or rebuilt. + /// The network changed while SPV stopped; nothing changed. case networkChanged - /// The rebuild ran. Its failures were already shown to the user. - case finished + /// The SDK build or the wallet manager's move failed and was reported. + /// The prepared settings were rolled back and nothing was published. + case failed + /// The new SDK, its network and the wallet manager on it are current. + case rebuilt } -/// Stop SPV, rebuild the SDK for `target`, and move the wallet manager and the -/// wallet-scoped services onto that SDK, as one main-actor sequence. Options -/// runs every SDK rebuild through it: a network switch, the Docker toggle and -/// devnet edits. +/// Stop SPV, build the SDK for `target`, move the wallet manager onto it, and +/// only then publish the network and the SDK, as one main-actor sequence. +/// Options runs every SDK rebuild through it: a network switch, the Docker +/// toggle and devnet edits. /// /// Each step runs only if the one before it succeeded: /// @@ -21,16 +24,21 @@ enum SdkRebuildOutcome: Equatable { /// the new SDK is built from. /// 2. The app must still be on `network`, re-checked because the stop /// suspends. -/// 3. `apply` changes what the new SDK reads when it is built, such as the -/// published network or the Docker endpoints. -/// 4. `rebuildSdk` builds and publishes the SDK for `target`, or returns `nil` -/// after reporting its failure. -/// 5. `activateManager` receives the SDK step 4 returned. The manager takes -/// its SDK clone, and the adaptive provider's SPV source slot, from that -/// SDK, so it must never be given an SDK read before the rebuild -/// finished. It returns false after reporting a failure. -/// 6. `rebindWalletScopedServices` runs last because it binds the services to -/// the active manager. +/// 3. `prepare` writes settings the build reads, such as the Docker +/// endpoints. +/// 4. `buildSdk` builds the SDK for `target` without publishing it, or +/// returns `nil` after reporting its failure. +/// 5. `activateManager` moves the wallet manager onto that SDK, or returns +/// false after reporting a failure. The manager takes its SDK clone, and +/// the adaptive provider's SPV source slot, from this SDK. +/// 6. `publish` makes `target` and the SDK current. The manager already +/// runs on that SDK, so the selected network, the published SDK and the +/// active manager change in the same main-actor turn. +/// 7. `rebindWalletScopedServices` binds the services to the active manager. +/// +/// If step 4 or 5 fails, `rollBack` undoes `prepare`. Nothing was published, +/// so the selected network, the published SDK and the active manager still +/// match. /// /// Generic over the SDK type so tests can drive it without building one. @MainActor @@ -39,16 +47,21 @@ func rebuildSdkAndMoveWalletManager( to target: Network, stopSpv: @MainActor () async -> Bool, currentNetwork: @MainActor () -> Network, - apply: @MainActor () -> Void, - rebuildSdk: @MainActor (Network) async -> Sdk?, + prepare: @MainActor () -> Void, + buildSdk: @MainActor (Network) async -> Sdk?, activateManager: @MainActor (Network, Sdk) -> Bool, + rollBack: @MainActor () -> Void, + publish: @MainActor (Network, Sdk) -> Void, rebindWalletScopedServices: @MainActor () -> Void ) async -> SdkRebuildOutcome { guard await stopSpv() else { return .stopFailed } guard currentNetwork() == network else { return .networkChanged } - apply() - guard let sdk = await rebuildSdk(target) else { return .finished } - guard activateManager(target, sdk) else { return .finished } + prepare() + guard let sdk = await buildSdk(target), activateManager(target, sdk) else { + rollBack() + return .failed + } + publish(target, sdk) rebindWalletScopedServices() - return .finished + return .rebuilt } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index 8829ea462d1..da2213a0ce9 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -171,12 +171,12 @@ struct SwiftExampleAppApp: App { rebindWalletScopedServices() } // SDK rebuilds from OptionsView: a network switch, the - // Docker toggle or a devnet edit. OptionsView rebuilds the - // SDK, moves the wallet manager onto it, then bumps this - // tick, so the services rebind against the new manager. - // The network itself is not observed here: it is published - // before the wallet manager moves onto the new network's - // SDK, and `rebindWalletScopedServices` reads `walletManager`. + // Docker toggle or a devnet edit. OptionsView builds the + // SDK, moves the wallet manager onto it, publishes the SDK + // and its network, then bumps this tick, so the services + // rebind against the new manager. A same-network rebuild + // changes neither the network nor the wallet ID set, so + // only this tick covers it. .onChange(of: platformState.walletScopedServicesRebindTick) { _, _ in rebindWalletScopedServices() } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift index ca2f38c54ee..c40fd1a13e8 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift @@ -36,12 +36,19 @@ struct OptionsView: View { @State private var devnetQuorumURLSnapshot: String? = nil @State private var devnetNameSnapshot: String? = nil - /// Devnet edits whose rebuild has not run yet: Options closed while a - /// network switch was running, or the rebuild's stop failed. The next - /// close rebuilds for them; a completed network switch clears them, since - /// every devnet SDK build reads the edited settings. + /// Devnet edits whose rebuild has not completed: Options closed while a + /// network switch was running, or the rebuild's stop, SDK build or + /// wallet-manager move failed. The next close rebuilds for them; a + /// completed network switch clears them, since every devnet SDK build + /// reads the edited settings. @State private var devnetRebuildPending = false + /// A switch to Devnet failed, usually for a missing or unreachable Quorum + /// URL, and the app stayed on its network. The devnet fields then show on + /// that network so they can be fixed before picking Devnet again. Cleared + /// by any completed switch and when the fields leave the screen. + @State private var devnetSettingsNeeded = false + /// Driven by the 0.5s-debounced `.task(id: faucetPassword)`. /// Runs a single cheap `getblockcount` JSON-RPC against the /// dashmate-managed Core (`127.0.0.1:`) and @@ -148,6 +155,10 @@ struct OptionsView: View { walletManagerStore.invalidatePendingSpvStarts() isSwitchingNetwork = true let network = appState.currentNetwork + // Leaving Local turns Docker off before the new + // network's SDK is built, so the build reads the + // cleared flag; a failed switch turns it back on. + let disablesDocker = newNetwork != .regtest && appState.useDockerSetup Task { defer { isSwitchingNetwork = false } // Devnet's SPV peers come from @@ -156,31 +167,42 @@ struct OptionsView: View { // here. See `CoreSpvLauncher.peerOverride` // for the devnet branch. - // The old network's SPV stops before the new - // network is published. An incomplete stop can - // leave the old run loop parked; the app then - // stays on this network, so picking again - // retries the stop. The manager moves only - // once the new network's SDK exists, and the - // wallet-scoped services rebind after it. + // The old network's SPV stops first. An + // incomplete stop can leave the old run loop + // parked; the app then stays on this network, + // so picking again retries the stop. The new + // network is published only with its SDK, once + // the manager has moved onto that SDK; if the + // build or the move fails, the app stays on + // this network with its SDK and manager. // TODO(platform-wallet): Once // PlatformWalletManager supports network // switching cleanly, call into it here. - _ = await rebuildSdk( + let outcome = await rebuildSdk( on: network, switchingTo: newNetwork, - failurePrefix: "Failed to stop SPV, staying on \(network.displayName)" - ) { - platformBalanceSyncService.reset() - shieldedService.reset() - appState.publishCurrentNetwork(newNetwork) - devnetRebuildPending = false - // Auto-disable Docker when leaving Local - // before the new network's SDK is built, - // so the build reads the cleared flag. - if newNetwork != .regtest && appState.useDockerSetup { - appState.useDockerSetup = false + failurePrefix: "Failed to stop SPV, staying on \(network.displayName)", + prepare: { + if disablesDocker { appState.useDockerSetup = false } + }, + rollBack: { + if disablesDocker { appState.useDockerSetup = true } + }, + beforePublish: { + platformBalanceSyncService.reset() + shieldedService.reset() + devnetRebuildPending = false + devnetSettingsNeeded = false + if newNetwork == .devnet { + // The new SDK was built from the + // devnet fields as they are now. + devnetQuorumURLSnapshot = devnetQuorumURL + devnetNameSnapshot = devnetName + } } + ) + if newNetwork == .devnet && outcome == .failed { + devnetSettingsNeeded = true } } } @@ -209,7 +231,7 @@ struct OptionsView: View { coordinator: walletManager.registrationCoordinator ) - if appState.currentNetwork == .regtest { + if appState.currentNetwork == .regtest && !devnetSettingsNeeded { Toggle("Use Docker Setup", isOn: Binding( get: { appState.useDockerSetup }, set: { useDocker in @@ -228,16 +250,16 @@ struct OptionsView: View { defer { isSwitchingNetwork = false } _ = await rebuildSdk( on: network, - failurePrefix: "Failed to stop SPV, so the Docker setting was not changed" - ) { - appState.useDockerSetup = useDocker - } + failurePrefix: "Failed to stop SPV, so the Docker setting was not changed", + prepare: { appState.useDockerSetup = useDocker }, + rollBack: { appState.useDockerSetup = !useDocker } + ) } } )) // A network switch away from regtest keeps this - // section on screen while its stop runs; the - // toggle stays off-limits until the switch ends. + // section on screen until it publishes the new + // network; the toggle stays off-limits until then. .disabled(isSwitchingNetwork) .help("Connect to local dashmate Docker network. Stops SPV; restart it from the Wallet tab.") @@ -279,7 +301,7 @@ struct OptionsView: View { } } } - } else if appState.currentNetwork == .devnet { + } else if appState.currentNetwork == .devnet || devnetSettingsNeeded { // Devnet UX: a single user input — the quorum // list service URL. SPV peers and DAPI nodes // are both derived from `{quorumURL}/masternodes` @@ -290,6 +312,11 @@ struct OptionsView: View { // node churn — the list re-fetches on every // network switch / launch. VStack(alignment: .leading, spacing: 6) { + if appState.currentNetwork != .devnet { + Text("Switching to Devnet failed. Fix these settings, then pick Devnet again.") + .font(.caption) + .foregroundColor(.orange) + } Text("Quorum URL") .font(.caption) .foregroundColor(.secondary) @@ -339,6 +366,7 @@ struct OptionsView: View { let nameChanged = devnetNameSnapshot != devnetName devnetQuorumURLSnapshot = nil devnetNameSnapshot = nil + devnetSettingsNeeded = false guard quorumChanged || nameChanged || devnetRebuildPending else { return } guard appState.currentNetwork == .devnet else { return } // A network switch in progress replaces the SDK if @@ -360,15 +388,15 @@ struct OptionsView: View { on: .devnet, failurePrefix: "Failed to stop SPV, so the devnet settings were not applied" ) - if outcome == .stopFailed { + if outcome == .stopFailed || outcome == .failed { devnetRebuildPending = true } } } } else { Toggle("Use Custom SPV Peers", isOn: $customSpvPeersEnabled) - // Its peer seed is network-specific, and - // `currentNetwork` changes partway through a switch. + // Its peer seed is network-specific, and during a + // switch `currentNetwork` is still the old one. .disabled(isSwitchingNetwork) .onChange(of: customSpvPeersEnabled) { _, isOn in // When enabling, seed the peers field with @@ -652,30 +680,32 @@ struct OptionsView: View { } /// Rebuild the SDK for `newNetwork` (by default `network`, the network the - /// flow started on), and move the wallet manager and the wallet-scoped - /// services onto it. See `rebuildSdkAndMoveWalletManager` for the order. - /// - /// `switchNetwork` alone would leave the cached manager on its clone of - /// the old SDK (old endpoints, old quorum policy, the old adaptive - /// provider's SPV source slot). Callers own + /// flow started on), move the wallet manager onto it, then publish the + /// SDK and its network. See `rebuildSdkAndMoveWalletManager` for the order + /// and the rollback: `prepare` writes the settings the build reads, + /// `rollBack` restores them if the build or the move fails, and + /// `beforePublish` runs just before a successful publish. Callers own /// `invalidatePendingSpvStarts()` and `isSwitchingNetwork`. @MainActor private func rebuildSdk( on network: Network, switchingTo newNetwork: Network? = nil, failurePrefix: String, - apply: @MainActor () -> Void = {} + prepare: @MainActor () -> Void = {}, + rollBack: @MainActor () -> Void = {}, + beforePublish: @MainActor () -> Void = {} ) async -> SdkRebuildOutcome { await rebuildSdkAndMoveWalletManager( from: network, to: newNetwork ?? network, stopSpv: { await stopSpvForReconfiguration(failurePrefix: failurePrefix) }, currentNetwork: { appState.currentNetwork }, - apply: apply, - rebuildSdk: { await appState.switchNetwork(to: $0) }, + prepare: prepare, + buildSdk: { await appState.buildSdk(for: $0) }, activateManager: { (target: Network, sdk: SDK) -> Bool in do { try walletManagerStore.activate(network: target, sdk: sdk) + return true } catch { SDKLogger.event( "manager_activation_failed", @@ -685,12 +715,15 @@ struct OptionsView: View { error: error ) appState.showError( - message: "The SDK was rebuilt, but the wallet manager could not move to it: \(error.localizedDescription)" + message: "The wallet manager could not move to the new \(target.displayName) SDK, so the app stays on its current SDK. SPV stays stopped until you restart it from the Wallet tab. \(error.localizedDescription)" ) return false } - appState.applyQuorumMode() - return true + }, + rollBack: rollBack, + publish: { (target: Network, sdk: SDK) in + beforePublish() + appState.publish(sdk, for: target) }, // The App scene's tick observer runs `rebindWalletScopedServices`. rebindWalletScopedServices: { appState.walletScopedServicesRebindTick &+= 1 } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift index fb8c6f6b63e..b55c819251c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift @@ -199,8 +199,8 @@ final class WalletManagerStore: ObservableObject { // If `AppState` has rebuilt the SDK since (network switch or // UserDefaults endpoint override change), the cached manager // is still pointing at the old endpoints and would keep - // failing proof verification. Drop it so the rebuild below - // picks up the fresh SDK. + // failing proof verification. The manager built below against + // the fresh SDK replaces it once `configure` succeeds. if let existing = managers[network] { if managerSdks[network] === sdk { if makeActive && existing !== activeManager { @@ -222,14 +222,10 @@ final class WalletManagerStore: ObservableObject { "reason": .publicText("sdk_changed"), ] ) - // No `activeManager = nil` — the field isn't optional. The - // rebuild below will overwrite it via `if makeActive { - // activeManager = manager }`. Until that line runs, the - // old `activeManager` reference still points at the - // now-stale cached manager, but it'll be replaced before - // any caller observes it (this method is synchronous). - managers[network] = nil - managerSdks[network] = nil + // The stale entry stays cached, and active if it was, until + // the manager built below replaces it. If `configure` throws, + // the store still tracks the manager that stays active on the + // SDK the app keeps. } let manager = PlatformWalletManager() try manager.configure(sdk: sdk, modelContainer: modelContainer) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/SdkRebuildSequenceTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/SdkRebuildSequenceTests.swift index af4cf21f42f..ce80d971836 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/SdkRebuildSequenceTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/SdkRebuildSequenceTests.swift @@ -3,11 +3,13 @@ import XCTest @testable import SwiftExampleApp /// Pins the order of `rebuildSdkAndMoveWalletManager`, the sequence every -/// SDK rebuild from Options runs: stop SPV, apply the new settings, build -/// the SDK, move the wallet manager onto exactly that SDK, then rebind the -/// wallet-scoped services. A manager activated with the SDK published -/// before the rebuild keeps the previous network's SDK clone and its -/// adaptive provider's SPV source slot. +/// SDK rebuild from Options runs: stop SPV, prepare the settings the build +/// reads, build the SDK, move the wallet manager onto exactly that SDK, then +/// publish the SDK and its network together and rebind the wallet-scoped +/// services. A manager activated with an SDK other than the one published +/// keeps another network's SDK clone and adaptive provider SPV source slot, +/// and a network published before a failed build or move leaves the +/// selected network out of step with the active manager. /// /// The steps are injected, so no real SDK or `PlatformWalletManager` is /// built; `FakeSdk` stands in for the SDK and is compared by identity. @@ -18,9 +20,11 @@ final class SdkRebuildSequenceTests: XCTestCase { private enum Step: Equatable { case stopSpv - case apply - case rebuildSdk(Network) + case prepare + case buildSdk(Network) case activateManager(Network) + case rollBack + case publish(Network) case rebindWalletScopedServices } @@ -42,9 +46,8 @@ final class SdkRebuildSequenceTests: XCTestCase { to target: Network, stopSucceeds: Bool = true, networkAfterStop: Network? = nil, - rebuildSucceeds: Bool = true, - activationSucceeds: Bool = true, - apply: @MainActor () -> Void = {} + buildSucceeds: Bool = true, + activationSucceeds: Bool = true ) async -> SdkRebuildOutcome { await rebuildSdkAndMoveWalletManager( from: network, @@ -57,21 +60,22 @@ final class SdkRebuildSequenceTests: XCTestCase { return stopSucceeds }, currentNetwork: { app.currentNetwork }, - apply: { - app.steps.append(.apply) - apply() - }, - rebuildSdk: { (network: Network) -> FakeSdk? in - app.steps.append(.rebuildSdk(network)) - guard rebuildSucceeds else { return nil } - let sdk = FakeSdk() - app.publishedSdk = sdk - return sdk + prepare: { app.steps.append(.prepare) }, + buildSdk: { (network: Network) -> FakeSdk? in + app.steps.append(.buildSdk(network)) + return buildSucceeds ? FakeSdk() : nil }, activateManager: { (network: Network, sdk: FakeSdk) -> Bool in app.steps.append(.activateManager(network)) + guard activationSucceeds else { return false } app.activatedSdk = sdk - return activationSucceeds + return true + }, + rollBack: { app.steps.append(.rollBack) }, + publish: { (network: Network, sdk: FakeSdk) in + app.steps.append(.publish(network)) + app.currentNetwork = network + app.publishedSdk = sdk }, rebindWalletScopedServices: { app.steps.append(.rebindWalletScopedServices) @@ -79,31 +83,31 @@ final class SdkRebuildSequenceTests: XCTestCase { ) } - /// A network switch publishes the new network after SPV stops, builds - /// that network's SDK, and moves the manager onto that SDK before the - /// services rebind. - func testNetworkSwitchMovesTheManagerOntoTheNewNetworksSdk() async { + /// A network switch builds the new network's SDK and moves the manager + /// onto it before publishing the network and that SDK; the services + /// rebind last. + func testNetworkSwitchPublishesTheSdkTheManagerMovedOnto() async { let app = AppModel(currentNetwork: .testnet) let previousSdk = app.publishedSdk - let outcome = await run(app, from: .testnet, to: .regtest) { - app.currentNetwork = .regtest - } + let outcome = await run(app, from: .testnet, to: .regtest) - XCTAssertEqual(outcome, .finished) + XCTAssertEqual(outcome, .rebuilt) XCTAssertEqual( app.steps, [ .stopSpv, - .apply, - .rebuildSdk(.regtest), + .prepare, + .buildSdk(.regtest), .activateManager(.regtest), + .publish(.regtest), .rebindWalletScopedServices, ] ) + XCTAssertEqual(app.currentNetwork, .regtest) XCTAssertTrue( app.activatedSdk === app.publishedSdk, - "the manager must move onto the SDK the rebuild published" + "the manager must run on the SDK that is published" ) XCTAssertFalse( app.activatedSdk === previousSdk, @@ -120,14 +124,15 @@ final class SdkRebuildSequenceTests: XCTestCase { let outcome = await run(app, from: .regtest, to: .regtest) - XCTAssertEqual(outcome, .finished) + XCTAssertEqual(outcome, .rebuilt) XCTAssertEqual( app.steps, [ .stopSpv, - .apply, - .rebuildSdk(.regtest), + .prepare, + .buildSdk(.regtest), .activateManager(.regtest), + .publish(.regtest), .rebindWalletScopedServices, ] ) @@ -137,12 +142,10 @@ final class SdkRebuildSequenceTests: XCTestCase { /// A failed stop may leave the old run loop parked. Nothing changes, so /// the app stays on its network and picking again retries the stop. - func testFailedStopAppliesAndRebuildsNothing() async { + func testFailedStopChangesNothing() async { let app = AppModel(currentNetwork: .testnet) - let outcome = await run(app, from: .testnet, to: .regtest, stopSucceeds: false) { - app.currentNetwork = .regtest - } + let outcome = await run(app, from: .testnet, to: .regtest, stopSucceeds: false) XCTAssertEqual(outcome, .stopFailed) XCTAssertEqual(app.steps, [.stopSpv]) @@ -151,7 +154,7 @@ final class SdkRebuildSequenceTests: XCTestCase { /// The stop suspends; a flow whose network was replaced meanwhile must /// not rebuild for a network the app has left. - func testNetworkChangedDuringTheStopRebuildsNothing() async { + func testNetworkChangedDuringTheStopChangesNothing() async { let app = AppModel(currentNetwork: .testnet) let outcome = await run(app, from: .testnet, to: .testnet, networkAfterStop: .devnet) @@ -160,29 +163,37 @@ final class SdkRebuildSequenceTests: XCTestCase { XCTAssertEqual(app.steps, [.stopSpv]) } - /// With no new SDK there is nothing to move the manager onto, and the - /// services must not rebind against the manager left behind. - func testFailedRebuildSkipsActivationAndRebind() async { + /// A failed SDK build during a network switch must leave the selected + /// network and the published SDK as they were, matching the manager that + /// stays active, and undo the prepared settings. + func testFailedBuildDuringANetworkSwitchKeepsTheSelectedNetwork() async { let app = AppModel(currentNetwork: .testnet) + let previousSdk = app.publishedSdk - let outcome = await run(app, from: .testnet, to: .regtest, rebuildSucceeds: false) + let outcome = await run(app, from: .testnet, to: .devnet, buildSucceeds: false) - XCTAssertEqual(outcome, .finished) - XCTAssertEqual(app.steps, [.stopSpv, .apply, .rebuildSdk(.regtest)]) + XCTAssertEqual(outcome, .failed) + XCTAssertEqual(app.steps, [.stopSpv, .prepare, .buildSdk(.devnet), .rollBack]) + XCTAssertEqual(app.currentNetwork, .testnet) + XCTAssertTrue(app.publishedSdk === previousSdk) XCTAssertNil(app.activatedSdk) } - /// The services bind to the active manager. If it did not move, a rebind - /// would attach them to the previous one. - func testFailedActivationSkipsRebind() async { - let app = AppModel(currentNetwork: .regtest) + /// A manager that could not move stays on the previous SDK, so that SDK + /// and its network must stay published, and the services must not + /// rebind. + func testFailedActivationDuringANetworkSwitchKeepsTheSelectedNetwork() async { + let app = AppModel(currentNetwork: .testnet) + let previousSdk = app.publishedSdk - let outcome = await run(app, from: .regtest, to: .regtest, activationSucceeds: false) + let outcome = await run(app, from: .testnet, to: .devnet, activationSucceeds: false) - XCTAssertEqual(outcome, .finished) + XCTAssertEqual(outcome, .failed) XCTAssertEqual( app.steps, - [.stopSpv, .apply, .rebuildSdk(.regtest), .activateManager(.regtest)] + [.stopSpv, .prepare, .buildSdk(.devnet), .activateManager(.devnet), .rollBack] ) + XCTAssertEqual(app.currentNetwork, .testnet) + XCTAssertTrue(app.publishedSdk === previousSdk) } } From 615bcbf98b94581fce2eb6e2ae01455db6d09f6b Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 23 Oct 2026 00:36:59 +0700 Subject: [PATCH 36/38] fix(sdk): report adaptive SDKs and their live quorum source in dash_sdk_get_status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dash_sdk_get_status` derived `mode` from `trusted_provider.is_some()`, so an adaptive SDK, which also holds a trusted provider, always reported "trusted" and the trusted cache's quorum count, even while its proofs were verified with SPV quorums. Callers that supplied their own provider reported "spv". The status JSON is now `{version, network, mode, quorumSource, quorumCount}`: - `mode`: "adaptive", "trusted", "spv", or "custom" for a provider this crate did not build (caller-supplied or mock); - `quorumSource`: the source answering quorum lookups now, "trusted" or "spv"; an adaptive SDK reports its live routing choice; null for custom; - `quorumCount`: quorums in the trusted provider's cache, whichever source answers; 0 without one. A shared `quorum_source` helper backs both the status and `dash_sdk_get_context_provider_source`, whose C return value is unchanged. Swift `SDKStatus` gains `quorumSource: String?` (decodes old and new payloads), and the example app labels the count "Trusted Quorums Cached". The example app's Masternodes sync row, hidden for "trusted", now shows for adaptive SDKs, which sync masternode lists for SPV routing. Addresses thepastaclaw's suggestion on a30f2b602 (status.rs:33-43). Test would have caught this in CI: should_report_an_adaptive_sdk_and_the_source_it_routes_to and should_report_the_fixed_sources_and_a_caller_supplied_provider ✖ before the fix: the adaptive SDK reported mode "trusted"; no status carried a quorum source ✔ after the fix (cargo test -p rs-sdk-ffi --all-features --lib). Co-Authored-By: Claude Opus 5.5 --- packages/rs-sdk-ffi/src/sdk.rs | 30 ++- packages/rs-sdk-ffi/src/system/status.rs | 172 ++++++++++++++++-- .../swift-sdk/Sources/SwiftDashSDK/SDK.swift | 9 + .../SwiftExampleApp/Views/OptionsView.swift | 2 +- 4 files changed, 189 insertions(+), 24 deletions(-) diff --git a/packages/rs-sdk-ffi/src/sdk.rs b/packages/rs-sdk-ffi/src/sdk.rs index 447af28d43a..f867339154a 100644 --- a/packages/rs-sdk-ffi/src/sdk.rs +++ b/packages/rs-sdk-ffi/src/sdk.rs @@ -738,7 +738,25 @@ pub unsafe extern "C" fn dash_sdk_set_context_provider_mode( } } -/// Return the currently selected quorum source for UI diagnostics. +/// The source answering `wrapper`'s quorum lookups now: an adaptive +/// provider's live routing choice, else the one source a trusted or strict +/// SPV SDK has. `None` for a provider this crate did not build. +pub(crate) fn quorum_source(wrapper: &SDKWrapper) -> Option { + 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. + Some(wrapper.runtime.block_on(async { provider.active_source() })) + } else if wrapper.trusted_provider.is_some() { + Some(AdaptiveContextProviderSource::Trusted) + } else if wrapper.spv_source_slot.is_some() { + Some(AdaptiveContextProviderSource::Spv) + } else { + None + } +} + +/// Return the currently selected quorum source for UI diagnostics. A +/// provider this crate did not build reports `Trusted`. /// /// # Safety /// `sdk_handle` must be a valid SDK handle for the duration of this call. @@ -748,15 +766,7 @@ pub unsafe extern "C" fn dash_sdk_get_context_provider_source(sdk_handle: *const return AdaptiveContextProviderSource::Trusted as u8; } let wrapper = &*(sdk_handle as *const SDKWrapper); - 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_slot.is_some() { - AdaptiveContextProviderSource::Spv as u8 - } else { - AdaptiveContextProviderSource::Trusted as u8 - } + quorum_source(wrapper).unwrap_or(AdaptiveContextProviderSource::Trusted) as u8 } /// Register global context provider callbacks diff --git a/packages/rs-sdk-ffi/src/system/status.rs b/packages/rs-sdk-ffi/src/system/status.rs index 6d52e042e11..4b6d3633b44 100644 --- a/packages/rs-sdk-ffi/src/system/status.rs +++ b/packages/rs-sdk-ffi/src/system/status.rs @@ -1,13 +1,24 @@ //! SDK status query +use dash_context_provider::routing::AdaptiveContextProviderSource; use serde_json::json; use std::ffi::CString; -use crate::sdk::SDKWrapper; +use crate::sdk::{quorum_source, SDKWrapper}; use crate::types::SDKHandle; use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult}; -/// Get SDK status including mode and quorum count +/// Get SDK status as JSON: `version`, `network`, `mode`, `quorumSource` and +/// `quorumCount`. +/// +/// - `mode` is the context provider the SDK was built with: `"trusted"`, +/// `"spv"`, `"adaptive"`, or `"custom"` for a provider this crate did not +/// build (caller-supplied or mock). +/// - `quorumSource` is the source answering quorum lookups now: `"trusted"` +/// or `"spv"`. An adaptive SDK reports its live routing choice. It is +/// `null` for a custom provider. +/// - `quorumCount` is the number of quorums in the trusted provider's cache, +/// whichever source answers; `0` without a trusted provider. /// /// # Safety /// - `sdk_handle` must be a valid pointer to an initialized SDKHandle. @@ -30,24 +41,38 @@ pub unsafe extern "C" fn dash_sdk_get_status(sdk_handle: *const SDKHandle) -> Da let network_str = wrapper.sdk.network.to_string(); - // Determine mode based on whether we have a trusted provider - let (mode, quorum_count) = if let Some(ref provider) = wrapper.trusted_provider { - let count = provider.get_cached_quorum_count(); - tracing::debug!( - quorum_count = count, - "dash_sdk_get_status: trusted provider quorum count" - ); - ("trusted", count) + // An adaptive SDK also holds a trusted provider, so it is checked first, + // in the order `quorum_source` uses. + let mode = if wrapper.adaptive_provider.is_some() { + "adaptive" + } else if wrapper.trusted_provider.is_some() { + "trusted" + } else if wrapper.spv_source_slot.is_some() { + "spv" } else { - // If no trusted provider, we're in SPV mode - ("spv", 0) + "custom" }; + let quorum_source = quorum_source(wrapper).map(|source| match source { + AdaptiveContextProviderSource::Trusted => "trusted", + AdaptiveContextProviderSource::Spv => "spv", + }); + + let quorum_count = wrapper + .trusted_provider + .as_ref() + .map_or(0, |provider| provider.get_cached_quorum_count()); + tracing::debug!( + mode, + quorum_source, + quorum_count, + "dash_sdk_get_status: quorum sources" + ); - // Create status JSON let status = json!({ "version": env!("CARGO_PKG_VERSION"), "network": network_str, "mode": mode, + "quorumSource": quorum_source, "quorumCount": quorum_count, }); @@ -76,3 +101,124 @@ pub unsafe extern "C" fn dash_sdk_get_status(sdk_handle: *const SDKHandle) -> Da tracing::info!("dash_sdk_get_status: success"); DashSDKResult::success_string(c_str.into_raw()) } + +#[cfg(test)] +mod tests { + use std::ffi::{CStr, CString}; + + use dash_context_provider::routing::ContextProviderMode; + use serde_json::Value; + + use super::dash_sdk_get_status; + use crate::sdk::SDKWrapper; + use crate::types::{DashSDKConfig, Network, SDKHandle}; + use crate::{ + dash_sdk_create_adaptive, dash_sdk_create_spv, dash_sdk_create_trusted, dash_sdk_destroy, + dash_sdk_error_free, dash_sdk_set_context_provider_mode, dash_sdk_string_free, + DashSDKResult, + }; + + /// A fixed-provider SDK built from `create`. Nothing connects: the DAPI + /// address and the quorum URL are loopback and only parsed. + fn sdk( + create: unsafe extern "C" fn(*const DashSDKConfig) -> DashSDKResult, + network: Network, + quorum_url: Option<&CString>, + ) -> *mut SDKHandle { + let dapi = CString::new("http://127.0.0.1:1").expect("address"); + let config = DashSDKConfig { + network: network.into(), + dapi_addresses: dapi.as_ptr(), + skip_asset_lock_proof_verification: false, + request_retry_count: 1, + request_timeout_ms: 1_000, + quorum_url: quorum_url.map_or(std::ptr::null(), |url| url.as_ptr()), + platform_version: 0, + }; + let result = unsafe { create(&config) }; + if !result.error.is_null() { + let message = unsafe { CStr::from_ptr((*result.error).message) } + .to_string_lossy() + .into_owned(); + unsafe { dash_sdk_error_free(result.error) }; + panic!("SDK failed to build: {message}"); + } + result.data as *mut SDKHandle + } + + fn status(handle: *const SDKHandle) -> Value { + let result = unsafe { dash_sdk_get_status(handle) }; + if !result.error.is_null() { + let message = unsafe { CStr::from_ptr((*result.error).message) } + .to_string_lossy() + .into_owned(); + unsafe { dash_sdk_error_free(result.error) }; + panic!("status failed: {message}"); + } + assert!(!result.data.is_null(), "status returned no JSON"); + let json = result.data as *mut std::os::raw::c_char; + let status = serde_json::from_str( + unsafe { CStr::from_ptr(json) } + .to_str() + .expect("UTF-8 status"), + ) + .expect("JSON status"); + unsafe { dash_sdk_string_free(json) }; + status + } + + /// An adaptive SDK holds a trusted provider but routes quorum lookups + /// between it and SPV at run time. Its status must say it is adaptive and + /// which source answers now, not report it as trusted for holding a + /// trusted provider. + #[test] + fn should_report_an_adaptive_sdk_and_the_source_it_routes_to() { + let quorum_url = CString::new("http://127.0.0.1:1").expect("quorum URL"); + let handle = sdk( + dash_sdk_create_adaptive, + Network::Regtest, + Some(&quorum_url), + ); + + let auto = status(handle); + assert_eq!(auto["mode"], "adaptive"); + assert_eq!( + auto["quorumSource"], "trusted", + "Auto routes to the trusted provider until an SPV source is ready" + ); + + let set = + unsafe { dash_sdk_set_context_provider_mode(handle, ContextProviderMode::Spv as u8) }; + assert!(set.error.is_null(), "setting the mode must succeed"); + let spv = status(handle); + assert_eq!(spv["mode"], "adaptive"); + assert_eq!(spv["quorumSource"], "spv"); + + unsafe { dash_sdk_destroy(handle) }; + } + + /// Trusted and strict SPV SDKs each have one source. An SDK whose + /// provider the caller supplied has no source this crate can name. + #[test] + fn should_report_the_fixed_sources_and_a_caller_supplied_provider() { + let quorum_url = CString::new("http://127.0.0.1:1").expect("quorum URL"); + let trusted = sdk(dash_sdk_create_trusted, Network::Regtest, Some(&quorum_url)); + let status_of_trusted = status(trusted); + assert_eq!(status_of_trusted["mode"], "trusted"); + assert_eq!(status_of_trusted["quorumSource"], "trusted"); + unsafe { dash_sdk_destroy(trusted) }; + + let spv = sdk(dash_sdk_create_spv, Network::Devnet, None); + let status_of_spv = status(spv); + assert_eq!(status_of_spv["mode"], "spv"); + assert_eq!(status_of_spv["quorumSource"], "spv"); + assert_eq!(status_of_spv["quorumCount"], 0); + unsafe { dash_sdk_destroy(spv) }; + + let custom = Box::into_raw(Box::new(SDKWrapper::new_mock())) as *mut SDKHandle; + let status_of_custom = status(custom); + assert_eq!(status_of_custom["mode"], "custom"); + assert!(status_of_custom["quorumSource"].is_null()); + unsafe { dash_sdk_destroy(custom) }; + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift index d8ee88c54b6..59bddf57c1e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift @@ -652,7 +652,16 @@ public final class SDK: @unchecked Sendable { public struct SDKStatus: Codable { public let version: String public let network: String + /// The context provider the SDK was built with: `"trusted"`, `"spv"`, + /// `"adaptive"`, or `"custom"` for a provider the native SDK did not build + /// (caller-supplied or mock). public let mode: String + /// The source answering quorum lookups now, `"trusted"` or `"spv"`; an + /// adaptive SDK reports its live routing choice. `nil` for a custom + /// provider. + public let quorumSource: String? + /// Quorums in the trusted provider's cache, whichever source answers; + /// `0` without a trusted provider. public let quorumCount: Int } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift index c40fd1a13e8..3bcc25e6ed2 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift @@ -604,7 +604,7 @@ struct OptionsView: View { } HStack { - Text("Quorums in Memory") + Text("Trusted Quorums Cached") Spacer() Text("\(status.quorumCount)") .foregroundColor(status.quorumCount > 0 ? .green : .red) From 9f75469bc2d8eb0e14f139484c8d9db26f6fa141 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 29 Oct 2026 15:55:50 +0700 Subject: [PATCH 37/38] fix(swift-sdk): keep known-contract buffers valid for the whole FFI call `SDK.loadKnownContracts` returned each contract's pointer out of its `Data.withUnsafeBytes` closure, then passed it to `dash_sdk_add_known_contracts`, which dereferences it immediately (`slice::from_raw_parts`, then deserialize). A `withUnsafeBytes` pointer is valid only inside its closure. Real contracts are larger than inline `Data` (14 bytes), so their pointers landed in heap storage that the `contracts` argument kept alive, and the code worked by accident. For inline `Data` the pointer is a dead stack copy. Strict-SPV proof verification depends on this preload. `SDK.withUnsafeBytePointers(to:_:)` copies the buffers into one allocation that it owns for the duration of the body and frees afterwards. Every pointer is non-nil, including an empty buffer's. Code review rejected nesting each buffer's `withUnsafeBytes` instead: stack depth grows with the contract count (it overflowed at about 1,000 buffers on a 512 KiB stack), and empty `Data` still yields a nil pointer. Ids, lengths, behaviour and error mapping are unchanged. Tests (SwiftDashSDKTests): - testShouldKeepEveryBufferReadableUntilTheBodyReturns: inline and heap buffers read back correctly through the helper's pointers. - testShouldReportAContractRustCannotDeserialize: undecodable bytes on a regtest strict-SPV SDK surface as `SDKError.internalError` carrying Rust's reason. Red -> green: the helper is new, so the first test's body ran in a standalone swiftc harness against the same signature, once with the old body (pointers returned out of `withUnsafeBytes`) and once with the new one. - Old body: failed in 6 of 6 runs at -Onone and at -O. Every inline buffer read the same reused stack slot, e.g. [[176,141,87],[176,141,87,4],[176]]. - New body: passed in 6 of 6, plus empty, sliced, 4 KiB and 100,000-buffer inputs. No end-to-end test can go red, because no valid contract fits in inline `Data`. Verified locally (no Xcode or XCTest on this machine): - the harness above; - the verbatim `loadKnownContracts` body and helper type-check under Swift 6 with -warnings-as-errors against the generated DashSDKFFI headers of a release-ios aarch64-apple-darwin build; - the test file type-checks against that module plus an XCTest signature stub; - `cargo clippy -D warnings` is clean for rs-sdk-ffi and rs-unified-sdk-ffi on aarch64-apple-darwin. `swift test` runs only in CI. Co-Authored-By: Claude Opus 5.5 --- .../swift-sdk/Sources/SwiftDashSDK/SDK.swift | 53 +++++++++++++------ .../SDKKnownContractsTests.swift | 53 +++++++++++++++++++ 2 files changed, 89 insertions(+), 17 deletions(-) create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SDKKnownContractsTests.swift diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift index 59bddf57c1e..8b79aaae624 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift @@ -488,26 +488,22 @@ public final class SDK: @unchecked Sendable { // Prepare contract IDs as comma-separated string let contractIds = contracts.map { $0.id }.joined(separator: ",") - // Prepare arrays of contract data - let contractDataPointers = contracts.map { contract in - contract.data.withUnsafeBytes { bytes in - bytes.baseAddress?.assumingMemoryBound(to: UInt8.self) - } - } - let contractLengths = contracts.map { $0.data.count } - // Call the FFI function + // Call the FFI function. Rust reads every contract through its pointer + // during the call, so all of them must stay valid until it returns. let result = contractIds.withCString { idsCStr in - contractDataPointers.withUnsafeBufferPointer { dataPointers in - contractLengths.withUnsafeBufferPointer { lengths in - dash_sdk_add_known_contracts( - handle, - idsCStr, - dataPointers.baseAddress, - lengths.baseAddress, - UInt(contracts.count) - ) + Self.withUnsafeBytePointers(to: contracts.map { $0.data }) { contractDataPointers in + contractDataPointers.withUnsafeBufferPointer { dataPointers in + contractLengths.withUnsafeBufferPointer { lengths in + dash_sdk_add_known_contracts( + handle, + idsCStr, + dataPointers.baseAddress, + lengths.baseAddress, + UInt(contracts.count) + ) + } } } } @@ -526,6 +522,29 @@ public final class SDK: @unchecked Sendable { print("✅ Successfully loaded \(contracts.count) known contracts into SDK") } + /// Run `body` with a pointer to the bytes of each buffer, in order, all + /// valid until `body` returns. A pointer from `Data.withUnsafeBytes` is + /// valid only inside that closure (for a `Data` small enough to be stored + /// inline it points at a temporary copy), so the bytes are copied into one + /// allocation this function owns and frees after `body`. Every pointer is + /// non-nil, an empty buffer's included. + static func withUnsafeBytePointers( + to buffers: [Data], + _ body: ([UnsafePointer?]) -> R + ) -> R { + let total = buffers.reduce(0) { $0 + $1.count } + let storage = UnsafeMutablePointer.allocate(capacity: max(total, 1)) + defer { storage.deallocate() } + var offset = 0 + let pointers = buffers.map { buffer -> UnsafePointer? in + let start = storage + offset + buffer.copyBytes(to: start, count: buffer.count) + offset += buffer.count + return UnsafePointer(start) + } + return body(pointers) + } + deinit { if let handle = handle { // Timed + thread-stamped: `dash_sdk_destroy` tears down the tokio diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SDKKnownContractsTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SDKKnownContractsTests.swift new file mode 100644 index 00000000000..6119fc8e95f --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SDKKnownContractsTests.swift @@ -0,0 +1,53 @@ +import XCTest + +@testable import SwiftDashSDK + +/// `SDK.loadKnownContracts` hands Rust one pointer per serialized contract, +/// and Rust deserializes through them during the call. Every pointer must +/// still address its contract's bytes at that point, not a copy that died +/// when the closure that produced it returned. +final class SDKKnownContractsTests: XCTestCase { + + /// `Data` of up to 14 bytes is stored inline, so the pointer + /// `withUnsafeBytes` hands out is a temporary stack copy. Returning it out + /// of the closure leaves the buffer reading whatever later reused that + /// stack slot. The last buffer is stored on the heap, like a real + /// contract. + func testShouldKeepEveryBufferReadableUntilTheBodyReturns() { + let buffers = [ + Data([0x01, 0x02, 0x03]), + Data([0x04, 0x05, 0x06, 0x07]), + Data([0x08]), + Data(repeating: 0x09, count: 256), + ] + + let read = SDK.withUnsafeBytePointers(to: buffers) { pointers in + zip(pointers, buffers).map { pointer, buffer in + Data(bytes: pointer!, count: buffer.count) + } + } + + XCTAssertEqual(read, buffers) + } + + /// Bytes Rust cannot deserialize as a contract must surface as an SDK + /// error carrying Rust's reason, not load silently. Reaching Rust's + /// deserializer also shows the ids, pointers and lengths arrived intact; + /// the pointer lifetime itself is pinned by the test above, since no + /// valid contract is small enough to be stored inline. + func testShouldReportAContractRustCannotDeserialize() throws { + let sdk = try SDK(network: .regtest, contextProvider: .spv) + + XCTAssertThrowsError( + try sdk.loadKnownContracts([(id: "unreadable", data: Data(repeating: 0xFF, count: 64))]) + ) { error in + guard case SDKError.internalError(let message) = error else { + return XCTFail("expected internalError, got \(error)") + } + XCTAssertTrue( + message.hasPrefix("Failed to add known contracts: Failed to deserialize contract unreadable"), + message + ) + } + } +} From e7bcde68b94d1af3921ee1a4b3873a2ec4cf20dc Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 29 Oct 2026 15:56:15 +0700 Subject: [PATCH 38/38] fix(swift-example-app): shut down a replaced wallet manager before its replacement loads A network's cached manager can be configured against an SDK that the app has since rebuilt. In that case `WalletManagerStore.activate` configured a replacement, restored its wallets and cached it, but never shut the old manager down. `PlatformWalletManager.deinit` only schedules native teardown for whenever the last reference goes, and views and background callers can hold one. Until then the old manager's platform-address, shielded, DashPay and DPNS workers (and SPV, if it was still running) kept writing the network's persisted wallets while the replacement loaded and wrote the same scope. `activate` now: 1. Configures the replacement. If that throws, it returns with the cache, the active manager and the old manager untouched, so the transactional rebuild still keeps the old manager and the old SDK. Creating a native manager reads and writes no persisted state and starts no sync: it clones the SDK, builds an in-memory WalletManager and claims the new SDK's SPV source slot. 2. Bumps the SPV start generation if the old manager is the active one, because shutting it down stops the active manager's SPV. 3. Awaits the old manager's `shutdown()`. 4. Restores wallets synchronously, as before, through a private `restoreWallets`. From the async body, `loadFromPersistor()` would resolve to its off-main overload. Then it caches and swaps in the replacement. A replaced active manager is always swapped out, even with `makeActive == false`, so a shut-down manager cannot stay active. `activate` and `backgroundManager(for:)` are now async; all their callers already ran in async contexts. `rebuildSdkAndMoveWalletManager` takes an async `activateManager`. The swap still comes after activate's last suspension, so the swap and the SDK publish happen in one main-actor turn. During the shutdown the old manager stays cached and active, but its handle is already revoked, so calls on it fail with `invalidHandle`. Tests (SwiftExampleAppTests, using real regtest SDKs and native managers, no network): - testShouldFinishShuttingDownTheReplacedManagerBeforeItsReplacementTakesOver wraps the old manager's native destroy, observes `$activeManager`, and asserts that destroy comes before the swap. Expected red -> green from reading the code: the old code never shuts the manager down, so the log is ["swap"] and the manager stays configured; the fix makes it pass. It does not pin shutdown-before-restore: the sync loader has no seam. - testShouldKeepTheCurrentManagerWhenItsReplacementFailsToConfigure: a rebuilt SDK whose SPV slot another manager holds makes configure throw. The current manager stays cached, active and configured, and a retry replaces it once the slot is free. This also passes on the old code; it guards against shutting down before configuring. Neither test has run: the app's tests need Xcode and run only in CI. Verified locally (no Xcode or XCTest on this machine): - WalletManagerStore.swift and SdkRebuildSequence.swift, verbatim, plus the new test, type-check under Swift 6 with -warnings-as-errors. They were checked against a stub SwiftDashSDK module with the real signatures, including both `loadFromPersistor` overloads, and the generated DashSDKFFI headers. The stub catches the overload error when the restore is inlined back into `activate`. - Harnesses confirm that `rebuildSdkAndMoveWalletManager` accepts both sync and async closures. - A harness confirms that returning from an async @MainActor callee lets no other main-actor job run before the caller continues: 0 of 200 runs, versus 197 of 200 with a deliberate yield. Co-Authored-By: Claude Opus 5.5 --- .../SwiftExampleApp/ContentView.swift | 2 +- .../Core/Views/CreateWalletView.swift | 2 +- .../Core/Views/WalletDetailView.swift | 2 +- .../SwiftExampleApp/SdkRebuildSequence.swift | 8 +- .../SwiftExampleApp/SwiftExampleAppApp.swift | 8 +- .../SwiftExampleApp/Views/OptionsView.swift | 4 +- .../SwiftExampleApp/WalletManagerStore.swift | 97 +++++++++++----- .../WalletManagerStoreActivateTests.swift | 105 ++++++++++++++++++ 8 files changed, 188 insertions(+), 40 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/WalletManagerStoreActivateTests.swift diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift index da6717461ce..55535d7aff5 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift @@ -503,7 +503,7 @@ struct ContentView: View { // changing the user's currently visible network. let recoveryManager: PlatformWalletManager do { - recoveryManager = try walletManagerStore.backgroundManager(for: restoredNetwork) + recoveryManager = try await walletManagerStore.backgroundManager(for: restoredNetwork) } catch { let message = "\"\(entry.displayName)\" (\(restoredNetwork.displayName)): " + "failed to prepare wallet manager — \(error.localizedDescription)" diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift index 3af077393ad..ebf6c88f978 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift @@ -388,7 +388,7 @@ struct CreateWalletView: View { } for net in selectedNetworks { do { - let mgr = try walletManagerStore.backgroundManager(for: net) + let mgr = try await walletManagerStore.backgroundManager(for: net) let managed = try await mgr.createWallet( mnemonic: mnemonicPhrase, network: net, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index 04b69f05cd3..9d99c593782 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -868,7 +868,7 @@ struct WalletInfoView: View { } do { - let mgr = try walletManagerStore.backgroundManager(for: network) + let mgr = try await walletManagerStore.backgroundManager(for: network) // Enabling an existing wallet on another network: the mnemonic is // pre-existing and may already have on-chain history there — scan // from genesis (birthHeight 0) so prior funds/payments are seen. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SdkRebuildSequence.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SdkRebuildSequence.swift index 5c6c6758486..a86eaabc4ac 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SdkRebuildSequence.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SdkRebuildSequence.swift @@ -30,7 +30,9 @@ enum SdkRebuildOutcome: Equatable { /// returns `nil` after reporting its failure. /// 5. `activateManager` moves the wallet manager onto that SDK, or returns /// false after reporting a failure. The manager takes its SDK clone, and -/// the adaptive provider's SPV source slot, from this SDK. +/// the adaptive provider's SPV source slot, from this SDK. It may suspend +/// while the manager it replaces shuts down, and swaps the active manager +/// only after its last suspension. /// 6. `publish` makes `target` and the SDK current. The manager already /// runs on that SDK, so the selected network, the published SDK and the /// active manager change in the same main-actor turn. @@ -49,7 +51,7 @@ func rebuildSdkAndMoveWalletManager( currentNetwork: @MainActor () -> Network, prepare: @MainActor () -> Void, buildSdk: @MainActor (Network) async -> Sdk?, - activateManager: @MainActor (Network, Sdk) -> Bool, + activateManager: @MainActor (Network, Sdk) async -> Bool, rollBack: @MainActor () -> Void, publish: @MainActor (Network, Sdk) -> Void, rebindWalletScopedServices: @MainActor () -> Void @@ -57,7 +59,7 @@ func rebuildSdkAndMoveWalletManager( guard await stopSpv() else { return .stopFailed } guard currentNetwork() == network else { return .networkChanged } prepare() - guard let sdk = await buildSdk(target), activateManager(target, sdk) else { + guard let sdk = await buildSdk(target), await activateManager(target, sdk) else { rollBack() return .failed } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index da2213a0ce9..47d0f3b5de7 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -412,7 +412,7 @@ struct SwiftExampleAppApp: App { // (filtered to the launch network's wallets via // the network-aware persistence handler), so no // separate restore pass is needed here. - try walletManagerStore.activate( + try await walletManagerStore.activate( network: platformState.currentNetwork, sdk: sdk ) @@ -438,7 +438,7 @@ struct SwiftExampleAppApp: App { // main context at launch and the recovered // wallet appears in its correct tab on the same // run instead of only after a relaunch. - preWarmOrphanNetworkManagers() + await preWarmOrphanNetworkManagers() rebindWalletScopedServices() } @@ -503,7 +503,7 @@ struct SwiftExampleAppApp: App { /// background `ModelContext` doesn't propagate writes back to /// the launch-time main context's `@Query` observers. @MainActor - private func preWarmOrphanNetworkManagers() { + private func preWarmOrphanNetworkManagers() async { let storage = WalletStorage() let keychainIds = (try? storage.listWalletIdsWithMnemonic()) ?? [] guard !keychainIds.isEmpty else { return } @@ -519,7 +519,7 @@ struct SwiftExampleAppApp: App { let active = platformState.currentNetwork for network in orphanNetworks where network != active { do { - _ = try walletManagerStore.backgroundManager(for: network) + _ = try await walletManagerStore.backgroundManager(for: network) SDKLogger.log( "🔥 Pre-warmed wallet manager for \(network.displayName) " + "(orphan recovery target)", diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift index 3bcc25e6ed2..e3a044cf62b 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift @@ -702,9 +702,9 @@ struct OptionsView: View { currentNetwork: { appState.currentNetwork }, prepare: prepare, buildSdk: { await appState.buildSdk(for: $0) }, - activateManager: { (target: Network, sdk: SDK) -> Bool in + activateManager: { (target: Network, sdk: SDK) async -> Bool in do { - try walletManagerStore.activate(network: target, sdk: sdk) + try await walletManagerStore.activate(network: target, sdk: sdk) return true } catch { SDKLogger.event( diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift index b55c819251c..b7d97608f26 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift @@ -183,16 +183,29 @@ final class WalletManagerStore: ObservableObject { /// one configured against `sdk` if it's not already warm. /// /// Idempotent: re-activating the current network is a no-op. - /// Failures during a fresh manager's `configure` / - /// `loadFromPersistor` propagate to the caller — the cache - /// stays untouched in that case so a later retry can succeed. + /// A failure in the fresh manager's `configure` propagates to the + /// caller. The cache and the active manager stay untouched in that + /// case, including a cached manager the fresh one would have + /// replaced, so a later retry can succeed. + /// + /// Replacing a cached manager configured against another SDK awaits + /// that manager's `shutdown()` after the fresh one is configured and + /// before it restores wallets, so the old manager's teardown has + /// finished before the fresh one touches the network's persisted + /// wallets. That can take as long as the old manager's workers need to + /// stop. Until then the old manager stays cached, and active if it + /// was, but its handle is already revoked: `manager(for:)` and + /// `backgroundManager(for:)` still return it, and calls on it fail + /// with `invalidHandle`. Calls must not overlap for the same network: + /// callers run one rebuild at a time. /// /// `makeActive == false` materializes the manager into the cache - /// without swapping `activeManager`. Used by background flows - /// (e.g. orphan recovery routing wallets to their original - /// network) that need a manager for a non-active network without - /// triggering a user-visible network switch. - func activate(network: Network, sdk: SDK, makeActive: Bool = true) throws { + /// without swapping `activeManager`, unless the manager it replaces is + /// the active one. Used by background flows (e.g. orphan recovery + /// routing wallets to their original network) that need a manager for + /// a non-active network without triggering a user-visible network + /// switch. + func activate(network: Network, sdk: SDK, makeActive: Bool = true) async throws { // Stale-cache check: the cached manager's Sdk clone is locked // in at `configure` time (the FFI is single-shot — see // `PlatformWalletManager.configure`'s `precondition(!isConfigured)`). @@ -229,26 +242,29 @@ final class WalletManagerStore: ObservableObject { } let manager = PlatformWalletManager() try manager.configure(sdk: sdk, modelContainer: modelContainer) - // Best-effort: a fresh manager comes up with no wallets in - // memory; restore from the network-scoped persistence - // handler so reopening the app on this network resurfaces - // the wallets the user already created here. Failures here - // are non-fatal — the user can still create / import - // wallets, the in-memory set just starts empty. - do { - _ = try manager.loadFromPersistor() - } catch { - SDKLogger.event( - "manager_restore_failed", - category: .lifecycle, - severity: .error, - fields: ["network": .publicText(network.displayName)], - error: error - ) + // A replaced active manager is shut down below, so its replacement + // must become active even if the caller did not ask for that. + let becomesActive = makeActive || managers[network] === activeManager + if let stale = managers[network] { + // Nothing below can fail the replacement, so the stale manager + // goes now, before the fresh one loads this network's wallets. + // Its sync workers keep writing them until its native teardown + // ends, and `deinit` only schedules that teardown for whenever + // the last reference is released. Configuring first leaves it + // usable if `configure` throws: creating a native manager reads + // and writes no persisted state and starts no sync. + if stale === activeManager { + // Shutting it down stops the active manager's SPV, so a + // start suspended in peer resolution must bail instead of + // resuming against the torn-down manager. + invalidatePendingSpvStarts() + } + await stale.shutdown() } + restoreWallets(into: manager, network: network) managers[network] = manager managerSdks[network] = sdk - if makeActive { + if becomesActive { // A freshly (re)built active manager supersedes any in-flight // SPV start against the previous one (defensive backstop; the // supersession call sites also invalidate at their earliest @@ -260,13 +276,38 @@ final class WalletManagerStore: ObservableObject { "manager_materialized", category: .lifecycle, fields: [ - "active": .boolean(makeActive), + "active": .boolean(becomesActive), "network": .publicText(network.displayName), "wallet_count": .integer(Int64(manager.wallets.count)), ] ) } + /// Best-effort: a fresh manager comes up with no wallets in + /// memory; restore from the network-scoped persistence + /// handler so reopening the app on this network resurfaces + /// the wallets the user already created here. Failures here + /// are non-fatal — the user can still create / import + /// wallets, the in-memory set just starts empty. + /// + /// Synchronous so the restore runs on the calling thread right before + /// `activate` caches and swaps the manager: called from `activate`'s + /// async body, `loadFromPersistor()` would resolve to its off-main + /// overload and add a suspension there. + private func restoreWallets(into manager: PlatformWalletManager, network: Network) { + do { + _ = try manager.loadFromPersistor() + } catch { + SDKLogger.event( + "manager_restore_failed", + category: .lifecycle, + severity: .error, + fields: ["network": .publicText(network.displayName)], + error: error + ) + } + } + /// Manager for `network` if one has been activated this session; /// otherwise `nil`. Used for diagnostics surfaces (e.g. Wallet /// Memory Explorer) that want to inspect a specific network's @@ -287,12 +328,12 @@ final class WalletManagerStore: ObservableObject { /// manager stamps `wallet.network = self.sdk.network` at /// registration time, so a regtest mnemonic recovered through /// the testnet manager lands as a testnet row). - func backgroundManager(for network: Network) throws -> PlatformWalletManager { + func backgroundManager(for network: Network) async throws -> PlatformWalletManager { if let existing = managers[network] { return existing } let sdk = try SDK(network: network, contextProvider: .adaptive) - try activate(network: network, sdk: sdk, makeActive: false) + try await activate(network: network, sdk: sdk, makeActive: false) guard let manager = managers[network] else { throw PlatformWalletError.invalidParameter( "Failed to materialize manager for \(network.displayName)" diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/WalletManagerStoreActivateTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/WalletManagerStoreActivateTests.swift new file mode 100644 index 00000000000..578935110bc --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/WalletManagerStoreActivateTests.swift @@ -0,0 +1,105 @@ +import Combine +import DashSDKFFI +import SwiftData +import XCTest +@testable import SwiftDashSDK +@testable import SwiftExampleApp + +/// Pins how `WalletManagerStore.activate` replaces a cached manager that was +/// configured against an SDK the app has since rebuilt: +/// +/// - the replaced manager's native teardown finishes before its replacement +/// becomes the active manager. Its sync workers keep writing the network's +/// persisted wallets until that teardown ends, and dropping the manager only +/// schedules the teardown for whenever its last reference goes, so two +/// managers would run against the same wallets; +/// - a replacement that fails to configure leaves the current manager cached, +/// active and running, so the app keeps working on the SDK it keeps, and a +/// later retry still replaces it. +/// +/// Real regtest SDKs and native managers: building either needs no network, +/// and nothing here starts a sync. +@MainActor +final class WalletManagerStoreActivateTests: XCTestCase { + + private final class EventLog: @unchecked Sendable { + private let lock = NSLock() + private var entries: [String] = [] + func append(_ event: String) { lock.withLock { entries.append(event) } } + var events: [String] { lock.withLock { entries } } + } + + func testShouldFinishShuttingDownTheReplacedManagerBeforeItsReplacementTakesOver() async throws { + let store = WalletManagerStore(modelContainer: try DashModelContainer.createInMemory()) + try await store.activate( + network: .regtest, sdk: try SDK(network: .regtest, contextProvider: .spv)) + let replaced = store.activeManager + XCTAssertTrue(replaced.isConfigured) + + // Record when the replaced manager's native destroy returns, and when + // the store swaps its active manager. + let log = EventLog() + let calls = replaced.nativeTeardownCalls + replaced.nativeTeardownCalls = PlatformWalletNativeTeardownCalls( + spvStop: calls.spvStop, + platformAddressSyncStop: calls.platformAddressSyncStop, + shieldedSyncStop: calls.shieldedSyncStop, + dashPaySyncStop: calls.dashPaySyncStop, + dpnsSyncStop: calls.dpnsSyncStop, + destroy: { handle in + let result = calls.destroy(handle) + log.append("destroy") + return result + }, + spvStart: calls.spvStart) + let swaps = store.$activeManager.dropFirst().sink { _ in log.append("swap") } + defer { swaps.cancel() } + + try await store.activate( + network: .regtest, sdk: try SDK(network: .regtest, contextProvider: .spv)) + + XCTAssertNotIdentical(store.activeManager, replaced) + XCTAssertIdentical(store.manager(for: .regtest), store.activeManager) + XCTAssertFalse(replaced.isConfigured) + XCTAssertEqual( + log.events, ["destroy", "swap"], + "the replaced manager must be torn down before its replacement becomes active" + ) + await store.activeManager.shutdown() + } + + func testShouldKeepTheCurrentManagerWhenItsReplacementFailsToConfigure() async throws { + let store = WalletManagerStore(modelContainer: try DashModelContainer.createInMemory()) + try await store.activate( + network: .regtest, sdk: try SDK(network: .regtest, contextProvider: .spv)) + let current = store.activeManager + + // Only one manager can hold an SDK's SPV quorum source slot, so a + // rebuilt SDK whose slot is already held makes the replacement's + // `configure` throw. + let rebuilt = try SDK(network: .regtest, contextProvider: .spv) + let slotHolder = try PlatformWalletManager(sdk: rebuilt) + do { + try await store.activate(network: .regtest, sdk: rebuilt) + XCTFail("a second manager on one SDK must fail to configure") + } catch { + XCTAssertTrue( + error.localizedDescription.contains("already serves this SDK's quorum keys"), + error.localizedDescription) + } + + XCTAssertIdentical(store.activeManager, current) + XCTAssertIdentical(store.manager(for: .regtest), current) + XCTAssertTrue( + current.isConfigured, + "a failed replacement must not shut down the manager the app keeps" + ) + + // Once the slot is free, the same rebuild replaces the kept manager. + await slotHolder.shutdown() + try await store.activate(network: .regtest, sdk: rebuilt) + XCTAssertNotIdentical(store.activeManager, current) + XCTAssertFalse(current.isConfigured) + await store.activeManager.shutdown() + } +}