Skip to content
Closed
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
29 changes: 25 additions & 4 deletions packages/rs-sdk/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -357,10 +357,14 @@ where

impl CanRetry for Error {
fn can_retry(&self) -> bool {
matches!(
self,
Error::StaleNode(..) | Error::TimeoutReached(_, _) | Error::Proof(_)
)
match self {
// Not a node failure: the Sdk's own chain id may be the outdated one.
// `sync::retry` still fails over to another node, with a short flat exclusion
// instead of a health ban.
Error::StaleNode(StaleNodeError::ChainIdMismatch { .. }) => false,
Error::StaleNode(..) | Error::TimeoutReached(_, _) | Error::Proof(_) => true,
_ => false,
}
}

fn is_no_available_addresses(&self) -> bool {
Expand Down Expand Up @@ -397,6 +401,23 @@ pub enum StaleNodeError {
/// Tolerance in milliseconds
tolerance_ms: u64,
},
/// Server returned metadata of another chain than the one the Sdk expects
///
/// The chain id is covered by the quorum signature, so this means the server
/// proved state of another Platform chain, e.g. one from before a network reset.
///
/// Unlike the other variants, this error is not retryable: the Sdk fails over to
/// other servers with a short flat exclusion instead of the exponential health
/// ban, and returns this error if none of them serves the expected chain.
///
/// See [`SdkBuilder::with_expected_chain_id`](crate::SdkBuilder::with_expected_chain_id).
#[error("received chain id is different: expected {expected}, received {received}; try another server")]
ChainIdMismatch {
/// Chain id the Sdk was configured with
expected: String,
/// Chain id received from the server
received: String,
},
/// Server kept reporting a current epoch that its own proofs contradict
///
/// The epoch index in response metadata is not covered by the quorum
Expand Down
9 changes: 8 additions & 1 deletion packages/rs-sdk/src/mock/sdk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -552,11 +552,18 @@ impl MockDashPlatformSdk {
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.
// network; `default()` reports 0, which the ratchet ignores. Report the
// expected chain id, if any, so that the chain id check passes.
Some(d) => (
Option::<O>::mock_deserialize(self, d),
ResponseMetadata {
protocol_version: dpp::version::LATEST_VERSION,
chain_id: self
.sdk
.load()
.as_ref()
.and_then(|sdk| sdk.expected_chain_id.clone())
.unwrap_or_default(),
..Default::default()
},
Proof::default(),
Expand Down
5 changes: 3 additions & 2 deletions packages/rs-sdk/src/platform/transition/broadcast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -534,8 +534,9 @@ impl WaitForOutcome for StateTransition {
.inner;

// `metadata` is quorum-authenticated only after the verification above, so the
// protocol-version ratchet must run here, never before. A `StaleNode` error is
// retryable and prompts another server.
// protocol-version ratchet must run here, never before. A `StaleNode` error
// prompts another server: most variants are retryable, and `sync::retry` fails
// over on `ChainIdMismatch` too.
let _: () = sdk
.verify_response_metadata("wait_for_state_transition_result", &metadata)
.wrap_to_execution_result(&response)?
Expand Down
146 changes: 146 additions & 0 deletions packages/rs-sdk/src/sdk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,11 @@ pub struct Sdk {
/// See [SdkBuilder::with_time_tolerance] for more information.
metadata_time_tolerance_ms: Option<u64>,

/// Chain id that response metadata must carry.
///
/// See [SdkBuilder::with_expected_chain_id] for more information.
pub(crate) expected_chain_id: Option<String>,

/// Cancellation token; once cancelled, all pending requests should be aborted.
pub(crate) cancel_token: CancellationToken,

Expand All @@ -276,6 +281,7 @@ impl Clone for Sdk {
metadata_last_seen_height: Arc::clone(&self.metadata_last_seen_height),
metadata_height_tolerance: self.metadata_height_tolerance,
metadata_time_tolerance_ms: self.metadata_time_tolerance_ms,
expected_chain_id: self.expected_chain_id.clone(),
dapi_client_settings: self.dapi_client_settings,
#[cfg(feature = "mocks")]
dump_dir: self.dump_dir.clone(),
Expand Down Expand Up @@ -368,6 +374,16 @@ impl Sdk {
method_name: &str,
metadata: &ResponseMetadata,
) -> Result<(), Error> {
// A response from another chain must not advance any state below.
if let Some(expected) = &self.expected_chain_id {
if metadata.chain_id != *expected {
return Err(StaleNodeError::ChainIdMismatch {
expected: expected.clone(),
received: metadata.chain_id.clone(),
}
.into());
}
}
let (metadata_height_tolerance, metadata_time_tolerance_ms) =
self.freshness_criteria(method_name);
// Check the independent local-clock anchor before mutating the
Expand Down Expand Up @@ -863,6 +879,11 @@ pub struct SdkBuilder {
/// monotonic freshness high-water mark.
trusted_initial_height: Option<u64>,

/// Chain id that response metadata must carry.
///
/// See [SdkBuilder::with_expected_chain_id] for more information.
expected_chain_id: Option<String>,

/// directory where dump files will be stored
#[cfg(feature = "mocks")]
dump_dir: Option<PathBuf>,
Expand Down Expand Up @@ -891,6 +912,7 @@ impl Default for SdkBuilder {
metadata_height_tolerance: Some(1),
metadata_time_tolerance_ms: None,
trusted_initial_height: None,
expected_chain_id: None,

#[cfg(feature = "mocks")]
data_contract_cache_size: NonZeroUsize::new(DEFAULT_CONTRACT_CACHE_SIZE)
Expand Down Expand Up @@ -1168,6 +1190,23 @@ impl SdkBuilder {
self
}

/// Require response metadata to carry the given Tenderdash chain id.
///
/// The chain id is covered by the quorum signature, but the quorums that sign it can be
/// the same on two Platform chains, e.g. before and after a testnet reset. A response
/// with another chain id fails with [`StaleNodeError::ChainIdMismatch`] before it can
/// advance the height high-water mark or the protocol version. The Sdk then tries
/// other servers, excluding the one that answered for a short flat 2 s rather than
/// putting it on the exponential health-ban ladder, since the configured chain id
/// may be the outdated one.
///
/// If not set, the chain id is not checked. An empty chain id is rejected by
/// [SdkBuilder::build].
pub fn with_expected_chain_id(mut self, chain_id: impl Into<String>) -> Self {
self.expected_chain_id = Some(chain_id.into());
self
}

/// Configure directory where dumps of all requests and responses will be saved.
/// Useful for debugging.
///
Expand Down Expand Up @@ -1209,6 +1248,11 @@ impl SdkBuilder {
.to_string(),
));
}
if self.expected_chain_id.as_deref() == Some("") {
return Err(Error::Config(
"expected chain id must not be empty".to_string(),
));
}

let dapi_client_settings = match self.settings {
Some(settings) => DEFAULT_REQUEST_SETTINGS.override_by(settings),
Expand Down Expand Up @@ -1252,6 +1296,7 @@ impl SdkBuilder {
)),
metadata_height_tolerance: self.metadata_height_tolerance,
metadata_time_tolerance_ms: self.metadata_time_tolerance_ms,
expected_chain_id: self.expected_chain_id,
#[cfg(feature = "mocks")]
dump_dir: self.dump_dir,
};
Expand Down Expand Up @@ -1324,6 +1369,7 @@ impl SdkBuilder {
)),
metadata_height_tolerance: self.metadata_height_tolerance,
metadata_time_tolerance_ms: self.metadata_time_tolerance_ms,
expected_chain_id: self.expected_chain_id,
};
let mut guard = mock_sdk.try_lock().expect("mock sdk is in use by another thread and cannot be reconfigured");
guard.set_sdk(sdk.clone());
Expand Down Expand Up @@ -1646,6 +1692,106 @@ mod test {
);
}

fn chain_id_metadata(chain_id: &str) -> ResponseMetadata {
ResponseMetadata {
chain_id: chain_id.to_string(),
height: 10,
protocol_version: 2,
..Default::default()
}
}

fn mock_sdk_with_chain_id(chain_id: &str) -> super::Sdk {
let sdk = SdkBuilder::new_mock()
.with_expected_chain_id(chain_id)
.build()
.expect("mock Sdk should be created");
sdk.protocol_version
.store(1, std::sync::atomic::Ordering::Relaxed);
sdk
}

#[test]
fn chain_id_mismatch_is_rejected_before_any_state_advances() {
use rs_dapi_client::CanRetry;

let sdk = mock_sdk_with_chain_id("dash-testnet-51");

let error = sdk
.verify_response_metadata("test", &chain_id_metadata("dash-testnet-50"))
.expect_err("a response from another chain must be rejected");

assert!(matches!(
&error,
crate::Error::StaleNode(super::StaleNodeError::ChainIdMismatch { expected, received })
if expected == "dash-testnet-51" && received == "dash-testnet-50"
));
assert!(
!error.can_retry(),
"the server must not be health-banned for serving another chain"
);
assert_eq!(sdk.protocol_version_number(), 1);
assert_eq!(
sdk.metadata_last_seen_height
.load(std::sync::atomic::Ordering::Acquire),
0
);
}

#[test]
fn matching_chain_id_is_accepted() {
let sdk = mock_sdk_with_chain_id("dash-testnet-51");

sdk.verify_response_metadata("test", &chain_id_metadata("dash-testnet-51"))
.expect("metadata should be valid");

assert_eq!(sdk.protocol_version_number(), 2);
assert_eq!(
sdk.metadata_last_seen_height
.load(std::sync::atomic::Ordering::Acquire),
10
);
}

#[test]
fn chain_id_is_not_checked_when_not_configured() {
let sdk = SdkBuilder::new_mock()
.build()
.expect("mock Sdk should be created");

sdk.verify_response_metadata("test", &chain_id_metadata("dash-testnet-50"))
.expect("metadata should be valid");
}

#[test]
fn chain_id_is_checked_before_time() {
let sdk = SdkBuilder::new_mock()
.with_expected_chain_id("dash-testnet-51")
.with_time_tolerance(Some(1000))
.build()
.expect("mock Sdk should be created");

// `chain_id_metadata` carries time 0, far outside the tolerance.
let error = sdk
.verify_response_metadata("test", &chain_id_metadata("dash-testnet-50"))
.expect_err("a response from another chain must be rejected");

assert!(matches!(
error,
crate::Error::StaleNode(super::StaleNodeError::ChainIdMismatch { .. })
));
}

#[test]
fn empty_expected_chain_id_is_rejected() {
let error = SdkBuilder::new_mock()
.with_expected_chain_id("")
.build()
.expect_err("an empty chain id must be rejected");

assert!(matches!(error, crate::Error::Config(_)));
}

#[test_matrix(97..102, 100, 2, false; "valid height")]
#[test_case(103, 100, 2, true; "invalid height")]
fn test_verify_metadata_height(
Expand Down
Loading
Loading