diff --git a/Cargo.lock b/Cargo.lock index 9aeb4627f89..17ca1a5813e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1743,12 +1743,14 @@ dependencies = [ name = "dash-context-provider" version = "5.0.1" dependencies = [ + "arc-swap", "dash-async", "dpp", "drive", "hex", "serde_json", "thiserror 1.0.69", + "tokio", ] [[package]] @@ -5432,6 +5434,7 @@ dependencies = [ "bimap", "bs58", "dash-async", + "dash-context-provider", "dash-sdk", "dash-spv", "dashcore", @@ -5472,6 +5475,7 @@ dependencies = [ "async-trait", "bs58", "cbindgen 0.27.0", + "dash-context-provider", "dash-network", "dash-sdk", "dashcore", @@ -6578,6 +6582,7 @@ dependencies = [ "bs58", "cbindgen 0.27.0", "dash-async", + "dash-context-provider", "dash-network", "dash-sdk", "dotenvy", 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-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]); + } +} diff --git a/packages/rs-platform-wallet-ffi/Cargo.toml b/packages/rs-platform-wallet-ffi/Cargo.toml index fd8a735f80a..3572ead4157 100644 --- a/packages/rs-platform-wallet-ffi/Cargo.toml +++ b/packages/rs-platform-wallet-ffi/Cargo.toml @@ -14,13 +14,14 @@ crate-type = ["staticlib", "cdylib", "rlib"] # `platform-wallet`'s default features: `persistence.rs` calls # `rebuild_provider_key_account` and matches on `ProviderKeyExtendedPubKey`, # both gated behind those features in `platform-wallet`. -platform-wallet = { path = "../rs-platform-wallet", features = ["bls", "eddsa"] } +platform-wallet = { path = "../rs-platform-wallet", features = ["bls", "eddsa", "spv-context"] } dpp = { path = "../rs-dpp" } dash-sdk = { path = "../rs-sdk", features = ["wallet"] } # Needed for `SignerHandle` + `VTableSigner` so the `*_with_signer` # 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/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 4d1adad6356..4bcd523ee3c 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; @@ -76,6 +77,7 @@ pub unsafe extern "C" fn platform_wallet_manager_create( ) -> PlatformWalletFFIResult { platform_wallet_manager_create_impl( sdk_ptr, + None, persistence, event_handler, PersistenceCapabilities::NONE, @@ -102,6 +104,7 @@ pub unsafe extern "C" fn platform_wallet_manager_create_with_persistence_capabil let declaration = persistence_capabilities_declaration(&*persistence_capabilities); platform_wallet_manager_create_impl( sdk_ptr, + None, persistence, event_handler, declaration, @@ -135,6 +138,7 @@ pub unsafe extern "C" fn platform_wallet_manager_create_with_persistence_extensi let extensions = persistence_extension_callbacks(persistence_extension); platform_wallet_manager_create_impl( sdk_ptr, + None, persistence, event_handler, declaration, @@ -166,6 +170,76 @@ pub unsafe extern "C" fn platform_wallet_manager_create_with_extensions( let dpns_event_callback = event_extension_dpns_callback(event_extension); platform_wallet_manager_create_impl( sdk_ptr, + None, + persistence, + event_handler, + declaration, + persistence_extensions, + dpns_event_callback, + out_handle, + ) +} + +/// 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, @@ -318,6 +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_quorum_lease: Option, persistence: *const PersistenceCallbacks, event_handler: *const EventHandlerCallbacks, declared_capabilities: PersistenceCapabilities, @@ -381,6 +456,9 @@ unsafe fn platform_wallet_manager_create_impl( let _runtime_guard = runtime().enter(); let manager = PlatformWalletManager::new(sdk, persister, handler); + if let Some(lease) = spv_quorum_lease { + manager.serve_spv_quorums(lease); + } let handle = PLATFORM_WALLET_MANAGER_STORAGE.insert(manager); *out_handle = handle; diff --git a/packages/rs-platform-wallet/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index 7da2bb5ce6c..ecab8b68590 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. Classic Dash signed messages use the optional base64 encoding. dashcore = { workspace = true, features = ["base64"] } +# 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" @@ -126,6 +131,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"] test-utils = ["key-wallet/test-utils"] bls = ["key-wallet/bls", "key-wallet-manager/bls"] eddsa = ["key-wallet/eddsa", "key-wallet-manager/eddsa"] diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index 55a9695f34c..e96e74a5c7a 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -26,6 +26,9 @@ pub 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/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 eac5c60bcff..15d0816e8e0 100644 --- a/packages/rs-platform-wallet/src/spv/runtime.rs +++ b/packages/rs-platform-wallet/src/spv/runtime.rs @@ -16,7 +16,7 @@ use dashcore::sml::masternode_list_entry::MasternodeListEntry; use dashcore::{PubkeyHash, QuorumHash, Transaction}; use dash_spv::network::PeerNetworkManager; -use dash_spv::sync::SyncProgress; +use dash_spv::sync::{SyncProgress, SyncState}; use dash_spv::{BroadcastResult, ClientConfig, DashSpvClient, EventHandler, Hash}; use key_wallet_manager::WalletManager; @@ -30,6 +30,25 @@ use crate::wallet::platform_wallet::PlatformWalletInfo; type SpvClient = DashSpvClient, PeerNetworkManager>; +/// 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_with_status`] 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) +} + /// A quorum public key from the SPV masternode state, see /// [`SpvRuntime::get_quorum_public_key_with_status`]. #[derive(Debug, Clone, PartialEq, Eq)] @@ -331,6 +350,25 @@ impl SpvRuntime { self.client.try_read().map(|c| c.is_some()).unwrap_or(false) } + /// 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() + .is_ok_and(|headers| headers.state() == SyncState::Synced) + && progress + .masternodes() + .is_ok_and(|masternodes| masternodes.state() == SyncState::Synced) + }) + } + /// Whether SPV sync is running or starting. `false` once background sync /// has failed, although the client stays started until [`Self::stop`]. /// Never waits, so an event handler may call it. @@ -438,9 +476,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`]. @@ -479,19 +519,31 @@ 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 = 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) @@ -1029,7 +1081,7 @@ mod tests { use key_wallet_manager::WalletManager; use tokio::sync::RwLock; - use super::{classify_spv_send_error, SpvRuntime}; + use super::{classify_spv_send_error, quorum_lookup_key, SpvRuntime}; use crate::broadcaster::BroadcastError; use crate::error::PlatformWalletError; use crate::events::{EventHandler, PlatformEventHandler, PlatformEventManager}; @@ -1503,6 +1555,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(); @@ -2371,4 +2461,53 @@ mod tests { ); } } + + /// Regression guard for the quorum-hash byte order used by + /// [`SpvRuntime::get_quorum_public_key_with_status`]. + /// + /// This exercises the production transform directly — [`quorum_lookup_key`] + /// is the exact function the lookup calls to build its engine 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. + #[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" + ); + } } 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..859524b1ec9 --- /dev/null +++ b/packages/rs-platform-wallet/src/spv_context_provider.rs @@ -0,0 +1,282 @@ +//! 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::platform_activation_height; +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, + { + // 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::QuorumSourceUnavailable( + "SPV context provider called outside a Tokio runtime".to_string(), + ) + })?; + if handle.runtime_flavor() == RuntimeFlavor::CurrentThread { + return Err(ContextProviderError::QuorumSourceUnavailable( + "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| { + // 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 + // 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 { + Ok(platform_activation_height(self.network)) + } + + 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) + } +} + +#[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!( + matches!(error, ContextProviderError::QuorumSourceUnavailable(ref message) + if message.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!( + matches!(error, ContextProviderError::QuorumSourceUnavailable(ref message) + if message.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!( + 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 + /// 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}" + ); + } +} diff --git a/packages/rs-sdk-ffi/Cargo.toml b/packages/rs-sdk-ffi/Cargo.toml index 33f50995ad7..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", 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 c38769720d9..87a3e472079 100644 --- a/packages/rs-sdk-ffi/src/evonode/queries/status.rs +++ b/packages/rs-sdk-ffi/src/evonode/queries/status.rs @@ -418,6 +418,9 @@ mod tests { sdk, runtime: Arc::new(crate::runtime::BigStackRuntime::build_shared().expect("runtime")), trusted_provider: None, + adaptive_provider: 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 b210367a198..e8a7b484bdf 100644 --- a/packages/rs-sdk-ffi/src/sdk.rs +++ b/packages/rs-sdk-ffi/src/sdk.rs @@ -11,6 +11,13 @@ use dash_sdk::{Sdk, SdkBuilder}; use std::ffi::CStr; use std::str::FromStr; +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}; @@ -55,7 +62,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_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 { @@ -64,6 +77,9 @@ impl SDKWrapper { sdk, runtime: Arc::new(BigStackRuntime::new(runtime)), trusted_provider: None, + adaptive_provider: None, + spv_source_slot: None, + local_contracts: None, } } @@ -71,12 +87,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_slot: None, } } @@ -91,6 +110,9 @@ impl SDKWrapper { sdk, runtime, trusted_provider: None, + adaptive_provider: None, + spv_source_slot: None, + local_contracts: None, } } } @@ -185,6 +207,9 @@ pub unsafe extern "C" fn dash_sdk_create(config: *const DashSDKConfig) -> DashSD sdk, runtime, trusted_provider: None, + adaptive_provider: 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) @@ -297,6 +322,9 @@ pub unsafe extern "C" fn dash_sdk_create_extended( sdk, runtime, trusted_provider: None, + adaptive_provider: 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) @@ -305,18 +333,57 @@ 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, + trusted: Option>, + adaptive: Option>, + spv_source_slot: Option>, + local_contracts: Arc, +} + +/// Create an SDK with a fixed trusted 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. +/// # 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. +/// +/// 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 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, @@ -337,45 +404,18 @@ pub unsafe extern "C" fn dash_sdk_create_trusted(config: *const DashSDKConfig) - } }; - info!( - ?network, - "dash_sdk_create_trusted: creating trusted 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), - }; + info!(?network, ?provider_kind, "creating SDK context provider"); - // 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), @@ -394,24 +434,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), @@ -423,13 +460,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); + let provider_parts = match fixed_provider_parts(config, network, provider_kind) { + Ok(parts) => parts, + Err(error) => return DashSDKResult::error(error), + }; - // 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, @@ -441,22 +481,29 @@ 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 { - // Quorum key fetches that come in while this runs share it. - match provider_for_prefetch.refresh_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"), - } - }); + // 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. + match provider_for_prefetch.refresh_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), + trusted_provider: provider_parts.trusted, + adaptive_provider: provider_parts.adaptive, + 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) @@ -465,6 +512,76 @@ pub unsafe extern "C" fn dash_sdk_create_trusted(config: *const DashSDKConfig) - } } +/// 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, @@ -482,10 +599,10 @@ 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 { +) -> Result { let cache_size = TRUSTED_PROVIDER_CACHE_SIZE; let provider = if let Some(quorum_url) = quorum_url { info!( @@ -570,6 +687,89 @@ 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 } +/// 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_slot( + 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_slot.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(), + )), + } +} + +/// 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. +#[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); + quorum_source(wrapper).unwrap_or(AdaptiveContextProviderSource::Trusted) as u8 +} + /// Register global context provider callbacks /// /// This must be called before creating an SDK instance that needs Core SDK functionality. @@ -681,13 +881,16 @@ 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. 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 +/// - `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 @@ -716,13 +919,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(), )); } }; @@ -779,10 +981,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 ); @@ -894,3 +1096,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/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/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. 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..469f3bb2fee --- /dev/null +++ b/packages/rs-sdk-trusted-context-provider/src/local.rs @@ -0,0 +1,480 @@ +//! 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, 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) { + for contract in contracts { + self.add_known_contract_if_newer(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 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}; + 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 mut older = app_contract(); + older.set_version(1); + + resolver.add_known_contract_if_newer(newer); + 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 9e68e6ebf8a..55d80355c2a 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(), }) @@ -294,14 +271,10 @@ 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 { - 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 +286,47 @@ 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 + /// 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) { - 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 +991,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 +1006,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 +1014,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 +1028,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 +2224,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 +2289,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 +2322,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. diff --git a/packages/rs-sdk/examples/read_contract.rs b/packages/rs-sdk/examples/read_contract.rs index d2cbae8dcd9..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}; @@ -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 6a8988f76c4..a7458ad312f 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}; use dapi_grpc::platform::VersionedGrpcResponse; @@ -302,12 +301,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, @@ -363,7 +358,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, @@ -820,11 +815,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. @@ -965,18 +957,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() @@ -1158,7 +1138,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. @@ -1432,8 +1412,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 } @@ -1577,13 +1564,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 @@ -1602,36 +1620,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 }, @@ -1645,7 +1637,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)); @@ -1667,7 +1659,7 @@ impl SdkBuilder { protocol_version_observer: self.protocol_version_observer.clone(), #[cfg(all(test, feature = "mocks"))] enforce_protocol_gate_on_mock: false, - 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( self.trusted_initial_height.unwrap_or(0), @@ -1756,6 +1748,63 @@ pub(crate) mod test { } } + /// 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/rs-sdk/src/sdk/protocol_gate_tests.rs b/packages/rs-sdk/src/sdk/protocol_gate_tests.rs index d1b4fba07f3..9b37f23b68e 100644 --- a/packages/rs-sdk/src/sdk/protocol_gate_tests.rs +++ b/packages/rs-sdk/src/sdk/protocol_gate_tests.rs @@ -501,25 +501,6 @@ async fn should_refuse_without_asking_when_pinned_below_the_version() { assert_eq!(sent(&sdk, &fee_pots()).await, 0); } -/// Without a context provider the answer to the probe cannot be verified, and an answer the -/// SDK cannot verify bans the node that sent it: refuse without asking. -#[tokio::test] -async fn should_refuse_without_asking_when_answers_cannot_be_verified() { - let mut sdk = mock_sdk(V13, false, 0); - answer_probe(&sdk, "pv14").await; - expect_fee_pots(&mut sdk).await; - sdk.context_provider.store(None); - assert_refused( - ContractFeePots::fetch(&sdk, fee_pots()).await, - "get_contract_fee_pots", - 0, - false, - "no context provider", - ); - assert_eq!(probes(&sdk).await, 0); - assert_eq!(sent(&sdk, &fee_pots()).await, 0); -} - /// An SDK without proofs still learns the network's version from a proved probe. #[tokio::test] async fn should_ask_with_a_proof_when_the_sdk_does_not_request_proofs() { diff --git a/packages/rs-sdk/src/sdk/quorum_key.rs b/packages/rs-sdk/src/sdk/quorum_key.rs index 6380c814d05..0715f722c2a 100644 --- a/packages/rs-sdk/src/sdk/quorum_key.rs +++ b/packages/rs-sdk/src/sdk/quorum_key.rs @@ -510,7 +510,9 @@ mod tests { metadata.height + 10, )); } - sdk.set_context_provider(provider.clone()); + // The provider reads this SDK's mock DAPI client, so it can only be + // installed after the SDK is built. + sdk.context_provider = provider.clone(); let settings = PutSettings { request_settings: RequestSettings { ban_failed_address: Some(true), diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 5c86e8bce11..336a99886dc 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -1167,6 +1167,7 @@ public class PlatformWalletManager: ObservableObject { // network, matching the per-network manager design. try configure( sdkPointer: UnsafeRawPointer(innerSdkPtr), + sdkHandle: sdkHandle, modelContainer: modelContainer, network: sdk.network ) @@ -1177,6 +1178,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 { try ensureConfigurationAllowed() SDKLogger.event( @@ -1221,15 +1236,27 @@ public class PlatformWalletManager: ObservableObject { var eventHandlerExtension = eventHandler.makeCallbacksExtension() do { - try platform_wallet_manager_create_with_extensions( - sdkPointer, - &persistence, - &eventHandlerCallbacks, - &declaredCapabilities, - &persistenceExtension, - &eventHandlerExtension, - &handle - ).check() + if let sdkHandle { + try platform_wallet_manager_create_with_sdk( + sdkHandle, + &persistence, + &eventHandlerCallbacks, + &declaredCapabilities, + &persistenceExtension, + &eventHandlerExtension, + &handle + ).check() + } else { + try platform_wallet_manager_create_with_extensions( + sdkPointer, + &persistence, + &eventHandlerCallbacks, + &declaredCapabilities, + &persistenceExtension, + &eventHandlerExtension, + &handle + ).check() + } } catch { // A failed create never took ownership of the retained callback // contexts (`makeCallbacks` pre-retains for the transfer), so diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift index ed62fecd35e..8b79aaae624 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift @@ -75,6 +75,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) { @@ -241,11 +251,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 @@ -256,7 +267,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 @@ -271,8 +286,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 @@ -328,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 { @@ -345,7 +360,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 @@ -368,6 +390,41 @@ 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 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 { + 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)") + } + } + /// Offline SDK over the FFI mock (`dash_sdk_create_handle_with_mock`), for /// tests. Its DAPI client never opens a connection: it answers only from /// the recorded `msg_*.json` responses in `vectorsDirectory` (proofs are @@ -431,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) + ) + } } } } @@ -469,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 @@ -595,7 +671,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/AppState.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift index 7c8b0e20257..21926142991 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift @@ -9,12 +9,17 @@ class AppState: ObservableObject { @Published var showError = false @Published var errorMessage = "" - @Published var currentNetwork: Network { + /// 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 + + /// Changes only with the SDK, through `publish(_:for:)`. + @Published private(set) var currentNetwork: Network { didSet { UserDefaults.standard.set(Int(currentNetwork.rawValue), forKey: "currentNetwork") - Task { - await switchNetwork(to: currentNetwork) - } } } @@ -28,15 +33,13 @@ 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 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 { @@ -46,12 +49,38 @@ 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. + } + } + + /// 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") } } @@ -85,6 +114,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) { @@ -103,8 +136,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 @@ -112,7 +153,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 @@ -125,44 +166,84 @@ 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 } - func switchNetwork(to network: Network) async { - guard let modelContext = modelContext else { return } - - // 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 new SDK instance for the network - let newSDK = try SDK(network: network) - sdk = newSDK - - // Eagerly learn the new network's protocol version (see - // `initializeSDK`). Non-fatal: the SDK still ratchets from - // metadata if this fails. - refreshProtocolVersion(for: newSDK) + let newSDK = try SDK(network: network, contextProvider: .adaptive) + try newSDK.setQuorumMode(sdkQuorumMode) + if let modelContext { + await loadKnownContractsIntoSDK(sdk: newSDK, modelContext: modelContext) + } + return newSDK + } catch { + 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 + } + } - // Load known contracts into the SDK's trusted provider - await loadKnownContractsIntoSDK(sdk: newSDK, modelContext: modelContext) + /// 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() + } - isLoading = false - } catch { - sdk = nil - showError(message: "Failed to switch network: \(error.localizedDescription)") - NSLog("❌ AppState.switchNetwork: \(error)") - isLoading = false + private var sdkQuorumMode: SDK.QuorumMode { + switch quorumMode { + case .auto: return .auto + case .spv: return .spv + case .trusted: return .trusted } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift index 994f45f7671..55535d7aff5 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) + } } } @@ -495,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 new file mode 100644 index 00000000000..a86eaabc4ac --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SdkRebuildSequence.swift @@ -0,0 +1,69 @@ +import SwiftDashSDK + +/// How an SDK rebuild started from Options ended. +enum SdkRebuildOutcome: Equatable { + /// SPV did not stop; nothing changed. + case stopFailed + /// The network changed while SPV stopped; nothing changed. + case networkChanged + /// 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, 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: +/// +/// 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. `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. 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. +/// 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 +func rebuildSdkAndMoveWalletManager( + from network: Network, + to target: Network, + stopSpv: @MainActor () async -> Bool, + currentNetwork: @MainActor () -> Network, + prepare: @MainActor () -> Void, + buildSdk: @MainActor (Network) async -> Sdk?, + activateManager: @MainActor (Network, Sdk) async -> 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 } + prepare() + guard let sdk = await buildSdk(target), await activateManager(target, sdk) else { + rollBack() + return .failed + } + publish(target, sdk) + rebindWalletScopedServices() + return .rebuilt +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index 62db9b2d36d..47d0f3b5de7 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -170,66 +170,25 @@ 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 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() } - } - } - - /// 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) - 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 - ) + // 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() + } } } @@ -453,10 +412,11 @@ 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 ) + platformState.applyQuorumMode() let restoredCount = walletManager.wallets.count if restoredCount > 0 { SDKLogger.log( @@ -478,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() } @@ -543,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 } @@ -559,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 258ddf6bf09..e3a044cf62b 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 @@ -147,52 +154,55 @@ struct OptionsView: View { // can't revive it. 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 // `{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 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. - // 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, 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. - if newNetwork != .regtest && appState.useDockerSetup { - appState.useDockerSetup = false - } - - await MainActor.run { - isSwitchingNetwork = false + let outcome = await rebuildSdk( + on: network, + switchingTo: newNetwork, + 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 } } } @@ -221,30 +231,37 @@ struct OptionsView: View { coordinator: walletManager.registrationCoordinator ) - 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. + if appState.currentNetwork == .regtest && !devnetSettingsNeeded { + 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", + prepare: { appState.useDockerSetup = useDocker }, + rollBack: { appState.useDockerSetup = !useDocker } + ) } } - .help("Connect to local dashmate Docker network.") + )) + // A network switch away from regtest keeps this + // 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.") if appState.useDockerSetup { TextField("Faucet RPC Password", text: $faucetPassword) @@ -284,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` @@ -295,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) @@ -344,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 @@ -361,40 +384,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 || outcome == .failed { 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 - ) - } - // 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 } } } @@ -433,6 +428,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() @@ -582,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) @@ -657,6 +679,57 @@ struct OptionsView: View { } } + /// Rebuild the SDK for `newNetwork` (by default `network`, the network the + /// 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, + 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 }, + prepare: prepare, + buildSdk: { await appState.buildSdk(for: $0) }, + activateManager: { (target: Network, sdk: SDK) async -> Bool in + do { + try await walletManagerStore.activate(network: target, sdk: sdk) + return true + } catch { + SDKLogger.event( + "manager_activation_failed", + category: .lifecycle, + severity: .error, + fields: ["network": .publicText(target.displayName)], + error: error + ) + appState.showError( + 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 + } + }, + 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 } + ) + } + /// 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 ec183dfa41e..b7d97608f26 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 @@ -152,7 +150,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 @@ -160,7 +158,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` @@ -181,27 +183,39 @@ 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)`). // 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] { - 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. @@ -221,37 +235,36 @@ 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 - managerSdkHandles[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) - // 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 - managerSdkHandles[network] = sdk.handle - if makeActive { + managerSdks[network] = sdk + 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 @@ -263,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 @@ -290,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) - try activate(network: network, sdk: sdk, makeActive: false) + let sdk = try SDK(network: network, contextProvider: .adaptive) + 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/SdkRebuildSequenceTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/SdkRebuildSequenceTests.swift new file mode 100644 index 00000000000..ce80d971836 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/SdkRebuildSequenceTests.swift @@ -0,0 +1,199 @@ +import XCTest +@testable import SwiftDashSDK +@testable import SwiftExampleApp + +/// Pins the order of `rebuildSdkAndMoveWalletManager`, the sequence every +/// 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. +@MainActor +final class SdkRebuildSequenceTests: XCTestCase { + + private final class FakeSdk {} + + private enum Step: Equatable { + case stopSpv + case prepare + case buildSdk(Network) + case activateManager(Network) + case rollBack + case publish(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, + buildSucceeds: Bool = true, + activationSucceeds: Bool = true + ) async -> SdkRebuildOutcome { + await rebuildSdkAndMoveWalletManager( + from: network, + to: target, + stopSpv: { + app.steps.append(.stopSpv) + if let networkAfterStop { + app.currentNetwork = networkAfterStop + } + return stopSucceeds + }, + currentNetwork: { app.currentNetwork }, + 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 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) + } + ) + } + + /// 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) + + XCTAssertEqual(outcome, .rebuilt) + XCTAssertEqual( + app.steps, + [ + .stopSpv, + .prepare, + .buildSdk(.regtest), + .activateManager(.regtest), + .publish(.regtest), + .rebindWalletScopedServices, + ] + ) + XCTAssertEqual(app.currentNetwork, .regtest) + XCTAssertTrue( + app.activatedSdk === app.publishedSdk, + "the manager must run on the SDK that is 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, .rebuilt) + XCTAssertEqual( + app.steps, + [ + .stopSpv, + .prepare, + .buildSdk(.regtest), + .activateManager(.regtest), + .publish(.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 testFailedStopChangesNothing() async { + let app = AppModel(currentNetwork: .testnet) + + let outcome = await run(app, from: .testnet, to: .regtest, stopSucceeds: false) + + 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 testNetworkChangedDuringTheStopChangesNothing() async { + let app = AppModel(currentNetwork: .testnet) + + let outcome = await run(app, from: .testnet, to: .testnet, networkAfterStop: .devnet) + + XCTAssertEqual(outcome, .networkChanged) + XCTAssertEqual(app.steps, [.stopSpv]) + } + + /// 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: .devnet, buildSucceeds: false) + + XCTAssertEqual(outcome, .failed) + XCTAssertEqual(app.steps, [.stopSpv, .prepare, .buildSdk(.devnet), .rollBack]) + XCTAssertEqual(app.currentNetwork, .testnet) + XCTAssertTrue(app.publishedSdk === previousSdk) + XCTAssertNil(app.activatedSdk) + } + + /// 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: .testnet, to: .devnet, activationSucceeds: false) + + XCTAssertEqual(outcome, .failed) + XCTAssertEqual( + app.steps, + [.stopSpv, .prepare, .buildSdk(.devnet), .activateManager(.devnet), .rollBack] + ) + XCTAssertEqual(app.currentNetwork, .testnet) + XCTAssertTrue(app.publishedSdk === previousSdk) + } +} 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() + } +} 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 + ) + } + } +} 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) + } +}