From cf34ee1f82c8c5f86b6a3b727aeee522dbd67420 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 4 Feb 2026 21:17:02 +0700 Subject: [PATCH] fix(sdk): deserialization error due to outdated contract cache --- packages/rs-context-provider/src/provider.rs | 16 ++ packages/rs-drive-proof-verifier/src/error.rs | 19 +- packages/rs-drive-proof-verifier/src/proof.rs | 72 ++---- .../rs-drive-proof-verifier/src/unproved.rs | 12 +- .../src/provider.rs | 4 + packages/rs-sdk/src/mock/provider.rs | 6 + packages/rs-sdk/src/mock/sdk.rs | 54 +++- .../src/platform/documents/document_query.rs | 14 ++ packages/rs-sdk/src/platform/fetch.rs | 209 +++++++++++++--- packages/rs-sdk/src/platform/fetch_many.rs | 152 +++++------ .../fetch/mock_document_contract_refresh.rs | 236 ++++++++++++++++++ packages/rs-sdk/tests/fetch/mod.rs | 1 + packages/wasm-sdk/src/context_provider.rs | 4 + 13 files changed, 620 insertions(+), 179 deletions(-) create mode 100644 packages/rs-sdk/tests/fetch/mock_document_contract_refresh.rs diff --git a/packages/rs-context-provider/src/provider.rs b/packages/rs-context-provider/src/provider.rs index d8843b48bee..83377e0dd53 100644 --- a/packages/rs-context-provider/src/provider.rs +++ b/packages/rs-context-provider/src/provider.rs @@ -88,6 +88,13 @@ pub trait ContextProvider: Send + Sync { /// * `Ok(CoreBlockHeight)`: On success, returns the platform activation height as defined by mn_rr /// * `Err(Error)`: On failure, returns an error indicating why the operation failed. fn get_platform_activation_height(&self) -> Result; + + /// Updates the cached data contract with a fresh version. + /// + /// Called when the SDK detects that a cached contract is stale (e.g., after a + /// document deserialization failure due to a contract schema update). + /// The default implementation is a no-op. + fn update_data_contract(&self, _contract: Arc) {} } impl + Send + Sync> ContextProvider for C { @@ -119,6 +126,10 @@ impl + Send + Sync> ContextProvider for C { fn get_platform_activation_height(&self) -> Result { self.as_ref().get_platform_activation_height() } + + fn update_data_contract(&self, contract: Arc) { + self.as_ref().update_data_contract(contract) + } } impl ContextProvider for std::sync::Mutex @@ -156,6 +167,11 @@ where let lock = self.lock().expect("lock poisoned"); lock.get_platform_activation_height() } + + fn update_data_contract(&self, contract: Arc) { + let lock = self.lock().expect("lock poisoned"); + lock.update_data_contract(contract) + } } /// A trait that provides a function that can be used to look up a [DataContract] by its [Identifier]. diff --git a/packages/rs-drive-proof-verifier/src/error.rs b/packages/rs-drive-proof-verifier/src/error.rs index 98f1991f073..15e5f4050da 100644 --- a/packages/rs-drive-proof-verifier/src/error.rs +++ b/packages/rs-drive-proof-verifier/src/error.rs @@ -29,8 +29,8 @@ pub enum Error { }, /// Dash Protocol error - #[error("dash protocol: {error}")] - ProtocolError { error: String }, + #[error("protocol: {0}")] + ProtocolError(ProtocolError), /// Empty response metadata #[error("empty response metadata")] @@ -99,17 +99,18 @@ pub enum Error { impl From for Error { fn from(error: drive::error::Error) -> Self { - Self::DriveError { - error: error.to_string(), + match error { + drive::error::Error::Protocol(protocol_err) => Self::ProtocolError(*protocol_err), + other => Self::DriveError { + error: other.to_string(), + }, } } } impl From for Error { fn from(error: ProtocolError) -> Self { - Self::ProtocolError { - error: error.to_string(), - } + Self::ProtocolError(error) } } @@ -138,6 +139,10 @@ impl MapGroveDbError for Result { }) } + Err(drive::error::Error::Protocol(protocol_err)) => { + Err(Error::ProtocolError(*protocol_err)) + } + Err(other) => Err(other.into()), } } diff --git a/packages/rs-drive-proof-verifier/src/proof.rs b/packages/rs-drive-proof-verifier/src/proof.rs index f3a9127d2cb..401dd87e0c5 100644 --- a/packages/rs-drive-proof-verifier/src/proof.rs +++ b/packages/rs-drive-proof-verifier/src/proof.rs @@ -277,9 +277,7 @@ impl FromProof for Identity { let id = match request.version.ok_or(Error::EmptyVersion)? { get_identity_request::Version::V0(v0) => { - Identifier::from_bytes(&v0.id).map_err(|e| Error::ProtocolError { - error: e.to_string(), - })? + Identifier::from_bytes(&v0.id).map_err(|e| Error::ProtocolError(e.into()))? } }; @@ -474,9 +472,7 @@ impl FromProof for IdentityPublicKeys { get_identity_keys_request::Version::V0(v0) => { let request_type = v0.request_type; let identity_id = Identifier::from_bytes(&v0.identity_id) - .map_err(|e| Error::ProtocolError { - error: e.to_string(), - })? + .map_err(|e| Error::ProtocolError(e.into()))? .into_buffer(); let limit = v0.limit.map(|i| i as u16); let offset = v0.offset.map(|i| i as u16); @@ -614,14 +610,12 @@ impl FromProof for IdentityNonceFetcher { let mtd = response.metadata().or(Err(Error::EmptyResponseMetadata))?; - let identity_id = - match request.version.ok_or(Error::EmptyVersion)? { - get_identity_nonce_request::Version::V0(v0) => Ok::( - Identifier::from_bytes(&v0.identity_id).map_err(|e| Error::ProtocolError { - error: e.to_string(), - })?, - ), - }?; + let identity_id = match request.version.ok_or(Error::EmptyVersion)? { + get_identity_nonce_request::Version::V0(v0) => Ok::( + Identifier::from_bytes(&v0.identity_id) + .map_err(|e| Error::ProtocolError(e.into()))?, + ), + }?; // Extract content from proof and verify Drive/GroveDB proofs let (root_hash, maybe_nonce) = Drive::verify_identity_nonce( @@ -667,12 +661,10 @@ impl FromProof for IdentityContractNo let (identity_id, contract_id) = match request.version.ok_or(Error::EmptyVersion)? { get_identity_contract_nonce_request::Version::V0(v0) => { Ok::<(Identifier, Identifier), Error>(( - Identifier::from_bytes(&v0.identity_id).map_err(|e| Error::ProtocolError { - error: e.to_string(), - })?, - Identifier::from_bytes(&v0.contract_id).map_err(|e| Error::ProtocolError { - error: e.to_string(), - })?, + Identifier::from_bytes(&v0.identity_id) + .map_err(|e| Error::ProtocolError(e.into()))?, + Identifier::from_bytes(&v0.contract_id) + .map_err(|e| Error::ProtocolError(e.into()))?, )) } }?; @@ -720,10 +712,9 @@ impl FromProof for IdentityBalance { let mtd = response.metadata().or(Err(Error::EmptyResponseMetadata))?; let id = match request.version.ok_or(Error::EmptyVersion)? { - get_identity_balance_request::Version::V0(v0) => Identifier::from_bytes(&v0.id) - .map_err(|e| Error::ProtocolError { - error: e.to_string(), - }), + get_identity_balance_request::Version::V0(v0) => { + Identifier::from_bytes(&v0.id).map_err(|e| Error::ProtocolError(e.into())) + } }?; // Extract content from proof and verify Drive/GroveDB proofs @@ -814,9 +805,7 @@ impl FromProof for IdentityBalan let id = match request.version.ok_or(Error::EmptyVersion)? { get_identity_balance_and_revision_request::Version::V0(v0) => { - Identifier::from_bytes(&v0.id).map_err(|e| Error::ProtocolError { - error: e.to_string(), - }) + Identifier::from_bytes(&v0.id).map_err(|e| Error::ProtocolError(e.into())) } }?; @@ -1134,9 +1123,7 @@ impl FromProof for DataContract { let id = match request.version.ok_or(Error::EmptyVersion)? { get_data_contract_request::Version::V0(v0) => { - Identifier::from_bytes(&v0.id).map_err(|e| Error::ProtocolError { - error: e.to_string(), - }) + Identifier::from_bytes(&v0.id).map_err(|e| Error::ProtocolError(e.into())) } }?; @@ -1181,9 +1168,7 @@ impl FromProof for (DataContract, Vec) { let id = match request.version.ok_or(Error::EmptyVersion)? { get_data_contract_request::Version::V0(v0) => { - Identifier::from_bytes(&v0.id).map_err(|e| Error::ProtocolError { - error: e.to_string(), - }) + Identifier::from_bytes(&v0.id).map_err(|e| Error::ProtocolError(e.into())) } }?; @@ -1294,9 +1279,8 @@ impl FromProof for DataContractHistory let (id, limit, offset, start_at_ms) = match request.version.ok_or(Error::EmptyVersion)? { get_data_contract_history_request::Version::V0(v0) => { - let id = Identifier::from_bytes(&v0.id).map_err(|e| Error::ProtocolError { - error: e.to_string(), - })?; + let id = + Identifier::from_bytes(&v0.id).map_err(|e| Error::ProtocolError(e.into()))?; let limit = u32_to_u16_opt(v0.limit.unwrap_or_default())?; let offset = u32_to_u16_opt(v0.offset.unwrap_or_default())?; let start_at_ms = v0.start_at_ms; @@ -1346,9 +1330,7 @@ impl FromProof for StateTransitionPro let proof = response.proof().or(Err(Error::NoProofInResult))?; let state_transition = StateTransition::deserialize_from_bytes(&request.state_transition) - .map_err(|e| Error::ProtocolError { - error: e.to_string(), - })?; + .map_err(Error::ProtocolError)?; let mtd = response.metadata().or(Err(Error::EmptyResponseMetadata))?; @@ -1773,20 +1755,14 @@ impl FromProof for IdentitiesContrac Ok(identifier.to_buffer()) }) .collect::, platform_value::Error>>() - .map_err(|e| Error::ProtocolError { - error: e.to_string(), - })?; + .map_err(|e| Error::ProtocolError(e.into()))?; let contract_id = Identifier::from_vec(contract_id) - .map_err(|e| Error::ProtocolError { - error: e.to_string(), - })? + .map_err(|e| Error::ProtocolError(e.into()))? .into_buffer(); let purposes = purposes .into_iter() .map(|purpose| { - Purpose::try_from(purpose).map_err(|e| Error::ProtocolError { - error: e.to_string(), - }) + Purpose::try_from(purpose).map_err(|e| Error::ProtocolError(e.into())) }) .collect::, Error>>()?; (identifiers, contract_id, document_type_name, purposes) diff --git a/packages/rs-drive-proof-verifier/src/unproved.rs b/packages/rs-drive-proof-verifier/src/unproved.rs index dc8fdabc25d..cb28a3db4a7 100644 --- a/packages/rs-drive-proof-verifier/src/unproved.rs +++ b/packages/rs-drive-proof-verifier/src/unproved.rs @@ -184,7 +184,7 @@ impl FromUnproved for CurrentQuorumsInfo .map(|q_hash| { let mut q_hash_array = [0u8; 32]; if q_hash.len() != 32 { - return Err(Error::ProtocolError { + return Err(Error::ResponseDecodeError { error: "Invalid quorum_hash length".to_string(), }); } @@ -196,7 +196,7 @@ impl FromUnproved for CurrentQuorumsInfo // Extract current quorum hash let mut current_quorum_hash = [0u8; 32]; if v0.current_quorum_hash.len() != 32 { - return Err(Error::ProtocolError { + return Err(Error::ResponseDecodeError { error: "Invalid current_quorum_hash length".to_string(), }); } @@ -204,7 +204,7 @@ impl FromUnproved for CurrentQuorumsInfo let mut last_block_proposer = [0u8; 32]; if v0.last_block_proposer.len() != 32 { - return Err(Error::ProtocolError { + return Err(Error::ResponseDecodeError { error: "Invalid last_block_proposer length".to_string(), }); } @@ -225,7 +225,7 @@ impl FromUnproved for CurrentQuorumsInfo .into_iter() .map(|member| { let pro_tx_hash = ProTxHash::from_slice(&member.pro_tx_hash) - .map_err(|_| Error::ProtocolError { + .map_err(|_| Error::ResponseDecodeError { error: "Invalid ProTxHash format".to_string(), })?; let validator = ValidatorV0 { @@ -244,7 +244,7 @@ impl FromUnproved for CurrentQuorumsInfo Ok(ValidatorSet::V0(ValidatorSetV0 { quorum_hash: QuorumHash::from_slice(quorum_hash.as_slice()) - .map_err(|_| Error::ProtocolError { + .map_err(|_| Error::ResponseDecodeError { error: "Invalid Quorum Hash format".to_string(), })?, quorum_index: None, // Assuming it's not provided here @@ -253,7 +253,7 @@ impl FromUnproved for CurrentQuorumsInfo threshold_public_key: BlsPublicKey::try_from( vs.threshold_public_key.as_slice(), ) - .map_err(|_| Error::ProtocolError { + .map_err(|_| Error::ResponseDecodeError { error: "Invalid BlsPublicKey format".to_string(), })?, })) diff --git a/packages/rs-sdk-trusted-context-provider/src/provider.rs b/packages/rs-sdk-trusted-context-provider/src/provider.rs index 91ad6eaa677..0f7847b65a6 100644 --- a/packages/rs-sdk-trusted-context-provider/src/provider.rs +++ b/packages/rs-sdk-trusted-context-provider/src/provider.rs @@ -810,6 +810,10 @@ impl ContextProvider for TrustedHttpContextProvider { )), } } + + fn update_data_contract(&self, contract: Arc) { + self.add_known_contract((*contract).clone()); + } } #[cfg(test)] diff --git a/packages/rs-sdk/src/mock/provider.rs b/packages/rs-sdk/src/mock/provider.rs index 88327ff5564..6680d6b28eb 100644 --- a/packages/rs-sdk/src/mock/provider.rs +++ b/packages/rs-sdk/src/mock/provider.rs @@ -6,6 +6,7 @@ use crate::sync::block_on; use crate::{Error, Sdk}; use arc_swap::ArcSwapAny; use dash_context_provider::{ContextProvider, ContextProviderError}; +use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::TokenConfiguration; use dpp::prelude::{CoreBlockHeight, DataContract, Identifier}; use dpp::version::PlatformVersion; @@ -239,6 +240,11 @@ impl ContextProvider for GrpcContextProvider { fn get_platform_activation_height(&self) -> Result { self.core.get_platform_activation_height() } + + fn update_data_contract(&self, contract: Arc) { + self.data_contracts_cache + .put(contract.id(), (*contract).clone()); + } } /// Thread-safe cache of various objects inside the SDK. diff --git a/packages/rs-sdk/src/mock/sdk.rs b/packages/rs-sdk/src/mock/sdk.rs index a6d75d829f1..c7904f05d4b 100644 --- a/packages/rs-sdk/src/mock/sdk.rs +++ b/packages/rs-sdk/src/mock/sdk.rs @@ -26,7 +26,11 @@ use rs_dapi_client::{ transport::TransportRequest, DapiClient, DumpData, ExecutionResponse, }; -use std::{collections::BTreeMap, path::PathBuf, sync::Arc}; +use std::{ + collections::{BTreeMap, BTreeSet}, + path::PathBuf, + sync::Arc, +}; use tokio::sync::{Mutex, OwnedMutexGuard}; /// Mechanisms to mock Dash Platform SDK. @@ -42,6 +46,9 @@ use tokio::sync::{Mutex, OwnedMutexGuard}; #[derive(Debug)] pub struct MockDashPlatformSdk { from_proof_expectations: BTreeMap>, + /// Keys for which proof parsing should return a `CorruptedSerialization` error. + /// Used to test contract-refresh retry logic. + proof_error_expectations: BTreeSet, platform_version: &'static PlatformVersion, dapi: Arc>, sdk: ArcSwapOption, @@ -69,6 +76,7 @@ impl MockDashPlatformSdk { pub(crate) fn new(version: &'static PlatformVersion, dapi: Arc>) -> Self { Self { from_proof_expectations: Default::default(), + proof_error_expectations: Default::default(), platform_version: version, dapi, sdk: ArcSwapOption::new(None), @@ -394,6 +402,39 @@ impl MockDashPlatformSdk { Ok(self) } + /// Expect a [Fetch] request to fail with a document deserialization (CorruptedSerialization) error. + /// + /// This sets up the mock so that proof parsing for the given query returns a + /// `ProtocolError(DataContractError::CorruptedSerialization(...))` error. + /// Used to test the contract-refresh retry logic. + pub async fn expect_fetch_proof_error::Request>>( + &mut self, + query: Q, + ) -> Result<&mut Self, Error> + where + <::Request as TransportRequest>::Response: Default, + { + let grpc_request = query.query(self.prove()).expect("query must be correct"); + let key = Key::new(&grpc_request); + + self.proof_error_expectations.insert(key); + + // Also set up DAPI mock for execute so the transport layer returns a default response + { + let mut dapi_guard = self.dapi.lock().await; + dapi_guard.expect( + &grpc_request, + &Ok(ExecutionResponse { + inner: Default::default(), + retries: 0, + address: "http://127.0.0.1".parse().expect("failed to parse address"), + }), + )?; + } + + Ok(self) + } + /// Save expectations for a request. async fn expect( &mut self, @@ -459,6 +500,17 @@ impl MockDashPlatformSdk { { let key = Key::new(&request); + // Check if this request should simulate a deserialization error + if self.proof_error_expectations.contains(&key) { + return Err(drive_proof_verifier::Error::ProtocolError( + dpp::ProtocolError::DataContractError( + dpp::data_contract::errors::DataContractError::CorruptedSerialization( + "mock: simulated stale contract deserialization error".to_string(), + ), + ), + )); + } + let data = match self.from_proof_expectations.get(&key) { Some(d) => ( Option::::mock_deserialize(self, d), diff --git a/packages/rs-sdk/src/platform/documents/document_query.rs b/packages/rs-sdk/src/platform/documents/document_query.rs index 3a669e5c42c..7808977e2aa 100644 --- a/packages/rs-sdk/src/platform/documents/document_query.rs +++ b/packages/rs-sdk/src/platform/documents/document_query.rs @@ -129,6 +129,20 @@ impl DocumentQuery { self } + + /// Create a clone of this query with a different data contract. + /// + /// Preserves all where/order_by/limit/start clauses. + pub fn clone_with_contract(&self, contract: Arc) -> Self { + Self { + data_contract: contract, + document_type_name: self.document_type_name.clone(), + where_clauses: self.where_clauses.clone(), + order_by_clauses: self.order_by_clauses.clone(), + limit: self.limit, + start: self.start.clone(), + } + } } impl TransportRequest for DocumentQuery { diff --git a/packages/rs-sdk/src/platform/fetch.rs b/packages/rs-sdk/src/platform/fetch.rs index 27c6394dfe5..a80eaacb97b 100644 --- a/packages/rs-sdk/src/platform/fetch.rs +++ b/packages/rs-sdk/src/platform/fetch.rs @@ -11,7 +11,9 @@ use crate::mock::MockResponse; use crate::sync::retry; use crate::{error::Error, platform::query::Query, Sdk}; +use dash_context_provider::ContextProvider; use dapi_grpc::platform::v0::{self as platform_proto, Proof, ResponseMetadata}; +use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::associated_token::token_perpetual_distribution::reward_distribution_moment::RewardDistributionMoment; use dpp::identity::identities_contract_keys::IdentitiesContractKeys; use dpp::voting::votes::Vote; @@ -157,47 +159,8 @@ where query: Q, settings: Option, ) -> Result<(Option, ResponseMetadata, Proof), Error> { - let request: &::Request = &query.query(sdk.prove())?; - - let fut = |settings: RequestSettings| async move { - let ExecutionResponse { - address, - retries, - inner: response, - } = request - .clone() - .execute(sdk, settings) - .await - .map_err(|execution_error| execution_error.inner_into())?; - - let object_type = std::any::type_name::().to_string(); - tracing::trace!(request = ?request, response = ?response, ?address, retries, object_type, "fetched object from platform"); - - let (object, response_metadata, proof): (Option, ResponseMetadata, Proof) = sdk - .parse_proof_with_metadata_and_proof(request.clone(), response) - .await - .map_err(|e| ExecutionError { - inner: e, - address: Some(address.clone()), - retries, - })?; - - match object { - Some(item) => Ok((item.into(), response_metadata, proof)), - None => Ok((None, response_metadata, proof)), - } - .map(|x| ExecutionResponse { - inner: x, - address, - retries, - }) - }; - - let settings = sdk - .dapi_client_settings - .override_by(settings.unwrap_or_default()); - - retry(sdk.address_list(), settings, fut).await.into_inner() + let request = query.query(sdk.prove())?; + fetch_request(sdk, &request, settings).await } /// Fetch single object from Platform. @@ -260,8 +223,85 @@ impl Fetch for (dpp::prelude::DataContract, Vec) { type Request = platform_proto::GetDataContractRequest; } +#[async_trait::async_trait] impl Fetch for Document { type Request = DocumentQuery; + + async fn fetch_with_metadata_and_proof::Request>>( + sdk: &Sdk, + query: Q, + settings: Option, + ) -> Result<(Option, ResponseMetadata, Proof), Error> { + let document_query: DocumentQuery = query.query(sdk.prove())?; + + // First attempt with current (possibly cached) contract + match fetch_request(sdk, &document_query, settings).await { + Ok(result) => Ok(result), + Err(e) if is_document_deserialization_error(&e) => { + let fresh_query = refetch_contract_for_query(sdk, &document_query).await?; + fetch_request(sdk, &fresh_query, settings).await + } + Err(e) => Err(e), + } + } +} + +/// Execute a fetch request with node-level retry logic. +/// +/// Shared implementation used by both the default [Fetch::fetch_with_metadata_and_proof] +/// and the [Document]-specific override. +async fn fetch_request( + sdk: &Sdk, + request: &R, + settings: Option, +) -> Result<(Option, ResponseMetadata, Proof), Error> +where + O: Sized + + Send + + Debug + + MockResponse + + FromProof::Response>, + R: TransportRequest + Into<>::Request> + Clone + Debug, +{ + let fut = |settings: RequestSettings| async move { + let ExecutionResponse { + address, + retries, + inner: response, + } = request + .clone() + .execute(sdk, settings) + .await + .map_err(|execution_error| execution_error.inner_into())?; + + let object_type = std::any::type_name::().to_string(); + tracing::trace!(request = ?request, response = ?response, ?address, retries, object_type, "fetched object from platform"); + + let (object, response_metadata, proof): (Option, ResponseMetadata, Proof) = sdk + .parse_proof_with_metadata_and_proof(request.clone(), response) + .await + .map_err(|e| ExecutionError { + inner: e, + address: Some(address.clone()), + retries, + })?; + + match object { + Some(item) => Ok((item.into(), response_metadata, proof)), + None => Ok((None, response_metadata, proof)), + } + .map(|x| ExecutionResponse { + inner: x, + address, + retries, + }) + }; + + let settings = sdk + .dapi_client_settings + .override_by(settings.unwrap_or_default()); + + retry(sdk.address_list(), settings, fut).await.into_inner() } impl Fetch for drive_proof_verifier::types::IdentityBalance { @@ -328,3 +368,90 @@ impl Fetch for drive_proof_verifier::types::RecentCompactedAddressBalanceChanges impl Fetch for drive_proof_verifier::types::PlatformAddressTrunkState { type Request = platform_proto::GetAddressesTrunkStateRequest; } + +/// Refetch the data contract from the network, update the context provider +/// cache, and return a new [DocumentQuery] with the fresh contract. +/// +/// Used by document fetch retry logic when a deserialization error indicates +/// a stale cached contract. +pub(super) async fn refetch_contract_for_query( + sdk: &Sdk, + document_query: &DocumentQuery, +) -> Result { + tracing::debug!( + contract_id = ?document_query.data_contract.id(), + "refetching contract for document query after deserialization failure" + ); + + let fresh_contract = dpp::prelude::DataContract::fetch(sdk, document_query.data_contract.id()) + .await? + .ok_or(Error::MissingDependency( + "DataContract".to_string(), + format!( + "data contract {} not found during refetch", + document_query.data_contract.id() + ), + ))?; + + let fresh_contract = std::sync::Arc::new(fresh_contract); + + // Update the cached contract in the context provider + if let Some(context_provider) = sdk.context_provider() { + context_provider.update_data_contract(fresh_contract.clone()); + } + + Ok(document_query.clone_with_contract(fresh_contract)) +} + +/// Returns true if the error indicates a document deserialization failure +/// that could be caused by a stale/outdated data contract schema. +pub(super) fn is_document_deserialization_error(error: &Error) -> bool { + use dpp::data_contract::errors::DataContractError; + + matches!( + error, + Error::Proof(drive_proof_verifier::Error::ProtocolError( + dpp::ProtocolError::DataContractError(DataContractError::CorruptedSerialization(_)) + )) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use dpp::data_contract::errors::DataContractError; + + #[test] + fn test_corrupted_serialization_is_detected() { + let error = Error::Proof(drive_proof_verifier::Error::ProtocolError( + dpp::ProtocolError::DataContractError(DataContractError::CorruptedSerialization( + "test error".to_string(), + )), + )); + + assert!(is_document_deserialization_error(&error)); + } + + #[test] + fn test_other_protocol_error_is_not_detected() { + let error = Error::Proof(drive_proof_verifier::Error::ProtocolError( + dpp::ProtocolError::DecodingError("some decoding error".to_string()), + )); + + assert!(!is_document_deserialization_error(&error)); + } + + #[test] + fn test_other_proof_error_is_not_detected() { + let error = Error::Proof(drive_proof_verifier::Error::EmptyVersion); + + assert!(!is_document_deserialization_error(&error)); + } + + #[test] + fn test_non_proof_error_is_not_detected() { + let error = Error::Generic("some error".to_string()); + + assert!(!is_document_deserialization_error(&error)); + } +} diff --git a/packages/rs-sdk/src/platform/fetch_many.rs b/packages/rs-sdk/src/platform/fetch_many.rs index c0e4947b1e2..bf20f76abc3 100644 --- a/packages/rs-sdk/src/platform/fetch_many.rs +++ b/packages/rs-sdk/src/platform/fetch_many.rs @@ -45,6 +45,7 @@ use rs_dapi_client::{ transport::TransportRequest, DapiRequest, ExecutionError, ExecutionResponse, InnerInto, IntoInner, RequestSettings, }; +use std::fmt::Debug; /// Fetch multiple objects from Platform. /// @@ -207,51 +208,8 @@ where query: Q, settings: Option, ) -> Result<(O, ResponseMetadata, Proof), Error> { - let request = &query.query(sdk.prove())?; - - let fut = |settings: RequestSettings| async move { - let ExecutionResponse { - address, - retries, - inner: response, - } = request - .clone() - .execute(sdk, settings) - .await - .map_err(|e| e.inner_into())?; - - let object_type = std::any::type_name::().to_string(); - tracing::trace!( - request = ?request, - response = ?response, - ?address, - retries, - object_type, - "fetched objects from platform" - ); - - sdk.parse_proof_with_metadata_and_proof::<>::Request, O>( - request.clone(), - response, - ) - .await - .map_err(|e| ExecutionError { - inner: e, - address: Some(address.clone()), - retries, - }) - .map(|(o, metadata, proof)| ExecutionResponse { - inner: (o.unwrap_or_default(), metadata, proof), - retries, - address: address.clone(), - }) - }; - - let settings = sdk - .dapi_client_settings - .override_by(settings.unwrap_or_default()); - - retry(sdk.address_list(), settings, fut).await.into_inner() + let request = query.query(sdk.prove())?; + fetch_many_request(sdk, &request, settings).await } /// Fetch multiple objects from Platform by their identifiers. @@ -320,43 +278,85 @@ impl FetchMany for Document { // type stores full contract, which is missing in the GetDocumentsRequest type. // TODO: Refactor to use ContextProvider type Request = DocumentQuery; - async fn fetch_many>::Request>>( + + async fn fetch_many_with_metadata_and_proof< + Q: Query<>::Request>, + >( sdk: &Sdk, query: Q, - ) -> Result { - let document_query: &DocumentQuery = &query.query(sdk.prove())?; - - retry(sdk.address_list(), sdk.dapi_client_settings, |settings| async move { - let request = document_query.clone(); + settings: Option, + ) -> Result<(Documents, ResponseMetadata, Proof), Error> { + let document_query: DocumentQuery = query.query(sdk.prove())?; + + // First attempt with current (possibly cached) contract + match fetch_many_request(sdk, &document_query, settings).await { + Ok(result) => Ok(result), + Err(e) if super::fetch::is_document_deserialization_error(&e) => { + let fresh_query = + super::fetch::refetch_contract_for_query(sdk, &document_query).await?; + fetch_many_request(sdk, &fresh_query, settings).await + } + Err(e) => Err(e), + } + } +} - let ExecutionResponse { - address, +/// Execute a fetch-many request with node-level retry logic. +/// +/// Shared implementation used by both the default [FetchMany::fetch_many_with_metadata_and_proof] +/// and the [Document]-specific override. +async fn fetch_many_request( + sdk: &Sdk, + request: &R, + settings: Option, +) -> Result<(O, ResponseMetadata, Proof), Error> +where + O: Send + + Default + + MockResponse + + FromProof::Response>, + R: TransportRequest + Into<>::Request> + Clone + Debug, +{ + let fut = |settings: RequestSettings| async move { + let ExecutionResponse { + address, + retries, + inner: response, + } = request + .clone() + .execute(sdk, settings) + .await + .map_err(|e| e.inner_into())?; + + let object_type = std::any::type_name::().to_string(); + tracing::trace!( + request = ?request, + response = ?response, + ?address, + retries, + object_type, + "fetched objects from platform" + ); + + sdk.parse_proof_with_metadata_and_proof::(request.clone(), response) + .await + .map_err(|e| ExecutionError { + inner: e, + address: Some(address.clone()), retries, - inner: response - } = request.execute(sdk, settings).await.map_err(|e| e.inner_into())?; - - tracing::trace!(request=?document_query, response=?response, ?address, retries, "fetch multiple documents"); - - // let object: Option> = sdk - let documents = sdk - .parse_proof::(document_query.clone(), response) - .await - .map_err(|e| ExecutionError { - inner: e, - retries, - address: Some(address.clone()), - })? - .unwrap_or_default(); - - Ok(ExecutionResponse { - inner: documents, + }) + .map(|(o, metadata, proof)| ExecutionResponse { + inner: (o.unwrap_or_default(), metadata, proof), retries, - address, + address: address.clone(), }) - }) - .await - .into_inner() - } + }; + + let settings = sdk + .dapi_client_settings + .override_by(settings.unwrap_or_default()); + + retry(sdk.address_list(), settings, fut).await.into_inner() } /// Retrieve public keys for a given identity. diff --git a/packages/rs-sdk/tests/fetch/mock_document_contract_refresh.rs b/packages/rs-sdk/tests/fetch/mock_document_contract_refresh.rs new file mode 100644 index 00000000000..9449a4cfb20 --- /dev/null +++ b/packages/rs-sdk/tests/fetch/mock_document_contract_refresh.rs @@ -0,0 +1,236 @@ +//! Tests for document fetch retry on stale contract deserialization error. +//! +//! When a data contract is updated on the network, the SDK may hold a cached (old) version. +//! Documents serialized with the new schema fail to deserialize with the old contract. +//! The SDK should detect this and retry once with a freshly-fetched contract. + +use super::common::{mock_data_contract, mock_document_type}; +use dash_sdk::{ + platform::{DocumentQuery, Fetch, FetchMany}, + Sdk, +}; +use dpp::{ + data_contract::{ + accessors::v0::DataContractV0Getters, + document_type::{ + accessors::DocumentTypeV0Getters, random_document::CreateRandomDocument, DocumentType, + }, + }, + document::{Document, DocumentV0Getters}, + prelude::DataContract, +}; +use drive_proof_verifier::types::Documents; + +/// Helper: create two data contracts sharing the same identity and ID, but the +/// "new" contract has an extra field. Both contracts use the same document type name. +/// +/// Returns (old_contract, new_contract, new_document_type). +fn make_old_and_new_contracts() -> (DataContract, DataContract, DocumentType) { + use dpp::{ + data_contract::{config::DataContractConfig, DataContractFactory}, + platform_value::{platform_value, Value}, + prelude::Identifier, + version::PlatformVersion, + }; + use std::collections::BTreeMap; + + let platform_version = PlatformVersion::latest(); + let protocol_version = platform_version.protocol_version; + let owner_id = Identifier::new([7u8; 32]); + + // --- "old" contract: single field --- + let old_schema = platform_value!({ + "type": "object", + "properties": { + "a": { + "type": "string", + "maxLength": 10, + "position": 0 + } + }, + "additionalProperties": false, + }); + + let mut old_types: BTreeMap = BTreeMap::new(); + old_types.insert("document_type_name".to_string(), old_schema); + + let old_contract = DataContractFactory::new(protocol_version) + .unwrap() + .create(owner_id, 0, platform_value!(old_types), None, None) + .expect("create old data contract") + .data_contract_owned(); + + // --- "new" contract: two fields (simulates a schema update) --- + let new_schema = platform_value!({ + "type": "object", + "properties": { + "a": { + "type": "string", + "maxLength": 10, + "position": 0 + }, + "b": { + "type": "integer", + "position": 1 + } + }, + "additionalProperties": false, + }); + + let config = + DataContractConfig::default_for_version(platform_version).expect("create a default config"); + + let new_document_type = DocumentType::try_from_schema( + old_contract.id(), + 1, + config.version(), + "document_type_name", + new_schema.clone(), + None, + &BTreeMap::new(), + &config, + true, + &mut vec![], + platform_version, + ) + .expect("create new document type"); + + let mut new_types: BTreeMap = BTreeMap::new(); + new_types.insert("document_type_name".to_string(), new_schema); + + let mut new_contract = DataContractFactory::new(protocol_version) + .unwrap() + .create(owner_id, 0, platform_value!(new_types), None, None) + .expect("create new data contract") + .data_contract_owned(); + + // Make the new contract share the same ID as the old one + use dpp::data_contract::accessors::v0::DataContractV0Setters; + new_contract.set_id(old_contract.id()); + + (old_contract, new_contract, new_document_type) +} + +/// Test: Document::fetch retries once when the first attempt fails with a deserialization error. +/// +/// Setup: +/// 1. Create old and new versions of a data contract +/// 2. Build a DocumentQuery using the OLD contract +/// 3. Mock the document query with old contract to return a CorruptedSerialization error +/// 4. Mock DataContract::fetch to return the NEW contract +/// 5. Mock the document query with new contract to return the expected document +/// 6. Verify that Document::fetch succeeds (retry worked) +#[tokio::test] +async fn test_fetch_document_retries_on_stale_contract() { + let mut sdk = Sdk::new_mock(); + + let (old_contract, new_contract, new_doc_type) = make_old_and_new_contracts(); + + let expected_document = new_doc_type + .random_document(None, sdk.version()) + .expect("create document"); + let document_id = expected_document.id(); + + // Build a query using the old contract (simulates stale cache) + let old_query = DocumentQuery::new(old_contract.clone(), "document_type_name") + .expect("create document query with old contract") + .with_document_id(&document_id); + + // 1) Old query should fail with a deserialization error + sdk.mock() + .expect_fetch_proof_error::(old_query.clone()) + .await + .expect("set error expectation"); + + // 2) DataContract::fetch should return the new contract (refetch) + sdk.mock() + .expect_fetch(new_contract.id(), Some(new_contract.clone())) + .await + .expect("set contract refetch expectation"); + + // 3) New query (with fresh contract) should succeed + let new_query = old_query.clone_with_contract(std::sync::Arc::new(new_contract)); + sdk.mock() + .expect_fetch(new_query, Some(expected_document.clone())) + .await + .expect("set document fetch expectation with new contract"); + + // Execute: the retry logic should handle the error and succeed + let result = Document::fetch(&sdk, old_query) + .await + .expect("fetch should succeed after retry"); + + let fetched = result.expect("document should be returned"); + assert_eq!(fetched.id(), expected_document.id()); +} + +/// Test: Document::fetch_many retries once when the first attempt fails with a deserialization error. +#[tokio::test] +async fn test_fetch_many_documents_retries_on_stale_contract() { + let mut sdk = Sdk::new_mock(); + + let (old_contract, new_contract, new_doc_type) = make_old_and_new_contracts(); + + let expected_document = new_doc_type + .random_document(None, sdk.version()) + .expect("create document"); + + let expected_documents = + Documents::from([(expected_document.id(), Some(expected_document.clone()))]); + + // Build a query using the old contract (simulates stale cache) + let old_query = DocumentQuery::new(old_contract.clone(), "document_type_name") + .expect("create document query with old contract"); + + // 1) Old query should fail with a deserialization error + // We use expect_fetch_proof_error with Document since DocumentQuery is the + // request type for both Fetch and FetchMany + sdk.mock() + .expect_fetch_proof_error::(old_query.clone()) + .await + .expect("set error expectation"); + + // 2) DataContract::fetch should return the new contract (refetch) + sdk.mock() + .expect_fetch(new_contract.id(), Some(new_contract.clone())) + .await + .expect("set contract refetch expectation"); + + // 3) New query (with fresh contract) should succeed + let new_query = old_query.clone_with_contract(std::sync::Arc::new(new_contract)); + sdk.mock() + .expect_fetch_many(new_query, Some(expected_documents.clone())) + .await + .expect("set document fetch_many expectation with new contract"); + + // Execute: the retry logic should handle the error and succeed + let result = Document::fetch_many(&sdk, old_query) + .await + .expect("fetch_many should succeed after retry"); + + assert_eq!(result.len(), 1); + let (doc_id, doc) = result.into_iter().next().unwrap(); + assert_eq!(doc_id, expected_document.id()); + assert!(doc.is_some()); +} + +/// Test: When the error is NOT a deserialization error, no retry happens and the error propagates. +#[tokio::test] +async fn test_fetch_document_does_not_retry_on_other_errors() { + let sdk = Sdk::new_mock(); + + let doc_type = mock_document_type(); + let contract = mock_data_contract(Some(&doc_type)); + + // Build a query but don't set any expectations — the mock will have no matching + // response for execute, which should result in an error that is NOT a deserialization error. + let query = DocumentQuery::new(contract, doc_type.name()).expect("create document query"); + + let result = Document::fetch(&sdk, query).await; + + // Should fail — non-deserialization errors propagate without retry + assert!( + result.is_err(), + "expected error when no mock expectations are set" + ); +} diff --git a/packages/rs-sdk/tests/fetch/mod.rs b/packages/rs-sdk/tests/fetch/mod.rs index 83fc9a97b03..7de201ee4cb 100644 --- a/packages/rs-sdk/tests/fetch/mod.rs +++ b/packages/rs-sdk/tests/fetch/mod.rs @@ -24,6 +24,7 @@ mod generated_data; mod group_actions; mod identity; mod identity_contract_nonce; +mod mock_document_contract_refresh; mod mock_fetch; mod mock_fetch_many; mod prefunded_specialized_balance; diff --git a/packages/wasm-sdk/src/context_provider.rs b/packages/wasm-sdk/src/context_provider.rs index fc4b1b74b91..c28d47af3c7 100644 --- a/packages/wasm-sdk/src/context_provider.rs +++ b/packages/wasm-sdk/src/context_provider.rs @@ -96,6 +96,10 @@ impl ContextProvider for WasmTrustedContext { fn get_platform_activation_height(&self) -> Result { self.inner.get_platform_activation_height() } + + fn update_data_contract(&self, contract: Arc) { + self.inner.update_data_contract(contract) + } } impl WasmTrustedContext {