Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
d4648cb
feat(sdk): let a context provider fetch a missing quorum key asynchro…
shumkov Oct 7, 2026
131aa1f
feat(sdk): fetch quorum keys newer than the trusted provider's cache
shumkov Oct 7, 2026
0d1b4dc
fix(sdk): verify the same response again after fetching a missing quo…
shumkov Oct 7, 2026
b05457e
fix(sdk-ffi): fetch missing quorum keys asynchronously on iOS and And…
shumkov Oct 7, 2026
232b76a
fix(wasm-sdk): fetch quorum keys newer than the trusted context's pre…
shumkov Oct 7, 2026
19dab24
fix(sdk): wait for a newer quorum refresh instead of reporting the so…
shumkov Oct 7, 2026
02fa859
fix(sdk): don't ban a node when the provider loses a key it fetched
shumkov Oct 7, 2026
044d7a6
refactor(sdk-ffi): make the trusted provider's cache size a constant
shumkov Oct 7, 2026
e1747e7
test(wasm-sdk): fetch a quorum key through the browser's fetch and timer
shumkov Oct 7, 2026
c7d547f
fix(sdk): judge a response that waited for a quorum key as fresh as w…
shumkov Oct 7, 2026
ef33ca0
fix(sdk): classify malformed trusted BLS keys as source failures
shumkov Oct 8, 2026
34c323d
fix(sdk): refresh unusable cached quorum keys
shumkov Oct 8, 2026
175fe8a
fix(sdk): preserve synchronous quorum source failure attribution
shumkov Oct 8, 2026
aacaaa8
test(wasm-sdk): measure quorum refresh spacing from its start
shumkov Oct 8, 2026
14924a5
test(sdk): cover missing quorum keys in broadcast waits
shumkov Oct 8, 2026
ead9374
fix(sdk): display quorum provider fetch failures consistently
shumkov Oct 8, 2026
37bc265
test(wasm-sdk): use a valid BLS key in proof preparation
shumkov Oct 8, 2026
5897319
test(sdk): keep quorum regression fixtures clippy clean
shumkov Oct 8, 2026
86c67e7
fix(sdk): reject identity-point trusted quorum keys
shumkov Oct 8, 2026
81354e2
fix(sdk): prevent stale cache entries from shadowing repaired keys
shumkov Oct 8, 2026
8e5d02a
fix(sdk): attribute unusable fetched keys to the provider
shumkov Oct 8, 2026
101a95c
test(sdk): prove broadcast wait ban attribution and recovery
shumkov Oct 8, 2026
2955d5f
fix(sdk): retain quorum context in trusted source diagnostics
shumkov Oct 8, 2026
39a82ca
fix(sdk): preserve usable quorum keys across overlapping repairs
shumkov Oct 9, 2026
8d7b804
fix(ffi): preserve wrapped quorum source error classification
shumkov Oct 9, 2026
22103f4
fix(sdk): use usable quorum keys from either fetched list
shumkov Oct 9, 2026
2e09426
fix(sdk): judge delayed proof freshness against arrival time
shumkov Oct 9, 2026
4af8d85
fix(sdk): preserve arrival freshness through synchronous quorum refetch
shumkov Oct 9, 2026
d29b52f
fix(ffi): retain quorum source classification through retry exhaustion
shumkov Oct 9, 2026
b5c3f05
test(sdk): bound quorum cache race coordination and document guarantees
shumkov Oct 9, 2026
e5bb4d1
Merge remote-tracking branch 'origin/v5.0-dev' into fix/sdk-fetch-mis…
shumkov Oct 9, 2026
29f4b76
fix(wasm-sdk): retain quorum source retryability through exhaustion
shumkov Oct 9, 2026
606f142
fix(sdk): reject quorum absence from uninterpretable source hashes
shumkov Oct 9, 2026
448a196
test(sdk): pin quorum source classification and clarify retry contracts
shumkov Oct 9, 2026
a7a6716
fix(sdk): bound malformed quorum hash diagnostics
shumkov Oct 9, 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
4 changes: 4 additions & 0 deletions Cargo.lock

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

