Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
f4703d6
feat(swift-sdk): use SPV-synced quorums for Platform proof verification
QuantumExplorer Mar 31, 2026
ad6904a
refactor(rs-sdk-ffi): move SPV context provider from Swift to Rust
QuantumExplorer Mar 31, 2026
d9e478e
feat(platform-wallet): add pure-Rust SpvContextProvider
QuantumExplorer Mar 31, 2026
8d2bb27
fix: address CodeRabbit review feedback on SPV quorums PR
QuantumExplorer Apr 2, 2026
fa7e531
feat: use pure-Rust SpvContextProvider, bump rust-dashcore
QuantumExplorer Apr 2, 2026
54b6ed0
fix: eliminate race between didSet and handleNetworkSwitch
QuantumExplorer Apr 2, 2026
cfde224
fix: use try_read() instead of blocking_read(), add dep: prefix
QuantumExplorer Apr 2, 2026
6f67870
Merge v4.1-dev into feat/ios-spv-quorums; re-integrate SPV context pr…
shumkov Jul 11, 2026
05fd41d
style(platform-wallet-ffi): apply rustfmt; drop internal re-integrati…
shumkov Jul 11, 2026
3fad6f8
feat(swift-sdk): verify proofs against SPV quorums via attach-after-b…
shumkov Jul 13, 2026
600b440
feat(sdk): share context provider across SDK clones; live Auto/SPV/Tr…
shumkov Jul 13, 2026
9a2289a
test(sdk,rs-sdk-ffi): cover context-provider clone-sharing + install/…
shumkov Jul 13, 2026
584e68c
fix(rs-platform-wallet): reverse quorum hash to internal order for SP…
shumkov Jul 14, 2026
1deb36b
fix(rs-platform-wallet): guard byte-order via production helper + pin…
shumkov Jul 14, 2026
8263be0
fix(rs-sdk-ffi): install SPV quorum provider as a composite over trusted
shumkov Jul 14, 2026
a53b983
fix(swift-sdk): force SPV install in SPV mode instead of gating on ru…
shumkov Jul 14, 2026
a0fa683
fix(rs-platform-wallet): avoid block_in_place panic on a current-thre…
shumkov Jul 15, 2026
6fc782f
fix(rs-platform-wallet): fail closed on current-thread runtime, not a…
shumkov Jul 15, 2026
3a5e96e
refactor(sdk): add adaptive SPV context provider
shumkov Jul 15, 2026
ba2fbef
docs: remove adaptive SPV provider spec
shumkov Jul 15, 2026
d4046ca
refactor(sdk): separate adaptive and SPV providers
shumkov Jul 16, 2026
4961a0e
chore(sdk): remove unused HTTP dependency
shumkov Jul 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 12 additions & 11 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/rs-platform-wallet-ffi/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ description = "C FFI bindings for platform-wallet"
crate-type = ["staticlib", "cdylib", "rlib"]

[dependencies]
platform-wallet = { path = "../rs-platform-wallet" }
platform-wallet = { path = "../rs-platform-wallet", features = ["spv-context"] }
dpp = { path = "../rs-dpp" }
dash-sdk = { path = "../rs-sdk", features = ["wallet"] }
# Needed for `SignerHandle` + `VTableSigner` so the `*_with_signer`
Expand Down
45 changes: 45 additions & 0 deletions packages/rs-platform-wallet-ffi/src/spv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -496,3 +496,48 @@ pub unsafe extern "C" fn platform_wallet_manager_spv_clear_storage(
unwrap_result_or_return!(result);
PlatformWalletFFIResult::ok()
}

/// Install this wallet manager's SPV-synced quorum data as the proof-verification
/// context provider on an already-built Platform SDK, replacing its trusted
/// provider. Subsequent proof-verified queries on that SDK handle resolve quorum
/// public keys live from the locally synced masternode list.
///
/// Call this only once the manager's SPV client is started and its masternode
/// list is synced (see `platform_wallet_manager_sync_progress`); before that,
/// lookups fail closed (no per-lookup fallback once installed).
///
/// The manager must have been created (`configure`d) against this same SDK
/// before attaching — that ordering is what keeps the manager's own cloned SDK
/// on its trusted provider (the SDK clone snapshots the provider slot).
///
/// # Safety
/// - `manager_handle` must be a live `PlatformWalletManager` handle.
/// - `sdk_handle` must be a valid `SDKHandle` for the duration of the call.
#[no_mangle]
pub unsafe extern "C" fn platform_wallet_manager_attach_spv_context(
manager_handle: Handle,
sdk_handle: *mut rs_sdk_ffi::SDKHandle,
network: crate::types::FFINetwork,
) -> rs_sdk_ffi::DashSDKResult {
// A shared handle to the manager's SPV runtime — the same runtime the SPV
// client writes to during sync. Cloned out of the storage closure so it
// outlives the borrow and backs the provider for as long as it's installed.
let spv = match PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| m.spv_arc()) {
Some(spv) => spv,
None => {
return rs_sdk_ffi::DashSDKResult::error(rs_sdk_ffi::DashSDKError::new(
rs_sdk_ffi::DashSDKErrorCode::InvalidParameter,
"Invalid wallet manager handle".to_string(),
));
}
};

