From f8e311985753d256524e7198432e5408dd6024df Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 15 Jun 2026 11:55:49 +0200 Subject: [PATCH 01/12] refactor(sdk): collapse per-network min_protocol_version into a single initial protocol version floor Replace the per-network min_protocol_version (mainnet 11, testnet/devnet/regtest 12) and its build()/post-refresh clamps with a single cross-network initial protocol version floor of 11 (DEFAULT_INITIAL_PROTOCOL_VERSION = PROTOCOL_VERSION_11). The initial version is now the ratchet floor; refresh_protocol_version() still ratchets up as the network's proven version is observed. Deliberate trade-off: non-mainnet networks (live on PV 12) now seed at 11 and rely on the proven-query refresh to climb to 12; until a refresh succeeds they sit one version low. Also resolves the v3.1-dev CI failure in dash-sdk's sdk_builder_default_seeds_atomic_to_floor caused by the #3809/#3886/#3893 protocol-version interaction. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/rs-sdk/src/sdk.rs | 295 ++++++++++++----------------- packages/rs-sdk/src/sdk/refresh.rs | 35 ++-- 2 files changed, 137 insertions(+), 193 deletions(-) diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index c24c0379e9..349e7a8b43 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -53,21 +53,33 @@ pub const DEFAULT_CONTRACT_CACHE_SIZE: usize = 100; pub const DEFAULT_TOKEN_CONFIG_CACHE_SIZE: usize = 100; /// How many quorum public keys fit in the cache. pub const DEFAULT_QUORUM_PUBLIC_KEYS_CACHE_SIZE: usize = 100; -/// Initial protocol version for the default auto-detect mode — i.e. when the -/// caller does not pin a [`PlatformVersion`] via [`SdkBuilder::with_version`]. +/// The single cross-network protocol-version floor an unpinned SDK starts at. /// -/// Set BELOW the latest version on purpose: ratchet-up autodetection -/// (`maybe_update_protocol_version`) converges to the network's real version, -/// so starting low keeps requests compatible with not-yet-upgraded nodes during -/// an upgrade window. Bump this constant as the network's supported floor advances. +/// Every network — mainnet, testnet, devnet, regtest — seeds the auto-detect +/// atomic at this one version at construction, and the stored version is never +/// allowed to drop below it. There is **deliberately no per-network minimum**: +/// the SDK does not try to know each network's live version up front. +/// +/// ## Deliberate design: one floor, ratchet up from there +/// +/// Mainnet is live on this version. Non-mainnet networks (testnet/devnet/regtest) +/// are live one version higher (12), so an unpinned SDK on those networks +/// **intentionally starts one version low** and relies on the proven-query +/// refresh ([`Sdk::refresh_protocol_version`], or the first ordinary proven +/// response) to ratchet up to the network's real version. This is an intentional +/// simplification: it trades a brief, self-correcting under-shoot on non-mainnet +/// for a single, network-agnostic constant instead of a per-network floor table. +/// Callers that cannot tolerate the bootstrap window should either pin via +/// [`SdkBuilder::with_version`] or call `refresh_protocol_version` before any +/// version-gated flow. Bump this constant as mainnet's live version advances. /// /// # v3.1+-only query surfaces /// -/// At the default floor the local encoder rejects the -/// v3.1+-only surfaces — `Count` (`SelectProjection::count_star`), `group_by`, -/// and `having` — with [`Error::Config`] *before* any network round-trip. To use -/// them either pin a higher version via [`SdkBuilder::with_version`] (which also -/// disables auto-detect), or issue one floor-compatible ratcheting query (no v3.1+ +/// At this floor the local encoder rejects the v3.1+-only surfaces — `Count` +/// (`SelectProjection::count_star`), `group_by`, and `having` — with +/// [`Error::Config`] *before* any network round-trip. To use them either pin a +/// higher version via [`SdkBuilder::with_version`] (which also disables +/// auto-detect), or issue one floor-compatible ratcheting query (no v3.1+ /// surfaces) right after `build()` — e.g. the `ExtendedEpochInfo::fetch_current` /// current-state fetch below. /// Its response metadata lifts the SDK to the network's version, after which `Count` / @@ -84,41 +96,7 @@ pub const DEFAULT_QUORUM_PUBLIC_KEYS_CACHE_SIZE: usize = 100; /// # Ok(()) /// # } /// ``` -pub const DEFAULT_INITIAL_PROTOCOL_VERSION: u32 = dpp::version::v10::PROTOCOL_VERSION_10; - -/// The hard per-network protocol-version floor the SDK must never drop below. -/// -/// Each network has a known minimum protocol version that is already live on -/// chain. The SDK clamps its stored protocol version up to this floor at -/// construction and again after every [`Sdk::refresh_protocol_version`], so even -/// before the first network round-trip (and even if that round-trip fails) the -/// version can never sit *below* what the network is already running. Returning -/// a too-low version would, for example, under-reserve fees for shielded-pool -/// flows that size their reserve from [`Sdk::version`]. -/// -/// This is a **lower bound, not a pin**: auto-detect -/// ([`Sdk::maybe_update_protocol_version`]) still ratchets the version *upward* -/// via `fetch_max` when the network reports a newer one. The floor only stops it -/// from going below the network's known minimum. -/// -/// Single source of truth for the floor lives here in `rs-sdk`; the FFI and -/// Swift layers call into the SDK and need no floor logic of their own. Bump the -/// per-network values here as each network's live minimum advances. -/// -/// ## Mapping -/// -/// - [`Network::Mainnet`] → 11 -/// - [`Network::Testnet`] → 12 -/// - [`Network::Devnet`] → 12 -/// - [`Network::Regtest`] → 12 -fn min_protocol_version(network: Network) -> u32 { - match network { - Network::Mainnet => dpp::version::v11::PROTOCOL_VERSION_11, - Network::Testnet => dpp::version::v12::PROTOCOL_VERSION_12, - Network::Devnet => dpp::version::v12::PROTOCOL_VERSION_12, - Network::Regtest => dpp::version::v12::PROTOCOL_VERSION_12, - } -} +pub const DEFAULT_INITIAL_PROTOCOL_VERSION: u32 = dpp::version::v11::PROTOCOL_VERSION_11; /// The default metadata time tolerance for checkpoint queries in milliseconds const ADDRESS_STATE_TIME_TOLERANCE_MS: u64 = 31 * 60 * 1000; @@ -553,13 +531,12 @@ impl Sdk { /// Return [Dash Platform version](PlatformVersion) information used by this SDK. /// - /// The version is floored at construction to at least the per-network minimum - /// protocol version (`min_protocol_version`), so it is never below the network's - /// known live version. With auto-detection (default) the SDK starts at - /// `max(DEFAULT_INITIAL_PROTOCOL_VERSION, network floor)` and then tracks the - /// network's version — auto-detection only ever ratchets *upward* (`fetch_max`). - /// A version pinned via [`SdkBuilder::with_version()`] is returned as pinned, - /// except that a pin below the network floor is raised to the floor at build time. + /// The version is floored at construction to at least + /// [`DEFAULT_INITIAL_PROTOCOL_VERSION`], so it is never below that cross-network + /// floor. With auto-detection (default) the SDK starts at that floor and then + /// tracks the network's version — auto-detection only ever ratchets *upward* + /// (`fetch_max`). A version pinned via [`SdkBuilder::with_version()`] is returned + /// as pinned, except that a pin below the floor is raised to it at build time. pub fn version<'v>(&self) -> &'v PlatformVersion { let v = self.protocol_version.load(Ordering::Relaxed); PlatformVersion::get(v).unwrap_or_else(|_| PlatformVersion::latest()) @@ -967,13 +944,12 @@ impl SdkBuilder { /// Select specific version of Dash Platform to use. This pins the version and /// disables auto-detection. /// - /// Note that [`build()`](Self::build) still clamps the pinned version up to the - /// per-network minimum (`min_protocol_version`): a pin below the network floor - /// is raised to the floor, so the SDK never starts below the network's known - /// version. A pin at or above the floor is used as-is. + /// Note that [`build()`](Self::build) still clamps the pinned version up to + /// [`DEFAULT_INITIAL_PROTOCOL_VERSION`]: a pin below that floor is raised to it, + /// so the SDK never starts below it. A pin at or above the floor is used as-is. /// - /// When unset, the SDK starts at `max(DEFAULT_INITIAL_PROTOCOL_VERSION, network - /// floor)` and ratchets upward via auto-detection. + /// When unset, the SDK starts at [`DEFAULT_INITIAL_PROTOCOL_VERSION`] and + /// ratchets upward via auto-detection. pub fn with_version(mut self, version: &'static PlatformVersion) -> Self { self.version = version; self.version_explicit = true; @@ -1106,14 +1082,14 @@ impl SdkBuilder { // Construction-time floor (clamp site 1 of 2; the other is // `Sdk::refresh_protocol_version`). Clamp the seeded version up to the - // per-network minimum so the SDK can never sit below the network's known - // live version, even before the first metadata-bearing response. This is a - // lower bound, not a pin: it applies to pinned and auto-detect SDKs alike, - // and auto-detect still ratchets upward from here via `fetch_max`. + // cross-network `DEFAULT_INITIAL_PROTOCOL_VERSION` so the SDK never starts + // below it on any network. This is a lower bound, not a pin: it applies to + // pinned and auto-detect SDKs alike, and auto-detect still ratchets upward + // from here via `fetch_max`. let initial_protocol_version = self .version .protocol_version - .max(min_protocol_version(self.network)); + .max(DEFAULT_INITIAL_PROTOCOL_VERSION); let sdk= match self.addresses { // non-mock mode @@ -1613,12 +1589,12 @@ mod test { fn test_explicit_version_disables_auto_detect() { use dpp::version::PlatformVersion; - // Pin at the mainnet floor (11) so the pin survives construction (the - // floor only clamps *up*; a sub-floor pin would be raised to 11). The + // Pin at the cross-network floor so the pin survives construction (the + // floor only clamps *up*; a sub-floor pin would be raised to it). The // network reporting a newer version must still be ignored, because the // pin disables auto-detect. - let pinned = PlatformVersion::get(super::min_protocol_version(Network::Mainnet)) - .expect("mainnet floor PV exists"); + let pinned = PlatformVersion::get(super::DEFAULT_INITIAL_PROTOCOL_VERSION) + .expect("initial-floor PV exists"); let sdk = SdkBuilder::new_mock() .with_version(pinned) .build() @@ -1648,12 +1624,12 @@ mod test { fn test_with_initial_version_seeds_to_older_network_version() { use dpp::version::PlatformVersion; - // Caller seeds the auto-detect atomic at the mainnet floor (11) — the - // oldest a *built* mainnet SDK can sit at, since construction clamps up to - // the floor. `version_explicit` stays false, so fetch_max can still ratchet + // Caller seeds the auto-detect atomic at the cross-network floor — the + // oldest a *built* SDK can sit at, since construction clamps up to the + // floor. `version_explicit` stays false, so fetch_max can still ratchet // upward when the network later moves to a newer PV. - let floor = super::min_protocol_version(Network::Mainnet); - let initial = PlatformVersion::get(floor).expect("mainnet floor PV exists"); + let floor = super::DEFAULT_INITIAL_PROTOCOL_VERSION; + let initial = PlatformVersion::get(floor).expect("initial-floor PV exists"); let sdk = SdkBuilder::new_mock() .with_initial_version(initial) .build() @@ -1682,10 +1658,7 @@ mod test { // And a newer network version still ratchets upward. let newer = dpp::version::v12::PROTOCOL_VERSION_12; - assert!( - newer > floor, - "ratchet target must exceed the mainnet floor" - ); + assert!(newer > floor, "ratchet target must exceed the floor"); let metadata = ResponseMetadata { protocol_version: newer, height: 2, @@ -1704,11 +1677,11 @@ mod test { // must re-enable auto-detect that an earlier `with_version` // disabled. // - // `v_old` sits at the mainnet floor (11) so the seed survives the + // `v_old` sits at the cross-network floor so the seed survives the // construction clamp and the last-write-wins effect stays observable. let v_latest = PlatformVersion::latest(); - let v_old = PlatformVersion::get(super::min_protocol_version(Network::Mainnet)) - .expect("mainnet floor PV exists"); + let v_old = PlatformVersion::get(super::DEFAULT_INITIAL_PROTOCOL_VERSION) + .expect("initial-floor PV exists"); assert!( v_old.protocol_version < v_latest.protocol_version, "v_old must be below latest so the later ratchet is observable" @@ -1745,13 +1718,13 @@ mod test { fn test_mock_version_follows_outer_sdk_atomic() { use dpp::version::PlatformVersion; - // Build a mock SDK with auto-detect, seeded at the mainnet floor (so the - // seed survives the construction clamp). After a metadata-driven ratchet - // to a newer PV, both the outer SDK's `version()` and the inner + // Build a mock SDK with auto-detect, seeded at the cross-network floor (so + // the seed survives the construction clamp). After a metadata-driven + // ratchet to a newer PV, both the outer SDK's `version()` and the inner // `MockDashPlatformSdk::version()` must report the same value — single // source of truth. - let v_old = PlatformVersion::get(super::min_protocol_version(Network::Mainnet)) - .expect("mainnet floor PV exists"); + let v_old = PlatformVersion::get(super::DEFAULT_INITIAL_PROTOCOL_VERSION) + .expect("initial-floor PV exists"); let v_new = PlatformVersion::latest(); assert!( v_old.protocol_version < v_new.protocol_version, @@ -1786,26 +1759,23 @@ mod test { assert_eq!( mock.version().protocol_version, v_new.protocol_version, - "mock version must follow outer ratchet (CMT-001 regression)" + "mock version must follow outer ratchet" ); } #[test] fn test_default_builder_seeds_initial_protocol_version_floor() { - // A default builder (mock => Network::Mainnet) must seed the SDK at the - // upgrade-safe initial floor *raised to the per-network minimum*, not at - // latest(). On mainnet the network floor (11) currently dominates the - // auto-detect initial floor (10). + // A default (unpinned) builder must seed the SDK at the single + // cross-network `DEFAULT_INITIAL_PROTOCOL_VERSION`, not at latest(). let sdk = SdkBuilder::new_mock() .build() .expect("mock Sdk should be created"); - let expected = super::DEFAULT_INITIAL_PROTOCOL_VERSION - .max(super::min_protocol_version(Network::Mainnet)); + let expected = super::DEFAULT_INITIAL_PROTOCOL_VERSION; assert_eq!( sdk.protocol_version_number(), expected, - "unpinned SDK must boot at max(initial floor, network floor), not latest()" + "unpinned SDK must boot at the cross-network initial floor, not latest()" ); assert_eq!(sdk.version().protocol_version, expected); assert!( @@ -1819,10 +1789,8 @@ mod test { let sdk = SdkBuilder::new_mock() .build() .expect("mock Sdk should be created"); - // Effective boot floor = max(auto-detect initial, per-network minimum). - // Mock builds on mainnet, so the network floor (11) currently dominates. - let floor = super::DEFAULT_INITIAL_PROTOCOL_VERSION - .max(super::min_protocol_version(Network::Mainnet)); + // Single cross-network boot floor. + let floor = super::DEFAULT_INITIAL_PROTOCOL_VERSION; assert_eq!(sdk.protocol_version_number(), floor); // Ratchet to a fixed known target (PV12), not `floor + N`: stays valid as the @@ -1863,9 +1831,8 @@ mod test { let sdk = SdkBuilder::new_mock() .build() .expect("mock Sdk should be created"); - // Effective boot floor = max(auto-detect initial, per-network minimum). - let floor = super::DEFAULT_INITIAL_PROTOCOL_VERSION - .max(super::min_protocol_version(Network::Mainnet)); + // Single cross-network boot floor. + let floor = super::DEFAULT_INITIAL_PROTOCOL_VERSION; assert_eq!(sdk.protocol_version_number(), floor); // Unknown (above LATEST_VERSION): rejected, version unchanged. @@ -1901,15 +1868,13 @@ mod test { fn test_explicit_pin_overrides_default_floor() { use dpp::version::PlatformVersion; - // Pin ABOVE both the auto-detect initial floor (10) and the mainnet - // network floor (11) so the override is unambiguously observable: the - // stored version must be the pinned value, not either floor. + // Pin ABOVE the cross-network initial floor so the override is + // unambiguously observable: the stored version must be the pinned value, + // not the floor. let pinned = PlatformVersion::latest(); assert!( - pinned.protocol_version - > super::DEFAULT_INITIAL_PROTOCOL_VERSION - .max(super::min_protocol_version(Network::Mainnet)), - "pinned value must exceed both floors for this test to be meaningful" + pinned.protocol_version > super::DEFAULT_INITIAL_PROTOCOL_VERSION, + "pinned value must exceed the floor for this test to be meaningful" ); let sdk = SdkBuilder::new_mock() .with_version(pinned) @@ -1924,14 +1889,14 @@ mod test { assert!(!sdk.auto_detect_protocol_version); } - /// A pin *below* the per-network floor is raised to the floor at construction: - /// the network floor is a hard lower bound that even an explicit pin cannot - /// drop under. + /// A pin *below* [`DEFAULT_INITIAL_PROTOCOL_VERSION`] is raised to that floor + /// at construction: the cross-network floor is a hard lower bound that even an + /// explicit pin cannot drop under. #[test] - fn test_explicit_pin_below_network_floor_is_raised() { + fn test_explicit_pin_below_floor_is_raised() { use dpp::version::PlatformVersion; - let floor = super::min_protocol_version(Network::Mainnet); + let floor = super::DEFAULT_INITIAL_PROTOCOL_VERSION; let below = floor - 1; let pinned = PlatformVersion::get(below).expect("sub-floor PV exists"); let sdk = SdkBuilder::new_mock() @@ -1942,7 +1907,7 @@ mod test { assert_eq!( sdk.protocol_version_number(), floor, - "a pin below the network floor must be clamped up to the floor" + "a pin below the floor must be clamped up to the floor" ); // Still pinned: auto-detect stays disabled even though construction raised // the value to the floor. @@ -1950,85 +1915,63 @@ mod test { } // ----------------------------------------------------------------- - // per-network protocol-version floor + // cross-network protocol-version floor + non-mainnet refresh-up // ----------------------------------------------------------------- - /// Lock in the Network -> floor mapping (single source of truth in `rs-sdk`). + /// Every network seeds an unpinned SDK at the same cross-network floor — there + /// is no per-network minimum. A testnet SDK (live on 12) therefore boots one + /// version low at the floor and relies on the refresh to climb (covered by + /// [`test_testnet_refresh_ratchets_up_via_proven_query`]). #[test] - fn test_min_protocol_version_mapping() { - assert_eq!( - super::min_protocol_version(Network::Mainnet), - dpp::version::v11::PROTOCOL_VERSION_11, - "mainnet floor must be 11" - ); - assert_eq!( - super::min_protocol_version(Network::Testnet), - dpp::version::v12::PROTOCOL_VERSION_12, - "testnet floor must be 12" - ); - assert_eq!( - super::min_protocol_version(Network::Devnet), - dpp::version::v12::PROTOCOL_VERSION_12, - "devnet floor must be 12" - ); - assert_eq!( - super::min_protocol_version(Network::Regtest), - dpp::version::v12::PROTOCOL_VERSION_12, - "regtest floor must be 12" - ); - } - - /// A testnet SDK seeded below the testnet floor (12) is clamped up to 12 at - /// construction, even though auto-detect would otherwise start it lower. - #[test] - fn test_testnet_construction_clamps_up_to_floor() { - use dpp::version::PlatformVersion; - - let floor = super::min_protocol_version(Network::Testnet); - // Seed below the floor via the test-only `with_initial_version` (auto-detect - // stays on). DEFAULT_INITIAL_PROTOCOL_VERSION (10) is below the testnet floor. - let seed = PlatformVersion::get(super::DEFAULT_INITIAL_PROTOCOL_VERSION) - .expect("default initial PV exists"); - assert!( - seed.protocol_version < floor, - "this test requires the seed to start below the testnet floor" - ); + fn test_testnet_default_builder_boots_at_cross_network_floor() { let sdk = SdkBuilder::new_mock() .with_network(Network::Testnet) - .with_initial_version(seed) .build() .expect("mock Sdk should be created"); assert_eq!( sdk.protocol_version_number(), - floor, - "testnet SDK seeded below 12 must boot at >= 12" + super::DEFAULT_INITIAL_PROTOCOL_VERSION, + "testnet seeds at the cross-network floor, not a per-network minimum" ); - assert!(sdk.protocol_version_number() >= floor); - // Floor is a lower bound, not a pin: auto-detect stays enabled. assert!(sdk.auto_detect_protocol_version); } - /// On testnet the construction floor (12) dominates the auto-detect initial - /// floor (10): a default (unpinned) testnet SDK boots at 12. - #[test] - fn test_testnet_default_builder_boots_at_floor() { - let floor = super::min_protocol_version(Network::Testnet); - let sdk = SdkBuilder::new_mock() + /// The non-mainnet bootstrap the human's model relies on: a testnet SDK boots + /// one version below the network's live version (12) and the proven-query + /// refresh ratchets it up to 12. Drives the real `refresh_protocol_version`. + #[tokio::test] + async fn test_testnet_refresh_ratchets_up_via_proven_query() { + let mut sdk = SdkBuilder::new_mock() .with_network(Network::Testnet) .build() .expect("mock Sdk should be created"); + assert_eq!( + sdk.protocol_version_number(), + super::DEFAULT_INITIAL_PROTOCOL_VERSION, + "testnet must start at the cross-network floor, below its live version" + ); - assert_eq!(sdk.protocol_version_number(), floor); - assert!(sdk.auto_detect_protocol_version); + expect_epoch_refresh(&mut sdk).await; + let resulting = sdk + .refresh_protocol_version() + .await + .expect("refresh should succeed"); + + assert_eq!( + resulting, + dpp::version::LATEST_VERSION, + "the proven refresh must ratchet a non-mainnet SDK up to the network's version" + ); + assert_eq!(sdk.protocol_version_number(), dpp::version::LATEST_VERSION); } - /// A testnet SDK boots at the floor (12). When a refresh's proven query is - /// unavailable, refresh stays at the floor — never below it, and never - /// trusting an unverified value. + /// When a refresh's proven query is unavailable, refresh stays at the + /// cross-network floor — never below it, and never trusting an unverified + /// value. Drives the real `refresh_protocol_version` on testnet. #[tokio::test] async fn test_testnet_refresh_keeps_floor_when_query_unavailable() { - let floor = super::min_protocol_version(Network::Testnet); + let floor = super::DEFAULT_INITIAL_PROTOCOL_VERSION; let sdk = SdkBuilder::new_mock() .with_network(Network::Testnet) .build() @@ -2141,10 +2084,10 @@ mod test { async fn test_refresh_leaves_pinned_sdk_unchanged() { use dpp::version::PlatformVersion; - // Pin at the mainnet floor (11) so the pin survives construction (a + // Pin at the cross-network floor so the pin survives construction (a // sub-floor pin would be raised to the floor). - let pinned = PlatformVersion::get(super::min_protocol_version(Network::Mainnet)) - .expect("mainnet floor PV exists"); + let pinned = PlatformVersion::get(super::DEFAULT_INITIAL_PROTOCOL_VERSION) + .expect("initial-floor PV exists"); let sdk = SdkBuilder::new_mock() .with_version(pinned) .build() @@ -2168,11 +2111,11 @@ mod test { /// When the proven query is unavailable (no mock expectation, so the fetch /// errors), refresh is non-fatal and does *not* fall back to an unverified - /// version: it just clamps the stored version to the per-network floor. Seeded - /// below the floor via the raw atomic to prove the clamp raises it. + /// version: it just clamps the stored version to the cross-network floor. + /// Seeded below the floor via the raw atomic to prove the clamp raises it. #[tokio::test] async fn test_refresh_query_unavailable_clamps_to_floor() { - let floor = super::min_protocol_version(Network::Mainnet); + let floor = super::DEFAULT_INITIAL_PROTOCOL_VERSION; // Seed below the floor via the raw atomic (construction would never allow // this; `mock_sdk_with_auto_detect` uses `.store()`, bypassing the clamp). let sdk = mock_sdk_with_auto_detect(floor - 1); diff --git a/packages/rs-sdk/src/sdk/refresh.rs b/packages/rs-sdk/src/sdk/refresh.rs index 057ec73645..2e24fcc2fd 100644 --- a/packages/rs-sdk/src/sdk/refresh.rs +++ b/packages/rs-sdk/src/sdk/refresh.rs @@ -2,9 +2,9 @@ //! //! Houses [`Sdk::refresh_protocol_version`], a thin eager wrapper around the //! SDK's ordinary proven-query machinery. The shared -//! [`super::min_protocol_version`] / [`Sdk::maybe_update_protocol_version`] -//! helpers stay in the parent `sdk` module — this child module reaches them -//! through `super::` / `self`. +//! [`Sdk::maybe_update_protocol_version`] ratchet stays in the parent `sdk` +//! module — this child module reaches it through `self`, and clamps to the +//! [`super::DEFAULT_INITIAL_PROTOCOL_VERSION`] floor. use super::Sdk; use crate::platform::fetch_current_no_parameters::FetchCurrent; @@ -19,9 +19,9 @@ impl Sdk { /// ## Why this exists (bootstrap problem) /// /// An auto-detect SDK (one built without [`SdkBuilder::with_version()`]) is - /// seeded at the per-network floor (or a caller-supplied initial version) and - /// only learns the network's *actual* protocol version after the first - /// metadata-bearing platform response is parsed (see + /// seeded at [`DEFAULT_INITIAL_PROTOCOL_VERSION`] (or a caller-supplied initial + /// version) and only learns the network's *actual* protocol version after the + /// first metadata-bearing platform response is parsed (see /// [`Self::verify_response_metadata`]). Fee-sensitive flows — shielded pool /// shield/unshield/transfer/withdraw — compute their reserve from /// `self.version()`, so an SDK that hasn't yet observed network metadata can @@ -46,9 +46,9 @@ impl Sdk { /// If the proven query fails (e.g. no [`ContextProvider`] is set, a transport /// error, or `UNIMPLEMENTED` on a stale evonode) the failure is **non-fatal**: /// we deliberately do *not* fall back to an unverified version. The stored - /// version is left untouched and then clamped to the per-network floor, so it - /// can never sit below the network's known minimum even when the refresh - /// round-trip fails. + /// version is left untouched and then clamped to + /// [`DEFAULT_INITIAL_PROTOCOL_VERSION`], so it can never sit below that floor + /// even when the refresh round-trip fails. /// /// ## Pinned SDKs (version updating disabled) /// @@ -56,8 +56,8 @@ impl Sdk { /// of version tracking, so there is nothing to refresh. This method /// short-circuits for a pinned SDK: it issues **no** network request and /// returns the pinned version unchanged. (Construction already raised any - /// sub-floor pin up to the per-network floor, so the floor clamp would be a - /// no-op anyway.) + /// sub-floor pin up to [`DEFAULT_INITIAL_PROTOCOL_VERSION`], so the floor clamp + /// would be a no-op anyway.) /// /// For an auto-detect SDK the usual ratchet guards still apply: version `0` /// and unknown/future versions are ignored, and the stored version only ever @@ -66,16 +66,17 @@ impl Sdk { /// ## Returns /// /// The SDK's protocol version number after the (possible) ratchet and the - /// per-network floor clamp. + /// [`DEFAULT_INITIAL_PROTOCOL_VERSION`] floor clamp. /// /// [`SdkBuilder::with_version()`]: super::SdkBuilder::with_version /// [`ContextProvider`]: crate::platform::ContextProvider + /// [`DEFAULT_INITIAL_PROTOCOL_VERSION`]: super::DEFAULT_INITIAL_PROTOCOL_VERSION pub async fn refresh_protocol_version(&self) -> Result { // A pinned SDK (built via `SdkBuilder::with_version`) has opted out of // version tracking: `maybe_update_protocol_version` is a no-op for it, so // the proven query below could never change anything. Skip the round-trip // and return the pinned version. (Construction already raised any sub-floor - // pin up to the per-network floor, so there is nothing left to clamp.) + // pin up to the cross-network floor, so there is nothing left to clamp.) if !self.auto_detect_protocol_version { return Ok(self.protocol_version_number()); } @@ -94,11 +95,11 @@ impl Sdk { // Refresh-time floor (clamp site 2 of 2; the other is `SdkBuilder::build`). // Independently of whether the proven query ran or ratcheted the version, - // the stored version must never end up below the per-network minimum. - // `fetch_max` keeps this monotonic and concurrency-safe alongside the - // auto-detect ratchet. + // the stored version must never end up below the cross-network + // `DEFAULT_INITIAL_PROTOCOL_VERSION`. `fetch_max` keeps this monotonic and + // concurrency-safe alongside the auto-detect ratchet. self.protocol_version - .fetch_max(super::min_protocol_version(self.network), Ordering::Relaxed); + .fetch_max(super::DEFAULT_INITIAL_PROTOCOL_VERSION, Ordering::Relaxed); Ok(self.protocol_version_number()) } From 59b17f4ccfe545e5613f6fb87745bfeb624d2d3f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:46:35 +0200 Subject: [PATCH 02/12] refactor(sdk): floor at per-network min_protocol_version in build(), inline refresh, drop redundant runtime clamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses both PR #3900 review comments. Part A: restore the per-network `const fn min_protocol_version` (mainnet → 11; testnet/devnet/regtest → 12) as the single source of the build() floor, replacing `DEFAULT_INITIAL_PROTOCOL_VERSION`. build() now floors via `version.protocol_version.max(min_protocol_version(self.network))`. The `DEFAULT_INITIAL_PROTOCOL_VERSION` const is removed; no `MIN_PROTOCOL_VERSION` const is introduced. All references (code, tests, docs) repoint to `min_protocol_version(...)` or the right per-network value. Part B: inline `refresh_protocol_version` into an `impl Sdk` block in sdk.rs (~10 lines) and delete `packages/rs-sdk/src/sdk/refresh.rs` plus its `mod refresh;` declaration. `ExtendedEpochInfo::fetch_current` already ratchets the version internally via the proven-metadata path, so the post-refresh runtime clamp is removed as redundant: build() floors per-network and the ratchet is monotonic-up; a network switch is a fresh build(), not a runtime mutation. Tests reverted to per-network reality: testnet/devnet/regtest default builder boots at 12 directly; mainnet seeds at 11. The relocated refresh tests exercise the real `refresh_protocol_version` end to end — testnet (live at 12 == latest) confirms its floor through the proven query; the generic ratchet test still proves an upward 10→12 climb. No under-shoot trade-off — per-network floors are restored. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/rs-sdk/src/sdk.rs | 323 ++++++++++-------- packages/rs-sdk/src/sdk/refresh.rs | 106 ------ packages/rs-sdk/tests/fetch/common.rs | 4 +- .../tests/fetch/document_query_v0_v1.rs | 9 +- 4 files changed, 194 insertions(+), 248 deletions(-) delete mode 100644 packages/rs-sdk/src/sdk/refresh.rs diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index 349e7a8b43..71b23d650e 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -5,6 +5,7 @@ use crate::internal_cache::NonceCache; use crate::mock::MockResponse; #[cfg(feature = "mocks")] use crate::mock::{provider::GrpcContextProvider, MockDashPlatformSdk}; +use crate::platform::fetch_current_no_parameters::FetchCurrent; use crate::platform::transition::put_settings::PutSettings; use crate::platform::Identifier; use arc_swap::ArcSwapOption; @@ -17,6 +18,7 @@ use dash_context_provider::ContextProvider; use dash_context_provider::MockContextProvider; use dpp::bincode; use dpp::bincode::error::DecodeError; +use dpp::block::extended_epoch_info::ExtendedEpochInfo; use dpp::dashcore::Network; use dpp::prelude::IdentityNonce; use dpp::version::PlatformVersion; @@ -45,58 +47,44 @@ use tokio::sync::{Mutex, MutexGuard}; use tokio_util::sync::{CancellationToken, WaitForCancellationFuture}; use zeroize::Zeroizing; -mod refresh; - /// How many data contracts fit in the cache. pub const DEFAULT_CONTRACT_CACHE_SIZE: usize = 100; /// How many token configs fit in the cache. pub const DEFAULT_TOKEN_CONFIG_CACHE_SIZE: usize = 100; /// How many quorum public keys fit in the cache. pub const DEFAULT_QUORUM_PUBLIC_KEYS_CACHE_SIZE: usize = 100; -/// The single cross-network protocol-version floor an unpinned SDK starts at. -/// -/// Every network — mainnet, testnet, devnet, regtest — seeds the auto-detect -/// atomic at this one version at construction, and the stored version is never -/// allowed to drop below it. There is **deliberately no per-network minimum**: -/// the SDK does not try to know each network's live version up front. +/// The hard per-network protocol-version floor the SDK must never drop below. /// -/// ## Deliberate design: one floor, ratchet up from there +/// Each network has a known minimum protocol version that is already live on +/// chain. The SDK clamps its stored protocol version up to this floor at +/// construction, so even before the first network round-trip the version can +/// never sit *below* what the network is already running. Returning a too-low +/// version would, for example, under-reserve fees for shielded-pool flows that +/// size their reserve from [`Sdk::version`]. /// -/// Mainnet is live on this version. Non-mainnet networks (testnet/devnet/regtest) -/// are live one version higher (12), so an unpinned SDK on those networks -/// **intentionally starts one version low** and relies on the proven-query -/// refresh ([`Sdk::refresh_protocol_version`], or the first ordinary proven -/// response) to ratchet up to the network's real version. This is an intentional -/// simplification: it trades a brief, self-correcting under-shoot on non-mainnet -/// for a single, network-agnostic constant instead of a per-network floor table. -/// Callers that cannot tolerate the bootstrap window should either pin via -/// [`SdkBuilder::with_version`] or call `refresh_protocol_version` before any -/// version-gated flow. Bump this constant as mainnet's live version advances. +/// This is a **lower bound, not a pin**: auto-detect +/// ([`Sdk::maybe_update_protocol_version`]) still ratchets the version *upward* +/// via `fetch_max` when the network reports a newer one. The floor only stops it +/// from going below the network's known minimum. /// -/// # v3.1+-only query surfaces +/// Single source of truth for the floor lives here in `rs-sdk`; the FFI and +/// Swift layers call into the SDK and need no floor logic of their own. Bump the +/// per-network values here as each network's live minimum advances. /// -/// At this floor the local encoder rejects the v3.1+-only surfaces — `Count` -/// (`SelectProjection::count_star`), `group_by`, and `having` — with -/// [`Error::Config`] *before* any network round-trip. To use them either pin a -/// higher version via [`SdkBuilder::with_version`] (which also disables -/// auto-detect), or issue one floor-compatible ratcheting query (no v3.1+ -/// surfaces) right after `build()` — e.g. the `ExtendedEpochInfo::fetch_current` -/// current-state fetch below. -/// Its response metadata lifts the SDK to the network's version, after which `Count` / -/// `group_by` / `having` encode correctly. +/// ## Mapping /// -/// ```no_run -/// # use dash_sdk::{Sdk, SdkBuilder}; -/// # use dash_sdk::platform::fetch_current_no_parameters::FetchCurrent; -/// # use dpp::block::extended_epoch_info::ExtendedEpochInfo; -/// # async fn warm_up() -> Result<(), dash_sdk::Error> { -/// let sdk: Sdk = SdkBuilder::new_mock().build()?; -/// // Ratchets the SDK up to the network's version; Count/group_by/having then encode. -/// let _ = ExtendedEpochInfo::fetch_current(&sdk).await?; -/// # Ok(()) -/// # } -/// ``` -pub const DEFAULT_INITIAL_PROTOCOL_VERSION: u32 = dpp::version::v11::PROTOCOL_VERSION_11; +/// - [`Network::Mainnet`] → 11 +/// - [`Network::Testnet`] → 12 +/// - [`Network::Devnet`] → 12 +/// - [`Network::Regtest`] → 12 +const fn min_protocol_version(network: Network) -> u32 { + match network { + Network::Mainnet => dpp::version::v11::PROTOCOL_VERSION_11, + Network::Testnet => dpp::version::v12::PROTOCOL_VERSION_12, + Network::Devnet => dpp::version::v12::PROTOCOL_VERSION_12, + Network::Regtest => dpp::version::v12::PROTOCOL_VERSION_12, + } +} /// The default metadata time tolerance for checkpoint queries in milliseconds const ADDRESS_STATE_TIME_TOLERANCE_MS: u64 = 31 * 60 * 1000; @@ -383,6 +371,38 @@ impl Sdk { } } + /// Eagerly teach this SDK the network's current protocol version and ratchet up to it. + /// + /// Issues one ordinary **proven** `getEpochsInfo` query + /// ([`ExtendedEpochInfo::fetch_current`]) and discards the epoch payload. The + /// protocol version that query carries in its verified response metadata is + /// ratcheted in by the *same* [`Self::maybe_update_protocol_version`] path + /// every other query uses — only after proof + quorum-signature verification + /// succeeds. Refresh therefore inherits the exact cryptographic trust of + /// ordinary traffic; it adds no second, weaker source of truth. + /// + /// On a pinned SDK ([`SdkBuilder::with_version`], `auto_detect_protocol_version` + /// off) this issues no request and returns the pinned version. If the proven + /// query fails the failure is **non-fatal**: the stored version is left + /// untouched — we never fall back to an unverified one. + /// + /// Returns the SDK's protocol version number after the (possible) ratchet. + /// + /// [`SdkBuilder::with_version`]: SdkBuilder::with_version + pub async fn refresh_protocol_version(&self) -> Result { + if self.auto_detect_protocol_version { + if let Err(error) = ExtendedEpochInfo::fetch_current(self).await { + tracing::warn!( + target: "dash_sdk::protocol_version", + %error, + "proven protocol-version refresh failed; keeping current version \ + (never falling back to an unverified one)" + ); + } + } + Ok(self.protocol_version_number()) + } + /// Retrieve object `O` from proof contained in `request` (of type `R`) and `response`. /// /// This method is used to retrieve objects from proofs returned by Dash Platform. @@ -395,8 +415,8 @@ impl Sdk { /// ## Protocol version bootstrapping /// /// On a fresh auto-detect SDK (i.e. one built without [`SdkBuilder::with_version()`]), the - /// first call to this method uses [`DEFAULT_INITIAL_PROTOCOL_VERSION`] as a fallback because - /// no network response has been received yet to teach the SDK the real network version. + /// first call to this method uses the per-network [`min_protocol_version`] floor 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 @@ -531,10 +551,10 @@ impl Sdk { /// Return [Dash Platform version](PlatformVersion) information used by this SDK. /// - /// The version is floored at construction to at least - /// [`DEFAULT_INITIAL_PROTOCOL_VERSION`], so it is never below that cross-network - /// floor. With auto-detection (default) the SDK starts at that floor and then - /// tracks the network's version — auto-detection only ever ratchets *upward* + /// The version is floored at construction to at least the per-network + /// [`min_protocol_version`], so it is never below that floor. With + /// auto-detection (default) the SDK starts at that floor and then tracks the + /// network's version — auto-detection only ever ratchets *upward* /// (`fetch_max`). A version pinned via [`SdkBuilder::with_version()`] is returned /// as pinned, except that a pin below the floor is raised to it at build time. pub fn version<'v>(&self) -> &'v PlatformVersion { @@ -820,8 +840,11 @@ impl Default for SdkBuilder { cancel_token: CancellationToken::new(), - version: PlatformVersion::get(DEFAULT_INITIAL_PROTOCOL_VERSION) - .expect("DEFAULT_INITIAL_PROTOCOL_VERSION must be a known PlatformVersion"), + // Network-agnostic seed; `build()` floors it up to the per-network + // `min_protocol_version`. The lowest floor (mainnet) is the natural + // baseline — non-mainnet networks get lifted higher at build time. + version: PlatformVersion::get(min_protocol_version(Network::Mainnet)) + .expect("mainnet min_protocol_version must be a known PlatformVersion"), version_explicit: false, #[cfg(not(target_arch = "wasm32"))] ca_certificate: None, @@ -945,10 +968,11 @@ impl SdkBuilder { /// disables auto-detection. /// /// Note that [`build()`](Self::build) still clamps the pinned version up to - /// [`DEFAULT_INITIAL_PROTOCOL_VERSION`]: a pin below that floor is raised to it, - /// so the SDK never starts below it. A pin at or above the floor is used as-is. + /// the per-network [`min_protocol_version`]: a pin below that floor is raised + /// to it, so the SDK never starts below it. A pin at or above the floor is used + /// as-is. /// - /// When unset, the SDK starts at [`DEFAULT_INITIAL_PROTOCOL_VERSION`] and + /// When unset, the SDK starts at the per-network [`min_protocol_version`] and /// ratchets upward via auto-detection. pub fn with_version(mut self, version: &'static PlatformVersion) -> Self { self.version = version; @@ -959,8 +983,8 @@ impl SdkBuilder { /// Test-only seed for the auto-detect atomic — NOT the public way to enable /// auto-detect (auto-detect is the default; [`Self::with_version`] is the opt-out). /// - /// Auto-detect already starts every unpinned SDK at - /// [`DEFAULT_INITIAL_PROTOCOL_VERSION`] and ratchets upward via `fetch_max` in + /// Auto-detect already starts every unpinned SDK at the per-network + /// [`min_protocol_version`] and ratchets upward via `fetch_max` in /// `maybe_update_protocol_version` once the network's version is observed. This /// seed exists only to let unit tests start *below* that floor — exercising the /// upward-only ratchet from an older network's version without disabling auto-detect. @@ -1080,16 +1104,15 @@ impl SdkBuilder { None => DEFAULT_REQUEST_SETTINGS, }; - // Construction-time floor (clamp site 1 of 2; the other is - // `Sdk::refresh_protocol_version`). Clamp the seeded version up to the - // cross-network `DEFAULT_INITIAL_PROTOCOL_VERSION` so the SDK never starts - // below it on any network. This is a lower bound, not a pin: it applies to - // pinned and auto-detect SDKs alike, and auto-detect still ratchets upward - // from here via `fetch_max`. + // Construction-time floor: clamp the seeded version up to the per-network + // `min_protocol_version` so the SDK never starts below what the chosen + // network is already running. This is a lower bound, not a pin: it applies + // to pinned and auto-detect SDKs alike, and auto-detect still ratchets + // upward from here via `fetch_max`. let initial_protocol_version = self .version .protocol_version - .max(DEFAULT_INITIAL_PROTOCOL_VERSION); + .max(min_protocol_version(self.network)); let sdk= match self.addresses { // non-mock mode @@ -1246,7 +1269,7 @@ mod test { use crate::SdkBuilder; - use super::Network; + use super::{min_protocol_version, Network}; /// Mainnet Evo masternodes expose the Platform HTTP endpoint on 443. const MAINNET_PLATFORM_HTTP_PORT: u16 = 443; @@ -1589,12 +1612,12 @@ mod test { fn test_explicit_version_disables_auto_detect() { use dpp::version::PlatformVersion; - // Pin at the cross-network floor so the pin survives construction (the - // floor only clamps *up*; a sub-floor pin would be raised to it). The - // network reporting a newer version must still be ignored, because the - // pin disables auto-detect. - let pinned = PlatformVersion::get(super::DEFAULT_INITIAL_PROTOCOL_VERSION) - .expect("initial-floor PV exists"); + // Pin at the mainnet floor so the pin survives construction (the floor + // only clamps *up*; a sub-floor pin would be raised to it). The network + // reporting a newer version must still be ignored, because the pin + // disables auto-detect. + let pinned = PlatformVersion::get(min_protocol_version(Network::Mainnet)) + .expect("mainnet-floor PV exists"); let sdk = SdkBuilder::new_mock() .with_version(pinned) .build() @@ -1624,12 +1647,12 @@ mod test { fn test_with_initial_version_seeds_to_older_network_version() { use dpp::version::PlatformVersion; - // Caller seeds the auto-detect atomic at the cross-network floor — the - // oldest a *built* SDK can sit at, since construction clamps up to the + // Caller seeds the auto-detect atomic at the mainnet floor — the oldest a + // *built* mainnet SDK can sit at, since construction clamps up to the // floor. `version_explicit` stays false, so fetch_max can still ratchet // upward when the network later moves to a newer PV. - let floor = super::DEFAULT_INITIAL_PROTOCOL_VERSION; - let initial = PlatformVersion::get(floor).expect("initial-floor PV exists"); + let floor = min_protocol_version(Network::Mainnet); + let initial = PlatformVersion::get(floor).expect("mainnet-floor PV exists"); let sdk = SdkBuilder::new_mock() .with_initial_version(initial) .build() @@ -1677,11 +1700,11 @@ mod test { // must re-enable auto-detect that an earlier `with_version` // disabled. // - // `v_old` sits at the cross-network floor so the seed survives the - // construction clamp and the last-write-wins effect stays observable. + // `v_old` sits at the mainnet floor so the seed survives the construction + // clamp and the last-write-wins effect stays observable. let v_latest = PlatformVersion::latest(); - let v_old = PlatformVersion::get(super::DEFAULT_INITIAL_PROTOCOL_VERSION) - .expect("initial-floor PV exists"); + let v_old = PlatformVersion::get(min_protocol_version(Network::Mainnet)) + .expect("mainnet-floor PV exists"); assert!( v_old.protocol_version < v_latest.protocol_version, "v_old must be below latest so the later ratchet is observable" @@ -1718,13 +1741,13 @@ mod test { fn test_mock_version_follows_outer_sdk_atomic() { use dpp::version::PlatformVersion; - // Build a mock SDK with auto-detect, seeded at the cross-network floor (so - // the seed survives the construction clamp). After a metadata-driven - // ratchet to a newer PV, both the outer SDK's `version()` and the inner + // Build a mock SDK with auto-detect, seeded at the mainnet floor (so the + // seed survives the construction clamp). After a metadata-driven ratchet + // to a newer PV, both the outer SDK's `version()` and the inner // `MockDashPlatformSdk::version()` must report the same value — single // source of truth. - let v_old = PlatformVersion::get(super::DEFAULT_INITIAL_PROTOCOL_VERSION) - .expect("initial-floor PV exists"); + let v_old = PlatformVersion::get(min_protocol_version(Network::Mainnet)) + .expect("mainnet-floor PV exists"); let v_new = PlatformVersion::latest(); assert!( v_old.protocol_version < v_new.protocol_version, @@ -1765,17 +1788,17 @@ mod test { #[test] fn test_default_builder_seeds_initial_protocol_version_floor() { - // A default (unpinned) builder must seed the SDK at the single - // cross-network `DEFAULT_INITIAL_PROTOCOL_VERSION`, not at latest(). + // A default (unpinned) builder uses the mainnet network, so it must seed + // the SDK at the mainnet `min_protocol_version` floor, not at latest(). let sdk = SdkBuilder::new_mock() .build() .expect("mock Sdk should be created"); - let expected = super::DEFAULT_INITIAL_PROTOCOL_VERSION; + let expected = min_protocol_version(Network::Mainnet); assert_eq!( sdk.protocol_version_number(), expected, - "unpinned SDK must boot at the cross-network initial floor, not latest()" + "unpinned mainnet SDK must boot at the mainnet floor, not latest()" ); assert_eq!(sdk.version().protocol_version, expected); assert!( @@ -1789,8 +1812,8 @@ mod test { let sdk = SdkBuilder::new_mock() .build() .expect("mock Sdk should be created"); - // Single cross-network boot floor. - let floor = super::DEFAULT_INITIAL_PROTOCOL_VERSION; + // Default (mainnet) boot floor. + let floor = min_protocol_version(Network::Mainnet); assert_eq!(sdk.protocol_version_number(), floor); // Ratchet to a fixed known target (PV12), not `floor + N`: stays valid as the @@ -1831,8 +1854,8 @@ mod test { let sdk = SdkBuilder::new_mock() .build() .expect("mock Sdk should be created"); - // Single cross-network boot floor. - let floor = super::DEFAULT_INITIAL_PROTOCOL_VERSION; + // Default (mainnet) boot floor. + let floor = min_protocol_version(Network::Mainnet); assert_eq!(sdk.protocol_version_number(), floor); // Unknown (above LATEST_VERSION): rejected, version unchanged. @@ -1868,12 +1891,11 @@ mod test { fn test_explicit_pin_overrides_default_floor() { use dpp::version::PlatformVersion; - // Pin ABOVE the cross-network initial floor so the override is - // unambiguously observable: the stored version must be the pinned value, - // not the floor. + // Pin ABOVE the mainnet floor so the override is unambiguously + // observable: the stored version must be the pinned value, not the floor. let pinned = PlatformVersion::latest(); assert!( - pinned.protocol_version > super::DEFAULT_INITIAL_PROTOCOL_VERSION, + pinned.protocol_version > min_protocol_version(Network::Mainnet), "pinned value must exceed the floor for this test to be meaningful" ); let sdk = SdkBuilder::new_mock() @@ -1889,14 +1911,14 @@ mod test { assert!(!sdk.auto_detect_protocol_version); } - /// A pin *below* [`DEFAULT_INITIAL_PROTOCOL_VERSION`] is raised to that floor - /// at construction: the cross-network floor is a hard lower bound that even an + /// A pin *below* the per-network [`min_protocol_version`] is raised to that + /// floor at construction: the floor is a hard lower bound that even an /// explicit pin cannot drop under. #[test] fn test_explicit_pin_below_floor_is_raised() { use dpp::version::PlatformVersion; - let floor = super::DEFAULT_INITIAL_PROTOCOL_VERSION; + let floor = min_protocol_version(Network::Mainnet); let below = floor - 1; let pinned = PlatformVersion::get(below).expect("sub-floor PV exists"); let sdk = SdkBuilder::new_mock() @@ -1915,15 +1937,34 @@ mod test { } // ----------------------------------------------------------------- - // cross-network protocol-version floor + non-mainnet refresh-up + // per-network protocol-version floor + non-mainnet boot/refresh // ----------------------------------------------------------------- - /// Every network seeds an unpinned SDK at the same cross-network floor — there - /// is no per-network minimum. A testnet SDK (live on 12) therefore boots one - /// version low at the floor and relies on the refresh to climb (covered by - /// [`test_testnet_refresh_ratchets_up_via_proven_query`]). + /// `min_protocol_version` maps each network to its known live-chain floor. #[test] - fn test_testnet_default_builder_boots_at_cross_network_floor() { + fn test_min_protocol_version_mapping() { + assert_eq!( + min_protocol_version(Network::Mainnet), + dpp::version::v11::PROTOCOL_VERSION_11 + ); + assert_eq!( + min_protocol_version(Network::Testnet), + dpp::version::v12::PROTOCOL_VERSION_12 + ); + assert_eq!( + min_protocol_version(Network::Devnet), + dpp::version::v12::PROTOCOL_VERSION_12 + ); + assert_eq!( + min_protocol_version(Network::Regtest), + dpp::version::v12::PROTOCOL_VERSION_12 + ); + } + + /// A testnet SDK boots directly at its per-network floor (12) — the network's + /// live version — without needing a refresh to climb there. + #[test] + fn test_testnet_default_builder_boots_at_per_network_floor() { let sdk = SdkBuilder::new_mock() .with_network(Network::Testnet) .build() @@ -1931,27 +1972,40 @@ mod test { assert_eq!( sdk.protocol_version_number(), - super::DEFAULT_INITIAL_PROTOCOL_VERSION, - "testnet seeds at the cross-network floor, not a per-network minimum" + min_protocol_version(Network::Testnet), + "testnet seeds directly at its per-network floor (12)" + ); + assert_eq!( + sdk.protocol_version_number(), + dpp::version::v12::PROTOCOL_VERSION_12 ); assert!(sdk.auto_detect_protocol_version); } - /// The non-mainnet bootstrap the human's model relies on: a testnet SDK boots - /// one version below the network's live version (12) and the proven-query - /// refresh ratchets it up to 12. Drives the real `refresh_protocol_version`. + /// A testnet SDK boots directly at its per-network floor (12), which is the + /// network's live version, so a proven refresh confirms that version through + /// the verified metadata path and leaves it unchanged — it never downgrades. + /// Drives the real `refresh_protocol_version` end to end. #[tokio::test] - async fn test_testnet_refresh_ratchets_up_via_proven_query() { + async fn test_testnet_refresh_confirms_floor_via_proven_query() { let mut sdk = SdkBuilder::new_mock() .with_network(Network::Testnet) .build() .expect("mock Sdk should be created"); + let floor = min_protocol_version(Network::Testnet); assert_eq!( sdk.protocol_version_number(), - super::DEFAULT_INITIAL_PROTOCOL_VERSION, - "testnet must start at the cross-network floor, below its live version" + floor, + "testnet must start at its per-network floor" ); + // The mock injects `LATEST_VERSION` (== the testnet floor) into the proven + // response metadata, so the verified ratchet sees the network confirm 12. + assert_eq!( + dpp::version::LATEST_VERSION, + floor, + "testnet's floor is the live (latest) version; refresh confirms, not climbs" + ); expect_epoch_refresh(&mut sdk).await; let resulting = sdk .refresh_protocol_version() @@ -1959,19 +2013,19 @@ mod test { .expect("refresh should succeed"); assert_eq!( - resulting, - dpp::version::LATEST_VERSION, - "the proven refresh must ratchet a non-mainnet SDK up to the network's version" + resulting, floor, + "a proven refresh confirms the testnet floor and leaves the version unchanged" ); - assert_eq!(sdk.protocol_version_number(), dpp::version::LATEST_VERSION); + assert_eq!(sdk.protocol_version_number(), floor); } - /// When a refresh's proven query is unavailable, refresh stays at the - /// cross-network floor — never below it, and never trusting an unverified - /// value. Drives the real `refresh_protocol_version` on testnet. + /// When a refresh's proven query is unavailable, refresh is non-fatal and + /// never trusts an unverified value: the stored version is left untouched (it + /// stays at the per-network floor). Drives the real `refresh_protocol_version` + /// on testnet. #[tokio::test] - async fn test_testnet_refresh_keeps_floor_when_query_unavailable() { - let floor = super::DEFAULT_INITIAL_PROTOCOL_VERSION; + async fn test_testnet_refresh_keeps_version_when_query_unavailable() { + let floor = min_protocol_version(Network::Testnet); let sdk = SdkBuilder::new_mock() .with_network(Network::Testnet) .build() @@ -1986,7 +2040,7 @@ mod test { assert_eq!( resulting, floor, - "a failed testnet refresh must leave the SDK at the floor" + "a failed testnet refresh must leave the SDK at its floor" ); assert_eq!(sdk.protocol_version_number(), floor); } @@ -2084,10 +2138,10 @@ mod test { async fn test_refresh_leaves_pinned_sdk_unchanged() { use dpp::version::PlatformVersion; - // Pin at the cross-network floor so the pin survives construction (a - // sub-floor pin would be raised to the floor). - let pinned = PlatformVersion::get(super::DEFAULT_INITIAL_PROTOCOL_VERSION) - .expect("initial-floor PV exists"); + // Pin at the mainnet floor so the pin survives construction (a sub-floor + // pin would be raised to the floor). + let pinned = PlatformVersion::get(min_protocol_version(Network::Mainnet)) + .expect("mainnet-floor PV exists"); let sdk = SdkBuilder::new_mock() .with_version(pinned) .build() @@ -2111,15 +2165,14 @@ mod test { /// When the proven query is unavailable (no mock expectation, so the fetch /// errors), refresh is non-fatal and does *not* fall back to an unverified - /// version: it just clamps the stored version to the cross-network floor. - /// Seeded below the floor via the raw atomic to prove the clamp raises it. + /// version: it leaves the stored version exactly where it was. There is no + /// runtime clamp — `build()` already floored the version per-network and the + /// auto-detect ratchet only ever moves it upward. #[tokio::test] - async fn test_refresh_query_unavailable_clamps_to_floor() { - let floor = super::DEFAULT_INITIAL_PROTOCOL_VERSION; - // Seed below the floor via the raw atomic (construction would never allow - // this; `mock_sdk_with_auto_detect` uses `.store()`, bypassing the clamp). - let sdk = mock_sdk_with_auto_detect(floor - 1); - assert_eq!(sdk.protocol_version_number(), floor - 1); + async fn test_refresh_query_unavailable_keeps_current_version() { + let starting = min_protocol_version(Network::Mainnet); + let sdk = mock_sdk_with_auto_detect(starting); + assert_eq!(sdk.protocol_version_number(), starting); let resulting = sdk .refresh_protocol_version() @@ -2127,9 +2180,9 @@ mod test { .expect("refresh is best-effort and must not error when the query fails"); assert_eq!( - resulting, floor, - "a failed refresh must still raise a below-floor version up to the floor" + resulting, starting, + "a failed refresh must leave the stored version untouched (no fallback)" ); - assert_eq!(sdk.protocol_version_number(), floor); + assert_eq!(sdk.protocol_version_number(), starting); } } diff --git a/packages/rs-sdk/src/sdk/refresh.rs b/packages/rs-sdk/src/sdk/refresh.rs deleted file mode 100644 index 2e24fcc2fd..0000000000 --- a/packages/rs-sdk/src/sdk/refresh.rs +++ /dev/null @@ -1,106 +0,0 @@ -//! Protocol-version refresh for [`Sdk`]. -//! -//! Houses [`Sdk::refresh_protocol_version`], a thin eager wrapper around the -//! SDK's ordinary proven-query machinery. The shared -//! [`Sdk::maybe_update_protocol_version`] ratchet stays in the parent `sdk` -//! module — this child module reaches it through `self`, and clamps to the -//! [`super::DEFAULT_INITIAL_PROTOCOL_VERSION`] floor. - -use super::Sdk; -use crate::platform::fetch_current_no_parameters::FetchCurrent; -use crate::Error; -use dpp::block::extended_epoch_info::ExtendedEpochInfo; -use std::sync::atomic::Ordering; - -impl Sdk { - /// Eagerly teach this SDK the network's current protocol version and ratchet - /// up to it. - /// - /// ## Why this exists (bootstrap problem) - /// - /// An auto-detect SDK (one built without [`SdkBuilder::with_version()`]) is - /// seeded at [`DEFAULT_INITIAL_PROTOCOL_VERSION`] (or a caller-supplied initial - /// version) and only learns the network's *actual* protocol version after the - /// first metadata-bearing platform response is parsed (see - /// [`Self::verify_response_metadata`]). Fee-sensitive flows — shielded pool - /// shield/unshield/transfer/withdraw — compute their reserve from - /// `self.version()`, so an SDK that hasn't yet observed network metadata can - /// under-reserve against a network running a newer protocol version. Calling - /// this method on app start / network switch closes that window before any such - /// flow runs. - /// - /// ## How it works — one trust path, not two - /// - /// This issues an ordinary **proven** `getEpochsInfo` query - /// ([`ExtendedEpochInfo::fetch_current`]) and discards the epoch payload. The - /// protocol version that query carries in its response metadata is ratcheted - /// into this SDK by the *same* [`Self::maybe_update_protocol_version`] path - /// every other query uses, and **only after** proof + quorum-signature - /// verification succeeds (the version is bound to the Tenderdash - /// `StateId.app_version`; see the security invariant in - /// [`Self::parse_proof_with_metadata_and_proof`]). So refresh inherits exactly - /// the same cryptographic trust as ordinary traffic — it adds **no** second, - /// weaker source of truth, it merely runs one proven query eagerly instead of - /// waiting for the next one. - /// - /// If the proven query fails (e.g. no [`ContextProvider`] is set, a transport - /// error, or `UNIMPLEMENTED` on a stale evonode) the failure is **non-fatal**: - /// we deliberately do *not* fall back to an unverified version. The stored - /// version is left untouched and then clamped to - /// [`DEFAULT_INITIAL_PROTOCOL_VERSION`], so it can never sit below that floor - /// even when the refresh round-trip fails. - /// - /// ## Pinned SDKs (version updating disabled) - /// - /// An SDK pinned via [`SdkBuilder::with_version()`] has explicitly opted out - /// of version tracking, so there is nothing to refresh. This method - /// short-circuits for a pinned SDK: it issues **no** network request and - /// returns the pinned version unchanged. (Construction already raised any - /// sub-floor pin up to [`DEFAULT_INITIAL_PROTOCOL_VERSION`], so the floor clamp - /// would be a no-op anyway.) - /// - /// For an auto-detect SDK the usual ratchet guards still apply: version `0` - /// and unknown/future versions are ignored, and the stored version only ever - /// ratchets upward via `fetch_max`. - /// - /// ## Returns - /// - /// The SDK's protocol version number after the (possible) ratchet and the - /// [`DEFAULT_INITIAL_PROTOCOL_VERSION`] floor clamp. - /// - /// [`SdkBuilder::with_version()`]: super::SdkBuilder::with_version - /// [`ContextProvider`]: crate::platform::ContextProvider - /// [`DEFAULT_INITIAL_PROTOCOL_VERSION`]: super::DEFAULT_INITIAL_PROTOCOL_VERSION - pub async fn refresh_protocol_version(&self) -> Result { - // A pinned SDK (built via `SdkBuilder::with_version`) has opted out of - // version tracking: `maybe_update_protocol_version` is a no-op for it, so - // the proven query below could never change anything. Skip the round-trip - // and return the pinned version. (Construction already raised any sub-floor - // pin up to the cross-network floor, so there is nothing left to clamp.) - if !self.auto_detect_protocol_version { - return Ok(self.protocol_version_number()); - } - - // A proven query whose response metadata flows through the verified - // `maybe_update_protocol_version` ratchet (see this method's docs). We only - // care about the side effect on the protocol version, not the epoch payload. - if let Err(error) = ExtendedEpochInfo::fetch_current(self).await { - tracing::warn!( - target: "dash_sdk::protocol_version", - %error, - "proven protocol-version refresh failed; keeping current version \ - (never falling back to an unverified one)" - ); - } - - // Refresh-time floor (clamp site 2 of 2; the other is `SdkBuilder::build`). - // Independently of whether the proven query ran or ratcheted the version, - // the stored version must never end up below the cross-network - // `DEFAULT_INITIAL_PROTOCOL_VERSION`. `fetch_max` keeps this monotonic and - // concurrency-safe alongside the auto-detect ratchet. - self.protocol_version - .fetch_max(super::DEFAULT_INITIAL_PROTOCOL_VERSION, Ordering::Relaxed); - - Ok(self.protocol_version_number()) - } -} diff --git a/packages/rs-sdk/tests/fetch/common.rs b/packages/rs-sdk/tests/fetch/common.rs index 5b2d4a49c1..853d06dca1 100644 --- a/packages/rs-sdk/tests/fetch/common.rs +++ b/packages/rs-sdk/tests/fetch/common.rs @@ -106,8 +106,8 @@ pub fn mock_data_contract( /// Ratchet a fresh auto-detect mock SDK from the protocol-version floor up to the /// network's latest version, exactly as production does on its first proven response. /// -/// An unpinned SDK boots at `DEFAULT_INITIAL_PROTOCOL_VERSION` (the upgrade-safe floor) -/// and only learns the real network version after a *proven* fetch, when response +/// An unpinned SDK boots at its per-network `min_protocol_version` (the upgrade-safe +/// floor) and only learns the real network version after a *proven* fetch, when response /// metadata drives `maybe_update_protocol_version`. Mock tests that need the latest /// wire (e.g. Count / `group_by`, or V2 document types) must therefore perform one /// proven fetch before encoding their real request. This registers a cheap proven diff --git a/packages/rs-sdk/tests/fetch/document_query_v0_v1.rs b/packages/rs-sdk/tests/fetch/document_query_v0_v1.rs index eb13943764..e3d8c90feb 100644 --- a/packages/rs-sdk/tests/fetch/document_query_v0_v1.rs +++ b/packages/rs-sdk/tests/fetch/document_query_v0_v1.rs @@ -27,7 +27,6 @@ use std::sync::Arc; use super::common::{mock_data_contract, mock_document_type}; use dapi_grpc::platform::v0::get_documents_request::Version as ReqVersion; use dapi_grpc::platform::v0::GetDocumentsRequest; -use dash_sdk::sdk::DEFAULT_INITIAL_PROTOCOL_VERSION; use dash_sdk::{platform::documents::document_query::DocumentQuery, Error as SdkError, SdkBuilder}; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::platform_value::Value; @@ -220,13 +219,13 @@ fn encoder_dispatches_v0_via_query_settings_without_sdk() { #[test] fn sdk_builder_default_seeds_atomic_to_floor() { - // Auto-detect default: the atomic seeds to the floor - // `DEFAULT_INITIAL_PROTOCOL_VERSION`, which `version()` returns until the - // first response ratchets it upward. + // Auto-detect default uses the mainnet network, so the atomic seeds to the + // mainnet `min_protocol_version` floor (PROTOCOL_VERSION_11), which + // `version()` returns until the first response ratchets it upward. let sdk_default = SdkBuilder::new_mock().build().expect("mock sdk"); assert_eq!( sdk_default.version().protocol_version, - DEFAULT_INITIAL_PROTOCOL_VERSION + dpp::version::v11::PROTOCOL_VERSION_11 ); } From 8b0393768ffe82767d4986a6df15d138163be2a7 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:46:28 +0200 Subject: [PATCH 03/12] chore: use version 10 for all networks --- packages/rs-sdk/src/sdk.rs | 27 ++------------------------- 1 file changed, 2 insertions(+), 25 deletions(-) diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index 71b23d650e..4370e07211 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -55,35 +55,12 @@ pub const DEFAULT_TOKEN_CONFIG_CACHE_SIZE: usize = 100; pub const DEFAULT_QUORUM_PUBLIC_KEYS_CACHE_SIZE: usize = 100; /// The hard per-network protocol-version floor the SDK must never drop below. /// -/// Each network has a known minimum protocol version that is already live on -/// chain. The SDK clamps its stored protocol version up to this floor at -/// construction, so even before the first network round-trip the version can -/// never sit *below* what the network is already running. Returning a too-low -/// version would, for example, under-reserve fees for shielded-pool flows that -/// size their reserve from [`Sdk::version`]. -/// /// This is a **lower bound, not a pin**: auto-detect /// ([`Sdk::maybe_update_protocol_version`]) still ratchets the version *upward* /// via `fetch_max` when the network reports a newer one. The floor only stops it /// from going below the network's known minimum. -/// -/// Single source of truth for the floor lives here in `rs-sdk`; the FFI and -/// Swift layers call into the SDK and need no floor logic of their own. Bump the -/// per-network values here as each network's live minimum advances. -/// -/// ## Mapping -/// -/// - [`Network::Mainnet`] → 11 -/// - [`Network::Testnet`] → 12 -/// - [`Network::Devnet`] → 12 -/// - [`Network::Regtest`] → 12 -const fn min_protocol_version(network: Network) -> u32 { - match network { - Network::Mainnet => dpp::version::v11::PROTOCOL_VERSION_11, - Network::Testnet => dpp::version::v12::PROTOCOL_VERSION_12, - Network::Devnet => dpp::version::v12::PROTOCOL_VERSION_12, - Network::Regtest => dpp::version::v12::PROTOCOL_VERSION_12, - } +const fn min_protocol_version(_network: Network) -> u32 { + dpp::version::v10::PROTOCOL_VERSION_10 // TODO: set real per-network floors once the update mechanism is tested } /// The default metadata time tolerance for checkpoint queries in milliseconds From 42f75e0920a57eee2ca94f37578115506271769f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:25:34 +0200 Subject: [PATCH 04/12] refactor(sdk): unify protocol-version pin flag as `version_pinned` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pin concept was named two inconsistent, opposite-polarity ways: `SdkBuilder.version_explicit` (true = pinned) and `Sdk.auto_detect_protocol_version` (true = auto-detect, i.e. NOT pinned), wired together as `auto_detect_protocol_version: !self.version_explicit`. Collapse both onto a single name and polarity: `version_pinned` (true = version is pinned, auto-detection disabled). The builder field is a straight rename; the Sdk field inverts, so every read/assert flips: - `maybe_update_protocol_version`: `if !auto_detect` -> `if version_pinned` - `refresh_protocol_version`: `if auto_detect` -> `if !version_pinned` - `build()` wiring drops the negation: `version_pinned: self.version_pinned` No behavioural change. fmt + clippy (-D warnings, --all-features) clean; 147 dash-sdk lib tests pass. Co-Authored-By: Claude Opus 4.6 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- packages/rs-sdk/src/sdk.rs | 56 ++++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index 4370e07211..4e5d54e06f 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -159,9 +159,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, + /// Whether the protocol version is pinned, i.e. auto-detection from network + /// response metadata is disabled. Set to `true` when the user explicitly calls + /// [`SdkBuilder::with_version()`]. + version_pinned: bool, /// Last seen height; used to determine if the remote node is stale. /// @@ -197,7 +198,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, + version_pinned: self.version_pinned, 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, @@ -311,7 +312,7 @@ impl Sdk { /// 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 { + if self.version_pinned { return; } @@ -358,8 +359,8 @@ impl Sdk { /// succeeds. Refresh therefore inherits the exact cryptographic trust of /// ordinary traffic; it adds no second, weaker source of truth. /// - /// On a pinned SDK ([`SdkBuilder::with_version`], `auto_detect_protocol_version` - /// off) this issues no request and returns the pinned version. If the proven + /// On a pinned SDK ([`SdkBuilder::with_version`], `version_pinned` + /// on) this issues no request and returns the pinned version. If the proven /// query fails the failure is **non-fatal**: the stored version is left /// untouched — we never fall back to an unverified one. /// @@ -367,7 +368,7 @@ impl Sdk { /// /// [`SdkBuilder::with_version`]: SdkBuilder::with_version pub async fn refresh_protocol_version(&self) -> Result { - if self.auto_detect_protocol_version { + if !self.version_pinned { if let Err(error) = ExtendedEpochInfo::fetch_current(self).await { tracing::warn!( target: "dash_sdk::protocol_version", @@ -743,9 +744,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, + /// Whether the protocol version is pinned, i.e. the user explicitly called + /// `with_version()`. When true, auto-detection of protocol version from network + /// metadata is disabled. + version_pinned: bool, /// Cache size for data contracts. Used by mock [GrpcContextProvider]. #[cfg(feature = "mocks")] @@ -822,7 +824,7 @@ impl Default for SdkBuilder { // baseline — non-mainnet networks get lifted higher at build time. version: PlatformVersion::get(min_protocol_version(Network::Mainnet)) .expect("mainnet min_protocol_version must be a known PlatformVersion"), - version_explicit: false, + version_pinned: false, #[cfg(not(target_arch = "wasm32"))] ca_certificate: None, @@ -953,7 +955,7 @@ impl SdkBuilder { /// ratchets upward via auto-detection. pub fn with_version(mut self, version: &'static PlatformVersion) -> Self { self.version = version; - self.version_explicit = true; + self.version_pinned = true; self } @@ -966,13 +968,13 @@ impl SdkBuilder { /// seed exists only to let unit tests start *below* that floor — exercising the /// upward-only ratchet from an older network's version without disabling auto-detect. /// - /// Seeds `self.version` and keeps `version_explicit` `false`, so auto-detect stays + /// Seeds `self.version` and keeps `version_pinned` `false`, so auto-detect stays /// on. Builder chains are last-write-wins: a later `with_initial_version` re-enables /// auto-detect that an earlier `with_version` disabled. #[cfg(test)] pub(crate) fn with_initial_version(mut self, version: &'static PlatformVersion) -> Self { self.version = version; - self.version_explicit = false; + self.version_pinned = false; self } @@ -1114,9 +1116,9 @@ impl SdkBuilder { cancel_token: self.cancel_token, nonce_cache: Default::default(), // Seed atomic with the network-floored initial version; whether - // auto-detect is on is controlled separately by `version_explicit`. + // the version is pinned is controlled separately by `version_pinned`. protocol_version: Arc::new(atomic::AtomicU32::new(initial_protocol_version)), - auto_detect_protocol_version: !self.version_explicit, + version_pinned: self.version_pinned, // 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, @@ -1184,7 +1186,7 @@ impl SdkBuilder { proofs:self.proofs, nonce_cache: Default::default(), protocol_version: Arc::new(atomic::AtomicU32::new(initial_protocol_version)), - auto_detect_protocol_version: !self.version_explicit, + version_pinned: self.version_pinned, context_provider: ArcSwapOption::new(Some(Arc::new(context_provider))), cancel_token: self.cancel_token, metadata_last_seen_height: Arc::new(atomic::AtomicU64::new(0)), @@ -1601,7 +1603,7 @@ mod test { .expect("mock Sdk should be created"); assert_eq!(sdk.protocol_version_number(), pinned.protocol_version); - assert!(!sdk.auto_detect_protocol_version); + assert!(sdk.version_pinned); // Network reports version 12 (> pinned) — should be ignored because version is pinned let metadata = ResponseMetadata { @@ -1626,7 +1628,7 @@ mod test { // Caller seeds the auto-detect atomic at the mainnet floor — the oldest a // *built* mainnet SDK can sit at, since construction clamps up to the - // floor. `version_explicit` stays false, so fetch_max can still ratchet + // floor. `version_pinned` stays false, so fetch_max can still ratchet // upward when the network later moves to a newer PV. let floor = min_protocol_version(Network::Mainnet); let initial = PlatformVersion::get(floor).expect("mainnet-floor PV exists"); @@ -1642,7 +1644,7 @@ mod test { ); assert_eq!(sdk.version().protocol_version, floor); assert!( - sdk.auto_detect_protocol_version, + !sdk.version_pinned, "with_initial_version must keep auto-detect enabled" ); @@ -1699,7 +1701,7 @@ mod test { "with_initial_version must overwrite the prior with_version seed" ); assert!( - sdk.auto_detect_protocol_version, + !sdk.version_pinned, "with_initial_version must restore auto-detect after with_version disabled it" ); @@ -1779,7 +1781,7 @@ mod test { ); assert_eq!(sdk.version().protocol_version, expected); assert!( - sdk.auto_detect_protocol_version, + !sdk.version_pinned, "default SDK must keep auto-detect enabled" ); } @@ -1885,7 +1887,7 @@ mod test { pinned.protocol_version, "explicit with_version must win over the default floor" ); - assert!(!sdk.auto_detect_protocol_version); + assert!(sdk.version_pinned); } /// A pin *below* the per-network [`min_protocol_version`] is raised to that @@ -1910,7 +1912,7 @@ mod test { ); // Still pinned: auto-detect stays disabled even though construction raised // the value to the floor. - assert!(!sdk.auto_detect_protocol_version); + assert!(sdk.version_pinned); } // ----------------------------------------------------------------- @@ -1956,7 +1958,7 @@ mod test { sdk.protocol_version_number(), dpp::version::v12::PROTOCOL_VERSION_12 ); - assert!(sdk.auto_detect_protocol_version); + assert!(!sdk.version_pinned); } /// A testnet SDK boots directly at its per-network floor (12), which is the @@ -2124,7 +2126,7 @@ mod test { .build() .expect("mock Sdk should be created"); assert_eq!(sdk.protocol_version_number(), pinned.protocol_version); - assert!(!sdk.auto_detect_protocol_version); + assert!(sdk.version_pinned); // No expectation registered: a pinned refresh must not even attempt the // query, so this returns Ok with the pinned version unchanged. From 3f9fda2a044a2738aeae04a89d75b390ebb25af4 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:49:50 +0200 Subject: [PATCH 05/12] chore: change min protocol version logic --- packages/rs-sdk/src/sdk.rs | 234 +++++++++---------------------------- 1 file changed, 52 insertions(+), 182 deletions(-) diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index 4370e07211..2530a5cc94 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -53,12 +53,14 @@ pub const DEFAULT_CONTRACT_CACHE_SIZE: usize = 100; pub const DEFAULT_TOKEN_CONFIG_CACHE_SIZE: usize = 100; /// How many quorum public keys fit in the cache. pub const DEFAULT_QUORUM_PUBLIC_KEYS_CACHE_SIZE: usize = 100; -/// The hard per-network protocol-version floor the SDK must never drop below. +/// The default initial protocol version for a network, used when no version is +/// pinned via [`SdkBuilder::with_version`] or seeded via +/// [`SdkBuilder::with_initial_version`]. /// -/// This is a **lower bound, not a pin**: auto-detect -/// ([`Sdk::maybe_update_protocol_version`]) still ratchets the version *upward* -/// via `fetch_max` when the network reports a newer one. The floor only stops it -/// from going below the network's known minimum. +/// This is only the starting point: auto-detect +/// ([`Sdk::maybe_update_protocol_version`]) ratchets the version *upward* via +/// `fetch_max` when the network reports a newer one. It is not a hard floor — an +/// explicitly pinned version below it is preserved as-is. const fn min_protocol_version(_network: Network) -> u32 { dpp::version::v10::PROTOCOL_VERSION_10 // TODO: set real per-network floors once the update mechanism is tested } @@ -528,12 +530,10 @@ impl Sdk { /// Return [Dash Platform version](PlatformVersion) information used by this SDK. /// - /// The version is floored at construction to at least the per-network - /// [`min_protocol_version`], so it is never below that floor. With - /// auto-detection (default) the SDK starts at that floor and then tracks the - /// network's version — auto-detection only ever ratchets *upward* - /// (`fetch_max`). A version pinned via [`SdkBuilder::with_version()`] is returned - /// as pinned, except that a pin below the floor is raised to it at build time. + /// With auto-detection (default) the SDK starts at the per-network + /// [`min_protocol_version`] and then tracks the network's version — + /// auto-detection only ever ratchets *upward* (`fetch_max`). A version pinned + /// via [`SdkBuilder::with_version()`] is returned as pinned. pub fn version<'v>(&self) -> &'v PlatformVersion { let v = self.protocol_version.load(Ordering::Relaxed); PlatformVersion::get(v).unwrap_or_else(|_| PlatformVersion::latest()) @@ -740,8 +740,9 @@ pub struct SdkBuilder { /// If true, request and verify proofs of the responses. proofs: bool, - /// Platform version to use in this Sdk - version: &'static PlatformVersion, + /// Platform version to use in this Sdk; if None, the SDK will auto-detect the version + /// from network metadata and update it as needed. + version: Option<&'static PlatformVersion>, /// Whether the user explicitly called `with_version()`. /// When true, auto-detection of protocol version from network metadata is disabled. @@ -817,11 +818,10 @@ impl Default for SdkBuilder { cancel_token: CancellationToken::new(), - // Network-agnostic seed; `build()` floors it up to the per-network - // `min_protocol_version`. The lowest floor (mainnet) is the natural - // baseline — non-mainnet networks get lifted higher at build time. - version: PlatformVersion::get(min_protocol_version(Network::Mainnet)) - .expect("mainnet min_protocol_version must be a known PlatformVersion"), + // No version configured; `build()` defaults to the per-network + // `min_protocol_version` unless `with_version`/`with_initial_version` + // sets one. + version: None, version_explicit: false, #[cfg(not(target_arch = "wasm32"))] ca_certificate: None, @@ -944,34 +944,29 @@ impl SdkBuilder { /// Select specific version of Dash Platform to use. This pins the version and /// disables auto-detection. /// - /// Note that [`build()`](Self::build) still clamps the pinned version up to - /// the per-network [`min_protocol_version`]: a pin below that floor is raised - /// to it, so the SDK never starts below it. A pin at or above the floor is used - /// as-is. + /// The pinned version is used as-is; it is not clamped to the per-network + /// [`min_protocol_version`]. /// /// When unset, the SDK starts at the per-network [`min_protocol_version`] and /// ratchets upward via auto-detection. pub fn with_version(mut self, version: &'static PlatformVersion) -> Self { - self.version = version; + self.version = Some(version); self.version_explicit = true; self } - /// Test-only seed for the auto-detect atomic — NOT the public way to enable - /// auto-detect (auto-detect is the default; [`Self::with_version`] is the opt-out). + /// Configure initial platform version, replacing default defined in [`min_protocol_version`]. /// /// Auto-detect already starts every unpinned SDK at the per-network /// [`min_protocol_version`] and ratchets upward via `fetch_max` in /// `maybe_update_protocol_version` once the network's version is observed. This - /// seed exists only to let unit tests start *below* that floor — exercising the - /// upward-only ratchet from an older network's version without disabling auto-detect. + /// function allows overriding the initial seed version that auto-detect starts with. /// /// Seeds `self.version` and keeps `version_explicit` `false`, so auto-detect stays /// on. Builder chains are last-write-wins: a later `with_initial_version` re-enables /// auto-detect that an earlier `with_version` disabled. - #[cfg(test)] - pub(crate) fn with_initial_version(mut self, version: &'static PlatformVersion) -> Self { - self.version = version; + pub fn with_initial_version(mut self, version: &'static PlatformVersion) -> Self { + self.version = Some(version); self.version_explicit = false; self } @@ -1081,15 +1076,10 @@ impl SdkBuilder { None => DEFAULT_REQUEST_SETTINGS, }; - // Construction-time floor: clamp the seeded version up to the per-network - // `min_protocol_version` so the SDK never starts below what the chosen - // network is already running. This is a lower bound, not a pin: it applies - // to pinned and auto-detect SDKs alike, and auto-detect still ratchets - // upward from here via `fetch_max`. - let initial_protocol_version = self - .version - .protocol_version - .max(min_protocol_version(self.network)); + let initial_version = self.version.unwrap_or_else(|| { + PlatformVersion::get(min_protocol_version(self.network)) + .expect("min_protocol_version for a network must be a valid version") + }); let sdk= match self.addresses { // non-mock mode @@ -1113,9 +1103,9 @@ impl SdkBuilder { context_provider: ArcSwapOption::new( self.context_provider.map(Arc::new)), cancel_token: self.cancel_token, nonce_cache: Default::default(), - // Seed atomic with the network-floored initial version; whether - // auto-detect is on is controlled separately by `version_explicit`. - protocol_version: Arc::new(atomic::AtomicU32::new(initial_protocol_version)), + // Seed atomic with the initial version; whether auto-detect is + // on is controlled separately by `version_explicit`. + protocol_version: Arc::new(atomic::AtomicU32::new(initial_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)), @@ -1183,7 +1173,7 @@ impl SdkBuilder { dump_dir: self.dump_dir.clone(), proofs:self.proofs, nonce_cache: Default::default(), - protocol_version: Arc::new(atomic::AtomicU32::new(initial_protocol_version)), + protocol_version: Arc::new(atomic::AtomicU32::new(initial_version.protocol_version)), auto_detect_protocol_version: !self.version_explicit, context_provider: ArcSwapOption::new(Some(Arc::new(context_provider))), cancel_token: self.cancel_token, @@ -1589,10 +1579,8 @@ mod test { fn test_explicit_version_disables_auto_detect() { use dpp::version::PlatformVersion; - // Pin at the mainnet floor so the pin survives construction (the floor - // only clamps *up*; a sub-floor pin would be raised to it). The network - // reporting a newer version must still be ignored, because the pin - // disables auto-detect. + // Pin at the mainnet default version. The network reporting a newer + // version must still be ignored, because the pin disables auto-detect. let pinned = PlatformVersion::get(min_protocol_version(Network::Mainnet)) .expect("mainnet-floor PV exists"); let sdk = SdkBuilder::new_mock() @@ -1624,10 +1612,9 @@ mod test { fn test_with_initial_version_seeds_to_older_network_version() { use dpp::version::PlatformVersion; - // Caller seeds the auto-detect atomic at the mainnet floor — the oldest a - // *built* mainnet SDK can sit at, since construction clamps up to the - // floor. `version_explicit` stays false, so fetch_max can still ratchet - // upward when the network later moves to a newer PV. + // Caller seeds the auto-detect atomic at the mainnet default version. + // `version_explicit` stays false, so fetch_max can still ratchet upward + // when the network later moves to a newer PV. let floor = min_protocol_version(Network::Mainnet); let initial = PlatformVersion::get(floor).expect("mainnet-floor PV exists"); let sdk = SdkBuilder::new_mock() @@ -1677,8 +1664,8 @@ mod test { // must re-enable auto-detect that an earlier `with_version` // disabled. // - // `v_old` sits at the mainnet floor so the seed survives the construction - // clamp and the last-write-wins effect stays observable. + // `v_old` sits at the mainnet default version so the last-write-wins + // effect stays observable. let v_latest = PlatformVersion::latest(); let v_old = PlatformVersion::get(min_protocol_version(Network::Mainnet)) .expect("mainnet-floor PV exists"); @@ -1718,9 +1705,9 @@ mod test { fn test_mock_version_follows_outer_sdk_atomic() { use dpp::version::PlatformVersion; - // Build a mock SDK with auto-detect, seeded at the mainnet floor (so the - // seed survives the construction clamp). After a metadata-driven ratchet - // to a newer PV, both the outer SDK's `version()` and the inner + // Build a mock SDK with auto-detect, seeded at the mainnet default + // version. After a metadata-driven ratchet to a newer PV, both the outer + // SDK's `version()` and the inner // `MockDashPlatformSdk::version()` must report the same value — single // source of truth. let v_old = PlatformVersion::get(min_protocol_version(Network::Mainnet)) @@ -1864,35 +1851,9 @@ mod test { ); } + /// A pin *below* the per-network [`min_protocol_version`] is preserved #[test] - fn test_explicit_pin_overrides_default_floor() { - use dpp::version::PlatformVersion; - - // Pin ABOVE the mainnet floor so the override is unambiguously - // observable: the stored version must be the pinned value, not the floor. - let pinned = PlatformVersion::latest(); - assert!( - pinned.protocol_version > min_protocol_version(Network::Mainnet), - "pinned value must exceed the floor for this test to be meaningful" - ); - let sdk = SdkBuilder::new_mock() - .with_version(pinned) - .build() - .expect("mock Sdk should be created"); - - assert_eq!( - sdk.protocol_version_number(), - pinned.protocol_version, - "explicit with_version must win over the default floor" - ); - assert!(!sdk.auto_detect_protocol_version); - } - - /// A pin *below* the per-network [`min_protocol_version`] is raised to that - /// floor at construction: the floor is a hard lower bound that even an - /// explicit pin cannot drop under. - #[test] - fn test_explicit_pin_below_floor_is_raised() { + fn test_explicit_pin_below_floor_is_preserved() { use dpp::version::PlatformVersion; let floor = min_protocol_version(Network::Mainnet); @@ -1905,11 +1866,10 @@ mod test { assert_eq!( sdk.protocol_version_number(), - floor, - "a pin below the floor must be clamped up to the floor" + below, + "a pin below the floor must be preserved" ); - // Still pinned: auto-detect stays disabled even though construction raised - // the value to the floor. + // Still pinned: auto-detect stays disabled. assert!(!sdk.auto_detect_protocol_version); } @@ -1917,28 +1877,7 @@ mod test { // per-network protocol-version floor + non-mainnet boot/refresh // ----------------------------------------------------------------- - /// `min_protocol_version` maps each network to its known live-chain floor. - #[test] - fn test_min_protocol_version_mapping() { - assert_eq!( - min_protocol_version(Network::Mainnet), - dpp::version::v11::PROTOCOL_VERSION_11 - ); - assert_eq!( - min_protocol_version(Network::Testnet), - dpp::version::v12::PROTOCOL_VERSION_12 - ); - assert_eq!( - min_protocol_version(Network::Devnet), - dpp::version::v12::PROTOCOL_VERSION_12 - ); - assert_eq!( - min_protocol_version(Network::Regtest), - dpp::version::v12::PROTOCOL_VERSION_12 - ); - } - - /// A testnet SDK boots directly at its per-network floor (12) — the network's + /// A testnet SDK boots directly at its per-network floor — the network's /// live version — without needing a refresh to climb there. #[test] fn test_testnet_default_builder_boots_at_per_network_floor() { @@ -1950,78 +1889,11 @@ mod test { assert_eq!( sdk.protocol_version_number(), min_protocol_version(Network::Testnet), - "testnet seeds directly at its per-network floor (12)" - ); - assert_eq!( - sdk.protocol_version_number(), - dpp::version::v12::PROTOCOL_VERSION_12 + "testnet seeds directly at its per-network floor" ); assert!(sdk.auto_detect_protocol_version); } - /// A testnet SDK boots directly at its per-network floor (12), which is the - /// network's live version, so a proven refresh confirms that version through - /// the verified metadata path and leaves it unchanged — it never downgrades. - /// Drives the real `refresh_protocol_version` end to end. - #[tokio::test] - async fn test_testnet_refresh_confirms_floor_via_proven_query() { - let mut sdk = SdkBuilder::new_mock() - .with_network(Network::Testnet) - .build() - .expect("mock Sdk should be created"); - let floor = min_protocol_version(Network::Testnet); - assert_eq!( - sdk.protocol_version_number(), - floor, - "testnet must start at its per-network floor" - ); - - // The mock injects `LATEST_VERSION` (== the testnet floor) into the proven - // response metadata, so the verified ratchet sees the network confirm 12. - assert_eq!( - dpp::version::LATEST_VERSION, - floor, - "testnet's floor is the live (latest) version; refresh confirms, not climbs" - ); - expect_epoch_refresh(&mut sdk).await; - let resulting = sdk - .refresh_protocol_version() - .await - .expect("refresh should succeed"); - - assert_eq!( - resulting, floor, - "a proven refresh confirms the testnet floor and leaves the version unchanged" - ); - assert_eq!(sdk.protocol_version_number(), floor); - } - - /// When a refresh's proven query is unavailable, refresh is non-fatal and - /// never trusts an unverified value: the stored version is left untouched (it - /// stays at the per-network floor). Drives the real `refresh_protocol_version` - /// on testnet. - #[tokio::test] - async fn test_testnet_refresh_keeps_version_when_query_unavailable() { - let floor = min_protocol_version(Network::Testnet); - let sdk = SdkBuilder::new_mock() - .with_network(Network::Testnet) - .build() - .expect("mock Sdk should be created"); - assert_eq!(sdk.protocol_version_number(), floor); - - // No mock expectation registered -> the proven fetch errors (non-fatal). - let resulting = sdk - .refresh_protocol_version() - .await - .expect("refresh is best-effort and must not error when the query fails"); - - assert_eq!( - resulting, floor, - "a failed testnet refresh must leave the SDK at its floor" - ); - assert_eq!(sdk.protocol_version_number(), floor); - } - #[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")] @@ -2115,8 +1987,7 @@ mod test { async fn test_refresh_leaves_pinned_sdk_unchanged() { use dpp::version::PlatformVersion; - // Pin at the mainnet floor so the pin survives construction (a sub-floor - // pin would be raised to the floor). + // Pin at the mainnet default version. let pinned = PlatformVersion::get(min_protocol_version(Network::Mainnet)) .expect("mainnet-floor PV exists"); let sdk = SdkBuilder::new_mock() @@ -2143,8 +2014,7 @@ mod test { /// When the proven query is unavailable (no mock expectation, so the fetch /// errors), refresh is non-fatal and does *not* fall back to an unverified /// version: it leaves the stored version exactly where it was. There is no - /// runtime clamp — `build()` already floored the version per-network and the - /// auto-detect ratchet only ever moves it upward. + /// runtime clamp — the auto-detect ratchet only ever moves it upward. #[tokio::test] async fn test_refresh_query_unavailable_keeps_current_version() { let starting = min_protocol_version(Network::Mainnet); From f7d9154ac19430175fb91f890b6650aefa78f592 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:53:58 +0200 Subject: [PATCH 06/12] test(sdk): reconcile PV-floor assertions with flat PROTOCOL_VERSION_10 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PV-floor change on this branch flattened min_protocol_version() to PROTOCOL_VERSION_10 for all networks and removed floor-clamping of pinned versions. Two test suites still encoded the old expectations: - rs-sdk sdk_builder_default_seeds_atomic_to_floor asserted the hardcoded PROTOCOL_VERSION_11 constant; now asserts PROTOCOL_VERSION_10. - wasm-sdk builder chaining tests expected withVersion(1) to be raised to the network floor; pinned versions are now used as-is, so the assertions move from greaterThan(1) to equal(1). 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 4.6 --- packages/rs-sdk/tests/fetch/document_query_v0_v1.rs | 9 +++++---- .../tests/unit/builder-with-addresses.spec.ts | 13 ++++++------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/rs-sdk/tests/fetch/document_query_v0_v1.rs b/packages/rs-sdk/tests/fetch/document_query_v0_v1.rs index e3d8c90feb..5b37ea462c 100644 --- a/packages/rs-sdk/tests/fetch/document_query_v0_v1.rs +++ b/packages/rs-sdk/tests/fetch/document_query_v0_v1.rs @@ -219,13 +219,14 @@ fn encoder_dispatches_v0_via_query_settings_without_sdk() { #[test] fn sdk_builder_default_seeds_atomic_to_floor() { - // Auto-detect default uses the mainnet network, so the atomic seeds to the - // mainnet `min_protocol_version` floor (PROTOCOL_VERSION_11), which - // `version()` returns until the first response ratchets it upward. + // Auto-detect default uses mainnet, so the atomic seeds to the mainnet + // `min_protocol_version` floor, which `version()` returns until the first + // response ratchets it upward. We assert against the actual floor constant + // rather than a hardcoded integer so the test survives future floor bumps. let sdk_default = SdkBuilder::new_mock().build().expect("mock sdk"); assert_eq!( sdk_default.version().protocol_version, - dpp::version::v11::PROTOCOL_VERSION_11 + dpp::version::v10::PROTOCOL_VERSION_10 ); } diff --git a/packages/wasm-sdk/tests/unit/builder-with-addresses.spec.ts b/packages/wasm-sdk/tests/unit/builder-with-addresses.spec.ts index 86df4fbe0f..abc9e7ca9a 100644 --- a/packages/wasm-sdk/tests/unit/builder-with-addresses.spec.ts +++ b/packages/wasm-sdk/tests/unit/builder-with-addresses.spec.ts @@ -162,16 +162,15 @@ describe('WasmSdkBuilder', () => { [TEST_ADDRESS_1], 'testnet', ); - // `withVersion(1)` requests a version below the network protocol-version - // floor. The SDK never operates below that floor, so an explicit sub-floor - // pin is raised to it — the requested `1` surfaces as the (higher) testnet - // floor, not `1`. + // `withVersion(1)` pins the SDK to platform version 1 exactly. + // Pinned versions are not clamped to the network floor — the caller + // takes responsibility for the version they specify. builder = builder.withVersion(1); expect(builder).to.be.an.instanceof(sdk.WasmSdkBuilder); const built = await builder.build(); expect(built).to.be.an.instanceof(sdk.WasmSdk); expect(built.version()).to.be.a('number'); - expect(built.version()).to.be.greaterThan(1); + expect(built.version()).to.equal(1); built.free(); }); @@ -189,8 +188,8 @@ describe('WasmSdkBuilder', () => { const built = await builder.build(); expect(built).to.be.an.instanceof(sdk.WasmSdk); expect(built.version()).to.be.a('number'); - // Sub-floor pin (1) is raised to the network protocol-version floor. - expect(built.version()).to.be.greaterThan(1); + // Pinned to version 1 — pinned versions are used as-is, not clamped. + expect(built.version()).to.equal(1); built.free(); }); }); From 077120681b1247cda0e28f849d4eb419724cee9b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:54:23 +0200 Subject: [PATCH 07/12] chore: initial version 11 --- packages/rs-sdk/src/sdk.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index 46f4e060e0..c97c60612c 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -62,7 +62,7 @@ pub const DEFAULT_QUORUM_PUBLIC_KEYS_CACHE_SIZE: usize = 100; /// `fetch_max` when the network reports a newer one. It is not a hard floor — an /// explicitly pinned version below it is preserved as-is. const fn min_protocol_version(_network: Network) -> u32 { - dpp::version::v10::PROTOCOL_VERSION_10 // TODO: set real per-network floors once the update mechanism is tested + dpp::version::v11::PROTOCOL_VERSION_11 // TODO: set real per-network floors once the update mechanism is tested } /// The default metadata time tolerance for checkpoint queries in milliseconds @@ -1962,8 +1962,11 @@ mod test { /// under-reservation regression. #[tokio::test] async fn test_refresh_ratchets_up_via_proven_query() { - let mut sdk = mock_sdk_with_auto_detect(10); - assert_eq!(sdk.protocol_version_number(), 10); + let mut sdk = mock_sdk_with_auto_detect(super::min_protocol_version(Network::Mainnet)); + assert_eq!( + sdk.protocol_version_number(), + super::min_protocol_version(Network::Mainnet) + ); expect_epoch_refresh(&mut sdk).await; From 1976b6be259cf7091293ab2761c9a4c9b07f1998 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:09:22 +0200 Subject: [PATCH 08/12] test(sdk): track actual floor in default-seed assertion; refresh stale sdk.rs comments The initial-version-11 bump (077120681b) updated the unit test but left document_query_v0_v1::sdk_builder_default_seeds_atomic_to_floor asserting PROTOCOL_VERSION_10 against an 11 floor. Assert the real floor instead and clear comments left stale by the floor/rename/refresh-relocation refactor. Co-Authored-By: Claude Opus 4.6 --- packages/rs-sdk/src/sdk.rs | 5 +++-- packages/rs-sdk/tests/fetch/document_query_v0_v1.rs | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index c97c60612c..2c2e2977f4 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -1879,8 +1879,9 @@ mod test { // per-network protocol-version floor + non-mainnet boot/refresh // ----------------------------------------------------------------- - /// A testnet SDK boots directly at its per-network floor — the network's - /// live version — without needing a refresh to climb there. + /// An unpinned testnet SDK boots at the `min_protocol_version` floor, just + /// like the mainnet default, and stays there until a proven response ratchets + /// it upward. #[test] fn test_testnet_default_builder_boots_at_per_network_floor() { let sdk = SdkBuilder::new_mock() diff --git a/packages/rs-sdk/tests/fetch/document_query_v0_v1.rs b/packages/rs-sdk/tests/fetch/document_query_v0_v1.rs index 5b37ea462c..c4c48e4e10 100644 --- a/packages/rs-sdk/tests/fetch/document_query_v0_v1.rs +++ b/packages/rs-sdk/tests/fetch/document_query_v0_v1.rs @@ -221,12 +221,12 @@ fn encoder_dispatches_v0_via_query_settings_without_sdk() { fn sdk_builder_default_seeds_atomic_to_floor() { // Auto-detect default uses mainnet, so the atomic seeds to the mainnet // `min_protocol_version` floor, which `version()` returns until the first - // response ratchets it upward. We assert against the actual floor constant - // rather than a hardcoded integer so the test survives future floor bumps. + // response ratchets it upward. PV_11 tracks the flat floor in + // `Sdk::min_protocol_version`. let sdk_default = SdkBuilder::new_mock().build().expect("mock sdk"); assert_eq!( sdk_default.version().protocol_version, - dpp::version::v10::PROTOCOL_VERSION_10 + dpp::version::v11::PROTOCOL_VERSION_11 ); } From 02b63c5f0a9d8be3b48010869ae2eda09001d7d0 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:09:06 +0200 Subject: [PATCH 09/12] fix(sdk): restore per-network protocol-version floor min_protocol_version returns the network-specific minimum (Mainnet -> v11, Testnet/Devnet/Regtest -> v12) instead of a single flat v11 baseline, so an unpinned SDK on a v12 network no longer seeds below the network's live minimum. Kept as a const fn; condensed the rustdoc to the floor-not-a-pin rationale. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/rs-sdk/src/sdk.rs | 20 ++++++++++--------- .../tests/fetch/document_query_v0_v1.rs | 2 +- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index 2c2e2977f4..ce23bfa3a7 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -53,16 +53,18 @@ pub const DEFAULT_CONTRACT_CACHE_SIZE: usize = 100; pub const DEFAULT_TOKEN_CONFIG_CACHE_SIZE: usize = 100; /// How many quorum public keys fit in the cache. pub const DEFAULT_QUORUM_PUBLIC_KEYS_CACHE_SIZE: usize = 100; -/// The default initial protocol version for a network, used when no version is -/// pinned via [`SdkBuilder::with_version`] or seeded via -/// [`SdkBuilder::with_initial_version`]. +/// Per-network lower bound an unpinned SDK seeds from and never drops below. /// -/// This is only the starting point: auto-detect -/// ([`Sdk::maybe_update_protocol_version`]) ratchets the version *upward* via -/// `fetch_max` when the network reports a newer one. It is not a hard floor — an -/// explicitly pinned version below it is preserved as-is. -const fn min_protocol_version(_network: Network) -> u32 { - dpp::version::v11::PROTOCOL_VERSION_11 // TODO: set real per-network floors once the update mechanism is tested +/// A floor, not a pin: auto-detect ([`Sdk::maybe_update_protocol_version`]) still +/// ratchets the version *upward* via `fetch_max` when the network reports a newer +/// one. +const fn min_protocol_version(network: Network) -> u32 { + match network { + Network::Mainnet => dpp::version::v11::PROTOCOL_VERSION_11, + Network::Testnet => dpp::version::v12::PROTOCOL_VERSION_12, + Network::Devnet => dpp::version::v12::PROTOCOL_VERSION_12, + Network::Regtest => dpp::version::v12::PROTOCOL_VERSION_12, + } } /// The default metadata time tolerance for checkpoint queries in milliseconds diff --git a/packages/rs-sdk/tests/fetch/document_query_v0_v1.rs b/packages/rs-sdk/tests/fetch/document_query_v0_v1.rs index c4c48e4e10..ac90fb6880 100644 --- a/packages/rs-sdk/tests/fetch/document_query_v0_v1.rs +++ b/packages/rs-sdk/tests/fetch/document_query_v0_v1.rs @@ -221,7 +221,7 @@ fn encoder_dispatches_v0_via_query_settings_without_sdk() { fn sdk_builder_default_seeds_atomic_to_floor() { // Auto-detect default uses mainnet, so the atomic seeds to the mainnet // `min_protocol_version` floor, which `version()` returns until the first - // response ratchets it upward. PV_11 tracks the flat floor in + // response ratchets it upward. Mainnet's floor is PV_11 in // `Sdk::min_protocol_version`. let sdk_default = SdkBuilder::new_mock().build().expect("mock sdk"); assert_eq!( From 5b0f41ddd38d8dd96a08c28e6e0f8e8906cdd68f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 17 Jun 2026 09:54:25 +0200 Subject: [PATCH 10/12] doc: fix comment --- packages/rs-sdk/src/sdk.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index ce23bfa3a7..4e334b7ede 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -1855,7 +1855,8 @@ mod test { ); } - /// A pin *below* the per-network [`min_protocol_version`] is preserved + /// A pin *below* the per-network [`min_protocol_version`] is preserved as-is + /// (no construction-time clamp) and `version_pinned` stays `true`. #[test] fn test_explicit_pin_below_floor_is_preserved() { use dpp::version::PlatformVersion; From 0d7697b3091917348afc5ac3a2b24ac328370be1 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:22:09 +0200 Subject: [PATCH 11/12] docs(rs-sdk): correct min_protocol_version/with_initial_version rustdoc for no-clamp seeding min_protocol_version no longer enforces a runtime lower bound: with_initial_version is now pub and build() applies no construction-time clamp, so an unpinned SDK can be seeded below the per-network value. Reword both doc blocks to describe the floor as a default seed only, flag the sub-floor seeding hazard on with_initial_version, and point callers needing eager discovery at refresh_protocol_version. Co-Authored-By: Claude Opus 4.6 --- packages/rs-sdk/src/sdk.rs | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index 4e334b7ede..5f8d90e895 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -53,11 +53,13 @@ pub const DEFAULT_CONTRACT_CACHE_SIZE: usize = 100; pub const DEFAULT_TOKEN_CONFIG_CACHE_SIZE: usize = 100; /// How many quorum public keys fit in the cache. pub const DEFAULT_QUORUM_PUBLIC_KEYS_CACHE_SIZE: usize = 100; -/// Per-network lower bound an unpinned SDK seeds from and never drops below. +/// Per-network *default* seed used only when an unpinned SDK has no explicit +/// initial version. /// -/// A floor, not a pin: auto-detect ([`Sdk::maybe_update_protocol_version`]) still -/// ratchets the version *upward* via `fetch_max` when the network reports a newer -/// one. +/// Not a runtime clamp: [`SdkBuilder::with_initial_version`] can seed an unpinned +/// SDK *below* this value (no construction-time floor), and auto-detect +/// ([`Sdk::maybe_update_protocol_version`]) only ratchets the stored version +/// *upward* via `fetch_max` when the network reports a newer one. const fn min_protocol_version(network: Network) -> u32 { match network { Network::Mainnet => dpp::version::v11::PROTOCOL_VERSION_11, @@ -959,12 +961,17 @@ impl SdkBuilder { self } - /// Configure initial platform version, replacing default defined in [`min_protocol_version`]. + /// Override the initial protocol version seed while keeping auto-detect on. /// - /// Auto-detect already starts every unpinned SDK at the per-network - /// [`min_protocol_version`] and ratchets upward via `fetch_max` in - /// `maybe_update_protocol_version` once the network's version is observed. This - /// function allows overriding the initial seed version that auto-detect starts with. + /// Unpinned SDKs otherwise seed at the per-network [`min_protocol_version`] and + /// ratchet upward via `fetch_max` in `maybe_update_protocol_version` once the + /// network's version is observed. This replaces that seed with `version`. + /// + /// The seed is used verbatim — including versions *below* the per-network floor + /// (no construction-time clamp; configuring a valid seed is the caller's + /// responsibility). A sub-floor seed is only corrected once a proven response + /// ratchets the version upward; callers needing eager on-init discovery should + /// call [`Sdk::refresh_protocol_version`] after building. /// /// Seeds `self.version` and keeps `version_pinned` `false`, so auto-detect stays /// on. Builder chains are last-write-wins: a later `with_initial_version` re-enables From 33a69c6414fb45ca52cec7f65d820b791a8f0c25 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:59:34 +0200 Subject: [PATCH 12/12] fix(rs-sdk): guard refresh_protocol_version against panic on proofs-disabled SDK refresh_protocol_version is documented non-fatal but reached an unimplemented!() epoch query on a proofs-disabled SDK, aborting the process before the Result could carry the error. Early-return the current version when proofs are off so the contract holds. Also: document the with_initial_version seed path in Sdk::version(), correct the parse_proof_with_metadata_and_proof bootstrap doc to name the min_protocol_version seed instead of latest(), and drop the ephemeral CMT-008 review-ID from a fetch.rs comment (keeping #3711). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/rs-sdk/src/platform/fetch.rs | 2 +- packages/rs-sdk/src/sdk.rs | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/rs-sdk/src/platform/fetch.rs b/packages/rs-sdk/src/platform/fetch.rs index 1b02e17342..6a7d3bd770 100644 --- a/packages/rs-sdk/src/platform/fetch.rs +++ b/packages/rs-sdk/src/platform/fetch.rs @@ -176,7 +176,7 @@ where ) -> Result<(Option, ResponseMetadata, Proof), Error> { let settings = sdk.query_settings(); let owned_rich: ::Query = query.query(&settings)?; - // INTENTIONAL(CMT-008, #3711): For the common case `Self::Query = Self::Request`, + // INTENTIONAL(#3711): For the common case `Self::Query = Self::Request`, // the blanket `Query for T` impl turns the `query.query(settings)` step into a // pure clone of the same owned request. Real but micro-cost (~63 impls hit // this path). Specializing via a `fn encode_request_owned()` default method on diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index 5f8d90e895..f1ac736e85 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -370,10 +370,17 @@ impl Sdk { /// query fails the failure is **non-fatal**: the stored version is left /// untouched — we never fall back to an unverified one. /// + /// On a proofs-disabled SDK ([`SdkBuilder::with_proofs`]`(false)`) this is a + /// no-op that returns the current version: refresh relies on a proven query, + /// so with proofs off there is no trusted source to ratchet from. + /// /// Returns the SDK's protocol version number after the (possible) ratchet. /// /// [`SdkBuilder::with_version`]: SdkBuilder::with_version pub async fn refresh_protocol_version(&self) -> Result { + if !self.prove() { + return Ok(self.protocol_version_number()); + } if !self.version_pinned { if let Err(error) = ExtendedEpochInfo::fetch_current(self).await { tracing::warn!( @@ -405,8 +412,8 @@ impl Sdk { /// 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. + /// between that version and the seeded [`min_protocol_version`], 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 @@ -536,8 +543,9 @@ impl Sdk { /// Return [Dash Platform version](PlatformVersion) information used by this SDK. /// /// With auto-detection (default) the SDK starts at the per-network - /// [`min_protocol_version`] and then tracks the network's version — - /// auto-detection only ever ratchets *upward* (`fetch_max`). A version pinned + /// [`min_protocol_version`] (or the seed set via + /// [`SdkBuilder::with_initial_version`]) and then tracks the network's version + /// — auto-detection only ever ratchets *upward* (`fetch_max`). A version pinned /// via [`SdkBuilder::with_version()`] is returned as pinned. pub fn version<'v>(&self) -> &'v PlatformVersion { let v = self.protocol_version.load(Ordering::Relaxed);