7 changes: 7 additions & 0 deletions packages/rs-context-provider/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ pub enum ContextProviderError {
#[error("invalid quorum: {0}")]
InvalidQuorum(String),

/// The provider's trusted quorum source gave no answer about a quorum: it
/// was unreachable, answered only in part, or was asked too recently to be
/// asked again, or returned an unusable key. Says nothing about whether
/// the quorum exists.
#[error("quorum source unavailable: {0}")]
QuorumSourceUnavailable(String),

/// Core Fork Error
#[error("activation fork error: {0}")]
ActivationForkError(String),
Expand Down
2 changes: 1 addition & 1 deletion packages/rs-context-provider/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ pub mod error;
pub mod provider;

pub use error::ContextProviderError;
pub use provider::{ContextProvider, DataContractProvider};
pub use provider::{ContextProvider, DataContractProvider, QuorumKeyFuture};

#[cfg(feature = "mocks")]
pub use provider::MockContextProvider;
190 changes: 189 additions & 1 deletion packages/rs-context-provider/src/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,22 @@ use dpp::data_contract::TokenConfiguration;
use dpp::prelude::{CoreBlockHeight, DataContract, Identifier};
use dpp::version::PlatformVersion;
use drive::{error::proof::ProofError, query::ContractLookupFn};
use std::{ops::Deref, sync::Arc};
use std::{future::Future, ops::Deref, pin::Pin, sync::Arc};

#[cfg(feature = "mocks")]
use {
dpp::data_contract::serialized_version::DataContractInSerializationFormat, hex::ToHex,
std::io::ErrorKind,
};

/// Future returned by [ContextProvider::fetch_quorum_public_key].
///
/// `Send` on every target because the SDK's request futures are `Send`
/// everywhere, and `'static` so that wrappers such as `Mutex<T>` can forward
/// the call without holding a guard across an await.
pub type QuorumKeyFuture =
Pin<Box<dyn Future<Output = Result<Option<[u8; 48]>, ContextProviderError>> + Send + 'static>>;

/// Interface between the Sdk and state of the application.
///
/// ContextProvider is called by the [FromProof](crate::FromProof) trait (and similar) to get information about
Expand Down Expand Up @@ -91,6 +99,39 @@ pub trait ContextProvider: Send + Sync {
core_chain_locked_height: u32,
) -> Result<[u8; 48], ContextProviderError>; // public key is 48 bytes

/// Fetches the public key of a quorum this provider does not hold yet.
///
/// The SDK calls this from async code when proof verification failed
/// because [get_quorum_public_key](Self::get_quorum_public_key) had no key
/// for the quorum, and verifies the same response again when a key comes
/// back. This lets a provider that caches quorum keys, and cannot block
/// inside the synchronous lookup, fetch a key that is newer than its cache.
///
/// The arguments come from a response that has not been verified yet. The
/// key must come from the provider's own trusted source, never from the
/// arguments, and the work done must stay bounded whatever they are.
///
/// # Returns
///
/// * `None`: an asynchronous fetch would not help, for example because the
/// provider cannot fetch keys or its synchronous lookup already fetched.
/// This is the default.
/// * `Some(future)`, resolving to:
/// * `Ok(Some(key))`: the key, which `get_quorum_public_key` now returns
/// as well.
/// * `Ok(None)`: the trusted source, asked after this call was made,
/// answered and does not know this quorum.
/// * `Err(_)`: the provider could not find out, for example because its
/// trusted source was unreachable.
fn fetch_quorum_public_key(
&self,
_quorum_type: u32,
_quorum_hash: [u8; 32],
_core_chain_locked_height: u32,
) -> Option<QuorumKeyFuture> {
None
}

/// Gets the platform activation height from core. Once this has happened this can be hardcoded.
///
/// # Returns
Expand Down Expand Up @@ -130,6 +171,16 @@ impl<C: AsRef<dyn ContextProvider> + Send + Sync> ContextProvider for C {
.get_quorum_public_key(quorum_type, quorum_hash, core_chain_locked_height)
}

fn fetch_quorum_public_key(
&self,
quorum_type: u32,
quorum_hash: [u8; 32],
core_chain_locked_height: u32,
) -> Option<QuorumKeyFuture> {
self.as_ref()
.fetch_quorum_public_key(quorum_type, quorum_hash, core_chain_locked_height)
}

fn get_platform_activation_height(&self) -> Result<CoreBlockHeight, ContextProviderError> {
self.as_ref().get_platform_activation_height()
}
Expand Down Expand Up @@ -171,6 +222,17 @@ where
lock.get_quorum_public_key(quorum_type, quorum_hash, core_chain_locked_height)
}

