From 7548871e507f949e0cf4eaf968f3f06738340aa0 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:42:26 +0200 Subject: [PATCH 01/12] feat(sdk): auto-detect protocol version from network response metadata Store protocol version as Arc on Sdk (shared between clones). On each gRPC response, compare metadata.protocol_version against stored value and atomically update if newer and known to this binary. Unknown versions are logged at WARN and ignored. Downgrades and zero values are silently skipped. Closes #3410 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/rs-sdk/src/sdk.rs | 212 ++++++++++++++++++++++++++++++++++--- 1 file changed, 196 insertions(+), 16 deletions(-) diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index 995126cd9c..a11c33a299 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -107,6 +107,9 @@ pub struct Sdk { /// Note that setting this to None can panic. context_provider: ArcSwapOption>, + /// Protocol version number detected from the network. Shared between clones. + protocol_version: Arc, + /// Last seen height; used to determine if the remote node is stale. /// /// This is clone-able and can be shared between threads. @@ -140,6 +143,7 @@ impl Clone for Sdk { nonce_cache: Arc::clone(&self.nonce_cache), context_provider: ArcSwapOption::new(self.context_provider.load_full()), cancel_token: self.cancel_token.clone(), + protocol_version: Arc::clone(&self.protocol_version), metadata_last_seen_height: Arc::clone(&self.metadata_last_seen_height), metadata_height_tolerance: self.metadata_height_tolerance, metadata_time_tolerance_ms: self.metadata_time_tolerance_ms, @@ -178,9 +182,6 @@ enum SdkInstance { Dapi { /// DAPI client used to communicate with Dash Platform. dapi: DapiClient, - - /// Platform version configured for this Sdk - version: &'static PlatformVersion, }, /// Mock SDK #[cfg(feature = "mocks")] @@ -192,8 +193,6 @@ enum SdkInstance { /// Mock SDK implementation processing mock expectations and responses. mock: Arc>, address_list: AddressList, - /// Platform version configured for this Sdk - version: &'static PlatformVersion, }, } @@ -250,9 +249,65 @@ impl Sdk { verify_metadata_time(metadata, now, time_tolerance)?; }; + self.maybe_update_protocol_version(metadata.protocol_version); + Ok(()) } + /// Update the stored protocol version if `received_version` is newer and known. + /// + /// Uses a CAS loop so the highest version always wins under concurrent updates. + /// In multi-SDK scenarios the `PlatformVersion::set_current` global is process-wide + /// (last writer wins). + fn maybe_update_protocol_version(&self, received_version: u32) { + if received_version == 0 { + return; + } + + let mut current = self.protocol_version.load(Ordering::Relaxed); + + if received_version <= current { + return; + } + + let new_version = match PlatformVersion::get(received_version) { + Ok(v) => v, + Err(_) => { + tracing::warn!( + received_version, + current_version = current, + "received unknown protocol version from network; keeping current" + ); + return; + } + }; + + loop { + match self.protocol_version.compare_exchange_weak( + current, + received_version, + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => { + tracing::info!( + old_version = current, + new_version = received_version, + "protocol version updated from network metadata" + ); + PlatformVersion::set_current(new_version); + return; + } + Err(actual) => { + if actual >= received_version { + return; + } + current = actual; + } + } + } + } + // TODO: Changed to public for tests /// Retrieve object `O` from proof contained in `request` (of type `R`) and `response`. /// @@ -382,16 +437,16 @@ impl Sdk { /// Return [Dash Platform version](PlatformVersion) information used by this SDK. /// - /// - /// - /// This is the version configured in [`SdkBuilder`]. - /// Useful whenever you need to provide [PlatformVersion] to other SDK and DPP methods. + /// The version is auto-detected from network responses and may change at runtime. + /// Falls back to [`PlatformVersion::latest()`] if the stored version number is unknown. pub fn version<'v>(&self) -> &'v PlatformVersion { - match &self.inner { - SdkInstance::Dapi { version, .. } => version, - #[cfg(feature = "mocks")] - SdkInstance::Mock { version, .. } => version, - } + let v = self.protocol_version.load(Ordering::Relaxed); + PlatformVersion::get(v).unwrap_or_else(|_| PlatformVersion::latest()) + } + + /// Return the raw protocol version number currently used by this SDK. + pub fn protocol_version_number(&self) -> u32 { + self.protocol_version.load(Ordering::Relaxed) } // TODO: Move to settings @@ -890,11 +945,12 @@ impl SdkBuilder { let mut sdk= Sdk{ network: self.network, dapi_client_settings, - inner:SdkInstance::Dapi { dapi, version:self.version }, + inner:SdkInstance::Dapi { dapi }, proofs:self.proofs, context_provider: ArcSwapOption::new( self.context_provider.map(Arc::new)), cancel_token: self.cancel_token, nonce_cache: Default::default(), + protocol_version: Arc::new(atomic::AtomicU32::new(self.version.protocol_version)), // Note: in the future, we need to securely initialize initial height during Sdk bootstrap or first request. metadata_last_seen_height: Arc::new(atomic::AtomicU64::new(0)), metadata_height_tolerance: self.metadata_height_tolerance, @@ -957,11 +1013,11 @@ impl SdkBuilder { mock:mock_sdk.clone(), dapi, address_list: AddressList::new(), - version: self.version, }, dump_dir: self.dump_dir.clone(), proofs:self.proofs, nonce_cache: Default::default(), + protocol_version: Arc::new(atomic::AtomicU32::new(self.version.protocol_version)), context_provider: ArcSwapOption::new(Some(Arc::new(context_provider))), cancel_token: self.cancel_token, metadata_last_seen_height: Arc::new(atomic::AtomicU64::new(0)), @@ -1137,6 +1193,130 @@ mod test { .expect_err("metadata should be invalid"); } + #[test] + fn test_version_update_from_metadata() { + use dpp::version::PlatformVersion; + + let sdk = SdkBuilder::new_mock() + .with_version(PlatformVersion::get(1).unwrap()) + .build() + .expect("mock Sdk should be created"); + + assert_eq!(sdk.protocol_version_number(), 1); + + let metadata = ResponseMetadata { + protocol_version: 2, + height: 1, + ..Default::default() + }; + + sdk.verify_response_metadata("test", &metadata) + .expect("metadata should be valid"); + + assert_eq!(sdk.protocol_version_number(), 2); + assert_eq!(sdk.version().protocol_version, 2); + } + + #[test] + fn test_unknown_version_ignored() { + use dpp::version::PlatformVersion; + + let latest = PlatformVersion::latest(); + let sdk = SdkBuilder::new_mock() + .with_version(latest) + .build() + .expect("mock Sdk should be created"); + + let original_version = sdk.protocol_version_number(); + + let metadata = ResponseMetadata { + protocol_version: 999, + height: 1, + ..Default::default() + }; + + sdk.verify_response_metadata("test", &metadata) + .expect("metadata should be valid"); + + assert_eq!(sdk.protocol_version_number(), original_version); + assert_eq!(sdk.version().protocol_version, original_version); + } + + #[test] + fn test_version_shared_between_clones() { + use dpp::version::PlatformVersion; + + let sdk = SdkBuilder::new_mock() + .with_version(PlatformVersion::get(1).unwrap()) + .build() + .expect("mock Sdk should be created"); + + let clone = sdk.clone(); + + let metadata = ResponseMetadata { + protocol_version: 2, + height: 1, + ..Default::default() + }; + + clone + .verify_response_metadata("test", &metadata) + .expect("metadata should be valid"); + + assert_eq!( + sdk.protocol_version_number(), + 2, + "original should see update from clone" + ); + } + + #[test] + fn test_version_downgrade_ignored() { + use dpp::version::PlatformVersion; + + let sdk = SdkBuilder::new_mock() + .with_version(PlatformVersion::get(2).unwrap()) + .build() + .expect("mock Sdk should be created"); + + assert_eq!(sdk.protocol_version_number(), 2); + + let metadata = ResponseMetadata { + protocol_version: 1, + height: 1, + ..Default::default() + }; + + sdk.verify_response_metadata("test", &metadata) + .expect("metadata should be valid"); + + assert_eq!(sdk.protocol_version_number(), 2); + } + + #[test] + fn test_version_zero_ignored() { + use dpp::version::PlatformVersion; + + let latest = PlatformVersion::latest(); + let sdk = SdkBuilder::new_mock() + .with_version(latest) + .build() + .expect("mock Sdk should be created"); + + let original_version = sdk.protocol_version_number(); + + let metadata = ResponseMetadata { + protocol_version: 0, + height: 1, + ..Default::default() + }; + + sdk.verify_response_metadata("test", &metadata) + .expect("metadata should be valid"); + + assert_eq!(sdk.protocol_version_number(), original_version); + } + #[test_matrix([90,91,100,109,110], 100, 10, false; "valid time")] #[test_matrix([0,89,111], 100, 10, true; "invalid time")] #[test_matrix([0,100], [0,100], 100, false; "zero time")] From acf0e6e042493f024805374d2f041f4ff9357835 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:55:48 +0200 Subject: [PATCH 02/12] test(sdk): add TC-6 concurrent updates and TC-7 global DPP version sync tests Cover the two remaining test cases for protocol version auto-detection: concurrent thread races converge to highest version, and PlatformVersionCurrentVersion::get_current() is synced after update. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/rs-sdk/src/sdk.rs | 67 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index a11c33a299..318042d561 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -1317,6 +1317,73 @@ mod test { assert_eq!(sdk.protocol_version_number(), original_version); } + #[test] + fn test_concurrent_updates_converge_to_highest() { + use dpp::version::PlatformVersion; + use std::thread; + + let sdk = SdkBuilder::new_mock() + .with_version(PlatformVersion::get(1).unwrap()) + .build() + .expect("mock Sdk should be created"); + + assert_eq!(sdk.protocol_version_number(), 1); + + let mut handles = Vec::new(); + // Spawn threads that race to update to version 2 and version 3 + for version in [2u32, 3, 2, 3, 2, 3] { + let sdk_clone = sdk.clone(); + handles.push(thread::spawn(move || { + let metadata = ResponseMetadata { + protocol_version: version, + height: 1, + ..Default::default() + }; + sdk_clone + .verify_response_metadata("test", &metadata) + .expect("metadata should be valid"); + })); + } + + for h in handles { + h.join().expect("thread should not panic"); + } + + // Highest known version (3) must win regardless of thread ordering + assert_eq!( + sdk.protocol_version_number(), + 3, + "concurrent updates must converge to highest version" + ); + } + + #[test] + fn test_global_dpp_version_synced_after_update() { + use dpp::version::{PlatformVersion, PlatformVersionCurrentVersion}; + + let sdk = SdkBuilder::new_mock() + .with_version(PlatformVersion::get(1).unwrap()) + .build() + .expect("mock Sdk should be created"); + + let metadata = ResponseMetadata { + protocol_version: 2, + height: 1, + ..Default::default() + }; + + sdk.verify_response_metadata("test", &metadata) + .expect("metadata should be valid"); + + let global: &PlatformVersion = PlatformVersionCurrentVersion::get_current() + .expect("global version should be set"); + assert_eq!( + global.protocol_version, + sdk.protocol_version_number(), + "global DPP version must match SDK version after update" + ); + } + #[test_matrix([90,91,100,109,110], 100, 10, false; "valid time")] #[test_matrix([0,89,111], 100, 10, true; "invalid time")] #[test_matrix([0,100], [0,100], 100, false; "zero time")] From 036ba3bb7fcccfc26a7bbda5da8b1f7f33c8567a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Apr 2026 11:00:56 +0200 Subject: [PATCH 03/12] fix(sdk): fix TC-7 test race and formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relax test_global_dpp_version_synced_after_update assertion to account for concurrent test execution — global PlatformVersion is process-wide, so parallel tests may push it higher. Assert >= instead of ==. Co-Authored-By: Claude Opus 4.6 --- packages/rs-sdk/src/sdk.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index 318042d561..3cfa9f9602 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -1375,12 +1375,15 @@ mod test { sdk.verify_response_metadata("test", &metadata) .expect("metadata should be valid"); - let global: &PlatformVersion = PlatformVersionCurrentVersion::get_current() - .expect("global version should be set"); - assert_eq!( + let global: &PlatformVersion = + PlatformVersionCurrentVersion::get_current().expect("global version should be set"); + // The global is process-wide (last writer wins), so concurrent tests + // may have pushed it higher. We only assert it's at least the version + // we set — not an exact match. + assert!( + global.protocol_version >= 2, + "global DPP version must be at least the version we set (2), got {}", global.protocol_version, - sdk.protocol_version_number(), - "global DPP version must match SDK version after update" ); } From 2624d3aa9217bfc5dedf3eb1bf0b842a362c76ff Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:22:30 +0200 Subject: [PATCH 04/12] feat(sdk): disable version auto-detect when version is explicitly set SdkBuilder::with_version() now sets auto_detect_protocol_version=false, preventing the SDK from overriding a user-pinned version with network metadata. Default behavior (no with_version() call) still auto-detects. Co-Authored-By: Claude Opus 4.6 --- packages/rs-sdk/src/sdk.rs | 102 +++++++++++++++++++++++-------------- 1 file changed, 65 insertions(+), 37 deletions(-) diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index 3cfa9f9602..9e00511e8e 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -110,6 +110,10 @@ pub struct Sdk { /// Protocol version number detected from the network. Shared between clones. protocol_version: Arc, + /// Whether to auto-detect protocol version from network response metadata. + /// Set to `false` when the user explicitly calls [`SdkBuilder::with_version()`]. + auto_detect_protocol_version: bool, + /// Last seen height; used to determine if the remote node is stale. /// /// This is clone-able and can be shared between threads. @@ -144,6 +148,7 @@ impl Clone for Sdk { context_provider: ArcSwapOption::new(self.context_provider.load_full()), cancel_token: self.cancel_token.clone(), protocol_version: Arc::clone(&self.protocol_version), + auto_detect_protocol_version: self.auto_detect_protocol_version, metadata_last_seen_height: Arc::clone(&self.metadata_last_seen_height), metadata_height_tolerance: self.metadata_height_tolerance, metadata_time_tolerance_ms: self.metadata_time_tolerance_ms, @@ -260,6 +265,10 @@ impl Sdk { /// In multi-SDK scenarios the `PlatformVersion::set_current` global is process-wide /// (last writer wins). fn maybe_update_protocol_version(&self, received_version: u32) { + if !self.auto_detect_protocol_version { + return; + } + if received_version == 0 { return; } @@ -624,6 +633,10 @@ pub struct SdkBuilder { /// Platform version to use in this Sdk version: &'static PlatformVersion, + /// Whether the user explicitly called `with_version()`. + /// When true, auto-detection of protocol version from network metadata is disabled. + version_explicit: bool, + /// Cache size for data contracts. Used by mock [GrpcContextProvider]. #[cfg(feature = "mocks")] data_contract_cache_size: NonZeroUsize, @@ -695,6 +708,7 @@ impl Default for SdkBuilder { cancel_token: CancellationToken::new(), version: PlatformVersion::latest(), + version_explicit: false, #[cfg(not(target_arch = "wasm32"))] ca_certificate: None, @@ -818,6 +832,7 @@ impl SdkBuilder { /// Defaults to [PlatformVersion::latest()]. pub fn with_version(mut self, version: &'static PlatformVersion) -> Self { self.version = version; + self.version_explicit = true; self } @@ -951,6 +966,7 @@ impl SdkBuilder { cancel_token: self.cancel_token, nonce_cache: Default::default(), protocol_version: Arc::new(atomic::AtomicU32::new(self.version.protocol_version)), + auto_detect_protocol_version: !self.version_explicit, // Note: in the future, we need to securely initialize initial height during Sdk bootstrap or first request. metadata_last_seen_height: Arc::new(atomic::AtomicU64::new(0)), metadata_height_tolerance: self.metadata_height_tolerance, @@ -1018,6 +1034,7 @@ impl SdkBuilder { proofs:self.proofs, nonce_cache: Default::default(), protocol_version: Arc::new(atomic::AtomicU32::new(self.version.protocol_version)), + auto_detect_protocol_version: !self.version_explicit, context_provider: ArcSwapOption::new(Some(Arc::new(context_provider))), cancel_token: self.cancel_token, metadata_last_seen_height: Arc::new(atomic::AtomicU64::new(0)), @@ -1193,14 +1210,22 @@ mod test { .expect_err("metadata should be invalid"); } - #[test] - fn test_version_update_from_metadata() { - use dpp::version::PlatformVersion; + /// Helper: build a mock SDK with auto-detect enabled and a specific starting version. + /// Does NOT call `with_version()` (which would disable auto-detect). + fn mock_sdk_with_auto_detect(starting_version: u32) -> super::Sdk { + use std::sync::atomic::Ordering; let sdk = SdkBuilder::new_mock() - .with_version(PlatformVersion::get(1).unwrap()) .build() .expect("mock Sdk should be created"); + sdk.protocol_version + .store(starting_version, Ordering::Relaxed); + sdk + } + + #[test] + fn test_version_update_from_metadata() { + let sdk = mock_sdk_with_auto_detect(1); assert_eq!(sdk.protocol_version_number(), 1); @@ -1221,12 +1246,7 @@ mod test { fn test_unknown_version_ignored() { use dpp::version::PlatformVersion; - let latest = PlatformVersion::latest(); - let sdk = SdkBuilder::new_mock() - .with_version(latest) - .build() - .expect("mock Sdk should be created"); - + let sdk = mock_sdk_with_auto_detect(PlatformVersion::latest().protocol_version); let original_version = sdk.protocol_version_number(); let metadata = ResponseMetadata { @@ -1244,12 +1264,7 @@ mod test { #[test] fn test_version_shared_between_clones() { - use dpp::version::PlatformVersion; - - let sdk = SdkBuilder::new_mock() - .with_version(PlatformVersion::get(1).unwrap()) - .build() - .expect("mock Sdk should be created"); + let sdk = mock_sdk_with_auto_detect(1); let clone = sdk.clone(); @@ -1272,12 +1287,7 @@ mod test { #[test] fn test_version_downgrade_ignored() { - use dpp::version::PlatformVersion; - - let sdk = SdkBuilder::new_mock() - .with_version(PlatformVersion::get(2).unwrap()) - .build() - .expect("mock Sdk should be created"); + let sdk = mock_sdk_with_auto_detect(2); assert_eq!(sdk.protocol_version_number(), 2); @@ -1297,12 +1307,7 @@ mod test { fn test_version_zero_ignored() { use dpp::version::PlatformVersion; - let latest = PlatformVersion::latest(); - let sdk = SdkBuilder::new_mock() - .with_version(latest) - .build() - .expect("mock Sdk should be created"); - + let sdk = mock_sdk_with_auto_detect(PlatformVersion::latest().protocol_version); let original_version = sdk.protocol_version_number(); let metadata = ResponseMetadata { @@ -1319,13 +1324,9 @@ mod test { #[test] fn test_concurrent_updates_converge_to_highest() { - use dpp::version::PlatformVersion; use std::thread; - let sdk = SdkBuilder::new_mock() - .with_version(PlatformVersion::get(1).unwrap()) - .build() - .expect("mock Sdk should be created"); + let sdk = mock_sdk_with_auto_detect(1); assert_eq!(sdk.protocol_version_number(), 1); @@ -1361,10 +1362,7 @@ mod test { fn test_global_dpp_version_synced_after_update() { use dpp::version::{PlatformVersion, PlatformVersionCurrentVersion}; - let sdk = SdkBuilder::new_mock() - .with_version(PlatformVersion::get(1).unwrap()) - .build() - .expect("mock Sdk should be created"); + let sdk = mock_sdk_with_auto_detect(1); let metadata = ResponseMetadata { protocol_version: 2, @@ -1387,6 +1385,36 @@ mod test { ); } + #[test] + fn test_explicit_version_disables_auto_detect() { + use dpp::version::PlatformVersion; + + // Explicitly pin to version 1 via with_version() + let sdk = SdkBuilder::new_mock() + .with_version(PlatformVersion::get(1).unwrap()) + .build() + .expect("mock Sdk should be created"); + + assert_eq!(sdk.protocol_version_number(), 1); + assert!(!sdk.auto_detect_protocol_version); + + // Network reports version 2 — should be ignored because version is pinned + let metadata = ResponseMetadata { + protocol_version: 2, + height: 1, + ..Default::default() + }; + + sdk.verify_response_metadata("test", &metadata) + .expect("metadata should be valid"); + + assert_eq!( + sdk.protocol_version_number(), + 1, + "pinned version must not be auto-updated" + ); + } + #[test_matrix([90,91,100,109,110], 100, 10, false; "valid time")] #[test_matrix([0,89,111], 100, 10, true; "invalid time")] #[test_matrix([0,100], [0,100], 100, false; "zero time")] From 218f474d8d4eae05ea4a0c8d6d045d8f5f2c32d0 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:06:04 +0200 Subject: [PATCH 05/12] refactor(sdk): simplify version update with fetch_max instead of CAS loop Replace compare_exchange_weak loop with a single fetch_max atomic operation. Versions are monotonically increasing, so fetch_max is sufficient and much simpler. Co-Authored-By: Claude Opus 4.6 --- packages/rs-sdk/src/sdk.rs | 37 ++++++++++++------------------------- 1 file changed, 12 insertions(+), 25 deletions(-) diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index 9e00511e8e..218642c998 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -261,7 +261,7 @@ impl Sdk { /// Update the stored protocol version if `received_version` is newer and known. /// - /// Uses a CAS loop so the highest version always wins under concurrent updates. + /// Uses `fetch_max` so the highest version always wins under concurrent updates. /// In multi-SDK scenarios the `PlatformVersion::set_current` global is process-wide /// (last writer wins). fn maybe_update_protocol_version(&self, received_version: u32) { @@ -273,7 +273,7 @@ impl Sdk { return; } - let mut current = self.protocol_version.load(Ordering::Relaxed); + let current = self.protocol_version.load(Ordering::Relaxed); if received_version <= current { return; @@ -291,29 +291,16 @@ impl Sdk { } }; - loop { - match self.protocol_version.compare_exchange_weak( - current, - received_version, - Ordering::Relaxed, - Ordering::Relaxed, - ) { - Ok(_) => { - tracing::info!( - old_version = current, - new_version = received_version, - "protocol version updated from network metadata" - ); - PlatformVersion::set_current(new_version); - return; - } - Err(actual) => { - if actual >= received_version { - return; - } - current = actual; - } - } + let previous = self + .protocol_version + .fetch_max(received_version, Ordering::Relaxed); + if previous < received_version { + tracing::info!( + old_version = previous, + new_version = received_version, + "protocol version updated from network metadata" + ); + PlatformVersion::set_current(new_version); } } From 7859df4110880c6d9af3407b10a6cb84e53f1942 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:14:29 +0200 Subject: [PATCH 06/12] refactor(sdk): remove PlatformVersion::set_current from SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The global CURRENT_PLATFORM_VERSION (process-wide RwLock) is not read by any SDK runtime code path — all SDK code passes &PlatformVersion explicitly. Removing set_current() makes multi-SDK scenarios clean: each instance tracks its own version independently. Co-Authored-By: Claude Opus 4.6 --- packages/rs-sdk/src/sdk.rs | 57 ++++++++++---------------------------- 1 file changed, 14 insertions(+), 43 deletions(-) diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index 218642c998..c3c05ac899 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -19,7 +19,7 @@ use dpp::bincode; use dpp::bincode::error::DecodeError; use dpp::dashcore::Network; use dpp::prelude::IdentityNonce; -use dpp::version::{PlatformVersion, PlatformVersionCurrentVersion}; +use dpp::version::PlatformVersion; use drive::grovedb::operations::proof::GroveDBProof; use drive_proof_verifier::FromProof; pub use http::Uri; @@ -262,8 +262,8 @@ impl Sdk { /// Update the stored protocol version if `received_version` is newer and known. /// /// Uses `fetch_max` so the highest version always wins under concurrent updates. - /// In multi-SDK scenarios the `PlatformVersion::set_current` global is process-wide - /// (last writer wins). + /// The version is stored per-SDK instance (not in the process-wide global), + /// so multiple SDK instances can track different networks independently. fn maybe_update_protocol_version(&self, received_version: u32) { if !self.auto_detect_protocol_version { return; @@ -279,17 +279,15 @@ impl Sdk { return; } - let new_version = match PlatformVersion::get(received_version) { - Ok(v) => v, - Err(_) => { - tracing::warn!( - received_version, - current_version = current, - "received unknown protocol version from network; keeping current" - ); - return; - } - }; + // Validate that we know this version before accepting it + if PlatformVersion::get(received_version).is_err() { + tracing::warn!( + received_version, + current_version = current, + "received unknown protocol version from network; keeping current" + ); + return; + } let previous = self .protocol_version @@ -300,7 +298,6 @@ impl Sdk { new_version = received_version, "protocol version updated from network metadata" ); - PlatformVersion::set_current(new_version); } } @@ -923,8 +920,6 @@ impl SdkBuilder { /// /// This method will return an error if the Sdk cannot be created. pub fn build(self) -> Result { - PlatformVersion::set_current(self.version); - let dapi_client_settings = match self.settings { Some(settings) => DEFAULT_REQUEST_SETTINGS.override_by(settings), None => DEFAULT_REQUEST_SETTINGS, @@ -1345,32 +1340,8 @@ mod test { ); } - #[test] - fn test_global_dpp_version_synced_after_update() { - use dpp::version::{PlatformVersion, PlatformVersionCurrentVersion}; - - let sdk = mock_sdk_with_auto_detect(1); - - let metadata = ResponseMetadata { - protocol_version: 2, - height: 1, - ..Default::default() - }; - - sdk.verify_response_metadata("test", &metadata) - .expect("metadata should be valid"); - - let global: &PlatformVersion = - PlatformVersionCurrentVersion::get_current().expect("global version should be set"); - // The global is process-wide (last writer wins), so concurrent tests - // may have pushed it higher. We only assert it's at least the version - // we set — not an exact match. - assert!( - global.protocol_version >= 2, - "global DPP version must be at least the version we set (2), got {}", - global.protocol_version, - ); - } + // TC-7 (global DPP version sync) removed — set_current() is no longer called + // from the SDK. Version is stored per-instance, not in the process-wide global. #[test] fn test_explicit_version_disables_auto_detect() { From 6d5935883896f012a8b166f47156566a177d4009 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:00:24 +0200 Subject: [PATCH 07/12] fix(sdk): seed auto-detect version at 0 so first response sets actual network version Default-built SDK was seeded at PlatformVersion::latest(), so it could never detect an older network version. Now seeds at 0 (uninitialized) when auto-detect is enabled. version() returns latest() as fallback until the first network response arrives and sets the real version. Co-Authored-By: Claude Opus 4.6 --- packages/rs-sdk/src/sdk.rs | 55 ++++++++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index c3c05ac899..fab3512139 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -275,7 +275,9 @@ impl Sdk { let current = self.protocol_version.load(Ordering::Relaxed); - if received_version <= current { + // current == 0 means uninitialized (auto-detect mode, first response). + // Accept any valid version in that case; after that, only increases. + if current > 0 && received_version <= current { return; } @@ -430,8 +432,9 @@ impl Sdk { /// Return [Dash Platform version](PlatformVersion) information used by this SDK. /// - /// The version is auto-detected from network responses and may change at runtime. - /// Falls back to [`PlatformVersion::latest()`] if the stored version number is unknown. + /// When auto-detection is enabled (default), returns [`PlatformVersion::latest()`] + /// until the first network response is received, then tracks the network's version. + /// When pinned via [`SdkBuilder::with_version()`], always returns the pinned version. pub fn version<'v>(&self) -> &'v PlatformVersion { let v = self.protocol_version.load(Ordering::Relaxed); PlatformVersion::get(v).unwrap_or_else(|_| PlatformVersion::latest()) @@ -947,7 +950,12 @@ impl SdkBuilder { context_provider: ArcSwapOption::new( self.context_provider.map(Arc::new)), cancel_token: self.cancel_token, nonce_cache: Default::default(), - protocol_version: Arc::new(atomic::AtomicU32::new(self.version.protocol_version)), + // When auto-detecting, seed with 0 (uninitialized) so the first + // network response sets the actual version — even if it's lower + // than the binary's latest. When pinned, use the explicit version. + protocol_version: Arc::new(atomic::AtomicU32::new( + if self.version_explicit { self.version.protocol_version } else { 0 }, + )), auto_detect_protocol_version: !self.version_explicit, // Note: in the future, we need to securely initialize initial height during Sdk bootstrap or first request. metadata_last_seen_height: Arc::new(atomic::AtomicU64::new(0)), @@ -1015,7 +1023,9 @@ impl SdkBuilder { dump_dir: self.dump_dir.clone(), proofs:self.proofs, nonce_cache: Default::default(), - protocol_version: Arc::new(atomic::AtomicU32::new(self.version.protocol_version)), + protocol_version: Arc::new(atomic::AtomicU32::new( + if self.version_explicit { self.version.protocol_version } else { 0 }, + )), auto_detect_protocol_version: !self.version_explicit, context_provider: ArcSwapOption::new(Some(Arc::new(context_provider))), cancel_token: self.cancel_token, @@ -1373,6 +1383,41 @@ mod test { ); } + #[test] + fn test_default_sdk_detects_older_network_version() { + use dpp::version::PlatformVersion; + + // Default SDK: auto-detect enabled, seeded at 0 (uninitialized) + let sdk = SdkBuilder::new_mock() + .build() + .expect("mock Sdk should be created"); + + // Before any network response, version() falls back to latest() + assert_eq!( + sdk.version().protocol_version, + PlatformVersion::latest().protocol_version, + "before first response, should fall back to latest" + ); + assert_eq!(sdk.protocol_version_number(), 0, "should be uninitialized"); + + // Network reports version 1 (older than latest) — should be accepted + let metadata = ResponseMetadata { + protocol_version: 1, + height: 1, + ..Default::default() + }; + + sdk.verify_response_metadata("test", &metadata) + .expect("metadata should be valid"); + + assert_eq!( + sdk.protocol_version_number(), + 1, + "default SDK must detect older network version" + ); + assert_eq!(sdk.version().protocol_version, 1); + } + #[test_matrix([90,91,100,109,110], 100, 10, false; "valid time")] #[test_matrix([0,89,111], 100, 10, true; "invalid time")] #[test_matrix([0,100], [0,100], 100, false; "zero time")] From b01969d24a92e7638a67de9a82144ed4311f0b84 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:06:55 +0200 Subject: [PATCH 08/12] refactor(sdk): remove redundant current > 0 guard in version update The received_version == 0 early return already guarantees received_version >= 1, so received_version <= 0 can never be true. Co-Authored-By: Claude Opus 4.6 --- packages/rs-sdk/src/sdk.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index fab3512139..520f279dec 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -275,9 +275,7 @@ impl Sdk { let current = self.protocol_version.load(Ordering::Relaxed); - // current == 0 means uninitialized (auto-detect mode, first response). - // Accept any valid version in that case; after that, only increases. - if current > 0 && received_version <= current { + if received_version <= current { return; } From 1085027e450a89366d6fe1433767c91e19a39c42 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 14 Apr 2026 09:28:07 +0200 Subject: [PATCH 09/12] fix(sdk): restore PlatformVersion::latest() in mock test helper and document first-request version-lag `SdkBuilder::build()` no longer calls `PlatformVersion::set_current()`, so `mock_document_type()` was panicking on `get_current().unwrap()`. Switch to `PlatformVersion::latest()` directly (consistent with `mock_data_contract()`). Also documents the known bootstrap limitation in `parse_proof_with_metadata_and_proof`: the first request on a fresh auto-detect SDK parses proofs with `PlatformVersion::latest()` fallback before the real network version is learned from response metadata. Co-Authored-By: Claude Sonnet 4.6 --- packages/rs-sdk/src/sdk.rs | 16 ++++++++++++++++ packages/rs-sdk/tests/fetch/common.rs | 8 ++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index 520f279dec..112f332710 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -310,6 +310,22 @@ impl Sdk { /// /// - `R`: Type of the request that was used to fetch the proof. /// - `O`: Type of the object to be retrieved from the proof. + /// + /// ## Protocol version bootstrapping + /// + /// On a fresh auto-detect SDK (i.e. one built without [`SdkBuilder::with_version()`]), the + /// first call to this method uses [`PlatformVersion::latest()`] as a fallback because no + /// network response has been received yet to teach the SDK the real network version. + /// + /// The actual network version is learned only *after* proof parsing succeeds, when + /// [`Self::verify_response_metadata()`] processes `metadata.protocol_version`. If the + /// connected network runs an older protocol version **and** proof interpretation differs + /// between that version and `latest()`, the very first request may fail before the SDK can + /// correct itself. Subsequent requests will use the correct version. + /// + /// This is a known bootstrap limitation. Callers that must guarantee correct version + /// behaviour on the first request should pin the version explicitly via + /// [`SdkBuilder::with_version()`]. pub(crate) async fn parse_proof_with_metadata_and_proof + MockResponse>( &self, request: O::Request, diff --git a/packages/rs-sdk/tests/fetch/common.rs b/packages/rs-sdk/tests/fetch/common.rs index 05231bd916..052b8b67a1 100644 --- a/packages/rs-sdk/tests/fetch/common.rs +++ b/packages/rs-sdk/tests/fetch/common.rs @@ -25,12 +25,12 @@ fn should_emit_test_logs_to_stdout() -> bool { /// Create a mock document type for testing of mock API pub fn mock_document_type() -> dpp::data_contract::document_type::DocumentType { use dpp::{ - data_contract::document_type::DocumentType, - platform_value::platform_value, - version::{PlatformVersion, PlatformVersionCurrentVersion}, + data_contract::document_type::DocumentType, platform_value::platform_value, + version::PlatformVersion, }; - let platform_version = PlatformVersion::get_current().unwrap(); + // `set_current()` is no longer called by the SDK builder; use `latest()` directly. + let platform_version = PlatformVersion::latest(); let schema = platform_value!({ "type": "object", From 99ccac72eb816410e32bf714cc6f5793f3d3f55b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 14 Apr 2026 09:40:46 +0200 Subject: [PATCH 10/12] fix(sdk): restore PlatformVersion::set_current() in SdkBuilder::build() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing the set_current() call broke DPP deserialization paths that read the process-wide current platform version via get_current() (e.g. DataContract serde, document query mock data loading). Restore it at the top of build() so the global is always initialized before any serialization or mock expectation loading occurs. In explicit-version mode this sets the pinned version; in auto-detect mode it sets PlatformVersion::latest() — the same fallback Sdk::version() returns before the first network response arrives. Fixes: fetch::document tests failing with "current platform version not initialized" Co-Authored-By: Claude Sonnet 4.6 --- packages/rs-sdk/src/sdk.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index 112f332710..10dc1d34e7 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -19,7 +19,7 @@ use dpp::bincode; use dpp::bincode::error::DecodeError; use dpp::dashcore::Network; use dpp::prelude::IdentityNonce; -use dpp::version::PlatformVersion; +use dpp::version::{PlatformVersion, PlatformVersionCurrentVersion}; use drive::grovedb::operations::proof::GroveDBProof; use drive_proof_verifier::FromProof; pub use http::Uri; @@ -937,6 +937,13 @@ impl SdkBuilder { /// /// This method will return an error if the Sdk cannot be created. pub fn build(self) -> Result { + // Initialize the process-wide current platform version so that DPP serialization / + // deserialization code (which calls `PlatformVersion::get_current()`) works correctly. + // In explicit-version mode this is the pinned version; in auto-detect mode it is + // `PlatformVersion::latest()`, the same fallback that `Sdk::version()` returns + // before the first network response is received. + PlatformVersion::set_current(self.version); + let dapi_client_settings = match self.settings { Some(settings) => DEFAULT_REQUEST_SETTINGS.override_by(settings), None => DEFAULT_REQUEST_SETTINGS, From 517594e8b40d893e7a55693701d1785cd6fa86f1 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:08:32 +0200 Subject: [PATCH 11/12] refactor(dpp): use get_version_or_current_or_latest in DataContract serde impls Replace all `get_current()` calls in DataContract/DataContractV0/DataContractV1 serde impls with `get_version_or_current_or_latest(None)`, which falls back to `PlatformVersion::latest()` when no global version is set. This removes the dependency on `set_current()` being called before serde operations, allowing the SDK to drop that call from `SdkBuilder::build()`. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../rs-dpp/src/data_contract/conversion/serde/mod.rs | 12 ++++-------- .../rs-dpp/src/data_contract/v0/serialization/mod.rs | 8 ++------ .../rs-dpp/src/data_contract/v1/serialization/mod.rs | 8 ++------ packages/rs-sdk/src/sdk.rs | 9 +-------- 4 files changed, 9 insertions(+), 28 deletions(-) diff --git a/packages/rs-dpp/src/data_contract/conversion/serde/mod.rs b/packages/rs-dpp/src/data_contract/conversion/serde/mod.rs index 0e9d58223b..a2106f394c 100644 --- a/packages/rs-dpp/src/data_contract/conversion/serde/mod.rs +++ b/packages/rs-dpp/src/data_contract/conversion/serde/mod.rs @@ -11,8 +11,8 @@ impl Serialize for DataContract { where S: Serializer, { - let current_version = - PlatformVersion::get_current().map_err(|e| serde::ser::Error::custom(e.to_string()))?; + let current_version = PlatformVersion::get_version_or_current_or_latest(None) + .map_err(|e| serde::ser::Error::custom(e.to_string()))?; let data_contract_in_serialization_format: DataContractInSerializationFormat = self .try_into_platform_versioned(current_version) .map_err(|e: ProtocolError| serde::ser::Error::custom(format!("expected to be able to serialize data contract into its serialized version: {}", e)))?; @@ -26,12 +26,8 @@ impl<'de> Deserialize<'de> for DataContract { D: Deserializer<'de>, { let serialization_format = DataContractInSerializationFormat::deserialize(deserializer)?; - let current_version = PlatformVersion::get_current().map_err(|e| { - serde::de::Error::custom(format!( - "expected to be able to get current platform version: {}", - e - )) - })?; + let current_version = PlatformVersion::get_version_or_current_or_latest(None) + .map_err(|e| serde::de::Error::custom(e.to_string()))?; // when deserializing from json/platform_value/cbor we always want to validate (as this is not coming from the state) DataContract::try_from_platform_versioned( serialization_format, diff --git a/packages/rs-dpp/src/data_contract/v0/serialization/mod.rs b/packages/rs-dpp/src/data_contract/v0/serialization/mod.rs index 938a72d114..0c45d29453 100644 --- a/packages/rs-dpp/src/data_contract/v0/serialization/mod.rs +++ b/packages/rs-dpp/src/data_contract/v0/serialization/mod.rs @@ -28,12 +28,8 @@ impl<'de> Deserialize<'de> for DataContractV0 { D: Deserializer<'de>, { let serialization_format = DataContractInSerializationFormatV0::deserialize(deserializer)?; - let current_version = PlatformVersion::get_current().map_err(|e| { - serde::de::Error::custom(format!( - "expected to be able to get current platform version: {}", - e - )) - })?; + let current_version = PlatformVersion::get_version_or_current_or_latest(None) + .map_err(|e| serde::de::Error::custom(e.to_string()))?; // when deserializing from json/platform_value/cbor we always want to validate (as this is not coming from the state) DataContractV0::try_from_platform_versioned_v0( serialization_format, diff --git a/packages/rs-dpp/src/data_contract/v1/serialization/mod.rs b/packages/rs-dpp/src/data_contract/v1/serialization/mod.rs index 68c8151bee..a04d7ce29e 100644 --- a/packages/rs-dpp/src/data_contract/v1/serialization/mod.rs +++ b/packages/rs-dpp/src/data_contract/v1/serialization/mod.rs @@ -27,12 +27,8 @@ impl<'de> Deserialize<'de> for DataContractV1 { D: Deserializer<'de>, { let serialization_format = DataContractInSerializationFormatV1::deserialize(deserializer)?; - let current_version = PlatformVersion::get_current().map_err(|e| { - serde::de::Error::custom(format!( - "expected to be able to get current platform version: {}", - e - )) - })?; + let current_version = PlatformVersion::get_version_or_current_or_latest(None) + .map_err(|e| serde::de::Error::custom(e.to_string()))?; // when deserializing from json/platform_value/cbor we always want to validate (as this is not coming from the state) DataContractV1::try_from_platform_versioned_v1( serialization_format, diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index 10dc1d34e7..112f332710 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -19,7 +19,7 @@ use dpp::bincode; use dpp::bincode::error::DecodeError; use dpp::dashcore::Network; use dpp::prelude::IdentityNonce; -use dpp::version::{PlatformVersion, PlatformVersionCurrentVersion}; +use dpp::version::PlatformVersion; use drive::grovedb::operations::proof::GroveDBProof; use drive_proof_verifier::FromProof; pub use http::Uri; @@ -937,13 +937,6 @@ impl SdkBuilder { /// /// This method will return an error if the Sdk cannot be created. pub fn build(self) -> Result { - // Initialize the process-wide current platform version so that DPP serialization / - // deserialization code (which calls `PlatformVersion::get_current()`) works correctly. - // In explicit-version mode this is the pinned version; in auto-detect mode it is - // `PlatformVersion::latest()`, the same fallback that `Sdk::version()` returns - // before the first network response is received. - PlatformVersion::set_current(self.version); - let dapi_client_settings = match self.settings { Some(settings) => DEFAULT_REQUEST_SETTINGS.override_by(settings), None => DEFAULT_REQUEST_SETTINGS, From 37de63bffac6ae8e1b854fd5a69aca56e580f2bf Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:32:08 +0200 Subject: [PATCH 12/12] test(dpp): add JSON serde roundtrip tests for DataContractV0 and DataContractV1 Exercise the serde Deserialize impls (which call get_version_or_current_or_latest) via serde_json roundtrip, increasing patch coverage for the DPP serde change. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/data_contract/v0/serialization/mod.rs | 25 ++++++++++++++++++ .../src/data_contract/v1/serialization/mod.rs | 26 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/packages/rs-dpp/src/data_contract/v0/serialization/mod.rs b/packages/rs-dpp/src/data_contract/v0/serialization/mod.rs index 0c45d29453..2fcee85088 100644 --- a/packages/rs-dpp/src/data_contract/v0/serialization/mod.rs +++ b/packages/rs-dpp/src/data_contract/v0/serialization/mod.rs @@ -188,4 +188,29 @@ mod tests { .expect("expected to deserialize state transition"); assert_eq!(contract, recovered_contract); } + + #[test] + #[cfg(feature = "random-identities")] + fn data_contract_v0_serde_json_roundtrip() { + use crate::data_contract::accessors::v0::DataContractV0Getters; + use crate::data_contract::v0::DataContractV0; + + let platform_version = PlatformVersion::first(); + let identity = Identity::random_identity(5, Some(5), platform_version) + .expect("expected a random identity"); + let contract = + get_data_contract_fixture(Some(identity.id()), 0, platform_version.protocol_version) + .data_contract_owned(); + let v0 = contract.into_v0().expect("expected V0 contract"); + + let json = serde_json::to_string(&v0).expect("expected to serialize to JSON"); + let recovered: DataContractV0 = + serde_json::from_str(&json).expect("expected to deserialize from JSON"); + + // Schema normalization during deserialization means full equality may differ; + // verify stable identity fields to confirm a successful roundtrip. + assert_eq!(v0.id(), recovered.id()); + assert_eq!(v0.owner_id(), recovered.owner_id()); + assert_eq!(v0.version(), recovered.version()); + } } diff --git a/packages/rs-dpp/src/data_contract/v1/serialization/mod.rs b/packages/rs-dpp/src/data_contract/v1/serialization/mod.rs index a04d7ce29e..c044aaf083 100644 --- a/packages/rs-dpp/src/data_contract/v1/serialization/mod.rs +++ b/packages/rs-dpp/src/data_contract/v1/serialization/mod.rs @@ -218,4 +218,30 @@ mod tests { .expect("expected to deserialize state transition"); assert_eq!(contract, recovered_contract); } + + #[test] + #[cfg(feature = "random-identities")] + fn data_contract_v1_serde_json_roundtrip() { + use crate::data_contract::accessors::v0::DataContractV0Getters; + use crate::data_contract::DataContractV1; + + // V1 contracts are first produced at platform version 9 + let platform_version = PlatformVersion::get(9).expect("expected protocol version 9"); + let identity = Identity::random_identity(5, Some(5), platform_version) + .expect("expected a random identity"); + let contract = + get_data_contract_fixture(Some(identity.id()), 0, platform_version.protocol_version) + .data_contract_owned(); + let v1 = contract.into_v1().expect("expected V1 contract"); + + let json = serde_json::to_string(&v1).expect("expected to serialize to JSON"); + let recovered: DataContractV1 = + serde_json::from_str(&json).expect("expected to deserialize from JSON"); + + // Schema normalization during deserialization means full equality may differ; + // verify stable identity fields to confirm a successful roundtrip. + assert_eq!(v1.id(), recovered.id()); + assert_eq!(v1.owner_id(), recovered.owner_id()); + assert_eq!(v1.version(), recovered.version()); + } }