From 58ccdc8832578b608ad16d0f95e6850b708d8e88 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 25 Jul 2026 20:05:22 +0800 Subject: [PATCH 1/3] fix(sdk): restore proved current-epoch fetch with two-step explicit-start query ExtendedEpochInfo::fetch_current sent a descending epoch query without an explicit start, which the proof verifier now rejects fail-closed (resolving "the last epoch" during verification would require trusting unsigned response metadata). Every Sdk::refresh_protocol_version at startup failed with "proved descending epoch queries require an explicit start epoch". An explicit descending start at MAX_EPOCH does not work either: Drive pre-creates perpetual_storage_epochs (2000) empty epoch trees above the current one, and a proved descending query starting inside that window consumes its limit on the empty trees, verifiably returning no epochs (confirmed against testnet and pinned by a new rs-drive test). fetch_current now issues two proved, guard-compliant queries: a genesis-epoch probe whose response metadata carries the current-epoch hint, then a descending fetch with an explicit start at that hint. The hint only shapes the request; proof verification stays a pure function of the request and quorum-signed state. An inflated hint lands in the provably-empty future window and yields EpochNotFound; a deflated hint has the same exposure as the pre-guard metadata trust, now documented on the impl. Also: - rs-sdk-ffi platform_status used the same broken shape via fetch_many; it now uses fetch_current (wasm-sdk is fixed transitively). - Mock helpers register both expectations to keep matching fetch_current. - test_epoch_fetch_current expects success again; its offline vectors are regenerated from a real testnet response (epoch 17673) and replay the full proof-verification path. - New regression tests: query shapes pass the FromProof guard and fit Drive's epoch key encoding; explicit-start descending requests pass the guard; descending-from-MAX_EPOCH provably returns no epochs. - Fix pre-existing drive-proof-verifier test compile break: three tests still referenced StateTransitionProofResult after the rename to StateTransitionProofOutcome. Co-Authored-By: Claude Fable 5 --- packages/rs-drive-proof-verifier/src/proof.rs | 55 +++++- .../system/verify_epoch_infos/v0/mod.rs | 31 +++- .../src/system/queries/platform_status.rs | 58 +++--- packages/rs-sdk/src/platform/types/epoch.rs | 174 ++++++++++++++++-- packages/rs-sdk/src/sdk.rs | 18 +- packages/rs-sdk/tests/fetch/common.rs | 17 +- packages/rs-sdk/tests/fetch/epoch.rs | 26 ++- ...67089535588985622579e77969e0ffd68afc7.json | Bin 65357 -> 0 bytes ...5e13f72f6116c5806f2674e8859e9e5aa069f.json | Bin 0 -> 80224 bytes ...6caf3663c40a12d3b03827006d66058e439ac.json | Bin 0 -> 81443 bytes ...48c03122daf7ab2e77108f4bf44af1ad15eae.json | Bin 62198 -> 0 bytes ...99a143d6ae990bb9bc89cb34f7c5db8b1b705.json | 1 - ...d5ce55258ac3ade86f8eda82cfb8dd17f79aa.json | 1 + 13 files changed, 298 insertions(+), 83 deletions(-) delete mode 100644 packages/rs-sdk/tests/vectors/test_epoch_fetch_current/msg_GetEpochsInfoRequest_1b87e649557ccb609adb9e2904c67089535588985622579e77969e0ffd68afc7.json create mode 100644 packages/rs-sdk/tests/vectors/test_epoch_fetch_current/msg_GetEpochsInfoRequest_2a013be99da0db14facf92af4ce5e13f72f6116c5806f2674e8859e9e5aa069f.json create mode 100644 packages/rs-sdk/tests/vectors/test_epoch_fetch_current/msg_GetEpochsInfoRequest_b2b426ac4a52cb4cb08904c63386caf3663c40a12d3b03827006d66058e439ac.json delete mode 100644 packages/rs-sdk/tests/vectors/test_epoch_fetch_current/msg_GetIdentityRequest_1d1e53ab5e04d9ec5dce4ff9ac048c03122daf7ab2e77108f4bf44af1ad15eae.json delete mode 100644 packages/rs-sdk/tests/vectors/test_epoch_fetch_current/quorum_pubkey-106-1f0a25d463a2912cd31dd4f91b899a143d6ae990bb9bc89cb34f7c5db8b1b705.json create mode 100644 packages/rs-sdk/tests/vectors/test_epoch_fetch_current/quorum_pubkey-6-0000006d47d12e570d70924b9e6d5ce55258ac3ade86f8eda82cfb8dd17f79aa.json diff --git a/packages/rs-drive-proof-verifier/src/proof.rs b/packages/rs-drive-proof-verifier/src/proof.rs index 2e3fa15de25..4faf625a744 100644 --- a/packages/rs-drive-proof-verifier/src/proof.rs +++ b/packages/rs-drive-proof-verifier/src/proof.rs @@ -3918,7 +3918,7 @@ mod tests { })), }; let provider = unreachable_provider(); - let err = >::maybe_from_proof( request, @@ -5121,6 +5121,53 @@ mod tests { } } + #[test] + fn epochs_info_descending_with_explicit_max_start_passes_guard() { + // The SDK's `fetch_current` sends a descending query with an explicit + // start (the hinted current epoch index). Any explicit start — up to + // MAX_EPOCH, the highest index that fits Drive's epoch key encoding — + // makes the request fully self-describing, so it must get past the + // explicit-start guard and proceed to proof verification (which here + // fails on the garbage proof, not on the request shape). + use dapi_grpc::platform::v0::get_epochs_info_request::GetEpochsInfoRequestV0; + use dpp::block::epoch::MAX_EPOCH; + use platform::get_epochs_info_response::{ + get_epochs_info_response_v0::Result as V0Result, GetEpochsInfoResponseV0, Version, + }; + let response = platform::GetEpochsInfoResponse { + version: Some(Version::V0(GetEpochsInfoResponseV0 { + result: Some(V0Result::Proof(Proof::default())), + metadata: Some(default_metadata_with_epoch(10)), + })), + }; + let request = platform::GetEpochsInfoRequest { + version: Some(platform::get_epochs_info_request::Version::V0( + GetEpochsInfoRequestV0 { + start_epoch: Some(MAX_EPOCH as u32), + count: 1, + ascending: false, + prove: true, + }, + )), + }; + let provider = unreachable_provider(); + + let err = + >::maybe_from_proof( + request, + response, + Network::Testnet, + default_platform_version(), + &provider, + ) + .unwrap_err(); + + assert!( + !matches!(err, Error::RequestError { .. }), + "explicit-start descending query must pass the guard, got: {err:?}" + ); + } + #[test] fn extended_epoch_info_single_bubbles_empty_version() { // Ensures the wrapper passes through error from inner impl. @@ -6023,7 +6070,7 @@ mod tests { }; let response = platform::WaitForStateTransitionResultResponse::default(); let provider = unreachable_provider(); - let err = >::maybe_from_proof( request, @@ -6039,7 +6086,7 @@ mod tests { #[test] fn broadcast_state_transition_protocol_error_fires_before_metadata_check() { // This test pins the ORDERING of validation in - // `StateTransitionProofResult::maybe_from_proof` for broadcast + // `StateTransitionProofOutcome::maybe_from_proof` for broadcast // state transitions: proof extraction -> state_transition decode -> // metadata check. An invalid state_transition payload triggers // `ProtocolError` on decode BEFORE the missing-metadata branch is @@ -6064,7 +6111,7 @@ mod tests { })), }; let provider = unreachable_provider(); - let err = >::maybe_from_proof( request, diff --git a/packages/rs-drive/src/verify/system/verify_epoch_infos/v0/mod.rs b/packages/rs-drive/src/verify/system/verify_epoch_infos/v0/mod.rs index 51308fc9f2f..0cdccfbd4c8 100644 --- a/packages/rs-drive/src/verify/system/verify_epoch_infos/v0/mod.rs +++ b/packages/rs-drive/src/verify/system/verify_epoch_infos/v0/mod.rs @@ -266,7 +266,7 @@ mod tests { use crate::util::batch::grovedb_op_batch::GroveDbOpBatchV0Methods; use crate::util::batch::GroveDbOpBatch; use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; - use dpp::block::epoch::Epoch; + use dpp::block::epoch::{Epoch, MAX_EPOCH}; #[test] fn should_prove_and_verify_epoch_infos_ascending() { @@ -442,5 +442,34 @@ mod tests { } .into() ); + + // Descending with a start above the current epoch does NOT skip forward to + // the newest started epoch: the initial state structure pre-creates empty + // epoch trees ahead of the current one, and the query limit is consumed by + // those empty trees, so the proof verifiably contains no epochs at all. + // This is why "fetch the current epoch" cannot be expressed as a single + // descending query from MAX_EPOCH — the SDK's `fetch_current` must first + // learn the current epoch index and use it as an explicit start (as the + // in-range `Some(1)` queries above do). + let max_start_proof = drive + .prove_epochs_infos(MAX_EPOCH, 1, false, None, platform_version) + .expect("should prove epoch infos from MAX_EPOCH"); + + let (_root_hash, far_future_start_infos) = Drive::verify_epoch_infos( + &max_start_proof, + 1, + Some(MAX_EPOCH), + 1, + false, + platform_version, + ) + .expect("should verify epoch infos descending from MAX_EPOCH"); + + assert_eq!( + far_future_start_infos.len(), + 0, + "descending from inside the pre-created empty epoch window must \ + provably return no epochs, not the newest started epoch" + ); } } diff --git a/packages/rs-sdk-ffi/src/system/queries/platform_status.rs b/packages/rs-sdk-ffi/src/system/queries/platform_status.rs index abe63294f57..f64ac109924 100644 --- a/packages/rs-sdk-ffi/src/system/queries/platform_status.rs +++ b/packages/rs-sdk-ffi/src/system/queries/platform_status.rs @@ -4,8 +4,7 @@ use crate::types::SDKHandle; use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, DashSDKResultDataType}; use dash_sdk::dpp::block::extended_epoch_info::v0::ExtendedEpochInfoV0Getters; use dash_sdk::dpp::block::extended_epoch_info::ExtendedEpochInfo; -use dash_sdk::platform::types::epoch::EpochQuery; -use dash_sdk::platform::{FetchMany, LimitQuery}; +use dash_sdk::platform::fetch_current_no_parameters::FetchCurrent; use std::ffi::CString; use std::os::raw::c_void; @@ -64,40 +63,29 @@ fn get_platform_status(sdk_handle: *const SDKHandle) -> Result { rt.block_on(async move { // Query for the most recent epoch - let query = LimitQuery { - query: EpochQuery { - start: None, - ascending: false, // Get most recent first - }, - limit: Some(1), - start_info: None, - }; + match ExtendedEpochInfo::fetch_current(&sdk).await { + Ok(epoch) => { + // Calculate current block height + // This is an approximation - the actual current block height would need a different query + let block_height = epoch.first_block_height(); + let core_height = epoch.first_core_block_height(); - match ExtendedEpochInfo::fetch_many(&sdk, query).await { - Ok(epochs) => { - // Get the first (most recent) epoch - if let Some((_, Some(epoch))) = epochs.iter().next() { - // Calculate current block height - // This is an approximation - the actual current block height would need a different query - let block_height = epoch.first_block_height(); - let core_height = epoch.first_core_block_height(); - - let json = format!( - r#"{{"version":{},"network":"{}","blockHeight":{},"coreHeight":{}}}"#, - 10, // Protocol version - network_str, - block_height, - core_height - ); - Ok(json) - } else { - // If no epochs found, return default values - let json = format!( - r#"{{"version":{},"network":"{}","blockHeight":0,"coreHeight":0}}"#, - 10, network_str - ); - Ok(json) - } + let json = format!( + r#"{{"version":{},"network":"{}","blockHeight":{},"coreHeight":{}}}"#, + 10, // Protocol version + network_str, + block_height, + core_height + ); + Ok(json) + } + Err(dash_sdk::Error::EpochNotFound) => { + // If no epochs found, return default values + let json = format!( + r#"{{"version":{},"network":"{}","blockHeight":0,"coreHeight":0}}"#, + 10, network_str + ); + Ok(json) } Err(e) => Err(format!("Failed to fetch platform status: {}", e)), } diff --git a/packages/rs-sdk/src/platform/types/epoch.rs b/packages/rs-sdk/src/platform/types/epoch.rs index 5983143f72c..e9c9da3a044 100644 --- a/packages/rs-sdk/src/platform/types/epoch.rs +++ b/packages/rs-sdk/src/platform/types/epoch.rs @@ -1,7 +1,11 @@ //! Epoch-related types and helpers use async_trait::async_trait; use dapi_grpc::platform::v0::{GetEpochsInfoRequest, Proof, ResponseMetadata}; -use dpp::block::{epoch::EpochIndex, extended_epoch_info::ExtendedEpochInfo}; +use dpp::block::{ + epoch::{EpochIndex, MAX_EPOCH}, + extended_epoch_info::ExtendedEpochInfo, +}; +use dpp::fee::epoch::GENESIS_EPOCH_INDEX; use crate::platform::fetch_current_no_parameters::FetchCurrent; use crate::{ @@ -14,22 +18,40 @@ pub type Epoch = ExtendedEpochInfo; #[async_trait] impl FetchCurrent for ExtendedEpochInfo { + /// Fetch the current epoch. + /// + /// The proof verifier rejects proved descending epoch queries without an explicit + /// start epoch: resolving "the last epoch" server-side would force verification to + /// trust unsigned response metadata. An explicit descending start does not help by + /// itself either — Drive pre-creates thousands of empty epoch trees above the + /// current one, and a proved descending query starting inside that window provably + /// returns nothing (the query limit is consumed by the empty trees). + /// + /// So the fetch is done in two proved, guard-compliant steps: + /// + /// 1. Probe: fetch the genesis epoch (explicit ascending start), and take the + /// current-epoch *hint* from the response metadata. + /// 2. Fetch the newest started epoch at or below the hint (explicit descending + /// start). + /// + /// The hint only shapes the second request; proof verification stays a pure + /// function of the request and quorum-signed state. A node inflating the hint + /// lands the query in the provably-empty future window, yielding + /// [`Error::EpochNotFound`] rather than a bogus epoch. A node deflating the hint + /// can present a stale epoch as current — the same exposure as trusting the + /// node's metadata directly, so treat the result accordingly. async fn fetch_current(sdk: &Sdk) -> Result { let (epoch, _) = Self::fetch_current_with_metadata(sdk).await?; Ok(epoch) } async fn fetch_current_with_metadata(sdk: &Sdk) -> Result<(Self, ResponseMetadata), Error> { - let query = LimitQuery { - query: EpochQuery { - start: None, - ascending: false, - }, - limit: Some(1), - start_info: None, - }; + let (_, probe_metadata) = + Self::fetch_with_metadata(sdk, current_epoch_probe_query(), None).await?; - let (epoch, metadata) = Self::fetch_with_metadata(sdk, query, None).await?; + let hint = epoch_hint_from_metadata(&probe_metadata); + let (epoch, metadata) = + Self::fetch_with_metadata(sdk, current_epoch_query(hint), None).await?; Ok((epoch.ok_or(Error::EpochNotFound)?, metadata)) } @@ -37,21 +59,43 @@ impl FetchCurrent for ExtendedEpochInfo { async fn fetch_current_with_metadata_and_proof( sdk: &Sdk, ) -> Result<(Self, ResponseMetadata, Proof), Error> { - let query = LimitQuery { - query: EpochQuery { - start: None, - ascending: false, - }, - limit: Some(1), - start_info: None, - }; + let (_, probe_metadata) = + Self::fetch_with_metadata(sdk, current_epoch_probe_query(), None).await?; + let hint = epoch_hint_from_metadata(&probe_metadata); let (epoch, metadata, proof) = - Self::fetch_with_metadata_and_proof(sdk, query, None).await?; + Self::fetch_with_metadata_and_proof(sdk, current_epoch_query(hint), None).await?; Ok((epoch.ok_or(Error::EpochNotFound)?, metadata, proof)) } } + +/// First step of [`ExtendedEpochInfo::fetch_current`]: a proved query with an +/// explicit start whose response metadata carries the current-epoch hint. +fn current_epoch_probe_query() -> LimitQuery { + LimitQuery { + query: EpochQuery::genesis(), + limit: Some(1), + start_info: None, + } +} + +/// Second step of [`ExtendedEpochInfo::fetch_current`]: fetch the newest started +/// epoch at or below the hinted index. +fn current_epoch_query(hint: EpochIndex) -> LimitQuery { + LimitQuery { + query: EpochQuery::newest_at_or_below(hint), + limit: Some(1), + start_info: None, + } +} + +/// Extract the current-epoch hint from response metadata, clamped to the range +/// Drive's epoch key encoding can express. +fn epoch_hint_from_metadata(metadata: &ResponseMetadata) -> EpochIndex { + metadata.epoch.min(MAX_EPOCH as u32) as EpochIndex +} + /// Query used to fetch multiple epochs from Platform. #[derive(Clone, Debug)] pub struct EpochQuery { @@ -63,11 +107,43 @@ pub struct EpochQuery { /// /// * if ascending is true, then it is the first epoch on Platform (eg. epoch 0). /// * if ascending is false, then it is the last epoch on Platform (eg. most recent epoch). + /// Note that proved descending queries without an explicit start are rejected by the + /// proof verifier (resolving "the last epoch" would require trusting unsigned response + /// metadata); use [`ExtendedEpochInfo::fetch_current`](crate::platform::fetch_current_no_parameters::FetchCurrent) + /// or [`EpochQuery::newest_at_or_below()`] instead. pub start: Option, /// Sort order. Default is ascending (true), which means that the first returned epoch is the oldest one. pub ascending: bool, } +impl EpochQuery { + /// Ascending query with an explicit start at the genesis epoch. + /// + /// Combined with `limit: Some(1)`, this is the cheapest proved epoch query with a + /// fully request-derived range, used by [`ExtendedEpochInfo::fetch_current`] as a + /// probe for the current-epoch hint in response metadata. + pub fn genesis() -> Self { + Self { + start: Some(GENESIS_EPOCH_INDEX), + ascending: true, + } + } + + /// Descending query with an explicit start, returning the newest started epochs at + /// or below `start`. + /// + /// With `limit: Some(1)` and `start` set to the current epoch index, this returns + /// the current epoch. Do not pass a `start` far above the current epoch: Drive + /// pre-creates empty epoch trees ahead of the current one, and a proved descending + /// query starting inside that empty window provably returns no epochs. + pub fn newest_at_or_below(start: EpochIndex) -> Self { + Self { + start: Some(start), + ascending: false, + } + } +} + impl Default for EpochQuery { fn default() -> Self { Self { @@ -94,3 +170,63 @@ impl Query for EpochQuery { LimitQuery::from(self.clone()).query(settings) } } + +#[cfg(test)] +mod tests { + use super::*; + use dapi_grpc::platform::v0::get_epochs_info_request; + use dpp::block::epoch::EPOCH_KEY_OFFSET; + use rs_dapi_client::RequestSettings; + + fn query_settings(request_settings: &RequestSettings) -> crate::platform::QuerySettings<'_> { + crate::platform::QuerySettings { + request_settings, + protocol_version: dpp::version::PlatformVersion::latest(), + prove: true, + } + } + + /// Both queries issued by `fetch_current` must satisfy the proof verifier's + /// fail-closed guard: an explicit start epoch on every proved descending + /// query, and starts that survive Drive's `+ EPOCH_KEY_OFFSET` key encoding + /// without overflowing `u16`. + #[test] + fn should_build_current_epoch_queries_verifiable_without_metadata() { + let request_settings = RequestSettings::default(); + let settings = query_settings(&request_settings); + + // Step 1: the probe is ascending with an explicit genesis start. + let probe = current_epoch_probe_query() + .query(&settings) + .expect("build probe query"); + let get_epochs_info_request::Version::V0(probe_v0) = probe.version.expect("version"); + assert_eq!(probe_v0.start_epoch, Some(GENESIS_EPOCH_INDEX as u32)); + assert!(probe_v0.ascending); + assert_eq!(probe_v0.count, 1); + + // Step 2: the fetch is descending with an explicit start at the hint. + for hint in [0u32, 42, u32::MAX] { + let metadata = dapi_grpc::platform::v0::ResponseMetadata { + epoch: hint, + ..Default::default() + }; + let request = current_epoch_query(epoch_hint_from_metadata(&metadata)) + .query(&settings) + .expect("build query"); + let get_epochs_info_request::Version::V0(v0) = request.version.expect("version"); + + let start_epoch = v0.start_epoch.expect( + "fetch_current must send an explicit start epoch, \ + or proof verification rejects the descending query", + ); + assert!(!v0.ascending); + assert_eq!(v0.count, 1); + assert_eq!(start_epoch, hint.min(MAX_EPOCH as u32)); + // Guard against overflow in Drive's prove/verify key encoding. + u16::try_from(start_epoch) + .expect("start epoch fits u16") + .checked_add(EPOCH_KEY_OFFSET) + .expect("start epoch must fit Drive's epoch key encoding"); + } + } +} diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index b7ecb800822..459907332c6 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -2104,12 +2104,16 @@ mod test { use crate::platform::LimitQuery; use dpp::block::extended_epoch_info::{v0::ExtendedEpochInfoV0, ExtendedEpochInfo}; - // Must match the query `ExtendedEpochInfo::fetch_current` issues. + // Must match the two queries `ExtendedEpochInfo::fetch_current` issues: + // a genesis probe, then a descending fetch from the hinted current epoch + // (mock expectation metadata reports epoch 0, so the hint is 0). + let probe_query = LimitQuery { + query: EpochQuery::genesis(), + limit: Some(1), + start_info: None, + }; let query = LimitQuery { - query: EpochQuery { - start: None, - ascending: false, - }, + query: EpochQuery::newest_at_or_below(0), limit: Some(1), start_info: None, }; @@ -2123,6 +2127,10 @@ mod test { protocol_version: dpp::version::LATEST_VERSION, }); + sdk.mock() + .expect_fetch::(probe_query, Some(epoch.clone())) + .await + .expect("register epoch probe expectation"); sdk.mock() .expect_fetch::(query, Some(epoch)) .await diff --git a/packages/rs-sdk/tests/fetch/common.rs b/packages/rs-sdk/tests/fetch/common.rs index 853d06dca12..13ebecdc05f 100644 --- a/packages/rs-sdk/tests/fetch/common.rs +++ b/packages/rs-sdk/tests/fetch/common.rs @@ -114,11 +114,16 @@ pub fn mock_data_contract( /// `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) { + // `fetch_current` issues two queries: a genesis probe, then a descending + // fetch from the hinted current epoch (mock expectation metadata reports + // epoch 0, so the hint is 0). + let probe_query = LimitQuery { + query: EpochQuery::genesis(), + limit: Some(1), + start_info: None, + }; let query = LimitQuery { - query: EpochQuery { - start: None, - ascending: false, - }, + query: EpochQuery::newest_at_or_below(0), limit: Some(1), start_info: None, }; @@ -132,6 +137,10 @@ pub(crate) async fn bootstrap_mock_sdk_to_latest(sdk: &mut Sdk) { protocol_version: dpp::version::LATEST_VERSION, }); + sdk.mock() + .expect_fetch::(probe_query, Some(epoch.clone())) + .await + .expect("register epoch probe expectation"); sdk.mock() .expect_fetch::(query, Some(epoch.clone())) .await diff --git a/packages/rs-sdk/tests/fetch/epoch.rs b/packages/rs-sdk/tests/fetch/epoch.rs index 33eb0685896..1f0d1e2e575 100644 --- a/packages/rs-sdk/tests/fetch/epoch.rs +++ b/packages/rs-sdk/tests/fetch/epoch.rs @@ -163,15 +163,15 @@ async fn test_epoch_fetch_future() { assert!(epoch.is_none()); } -/// Fetching the "current" epoch through a proved request must fail closed. +/// Given a proved request, `fetch_current` returns the current epoch. /// -/// `fetch_current` maps to a descending epoch query without an explicit start, -/// which previously selected its upper bound from unsigned response metadata. -/// Because that selector is not part of the authenticated state, a malicious -/// node could cap the proof below the real chain tip and pass off a stale epoch -/// as current. The proof verifier now rejects such queries until an -/// authenticated current-epoch marker exists (security finding DS-CAND-374); -/// callers must fetch a specific epoch by explicit index instead. +/// `fetch_current` issues two proved queries with request-derived ranges: a +/// genesis-epoch probe (whose response metadata hints the current epoch index) +/// and a descending fetch with an explicit start at that hint. Both pass the +/// proof verifier's guard against descending queries without an explicit start +/// (resolving "the last epoch" during verification would require trusting +/// unsigned metadata, letting a malicious node pass off a stale epoch as +/// current). #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_epoch_fetch_current() { setup_logs(); @@ -179,12 +179,10 @@ async fn test_epoch_fetch_current() { let cfg = Config::new(); let sdk = cfg.setup_api("test_epoch_fetch_current").await; - let error = ExtendedEpochInfo::fetch_current(&sdk) + let epoch = ExtendedEpochInfo::fetch_current(&sdk) .await - .expect_err("proved current-epoch fetch must fail closed"); + .expect("fetch current epoch"); - assert!( - error.to_string().contains("explicit start epoch"), - "expected an explicit-start rejection, got: {error}" - ); + // The returned epoch is a real one, not the MAX_EPOCH query bound. + assert!(epoch.index() < dpp::block::epoch::MAX_EPOCH); } diff --git a/packages/rs-sdk/tests/vectors/test_epoch_fetch_current/msg_GetEpochsInfoRequest_1b87e649557ccb609adb9e2904c67089535588985622579e77969e0ffd68afc7.json b/packages/rs-sdk/tests/vectors/test_epoch_fetch_current/msg_GetEpochsInfoRequest_1b87e649557ccb609adb9e2904c67089535588985622579e77969e0ffd68afc7.json deleted file mode 100644 index 6563d3ed893ca41f1b2b2351b4fe05339303b133..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 65357 zcmeI&-ELLa6$Rj4^Ar)+=fcO>z{yMW0jjEsQrbq1AOez`h+yASI-j5C`*Z%^zu)HX`E%~~ zS#)!{ecpASZo0emCjb7WRUg(~UDh>EPnT!;>(%(*d3<-^gpcPW%!b=AubN83e)=(p zoON?K-JbiCo34GI>OA4@e{W8g1T}JSPjci4v4u#Oy(l%xx-P$J@V$Ti%RS-D zSZ<%4ZlByA&cq?*et*}^$GlSLd(&wlW?^QrN9axQ_TxFLKDVda>rtquBeLiH_V%iE zyA7PbIBoRBL@d zg>&FM;w(_(oX;}me0}@t5jOqa(-Jh{GNgpL!^+PI=u;|wTFI6#|9-!+esM?@@d1t{ z>@syhaTly#Tyg35zpj-#_uS9KJBw4(RyJH~g^rznvBi_K$ItKjd&T%^3RMQC5E(&8 zPpUaspLnuGL%LUGceBp3OQHSv&K=v`6VYv*@j zYgDmhO`Hllb-Iy=sI`pJ#087$G+)B=)HWdzNy_sxAimr~6Hkj4>NX_8BIgT3dU$v! zaLo1QY%v6cuYOL_P?D!mxTTE3LbbfY5UOf{NER4U3rjo?b_&Gk8pqAEAqhPyRT9x0 zhsn&x;0!5@`TF?!VwcAU0%D9Slz4AJTU%R%RJ}r^6gqJh$z2d{7CKa5q3ZwWQHiR7 z3DvtLwj*O?uRy{VzSvamB8|j+B$q}-yh>EbMR5-I@DW3D zg=F0WkAX{;^l7(W?!$T5n z$I+47$Z3a`MFU{S6TEKyNAn8qvK*jzL!nS3fkiKjqF%8fr%e0caMi&<4F zY!#)c=G5+T`$dL zqpF9y>MRk3)Gg^O!qT!@=Poq-Ug4dnxey$&3o+!LANYhMjYqC%;vqS0!S|4AlIm9=nV(#fQU54i!3q*|=S3aW*0Rp>gm5TjZs{KXYzRIiZX zfvURV7w2DFM0eTJUFytoMGj|rs3LNO0I>=sw%;xI$X5Ev71KR}tnPk2(4XTt11i^>T zn@NF4ON^z(1$RSqVt#r*{3ZU_3&qot zW{YTlr7YG*sFg_CNh4?VRV`GaqRyDY20NvzaHBxzlTo-tz$J^3h*?Jx7e=+P-b}ki zrHY6wb&vp4E&-iLW!OpBI>pUIq`8zK=@h8wZV@LhNoakdlBr{5LA)KjGTT=;JRZwB z%~63%*4k4xW{(YNsDEy~iedjG1Z8mzBAIor)W5*jjZhjA}^QxH0RM1saHU znclKtQ;~+W%igm0QiVDuBGRbT&x5-PL|@j$Lj?3>QP9ak10U9^bAnva+NM?l-y(?w z#?%$kZnWc-wH7KVi}~~x*K949TzAe?8wko4=3#6^&R?XG_EBdOIEqES#1^V1;d$ek zU-)S$-9jm}up2W~3MJ}D{AAIMDz<|%?=FdIVqvun+@ijQq@;>2v=L0|V!LUrNFX_% z)<@&gms_hmlcJz%BZ5e&D21vz$aa!~oWH}6q<}ov!nUe|y2qbM*A}YPq`FvcqmuQs zxQAzK6WiH=PvT$ai;fl$N5fLUb(TsKvAE%L7$G5d#IxY9Az=gJR41Vd%P6{C*t+1!=A9yWs(S% zD~4d6KsuUTPqJ>I@O^K$M1TTEYTNa~lLd#|vT`BiCU;riE0T-)Arih<zR#nbw11IzA*faT!Wg8Z)Hh@ihLxCk$--X<_95*t;85dN+|Oz)@i%y?hZ5BW=0@ zt)NvE#uhkRdDsB%W;Gt_apS zh3ySVsT(-Zrud53l-OFch+xGNx`kSd_sVKkv(V8faLW||g6WazOBmIxhcwJaLPCKr zAz9)ZHnqWXV$_2zF1Mpls%Id>hji_F!He87ETR;ewYNOZI#m+IHL7rru$~G+(NV2h z+l>!s^bscOa}m9`PPwGKGm+z0Oj4Pl#RmkwA|3=WiwIV0)mt<$w1vsu4Qknz4NWC? z9YRuJ*-|+04PmpEk-G?LRDwA1yjW-akO!Z&g{ZFi&JxKLMIpItBqEmED-xUdTRj1r za&OV70-{89u*QT2<=a6_#ruObpl7f)D;(2MazMTw_S2w$bQu?3?7PfJxL9$GvN zR=3nJ3AGF%;s)(ss8b!KigLIiw{%M$vNUlbMx2?>pUx~cScT^CN?Ih@>=HHkfDZ-* zLavxRr{~JT9q9=i-4bb#T7x*jw=sKc*fNneI?^i1`o$=eYO#?1)myZ=D0b-NO+lS7 z>rofILbkL}F4zu`)B?O#Ete#c_1z-sTZj;Q<5WW3SzhvxnFyO(0wbpiB2ud+5>1ZTX(qX&6Wo{8s)T8o6bHauq@cq(Kgdo@~WPPq!UxzUQ7Ct!D1sE^BCV*URxMN# zr>G`~WV1;;yN)w(A}FL)#M^w8hbfd~JwqZV@0PSAp>EyAJ+15|M6hwUyy6T^JRu3< zwQ*@->k6tZj^)vjH5ZA$c;SZ&O<6TCWI8NF~Rn^*l zM^}X88`H9ocD7)u(!}C;p|ZYT^it*UVmjF)C-QeWbs^YM|eDW@f&IajdMD3gT@6^X~Hl*2_Swm2n9Rb}p~Eepy;K~V1c5KmBR zNOA?`+jubLQYdXvyrNt7=uOy#tFpc+jP#E;m$`-3HzcZ6vJeK=1Z4e8SzC5@(JT~` zp{&CYa_`9~v8JXV*7yJQPp(g2`1&2z=eRJ*QHjaip|oR)Oepd?2?<0RH?=&fp?SW< zY+;B!7MIu&K`D{j$zs%v9A5!Z8E0jV3z-hLMkxiSF#h<9iBVK%9N5U`s~U;CW35ae zo&3cm8hrAV>8>75u9AH93Vg@j_yyuVkH{Eb6cUmEQIr!BVV$V3uuViB^a{krFrTQX zydhN#oSrE4B|s3#mz1luqS<5k0B_M*i`2T2!Uo(@rRqZ5qYO-mL`n;2Vjw;dH!6Iz z=NA-WIE_b2l=U#r|!=FSYS@en|j){~QBAniW zfz{`;i>x>mmPC4fwn(`&o;EBxKc*+5nZ5)FokI?soANCm=w z3tPfyj)%wERy&+m|H&5NeA|JnZwk?fY{_D4{a8}6g$EbNgE5WLBo03yIs~OGHeJZc z)txl)2W3=+M#0nKVd>yVa=7((Ir#w#^7{yb7*%)07a&%^$U)~WT2D2jC{XT!cx|}Yz`QOdy(tnwMeUcIPdGEbs<$VFnU(_jx*zO6J zf-ZHglLSg?-1r`KIwxJxEtyyjLNfO*Y}FS`Q!6OpG_3R zjgQIjWoZ-1EfQEXb#K>@w$-N8KW$%Evrj#p-K$;f>U`ShS4}Gl5Y`mW7>v6G`F#SL zB|lD_u1{bB(Y)pO6&-Iyl(kjry`#2tMY9fql64Wxm%d{_+iO&k`-Gc#)h($o<(|dT zi0VeDjz&cxO|w7zifF+nS2X3kzrig8-zEtqq+(6BLvLlKj&uv^Xkmx5z}y8kwK3FW z#uR5OqeMp)Mc~8o(iN>z{2$f2V^i4vnmaa*CNAY|;v|iD&LBLHD& zn+1+?P;M{o#vh^gtx20TCT*Vxe(PuT?wgFr}Gnk5SKy~4)nK%L{|iE5;&M2M z(fVXkRjj2-Wq1LLVso2(@G( zbX`#i{g(4ndC~S;D3W%#L{*9*U;no6!}`%pKf>t;Hdi@1pU}Mjz~*S{&c{8^von8k z(xo3WJ)55c^*vkOvCYqc{4w5vN;|(UY3Qw@SjZJYEfmjNW5>*Muf|;yc zK}4dylEs2=gWq^Q*B8gBf1(oiAgwivuoSCm>xXtCgx~Z7pkM#Jg>ya#x^6FQi@ozf f(5z!D@(XdCuiNE5^XnC#`G-$_w7$vz{`&4ezb1^*!VEht!fGCPAha^@aV*&P!#k*&%y*Dr>nEt<=bmR+5 z4W7e3RMk3E{oN?}`N>ybJ^ktB>o?!M`u^$HU;UhK`-AHbU+23wZ@+o{_Af7gc=hc+ z^5fIT|GR{m)GMzr}5o^6F#1kFdJ^eylyHD``O1J zde+U=eg4ktcKkgOx$D07Y0uLh|9yA$kN`*C^MZ%_@sk`s!fjC!axYE2vaZXon@D3> zzc~}y-NT6J`l?@?#^2Ab&c-#t2?p;kz5AGK54s;Y=I@+%F2`1CR&Js(@@d}pAJ0qy z*Rjt=C7+Kg@^y-N-COPx^oxGomFI741n(}^q9k}QBNjKHKZB@q3&yeVJbN}nXHTmB7{_K*b9C}3B&*|%z z>^z+w*RjRR@6QYGlS3+)4`3|am%IyzFI@4^D?gL@@{vdPq)caUdc61rEH3>93~>I% zg0VD1-zew{BzaVmS<=!y!Bc(Wd#TTtMe^vP*7F9h7+dI;WN}`)kD}`O5c|0#lEnga zJ@c%aJPN6c<9KK7?vpJEiK2?r29D$K#gGZ^R8<&QAma7{1kttUkA?y%kM&lii5R<+|<3M(zX>s6{* zFLatAQFG}yG|cn3G*%+gwYvp9!QxRMv{}S6CH^W}6{if3J4r|rHPUIfq_8CIu!7c) zcX*Ct9c~-FqadBNQdsGyt&+CLAv$G93#+8saQ;+GcvVl63M+ywZ>~^*hbQF!4tn6= z@c^B+Gp!_^lx_V`x51q)At7m82U?G+BvB1!{ozL*(j8A|Qp{#5BY{%%cukCH;ear? z1y81AEJIvo-Bfv5Ol41Hs&s-i>&f3n$BB_1$9FPKQR_pK1a?^`!5OJQ=(EfyELF9Q zgGw~CKv!&7I?=D(h@yqDi%v9Jq_)_XJ6-4%h+w6PI!f*a-{hgRr&3o))hyhQk7v<% z6uSY0)|Rp^2{R)heDwrHFe9tnQZ>myGRwd-@d)MLFpoP_cNDz`sRNO2zASNQDDtl5^tfXup1L&bR?tfN?fe~)fzV= zL#vK*1xYD}NmvB#L?xctsA6kWUUc$afmi@z@qL?>uXyZ+^j6A(UAJH^4J=qlE+o~4 z6kf~Kd+zVGlK}~_C4%LDLQ*Fu!H;G4(^$O8iuFw*7Jx&o#1g)&M;J7sdBllYh zqCsM|R1rl3Lmg#{;htJ4&t?6B0}GWDO0eUPoSA+_P>pJ1<`)eXh{N&I;tm61ea45; z8Y~{+!}1LT_jn~Ygjq7Ao1ptuH8Q^T7U56%N$Vp4Pr+t@X-9`y-4uu$Nr8wRi47qG zJ#>%`1R~_pJFJi`O<{vexP|O-iioo4mdul)h>DIFK868eRBIc=RTtr{J__X)@w5hp z4Je6Ixq5{T$&)ZM&H_v{1;aCLVQ&1}O{j%<3X9;d$ea6AA-!zOB#UBvoxL z;kGz=MJi9Dt&g%uDY5Yo;ah?NCDO#j%_f(Q_s6c!`NWNB%K~?A!TyykTC0s)>ahvz zxB({jNJ*@wQDIB!mIz8bq@I&oXp0!m42cqNFjA9wQmZZ11fwd)WWO1rW zQEiHXup9lwY8&2)=QMFyKW&h@WUts5*f6l(qGlK#Qo9dBsIj_Q7Y`|_+#(3hD@@=? zTzK0nri~nbu8fCBZ5P~!&+qz7AmSk_3+!UXC;__hL`RZBg0YtB&fKahaKT#WMCaF* z>2Sff#ngTsLzepJ6^q!E#ndJyQpb=j4J^K-Pq?ufb+Ziv49|%qvtpV{q#*O4G zT4V^lLaBnB?-hv(%Nw5PmO2lOosnS(EFh*yjkg`h#lCDRd^p=eoBLbmvk(v^Z zW*v=-`ieOETMHHQk*vD~pR<&@4M{AwA%%xj-ZT!PdKjox!YoR1xuUwrOSZI78-AF; zgogz}M>wat!A;yqvMH1>ut3NtGJzllwxm(FOs(MtRcU5Tj9Mg=TCGH+Ts0g*FlMW| zND5*I6G7_86_M&99-@hBR}yJA>tLa^&3divMy;q&8)lorY8G2O zsx3ZflvMZ#U)xF6YCE||5^0ri3t`uxMSyEC!9upQ-Ak3aSh&%Uk_CCrY_H-WL9Gm- zw_yXcBvRl+-P6n&Zps?5TUZd>DXjXxPJ^L`gtW2{l6Oni3*6nJwu%1V!iG^ARS>zb zM>ThR3TPHX>Blt~X;31wTr@=&!3}E3= zNtc-=qAg({WzoimS+{+w-{uNxOjxL9EsE&+JOW5NZp!%RTKKZ-!sZzU_7<8e0*6G^ za%nF^o@;Fz5=K>?Q`YeiGN_3)@f_)4RuB=9C}IUdq_!=oXBHPSahqC=G{?rD`V#~a zPgB4lt({k{Xo-Y%4N1uF1U5`nIqFXhSvLaLCQuGkrv#m~w7TT&vb_fxZ`@P}CK)-;K25bIzwO#}N^Tb>%GK0ji4TdOEe zMVj=*2dW4WX%#1hD#`QGEs2VBmn*VG)YS6k3L$*AY}`U{MB{{b)@z~CjO6*Q@aeRM zq*_#$s1_EuCA<1MJQG%cLfuBCY}d(F;!)sMeW*kkXvsjlYqX@3golBfKp4>C9`RTe z5r;IgE+U7st~+7lQx7Z<`cSMxTCVU!+QA9I!6@63sKjuJh4hzITk024)5gZWzG9J`Du(rD1fJ@eK57BAk2^<15{U(SqzT?|5u*x`M^eD1@Vp(()C)XjgL`6D)GSbR!CUvWPP(pe~~TK zkrb*SjTbN~IwI_*jwlPU%~d6}O-Z3wBx}7wrI+;*RkfY`};gXA(Bq_(rX$9dlMiHiI`=?|(LzooWD)SXvs zY!NCZamGDcX^+!+@LAP|8X*8u_Um(#CC!; zvAQ8?SIT-xyjP4wNXt!x8q-8Xx<(31S2PhE3n$`+AlWI>qw(kiwN3~dq|H#FR*Z?4 zfmGPI0eWDsAo|7AD5L-pc3qgU~ zAyF{3Vj?OeYY;g#wuA5*4g?iKg8c@!9xniDg=9cy( zVk~y}C|c~$a=RsP6A2N)RV(6cc&?;PxrnrLJy9`mNL1xvNQJzLz=hoPky7KqkdE7CizD>5OfPow@6*Wb&DAHb z@=*M9gI8^5)M(;_LiMJAPZ0Ge%)0(8SyZrDj~b*aDk~97LrP&aq?DJ;P@c;gQp{E! zg=Q8ugXIcg)Vf084sdiu=_G|}iSm{LlD3K97BvKwUPJOH>y}p%=@kke!9l@9L31Xg z&p~v$%X)Ex0-cv6QtYHkzpwwK+kW!xMt!mc<}~qD9_mD2#!MNEqx9niq3ZGCs6!&B zn$Y69rRcP7DP`pKvw6uBQE5mqYh{u`!43mvTETpOvyW_zZg}emou1`qX5uy$OPQAK^i}6x0 zt*V|s8UfuBDLIamGZKscl;^3H2&7v!ND{<_?F@GY7~-w?XGku%IYeTC^tW-&@ zNn8x1we>^_pF}lpv2jamjdc|137v|a&|{-Hww`oFbR8WD)>&N75$W}O%{{kZ>OyJn*U%5hTuu%!gZSipOTtWS@At^VKg4|M)JD1Fv z1?Z+6MPSRLs+N0X9a>TxoRTaPwosrRgee@4Kf!dcr1OKX$g}z45Z*1JwD86NFoKAP}Uxl!U`)^VO4I? z8fIOEN@r|f>2$JX>WFiZ9n28g7-IA6e(yP;o^m@gHGKzYwX;Paa5N%gLTt>C%JVoe(Xhx>-l0|bok{8PGS5;7Lzd3R%m@d zEW>Hi7KZnH9UZBS`t;k7s2SnR-}5ulP)nL>6%?uw65xzV-7khIZ?twG7q}-DruPA# zmY1?PKN>dh@ zH+Ot!Rq8ftrEW+iMNM>C2(m?_S}VZ#yG5ev`BKsv6b{&p zM?}{v!ad2N=i|p!#UgVB*Tr=CAF2q{l#;4KA0uvm!`)zer7iy2a7dzgfc zYNT&lq*feHvKC#`Z!zUrC`x(3r-c>xk~Jlwb62wt{e*#Yi;}Pdp1dWceY{wB7ocgR zwN;xUtZ`;uKAHkh5i&6zA#8MkLAkqN7#` zJ)~Q5g;c3qa60N1fBdcYnx_na6Ang1%z^zpB@$a$DbQ2G(5x0pqf#4bB0`Tgjv=Vy^yn2h@YLU? z#T}o)vOdm$nr&=C-KLMv#4T60Y*2-8Nrf@@niVyj_?5Wms+G(b<%xD%bloDJEeJY$ zd)OFPl-wJbK1WP33W&WWv3Ze8Bc;ZIXzTg&N{aQ^vRZ3JcYo)3QiMCYfRNEO9 zx>ggpKK~hue8>~FjSqPGaZZoU=Qr;^&N)i?d>GXC^71DqJv`*g=krsdz8#mh;^wDB z|J?^hm3rv7fuhV1HD7_m7LN{kw5a1Vjw)Pj8bw~N>n)i|Ws+N09QL&-KT5n?f?X0h zd`sm@wIyN%1zJDtK&o0rWS2Go854Ur&K6yd20gDJM(vH2Mvsu35X^e1Z5xKVS>hqM zbA>eHjnT87ECySw!&@oTWYpPzS0Fv_*Z*O|DIXc#^xr$&q;);|$SCamLIkIqwz$vy S%Ztza;uk+!-{e1ke)n&!8FvN% literal 0 HcmV?d00001 diff --git a/packages/rs-sdk/tests/vectors/test_epoch_fetch_current/msg_GetEpochsInfoRequest_b2b426ac4a52cb4cb08904c63386caf3663c40a12d3b03827006d66058e439ac.json b/packages/rs-sdk/tests/vectors/test_epoch_fetch_current/msg_GetEpochsInfoRequest_b2b426ac4a52cb4cb08904c63386caf3663c40a12d3b03827006d66058e439ac.json new file mode 100644 index 0000000000000000000000000000000000000000..4c66a428e31e8e18909e4adacccb6cbb33b629ad GIT binary patch literal 81443 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 diff --git a/packages/rs-sdk/tests/vectors/test_epoch_fetch_current/quorum_pubkey-106-1f0a25d463a2912cd31dd4f91b899a143d6ae990bb9bc89cb34f7c5db8b1b705.json b/packages/rs-sdk/tests/vectors/test_epoch_fetch_current/quorum_pubkey-106-1f0a25d463a2912cd31dd4f91b899a143d6ae990bb9bc89cb34f7c5db8b1b705.json deleted file mode 100644 index e048700d163..00000000000 --- a/packages/rs-sdk/tests/vectors/test_epoch_fetch_current/quorum_pubkey-106-1f0a25d463a2912cd31dd4f91b899a143d6ae990bb9bc89cb34f7c5db8b1b705.json +++ /dev/null @@ -1 +0,0 @@ -acbfb39d5f22cd2f096af600d2f19d618fa9898fecd778393c777d1b1785ee5fbad857cf62b3b69ade96894bafe42c7c \ No newline at end of file diff --git a/packages/rs-sdk/tests/vectors/test_epoch_fetch_current/quorum_pubkey-6-0000006d47d12e570d70924b9e6d5ce55258ac3ade86f8eda82cfb8dd17f79aa.json b/packages/rs-sdk/tests/vectors/test_epoch_fetch_current/quorum_pubkey-6-0000006d47d12e570d70924b9e6d5ce55258ac3ade86f8eda82cfb8dd17f79aa.json new file mode 100644 index 00000000000..137925e636e --- /dev/null +++ b/packages/rs-sdk/tests/vectors/test_epoch_fetch_current/quorum_pubkey-6-0000006d47d12e570d70924b9e6d5ce55258ac3ade86f8eda82cfb8dd17f79aa.json @@ -0,0 +1 @@ +9068591a43f9b9eced39dc231f1b5693721ef5df2b131fdab36525571b4574b916b78b42a5ed7987619448753c290494 \ No newline at end of file From 8849680f3ee6482a2d4797e2918cad6e9e96ab6a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 27 Jul 2026 20:16:16 +0700 Subject: [PATCH 2/3] fix(sdk): authenticate the current-epoch selection with the proof MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up: the two-step fetch_current still let unsigned response metadata pick which epoch is "current". metadata.epoch is not covered by the Tenderdash signature, so a node could return a valid genesis proof with a deflated epoch hint, and the second query's valid proof would establish only that the older epoch exists — not that it is current. Callers got proof-valid but stale epoch fee, version, time and height data under a current-epoch API. The same stale result could happen honestly when the epoch turned over between the two requests. The hint is now a request-shaping input that the proof checks, not an answer: - The second query is ascending from the hint with count 2 instead of descending with count 1. Drive keeps epoch n+1 as a pre-created empty tree until it starts, and an empty tree consumes query limit without contributing elements, so a single-epoch result IS proof that the hint is the newest started epoch. - A hint above the current epoch lands in the empty window and provably matches nothing -> EpochNotFound. - A hint below the tip is contradicted by the node's own proof, which carries the newer epoch; the query repeats from that proven index, bounded at two refinements (enough for an epoch turning over mid-fetch) and then fails closed with the new StaleNodeError::Epoch. Also from the review: - EpochQuery::newest_at_or_below is gone (its name promised a search it could not perform); EpochQuery::ascending_from describes what the query does. - refresh_protocol_version docs no longer claim a single query or that a failed refresh leaves the stored version untouched: each verified response ratchets on its own, so a partial refresh can raise it. - test_epoch_fetch_current pins the returned epoch against response metadata and asserts the epoch above it is absent, instead of the near-vacuous index < MAX_EPOCH. Offline vectors regenerated against testnet (epoch 17723, protocol version 13). - The rs-sdk-ffi platform_status test registers both epoch expectations and asserts the success path's JSON, plus a case pinning the error path. New tests: rs-drive pins the empty-tree limit behaviour the confirmation relies on (at, below and above the current epoch); rs-sdk mock tests cover confirmed, stale-by-one, persistently-deflated and inflated hints. --- .../system/verify_epoch_infos/v0/mod.rs | 85 +++++ .../src/system/queries/platform_status.rs | 110 +++++- packages/rs-sdk/src/error.rs | 14 + packages/rs-sdk/src/platform/types/epoch.rs | 358 ++++++++++++++---- packages/rs-sdk/src/sdk.rs | 43 ++- packages/rs-sdk/tests/fetch/common.rs | 20 +- packages/rs-sdk/tests/fetch/epoch.rs | 30 +- ...5e13f72f6116c5806f2674e8859e9e5aa069f.json | Bin 80224 -> 0 bytes ...c642c4a96dbe15bb56e8891028dfb50539827.json | Bin 0 -> 105432 bytes ...6caf3663c40a12d3b03827006d66058e439ac.json | Bin 81443 -> 81458 bytes ...d05ca3568fa6327fe2f18bfa7200b17b53dbf.json | Bin 0 -> 82707 bytes ...d5ce55258ac3ade86f8eda82cfb8dd17f79aa.json | 1 - ...33975e7cb75e02122b817b9de3b3805f70a56.json | 1 + 13 files changed, 558 insertions(+), 104 deletions(-) delete mode 100644 packages/rs-sdk/tests/vectors/test_epoch_fetch_current/msg_GetEpochsInfoRequest_2a013be99da0db14facf92af4ce5e13f72f6116c5806f2674e8859e9e5aa069f.json create mode 100644 packages/rs-sdk/tests/vectors/test_epoch_fetch_current/msg_GetEpochsInfoRequest_459052173de973fafadb1e9dc48c642c4a96dbe15bb56e8891028dfb50539827.json create mode 100644 packages/rs-sdk/tests/vectors/test_epoch_fetch_current/msg_GetEpochsInfoRequest_d7e33f3bbf7640d1c44d5ea7f0ad05ca3568fa6327fe2f18bfa7200b17b53dbf.json delete mode 100644 packages/rs-sdk/tests/vectors/test_epoch_fetch_current/quorum_pubkey-6-0000006d47d12e570d70924b9e6d5ce55258ac3ade86f8eda82cfb8dd17f79aa.json create mode 100644 packages/rs-sdk/tests/vectors/test_epoch_fetch_current/quorum_pubkey-6-0000011237497563843be632f0233975e7cb75e02122b817b9de3b3805f70a56.json diff --git a/packages/rs-drive/src/verify/system/verify_epoch_infos/v0/mod.rs b/packages/rs-drive/src/verify/system/verify_epoch_infos/v0/mod.rs index 0cdccfbd4c8..7fff62e9ad1 100644 --- a/packages/rs-drive/src/verify/system/verify_epoch_infos/v0/mod.rs +++ b/packages/rs-drive/src/verify/system/verify_epoch_infos/v0/mod.rs @@ -267,6 +267,7 @@ mod tests { use crate::util::batch::GroveDbOpBatch; use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; use dpp::block::epoch::{Epoch, MAX_EPOCH}; + use dpp::block::extended_epoch_info::v0::ExtendedEpochInfoV0Getters; #[test] fn should_prove_and_verify_epoch_infos_ascending() { @@ -472,4 +473,88 @@ mod tests { provably return no epochs, not the newest started epoch" ); } + + /// An ascending query starting at epoch `n` with `count = 2` proves whether + /// `n` is the newest started epoch: the pre-created empty tree at `n + 1` + /// consumes query limit without contributing elements, so a single-element + /// result is proof that `n + 1` has not started. + /// + /// The SDK's `ExtendedEpochInfo::fetch_current` relies on this to turn the + /// unsigned current-epoch hint in response metadata into an authenticated + /// selection: a hint below the real current epoch yields two epochs here and + /// is rejected, a hint above it yields none. + #[test] + fn should_prove_that_no_epoch_started_above_the_current_one() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let transaction = drive.grove.start_transaction(); + + for (index, start_time, start_block_height, start_core_height, fee_multiplier) in [ + (0u16, 1_000_000u64, 100u64, 50u32, 1000u64), + (1, 2_000_000, 200, 100, 2000), + ] { + let epoch = Epoch::new(index).unwrap(); + let mut batch = GroveDbOpBatch::new(); + epoch.add_init_current_operations( + fee_multiplier, + start_block_height, + start_core_height, + start_time, + platform_version.protocol_version, + &mut batch, + ); + drive + .grove_apply_batch(batch, false, Some(&transaction), &platform_version.drive) + .expect("should apply batch"); + } + + drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("should commit transaction"); + + // Epoch 1 is current: asking for two epochs from 1 returns only epoch 1, + // which proves epoch 2 has not started. + let proof = drive + .prove_epochs_infos(1, 2, true, None, platform_version) + .expect("should prove epoch infos"); + let (_root_hash, at_current) = + Drive::verify_epoch_infos(&proof, 1, Some(1), 2, true, platform_version) + .expect("should verify epoch infos ascending from the current epoch"); + assert_eq!( + at_current.len(), + 1, + "the epoch above the current one must provably be unstarted" + ); + assert_eq!(at_current[0].index(), 1); + + // A hint one epoch below the current one returns both, which is how the + // SDK detects a deflated (stale or malicious) hint. + let proof = drive + .prove_epochs_infos(0, 2, true, None, platform_version) + .expect("should prove epoch infos"); + let (_root_hash, below_current) = + Drive::verify_epoch_infos(&proof, 1, Some(0), 2, true, platform_version) + .expect("should verify epoch infos ascending from below the current epoch"); + assert_eq!( + below_current.len(), + 2, + "a start below the current epoch must provably return a newer epoch too" + ); + + // A hint above the current epoch lands in the pre-created empty window + // and returns nothing at all. + let proof = drive + .prove_epochs_infos(2, 2, true, None, platform_version) + .expect("should prove epoch infos"); + let (_root_hash, above_current) = + Drive::verify_epoch_infos(&proof, 1, Some(2), 2, true, platform_version) + .expect("should verify epoch infos ascending from above the current epoch"); + assert_eq!( + above_current.len(), + 0, + "a start above the current epoch must provably return no epochs" + ); + } } diff --git a/packages/rs-sdk-ffi/src/system/queries/platform_status.rs b/packages/rs-sdk-ffi/src/system/queries/platform_status.rs index f64ac109924..2cf9810c192 100644 --- a/packages/rs-sdk-ffi/src/system/queries/platform_status.rs +++ b/packages/rs-sdk-ffi/src/system/queries/platform_status.rs @@ -5,7 +5,11 @@ use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, DashSDKResultDataType use dash_sdk::dpp::block::extended_epoch_info::v0::ExtendedEpochInfoV0Getters; use dash_sdk::dpp::block::extended_epoch_info::ExtendedEpochInfo; use dash_sdk::platform::fetch_current_no_parameters::FetchCurrent; +#[cfg(test)] +use std::ffi::CStr; use std::ffi::CString; +#[cfg(test)] +use std::os::raw::c_char; use std::os::raw::c_void; /// Get platform status including block heights @@ -95,7 +99,71 @@ fn get_platform_status(sdk_handle: *const SDKHandle) -> Result { #[cfg(test)] mod tests { use super::*; - use crate::test_utils::test_utils::create_mock_sdk_handle; + use crate::sdk::SDKWrapper; + use crate::test_utils::test_utils::{create_mock_sdk_handle, destroy_mock_sdk_handle}; + use crate::types::SDKHandle; + use dash_sdk::dpp::block::extended_epoch_info::v0::ExtendedEpochInfoV0; + use dash_sdk::platform::types::epoch::EpochQuery; + use dash_sdk::platform::LimitQuery; + use dash_sdk::query_types::ExtendedEpochInfos; + + const MOCK_EPOCH_BLOCK_HEIGHT: u64 = 1234; + const MOCK_EPOCH_CORE_HEIGHT: u32 = 567; + + /// Mock SDK handle that can answer `ExtendedEpochInfo::fetch_current`. + /// + /// `fetch_current` issues two proved queries — a genesis probe, then a + /// two-epoch ascending confirmation from the epoch the mock reports in + /// response metadata (0) — so both have to be registered or the call fails + /// with an unmatched-expectation error. + fn create_mock_sdk_handle_with_current_epoch() -> *mut SDKHandle { + let mut wrapper = Box::new(SDKWrapper::new_mock()); + + let epoch = ExtendedEpochInfo::from(ExtendedEpochInfoV0 { + index: 0, + first_block_time: 0, + first_block_height: MOCK_EPOCH_BLOCK_HEIGHT, + first_core_block_height: MOCK_EPOCH_CORE_HEIGHT, + fee_multiplier_permille: 0, + protocol_version: dash_sdk::dpp::version::LATEST_VERSION, + }); + + // Registering expectations is async, and the FFI entry point below spins + // up its own runtime and blocks on it, so this runtime must be gone by + // the time it runs. + let setup_runtime = tokio::runtime::Runtime::new().expect("create setup runtime"); + setup_runtime.block_on(async { + wrapper + .sdk + .mock() + .expect_fetch::( + LimitQuery { + query: EpochQuery::genesis(), + limit: Some(1), + start_info: None, + }, + Some(epoch.clone()), + ) + .await + .expect("register epoch probe expectation"); + wrapper + .sdk + .mock() + .expect_fetch_many::<_, ExtendedEpochInfo, _, ExtendedEpochInfos>( + LimitQuery { + query: EpochQuery::ascending_from(0), + limit: Some(2), + start_info: None, + }, + Some(ExtendedEpochInfos::from_iter([(0, Some(epoch))])), + ) + .await + .expect("register epoch confirmation expectation"); + }); + drop(setup_runtime); + + Box::into_raw(wrapper) as *mut SDKHandle + } #[test] fn test_get_platform_status_null_handle() { @@ -105,13 +173,47 @@ mod tests { } } + /// With both `fetch_current` queries answered, the FFI entry point must + /// report the fetched epoch's heights rather than the zeroed fallback. #[test] fn test_get_platform_status() { + let handle = create_mock_sdk_handle_with_current_epoch(); + unsafe { + let result = dash_sdk_get_platform_status(handle); + + assert!(result.error.is_null(), "platform status must succeed"); + assert_eq!(result.data_type, DashSDKResultDataType::String); + assert!(!result.data.is_null()); + + let json = CStr::from_ptr(result.data as *const c_char) + .to_str() + .expect("utf-8 json") + .to_string(); + assert!( + json.contains(&format!(r#""blockHeight":{}"#, MOCK_EPOCH_BLOCK_HEIGHT)), + "unexpected json: {json}" + ); + assert!( + json.contains(&format!(r#""coreHeight":{}"#, MOCK_EPOCH_CORE_HEIGHT)), + "unexpected json: {json}" + ); + + let _ = CString::from_raw(result.data as *mut c_char); + destroy_mock_sdk_handle(handle); + } + } + + /// Without expectations the mock cannot answer the epoch queries, so the + /// call must surface an error rather than silently returning the + /// `EpochNotFound` fallback JSON. + #[test] + fn test_get_platform_status_without_epoch_expectations_errors() { let handle = create_mock_sdk_handle(); unsafe { - let _result = dash_sdk_get_platform_status(handle); - // Result depends on mock implementation - crate::test_utils::test_utils::destroy_mock_sdk_handle(handle); + let result = dash_sdk_get_platform_status(handle); + assert!(!result.error.is_null()); + assert_eq!(result.data_type, DashSDKResultDataType::NoData); + destroy_mock_sdk_handle(handle); } } } diff --git a/packages/rs-sdk/src/error.rs b/packages/rs-sdk/src/error.rs index 88d069e295f..4e4ab3a9a18 100644 --- a/packages/rs-sdk/src/error.rs +++ b/packages/rs-sdk/src/error.rs @@ -3,6 +3,7 @@ use dapi_grpc::platform::v0::StateTransitionBroadcastError as StateTransitionBro use dapi_grpc::tonic::Code; pub use dash_context_provider::ContextProviderError; use dpp::block::block_info::BlockInfo; +use dpp::block::epoch::EpochIndex; use dpp::consensus::basic::state_transition::{ OutputBelowMinimumError, TransitionNoInputsError, TransitionNoOutputsError, }; @@ -386,6 +387,19 @@ pub enum StaleNodeError { /// Tolerance in milliseconds tolerance_ms: u64, }, + /// Server kept reporting a current epoch that its own proofs contradict + /// + /// The epoch index in response metadata is not covered by the quorum + /// signature, so `ExtendedEpochInfo::fetch_current` only uses it to shape a + /// proved query and then checks it against the proof. This error means the + /// check kept failing: every proof showed a newer epoch already started. + #[error("received epoch is outdated: hinted {hinted_epoch}, proven started epoch {proven_epoch}; try another server")] + Epoch { + /// Epoch index the server reported as current in unsigned response metadata + hinted_epoch: EpochIndex, + /// Newer epoch index that the server's own proof showed as already started + proven_epoch: EpochIndex, + }, } #[cfg(test)] diff --git a/packages/rs-sdk/src/platform/types/epoch.rs b/packages/rs-sdk/src/platform/types/epoch.rs index e9c9da3a044..c0614a3ddab 100644 --- a/packages/rs-sdk/src/platform/types/epoch.rs +++ b/packages/rs-sdk/src/platform/types/epoch.rs @@ -7,67 +7,144 @@ use dpp::block::{ }; use dpp::fee::epoch::GENESIS_EPOCH_INDEX; +use crate::error::StaleNodeError; use crate::platform::fetch_current_no_parameters::FetchCurrent; use crate::{ - platform::{Fetch, LimitQuery, Query}, + platform::{Fetch, FetchMany, LimitQuery, Query}, Error, Sdk, }; /// Epoch type used in the SDK. pub type Epoch = ExtendedEpochInfo; +/// How many times [`ExtendedEpochInfo::fetch_current`] repeats its confirmation +/// query after a proof shows that a newer epoch has already started. +/// +/// One repeat absorbs the honest race where the epoch turns over between the +/// probe and the confirmation. Past that, the node keeps hinting an epoch that +/// its own proofs contradict, and the lookup fails closed. +const MAX_CURRENT_EPOCH_REFINEMENTS: usize = 2; + #[async_trait] impl FetchCurrent for ExtendedEpochInfo { /// Fetch the current epoch. /// - /// The proof verifier rejects proved descending epoch queries without an explicit - /// start epoch: resolving "the last epoch" server-side would force verification to - /// trust unsigned response metadata. An explicit descending start does not help by - /// itself either — Drive pre-creates thousands of empty epoch trees above the - /// current one, and a proved descending query starting inside that window provably - /// returns nothing (the query limit is consumed by the empty trees). - /// - /// So the fetch is done in two proved, guard-compliant steps: - /// - /// 1. Probe: fetch the genesis epoch (explicit ascending start), and take the - /// current-epoch *hint* from the response metadata. - /// 2. Fetch the newest started epoch at or below the hint (explicit descending - /// start). - /// - /// The hint only shapes the second request; proof verification stays a pure - /// function of the request and quorum-signed state. A node inflating the hint - /// lands the query in the provably-empty future window, yielding - /// [`Error::EpochNotFound`] rather than a bogus epoch. A node deflating the hint - /// can present a stale epoch as current — the same exposure as trusting the - /// node's metadata directly, so treat the result accordingly. + /// See [`fetch_current_with_metadata_and_proof`](ExtendedEpochInfo::fetch_current_with_metadata_and_proof) + /// for how the current epoch is selected and authenticated. async fn fetch_current(sdk: &Sdk) -> Result { - let (epoch, _) = Self::fetch_current_with_metadata(sdk).await?; + let (epoch, _, _) = resolve_current_epoch(sdk).await?; Ok(epoch) } + /// Fetch the current epoch together with the metadata of the response that + /// authenticated it. + /// + /// See [`fetch_current_with_metadata_and_proof`](ExtendedEpochInfo::fetch_current_with_metadata_and_proof) + /// for how the current epoch is selected and authenticated. async fn fetch_current_with_metadata(sdk: &Sdk) -> Result<(Self, ResponseMetadata), Error> { - let (_, probe_metadata) = - Self::fetch_with_metadata(sdk, current_epoch_probe_query(), None).await?; - - let hint = epoch_hint_from_metadata(&probe_metadata); - let (epoch, metadata) = - Self::fetch_with_metadata(sdk, current_epoch_query(hint), None).await?; - - Ok((epoch.ok_or(Error::EpochNotFound)?, metadata)) + let (epoch, metadata, _) = resolve_current_epoch(sdk).await?; + Ok((epoch, metadata)) } + /// Fetch the current epoch together with the metadata and proof that + /// authenticated it. + /// + /// # Why this takes two queries + /// + /// The proof verifier rejects proved descending epoch queries without an + /// explicit start epoch: resolving "the last epoch" server-side would force + /// verification to trust unsigned response metadata. An explicit descending + /// start does not help by itself either — Drive pre-creates thousands of + /// empty epoch trees above the current one, and a proved descending query + /// starting inside that window provably returns nothing (the query limit is + /// consumed by the empty trees). + /// + /// So the fetch is done in two proved, guard-compliant steps: + /// + /// 1. **Probe**: fetch the genesis epoch (explicit ascending start), and take + /// the current-epoch *hint* from the response metadata. + /// 2. **Confirm**: fetch two epochs ascending from the hint. Drive stores + /// epoch `n + 1` as an empty tree until epoch `n + 1` starts, and an empty + /// tree consumes query limit without contributing elements — so a + /// single-epoch result is *proof* that the hint is the newest started + /// epoch. + /// + /// # What is authenticated + /// + /// The metadata hint only shapes the second request; it is never trusted as + /// an answer. The returned epoch and the absence of any epoch above it both + /// come from the same quorum-signed GroveDB proof: + /// + /// * A hint **above** the current epoch lands in the pre-created empty window + /// and provably matches nothing — [`Error::EpochNotFound`]. + /// * A hint **below** the current epoch is contradicted by the node's own + /// proof, which then carries the newer epoch. The query is repeated from + /// that proven index (up to [`MAX_CURRENT_EPOCH_REFINEMENTS`] times, enough + /// for an epoch that turns over mid-fetch) and otherwise fails closed with + /// [`StaleNodeError::Epoch`]. + /// + /// The returned proof is the one from the confirming query, so it covers both + /// the epoch itself and the evidence that no later epoch has started. async fn fetch_current_with_metadata_and_proof( sdk: &Sdk, ) -> Result<(Self, ResponseMetadata, Proof), Error> { - let (_, probe_metadata) = - Self::fetch_with_metadata(sdk, current_epoch_probe_query(), None).await?; + resolve_current_epoch(sdk).await + } +} + +/// Fetch the newest started epoch, proving that it is in fact the newest. +/// +/// See [`ExtendedEpochInfo::fetch_current_with_metadata_and_proof`] for the +/// two-step protocol and its trust boundary. +async fn resolve_current_epoch( + sdk: &Sdk, +) -> Result<(ExtendedEpochInfo, ResponseMetadata, Proof), Error> { + let (_, probe_metadata) = + ExtendedEpochInfo::fetch_with_metadata(sdk, current_epoch_probe_query(), None).await?; - let hint = epoch_hint_from_metadata(&probe_metadata); - let (epoch, metadata, proof) = - Self::fetch_with_metadata_and_proof(sdk, current_epoch_query(hint), None).await?; + let hint = epoch_hint_from_metadata(&probe_metadata); + let mut candidate = hint; - Ok((epoch.ok_or(Error::EpochNotFound)?, metadata, proof)) + for _ in 0..=MAX_CURRENT_EPOCH_REFINEMENTS { + let (epochs, metadata, proof) = ExtendedEpochInfo::fetch_many_with_metadata_and_proof( + sdk, + current_epoch_confirmation_query(candidate), + None, + ) + .await?; + + let mut started = epochs + .into_iter() + .filter_map(|(index, info)| info.map(|info| (index, info))); + + // Nothing at or above the candidate has started: the query landed in + // Drive's pre-created empty epoch window. + let Some((index, info)) = started.next() else { + return Err(Error::EpochNotFound); + }; + + match started.next() { + // Only the candidate came back, so the proof also covers the epoch + // above it and shows it as not started: the candidate is current. + None if index == candidate => return Ok((info, metadata, proof)), + // Epochs are initialized contiguously, so a gap at the candidate with + // a started epoch above it cannot happen in a well-formed state. + None => { + return Err(Error::InvalidProvedResponse(format!( + "epoch {candidate} is not started but epoch {index} is; \ + epochs must be initialized contiguously" + ))) + } + // The hint was below the chain tip; retry from the proven epoch. + Some((newer_index, _)) => candidate = newer_index, + } + } + + Err(StaleNodeError::Epoch { + hinted_epoch: hint, + proven_epoch: candidate, } + .into()) } /// First step of [`ExtendedEpochInfo::fetch_current`]: a proved query with an @@ -80,12 +157,13 @@ fn current_epoch_probe_query() -> LimitQuery { } } -/// Second step of [`ExtendedEpochInfo::fetch_current`]: fetch the newest started -/// epoch at or below the hinted index. -fn current_epoch_query(hint: EpochIndex) -> LimitQuery { +/// Second step of [`ExtendedEpochInfo::fetch_current`]: fetch `candidate` and the +/// epoch above it, so the proof shows whether `candidate` is the newest started +/// epoch. +fn current_epoch_confirmation_query(candidate: EpochIndex) -> LimitQuery { LimitQuery { - query: EpochQuery::newest_at_or_below(hint), - limit: Some(1), + query: EpochQuery::ascending_from(candidate), + limit: Some(2), start_info: None, } } @@ -110,37 +188,31 @@ pub struct EpochQuery { /// Note that proved descending queries without an explicit start are rejected by the /// proof verifier (resolving "the last epoch" would require trusting unsigned response /// metadata); use [`ExtendedEpochInfo::fetch_current`](crate::platform::fetch_current_no_parameters::FetchCurrent) - /// or [`EpochQuery::newest_at_or_below()`] instead. + /// instead. pub start: Option, /// Sort order. Default is ascending (true), which means that the first returned epoch is the oldest one. pub ascending: bool, } impl EpochQuery { - /// Ascending query with an explicit start at the genesis epoch. + /// Ascending query with an explicit start at `start`. /// - /// Combined with `limit: Some(1)`, this is the cheapest proved epoch query with a - /// fully request-derived range, used by [`ExtendedEpochInfo::fetch_current`] as a - /// probe for the current-epoch hint in response metadata. - pub fn genesis() -> Self { + /// The returned range is fully described by the request, so proved responses + /// verify without consulting unsigned response metadata. + pub fn ascending_from(start: EpochIndex) -> Self { Self { - start: Some(GENESIS_EPOCH_INDEX), + start: Some(start), ascending: true, } } - /// Descending query with an explicit start, returning the newest started epochs at - /// or below `start`. + /// Ascending query with an explicit start at the genesis epoch. /// - /// With `limit: Some(1)` and `start` set to the current epoch index, this returns - /// the current epoch. Do not pass a `start` far above the current epoch: Drive - /// pre-creates empty epoch trees ahead of the current one, and a proved descending - /// query starting inside that empty window provably returns no epochs. - pub fn newest_at_or_below(start: EpochIndex) -> Self { - Self { - start: Some(start), - ascending: false, - } + /// Combined with `limit: Some(1)`, this is the cheapest proved epoch query with a + /// fully request-derived range, used by [`ExtendedEpochInfo::fetch_current`] as a + /// probe for the current-epoch hint in response metadata. + pub fn genesis() -> Self { + Self::ascending_from(GENESIS_EPOCH_INDEX) } } @@ -155,10 +227,7 @@ impl Default for EpochQuery { impl From for EpochQuery { fn from(start: EpochIndex) -> Self { - Self { - start: Some(start), - ascending: true, - } + Self::ascending_from(start) } } @@ -187,9 +256,14 @@ mod tests { } /// Both queries issued by `fetch_current` must satisfy the proof verifier's - /// fail-closed guard: an explicit start epoch on every proved descending - /// query, and starts that survive Drive's `+ EPOCH_KEY_OFFSET` key encoding - /// without overflowing `u16`. + /// fail-closed guard: an explicit start epoch on every proved query, and + /// starts that survive Drive's `+ EPOCH_KEY_OFFSET` key encoding without + /// overflowing `u16`. + /// + /// The confirmation query must also ask for *two* epochs: the second slot is + /// what turns "here is epoch n" into "epoch n is the newest started epoch", + /// because Drive's pre-created empty tree at `n + 1` consumes limit without + /// returning elements. #[test] fn should_build_current_epoch_queries_verifiable_without_metadata() { let request_settings = RequestSettings::default(); @@ -204,23 +278,26 @@ mod tests { assert!(probe_v0.ascending); assert_eq!(probe_v0.count, 1); - // Step 2: the fetch is descending with an explicit start at the hint. + // Step 2: the confirmation is ascending from the hint, two epochs wide. for hint in [0u32, 42, u32::MAX] { let metadata = dapi_grpc::platform::v0::ResponseMetadata { epoch: hint, ..Default::default() }; - let request = current_epoch_query(epoch_hint_from_metadata(&metadata)) + let request = current_epoch_confirmation_query(epoch_hint_from_metadata(&metadata)) .query(&settings) .expect("build query"); let get_epochs_info_request::Version::V0(v0) = request.version.expect("version"); let start_epoch = v0.start_epoch.expect( "fetch_current must send an explicit start epoch, \ - or proof verification rejects the descending query", + or proof verification rejects the query", + ); + assert!(v0.ascending); + assert_eq!( + v0.count, 2, + "the confirmation query must cover the epoch above the hint" ); - assert!(!v0.ascending); - assert_eq!(v0.count, 1); assert_eq!(start_epoch, hint.min(MAX_EPOCH as u32)); // Guard against overflow in Drive's prove/verify key encoding. u16::try_from(start_epoch) @@ -230,3 +307,142 @@ mod tests { } } } + +/// How `fetch_current` reacts to what the confirming proof says, with the +/// unsigned hint held at 0 (what the mock reports in response metadata). +#[cfg(all(test, feature = "mocks"))] +mod mock_tests { + use super::*; + use crate::SdkBuilder; + use dpp::block::extended_epoch_info::v0::{ExtendedEpochInfoV0, ExtendedEpochInfoV0Getters}; + use drive_proof_verifier::types::ExtendedEpochInfos; + + fn epoch_at(index: EpochIndex) -> ExtendedEpochInfo { + ExtendedEpochInfoV0 { + index, + first_block_time: 1_000 * index as u64, + first_block_height: 10 * index as u64, + first_core_block_height: index as u32, + fee_multiplier_permille: 1000, + protocol_version: dpp::version::LATEST_VERSION, + } + .into() + } + + /// Mock SDK where epochs `0..=newest_started` have started. + /// + /// Each confirmation query is answered the way a real proof would answer it: + /// with the (at most two) started epochs at or above its start. Answering + /// with two is how the chain says "your start is stale"; answering with one + /// is how it says "your start is the newest". + async fn mock_sdk_with_started_epochs(newest_started: EpochIndex) -> Sdk { + let mut sdk = SdkBuilder::new_mock().build().expect("build mock sdk"); + + sdk.mock() + .expect_fetch::( + current_epoch_probe_query(), + Some(epoch_at(GENESIS_EPOCH_INDEX)), + ) + .await + .expect("register probe expectation"); + + for candidate in 0..=newest_started { + let started: ExtendedEpochInfos = (candidate..=newest_started) + .take(2) + .map(|index| (index, Some(epoch_at(index)))) + .collect(); + sdk.mock() + .expect_fetch_many::<_, ExtendedEpochInfo, _, ExtendedEpochInfos>( + current_epoch_confirmation_query(candidate), + Some(started), + ) + .await + .expect("register confirmation expectation"); + } + + sdk + } + + /// The hint is right: one epoch comes back, and that is the answer. + #[tokio::test] + async fn should_return_the_hinted_epoch_when_the_proof_confirms_it() { + let sdk = mock_sdk_with_started_epochs(0).await; + + let epoch = ExtendedEpochInfo::fetch_current(&sdk) + .await + .expect("fetch current epoch"); + + assert_eq!(epoch.index(), 0); + } + + /// The hint is one epoch stale — an epoch turning over mid-fetch, or a node + /// deflating it. The proof carries the newer epoch, so the query repeats + /// from there and the newer epoch wins. + #[tokio::test] + async fn should_advance_past_a_hint_the_proof_shows_as_stale() { + let sdk = mock_sdk_with_started_epochs(1).await; + + let epoch = ExtendedEpochInfo::fetch_current(&sdk) + .await + .expect("fetch current epoch"); + + assert_eq!( + epoch.index(), + 1, + "the epoch proven to be newer must win over the hinted one" + ); + } + + /// A node that keeps hinting far below the tip cannot walk the SDK into + /// accepting a stale epoch: refinements are bounded and the lookup fails. + #[tokio::test] + async fn should_fail_closed_when_the_hint_stays_below_the_proven_tip() { + let sdk = + mock_sdk_with_started_epochs(MAX_CURRENT_EPOCH_REFINEMENTS as EpochIndex + 5).await; + + let error = ExtendedEpochInfo::fetch_current(&sdk) + .await + .expect_err("a persistently deflated hint must not resolve"); + + assert!( + matches!( + error, + Error::StaleNode(StaleNodeError::Epoch { + hinted_epoch: 0, + .. + }) + ), + "expected a stale-node epoch error, got: {error}" + ); + } + + /// An inflated hint lands in Drive's pre-created empty epoch window, where + /// the proof matches nothing at all. + #[tokio::test] + async fn should_fail_closed_when_the_hint_is_above_every_started_epoch() { + let mut sdk = SdkBuilder::new_mock().build().expect("build mock sdk"); + sdk.mock() + .expect_fetch::( + current_epoch_probe_query(), + Some(epoch_at(GENESIS_EPOCH_INDEX)), + ) + .await + .expect("register probe expectation"); + sdk.mock() + .expect_fetch_many::<_, ExtendedEpochInfo, _, ExtendedEpochInfos>( + current_epoch_confirmation_query(0), + None, + ) + .await + .expect("register confirmation expectation"); + + let error = ExtendedEpochInfo::fetch_current(&sdk) + .await + .expect_err("an unstarted epoch must not resolve"); + + assert!( + matches!(error, Error::EpochNotFound), + "expected EpochNotFound, got: {error}" + ); + } +} diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index 459907332c6..a9f76afbf5d 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -366,18 +366,25 @@ impl Sdk { /// Eagerly teach this SDK the network's current protocol version and ratchet up to it. /// - /// Issues one ordinary **proven** `getEpochsInfo` query + /// Issues ordinary **proven** `getEpochsInfo` queries /// ([`ExtendedEpochInfo::fetch_current`]) and discards the epoch payload. The - /// protocol version that query carries in its verified response metadata is + /// protocol version those queries carry in their 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`], `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. + /// on) this issues no request and returns the pinned version. + /// + /// If the fetch fails the failure is **non-fatal**: whatever version was + /// already learned is kept — we never fall back to an unverified one. Note + /// that [`ExtendedEpochInfo::fetch_current`] makes more than one round trip, + /// and each verified response ratchets the version on its own. A refresh that + /// ends in an error may therefore still have raised the stored version, and + /// the value returned here reflects that. This is by construction: every + /// ratchet step is proof-verified and upward-only, so a partial refresh can + /// only ever leave the SDK closer to the network's real version. /// /// 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, @@ -395,8 +402,10 @@ impl Sdk { tracing::warn!( target: "dash_sdk::protocol_version", %error, - "proven protocol-version refresh failed; keeping current version \ - (never falling back to an unverified one)" + version = self.protocol_version_number(), + "proven protocol-version refresh failed; keeping the highest \ + proof-verified version learned so far (never falling back to \ + an unverified one)" ); } } @@ -2103,18 +2112,21 @@ mod test { use crate::platform::types::epoch::EpochQuery; use crate::platform::LimitQuery; use dpp::block::extended_epoch_info::{v0::ExtendedEpochInfoV0, ExtendedEpochInfo}; + use drive_proof_verifier::types::ExtendedEpochInfos; - // Must match the two queries `ExtendedEpochInfo::fetch_current` issues: - // a genesis probe, then a descending fetch from the hinted current epoch - // (mock expectation metadata reports epoch 0, so the hint is 0). + // Must match the two queries `ExtendedEpochInfo::fetch_current` issues: a + // genesis probe, then a two-epoch ascending confirmation from the hinted + // current epoch (mock expectation metadata reports epoch 0, so the hint is + // 0). The confirmation answers with epoch 0 alone, which is how a real + // proof says "no epoch above 0 has started". let probe_query = LimitQuery { query: EpochQuery::genesis(), limit: Some(1), start_info: None, }; - let query = LimitQuery { - query: EpochQuery::newest_at_or_below(0), - limit: Some(1), + let confirmation_query = LimitQuery { + query: EpochQuery::ascending_from(0), + limit: Some(2), start_info: None, }; @@ -2132,7 +2144,10 @@ mod test { .await .expect("register epoch probe expectation"); sdk.mock() - .expect_fetch::(query, Some(epoch)) + .expect_fetch_many::<_, ExtendedEpochInfo, _, ExtendedEpochInfos>( + confirmation_query, + Some(ExtendedEpochInfos::from_iter([(0, Some(epoch))])), + ) .await .expect("register epoch refresh expectation"); } diff --git a/packages/rs-sdk/tests/fetch/common.rs b/packages/rs-sdk/tests/fetch/common.rs index 13ebecdc05f..b9ff7a69174 100644 --- a/packages/rs-sdk/tests/fetch/common.rs +++ b/packages/rs-sdk/tests/fetch/common.rs @@ -10,6 +10,7 @@ use dpp::block::extended_epoch_info::v0::{ExtendedEpochInfoV0, ExtendedEpochInfo use dpp::block::extended_epoch_info::ExtendedEpochInfo; use dpp::data_contract::config::DataContractConfig; use dpp::{data_contract::DataContractFactory, prelude::Identifier}; +use drive_proof_verifier::types::ExtendedEpochInfos; use hex::ToHex; use rs_dapi_client::transport::TransportRequest; use std::collections::BTreeMap; @@ -114,17 +115,19 @@ pub fn mock_data_contract( /// `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) { - // `fetch_current` issues two queries: a genesis probe, then a descending - // fetch from the hinted current epoch (mock expectation metadata reports - // epoch 0, so the hint is 0). + // `fetch_current` issues two queries: a genesis probe, then a two-epoch + // ascending confirmation from the hinted current epoch (mock expectation + // metadata reports epoch 0, so the hint is 0). The confirmation answers with + // epoch 0 alone, which is how a real proof says "no epoch above 0 has + // started". let probe_query = LimitQuery { query: EpochQuery::genesis(), limit: Some(1), start_info: None, }; - let query = LimitQuery { - query: EpochQuery::newest_at_or_below(0), - limit: Some(1), + let confirmation_query = LimitQuery { + query: EpochQuery::ascending_from(0), + limit: Some(2), start_info: None, }; @@ -142,7 +145,10 @@ pub(crate) async fn bootstrap_mock_sdk_to_latest(sdk: &mut Sdk) { .await .expect("register epoch probe expectation"); sdk.mock() - .expect_fetch::(query, Some(epoch.clone())) + .expect_fetch_many::<_, ExtendedEpochInfo, _, ExtendedEpochInfos>( + confirmation_query, + Some(ExtendedEpochInfos::from_iter([(0, Some(epoch.clone()))])), + ) .await .expect("register epoch bootstrap expectation"); diff --git a/packages/rs-sdk/tests/fetch/epoch.rs b/packages/rs-sdk/tests/fetch/epoch.rs index 1f0d1e2e575..996b4f792a9 100644 --- a/packages/rs-sdk/tests/fetch/epoch.rs +++ b/packages/rs-sdk/tests/fetch/epoch.rs @@ -167,11 +167,13 @@ async fn test_epoch_fetch_future() { /// /// `fetch_current` issues two proved queries with request-derived ranges: a /// genesis-epoch probe (whose response metadata hints the current epoch index) -/// and a descending fetch with an explicit start at that hint. Both pass the -/// proof verifier's guard against descending queries without an explicit start -/// (resolving "the last epoch" during verification would require trusting -/// unsigned metadata, letting a malicious node pass off a stale epoch as -/// current). +/// and a two-epoch ascending fetch with an explicit start at that hint. Both +/// pass the proof verifier's guard against descending queries without an +/// explicit start (resolving "the last epoch" during verification would require +/// trusting unsigned metadata, letting a malicious node pass off a stale epoch +/// as current), and the second one *proves* the hint is the newest started +/// epoch: it returns one epoch only because the epoch above it is an empty, +/// not-yet-started tree. #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_epoch_fetch_current() { setup_logs(); @@ -179,10 +181,24 @@ async fn test_epoch_fetch_current() { let cfg = Config::new(); let sdk = cfg.setup_api("test_epoch_fetch_current").await; - let epoch = ExtendedEpochInfo::fetch_current(&sdk) + let (epoch, metadata) = ExtendedEpochInfo::fetch_current_with_metadata(&sdk) .await .expect("fetch current epoch"); - // The returned epoch is a real one, not the MAX_EPOCH query bound. + // The epoch actually returned is the chain tip's, not the genesis epoch the + // probe fetched, nor some older epoch a deflated hint would have selected. + // Compared against metadata (rather than a hard-coded index) so the test + // survives regeneration of the recorded vectors. + assert_eq!(u32::from(epoch.index()), metadata.epoch); assert!(epoch.index() < dpp::block::epoch::MAX_EPOCH); + + // The next epoch has not started; that is exactly what the confirming query + // proved, so it must not be fetchable. + assert!( + ExtendedEpochInfo::fetch(&sdk, epoch.index() + 1) + .await + .expect("fetch epoch above current") + .is_none(), + "fetch_current returned an epoch that is not the newest started one" + ); } diff --git a/packages/rs-sdk/tests/vectors/test_epoch_fetch_current/msg_GetEpochsInfoRequest_2a013be99da0db14facf92af4ce5e13f72f6116c5806f2674e8859e9e5aa069f.json b/packages/rs-sdk/tests/vectors/test_epoch_fetch_current/msg_GetEpochsInfoRequest_2a013be99da0db14facf92af4ce5e13f72f6116c5806f2674e8859e9e5aa069f.json deleted file mode 100644 index 66afd2311b1d1c3e52eb224e223873787eb5cf3c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 80224 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 S%Ztza;uk+!-{e1ke)n&!8FvN% diff --git a/packages/rs-sdk/tests/vectors/test_epoch_fetch_current/msg_GetEpochsInfoRequest_459052173de973fafadb1e9dc48c642c4a96dbe15bb56e8891028dfb50539827.json b/packages/rs-sdk/tests/vectors/test_epoch_fetch_current/msg_GetEpochsInfoRequest_459052173de973fafadb1e9dc48c642c4a96dbe15bb56e8891028dfb50539827.json new file mode 100644 index 0000000000000000000000000000000000000000..68ab6b663bfc7f391d57f2a3fbc9bc8981222f59 GIT binary patch literal 105432 zcmeI(-Oe59ah_ps>nH?mbA#jzXGEVw4j>2uLm4E70m+bL?+7~gJa@lKBgz>K`)ZrU z2oA8wA^E*ORrUN-buZ};Kl}O5fAO#1{q}eN_?ut<;_rX{hyAsGc>DdI_qX5u{vUq( z`~UgfKmX=e|7ZXF?%QwQfA!{Cx1as$H~;eM-|t`FfB!H4@Vj4q_v`=lkH7xk``6$9 z_FumH{s;g3+u!`_cdviEw*PUz|M6#k{Mq0A><_>AZ~yd*zyI&=cJRBmZ@zkeeZGG4 z?%gl<_jmhmfBvvP@7KHjzC~ZZ`S8ou{pF@lw|>2!|JbVU*S`Juy5^fVAHUrn-(LTD zcm3_g34cCkVHR#-zAcr){^rj?bk_SfA3nTUWFOvLzkk(Fzi!{3(mtns`uo>!J}$s3 z?{mS&57)o!_$%CsvXHxU_1e0Yzb%nsdHv)wp?&@F&4;hve0Y0(7SYF|`)phXoMG^% zOTYeevYq(h`s~mp`?(ye)T!J-q}iu95|Chk)IABfkrj{}-`P`-IJl`+NTNqa|Dzl_uW5{yezP-@oa{rxMrB zA0W}Y_v71ND}KrMvp=03Y2wf)r2Qd%{Utm9vW^vRKmWY&{>>o?=6f(+x{tgsAU=ia z-(2yrzyCnyFCY2joYj&SdxjM>U( zE$-9vj|aVlE6R5woEZKZ&p$c;1+H&d7MA#DP2rQMiXYE!%a2I=lfyqvSy|os&vt@O z?m8`mfEWXbAtf#hJnI*jMWsAnXII3pj(b0<7=nnWK23bRRisq&mGv*)BB|OnrxJk& z2i-En^@@Q$+rJ%E>R7q-mf=nodd4A&>hv$!0$D%c?c!05kLtq1uo!#~un@$@TeZ-r zJ`GCx%Yw|u4nKvWc;-2K#z8nr94)l+-X`q>EGn${XNxuQ2kmbH7ZSdxPFrS%(rYRd ztzV%AH`SKTM`@=BX%=w_TT&j5SJW)T`3vbh8jC2R2kMTa`tn!Y_koujzPzCYpWcF` zdht-;EB#r4IOk!=6VDO$?v~7xWImfB4_WT}Xw%;oit5}#x%<;!wNgu$#Itl+fBcY| z{>!ff!M5O}*On=frjaEs0_h4-`dM%AjZ|_)jeu-HJ0|v~C+a51Rv%Ry$w!J$A1^s;|skRbT zSFBK?tWtO&6ZKDYp$D}J?xn9rv_z!Bqq=IUs8CZOB8k+_P#vV%Qb3L0C;1CCve4NY ztw&KTq#zK~k}A4ESBO+A)cPh7V}%kM?26eMU9rV_#R@B1YRcHiN<`tfa=*`8!mg4E zV-FC#W&MS)TZxu6@qAZc7wd`#B$;N(b5I&5al2yXkLBg{%UXC_yO;h|)>|)BES@

