Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions packages/rs-sdk-ffi/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,10 @@ pub struct DashSDKConfig {
/// immediately, caller may free after the FFI call returns.
pub quorum_url: *const c_char,
/// Pin to a specific Dash Platform protocol version.
/// `0` keeps the SDK default (auto-detect / latest); any non-zero value
/// is forwarded to `SdkBuilder::with_version` and rejected if unknown.
/// `0` keeps the SDK default — auto-detect seeded at the default initial
/// protocol-version floor, ratcheting up to the network's version; any
/// non-zero value is forwarded to `SdkBuilder::with_version` and rejected
/// if unknown.
pub platform_version: u32,
}

Expand Down
8 changes: 7 additions & 1 deletion packages/rs-sdk/src/mock/sdk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -520,9 +520,15 @@ impl MockDashPlatformSdk {
let key = Key::new(&request);

let data = match self.from_proof_expectations.get(&key) {
// Report the latest protocol version so the proof path's ratchet
// (`maybe_update_protocol_version`) fires as it would against a real
// network; `default()` reports 0, which the ratchet ignores.
Some(d) => (
Option::<O>::mock_deserialize(self, d),
ResponseMetadata::default(),
ResponseMetadata {
protocol_version: dpp::version::LATEST_VERSION,
..Default::default()
},
Proof::default(),
),
None => {
Expand Down
212 changes: 179 additions & 33 deletions packages/rs-sdk/src/sdk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,38 @@ 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`].
///
/// 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.
///
/// # 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+
/// 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.
///
/// ```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::v10::PROTOCOL_VERSION_10;
/// The default metadata time tolerance for checkpoint queries in milliseconds
const ADDRESS_STATE_TIME_TOLERANCE_MS: u64 = 31 * 60 * 1000;

Expand Down Expand Up @@ -335,7 +367,6 @@ impl Sdk {
}
}

// TODO: Changed to public for tests
/// 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.
Expand All @@ -348,8 +379,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 [`PlatformVersion::latest()`] 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 [`DEFAULT_INITIAL_PROTOCOL_VERSION`] 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
Expand Down Expand Up @@ -388,6 +419,10 @@ impl Sdk {
}
}?;

// Security invariant: proof+signature verification above (the `?`) must
// precede this call, which ratchets the protocol version from the now-trusted
// `metadata.protocol_version`. Never reorder — the ratchet must not consume
// unverified metadata.
self.verify_response_metadata(method_name, &metadata)
.inspect_err(|err| {
tracing::warn!(%err,method=method_name,"received response with stale metadata; try another server");
Expand Down Expand Up @@ -480,7 +515,7 @@ impl Sdk {

/// Return [Dash Platform version](PlatformVersion) information used by this SDK.
///
/// When auto-detection is enabled (default), returns [`PlatformVersion::latest()`]
/// 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.
pub fn version<'v>(&self) -> &'v PlatformVersion {
Expand Down Expand Up @@ -756,7 +791,8 @@ impl Default for SdkBuilder {

cancel_token: CancellationToken::new(),

version: PlatformVersion::latest(),
version: PlatformVersion::get(DEFAULT_INITIAL_PROTOCOL_VERSION)
.expect("DEFAULT_INITIAL_PROTOCOL_VERSION must be a known PlatformVersion"),
Comment thread
Claudius-Maginificent marked this conversation as resolved.
version_explicit: false,
#[cfg(not(target_arch = "wasm32"))]
ca_certificate: None,
Expand Down Expand Up @@ -876,41 +912,31 @@ impl SdkBuilder {

/// Configure platform version.
///
/// Select specific version of Dash Platform to use.
/// Select specific version of Dash Platform to use. This pins the version and
/// disables auto-detection.
///
/// Defaults to [PlatformVersion::latest()].
/// When unset, the SDK starts at [`DEFAULT_INITIAL_PROTOCOL_VERSION`] and
Comment thread
lklimek marked this conversation as resolved.
/// ratchets upward via auto-detection.
pub fn with_version(mut self, version: &'static PlatformVersion) -> Self {
self.version = version;
self.version_explicit = true;
self
}

/// Set the *initial* protocol version seed for auto-detect mode.
///
/// Unlike [`Self::with_version`], this leaves auto-detect active —
/// the SDK starts at `version.protocol_version` and ratchets upward
/// (via `fetch_max` in `maybe_update_protocol_version`) once the
/// network's actual version is observed in response metadata.
///
/// Use this when an SDK built against `PlatformVersion::latest()`
/// must talk to a network running an older protocol version (e.g.
/// a v3.0 testnet from a v3.1+ binary). Without an explicit initial
/// version, the SDK's `version()` fallback returns `latest()` until
/// the first response is parsed, and the upward-only `fetch_max`
/// guard can never ratchet *down* to the older network — leaving
/// any version-dispatched encoders (e.g. the documents query) to
/// ship a too-new wire shape that the network rejects.
///
/// Seeds `self.version` and resets `version_explicit` to `false`, so
/// auto-detect is (re-)enabled. Builder chains use last-write-wins:
/// calling `with_initial_version` after `with_version` restores
/// auto-detect rather than silently keeping it disabled.
///
/// **Caveat**: this protection only holds for encoders whose
/// `drive_abci.query.<name>.default_current_version` is correctly pinned per
/// historical PV. New versioned encoders must follow the same per-PV pinning
/// pattern as `document_query`.
pub fn with_initial_version(mut self, version: &'static PlatformVersion) -> 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).
///
/// Auto-detect already starts every unpinned SDK at
/// [`DEFAULT_INITIAL_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.
///
/// 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;
self.version_explicit = false;
self
Expand Down Expand Up @@ -1660,6 +1686,126 @@ mod test {
);
}

#[test]
fn test_default_builder_seeds_initial_protocol_version_floor() {
// A default builder must seed the SDK at the floor, not latest().
let sdk = SdkBuilder::new_mock()
.build()
.expect("mock Sdk should be created");

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
);
assert!(
sdk.auto_detect_protocol_version,
"default SDK must keep auto-detect enabled"
);
}

#[test]
fn test_default_floor_ratchets_up_but_never_down() {
let sdk = SdkBuilder::new_mock()
.build()
.expect("mock Sdk should be created");
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
// floor advances, and `maybe_update_protocol_version` only accepts known versions.
let target = dpp::version::v12::PROTOCOL_VERSION_12;
assert!(
target > floor,
"ratchet test target must exceed the floor; bump it if the floor reaches v12"
);
sdk.maybe_update_protocol_version(target);
assert_eq!(
sdk.protocol_version_number(),
target,
"auto-detect must ratchet upward from the floor"
);

// Never down: an older network version is ignored.
sdk.maybe_update_protocol_version(floor - 1);
assert_eq!(
sdk.protocol_version_number(),
target,
"ratchet must never downgrade below the highest observed version"
);
}

/// Regression guard for the verify-before-ratchet security invariant.
///
/// The full tampered-*signed*-proof path isn't unit-testable here: it needs a
/// quorum BLS signature, a context provider, and a `FromProof` verifier round-trip.
/// That path's safety rests on `parse_proof_with_metadata_and_proof` running proof
/// verification (the `?`) BEFORE `verify_response_metadata` → `maybe_update_protocol_version`
/// (see the guard comment at that call site). Here we lock in the ratchet's own gates:
/// it must NOT raise the stored version off untrustworthy inputs (unknown / zero / lower),
/// so even a metadata value that slipped past verification can't move the SDK to a bogus
/// protocol version.
#[test]
fn test_ratchet_rejects_unknown_and_non_upward_versions() {
let sdk = SdkBuilder::new_mock()
.build()
.expect("mock Sdk should be created");
let floor = super::DEFAULT_INITIAL_PROTOCOL_VERSION;
assert_eq!(sdk.protocol_version_number(), floor);

// Unknown (above LATEST_VERSION): rejected, version unchanged.
sdk.maybe_update_protocol_version(dpp::version::LATEST_VERSION + 1);
assert_eq!(
sdk.protocol_version_number(),
floor,
"unknown protocol version must not move the stored version"
);

// Zero (e.g. metadata default / stripped field): ignored.
sdk.maybe_update_protocol_version(0);
assert_eq!(
sdk.protocol_version_number(),
floor,
"zero protocol version must be ignored"
);

// Equal: no-op (no spurious downgrade or churn).
sdk.maybe_update_protocol_version(floor);
assert_eq!(sdk.protocol_version_number(), floor);

// Lower known version: ignored by the upward-only guard.
sdk.maybe_update_protocol_version(floor - 1);
assert_eq!(
sdk.protocol_version_number(),
floor,
"lower known version must not downgrade the stored version"
);
}

#[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");
let sdk = SdkBuilder::new_mock()
.with_version(pinned)
.build()
.expect("mock Sdk should be created");

assert_eq!(
sdk.protocol_version_number(),
pinned_number,
"explicit with_version must win over the default floor"
);
assert!(!sdk.auto_detect_protocol_version);
}

#[test_matrix([90,91,100,109,110], 100, 10, false; "valid time")]
#[test_matrix([0,89,111], 100, 10, true; "invalid time")]
#[test_matrix([0,100], [0,100], 100, false; "zero time")]
Expand Down
51 changes: 51 additions & 0 deletions packages/rs-sdk/tests/fetch/common.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
use dash_sdk::platform::fetch_current_no_parameters::FetchCurrent;
use dash_sdk::platform::types::epoch::EpochQuery;
use dash_sdk::platform::LimitQuery;
use dash_sdk::{
mock::Mockable,
platform::{Query, QuerySettings},
Sdk,
};
use dpp::block::extended_epoch_info::v0::{ExtendedEpochInfoV0, ExtendedEpochInfoV0Getters};
use dpp::block::extended_epoch_info::ExtendedEpochInfo;
use dpp::data_contract::config::DataContractConfig;
use dpp::{data_contract::DataContractFactory, prelude::Identifier};
use hex::ToHex;
Expand Down Expand Up @@ -98,6 +103,52 @@ pub fn mock_data_contract(
.data_contract_owned()
}

/// 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
/// 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
/// `ExtendedEpochInfo::fetch_current` expectation and consumes it, leaving the SDK
/// ratcheted to `LATEST_VERSION`.
pub(crate) async fn bootstrap_mock_sdk_to_latest(sdk: &mut Sdk) {
let query = LimitQuery {
query: EpochQuery {
start: None,
ascending: false,
},
limit: Some(1),
start_info: None,
};

let epoch = ExtendedEpochInfo::from(ExtendedEpochInfoV0 {
index: 0,
first_block_time: 0,
first_block_height: 0,
first_core_block_height: 0,
fee_multiplier_permille: 0,
protocol_version: dpp::version::LATEST_VERSION,
});

sdk.mock()
.expect_fetch::<ExtendedEpochInfo, _>(query, Some(epoch.clone()))
.await
.expect("register epoch bootstrap expectation");

let fetched = ExtendedEpochInfo::fetch_current(sdk)
.await
.expect("bootstrap fetch_current should ratchet the SDK to latest");

assert_eq!(fetched.index(), epoch.index());
assert_eq!(
sdk.version().protocol_version,
dpp::version::LATEST_VERSION,
"bootstrap must ratchet the auto-detect SDK to the network's latest protocol version"
);
}

/// Enable logging for tests
pub fn setup_logs() {
let make_writer = if should_emit_test_logs_to_stdout() {
Expand Down
Loading
Loading