fn fetch_quorum_public_key(
&self,
quorum_type: u32,
quorum_hash: [u8; 32],
core_chain_locked_height: u32,
) -> Option<QuorumKeyFuture> {
// The future is 'static, so the guard is released before it is awaited.
let lock = self.lock().expect("lock poisoned");
lock.fetch_quorum_public_key(quorum_type, quorum_hash, core_chain_locked_height)
}

fn get_platform_activation_height(&self) -> Result<CoreBlockHeight, ContextProviderError> {
let lock = self.lock().expect("lock poisoned");
lock.get_platform_activation_height()
Expand Down Expand Up @@ -353,3 +415,129 @@ impl<'a, T: ContextProvider + 'a> AsRef<dyn ContextProvider + 'a> for Arc<T> {
self.deref()
}
}

#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;

/// Provider whose `fetch_quorum_public_key` counts its calls and records
/// the quorum it was asked for.
#[derive(Default)]
struct FetchingProvider {
calls: AtomicUsize,
asked: Mutex<Option<(u32, [u8; 32], u32)>>,
}

impl ContextProvider for FetchingProvider {
fn get_data_contract(
&self,
_id: &Identifier,
_platform_version: &PlatformVersion,
) -> Result<Option<Arc<DataContract>>, ContextProviderError> {
Ok(None)
}

fn get_token_configuration(
&self,
_token_id: &Identifier,
) -> Result<Option<TokenConfiguration>, ContextProviderError> {
Ok(None)
}

fn get_quorum_public_key(
&self,
_quorum_type: u32,
_quorum_hash: [u8; 32],
_core_chain_locked_height: u32,
) -> Result<[u8; 48], ContextProviderError> {
Err(ContextProviderError::InvalidQuorum(
"not cached".to_string(),
))
}

fn fetch_quorum_public_key(
&self,
quorum_type: u32,
quorum_hash: [u8; 32],
core_chain_locked_height: u32,
) -> Option<QuorumKeyFuture> {
self.calls.fetch_add(1, Ordering::SeqCst);
*self.asked.lock().unwrap() =
Some((quorum_type, quorum_hash, core_chain_locked_height));
Some(Box::pin(async { Ok(Some([7u8; 48])) }))
}

fn get_platform_activation_height(&self) -> Result<CoreBlockHeight, ContextProviderError> {
Ok(1)
}
}

/// The SDK reaches its provider through `Arc`, `Box<dyn ContextProvider>`
/// and `Mutex` wrappers. A wrapper that fell back to the default `None`
/// would silently turn off fetching of keys newer than the provider's
/// cache, so every wrapper must reach the inner provider with the same
/// arguments.
#[test]
fn should_forward_quorum_key_fetches_through_every_wrapper() {
fn assert_forwards(provider: &dyn ContextProvider, inner: &FetchingProvider, call: usize) {
assert!(provider
.fetch_quorum_public_key(6, [0xab; 32], 42)
.is_some());
assert_eq!(inner.calls.load(Ordering::SeqCst), call);
assert_eq!(*inner.asked.lock().unwrap(), Some((6, [0xab; 32], 42)));
}

let inner = Arc::new(FetchingProvider::default());

let arc: Arc<FetchingProvider> = Arc::clone(&inner);
assert_forwards(&arc, &inner, 1);

let boxed: Arc<Box<dyn ContextProvider>> =
Arc::new(Box::new(Arc::clone(&inner)) as Box<dyn ContextProvider>);
assert_forwards(&boxed, &inner, 2);

let mutex = std::sync::Mutex::new(Arc::clone(&inner));
assert_forwards(&mutex, &inner, 3);
}