g! 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 literal 0 HcmV?d00001 diff --git a/packages/rs-sdk/tests/vectors/test_epoch_fetch_current/msg_GetEpochsInfoRequest_b2b426ac4a52cb4cb08904c63386caf3663c40a12d3b03827006d66058e439ac.json b/packages/rs-sdk/tests/vectors/test_epoch_fetch_current/msg_GetEpochsInfoRequest_b2b426ac4a52cb4cb08904c63386caf3663c40a12d3b03827006d66058e439ac.json index 4c66a428e31e8e18909e4adacccb6cbb33b629ad..bb90d30ec1d7bc17e428575d0c22ea297cb311ce 100644 GIT binary patch delta 2759 zcmZ8je{5CN9nXc^dt27EZ!r{XpnXMXq1@Yh-n;L$u%@+!IY+X{gG6DJ!ZRlOqw?cH z=5$o((=l|b{o(uA9c_RK^iS(H;YgXoJSjT=?`!?eoOMqw6U>pqgIp?-9 z**|S_de8Tq@8{R|{QQ>r`Ym%bWt7LA*g7X3(La61$9;7*$QpZ!;=+l?kos{g)-70N z#F6c<#gPSmmG8vXJ8|EM$JMT2qgr8|^>f=>%@C}GEqLRSDrKGRT=?Pw6gEU~dYgyb zBPIr}IGL1>wg+vT_*BTp8VrF%E-LeP87?Ni5<|XG$eeF@7}#LJeZkG7tifTU9;tF0 zPemL|c3JX}X*9^3&5v?JzDyMvB2!`ES5pmm>8cm}>B{7K!|9d!d) zZxfwDB51v^Ltx^kiXsn*RvX7V^+yXtAvEAZb6Pl4?#;6Hki(6Ot@P8U0E!ZgPg zc2#-m^mq5WxYaDMdWN0crLYPP?t7I)o2cIA%CFWcUbk##=R$^-8x3fC#*&9a#u~jk zu)!QmQ4hV91=No@GJiJ_&i7ifI!>z3oFXDQAJZk?+}=eN_Dws;)-~bw9|S^g6d}9V zReYeX*v0vCz8tABe1)-`EH$FowO?T8k%-2vy(Ox}*Y+UQb0@~#7Ir;3k15G{+Ct$+ zYYC5#Ll&!yeV6C1GMbQhD=H@&3=^f(JnSI`32)$@?#Bc@zaY&deVh)c`D z+!{X1PQSlh?yNFAIl7WvRm}RxKkK9Q27&sQuL(#_C=0M$mxVJcBA9tcY1@_?YE_9| zp|x@5HaFJzs7gnX2uFyzz`pmCPCTU3f$o|YE1lS~YX#{JJ)@5lTJE{$T@(&k^e0PS z5L%YQQ6(S~+0VV8tz*j_ymvw%d_6YUW_$66{ae@IYF6m}+oocfSE9(j)`U{t zCwYN6LK?4T9Bs=xmji8%mbkccQ_F35nhhT{@sr=`;2>h`l?J5N=e?lm$nPbmttLWE zE;h9}5F1VG3AyB(iEz}E|7fG z$(6c8?vU>8?%4pi>suZ2;G?i*Yhap6#-q}jN+o<)qSJ|Fn=)w_RQXclVHx5#7`X2( zGV023nX#SL33?f20*U>E_aXaidGpeXnE5?5eGk*5`)~Dg85I!K{ht8UJ<3lyC+Fx+ zPIw5%O*E2`xnmvNs$hzC*(!83ZN&rbd`NMD+m-Ix78Ri^+MtjXaKEN>G#6z;4Gt4c z=Y3rNH`f`9qctRj_Wn~tX4H~9VoZh0Q~VcWzP&~0PxH@uvZG3&X3Ih~{icE{kYa#Y z;#9-`q4Flr{;UkCb`P(Vxq4`o_q*s^O?arzqnllvo^4d_b+MN$^HS&jLWBOol$+wz z!%KgAO}=?i^i|I*PRp9oL7WyNWpmT1Fjqm^9c~Y97A804CrATl$#o4PWLb~ zf?1B|R_TP}ApRyL)y!L*p7mvyN#(CDRv}%Z!Af9m02NvTA?#+4*Roo0*fg#OfRCQ6 z{`iZJ>FFUG=^7XLM=ja9jwA8qr(#M2Dj*1L5Im{_P7m!^rTCYwa-Ls|>r_qOFVO#L z61$rPg@W+A%!g4uvutAG;@&}_$Ln#1TlnkLhe{R|0o~&iAs+|ZJ(V_=46mepv@;Jr z{^i{{6qL4f(agq(BK}MJs%Oh&YFB_52ayVenYC<6ldJp_I6|S_b6P041o4hk(m@nZ zI1{7De3=x6kcnpI>HM=sQ#aD6s8h;l)XA-iQHA35Qcq7yH7tT#DdT0HpkIC?)bZLsY)Z&%X>g%N^jB&P zeftte27x=o#|obc>S;zZ=mP@b7rvn*L%rbY)nVa|z$l56jTjg*Wq&a#_5Q4$H;p)Z a%$MJ4BH?@rE{Lti=8_eG-uqgN!T$k0t}`b9 delta 2775 zcmYLLZEO_R71h9a#xw@o3QDC0dreZ4*q+&WJF~mS<|9UeIzI@kln6V7+HNDFD#^;L z_D3iw*nk_;pxB!|g@+0Ow}b`)#489++wifVQiQ1SMp5~b09$IL(l%uyA)&^O-Fx5c z8p-yqXWqN--gD16_nn;74^8UngjOE5BJ|^_zrylxVeLW;Y4Orsd(7H4%v0 z$WM3}-RYoao*_%4S`e{51Fv7SQrp8wTowpOCv{s#X1*hZqY0$18W=qi#K@$881!)d zD+|4Egk^7~=Ahz-I{x#vK<=jk&%EXG#z5G{pS?xf zpR#GYgM*ht=vv~?aR#~v>eXj_TL|TwT_mD9Iv%x-g%pTh8$Ah4p*Tya6A9$(lCX%j=uADD2SrP8RIWuFI;8>cueE~@T^kXvsf*HnPxMLNQS*jQhDygNx8`Qo<@hJ=ox zZv^s-3`~EopWbs*b(Bm~s&{qS5}|T40ovS3dDfc9%@MdmY1M47Q-?gPi3gFr>@i*? z5P=}p)Ty8{U8xZ*KV*O#<+3@HFz*gNAT$w1*N6|X{C1%x#dYn8VNxzac7#87?sOx&I8Pr2t%T(n0mDAd;;mvdiF|!bGgiVxXQsNSq@~JD>IB z)MBbCAv$FGyR@kJC2DPJ6!Pl`-Zn$%Z6glsbde0`GSjTt@|ggopFe7%>wqnfnOYRx zoi?`Cd3c8kYX<%bDXcpJu1q#*MDZ$tnm$*_k@+!t z+A1hJA_QOGR3_FB@fwO-WNYn@MW!yzqV}s1jrf&E` zw|&@e{DmFHz_laHYr#8NswP(dvaq80wBROFp!UM6^#3e~L%KZQKv3tlMA5rN$DiJ? zvA;8l?0f5&gqehG`4s$<_rK6GV2@oNYK9b~S1i1*t?L9IGrqw`IH z{d?&!0zbla=6ws!jxZV?xX9Jnr$po9S)85ku~Vilx;`cY2|mWehMU%&I;|1^ z=<_hW*`YR?_~&D`+!rL6%1?RBDi7U{c*wgBISS?CH`?Y7`6cY{^ngL;o`L^*%79SO zLc$&=xFf{FWSX$@W$I$Iy8#{788o$>T0?fKa>;Kb)=-R0B7~klL~+g}t$kCUTCpB2 z&Fk@H50R(M;qD`QU$83vPTF;`D;XyHCQLVwF%hL=uh8cT`;%Cu)>OvBLg2tQ2Vca= zib&Mt?#U6Z)IjTrdd2soMc5AO^7?FoP|=(QRRd>Z%FU%DzN&H;BBbN-AKOYf*Z<1a zH=`gr`?V{8Kk2rPQPYZ~qlB!g zWgjE=xYk`S-}E#`{)Wi-khU!P#HS9ME?HU&d$-966xHu1BEKdb2BJu;@|34kUv=2U z7;!Rr^2~`6RWjM~E?bTG92qK`61nh>Hu9(YD$|D^Qn;;f6AhHoXWEpij SS!-bXoP{hCOTM>E+x>sYVLHeF diff --git a/packages/rs-sdk/tests/vectors/test_epoch_fetch_current/msg_GetEpochsInfoRequest_d7e33f3bbf7640d1c44d5ea7f0ad05ca3568fa6327fe2f18bfa7200b17b53dbf.json b/packages/rs-sdk/tests/vectors/test_epoch_fetch_current/msg_GetEpochsInfoRequest_d7e33f3bbf7640d1c44d5ea7f0ad05ca3568fa6327fe2f18bfa7200b17b53dbf.json new file mode 100644 index 0000000000000000000000000000000000000000..b4393e23be8af0495ab61c4c670b945c0767b9d4 GIT binary patch literal 82707 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 Date: Sun, 2 Aug 2026 19:35:16 +0700 Subject: [PATCH 3/3] fix(sdk): select the current epoch by index, not by response order CodeRabbit review follow-up. ExtendedEpochInfos is an insertion-ordered IndexMap, not a BTreeMap, so the confirmation handling could not rely on iteration position to identify the candidate epoch. Today the verifier happens to insert in ascending index order, but the code read as if that were guaranteed: a response listing the newer epoch first would have been mistaken for a stale hint pointing at the older one, and the refinement loop would have spun until it failed closed. Selection now goes through a BTreeMap keyed by epoch index, so the outcome depends only on which epochs the proof contains. Also: dash_sdk_get_platform_status reported a hard-coded "version":10 in both JSON payloads. It now reports the current epoch's protocol version off the verified proof, falling back to the SDK's highest verified version when there is no epoch to read. --- .../src/system/queries/platform_status.rs | 20 ++++- packages/rs-sdk/src/platform/types/epoch.rs | 84 ++++++++++++++----- 2 files changed, 80 insertions(+), 24 deletions(-) diff --git a/packages/rs-sdk-ffi/src/system/queries/platform_status.rs b/packages/rs-sdk-ffi/src/system/queries/platform_status.rs index 2cf9810c192..d2db4eddb06 100644 --- a/packages/rs-sdk-ffi/src/system/queries/platform_status.rs +++ b/packages/rs-sdk-ffi/src/system/queries/platform_status.rs @@ -76,7 +76,9 @@ fn get_platform_status(sdk_handle: *const SDKHandle) -> Result { let json = format!( r#"{{"version":{},"network":"{}","blockHeight":{},"coreHeight":{}}}"#, - 10, // Protocol version + // The current epoch's protocol version, straight off the + // verified proof — not a hard-coded guess. + epoch.protocol_version(), network_str, block_height, core_height @@ -84,10 +86,12 @@ fn get_platform_status(sdk_handle: *const SDKHandle) -> Result { Ok(json) } Err(dash_sdk::Error::EpochNotFound) => { - // If no epochs found, return default values + // No epoch to read a version off, so fall back to the highest + // version the SDK has verified so far. let json = format!( r#"{{"version":{},"network":"{}","blockHeight":0,"coreHeight":0}}"#, - 10, network_str + sdk.protocol_version_number(), + network_str ); Ok(json) } @@ -174,7 +178,8 @@ mod tests { } /// With both `fetch_current` queries answered, the FFI entry point must - /// report the fetched epoch's heights rather than the zeroed fallback. + /// report the fetched epoch's protocol version and heights rather than the + /// zeroed fallback. #[test] fn test_get_platform_status() { let handle = create_mock_sdk_handle_with_current_epoch(); @@ -197,6 +202,13 @@ mod tests { json.contains(&format!(r#""coreHeight":{}"#, MOCK_EPOCH_CORE_HEIGHT)), "unexpected json: {json}" ); + assert!( + json.contains(&format!( + r#""version":{}"#, + dash_sdk::dpp::version::LATEST_VERSION + )), + "the reported version must come from the fetched epoch: {json}" + ); let _ = CString::from_raw(result.data as *mut c_char); destroy_mock_sdk_handle(handle); diff --git a/packages/rs-sdk/src/platform/types/epoch.rs b/packages/rs-sdk/src/platform/types/epoch.rs index c0614a3ddab..4cbbcf8fe68 100644 --- a/packages/rs-sdk/src/platform/types/epoch.rs +++ b/packages/rs-sdk/src/platform/types/epoch.rs @@ -13,6 +13,7 @@ use crate::{ platform::{Fetch, FetchMany, LimitQuery, Query}, Error, Sdk, }; +use std::collections::BTreeMap; /// Epoch type used in the SDK. pub type Epoch = ExtendedEpochInfo; @@ -113,30 +114,33 @@ async fn resolve_current_epoch( ) .await?; - let mut started = epochs + // `ExtendedEpochInfos` is insertion-ordered, so select by index rather + // than by position. + let mut started: BTreeMap = epochs .into_iter() - .filter_map(|(index, info)| info.map(|info| (index, info))); - - // Nothing at or above the candidate has started: the query landed in - // Drive's pre-created empty epoch window. - let Some((index, info)) = started.next() else { - return Err(Error::EpochNotFound); - }; - - match started.next() { - // Only the candidate came back, so the proof also covers the epoch - // above it and shows it as not started: the candidate is current. - None if index == candidate => return Ok((info, metadata, proof)), - // Epochs are initialized contiguously, so a gap at the candidate with - // a started epoch above it cannot happen in a well-formed state. - None => { + .filter_map(|(index, info)| info.map(|info| (index, info))) + .collect(); + + match started.keys().next_back().copied() { + // Nothing at or above the candidate has started: the query landed in + // Drive's pre-created empty epoch window. + None => return Err(Error::EpochNotFound), + // The candidate is the newest epoch the proof shows as started, so + // the proof also covers the epoch above it and shows it as not + // started: the candidate is current. + Some(newest) if newest == candidate => { + let info = started.remove(&candidate).expect("key just observed"); + return Ok((info, metadata, proof)); + } + // The hint was below the chain tip; retry from the proven epoch. + Some(newest) if newest > candidate => candidate = newest, + // The query range starts at the candidate, so a verified proof + // cannot contain anything below it. + Some(newest) => { return Err(Error::InvalidProvedResponse(format!( - "epoch {candidate} is not started but epoch {index} is; \ - epochs must be initialized contiguously" + "epoch query from {candidate} returned epoch {newest}, below its own start" ))) } - // The hint was below the chain tip; retry from the proven epoch. - Some((newer_index, _)) => candidate = newer_index, } } @@ -375,6 +379,46 @@ mod mock_tests { assert_eq!(epoch.index(), 0); } + /// `ExtendedEpochInfos` is an insertion-ordered `IndexMap`, so which epoch + /// is "first" is a property of how the response was built, not of the epoch + /// indices. Selection must go by index: fed a response listing the newer + /// epoch first, the fetch must still recognise the hint as stale and + /// advance, rather than mistake the ordering for a malformed proof. + #[tokio::test] + async fn should_ignore_the_order_epochs_arrive_in() { + let mut sdk = SdkBuilder::new_mock().build().expect("build mock sdk"); + sdk.mock() + .expect_fetch::( + current_epoch_probe_query(), + Some(epoch_at(GENESIS_EPOCH_INDEX)), + ) + .await + .expect("register probe expectation"); + sdk.mock() + .expect_fetch_many::<_, ExtendedEpochInfo, _, ExtendedEpochInfos>( + current_epoch_confirmation_query(0), + Some(ExtendedEpochInfos::from_iter([ + (1, Some(epoch_at(1))), + (0, Some(epoch_at(0))), + ])), + ) + .await + .expect("register out-of-order confirmation expectation"); + sdk.mock() + .expect_fetch_many::<_, ExtendedEpochInfo, _, ExtendedEpochInfos>( + current_epoch_confirmation_query(1), + Some(ExtendedEpochInfos::from_iter([(1, Some(epoch_at(1)))])), + ) + .await + .expect("register confirmation expectation"); + + let epoch = ExtendedEpochInfo::fetch_current(&sdk) + .await + .expect("fetch current epoch"); + + assert_eq!(epoch.index(), 1); + } + /// The hint is one epoch stale — an epoch turning over mid-fetch, or a node /// deflating it. The proof carries the newer epoch, so the query repeats /// from there and the newer epoch wins.