diff --git a/Cargo.lock b/Cargo.lock index 7e3c00895df..2eef6e0cc2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1206,7 +1206,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2428,7 +2428,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2489,7 +2489,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -3499,7 +3499,6 @@ dependencies = [ "hyper", "hyper-util", "rustls", - "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", @@ -3803,7 +3802,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -4596,7 +4595,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5142,6 +5141,7 @@ dependencies = [ "async-trait", "bimap", "bs58", + "dash-context-provider", "dash-sdk", "dash-spv", "dashcore", @@ -5698,7 +5698,7 @@ dependencies = [ "once_cell", "socket2 0.5.10", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6043,7 +6043,6 @@ dependencies = [ "pin-project-lite", "quinn", "rustls", - "rustls-native-certs", "rustls-pki-types", "serde", "serde_json", @@ -6325,6 +6324,7 @@ dependencies = [ name = "rs-sdk-ffi" version = "4.0.0" dependencies = [ + "arc-swap", "async-trait", "bincode", "bs58", @@ -6343,7 +6343,6 @@ dependencies = [ "log", "once_cell", "platform-encryption", - "reqwest 0.12.28", "rs-sdk-trusted-context-provider", "serde", "serde_json", @@ -6489,7 +6488,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6502,7 +6501,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6561,7 +6560,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -7421,7 +7420,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -8870,7 +8869,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] 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/Cargo.toml b/packages/rs-platform-wallet-ffi/Cargo.toml index 370e71754ea..39d05619a41 100644 --- a/packages/rs-platform-wallet-ffi/Cargo.toml +++ b/packages/rs-platform-wallet-ffi/Cargo.toml @@ -10,7 +10,7 @@ description = "C FFI bindings for platform-wallet" crate-type = ["staticlib", "cdylib", "rlib"] [dependencies] -platform-wallet = { path = "../rs-platform-wallet" } +platform-wallet = { path = "../rs-platform-wallet", features = ["spv-context"] } dpp = { path = "../rs-dpp" } dash-sdk = { path = "../rs-sdk", features = ["wallet"] } # Needed for `SignerHandle` + `VTableSigner` so the `*_with_signer` 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 7e553a64bc0..d4074ee2435 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -59,12 +59,70 @@ 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 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. +#[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::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(PlatformWalletManagerHandle::new( + manager, + controller.as_ref().map(|_| provider), + controller, + )); + 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 @@ -354,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/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index 522b9c7a4e4..bceb8a853e2 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -20,6 +20,11 @@ dash-spv = { workspace = true } # Core dependencies dashcore = { workspace = true } +# SPV context provider dependency (optional). `dash-spv` and `tokio` are +# already non-optional dependencies above, so the `spv-context` feature only +# needs to pull in the context-provider trait crate. +dash-context-provider = { path = "../rs-context-provider", optional = true } + # Standard dependencies thiserror = "1.0" async-trait = "0.1" @@ -112,6 +117,11 @@ rs-sdk-trusted-context-provider = { path = "../rs-sdk-trusted-context-provider" [features] default = ["bls", "eddsa"] +# SPV-backed Platform context provider (delegates quorum lookups to the SPV +# runtime). `dash-spv`/`tokio` are already non-optional deps; this pulls in the +# context-provider trait crate plus `rt-multi-thread` (the sync->async bridge +# in `spv_context_provider` uses `tokio::task::block_in_place`). +spv-context = ["dep:dash-context-provider", "tokio/rt-multi-thread"] bls = ["key-wallet/bls", "key-wallet-manager/bls"] eddsa = ["key-wallet/eddsa", "key-wallet-manager/eddsa"] shielded = ["dep:grovedb-commitment-tree", "dep:rusqlite", "dep:zip32", "dep:futures", "dash-sdk/shielded", "dpp/shielded-client"] diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index f936b5aa68d..8c0265fd36d 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -24,6 +24,9 @@ pub(crate) mod test_support; mod util; pub mod wallet; +#[cfg(feature = "spv-context")] +pub mod spv_context_provider; + pub use error::PlatformWalletError; pub use events::{PlatformEventHandler, PlatformEventManager}; pub use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; diff --git a/packages/rs-platform-wallet/src/spv/runtime.rs b/packages/rs-platform-wallet/src/spv/runtime.rs index fd706bb46d1..a1e9d7df648 100644 --- a/packages/rs-platform-wallet/src/spv/runtime.rs +++ b/packages/rs-platform-wallet/src/spv/runtime.rs @@ -5,12 +5,13 @@ 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}; 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; @@ -24,6 +25,37 @@ 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) +} + +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 @@ -86,7 +118,6 @@ impl SpvRuntime { return Err(PlatformWalletError::SpvAlreadyRunning); } } - let network_manager = PeerNetworkManager::new(&config) .await .map_err(|e| PlatformWalletError::SpvError(e.to_string()))?; @@ -126,6 +157,19 @@ 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. + 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. /// /// Failures are classified per the [`TransactionBroadcaster::broadcast`] @@ -165,14 +209,17 @@ impl SpvRuntime { ))?; let llmq_type = LLMQType::from(quorum_type as u8); - let qh = QuorumHash::from_byte_array(quorum_hash).reverse(); + let qh = quorum_lookup_key(quorum_hash); let quorum = client .get_quorum_at_height(height, llmq_type, qh) .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`] @@ -389,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, 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; @@ -460,4 +513,76 @@ mod tests { ); } } + + /// 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 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). + #[test] + fn quorum_hash_reversed_to_internal_order_before_lookup() { + use dashcore::hashes::Hash; + use dashcore::QuorumHash; + use std::collections::BTreeMap; + + // 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(); + + let pubkey = [0xABu8; 48]; + let mut quorums: BTreeMap = BTreeMap::new(); + quorums.insert(QuorumHash::from_byte_array(internal_bytes), pubkey); + + // The production key builder must land on the internally-keyed quorum. + assert_eq!( + quorums.get(&quorum_lookup_key(display_bytes)), + Some(&pubkey), + "quorum_lookup_key must reverse display order to the engine's internal key" + ); + + // 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, + "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 new file mode 100644 index 00000000000..02b008a5a8f --- /dev/null +++ b/packages/rs-platform-wallet/src/spv_context_provider.rs @@ -0,0 +1,185 @@ +//! SPV-based Context Provider +//! +//! Thin quorum source that resolves Platform proof quorum public keys +//! from the SPV runtime owned by the [`PlatformWalletManager`]. +//! +//! # Architecture +//! +//! [`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. +//! +//! The [`ContextProvider`] trait method is synchronous, but the runtime lookup +//! is async. Proof verification runs inside the SDK's multi-threaded Tokio +//! 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 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; +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}; +use dpp::version::PlatformVersion; +use tokio::runtime::RuntimeFlavor; + +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 +/// the SPV client populates during sync is read live for each proof. +pub struct SpvQuorumSource { + spv: Arc, + network: Network, +} + +impl SpvQuorumSource { + /// Create a new SPV quorum source. + /// + /// # Arguments + /// + /// * `spv` - Shared reference to the SPV runtime, obtained from + /// [`PlatformWalletManager::spv_arc`](crate::manager::PlatformWalletManager::spv_arc). + /// * `network` - The Dash network (mainnet, testnet, devnet, etc.). + 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 SpvQuorumSource { + fn get_quorum_public_key( + &self, + quorum_type: u32, + 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 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 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())); + + // 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 { + // 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), + } + } + + fn get_data_contract( + &self, + _data_contract_id: &Identifier, + _platform_version: &PlatformVersion, + ) -> Result>, ContextProviderError> { + // 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) + } + + fn get_token_configuration( + &self, + _token_id: &Identifier, + ) -> Result, ContextProviderError> { + // 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/Cargo.toml b/packages/rs-sdk-ffi/Cargo.toml index 8030a2e53f9..18c38d687ef 100644 --- a/packages/rs-sdk-ffi/Cargo.toml +++ b/packages/rs-sdk-ffi/Cargo.toml @@ -72,9 +72,7 @@ zeroize = "1.8" # Concurrency once_cell = "1.20" - -# HTTP client for diagnostics -reqwest = { version = "0.12", features = ["json", "rustls-tls-native-roots"] } +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 66db6893d0f..0687f290239 100644 --- a/packages/rs-sdk-ffi/src/context_provider.rs +++ b/packages/rs-sdk-ffi/src/context_provider.rs @@ -3,11 +3,18 @@ //! This module provides FFI bindings for configuring context providers, //! allowing the Platform SDK to connect to Core SDK for proof verification. -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; +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}; +use crate::types::Network; /// Handle for Core SDK that can be passed to Platform SDK /// This matches the definition from dash_spv_ffi.h @@ -23,7 +30,9 @@ pub struct ContextProviderHandle { } /// Internal wrapper for context provider -pub(crate) struct ContextProviderWrapper { +/// Adapter wrapping any [`ContextProvider`] as an opaque +/// [`ContextProviderHandle`] for callback-based SDK configuration. +pub struct ContextProviderWrapper { provider: Arc, } @@ -39,6 +48,317 @@ 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(), + }); + } + + 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: 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, + ), + }, + } + } + + 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) + } +} + +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 @@ -79,3 +399,241 @@ 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, 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 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")); + } +} diff --git a/packages/rs-sdk-ffi/src/sdk.rs b/packages/rs-sdk-ffi/src/sdk.rs index 0f3f89b666b..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; @@ -10,7 +10,11 @@ use dash_sdk::{Sdk, SdkBuilder}; use std::ffi::CStr; use std::str::FromStr; -use crate::context_provider::{ContextProviderHandle, ContextProviderWrapper, CoreSDKHandle}; +use crate::context_provider::{ + AdaptiveContextProvider, AdaptiveContextProviderSource, AuxiliaryContextProvider, + ContextProviderHandle, ContextProviderMode, ContextProviderWrapper, CoreSDKHandle, + SpvContextProvider, SpvSourceController, +}; use crate::runtime::BigStackRuntime; use crate::types::{DashSDKConfig, FFINetwork, Network, SDKHandle}; use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, FFIError}; @@ -44,6 +48,8 @@ pub(crate) struct SDKWrapper { pub sdk: Sdk, pub runtime: Arc, pub trusted_provider: Option>, + pub adaptive_provider: Option>, + pub spv_source_controller: Option>, } impl SDKWrapper { @@ -52,6 +58,8 @@ impl SDKWrapper { sdk, runtime: Arc::new(BigStackRuntime::new(runtime)), trusted_provider: None, + adaptive_provider: None, + spv_source_controller: None, } } @@ -65,6 +73,8 @@ impl SDKWrapper { sdk, runtime: Arc::new(BigStackRuntime::new(runtime)), trusted_provider: Some(provider), + adaptive_provider: None, + spv_source_controller: None, } } @@ -79,6 +89,8 @@ impl SDKWrapper { sdk, runtime, trusted_provider: None, + adaptive_provider: None, + spv_source_controller: None, } } } @@ -173,6 +185,8 @@ pub unsafe extern "C" fn dash_sdk_create(config: *const DashSDKConfig) -> DashSD sdk, 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) @@ -285,6 +299,8 @@ pub unsafe extern "C" fn dash_sdk_create_extended( sdk, 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) @@ -293,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, @@ -325,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: @@ -354,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, @@ -363,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), @@ -371,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), @@ -396,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), @@ -409,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), @@ -440,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), @@ -469,13 +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 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), + } + } + }; - // 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)); + 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, @@ -487,36 +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: 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) @@ -554,6 +626,78 @@ 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. +/// +/// # Safety +/// `sdk_handle` must be a valid SDK handle for the duration of this call. +pub unsafe fn dash_sdk_spv_source_controller( + sdk_handle: *mut SDKHandle, +) -> 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()) +} + +/// Set the adaptive quorum routing mode without replacing the SDK provider. +/// +/// # Safety +/// `sdk_handle` must be a valid SDK handle for the duration of this call. +#[no_mangle] +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( + DashSDKErrorCode::InvalidParameter, + "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.adaptive_provider { + Some(provider) => { + provider.set_mode(mode); + DashSDKResult::success(std::ptr::null_mut()) + } + None => DashSDKResult::error(DashSDKError::new( + DashSDKErrorCode::InvalidParameter, + "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 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_controller.is_some() { + AdaptiveContextProviderSource::Spv as u8 + } else { + AdaptiveContextProviderSource::Trusted as u8 + } +} + /// 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/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 205d66c9478..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,12 +154,8 @@ pub struct Sdk { /// Nonce cache managed exclusively by the SDK. nonce_cache: Arc, - /// Context provider used by the SDK. - /// - /// ## Panics - /// - /// Note that setting this to None can panic. - context_provider: ArcSwapOption>, + /// 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, @@ -201,7 +196,7 @@ 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()), + 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, @@ -459,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. @@ -577,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() @@ -775,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. @@ -1000,8 +980,15 @@ 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 } @@ -1113,13 +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: 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 @@ -1133,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 }, @@ -1176,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)); @@ -1194,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: 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, @@ -1262,6 +1254,63 @@ mod test { /// Testnet Evo masternodes expose the Platform HTTP endpoint on 1443. const TESTNET_PLATFORM_HTTP_PORT: u16 = 1443; + /// SDK clones retain the exact context-provider identity selected at build. + #[test] + 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}; + 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() + .with_context_provider(SentinelProvider(SENTINEL)) + .build() + .expect("mock sdk should build"); + let clone = sdk.clone(); + + 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); + } + #[test] fn new_testnet_sources_bootstrap_from_seeds() { let builder = SdkBuilder::new_testnet(); 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 405ce5d8447..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 @@ -367,6 +389,37 @@ public final class SDK: @unchecked Sendable { self.network = network } + /// Which quorum-key source this SDK's proof verification currently uses. + public enum QuorumSource: Sendable { + case trusted + case 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 + } + + /// 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 set quorum mode: SDK handle is nil") + } + 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 set quorum mode: \(message)") + } + quorumSource = dash_sdk_get_context_provider_source(handle) == 1 ? .spv : .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 23eeaa39435..6303bf34869 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. + /// 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 + @Published var currentNetwork: Network { didSet { UserDefaults.standard.set(Int(currentNetwork.rawValue), forKey: "currentNetwork") @@ -42,6 +49,35 @@ class AppState: ObservableObject { } } + /// 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 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") + } + } + // Identity-key signing is performed per-flow via a fresh // `KeychainSigner` constructed from the active `ModelContainer` // (see `CreateIdentityView.submit()`). `AppState` no longer holds @@ -72,6 +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: Auto — trusted until SPV syncs, then SPV. + self.quorumMode = + UserDefaults.standard.string(forKey: "quorumMode").flatMap(QuorumMode.init(rawValue:)) + ?? .auto } func initializeSDK(modelContext: ModelContext) { @@ -90,8 +130,16 @@ class AppState: ObservableObject { SDK.enableLogging(level: .debug) NSLog("🔵 AppState: Creating SDK for network=\(currentNetwork), docker=\(useDockerSetup)") - let newSDK = try SDK(network: currentNetwork) + // Build with one adaptive provider backed initially by trusted + // 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 = newSDK.quorumSource NSLog("✅ AppState: SDK created successfully") // Eagerly learn the network's protocol version so @@ -99,7 +147,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 @@ -112,6 +160,31 @@ class AppState: ObservableObject { } } + /// 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. + /// - `.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 the provider's current routing choice, which may + /// lag the requested mode while SPV isn't ready. + @MainActor + func applyQuorumMode() { + guard let sdk else { return } + + do { + try sdk.setQuorumMode(sdkQuorumMode) + quorumSource = sdk.quorumSource + } catch { + NSLog("⚠️ AppState: failed to apply quorum mode: \(error.localizedDescription)") + } + } + func showError(message: String) { errorMessage = message showError = true @@ -132,9 +205,12 @@ class AppState: ObservableObject { do { isLoading = true - // Create new SDK instance for the network - 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 = newSDK.quorumSource // Eagerly learn the new network's protocol version (see // `initializeSDK`). Non-fatal: the SDK still ratchets from @@ -153,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/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index 99d8459348e..4fbed9e07d9 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() } + // Refresh adaptive-provider policy and the active-source + // indicator as SPV readiness changes. + .onChange(of: walletManager.spvProgress) { _, _ in + platformState.applyQuorumMode() + } + // Apply the Quorum Source picker to the fixed adaptive provider. + .onChange(of: platformState.quorumMode) { _, _ in + platformState.applyQuorumMode() + } } } @@ -186,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 " @@ -332,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 e562a3d90cd..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. @@ -347,6 +348,33 @@ struct OptionsView: View { } } + 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") + 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") Spacer() 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(