/// Providers that do not override the method keep their behaviour: the
/// SDK sees `None` and reports the original verification error.
#[test]
fn should_not_fetch_quorum_keys_by_default() {
struct CacheOnly;
impl ContextProvider for CacheOnly {
fn get_data_contract(
&self,
_id: &Identifier,
_platform_version: &PlatformVersion,
) -> Result<Option<Arc<DataContract>>, ContextProviderError> {
Ok(None)
}
fn get_token_configuration(
&self,
_token_id: &Identifier,
) -> Result<Option<TokenConfiguration>, ContextProviderError> {
Ok(None)
}
fn get_quorum_public_key(
&self,
_quorum_type: u32,
_quorum_hash: [u8; 32],
_core_chain_locked_height: u32,
) -> Result<[u8; 48], ContextProviderError> {
Err(ContextProviderError::InvalidQuorum(
"not cached".to_string(),
))
}
fn get_platform_activation_height(
&self,
) -> Result<CoreBlockHeight, ContextProviderError> {
Ok(1)
}
}

assert!(CacheOnly.fetch_quorum_public_key(6, [0; 32], 1).is_none());
}
}
20 changes: 20 additions & 0 deletions packages/rs-drive-proof-verifier/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,26 @@ pub enum Error {
/// Context provider error
#[error("context provider error: {0}")]
ContextProviderError(#[from] dash_context_provider::ContextProviderError),

/// The context provider could not supply the public key of the quorum
/// that signed the proof. The quorum fields come from the response, which
/// is not verified yet; they say which key to look for, not that the
/// quorum exists.
#[error(
"quorum key unavailable for quorum {quorum_type}:{}: {error}",
hex::encode(.quorum_hash)
)]
QuorumKeyUnavailable {
/// Quorum type named by the proof
quorum_type: u32,
/// Quorum hash named by the proof
quorum_hash: [u8; 32],
/// Core chain locked height from the response metadata
core_chain_locked_height: u32,
/// Why the provider could not supply the key
#[source]
error: dash_context_provider::ContextProviderError,
},
}

impl From<drive::error::Error> for Error {
Expand Down
4 changes: 3 additions & 1 deletion packages/rs-drive-proof-verifier/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@ pub use proof::{FromProof, Length};
// Re-export context provider types from dash-context-provider
#[cfg(feature = "mocks")]
pub use dash_context_provider::MockContextProvider;
pub use dash_context_provider::{ContextProvider, ContextProviderError, DataContractProvider};
pub use dash_context_provider::{
ContextProvider, ContextProviderError, DataContractProvider, QuorumKeyFuture,
};

/// From Request
pub mod from_request;
Expand Down
38 changes: 31 additions & 7 deletions packages/rs-drive-proof-verifier/src/verify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,14 @@ pub(crate) fn verify_tenderdash_signature(
// Now, lookup quorum details
let chain_id = mtd.chain_id.clone();
let quorum_type = proof.quorum_type;
let pubkey_bytes =
provider.get_quorum_public_key(quorum_type, quorum_hash, core_locked_height)?;
let pubkey_bytes = provider
.get_quorum_public_key(quorum_type, quorum_hash, core_locked_height)
.map_err(|error| Error::QuorumKeyUnavailable {
quorum_type,
quorum_hash,
core_chain_locked_height: core_locked_height,
error,
})?;

let state_id = StateId {
app_version: version,
Expand Down Expand Up @@ -534,19 +540,24 @@ mod tests {
}

/// When the provider fails to return a quorum public key, the error must
/// propagate (wrapped into Error::ContextProviderError) rather than panic.
/// name the quorum the proof asked for, so the SDK can fetch that key and
/// verify the same response again, and keep the provider's message for
/// callers that classify errors by their text.
#[test]
fn test_verify_tenderdash_proof_provider_error_propagates() {
fn should_report_the_proofs_quorum_when_the_provider_has_no_key() {
let proof = Proof {
grovedb_proof: grovedb_proof_bytes(1),
quorum_hash: vec![0u8; 32],
quorum_hash: vec![0xab; 32],
signature: vec![0u8; 96],
round: 1,
block_id_hash: vec![0u8; 32],
quorum_type: 1,
quorum_type: 6,
};

let metadata = test_metadata();
let metadata = ResponseMetadata {
core_chain_locked_height: 4321,
..test_metadata()
};
let provider = ErroringProvider;

let result = verify_tenderdash_proof(&proof, &metadata, &[0u8; 32], &provider);
Expand All @@ -556,6 +567,19 @@ mod tests {
err_msg.contains("simulated provider failure"),
"error should contain provider message, got: {err_msg}"
);
match err {
Error::QuorumKeyUnavailable {
quorum_type,
quorum_hash,
core_chain_locked_height,
error: ContextProviderError::InvalidQuorum(_),
} => {
assert_eq!(quorum_type, 6);
assert_eq!(quorum_hash, [0xab; 32]);
assert_eq!(core_chain_locked_height, metadata.core_chain_locked_height);
}
other => panic!("expected QuorumKeyUnavailable, got {other:?}"),
}
}

/// A provider that returns 48 zero-bytes as the "public key" causes the
Expand Down
Loading
Loading