diff --git a/packages/rs-sdk-ffi/src/protocol_version/queries/mod.rs b/packages/rs-sdk-ffi/src/protocol_version/queries/mod.rs index 5056d931493..d4036c5804a 100644 --- a/packages/rs-sdk-ffi/src/protocol_version/queries/mod.rs +++ b/packages/rs-sdk-ffi/src/protocol_version/queries/mod.rs @@ -1,7 +1,9 @@ // Protocol version queries +pub mod refresh; pub mod upgrade_state; pub mod upgrade_vote_status; // Re-export all public functions for convenient access +pub use refresh::dash_sdk_refresh_protocol_version; pub use upgrade_state::dash_sdk_protocol_version_get_upgrade_state; pub use upgrade_vote_status::dash_sdk_protocol_version_get_upgrade_vote_status; diff --git a/packages/rs-sdk-ffi/src/protocol_version/queries/refresh.rs b/packages/rs-sdk-ffi/src/protocol_version/queries/refresh.rs new file mode 100644 index 00000000000..b474185aa9a --- /dev/null +++ b/packages/rs-sdk-ffi/src/protocol_version/queries/refresh.rs @@ -0,0 +1,76 @@ +use crate::sdk::SDKWrapper; +use crate::types::SDKHandle; +use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult}; +use std::ffi::CString; + +/// Refresh the SDK's protocol version from the connected network. +/// +/// Issues an unproved `getStatus` against the network and ratchets this SDK's +/// auto-detected protocol version up to the network's current Drive protocol +/// version (see [`dash_sdk::Sdk::refresh_protocol_version`]). The resulting +/// protocol version number propagates to every clone of this SDK — including +/// the `Sdk` clone held by a `PlatformWalletManager` — because the version is +/// stored in a shared `Arc`. +/// +/// Call this on app start and after every network switch so fee-sensitive +/// flows (shielded pool shield/unshield/transfer/withdraw) reserve against the +/// network's actual protocol version instead of the SDK's seed version. +/// +/// # Parameters +/// * `sdk_handle` - Handle to the SDK instance. +/// +/// # Returns +/// * On success, a `DashSDKResult` carrying a heap-allocated C string with the +/// decimal protocol version number (e.g. `"12"`). +/// * On failure, a `DashSDKResult` carrying an error. +/// +/// # Safety +/// - `sdk_handle` must be a valid, non-null pointer to an initialized `SDKHandle`. +/// - The function does not retain references to the input pointer beyond the duration of the call. +/// - On success, the returned `DashSDKResult` contains a heap-allocated C string; the caller must +/// free it using the SDK's string-free routine to avoid leaks. +/// - Passing a dangling or invalid pointer for `sdk_handle` results in undefined behavior. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_refresh_protocol_version( + sdk_handle: *const SDKHandle, +) -> DashSDKResult { + if sdk_handle.is_null() { + return DashSDKResult::error(DashSDKError::new( + DashSDKErrorCode::InvalidParameter, + "SDK handle is null".to_string(), + )); + } + + let wrapper = &*(sdk_handle as *const SDKWrapper); + + let result = wrapper + .runtime + .block_on(wrapper.sdk.refresh_protocol_version()); + + match result { + Ok(version) => match CString::new(version.to_string()) { + Ok(c_str) => DashSDKResult::success_string(c_str.into_raw()), + Err(e) => DashSDKResult::error(DashSDKError::new( + DashSDKErrorCode::InternalError, + format!("Failed to create CString: {}", e), + )), + }, + Err(e) => DashSDKResult::error(DashSDKError::new( + DashSDKErrorCode::InternalError, + format!("Failed to refresh protocol version: {}", e), + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_refresh_protocol_version_null_handle() { + unsafe { + let result = dash_sdk_refresh_protocol_version(std::ptr::null()); + assert!(!result.error.is_null()); + } + } +} diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index 05fdfe35d8a..675650f97cd 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -44,6 +44,8 @@ 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. @@ -82,6 +84,41 @@ pub const DEFAULT_QUORUM_PUBLIC_KEYS_CACHE_SIZE: usize = 100; /// # } /// ``` 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, + } +} + /// The default metadata time tolerance for checkpoint queries in milliseconds const ADDRESS_STATE_TIME_TOLERANCE_MS: u64 = 31 * 60 * 1000; @@ -515,9 +552,13 @@ impl Sdk { /// Return [Dash Platform version](PlatformVersion) information used by this SDK. /// - /// When auto-detection is enabled (default), returns [`DEFAULT_INITIAL_PROTOCOL_VERSION`] - /// until the first network response is received, then tracks the network's version. - /// When pinned via [`SdkBuilder::with_version()`], always returns the pinned version. + /// 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. pub fn version<'v>(&self) -> &'v PlatformVersion { let v = self.protocol_version.load(Ordering::Relaxed); PlatformVersion::get(v).unwrap_or_else(|_| PlatformVersion::latest()) @@ -915,8 +956,13 @@ impl SdkBuilder { /// Select specific version of Dash Platform to use. This pins the version and /// disables auto-detection. /// - /// When unset, the SDK starts at [`DEFAULT_INITIAL_PROTOCOL_VERSION`] and - /// ratchets upward via 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. + /// + /// When unset, the SDK starts at `max(DEFAULT_INITIAL_PROTOCOL_VERSION, network + /// floor)` and ratchets upward via auto-detection. pub fn with_version(mut self, version: &'static PlatformVersion) -> Self { self.version = version; self.version_explicit = true; @@ -1047,6 +1093,17 @@ 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 + // 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`. + let initial_protocol_version = self + .version + .protocol_version + .max(min_protocol_version(self.network)); + let sdk= match self.addresses { // non-mock mode Some(addresses) => { @@ -1069,11 +1126,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 self.version; whether auto-detect is on - // is controlled separately by `version_explicit`. - protocol_version: Arc::new(atomic::AtomicU32::new( - self.version.protocol_version, - )), + // 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)), 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)), @@ -1141,9 +1196,7 @@ impl SdkBuilder { dump_dir: self.dump_dir.clone(), proofs:self.proofs, nonce_cache: Default::default(), - protocol_version: Arc::new(atomic::AtomicU32::new( - self.version.protocol_version, - )), + protocol_version: Arc::new(atomic::AtomicU32::new(initial_protocol_version)), auto_detect_protocol_version: !self.version_explicit, context_provider: ArcSwapOption::new(Some(Arc::new(context_provider))), cancel_token: self.cancel_token, @@ -1549,18 +1602,23 @@ mod test { fn test_explicit_version_disables_auto_detect() { use dpp::version::PlatformVersion; - // Explicitly pin to version 1 via with_version() + // 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 + // 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 sdk = SdkBuilder::new_mock() - .with_version(PlatformVersion::get(1).unwrap()) + .with_version(pinned) .build() .expect("mock Sdk should be created"); - assert_eq!(sdk.protocol_version_number(), 1); + assert_eq!(sdk.protocol_version_number(), pinned.protocol_version); assert!(!sdk.auto_detect_protocol_version); - // Network reports version 2 — should be ignored because version is pinned + // Network reports version 12 (> pinned) — should be ignored because version is pinned let metadata = ResponseMetadata { - protocol_version: 2, + protocol_version: dpp::version::v12::PROTOCOL_VERSION_12, height: 1, ..Default::default() }; @@ -1570,7 +1628,7 @@ mod test { assert_eq!( sdk.protocol_version_number(), - 1, + pinned.protocol_version, "pinned version must not be auto-updated" ); } @@ -1579,10 +1637,12 @@ mod test { fn test_with_initial_version_seeds_to_older_network_version() { use dpp::version::PlatformVersion; - // Caller knows the network is on PV 1 and seeds the auto-detect - // atomic accordingly. `version_explicit` stays false, so fetch_max - // can still ratchet upward when the network later moves to a newer PV. - let initial = PlatformVersion::get(1).expect("PV 1 exists"); + // 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 + // 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 sdk = SdkBuilder::new_mock() .with_initial_version(initial) .build() @@ -1590,20 +1650,39 @@ mod test { assert_eq!( sdk.protocol_version_number(), - 1, + floor, "with_initial_version must seed the atomic without pinning" ); - assert_eq!(sdk.version().protocol_version, 1); + assert_eq!(sdk.version().protocol_version, floor); + assert!( + sdk.auto_detect_protocol_version, + "with_initial_version must keep auto-detect enabled" + ); - // Metadata at PV 1 is accepted (matches current seed, no ratchet needed). + // Metadata at the floor is accepted (matches current seed, no ratchet needed). let metadata = ResponseMetadata { - protocol_version: 1, + protocol_version: floor, height: 1, ..Default::default() }; sdk.verify_response_metadata("test", &metadata) .expect("metadata should be valid"); - assert_eq!(sdk.protocol_version_number(), 1); + assert_eq!(sdk.protocol_version_number(), floor); + + // 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" + ); + let metadata = ResponseMetadata { + protocol_version: newer, + height: 2, + ..Default::default() + }; + sdk.verify_response_metadata("test", &metadata) + .expect("metadata should be valid"); + assert_eq!(sdk.protocol_version_number(), newer); } #[test] @@ -1613,8 +1692,16 @@ mod test { // Last-write-wins composability: a later `with_initial_version` // must re-enable auto-detect that an earlier `with_version` // disabled. + // + // `v_old` sits at the mainnet floor (11) 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(1).expect("PV 1 exists"); + let v_old = PlatformVersion::get(super::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" + ); let sdk = SdkBuilder::new_mock() .with_version(v_latest) @@ -1647,12 +1734,18 @@ mod test { fn test_mock_version_follows_outer_sdk_atomic() { use dpp::version::PlatformVersion; - // Build a mock SDK with auto-detect, seeded at PV 1. 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(1).expect("PV 1 exists"); + // 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::min_protocol_version(Network::Mainnet)) + .expect("mainnet floor PV exists"); let v_new = PlatformVersion::latest(); + assert!( + v_old.protocol_version < v_new.protocol_version, + "v_old must be below latest so the ratchet is observable" + ); let mut sdk = SdkBuilder::new_mock() .with_initial_version(v_old) @@ -1688,20 +1781,22 @@ mod test { #[test] fn test_default_builder_seeds_initial_protocol_version_floor() { - // A default builder must seed the SDK at the floor, not latest(). + // 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). 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)); assert_eq!( sdk.protocol_version_number(), - super::DEFAULT_INITIAL_PROTOCOL_VERSION, - "unpinned SDK must boot at the upgrade-safe floor, not latest()" - ); - assert_eq!( - sdk.version().protocol_version, - super::DEFAULT_INITIAL_PROTOCOL_VERSION + expected, + "unpinned SDK must boot at max(initial floor, network floor), not latest()" ); + assert_eq!(sdk.version().protocol_version, expected); assert!( sdk.auto_detect_protocol_version, "default SDK must keep auto-detect enabled" @@ -1713,7 +1808,10 @@ mod test { let sdk = SdkBuilder::new_mock() .build() .expect("mock Sdk should be created"); - let floor = super::DEFAULT_INITIAL_PROTOCOL_VERSION; + // 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)); assert_eq!(sdk.protocol_version_number(), floor); // Ratchet to a fixed known target (PV12), not `floor + N`: stays valid as the @@ -1754,7 +1852,9 @@ mod test { let sdk = SdkBuilder::new_mock() .build() .expect("mock Sdk should be created"); - let floor = super::DEFAULT_INITIAL_PROTOCOL_VERSION; + // Effective boot floor = max(auto-detect initial, per-network minimum). + let floor = super::DEFAULT_INITIAL_PROTOCOL_VERSION + .max(super::min_protocol_version(Network::Mainnet)); assert_eq!(sdk.protocol_version_number(), floor); // Unknown (above LATEST_VERSION): rejected, version unchanged. @@ -1790,9 +1890,16 @@ mod test { fn test_explicit_pin_overrides_default_floor() { use dpp::version::PlatformVersion; - // Pin off the floor so the override is observable wherever the floor sits. - let pinned_number = super::DEFAULT_INITIAL_PROTOCOL_VERSION - 1; - let pinned = PlatformVersion::get(pinned_number).expect("pinned PV exists"); + // 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. + 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" + ); let sdk = SdkBuilder::new_mock() .with_version(pinned) .build() @@ -1800,12 +1907,142 @@ mod test { assert_eq!( sdk.protocol_version_number(), - pinned_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 floor is raised to the floor at construction: + /// the 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() { + use dpp::version::PlatformVersion; + + let floor = super::min_protocol_version(Network::Mainnet); + let below = floor - 1; + let pinned = PlatformVersion::get(below).expect("sub-floor PV exists"); + let sdk = SdkBuilder::new_mock() + .with_version(pinned) + .build() + .expect("mock Sdk should be created"); + + assert_eq!( + sdk.protocol_version_number(), + floor, + "a pin below the network floor must be clamped up to the floor" + ); + // Still pinned: auto-detect stays disabled even though construction raised + // the value to the floor. + assert!(!sdk.auto_detect_protocol_version); + } + + // ----------------------------------------------------------------- + // per-network protocol-version floor + // ----------------------------------------------------------------- + + /// Lock in the Network -> floor mapping (single source of truth in `rs-sdk`). + #[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" + ); + 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" + ); + 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() + .with_network(Network::Testnet) + .build() + .expect("mock Sdk should be created"); + + assert_eq!(sdk.protocol_version_number(), floor); + assert!(sdk.auto_detect_protocol_version); + } + + /// A testnet refresh that reports a version below the floor leaves the SDK at + /// the floor (12), never below it. + #[tokio::test] + async fn test_testnet_refresh_below_floor_stays_at_floor() { + let floor = super::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); + + // Network reports a known version below the floor (e.g. 11). + let below = dpp::version::v11::PROTOCOL_VERSION_11; + assert!( + below < floor, + "test requires a reported version below the floor" + ); + expect_get_status(&sdk, status_response_with_drive_current(below)).await; + + let resulting = sdk + .refresh_protocol_version() + .await + .expect("refresh should succeed"); + + assert_eq!( + resulting, floor, + "a testnet refresh reporting below the floor must leave the SDK at the 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")] @@ -1825,4 +2062,227 @@ mod test { assert_eq!(result.is_err(), expect_err); } + + // ----------------------------------------------------------------- + // refresh_protocol_version + // ----------------------------------------------------------------- + + /// Build a `GetStatusResponse` whose Drive protocol `current` equals + /// `drive_current`, leaving the rest of the version tree populated the + /// minimal amount needed to walk to that field. + fn status_response_with_drive_current( + drive_current: u32, + ) -> dapi_grpc::platform::v0::GetStatusResponse { + use dapi_grpc::platform::v0::get_status_response::{ + get_status_response_v0::{version::protocol, version::Protocol, Version as VersionV0}, + GetStatusResponseV0, Version, + }; + use dapi_grpc::platform::v0::GetStatusResponse; + + let drive = protocol::Drive { + latest: drive_current, + current: drive_current, + next_epoch: drive_current, + }; + let protocol = Protocol { + tenderdash: None, + drive: Some(drive), + }; + let version = VersionV0 { + software: None, + protocol: Some(protocol), + }; + let v0 = GetStatusResponseV0 { + version: Some(version), + node: None, + chain: None, + network: None, + state_sync: None, + time: None, + }; + GetStatusResponse { + version: Some(Version::V0(v0)), + } + } + + /// Build a `GetStatusResponse` with no version block at all (a node that + /// did not report its protocol version). + fn status_response_without_version() -> dapi_grpc::platform::v0::GetStatusResponse { + dapi_grpc::platform::v0::GetStatusResponse { version: None } + } + + /// Register a `GetStatusRequest -> response` expectation on the mock SDK's + /// inner DAPI client so `refresh_protocol_version` can execute it. + async fn expect_get_status( + sdk: &super::Sdk, + response: dapi_grpc::platform::v0::GetStatusResponse, + ) { + use dapi_grpc::platform::v0::{get_status_request, GetStatusRequest}; + use rs_dapi_client::ExecutionResponse; + + let request = GetStatusRequest { + version: Some(get_status_request::Version::V0( + get_status_request::GetStatusRequestV0 {}, + )), + }; + + match sdk.inner { + super::SdkInstance::Mock { ref dapi, .. } => { + let mut guard = dapi.lock().await; + guard + .expect( + &request, + &Ok(ExecutionResponse { + inner: response, + retries: 0, + address: "http://127.0.0.1".parse().expect("valid address"), + }), + ) + .expect("expectation registered"); + } + _ => panic!("expected a mock SDK"), + } + } + + #[test] + fn test_extract_network_protocol_version_present() { + let response = status_response_with_drive_current(12); + assert_eq!( + super::refresh::extract_network_protocol_version(&response), + Some(12) + ); + } + + #[test] + fn test_extract_network_protocol_version_missing_version_block() { + let response = status_response_without_version(); + assert_eq!( + super::refresh::extract_network_protocol_version(&response), + None + ); + } + + /// Seeded at 10, network reports 12 -> SDK ratchets to 12. + /// Mirrors the testnet shielded-fee under-reservation regression. + #[tokio::test] + async fn test_refresh_ratchets_up_to_network_version() { + let sdk = mock_sdk_with_auto_detect(10); + assert_eq!(sdk.protocol_version_number(), 10); + + expect_get_status(&sdk, status_response_with_drive_current(12)).await; + + let resulting = sdk + .refresh_protocol_version() + .await + .expect("refresh should succeed"); + + assert_eq!(resulting, 12, "returned version must reflect the ratchet"); + assert_eq!(sdk.protocol_version_number(), 12); + assert_eq!(sdk.version().protocol_version, 12); + } + + /// An unknown (future) version is ignored by the `maybe_update` + /// guard, leaving the SDK on its current version. + #[tokio::test] + async fn test_refresh_ignores_unknown_version() { + use dpp::version::PlatformVersion; + + let sdk = mock_sdk_with_auto_detect(PlatformVersion::latest().protocol_version); + let original = sdk.protocol_version_number(); + + expect_get_status(&sdk, status_response_with_drive_current(9999)).await; + + let resulting = sdk + .refresh_protocol_version() + .await + .expect("refresh should succeed"); + + assert_eq!(resulting, original, "unknown version must be ignored"); + assert_eq!(sdk.protocol_version_number(), original); + } + + /// A pinned (explicit `with_version`) SDK has auto-detect disabled and + /// must not move even when the network reports a newer version. + #[tokio::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 + // sub-floor pin would be raised to the floor). Refresh must still be a + // no-op: auto-detect is off, and the refresh-time floor clamp is a no-op + // because the value already equals the floor. + let pinned = PlatformVersion::get(super::min_protocol_version(Network::Mainnet)) + .expect("mainnet floor PV exists"); + let sdk = SdkBuilder::new_mock() + .with_version(pinned) + .build() + .expect("mock Sdk should be created"); + assert_eq!(sdk.protocol_version_number(), pinned.protocol_version); + assert!(!sdk.auto_detect_protocol_version); + + expect_get_status( + &sdk, + status_response_with_drive_current(dpp::version::v12::PROTOCOL_VERSION_12), + ) + .await; + + let resulting = sdk + .refresh_protocol_version() + .await + .expect("refresh should succeed"); + + assert_eq!( + resulting, pinned.protocol_version, + "pinned version must not move" + ); + assert_eq!(sdk.protocol_version_number(), pinned.protocol_version); + } + + /// A response without a version block is a non-fatal no-op: the call + /// succeeds and the version stays put. + /// + /// Seeded at the mainnet floor so the refresh-time floor clamp is itself a + /// no-op and we observe only the missing-version-block behavior. + #[tokio::test] + async fn test_refresh_missing_version_is_noop() { + let floor = super::min_protocol_version(Network::Mainnet); + let sdk = mock_sdk_with_auto_detect(floor); + + expect_get_status(&sdk, status_response_without_version()).await; + + let resulting = sdk + .refresh_protocol_version() + .await + .expect("refresh should succeed even without a version block"); + + assert_eq!(resulting, floor); + assert_eq!(sdk.protocol_version_number(), floor); + } + + /// The refresh-time floor is a hard lower bound: even when the network + /// reports a version *below* the per-network minimum (and even on an SDK + /// artificially seeded below the floor), `refresh_protocol_version` leaves the + /// stored version at the floor — never below it. + #[tokio::test] + async fn test_refresh_raises_below_floor_to_network_floor() { + let floor = super::min_protocol_version(Network::Mainnet); + // 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); + + // Network reports a known version that is still below the floor. + expect_get_status(&sdk, status_response_with_drive_current(floor - 1)).await; + + let resulting = sdk + .refresh_protocol_version() + .await + .expect("refresh should succeed"); + + assert_eq!( + resulting, floor, + "refresh must raise a below-floor version up to the network floor" + ); + assert_eq!(sdk.protocol_version_number(), floor); + } } diff --git a/packages/rs-sdk/src/sdk/refresh.rs b/packages/rs-sdk/src/sdk/refresh.rs new file mode 100644 index 00000000000..da234100e92 --- /dev/null +++ b/packages/rs-sdk/src/sdk/refresh.rs @@ -0,0 +1,107 @@ +//! Protocol-version refresh for [`Sdk`]. +//! +//! Houses [`Sdk::refresh_protocol_version`] and its private helper +//! [`extract_network_protocol_version`]. 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`. + +use super::Sdk; +use crate::error::Error; +use rs_dapi_client::{DapiRequestExecutor, IntoInner}; +use std::sync::atomic::Ordering; + +impl Sdk { + /// Query the connected network for its current protocol version and ratchet + /// this SDK's auto-detected protocol version up to it. + /// + /// ## Why this exists (bootstrap problem) + /// + /// An auto-detect SDK (one built without [`SdkBuilder::with_version()`]) is + /// seeded with [`PlatformVersion::latest()`] (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 teaches the SDK the network + /// version eagerly, before any such flow runs. + /// + /// ## How it works + /// + /// Issues an **unproved** `getStatus` request (no proof parsing), which keeps + /// working even when proofed queries fail (e.g. UNIMPLEMENTED on stale + /// evonodes) and is immune to proof-interpretation version skew. The + /// network's current Drive protocol version is read from the response and fed + /// into [`Self::maybe_update_protocol_version`], which applies the usual + /// guards: pinned (non-auto-detect) SDKs are left untouched, version `0` and + /// unknown 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. A response + /// that omits the protocol-version field is treated as a non-fatal no-op: a + /// warning is logged and the current version number is returned unchanged. + pub async fn refresh_protocol_version(&self) -> Result { + use dapi_grpc::platform::v0::{get_status_request, GetStatusRequest}; + + let request = GetStatusRequest { + version: Some(get_status_request::Version::V0( + get_status_request::GetStatusRequestV0 {}, + )), + }; + + let response = self + .execute(request, self.dapi_client_settings) + .await + .into_inner()?; + + match extract_network_protocol_version(&response) { + Some(network_version) => { + self.maybe_update_protocol_version(network_version); + } + None => { + tracing::warn!( + target: "dash_sdk::protocol_version", + "getStatus response did not contain a Drive protocol version; \ + keeping current protocol version" + ); + } + } + + // Refresh-time floor (clamp site 2 of 2; the other is `SdkBuilder::build`). + // Independently of what the network reported — a too-low value the ratchet + // ignored, an unknown/zero version, or a missing version block — 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. + self.protocol_version + .fetch_max(super::min_protocol_version(self.network), Ordering::Relaxed); + + Ok(self.protocol_version_number()) + } +} + +/// Extract the network's current Drive protocol version from a `getStatus` +/// response. +/// +/// Walks `version → V0(v0) → v0.version → protocol → drive → current`, returning +/// `None` if any link in that chain is absent (e.g. a node that did not populate +/// the version block). Mirrors the field path used by +/// `drive_proof_verifier::types::evonode_status::Version::try_from`. +pub(super) fn extract_network_protocol_version( + response: &dapi_grpc::platform::v0::GetStatusResponse, +) -> Option { + use dapi_grpc::platform::v0::get_status_response; + + match &response.version { + Some(get_status_response::Version::V0(v0)) => v0 + .version + .as_ref() + .and_then(|v| v.protocol.as_ref()) + .and_then(|p| p.drive.as_ref()) + .map(|d| d.current), + None => None, + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift index 9ad2b9c5b2d..6c21e6d2c54 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift @@ -509,6 +509,51 @@ public final class SDK: @unchecked Sendable { } } + /// Refresh this SDK's protocol version from the connected network. + /// + /// Issues an unproved `getStatus` on the Rust side and ratchets the SDK's + /// auto-detected protocol version up to the network's current version. The + /// new version is shared across every clone of the underlying `Sdk` + /// (including the clone held by a `PlatformWalletManager`), so fee-sensitive + /// flows pick it up automatically. + /// + /// Call on app start and after every network switch. Bridges + /// `dash_sdk_refresh_protocol_version`. + /// + /// - Returns: the SDK's protocol version number after the (possible) ratchet. + @discardableResult + public func refreshProtocolVersion() throws -> UInt32 { + guard let handle = handle else { + throw SDKError.invalidState("SDK not initialized") + } + + let result = dash_sdk_refresh_protocol_version(handle) + + if result.error != nil { + let error = result.error!.pointee + defer { + dash_sdk_error_free(result.error) + } + throw SDKError.fromDashSDKError(error) + } + + guard result.data != nil else { + throw SDKError.internalError("No protocol version returned") + } + + let cStr = result.data.assumingMemoryBound(to: CChar.self) + let versionStr = String(cString: cStr) + defer { + dash_sdk_string_free(cStr) + } + + guard let version = UInt32(versionStr) else { + throw SDKError.serializationError("Invalid protocol version: \(versionStr)") + } + + return version + } + // TODO: Re-enable when CDashSDKFFI module is working // /// Test the new FFI connection // public func testNewFFI() -> Bool { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift index 71320f832ed..f149aaf7eb2 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift @@ -94,6 +94,11 @@ class AppState: ObservableObject { sdk = newSDK NSLog("✅ AppState: SDK created successfully") + // Eagerly learn the network's protocol version so + // fee-sensitive flows reserve correctly before the + // first metadata-bearing response ratchets the SDK. + refreshProtocolVersion(for: newSDK) + // Load known contracts into the SDK's trusted provider await loadKnownContractsIntoSDK(sdk: newSDK, modelContext: modelContext) @@ -131,6 +136,11 @@ class AppState: ObservableObject { let newSDK = try SDK(network: network) sdk = newSDK + // Eagerly learn the new network's protocol version (see + // `initializeSDK`). Non-fatal: the SDK still ratchets from + // metadata if this fails. + refreshProtocolVersion(for: newSDK) + // Load known contracts into the SDK's trusted provider await loadKnownContractsIntoSDK(sdk: newSDK, modelContext: modelContext) @@ -143,6 +153,27 @@ class AppState: ObservableObject { } } + /// Kick off a network protocol-version refresh for `sdk` without + /// blocking UI readiness. + /// + /// `SDK.refreshProtocolVersion()` blocks (it drives an unproved + /// `getStatus` to completion on the Rust runtime), so run it on a + /// background task. The ratchet propagates to the shared + /// `Arc` behind every clone of the SDK — including the + /// one a `PlatformWalletManager` holds — so shielded fee math sees + /// the network's real version. Failure is non-fatal: the SDK still + /// learns the version later from response metadata. + private func refreshProtocolVersion(for sdk: SDK) { + Task.detached { + do { + let version = try sdk.refreshProtocolVersion() + NSLog("✅ AppState: refreshed protocol version to \(version)") + } catch { + NSLog("⚠️ AppState: protocol version refresh failed (non-fatal): \(error.localizedDescription)") + } + } + } + // Identity, contract, and document mutations are performed // directly on SwiftData now. Views own their `ModelContext` and // write via `PersistentIdentity` / `PersistentDataContract` /