let network: crate::types::Network = network.into();
let provider = platform_wallet::spv_context_provider::SpvContextProvider::new(spv, network);
let handle = Box::into_raw(Box::new(rs_sdk_ffi::ContextProviderWrapper::new(provider)))
as *mut rs_sdk_ffi::ContextProviderHandle;

// `dash_sdk_install_context_provider` TAKES ownership of the wrapper box and
// reclaims it exactly once — this function must not reclaim it.
rs_sdk_ffi::dash_sdk_install_context_provider(sdk_handle, handle)
Comment thread
shumkov marked this conversation as resolved.
Outdated
Comment thread
shumkov marked this conversation as resolved.
Outdated
Comment thread
shumkov marked this conversation as resolved.
Outdated
}
10 changes: 10 additions & 0 deletions packages/rs-platform-wallet/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ dash-spv = { workspace = true }
# Core dependencies
dashcore = { workspace = true }

# SPV context provider dependency (optional). `dash-spv` and `tokio` are
# already non-optional dependencies above, so the `spv-context` feature only
# needs to pull in the context-provider trait crate.
dash-context-provider = { path = "../rs-context-provider", optional = true }

# Standard dependencies
thiserror = "1.0"
async-trait = "0.1"
Expand Down Expand Up @@ -112,6 +117,11 @@ rs-sdk-trusted-context-provider = { path = "../rs-sdk-trusted-context-provider"

[features]
default = ["bls", "eddsa"]
# SPV-backed Platform context provider (delegates quorum lookups to the SPV
# runtime). `dash-spv`/`tokio` are already non-optional deps; this pulls in the
# context-provider trait crate plus `rt-multi-thread` (the sync->async bridge
# in `spv_context_provider` uses `tokio::task::block_in_place`).
spv-context = ["dep:dash-context-provider", "tokio/rt-multi-thread"]
bls = ["key-wallet/bls", "key-wallet-manager/bls"]
eddsa = ["key-wallet/eddsa", "key-wallet-manager/eddsa"]
shielded = ["dep:grovedb-commitment-tree", "dep:rusqlite", "dep:zip32", "dep:futures", "dash-sdk/shielded", "dpp/shielded-client"]
Expand Down
3 changes: 3 additions & 0 deletions packages/rs-platform-wallet/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ pub(crate) mod test_support;
mod util;
pub mod wallet;

#[cfg(feature = "spv-context")]
pub mod spv_context_provider;

pub use error::PlatformWalletError;
pub use events::{PlatformEventHandler, PlatformEventManager};
pub use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType;
Expand Down
51 changes: 50 additions & 1 deletion packages/rs-platform-wallet/src/spv/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,12 @@ impl SpvRuntime {
))?;

let llmq_type = LLMQType::from(quorum_type as u8);
let qh = QuorumHash::from_byte_array(quorum_hash).reverse();
// `quorum_hash` arrives in internal byte order (drive-abci sends it via
// `to_byte_array()`, verified by the consensus equality check in
// finalize_block_proposal), and the masternode engine keys its quorum
// map in that same internal order. So the lookup key must NOT be
// reversed — reversing guarantees a miss on every real quorum.
let qh = QuorumHash::from_byte_array(quorum_hash);

let quorum = client
.get_quorum_at_height(height, llmq_type, qh)
Expand Down Expand Up @@ -460,4 +465,48 @@ mod tests {
);
}
}

/// Regression guard for the quorum-hash byte order in
/// [`SpvRuntime::get_quorum_public_key`].
///
/// A Platform proof carries `quorum_hash` in internal byte order
/// (drive-abci emits it via `to_byte_array()`), and the masternode engine
/// keys its quorum map — `quorum_entry_of_type_for_quorum_hash`, i.e. a
/// `BTreeMap<QuorumHash, _>::get` — in that same internal order. So the
/// lookup key must be `QuorumHash::from_byte_array(quorum_hash)` with **no**
/// reversal, which is what `get_quorum_public_key` now uses.
///
/// A previous version reversed it (`.reverse()`); this test pins why that
/// was wrong: the reversed key is absent from the map, so every real lookup
/// missed and fell through to fail-closed rejection (silently masked by the
/// trusted-quorum fallback). Re-introducing the reversal fails this test.
#[test]
fn quorum_hash_lookup_uses_internal_order_not_reversed() {
use dashcore::hashes::Hash;
use dashcore::QuorumHash;
use std::collections::BTreeMap;

// 32 distinct bytes so internal order differs from reversed order.
let wire_bytes: [u8; 32] = std::array::from_fn(|i| (i as u8) + 1);

// Model the engine's quorum map, keyed exactly as the engine keys it:
// by the `QuorumHash` in internal byte order.
let pubkey = [0xABu8; 48];
let mut quorums: BTreeMap<QuorumHash, [u8; 48]> = BTreeMap::new();
quorums.insert(QuorumHash::from_byte_array(wire_bytes), pubkey);

// What `get_quorum_public_key` does now (no reverse): the key matches.
assert_eq!(
quorums.get(&QuorumHash::from_byte_array(wire_bytes)),
Some(&pubkey),
"internal-order hash must find the quorum"
);

// The old buggy `.reverse()`: flipped key is absent → miss (the bug).
assert_eq!(
quorums.get(&QuorumHash::from_byte_array(wire_bytes).reverse()),
None,
"reversed hash must NOT find the quorum — this was the bug"
);
}
Comment thread
shumkov marked this conversation as resolved.
Outdated
Comment thread
shumkov marked this conversation as resolved.
Outdated
}
116 changes: 116 additions & 0 deletions packages/rs-platform-wallet/src/spv_context_provider.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
//! SPV-based Context Provider
//!
//! Thin [`ContextProvider`] that resolves Platform proof quorum public keys
//! from the SPV runtime owned by the [`PlatformWalletManager`].
//!
//! # Architecture
//!
//! [`SpvContextProvider`] holds a shared [`Arc<SpvRuntime>`] — 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 provider is constructed at the FFI SDK-create call,
//! which runs off any runtime, so the handle is resolved at call time (and a
//! call from outside a runtime returns an error rather than panicking).
//!
//! [`PlatformWalletManager`]: crate::manager::PlatformWalletManager
//! [`SpvRuntime::get_quorum_public_key`]: crate::spv::SpvRuntime::get_quorum_public_key

use std::sync::Arc;

use dash_context_provider::ContextProvider;
use dash_context_provider::ContextProviderError;
use dashcore::Network;
use dpp::data_contract::TokenConfiguration;
use dpp::prelude::{CoreBlockHeight, DataContract, Identifier};
use dpp::version::PlatformVersion;

use crate::spv::SpvRuntime;

/// 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 SpvContextProvider {
spv: Arc<SpvRuntime>,
network: Network,
}

impl SpvContextProvider {
/// Create a new SPV context provider.
///
/// # 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<SpvRuntime>, network: Network) -> Self {
Self { spv, network }
}
}

impl ContextProvider for SpvContextProvider {
fn get_quorum_public_key(
&self,
quorum_type: u32,
quorum_hash: [u8; 32],
core_chain_locked_height: u32,
) -> Result<[u8; 48], ContextProviderError> {
// Bridge the sync trait method to the async runtime lookup. Proof
// verification always runs inside the SDK's multi-threaded runtime, so
// the ambient handle is present; a call from outside a runtime returns
// an error rather than panicking. The lookup is pure in-memory (two
// brief RwLock reads, no network I/O); on write contention with SPV
// sync it waits (fail-slow) rather than erroring.
let handle = tokio::runtime::Handle::try_current().map_err(|_| {
ContextProviderError::Generic(
"SPV quorum lookup called outside a Tokio runtime".to_string(),
)
})?;
tokio::task::block_in_place(|| {
handle.block_on(self.spv.get_quorum_public_key(
quorum_type,
quorum_hash,
core_chain_locked_height,
))
})
.map_err(|e| ContextProviderError::InvalidQuorum(e.to_string()))
Comment thread
shumkov marked this conversation as resolved.
Outdated
Comment thread
shumkov marked this conversation as resolved.
Outdated
Comment thread
shumkov marked this conversation as resolved.
Outdated
}
Comment thread
shumkov marked this conversation as resolved.
Outdated

fn get_platform_activation_height(&self) -> Result<CoreBlockHeight, ContextProviderError> {
// Match the values the trusted HTTP provider ships (the L1 locked
// height per network) so proof verification behaves identically
// whether quorum keys come from SPV or the trusted service. See
// `rs-sdk-trusted-context-provider`'s `get_platform_activation_height`.
match self.network {
Network::Mainnet => Ok(2_132_092),
Network::Testnet => Ok(1_090_319),
Network::Devnet | Network::Regtest => Ok(1),
}
}

fn get_data_contract(
&self,
_data_contract_id: &Identifier,
_platform_version: &PlatformVersion,
) -> Result<Option<Arc<DataContract>>, ContextProviderError> {
// Data contract lookup is handled by the SDK's contract cache,
// not the SPV layer.
Ok(None)
}

fn get_token_configuration(
&self,
_token_id: &Identifier,
) -> Result<Option<TokenConfiguration>, ContextProviderError> {
// Token configuration lookup is handled by the SDK's contract cache,
// not the SPV layer.
Ok(None)
}
Comment thread
shumkov marked this conversation as resolved.
Comment thread
shumkov marked this conversation as resolved.
Outdated
}
Comment thread
shumkov marked this conversation as resolved.
Outdated
5 changes: 4 additions & 1 deletion packages/rs-sdk-ffi/src/context_provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ pub struct ContextProviderHandle {
}

/// Internal wrapper for context provider
pub(crate) struct ContextProviderWrapper {
/// Adapter wrapping any [`ContextProvider`] as an opaque
/// [`ContextProviderHandle`] for the SDK. Public so sibling FFI crates
/// (e.g. `platform-wallet-ffi`) can install a native Rust provider.
pub struct ContextProviderWrapper {
provider: Arc<dyn ContextProvider>,
}

Expand Down
Loading
Loading