From 30a5546cca4058e257ec6dfb1a2241fd1e2b3cda Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 29 Oct 2026 10:19:45 -0500 Subject: [PATCH 01/11] feat(sdk): refresh the embedded quorum-proof checkpoints on release `yarn release` now moves the verified provider's embedded checkpoints forward. A new example, `refresh_checkpoints`, asks each network's relay for a proof from the current checkpoint to a block just past the oldest active Platform quorum's mining (about a day below the tip), verifies it, and writes the target as the new checkpoint along with the header, coinbase and Merkle branch the `checkpoints` test checks. Each checkpoint is proven from the one it replaces. A network whose relay fails keeps its checkpoint and the release warns; `MAINNET_QUORUM_RELAY` / `TESTNET_QUORUM_RELAY` select another relay while public mainnet `/proofs` returns 403. The unit test that pinned the checkpoint heights now checks the network and a floor instead. Co-Authored-By: Claude Opus 5.5 --- .../rs-sdk-trusted-context-provider/README.md | 45 +++- .../examples/refresh_checkpoints.rs | 193 ++++++++++++++++++ .../src/verified_provider.rs | 12 +- scripts/release/pr_description.md | 1 + scripts/release/release.sh | 13 ++ 5 files changed, 253 insertions(+), 11 deletions(-) create mode 100644 packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs diff --git a/packages/rs-sdk-trusted-context-provider/README.md b/packages/rs-sdk-trusted-context-provider/README.md index 67212b58059..8e669be2a55 100644 --- a/packages/rs-sdk-trusted-context-provider/README.md +++ b/packages/rs-sdk-trusted-context-provider/README.md @@ -230,9 +230,9 @@ response must be is the SDK's policy. A proof covers every ChainLock quorum rotation between its starting state and its target, so it grows as the checkpoint ages, and a relay has to keep the -blocks back to it. Each SDK release can move the embedded checkpoints forward. -A long-lived application should persist `latest_verified_state()` and pass it -back to `with_checkpoint` on the next start, so its proofs stay short. +blocks back to it. Each release moves the embedded checkpoints forward, as +below. A long-lived application should persist `latest_verified_state()` and +pass it back to `with_checkpoint` on the next start, so its proofs stay short. ### Limitations @@ -256,7 +256,36 @@ back to `with_checkpoint` on the next start, so its proofs stay short. ### Refreshing the embedded checkpoints `checkpoints/.json` hold Core `ProofState` JSON (hashes in RPC display -order). To move one to a newer block `H`: +order). `yarn release` moves both forward with + +```sh +cargo run -p rs-sdk-trusted-context-provider --example refresh_checkpoints [mainnet] [testnet] +``` + +For each network it asks the public relay for a proof from the current +checkpoint to a block about a day below the tip (just past the oldest active +Platform quorum's mining), verifies it, and writes the proof's target as the +new checkpoint. Each new checkpoint is proven from the one it replaces, so a +refresh adds no trust in the relay. It also rewrites +`tests/data/checkpoint-evidence-.json` from the proof: the target's +raw header, its coinbase transaction and the coinbase's Merkle branch. The +`checkpoints` test recomputes the block hash, the header's Merkle root and +both roots from that evidence. + +`MAINNET_QUORUM_RELAY` or `TESTNET_QUORUM_RELAY` points a network at another +relay serving `/quorums` and `/proofs`, for example a self-hosted one while +public mainnet `/proofs` returns 403. A network that fails keeps its +checkpoint, and the release prints a warning; an older checkpoint stays valid +and only makes proofs longer. A target well above the requested height is +refused, since a checkpoint at the tip would leave the heights Platform still +reports unprovable. + +A refresh relies on the same thing every proof does: that no quorum on the way +from the old checkpoint signed a false ChainLock. A bad checkpoint would carry +into every later SDK build, so the release PR's reviewer should compare each +printed block hash with a block explorer at that height. + +To move a checkpoint by hand, for example when no relay can serve it: 1. Pick a height with `chainlock: true` in `getblock` on two independent Core nodes, and confirm `getblockhash H` and the block hash on a block explorer @@ -264,11 +293,9 @@ order). To move one to a newer block `H`: 2. Write the state from the block: `network` (0 mainnet, 1 testnet), `height`, `block_hash`, and the coinbase payload's `merkleRootMNList` and `merkleRootQuorums` as `masternode_root` and `quorum_root`. -3. Update `tests/data/checkpoint-evidence-.json`: the raw header - (`getblockheader false`), the raw coinbase transaction, and the - coinbase's Merkle branch (sibling hashes in wire order). The - `checkpoints` test recomputes the block hash, the header's Merkle root and - both roots from this evidence. +3. Write the evidence file: the raw header (`getblockheader false`), the + raw coinbase transaction, and the coinbase's Merkle branch (sibling hashes + in wire order). ## Implementation Details diff --git a/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs b/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs new file mode 100644 index 00000000000..955304e2268 --- /dev/null +++ b/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs @@ -0,0 +1,193 @@ +//! Moves the embedded checkpoints forward. `yarn release` runs it. +//! +//! ```sh +//! cargo run -p rs-sdk-trusted-context-provider --example refresh_checkpoints [mainnet] [testnet] +//! ``` +//! +//! For each network it asks the relay for a proof from the current +//! checkpoint, verifies it, and writes the proof's target as the new +//! checkpoint along with the evidence `tests/checkpoints.rs` checks. A new +//! checkpoint is proven from the one it replaces, so a refresh adds no trust +//! in the relay. `MAINNET_QUORUM_RELAY` or `TESTNET_QUORUM_RELAY` replaces a +//! network's public relay, which must serve both `/quorums` and `/proofs`. +//! +//! The target is about one Platform quorum lifetime (a day) below the tip, +//! so the Core heights Platform reports after a release are above it. A +//! network that fails keeps its checkpoint, and the exit status is nonzero. + +use dash_core_proof::{bootstrap, rpc_hex, QuorumProofChain}; +use dpp::dashcore::sml::llmq_type::network::NetworkLLMQExt; +use dpp::dashcore::Network; +use rs_sdk_trusted_context_provider::types::QuorumsResponse; +use rs_sdk_trusted_context_provider::{get_quorum_base_url, VerifiedHttpContextProvider}; +use serde_json::json; +use std::path::PathBuf; +use std::process::ExitCode; +use std::time::Duration; + +#[tokio::main] +async fn main() -> ExitCode { + let mut networks = Vec::new(); + for arg in std::env::args().skip(1) { + match arg.as_str() { + "mainnet" => networks.push(Network::Mainnet), + "testnet" => networks.push(Network::Testnet), + _ => { + eprintln!("usage: refresh_checkpoints [mainnet] [testnet]"); + return ExitCode::FAILURE; + } + } + } + if networks.is_empty() { + networks = vec![Network::Mainnet, Network::Testnet]; + } + + let client = match reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(180)) + .build() + { + Ok(client) => client, + Err(error) => { + eprintln!("cannot build an HTTP client: {error}"); + return ExitCode::FAILURE; + } + }; + + let mut status = ExitCode::SUCCESS; + for network in networks { + match refresh(&client, network).await { + Ok(message) => println!("{network}: {message}"), + Err(error) => { + eprintln!("{network}: checkpoint not refreshed: {error}"); + status = ExitCode::FAILURE; + } + } + } + status +} + +async fn refresh(client: &reqwest::Client, network: Network) -> Result { + let current = VerifiedHttpContextProvider::embedded_checkpoint(network) + .map_err(|error| error.to_string())?; + let name = network.to_string(); + let variable = format!("{}_QUORUM_RELAY", name.to_uppercase()); + let relay = match std::env::var(&variable) { + Ok(relay) => relay.trim_end_matches('/').to_string(), + Err(_) => get_quorum_base_url(network, None).map_err(|error| error.to_string())?, + }; + + // The oldest active quorum is in every quorum root from shortly after + // its DKG until about a day later, so a target just past its mining + // can open it. + let quorums: QuorumsResponse = client + .get(format!("{relay}/quorums")) + .send() + .await + .and_then(|response| response.error_for_status()) + .map_err(|error| format!("{relay}/quorums: {error}"))? + .json() + .await + .map_err(|error| format!("{relay}/quorums: {error}"))?; + let oldest = quorums + .data + .iter() + .min_by_key(|quorum| quorum.height) + .ok_or_else(|| format!("{relay}/quorums lists no quorums"))?; + // A quorum's height is where its DKG started; it is mined within the + // next DKG interval. + let interval = network.platform_type().params().dkg_params.interval; + let height = u32::try_from(oldest.height) + .ok() + .and_then(|height| height.checked_add(interval)) + .ok_or_else(|| format!("quorum height {} is out of range", oldest.height))?; + if height <= current.height { + return Ok(format!( + "kept {}; the relay's oldest quorum is not past it", + current.height + )); + } + + let response = client + .post(format!("{relay}/proofs")) + .json(&json!({ + "checkpoint": rpc_hex(¤t.block_hash), + "height": height, + "quorumHash": oldest.quorum_hash, + "llmqType": u8::from(network.platform_type()), + "nodeCount": 0, + })) + .send() + .await + .and_then(|response| response.error_for_status()) + .map_err(|error| format!("{relay}/proofs: {error}"))?; + let body = response + .bytes() + .await + .map_err(|error| format!("{relay}/proofs: {error}"))?; + + let target = bootstrap::verify(&body, ¤t, height) + .map_err(|error| format!("the proof does not verify: {}", error.message()))? + .state() + .clone(); + // The relay's height is a minimum. A target far above it, near the tip, + // would leave heights Platform still reports unprovable. + if target.height > height.saturating_add(interval) { + return Err(format!( + "the relay answered at {}, well above the {height} asked", + target.height + )); + } + // The envelope opens with the chain as a blob: a little-endian u32 + // length and the bytes. `verify` has already parsed it. + let length = u32::from_le_bytes(body[..4].try_into().expect("4 bytes")) as usize; + let chain = QuorumProofChain::decode(&body[4..4 + length]) + .map_err(|error| format!("the proof does not decode: {}", error.message()))?; + let evidence = json!({ + "header": hex::encode(chain.target.header), + "coinbase": hex::encode(&chain.coinbase.transaction), + "coinbase_path": { + "index": chain.coinbase.path.index, + "count": chain.coinbase.path.count, + "siblings": chain.coinbase.path.siblings.iter().map(hex::encode).collect::>(), + }, + }); + + let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + write_together([ + ( + crate_dir.join(format!("tests/data/checkpoint-evidence-{name}.json")), + to_json(&evidence)?, + ), + ( + crate_dir.join(format!("checkpoints/{name}.json")), + to_json(&target)?, + ), + ])?; + Ok(format!( + "{} -> {} ({})", + current.height, + target.height, + rpc_hex(&target.block_hash) + )) +} + +fn to_json(value: &impl serde::Serialize) -> Result { + serde_json::to_string_pretty(value) + .map(|text| text + "\n") + .map_err(|error| error.to_string()) +} + +/// Writes every file beside its destination first, so a failed write leaves +/// the checkpoint and its evidence as they were. +fn write_together(files: [(PathBuf, String); N]) -> Result<(), String> { + let staged = files.map(|(path, text)| (path.with_extension("json.new"), path, text)); + for (staging, _, text) in &staged { + std::fs::write(staging, text).map_err(|error| format!("{}: {error}", staging.display()))?; + } + for (staging, path, _) in &staged { + std::fs::rename(staging, path).map_err(|error| format!("{}: {error}", path.display()))?; + } + Ok(()) +} diff --git a/packages/rs-sdk-trusted-context-provider/src/verified_provider.rs b/packages/rs-sdk-trusted-context-provider/src/verified_provider.rs index 3e3506154dc..c4119000213 100644 --- a/packages/rs-sdk-trusted-context-provider/src/verified_provider.rs +++ b/packages/rs-sdk-trusted-context-provider/src/verified_provider.rs @@ -1928,7 +1928,9 @@ mod tests { } let mainnet = VerifiedHttpContextProvider::embedded_checkpoint(Network::Mainnet) .expect("mainnet checkpoint"); - assert_eq!(mainnet.height, 2_550_000); + // Releases move the checkpoints forward, never back. + assert_eq!(mainnet.network, ProofNetwork::Mainnet); + assert!(mainnet.height >= 2_550_000); let mismatched = VerifiedHttpContextProvider::with_checkpoint( Network::Testnet, mainnet.clone(), @@ -1953,7 +1955,13 @@ mod tests { let testnet = VerifiedHttpContextProvider::new(Network::Testnet, cache_size()) .expect("a testnet provider without network access"); - assert_eq!(testnet.checkpoint().height, 1_560_000); + assert_eq!( + *testnet.checkpoint(), + VerifiedHttpContextProvider::embedded_checkpoint(Network::Testnet) + .expect("testnet checkpoint") + ); + assert_eq!(testnet.checkpoint().network, ProofNetwork::Testnet); + assert!(testnet.checkpoint().height >= 1_560_000); assert_eq!(testnet.latest_verified_state(), *testnet.checkpoint()); assert_eq!( testnet.sources()[0].as_str(), diff --git a/scripts/release/pr_description.md b/scripts/release/pr_description.md index e1504e6a9e1..fa96412bea9 100644 --- a/scripts/release/pr_description.md +++ b/scripts/release/pr_description.md @@ -10,6 +10,7 @@ Release new Dash Platform version - Updated changelog - Bumped packages version +- Moved the SDK's embedded quorum-proof checkpoints forward (reviewers: check each new block hash at its height on a block explorer) ## How Has This Been Tested? diff --git a/scripts/release/release.sh b/scripts/release/release.sh index 939ab1a1999..cbb7600466f 100755 --- a/scripts/release/release.sh +++ b/scripts/release/release.sh @@ -91,6 +91,16 @@ else node "$DIR/../../packages/dashmate/scripts/generate-tenderdash-seeds.js" fi +# Move the SDK's embedded quorum-proof checkpoints forward. Each new checkpoint +# is proven from the one it replaces. A network whose relay fails keeps its +# checkpoint, which stays valid; proofs from it are only longer. +# MAINNET_QUORUM_RELAY / TESTNET_QUORUM_RELAY replace a network's public relay. +# See packages/rs-sdk-trusted-context-provider/README.md. +if ! cargo run --quiet --package rs-sdk-trusted-context-provider --example refresh_checkpoints; then + CHECKPOINTS_KEPT=1 + echo "WARNING: an embedded quorum-proof checkpoint was not refreshed; see above." >&2 +fi + cargo metadata --format-version 1 > /dev/null NEW_PACKAGE_VERSION=$(cat $DIR/../../package.json|grep version|head -1|awk -F: '{ print $2 }'|sed 's/[", ]//g') @@ -108,6 +118,9 @@ echo "" echo "----------------------------------------------------------------" echo "Changelog base tag : $LATEST_TAG" echo "New version : $NEW_PACKAGE_VERSION" +if [ -n "${CHECKPOINTS_KEPT:-}" ]; then + echo "Quorum-proof checkpoints: NOT all refreshed (warning above)" +fi echo "Verify the base tag is the immediately-preceding release." echo "If it is wrong, abort and re-run with:" echo " yarn release -v=$NEW_PACKAGE_VERSION -c=" From 3ab1a92a7889c444c7912d7a5c1fc99f413a3116 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 29 Oct 2026 16:24:55 -0500 Subject: [PATCH 02/11] fix(sdk): bound refreshed checkpoints by a verified tip, not relay quorum heights The refresh chose and checked the new checkpoint's height against the relay's `/quorums` heights, which nothing authenticates, so a relay could get a valid checkpoint near the tip, above the Core heights Platform still reports. It now first verifies a proof to the relay's tip and requires the new checkpoint to be at least half a Platform quorum lifetime (288 blocks) below it. A relay cannot prove a ChainLock above the real tip, and hiding its tip only makes the checkpoint older. Also from review: - read `/quorums` and `/proofs` bodies with a cap instead of buffering them whole; - reject a quorum list marked unsuccessful; - put back already-replaced files if a later rename fails; - `--report` records each network's result, and `release.sh` puts it in the release PR's description instead of a fixed line. Co-Authored-By: Claude Opus 5.5 --- .../rs-sdk-trusted-context-provider/README.md | 24 +- .../examples/refresh_checkpoints.rs | 302 +++++++++++++----- scripts/release/pr_description.md | 1 - scripts/release/release.sh | 23 +- 4 files changed, 260 insertions(+), 90 deletions(-) diff --git a/packages/rs-sdk-trusted-context-provider/README.md b/packages/rs-sdk-trusted-context-provider/README.md index 8e669be2a55..f9f1b570372 100644 --- a/packages/rs-sdk-trusted-context-provider/README.md +++ b/packages/rs-sdk-trusted-context-provider/README.md @@ -262,11 +262,12 @@ order). `yarn release` moves both forward with cargo run -p rs-sdk-trusted-context-provider --example refresh_checkpoints [mainnet] [testnet] ``` -For each network it asks the public relay for a proof from the current -checkpoint to a block about a day below the tip (just past the oldest active -Platform quorum's mining), verifies it, and writes the proof's target as the -new checkpoint. Each new checkpoint is proven from the one it replaces, so a -refresh adds no trust in the relay. It also rewrites +For each network it asks the public relay for two proofs from the current +checkpoint, one to the relay's tip and one to a block about a day below it +(just past the oldest active Platform quorum's mining), verifies both, and +writes the lower target as the new checkpoint. Each new checkpoint is proven +from the one it replaces, so a refresh adds no trust in the relay. It also +rewrites `tests/data/checkpoint-evidence-.json` from the proof: the target's raw header, its coinbase transaction and the coinbase's Merkle branch. The `checkpoints` test recomputes the block hash, the header's Merkle root and @@ -275,10 +276,15 @@ both roots from that evidence. `MAINNET_QUORUM_RELAY` or `TESTNET_QUORUM_RELAY` points a network at another relay serving `/quorums` and `/proofs`, for example a self-hosted one while public mainnet `/proofs` returns 403. A network that fails keeps its -checkpoint, and the release prints a warning; an older checkpoint stays valid -and only makes proofs longer. A target well above the requested height is -refused, since a checkpoint at the tip would leave the heights Platform still -reports unprovable. +checkpoint, and the release prints a warning and records it in the release +PR; an older checkpoint stays valid and only makes proofs longer. + +The relay's quorum list only suggests the height to ask for. The new +checkpoint must be at least half a Platform quorum lifetime (about 12 hours) +below the verified tip, so the Core heights Platform reports after the +release are above it. A relay cannot prove a ChainLock above the real tip, and +one that hides its tip only makes the checkpoint older. If Platform has +halted for longer than that, refresh after it resumes. A refresh relies on the same thing every proof does: that no quorum on the way from the old checkpoint signed a false ChainLock. A bad checkpoint would carry diff --git a/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs b/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs index 955304e2268..0cf197a2db9 100644 --- a/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs +++ b/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs @@ -1,39 +1,63 @@ //! Moves the embedded checkpoints forward. `yarn release` runs it. //! //! ```sh -//! cargo run -p rs-sdk-trusted-context-provider --example refresh_checkpoints [mainnet] [testnet] +//! cargo run -p rs-sdk-trusted-context-provider --example refresh_checkpoints -- \ +//! [--report ] [mainnet] [testnet] //! ``` //! -//! For each network it asks the relay for a proof from the current -//! checkpoint, verifies it, and writes the proof's target as the new -//! checkpoint along with the evidence `tests/checkpoints.rs` checks. A new -//! checkpoint is proven from the one it replaces, so a refresh adds no trust -//! in the relay. `MAINNET_QUORUM_RELAY` or `TESTNET_QUORUM_RELAY` replaces a -//! network's public relay, which must serve both `/quorums` and `/proofs`. +//! For each network it asks the relay for two proofs from the current +//! checkpoint, one to the relay's tip and one to a block about a day below +//! it, verifies both, and writes the lower target as the new checkpoint along +//! with the evidence `tests/checkpoints.rs` checks. A new checkpoint is proven +//! from the one it replaces, so a refresh adds no trust in the relay. //! -//! The target is about one Platform quorum lifetime (a day) below the tip, -//! so the Core heights Platform reports after a release are above it. A -//! network that fails keeps its checkpoint, and the exit status is nonzero. +//! The relay's quorum list only suggests which height to ask for. What bounds +//! the new checkpoint is the verified tip: a relay cannot prove a ChainLock +//! above the real tip, and the checkpoint must be at least half a Platform +//! quorum lifetime (about 12 hours) below it, so the Core heights Platform +//! reports after a release are above it. A relay that hides its tip only +//! makes the checkpoint older. +//! +//! `MAINNET_QUORUM_RELAY` or `TESTNET_QUORUM_RELAY` replaces a network's +//! public relay, which must serve both `/quorums` and `/proofs`. A network +//! that fails keeps its checkpoint, and the exit status is nonzero. +//! `--report` appends one Markdown line per network to a file, for the +//! release PR. -use dash_core_proof::{bootstrap, rpc_hex, QuorumProofChain}; +use dash_core_proof::{bootstrap, rpc_hex, QuorumProofChain, State, MAX_PROOF_BYTES}; use dpp::dashcore::sml::llmq_type::network::NetworkLLMQExt; use dpp::dashcore::Network; use rs_sdk_trusted_context_provider::types::QuorumsResponse; use rs_sdk_trusted_context_provider::{get_quorum_base_url, VerifiedHttpContextProvider}; use serde_json::json; +use std::io::Write; use std::path::PathBuf; use std::process::ExitCode; use std::time::Duration; +/// Largest `/quorums` body read. A real list is a few KiB. +const MAX_QUORUMS_BYTES: usize = 1 << 20; + +const USAGE: &str = "usage: refresh_checkpoints [--report ] [mainnet] [testnet]"; + #[tokio::main] async fn main() -> ExitCode { let mut networks = Vec::new(); - for arg in std::env::args().skip(1) { + let mut report = None; + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { match arg.as_str() { "mainnet" => networks.push(Network::Mainnet), "testnet" => networks.push(Network::Testnet), + "--report" => match args.next() { + Some(path) => report = Some(PathBuf::from(path)), + None => { + eprintln!("{USAGE}"); + return ExitCode::FAILURE; + } + }, _ => { - eprintln!("usage: refresh_checkpoints [mainnet] [testnet]"); + eprintln!("{USAGE}"); return ExitCode::FAILURE; } } @@ -57,11 +81,33 @@ async fn main() -> ExitCode { let mut status = ExitCode::SUCCESS; for network in networks { - match refresh(&client, network).await { - Ok(message) => println!("{network}: {message}"), + let line = match refresh(&client, network).await { + Ok(message) => { + println!("{network}: {message}"); + format!("- {network}: {message}") + } Err(error) => { eprintln!("{network}: checkpoint not refreshed: {error}"); status = ExitCode::FAILURE; + // Relay text stays in a code span, so it cannot pose as + // Markdown in the release PR. + format!( + "- {network}: **not refreshed**, kept the previous checkpoint: `{}`", + error.replace('`', "'") + ) + } + }; + // Written as each network finishes, so the report matches the files + // even if a later network's run is cut short. + if let Some(path) = &report { + let written = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .and_then(|mut file| writeln!(file, "{line}")); + if let Err(error) = written { + eprintln!("{}: {error}", path.display()); + status = ExitCode::FAILURE; } } } @@ -77,73 +123,79 @@ async fn refresh(client: &reqwest::Client, network: Network) -> Result relay.trim_end_matches('/').to_string(), Err(_) => get_quorum_base_url(network, None).map_err(|error| error.to_string())?, }; + let quorum_type = network.platform_type(); + let params = quorum_type.params(); + let interval = params.dkg_params.interval; + // Half a quorum lifetime: Platform's Core height trails the tip by a few + // blocks unless it halts. + let depth = interval * params.signing_active_quorum_count / 2; - // The oldest active quorum is in every quorum root from shortly after - // its DKG until about a day later, so a target just past its mining - // can open it. - let quorums: QuorumsResponse = client - .get(format!("{relay}/quorums")) + let url = format!("{relay}/quorums"); + let response = client + .get(&url) .send() .await .and_then(|response| response.error_for_status()) - .map_err(|error| format!("{relay}/quorums: {error}"))? - .json() - .await - .map_err(|error| format!("{relay}/quorums: {error}"))?; - let oldest = quorums - .data - .iter() - .min_by_key(|quorum| quorum.height) - .ok_or_else(|| format!("{relay}/quorums lists no quorums"))?; - // A quorum's height is where its DKG started; it is mined within the - // next DKG interval. - let interval = network.platform_type().params().dkg_params.interval; + .map_err(|error| format!("{url}: {error}"))?; + let quorums: QuorumsResponse = + serde_json::from_slice(&read_capped(response, MAX_QUORUMS_BYTES, &url).await?) + .map_err(|error| format!("{url}: {error}"))?; + if !quorums.success { + return Err(format!("{url} reports failure")); + } + let (Some(oldest), Some(newest)) = ( + quorums.data.iter().min_by_key(|quorum| quorum.height), + quorums.data.iter().max_by_key(|quorum| quorum.height), + ) else { + return Err(format!("{url} lists no quorums")); + }; + + // Height 0 asks for the relay's tip; the newest quorum is active there. + let (tip, _) = prove(client, &relay, network, ¤t, 0, &newest.quorum_hash).await?; + let bound = tip.height.saturating_sub(depth); + if bound <= current.height { + return Ok(format!( + "kept {}; the verified tip {} is not {depth} blocks past it", + current.height, tip.height + )); + } + + // The oldest active quorum is in every quorum root from shortly after + // its DKG until about a day later, so a target just past its mining can + // open it. A quorum's height is where its DKG started; it is mined within + // the next DKG interval. let height = u32::try_from(oldest.height) .ok() .and_then(|height| height.checked_add(interval)) .ok_or_else(|| format!("quorum height {} is out of range", oldest.height))?; if height <= current.height { return Ok(format!( - "kept {}; the relay's oldest quorum is not past it", - current.height + "kept {}; the oldest listed quorum ({}) is not past it", + current.height, oldest.height )); } - - let response = client - .post(format!("{relay}/proofs")) - .json(&json!({ - "checkpoint": rpc_hex(¤t.block_hash), - "height": height, - "quorumHash": oldest.quorum_hash, - "llmqType": u8::from(network.platform_type()), - "nodeCount": 0, - })) - .send() - .await - .and_then(|response| response.error_for_status()) - .map_err(|error| format!("{relay}/proofs: {error}"))?; - let body = response - .bytes() - .await - .map_err(|error| format!("{relay}/proofs: {error}"))?; - - let target = bootstrap::verify(&body, ¤t, height) - .map_err(|error| format!("the proof does not verify: {}", error.message()))? - .state() - .clone(); - // The relay's height is a minimum. A target far above it, near the tip, - // would leave heights Platform still reports unprovable. - if target.height > height.saturating_add(interval) { + if height > bound { return Err(format!( - "the relay answered at {}, well above the {height} asked", - target.height + "the oldest listed quorum ({}) is within {depth} blocks of the verified tip {}", + oldest.height, tip.height + )); + } + let (target, chain) = prove( + client, + &relay, + network, + ¤t, + height, + &oldest.quorum_hash, + ) + .await?; + // Both heights are proven, so this holds whatever the relay listed. + if target.height > bound { + return Err(format!( + "the relay answered at {}, within {depth} blocks of the verified tip {}", + target.height, tip.height )); } - // The envelope opens with the chain as a blob: a little-endian u32 - // length and the bytes. `verify` has already parsed it. - let length = u32::from_le_bytes(body[..4].try_into().expect("4 bytes")) as usize; - let chain = QuorumProofChain::decode(&body[4..4 + length]) - .map_err(|error| format!("the proof does not decode: {}", error.message()))?; let evidence = json!({ "header": hex::encode(chain.target.header), "coinbase": hex::encode(&chain.coinbase.transaction), @@ -155,7 +207,7 @@ async fn refresh(client: &reqwest::Client, network: Network) -> Result Result {} ({})", + "{} -> {} (`{}`), verified tip {}", current.height, target.height, - rpc_hex(&target.block_hash) + rpc_hex(&target.block_hash), + tip.height )) } +/// Ask the relay for a proof from `checkpoint` to a target at or above +/// `height`, and verify it. The quorum only lets the relay build the proof; +/// its opening is not checked. +async fn prove( + client: &reqwest::Client, + relay: &str, + network: Network, + checkpoint: &State, + height: u32, + quorum_hash: &str, +) -> Result<(State, QuorumProofChain), String> { + let url = format!("{relay}/proofs"); + let response = client + .post(&url) + .json(&json!({ + "checkpoint": rpc_hex(&checkpoint.block_hash), + "height": height, + "quorumHash": quorum_hash, + "llmqType": u8::from(network.platform_type()), + "nodeCount": 0, + })) + .send() + .await + .and_then(|response| response.error_for_status()) + .map_err(|error| format!("{url}: {error}"))?; + let body = read_capped(response, MAX_PROOF_BYTES, &url).await?; + let state = bootstrap::verify(&body, checkpoint, height) + .map_err(|error| format!("the proof does not verify: {}", error.message()))? + .state() + .clone(); + // The envelope opens with the chain as a blob: a little-endian u32 + // length and the bytes. `verify` has already parsed it. + let length = u32::from_le_bytes(body[..4].try_into().expect("4 bytes")) as usize; + let chain = QuorumProofChain::decode(&body[4..4 + length]) + .map_err(|error| format!("the proof does not decode: {}", error.message()))?; + Ok((state, chain)) +} + +/// Read a body, refusing one over `limit` before buffering past it. +async fn read_capped( + mut response: reqwest::Response, + limit: usize, + url: &str, +) -> Result, String> { + let too_large = || format!("{url}: the body is over {limit} bytes"); + if response + .content_length() + .is_some_and(|length| length > limit as u64) + { + return Err(too_large()); + } + let mut body = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|error| format!("{url}: {error}"))? + { + if chunk.len() > limit - body.len() { + return Err(too_large()); + } + body.extend_from_slice(&chunk); + } + Ok(body) +} + fn to_json(value: &impl serde::Serialize) -> Result { serde_json::to_string_pretty(value) .map(|text| text + "\n") .map_err(|error| error.to_string()) } -/// Writes every file beside its destination first, so a failed write leaves -/// the checkpoint and its evidence as they were. -fn write_together(files: [(PathBuf, String); N]) -> Result<(), String> { - let staged = files.map(|(path, text)| (path.with_extension("json.new"), path, text)); - for (staging, _, text) in &staged { - std::fs::write(staging, text).map_err(|error| format!("{}: {error}", staging.display()))?; +/// Replaces the files as one generation: each is written beside its +/// destination first, and if a rename fails the files already renamed get +/// their old contents back. A crash between two renames can still leave a +/// mixed pair, which the `checkpoints` test then fails. +fn write_together(files: &[(PathBuf, String)]) -> Result<(), String> { + let mut staged: Vec<(PathBuf, &PathBuf, Vec)> = Vec::with_capacity(files.len()); + for (path, text) in files { + let staging = path.with_extension("json.new"); + let written = std::fs::read(path) + .map_err(|error| format!("{}: {error}", path.display())) + .and_then(|old| { + std::fs::write(&staging, text) + .map(|()| old) + .map_err(|error| format!("{}: {error}", staging.display())) + }); + match written { + Ok(old) => staged.push((staging, path, old)), + Err(error) => { + for (staging, _, _) in &staged { + let _ = std::fs::remove_file(staging); + } + return Err(error); + } + } } - for (staging, path, _) in &staged { - std::fs::rename(staging, path).map_err(|error| format!("{}: {error}", path.display()))?; + for (index, (staging, path, _)) in staged.iter().enumerate() { + if let Err(error) = std::fs::rename(staging, path) { + for (_, done, old) in &staged[..index] { + if let Err(error) = std::fs::write(done, old) { + eprintln!("{}: cannot restore: {error}", done.display()); + } + } + for (staging, _, _) in &staged[index..] { + let _ = std::fs::remove_file(staging); + } + return Err(format!("{}: {error}", path.display())); + } } Ok(()) } diff --git a/scripts/release/pr_description.md b/scripts/release/pr_description.md index fa96412bea9..e1504e6a9e1 100644 --- a/scripts/release/pr_description.md +++ b/scripts/release/pr_description.md @@ -10,7 +10,6 @@ Release new Dash Platform version - Updated changelog - Bumped packages version -- Moved the SDK's embedded quorum-proof checkpoints forward (reviewers: check each new block hash at its height on a block explorer) ## How Has This Been Tested? diff --git a/scripts/release/release.sh b/scripts/release/release.sh index cbb7600466f..0c74439e6b6 100755 --- a/scripts/release/release.sh +++ b/scripts/release/release.sh @@ -96,7 +96,9 @@ fi # checkpoint, which stays valid; proofs from it are only longer. # MAINNET_QUORUM_RELAY / TESTNET_QUORUM_RELAY replace a network's public relay. # See packages/rs-sdk-trusted-context-provider/README.md. -if ! cargo run --quiet --package rs-sdk-trusted-context-provider --example refresh_checkpoints; then +# The per-network results go into the release PR's description. +CHECKPOINT_REPORT=$(mktemp) +if ! cargo run --quiet --package rs-sdk-trusted-context-provider --example refresh_checkpoints -- --report "$CHECKPOINT_REPORT"; then CHECKPOINTS_KEPT=1 echo "WARNING: an embedded quorum-proof checkpoint was not refreshed; see above." >&2 fi @@ -154,11 +156,28 @@ else MILESTONE="v${VERSION_WITHOUT_PRERELEASE%.*}.0" fi +# The release PR's description, with each network's checkpoint result. +PR_BODY=$(mktemp) +{ + cat "$DIR/pr_description.md" + echo "" + echo "## Quorum-proof checkpoints" + echo "" + echo "Each moved checkpoint is proven from the one it replaces. Reviewers: check each new block hash at its height on a block explorer." + echo "" + if [ -s "$CHECKPOINT_REPORT" ]; then + cat "$CHECKPOINT_REPORT" + else + echo "- **The refresh did not run**; both checkpoints are unchanged (see the release log)." + fi +} > "$PR_BODY" + gh pr create --base $CURRENT_BRANCH \ --fill \ --title "chore(release): update changelog and bump version to $NEW_PACKAGE_VERSION" \ - --body-file $DIR/pr_description.md \ + --body-file "$PR_BODY" \ --milestone $MILESTONE +rm -f "$CHECKPOINT_REPORT" "$PR_BODY" echo "" echo "----------------------------------------------------------------" From 0379b7b9a93b7be636f9671562f8f4409fb39629 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 29 Oct 2026 17:08:15 -0500 Subject: [PATCH 03/11] test(sdk): cover the checkpoint refresh's trust and failure paths The refresh tool's logic had no direct tests. `refresh` now takes the relay, the current checkpoint and the output directory, so its tests run it against a loopback relay serving two real testnet proofs from one checkpoint (to 1,576,768 and to a later tip). They cover a successful refresh and its evidence, a target or listed quorum within 288 blocks of the verified tip, a relay hiding its tip, an unsuccessful quorum list, an oversized proof, a relay error, and staging that fails partway. The example's tests run with the crate's (`test = true`). Staging now also removes the file whose write failed, so a partial `.json.new` cannot block the next release's clean-tree check. Co-Authored-By: Claude Opus 5.5 --- .../Cargo.toml | 5 + .../examples/refresh_checkpoints.rs | 321 +++++++++++++++++- .../tests/data/proof-testnet-tip.json | 10 + 3 files changed, 319 insertions(+), 17 deletions(-) create mode 100644 packages/rs-sdk-trusted-context-provider/tests/data/proof-testnet-tip.json diff --git a/packages/rs-sdk-trusted-context-provider/Cargo.toml b/packages/rs-sdk-trusted-context-provider/Cargo.toml index 67c595fe060..dd1c1ffd83b 100644 --- a/packages/rs-sdk-trusted-context-provider/Cargo.toml +++ b/packages/rs-sdk-trusted-context-provider/Cargo.toml @@ -36,6 +36,11 @@ futures = { version = "0.3.30" } # Monotonic clock that also works on wasm32, for spacing out refreshes. web-time = "1.1" +[[example]] +name = "refresh_checkpoints" +# Its tests run with the crate's. +test = true + [features] default = [] diff --git a/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs b/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs index 0cf197a2db9..4a8a16a1cd9 100644 --- a/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs +++ b/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs @@ -31,7 +31,7 @@ use rs_sdk_trusted_context_provider::types::QuorumsResponse; use rs_sdk_trusted_context_provider::{get_quorum_base_url, VerifiedHttpContextProvider}; use serde_json::json; use std::io::Write; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::ExitCode; use std::time::Duration; @@ -81,7 +81,7 @@ async fn main() -> ExitCode { let mut status = ExitCode::SUCCESS; for network in networks { - let line = match refresh(&client, network).await { + let line = match refresh_embedded(&client, network).await { Ok(message) => { println!("{network}: {message}"); format!("- {network}: {message}") @@ -114,15 +114,35 @@ async fn main() -> ExitCode { status } -async fn refresh(client: &reqwest::Client, network: Network) -> Result { +/// Refresh this crate's embedded checkpoint for `network`. +async fn refresh_embedded(client: &reqwest::Client, network: Network) -> Result { let current = VerifiedHttpContextProvider::embedded_checkpoint(network) .map_err(|error| error.to_string())?; - let name = network.to_string(); - let variable = format!("{}_QUORUM_RELAY", name.to_uppercase()); + let variable = format!("{}_QUORUM_RELAY", network.to_string().to_uppercase()); let relay = match std::env::var(&variable) { Ok(relay) => relay.trim_end_matches('/').to_string(), Err(_) => get_quorum_base_url(network, None).map_err(|error| error.to_string())?, }; + refresh( + client, + network, + &relay, + ¤t, + Path::new(env!("CARGO_MANIFEST_DIR")), + ) + .await +} + +/// Move `current` forward through `relay`, writing the new checkpoint and its +/// evidence under `crate_dir`. +async fn refresh( + client: &reqwest::Client, + network: Network, + relay: &str, + current: &State, + crate_dir: &Path, +) -> Result { + let name = network.to_string(); let quorum_type = network.platform_type(); let params = quorum_type.params(); let interval = params.dkg_params.interval; @@ -151,7 +171,7 @@ async fn refresh(client: &reqwest::Client, network: Network) -> Result Result bound { return Err(format!( @@ -206,7 +219,6 @@ async fn refresh(client: &reqwest::Client, network: Network) -> Result Result<(), String> { .and_then(|old| { std::fs::write(&staging, text) .map(|()| old) - .map_err(|error| format!("{}: {error}", staging.display())) + .map_err(|error| { + // A failed write can leave a partial file behind. + let _ = std::fs::remove_file(&staging); + format!("{}: {error}", staging.display()) + }) }); match written { Ok(old) => staged.push((staging, path, old)), @@ -337,3 +353,274 @@ fn write_together(files: &[(PathBuf, String)]) -> Result<(), String> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use dash_core_proof::header_hash; + use serde_json::Value; + use std::io::{BufRead, BufReader, Read}; + use std::net::TcpListener; + + /// Two real testnet proofs from one checkpoint (1,574,768): one to + /// 1,576,768, and one to the relay's tip about 5,000 blocks later. + fn fixtures() -> (State, Vec, Vec) { + let read = |json: &str| -> Value { serde_json::from_str(json).expect("fixture JSON") }; + let target = read(include_str!("../tests/data/proof-testnet.json")); + let tip = read(include_str!("../tests/data/proof-testnet-tip.json")); + assert_eq!(target["checkpoint"], tip["checkpoint"]); + let proof = |value: &Value| hex::decode(value["bootstrap_hex"].as_str().expect("hex")); + ( + serde_json::from_value(target["checkpoint"].clone()).expect("a checkpoint"), + proof(&target).expect("hex"), + proof(&tip).expect("hex"), + ) + } + + fn proven_height(proof: &[u8], checkpoint: &State) -> u32 { + bootstrap::verify(proof, checkpoint, 0) + .expect("a valid proof") + .state() + .height + } + + fn quorums(height: u32, success: bool) -> Vec { + serde_json::to_vec(&json!({ + "success": success, + "data": [{ + "quorum_hash": "00".repeat(32), + "key": "", + "height": height, + "valid_members_count": 0, + }], + })) + .expect("JSON") + } + + type Handler = Box (u16, Vec) + Send>; + + /// A relay on loopback answering each request with `handler(path, body)`. + fn relay(handler: Handler) -> String { + let listener = TcpListener::bind("127.0.0.1:0").expect("a loopback port"); + let address = listener.local_addr().expect("an address"); + std::thread::spawn(move || { + for stream in listener.incoming().flatten() { + let mut reader = BufReader::new(stream); + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 { + continue; + } + let path = line.split_whitespace().nth(1).unwrap_or("").to_string(); + let mut length = 0; + loop { + let mut header = String::new(); + if reader.read_line(&mut header).unwrap_or(0) == 0 || header.trim().is_empty() { + break; + } + if let Some((name, value)) = header.split_once(':') { + if name.eq_ignore_ascii_case("content-length") { + length = value.trim().parse().unwrap_or(0); + } + } + } + let mut body = vec![0; length]; + if reader.read_exact(&mut body).is_err() { + continue; + } + let request = serde_json::from_slice(&body).unwrap_or(Value::Null); + let (status, body) = handler(&path, &request); + let mut stream = reader.into_inner(); + let _ = write!( + stream, + "HTTP/1.1 {status} X\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = stream.write_all(&body); + } + }); + format!("http://{address}") + } + + /// A crate directory holding placeholder testnet checkpoint files. + fn crate_dir(test: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("refresh-checkpoints-{}-{test}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + for sub in ["checkpoints", "tests/data"] { + std::fs::create_dir_all(dir.join(sub)).expect("a directory"); + } + for file in [ + "checkpoints/testnet.json", + "tests/data/checkpoint-evidence-testnet.json", + ] { + std::fs::write(dir.join(file), "old\n").expect("a placeholder"); + } + dir + } + + fn unchanged(dir: &Path) -> bool { + [ + "checkpoints/testnet.json", + "tests/data/checkpoint-evidence-testnet.json", + ] + .iter() + .all(|file| std::fs::read_to_string(dir.join(file)).ok().as_deref() == Some("old\n")) + && !dir.join("checkpoints/testnet.json.new").exists() + } + + async fn run(handler: Handler, dir: &Path) -> Result { + let (checkpoint, _, _) = fixtures(); + refresh( + &reqwest::Client::new(), + Network::Testnet, + &relay(handler), + &checkpoint, + dir, + ) + .await + } + + /// Answers `/quorums` with one quorum at `quorum_height`, the tip ask + /// with `tip` and any other ask with `target`. + fn honest(quorum_height: u32, tip: Vec, target: Vec) -> Handler { + Box::new(move |path, request| match path { + "/quorums" => (200, quorums(quorum_height, true)), + _ if request["height"] == 0 => (200, tip.clone()), + _ => (200, target.clone()), + }) + } + + #[tokio::test] + async fn should_write_a_proven_target_below_the_verified_tip_and_its_evidence() { + let (checkpoint, target, tip) = fixtures(); + let target_height = proven_height(&target, &checkpoint); + let dir = crate_dir("success"); + + let message = run(honest(target_height - 24, tip, target.clone()), &dir) + .await + .expect("a refresh"); + assert!( + message.starts_with(&format!("1574768 -> {target_height}")), + "{message}" + ); + + let written: State = serde_json::from_str( + &std::fs::read_to_string(dir.join("checkpoints/testnet.json")).expect("a checkpoint"), + ) + .expect("a state"); + assert_eq!( + written, + *bootstrap::verify(&target, &checkpoint, 0) + .expect("valid") + .state() + ); + let evidence: Value = serde_json::from_str( + &std::fs::read_to_string(dir.join("tests/data/checkpoint-evidence-testnet.json")) + .expect("evidence"), + ) + .expect("JSON"); + let header: [u8; 80] = hex::decode(evidence["header"].as_str().expect("hex")) + .expect("hex") + .try_into() + .expect("80 bytes"); + assert_eq!(header_hash(&header), written.block_hash); + assert!(!dir.join("checkpoints/testnet.json.new").exists()); + } + + #[tokio::test] + async fn should_refuse_a_target_within_the_depth_of_the_verified_tip() { + let (checkpoint, target, tip) = fixtures(); + let dir = crate_dir("near-tip-target"); + // The relay answers every ask at its tip. + let error = run( + honest(proven_height(&target, &checkpoint) - 24, tip.clone(), tip), + &dir, + ) + .await + .expect_err("a target at the tip"); + assert!( + error.contains("within 288 blocks of the verified tip"), + "{error}" + ); + assert!(unchanged(&dir)); + } + + #[tokio::test] + async fn should_refuse_a_listed_quorum_near_the_verified_tip() { + let (checkpoint, target, tip) = fixtures(); + let tip_height = proven_height(&tip, &checkpoint); + let dir = crate_dir("near-tip-quorum"); + let error = run(honest(tip_height - 30, tip, target), &dir) + .await + .expect_err("a quorum the relay calls oldest near the tip"); + assert!(error.contains("oldest listed quorum"), "{error}"); + assert!(unchanged(&dir)); + } + + #[tokio::test] + async fn should_bound_by_a_lower_tip_when_the_relay_hides_its_own() { + let (checkpoint, target, _) = fixtures(); + let dir = crate_dir("hidden-tip"); + // The "tip" is the target itself, so it cannot be 288 blocks below. + let error = run( + honest( + proven_height(&target, &checkpoint) - 24, + target.clone(), + target, + ), + &dir, + ) + .await + .expect_err("no target far enough below a hidden tip"); + assert!(error.contains("288 blocks"), "{error}"); + assert!(unchanged(&dir)); + } + + #[tokio::test] + async fn should_refuse_an_unsuccessful_quorum_list() { + let dir = crate_dir("unsuccessful"); + let error = run(Box::new(|_, _| (200, quorums(1_576_744, false))), &dir) + .await + .expect_err("an unsuccessful list"); + assert!(error.contains("reports failure"), "{error}"); + assert!(unchanged(&dir)); + } + + #[tokio::test] + async fn should_refuse_an_oversized_proof_without_buffering_it() { + let dir = crate_dir("oversized"); + let error = run( + Box::new(|path, _| match path { + "/quorums" => (200, quorums(1_576_744, true)), + _ => (200, vec![0; MAX_PROOF_BYTES + 1]), + }), + &dir, + ) + .await + .expect_err("an oversized body"); + assert!(error.contains("over"), "{error}"); + assert!(unchanged(&dir)); + } + + #[tokio::test] + async fn should_report_a_relay_error() { + let dir = crate_dir("relay-error"); + let error = run(Box::new(|_, _| (403, b"Forbidden".to_vec())), &dir) + .await + .expect_err("a 403"); + assert!(error.contains("403"), "{error}"); + assert!(unchanged(&dir)); + } + + #[test] + fn should_leave_every_file_unchanged_when_staging_one_fails() { + let dir = crate_dir("staging"); + let error = write_together(&[ + (dir.join("checkpoints/testnet.json"), "new\n".to_string()), + (dir.join("missing/testnet.json"), "new\n".to_string()), + ]) + .expect_err("a missing destination"); + assert!(error.contains("missing"), "{error}"); + assert!(unchanged(&dir)); + } +} diff --git a/packages/rs-sdk-trusted-context-provider/tests/data/proof-testnet-tip.json b/packages/rs-sdk-trusted-context-provider/tests/data/proof-testnet-tip.json new file mode 100644 index 00000000000..0ac2beb89fd --- /dev/null +++ b/packages/rs-sdk-trusted-context-provider/tests/data/proof-testnet-tip.json @@ -0,0 +1,10 @@ +{ + "checkpoint": { + "network": 1, + "height": 1574768, + "block_hash": "0000006648a17e1ab1c4c5745d80fdb6d69bff32fd93ad4fc60baad624e1bb62", + "masternode_root": "d19783ddd57d2cdcf8cc2552a82859f062faab7c4b675b870762bd0848b5af8c", + "quorum_root": "5e6becb125aed06017530637a1423d87a27b468bef6865405d619b20e8dcf2c5" + }, + "bootstrap_hex": "402c0000444153484e433032017007180062bbe124d6aa0bc64fad93fd32ff9bd6b6fd805d74c5c4b11a7ea148660000008cafb54808bd6207875b674b7cabfa62f05928a85225ccf8dc2c7dd5dd8397d1c5f2dce8209b615d406568ef8b467ba2873d42a13706531760d0ae25b1ec6b5e43010000030001a36c8f433399c91765a161579f3581042422e983846375e424f47c6f0000000032ffffffffffff0332ffffffffffff03a51893239a63e5eef0983af313b6712c537d987db09433697e4e20c465650edfa27d31415ed2374643bd7770a94f4bc72af5ca8a03f64397d0ca7e68912ec6bf025df2324e4788d8f75d16ddf302ea61a1fc610571f7c92b099285e466729bbbe9a9c0b4063df1b04d6a22380440c46b65813cb857e796c5aa2b69142ec57fbe0a617bcb712c85490247b1109ea68a8ae749bbd2e8acd1bee68532a63d0e686f07ee98ca6df3e3f1a1f6f74598a1aa5eb39fd68c61165bdd6a5a983a01e17bdbba73851ed52227c6110a65df8a2f1e1796eece4a0fcbfdf4fdaf960411787a110b814c44072f5581d844373d8ed731a677ec24a09caccc1124ef76fd515980cd8006a08aaa37f35d697e879ca2038a2d2b0000006d000000077a62b476b57fec70744a81f589950ce4bd04acae0bbbc8211c1bff4f3a336ff3bd03983ba422d5979a2358f2755c6dd1cbc3944a8245c0e0739c6ed0394ef3cfcd34ac5b429af38e488cc991591a9e0f4bf855b2101aa9aa3c72aec0d2ae47a8d512a037d78330b85a937eb9e15959bcb7b6ef1dd635ca68d4bb1426109826cc10f107c74afdf43fb344c5d26ddaa381a9177e7008f8b27c244f6d0dcad3bed3b6947349c68c99dedf77f7fff75ad54c22dd8a9731421a0e195716dc082f3c2d4068b9da7d03134f7cc1fc8305a7811c3e7dd08365578f04ebdd5222a0f1b7e90d00db08180000000020d4ee16f728e3d2dbe889fea7ef248d64073eca271206694bcf22762b0400000020e2dbcc49200759d9c39a72c79bb407d3df703e72d9b20dc3777895bced19eabe6ad56ae7c5001ea52902008df875ee3138dec51f26f46d5412893b07303bb9b1cbb15350f47080440e115a6daf1ad2e2b2f16689649737a1ecefec026c8899c01783d591878cad3c9606763d8ca59e06778d4c9f319cc2b2049a55ab7c03ff14debe95a3f75b441a86cf5e5601000003000600000000000000fd49010100db081800030001399999407c2e615d1b748723ac9359485754ac5f158e2280bdf38b601700000032ffffffffffff0332ffffffffffff0394c67dcaa70a91b31d281c380e051dc97cff3779cceb8b5db7fe222a3e2f6723ef290c9da57634ef70cd4b1cfcbc817f21bd736f00c1033629054cb78ecab8db3a011600d451bacdbc7187115d88aad894ba2dbea90e446c67568e0520dad89f4cf309cc9e4b5afbefea0bbf561104deca51dea17fe05cd12c60b50ef19b1fbd17f7a5a0c1b533c2605884bf7c033d2aaffbf9c4e5a567caffc017c7fc7bbfd7b177730dd1583bc939f9fcda49d7b08aa7bf05164244af9dd667be359ae5c7a893c17b958048692a14526515a7d7da91b34d09045982fc914b56124e48cea6931960612346a9bd3aba0bddf82e6c231bfd8a79d517ca008ac258d565442bb1e121f931b500f60ec5ce90a6b3c76d847b0100000004000000021e4ca4985cb770d03fc12067e78480c170b3eacdb9b604abb6661794209c590ea8d4e7f17877174ee36a084719cf458b22d2710c8d2b62325156d049241f6c1b00001d0b180000000020884032b52f4bb1f5a1e3d965c2aefa95c3b4fe78d9dd95c2d3888e6c73000000d42a14cc705c3bf036c749c951ae2abe79a0c40ad51356d5e8489ad0c3c8067187a5d66af4c3001eaaab090092af6b4a402484a3f58dce67c0ed86b16cd7ec1de519bf26895640c126113c781f461a024cdc6d9f2cee38a3b9406656163b7ccd72168697b67f62b6bf6271f75cbcf76b3ab68b88e729f41264104e3323d4a814f33fbcf51a855f817c0f8d5e5601000003000600000000000000fd490101001c0b180003000190e99d95e1f645f2bd29a1f6e63e04758df76a2af780206f928146430d00000032ffffffffffff0332ffffffffffff03b73d638e5471eac8c27d7967738f84927b294698092b3e2886eae0c688ee8914b91943f8f76e3d76a2ed6638639d1738264d90c59b16ce249a440ba7301224f547f9c4a679313637d4b541523a2ad7d88f42583b87bbadffd25b11a27663bd13377fc40c9c40b90e59dab2902f3d506feee61c5ecf1ca5378e0c5f0cc426a07908f2983bc6020aabf7922a8d4714390b7208b99d20e7bd1cd5c5d5e612921b2dc8bc9e11eae11066f5327fc86265bda2a3683dde2fa82fe757d6c749851b0984c220dcfd7c99aff2489a68c407e70fcb796884835d00d79f7b46b119b7bb081b19889141724f5244a22b0a18370cabb9a30cbcfd06984581176468fd093f48d7fb1332535f49883d0eea5540c81e86f6010000000400000002b7a94165dacb30f0632e740b5921ad330ea845361a1dfcf83e584f35468e57cfce7bc0bf4bdb874889e7d42488da85ccbdf53ddb92d430a2730b03d655f61f5701000000002088c25e56721e4bf7bd28641de90b890a97ed8d88e05d0ab69978246f9d000000f305bea6028bedf2495f5b48bcfe5bf07586fa94e7b559bdaaef4d0f8206c5ca60a5d66a27c9001e52e906005b0d1800000000203be80b2da319518146eb2da3eefced36550dd4b076ee07daa8112abcf100000046e44661b169dba55dbd68716d1ce8c3c96cad41b9b36b493a4e425146be3d0f59d6d76a523a011ead9b0e0086ac3100239ec4fb945145811c70c4027a32cb5bd56f54a5b953d9efeb1d810693fd49accfaa8846d558358d2f45ee970d142d9ce09693d428242f67b45f1ae2fd8169bc9e60227ecb9bb2bdae407c916a9554c2f81707aa6276e5ebdca6a50f5601000003000600000000000000fd490101005b0d180003000113e78d688eeda3aa4b037bcb150c2580ff72306bfc0f1317d15bb777b100000032ffffffffffff0332ffffffffffff03a3d73c18724f5e3abb500e2a9670518f44472ab2b1280ad25bf9e3f58d211e80c4fa8034037aa30550eb7605b7b4e2c30224e116b4eb30e0f71e00e46160d9578fd8999770a88d0a8f18743bf4208d9bb0f2e85dfdc21cc58fec1902d138eb938f884ce54aea718c6434f6869be5a221b8886316254bde0d1df669bd34d0a1670af073d982fd22a82a82060368438173c199dc9f3addfaffbbecd200d9dc4090e11f8a7b470772d02dccd7c4ae6b03cb829c4f37eae9a1e8c7336096fec8824487746b3a919065615393c4f4ea5680537122cc542ee21aaa40495e5975e4be8b193c507ac95b5ff20fdaca44d8f0af7f7872ad2ded15d526a2a759d472927b0bcfe7a72db94ade09415be081d774f357010000000400000002feb2ad28b1b1c04f18cb0f9813cfc6883d07a7a894f80724df57bf37af637fd9953914d183c09cd57e2c248f0bc61e359d9c525eaf6a4dc3fff9e6de89ff126e00006d0f180000000020e6ac9e6be33e61f672a8d3dc6a68af3c15011e880e232fe37a5266b13a000000e0f214c16568f276049465f28d6cc1a6ba54703277a7657714970d6117cf05db28fdd86a693d011e45b2020082887c682c2940a8427c33784cf5b9ab68001d0b3ae43f9fbee3253acaadcd4a868969fe5622619f2cda2007d2d1baaf14c5dc8b70c997a8a0287ef6957cb26af8508e597008cba73e3872921c5187276f6b086e57ccaa648b2193c4d2ecf2015601000003000600000000000000fd490101006b0f180003000135cfd63f1f955afe02842849a6711d8c44a1f609bf7a1d07c30ab868e300000032ffffffffffff0332ffffffffffff03a09ad2beec622c017f3a3a1ab0968e008302a37cfe9aeb3e0f1eaceb0b630f984044592d7a272bc5f4dcb664fedb19863b0aef37ee5d597e1985a755e523feec642801ba5041fad6b4c75ba2cf59fac0841e043e3cdd976f3edad1d23c7856111b9008753e8a106513d77865abd631f6bfd096c04aaa840ec0fa952b78430f7201ecca5eb903576379a0546824ec08cc542ee5cd043a989f43f0aa19651bc75bccd0c4ec6ba77be37051822322881b22b6e86547a1aa6957b8a52f6e402d172a2a4bd8e27450001aa97397959ea22cb4dc094b04a9c6554c5a8d136462d9f6f10ebaadcd94ae69ada8e34284f70e6a290f58bbe17d1a17ab3864fc555d1d35ab9e6b096004bc809f646eabfe743d5aad010000000800000003e3094b67368c1fe6f48c8a426df3dd95bd3cc0a5ed9826b3c0178b343d8a1d41d2b78b1fb412832023225bacbcc1d7ced5c8c18e5e627432d8b1c5eb4fbff101955333026f0961e7cf4303dbd455083abcece0dd00efab9fa901f4da8ad018d9020000000020ea43c0b8c89ba96f447d19608287e5006101c21315a59c5c934deed4030000001919f9794609e954f0cdbeaa195e055e2c4720eacaed53b58e683f2cd895c0efecfad86af50a011eb3280800000000201764588dbf63f9bf1d04c63ecf9b07fa45473a99dcafffcdf3df140064000000bd99f537925c7cd2144d3d74d2c2b5a6f6dac903b0771920239f52f705e3715911fdd86a3715011e4bdd060004111800000000205a8f44f3174593f01a81cdb5b73ea9bc4f01529dbdb867ce651fc05eb400000031d4116f9ac1c349eeb6e41ea5aad7fc8b704384d5aa17a7fe6c533c83f26eb832ddd96a2016011ef1a60d00a42d4d99e47c8df5113de94c1114723f2dd6ffbc29f45bf62a937af990b5f9f0d1f4a1fad6528d7bebc09c0d870c2b4b07b995a3ef10381ec6709f62d8ea0ffe04e835a271b876ff21a663ca576841d6c42eb666323f425b7a3c33166643619e5601000003000600000000000000fd4901010003111800030001b0c7b2e281ab081c9ed7865d6aafc202f0ea4e0dbc8c61e87ca21fba0601000032ffffffffffff0332ffffffffffff03a3bcd6f8055c980bfd61ad614fbb23a4b004810f08b28f2f4325d61958fc1e569c0f70dbfee75d13399c6ae3df57a14900f9bbbbdada18d432e15b3a4f578c7c98cb95895ef91bbe9859a148da4392ed85d4f00abbdbc2eec4d98ec5d8879e170a31490711a18af6acc6435933d9fbf80d043f2d3cd378fe27b63f0fdec8c6d019aebaa46d27fa6a4dcb37db521a6a19e4b93d28382cf433ea4ff2aa3feac7b7cff1076f1ac77a581f98bea8be40ce2282b96cbe2111f8a67292e4f4e6febb39b8f2135105b9a509f4bb3067a8273f783a98bc0786cf360cecc7f5680b8aaebb034f6dcf43110de02b7d763cd768dbea9288bbf856c8794660649cbfc28d3c6f7da26556174e50c7cedd92c40a09346b010000000400000002e8277a2be0f7a529a1ac2d0a4ce22569e5543b1ba5c926fd80cf6ebf26543a6df8a442e481ebaf3fa7e21767dc4ee04bfd238490c0eac41b64d43b0865eba7c9010000000020f4295b4438d59c9363a206cdab2a3ccf685221db4cdc1ab58d670867a7000000e1eb2ece5e4ef3e747ab905ef092ddc4abee6029f80a4249ca4774b00872e0b61bddd96a4a1e011e606906004313180000000020a3341868203ded8202621208d2b1f15f6278dc5c4f460af1576ddf8342000000ed98aad8b99173694d06ff136e09798fba9a8226ca503efe2d1bd2ace9a835808313db6a3f74011e29340d0081654d6cc0b8e31b07e5fb439689b3097467af284d4c4b92e6a0c66e7d425b9087bc6be0aa223489693800ee670c5ccd10644060d1b91ace439e7a9780db85a641b09d12ec7f0c0955037ce0d3bdeed75a1d88b51ec0acf15736211590f954905601000003000600000000000000fd4901010043131800030001cb284f39cb5ea6e28d0354edb8f8674aa4dec8b1efa4b920bb74dd9fde00000032ffffffffffff0332ffffffffffff03a1c6a9ba74cf9268322255be1ba10b63bfccb75255e1486f457d74f8b974efdb1f5548e2565dae8299d8c98cf4a9f01d90cd51e00432000a1c56e2bb1df149e9a56137e7ce86128305c9876a656594fb9356877448e1a377f447ae0025043f7710a936e4fb8ada33c72f89ae219d148034d0c36c12b68892ec4a87501b85c6f20aa31721915cf20773f280f27179d8a05d1d1e04afb16605f1dcc9dc13158a51f85722e1d5dd827dc371c66743c12047a67c4b682f7268c151d1a6fbcdb8e26b6dae33a588cd4568f2425757461a1413d97162631aedc849093efea1598f869d061478bf3b29212cd6240ec799a7054cc623a26e0f649185d9f937e0eec6cfd4b03f4981e4c3b05e5918c00e73af055e0100000004000000022fd98250a38b49870143d550afde85fd6adc5a0339e37d4728f1b1f131c8e56ed3b6af49207220df276b20a77077ab5b8d0e8703ac8d2fcc175d4898bd90b4eb000083151800000000203bcab625167aeebd092f25e24b2c50757aee7d5dd6d6e9535ee355a4670000009927d9f340ccf24d989f2a92208cf5180128f67ae7389a569227e41715c19dc1fe58dc6a5485011e234c0b0094f5385cc7587b3659717dda8f8147a50d35fe58dc77392df2ca9ae378813f1543292ac7040c13c6dc9c5f735a6658d8050581b1217363e60f3039c2fb5ec59ad33830b0e03027c3c045af03be0383812ecb9207b1c4be8bd79ebb22851d55355601000003000600000000000000fd4901010083151800030001d3bded84637e1abbec27152b0f63ce05f64eb09dcf40b1f9dd48f6b72400000032ffefffffffff0332ffffffffffff03b96cd4c0a09afd95e1e9eb8e785b00e19918e4d748038cdf7429a0cd34a172d176f087074b295d7811c7147e04240fe383955ffbe210848bd6a0c928bacf3c2bdba86e8045d2250a453249a9189ae77097edc93344e1ed0b224507fb65388d1bed46242878de4ae9e3811969d05f6a6c26d7f92333f3d30fd0f9a57b934f0d121435be5ed53d895c9fc0430d8bf002cd21040d13cfb4330b0c9a8bdfc0107121183d38ae79f0a3b990f62a8905484d2da76b4d93450c7d2c747e49be5d1785eea9ca74ba5dca3e01feb1ae0ac248e99bd059478b5812ef35f84f70b2757dd2b9125b208fdaf6f8d8418a6bceecf247f676de955bd3f19a6d24da944187be6309d21b6893f7db3aac858c19238d4138b3010000000500000003caf77c38f3640a0f6e5099bcf786948cb0d38a80d7152ee8d049a4edb533564ca2767577c0263fcefc4cf701a8390d967400c24ac12c0210ec7264d1e5fd79049f417115da2e4a81ce7446fd98e8ad0cd1ec7f2a9b39164952356f2d77db77d600009317180000000020b11c178bf9f6f27e17befc0834b7ae023680bb7be99bb769651412d23c00000012fec1208066ef75e1321503acdca40e9c3e518c2007ba9fee16334c5869ce756176dd6a5ec8001e322c01008251768b43db38797624226710a499f71d2a09047e9731d65ce319f02d2b8090299e7c9beabdd658524896ad421e929a0152ffb557a0da9dbb1db0c9fc73609d7697f63730d471204042e03a856e58447d78e2e5ac5ade0fecfa6c6b4017bb565601000003000600000000000000fd490101009317180003000187dd321749ae4f4921cdd00a7472fc03fcdd3b061bf250a033f36be25900000032ffffffffffff0332ffffffffffff03b918ef595cabf2a05257484b85f17374fe23c3a5e31ba461ae895da39da548e72a7c4b74a9939856944f6a2ed6258c879d039aa9ab63f1c000fbf346b80fd218abccbcbbfc7897ab438da5d67c240ea995b08914b479510b835b0296a4c12fa50d0e34dcb3614f03ca8d73f8d26bad7c09278264c29a21330f32c8046180bba90c320ef31bbddf05f3dae13634479c72c24995f9ac2e7422bfecb5bd13b0d9f3a3c9f88a717427612253bb44d2a7691bb1efa1640e68de2bdf6ca331b2d43a0543c9ba4b0c1143b0449ca93455795c10757c42660e806aae00371e73f63868b90361fbe96addc143310db1d965f3cf46ba0d9fc0c85fe4bdb333fd6e4959a3779bb6c0e3d2bcc2f6b628f276cadaad090100000005000000035d51f04b38e96685a807948c4bf75f006c1aacdeb91091742b3a5c340e2866540728ae8a72c9cc9110a4edf4ee910e481566a64a96e4e04ed598f8c0aa0b13166185ad9ec8b81d5d6fcecaccd2f09978256cd16a11826efbf66625418abcc2640000a5191800000000209d41a1534c4197b85221e7569571daa991dcb269aca4ca0f562070b4d90000005bfad1d7535bdabb8e9400b407cb4580641c32400a0bd2e352b780ff6e7cf167119dde6aac00011e82020b008d883d1ddcee0143754a740dca1fe02d26c82643fc82a366fb6f9e30bbe87df9dd1ee2a09e766f52d003050db1e514b216013e3aa247852028e226e1c2eedfade001e9427818f25370e86d12cdcf5fabb2ef15d7896434e5992719233955a2725601000003000600000000000000fd49010100a3191800030001ad83bfbaf2c5eb05f8b31e2bc8aa5c48930e7c99a4220c379c984ad70201000032ffffffffffff0332ffffffffffff03a18bf4d78d2d851aaba084b2de0a6a01d5df6bc0ec10e60e405a74660afaa0e2874fd028a87dce60390fda90ef3bde90bb134a44c9550dba7a274e847eef886f3e265b22b2d99288e4c9118b52f1caeeadf71dd1f3a1ac2221b5ec3d4b12a6561d98cf4d9a7b27ce653ec733f67352f0a04e4c73ea2cce45a566362f9ef3f2a916a16168b76d6a9ebe0854693b1a85bed84a33db1699194ad338cc0698a2d7a8977f3f3f4cb01fcbcba43d8aa02630b183be8afcfda8e736bcbf075b14910bf25db3b5b6b5990c0035a40e0cba8b9cb91b082e727d2c11978c297ed10d0c6c57170565736ea956b7fe28300629b04780521ae419697bc2eee76991f238e025e80b52db6a6a22d5b778acb7dd423259000100000006000000037798fbcc82bd4a857a05cfd1835326c0ca16d40af1e86f6bcae6941c9a9e6840b234ff95c9f43c20346fd9cedb4dcfa6068381e67f23dd4aab2b65e21577162dd39102e2f27b2df06512864c573487ef7a24e227e6caed4786f25dfbcc6763e4020000000020132a5a6a06be1e95cfb73f0e241a65119d8e18d26f5a5c0a7402cb7e07000000ef087389c837b26fc6179ecda54800150ff0c0f3733a2c7b591154857747443f7d9bde6ac2fb001ee1850e0000000020127849bb54a9c7a50ca662151127de91212d0b6cdc5f384f1696e8fdd20000007e438aa343bfb642faac4c60957e8bfdbf7141bd4b0f050a1c5e4888651d6a9ef49cde6aaafe001eb0f30600e81b1800000000204b0a090d508a0e5086440e9d73ddb274021919fe683403c2f5689f861c000000dff5af6ffde7e8f948494e475722ef220ede01c8ee75bcbad9995f5a15a1904672d2df6a4ace001e589d0200a5ebb53634ad43263e0caf7054e1965c4f17a41c95e37c61c456056a4e7d5a20ec68df4b5a30d4f8e1fc3bf7f3053a2d0c0f3943e67036c0da12d1c664efbbe3a65ba685d3b1b00aecdfcf631d2d84fca654a2a9f1b7077497ac52e05ff5f6d25601000003000600000000000000fd49010100e31b18000300018bcc929986c96aa428ac63ce4bd25520244172325ef4e28759aa95351700000032ffffffffffff0332ffffffffffff0391d53a49af51a781573bfa563817cebff72ca951c4c8d8955d5a72fd80b2dfd8f5d98828104da4446adf2f84ab7aa414f6083f6e0cd37cb1f957538cbb5087dd80032dd69002f40e8740bbf7081f4a99a90051e8fb327247feeb4120c9edeefd8cdd33b206072ca577f1d80d17fc1dfe9f0f07583c604766de84d4477c5cac4a14e0e0d49c30d110c7127af43a6f0ec6ad57eea803c5817a99de595444d11237ecaf2fea6bca9925c2bb922c243bea8183080d49465a181fc48c357a78ebfd9c487e8c8f08e7aa67714ea2884854b4f3596e0af66568bdf6bad358ee02b89f3a0caa4c2e9089661d2c2fde0bc4fb3463cee9aa8c704fe4c1e03517b159966fb3efcc48cdb5f4d75202fee4e2bba0f16c010000003400000006daf10af0ed2d20e8dafb9fbb635f9e7b0803dcdd7fdf603bdc234d963df14c81ea38c0c8b415e448069cec255a74f5d6576059bf64ea2ce00259ce57e586f33e6cb9335a4d88eca612825ddb3e7d20e0bcd376251f845d85da78c8c076ed96fe4a55ac61812c9b462ab77e2ca803779d8935bf31cc2a6817e9e8d10e0f88336fcea1e08263ff92bfa098f514908e7a01dccffdd8a5c19b55a5531424a63635a9891d8d06b2990a6a256803b39abca4cc6ff29189d47752b18272a580369a5191050000000020ca8ca9a6f6554be982c5f7afb3305cdb7720cb4cbf87426e70a92d9020000000c5326c2a8be0c601ace3b66740185d955fdc4e75f32a1febfbb6c95a23d3aced17d0df6a21fa001e9f150d0000000020738da062d736d9b74f50422e3432c07255d4cc60b9a59fd611e501fbc30000003702a7755682fd0310b70792e8df461e71a2595b29e02b61ac7e70269e1f3b414ed0df6a651d011e8cb90100000000205c0b62eec35706c4599bc8a513d37bf34d012a327721f20dc01e086b460000000e727bf6e9e4b4a5087f2a222dd23e3276213de489a8fb27bdf7a7d2eea662e53fd1df6a9d25011e96f4080000000020aac54bafe96468717c24754b8682c3b2ea39a97c9640ee2ef093484ab3000000c28959791fe2cbedba0527963ca4c6921555cc9178521a2d21524d169a3866a68bd1df6ae004011ee682040000000020046260e6679dc301afc2e7cfae540816612206be91dc32624d2ceb22b60000005ddcbabf8f443cd541911b5c57154540c0aa97617ce4abb38723c2d8392e5b3055d2df6accc5001e24150200411e180000000020d53b8b4607711eb6f01fadf43bd5bdecbe77bb45eb78be2f2f993b4234010000a80b948fe608ea45520e5c945611f5d326c7d0cd9dd424ed720155e6bfca8eb8e816e16a0ed9011eb8850600b3ef2222c12b6145be23b67a1516c10becbe7e68ac62ba7b071e3ad05309fa82ff57bdb9dc911ee7c82fe691a9800a6d0781d3f2111a2640f25cbf64e7f8d2b9501d5ef3f2671893d5356194f038b799990838ae6ed7ff7cc332ebfbe585cefa5601000003000600000000000000fd490101003b1e18000300019d756dbcf35a0b0af108e4be71201676f85172a1e3189b35818e7b4a4c00000032ffffffffffff0332ffffffffffff03a5628d8529af81c942ebe5b54f7d1e7a6dcc1d0955a314d677cb8ca9920ae6a6081b12b380ac55b199baac5b87b50ee7e119bd0b91cd2e80dbb2641fb2a28bf4ef8284a943be8fe95bab07c5c269dccf86158919db1c1e3a86f180030cb37d9fcd9792dc5186a2ea44f09caf509b0b1ff27ec3150204e64636a0511be133c20b154dfc1b05be10bb859c75712d4589b9b1428a7522899b4f9d5794ab42600f032289bd4c6bd0fa3e7eb9cc1dc78cc5f68aae5deeb18ba24e32e5f18da6212e4f30c16855c07772ee67926bdab7b45f238d5dec7a77b44130beb894744390a89503e530ec799c6a5feb48726e8a2a7affe4aa220d4c7475cfa0965e9ddd2664cb2aef71d7a88c9b98b0ff7518f1e2827d010000000800000003d572102c9866a181ab06a90d8eadaf046590ba2bed388e923c764b76e5c1fad3a46ff7de4f75adae908706558aaf2a55a09c0ea555d13956a7129ccbe83097067f3cfebbb5d3dc855c911ca8a64e53ac4eeb3bd6e66c7e904ad0740df253433c0600000000202e8cc34d8e5b743ca8298070ac0aea9197366a0361ce04b29d9eb403350100005cb3f0770cc6f644844390745f9191dfd37ff43d741d34e47b667fa4411cf12ef914e16ae39e011e54ba0e00000000206e7de8f176aa3360adb94121a28e50202532f5f3c3bf4b834b1fb8bbc90000004851e060d1dff8d7bc6acc41722c7d1e0270d80ad569f61ae96e9c91351cbba62315e16ad199011e09b1030000000020b5fadea7c5cbec8f67437e3fa48fba939e8866eb62ff969a5cc79bba8b010000703c0416fb68c4197012925aa131309045c7bc5bb11d536e819355969c0540013715e16a4ca4011e9d980c0000000020cf7656e8180d0cebb16fd5787ef5aee62503a9e7c6c2baa47404b8556e0000003364b39d984724f1b56509b5dce09beae2708b25011de6cb326ec44a4166c9c8a815e16aa6a9011eb4dc0000000000203cba36719ca36b4ef1e59f4ade5b7c91899c62e4eaae388c45d8061d6e000000a5cc737c4b741a4697a82f752f3b66bd27f65110e7837e71a8f71958d54dbb1ca915e16abebe011edcfd0a0000000020dd7bb31b50a449c0bb15af6b1f469ab488683d176be4d965443a65d131000000d9b7ca112d870f6da258814fcd9e8f969b5b8686f4b59078da2eaa5462b03d299316e16a2cb5011e0a6804004b20180000000020142fa505451699d9d7d40a4a808f4bfa5cd43221e6d5ca7f9695547738000000531c2ff60ae682cceb43c3783d008575c47e07fcc437dd91ffb260c5a6564f37dc34e26a1178011e22cd0e00a06d521ffc3219e5924b3e6b88f4ea05acc0ef9c95ca021f00d75276fb9aec5bb458206180e96718b9df1056db190b8f0d855880f7ecd31938bf63fbd8f2d9c67fa37cabf15e0e96051870b10d13ae1165773dbf05cb61319f4d50dd3440980e5601000003000600000000000000fd490101004b201800030001941b849cc10e92a87ab079ce6a0e1e2b20d5d726f706d01fa7cf2b936200000032ffffffffffff0332ffffffffffff03a36e4e5c0c5505554e8ff6b2c9752487ba2462c8e1dc00aa6d478f90f12b4e2ef1c9ebd8ad4b3735f568a279279ae486d60f7c5a29a5690e2d0612da673fd8a0dc2e16d6f02d7e5f35b37a7a5cd49be58dd538968e28b7462581bf0310f5a5448d4c733907cd38501da8376a1ee441089529b489f944fcf9d9ae00c4e38d2eb80b261f6443dbdb24879bcb3ffffc5f9dfdc3ac8b62961c240b9fac7cbde9cb625d4a8682147f88b579e1c7ed4465b3aab49df3113f75efa49e4d0725d1a46d792635b059c00ed462cdefd463a6a42c0d61f3e1d5f6e6f1c6cf5ff9749069e0fb09c96ae324994eb63b92a7af4e3513d8d61e7a5e1684cc8e7729b733265b564c32ff99596f8f1758d93e03dd00583271010000001100000005c4432325af772414739f1653f6bed20c87e76aec7fd282cc78d7da1bba9c599db243844a34faa25128ff968096e2276afee91593c5dc9a2a1d6c7fe499a81950157cf2bfd288b9195dff259e574d349a34a24c00089dc2560cb55998940bb78ed932f0802062dd1495f2be8beea903ce10c7b8fa1d48c056f55312cf9648abab73efa92ef1fb885f5547f29d11b5b806fca5f93acd96fd7bd1ce241f5299c8d200008e22180000000020ddfe0bfd1c868e541a4891816e4c0ef5522e356b79e510d8ccffd749fa0000004ea3af4dfeba58d8255cbbe800cb9790dd96bd543f7705763f8bbd0d5c80eeee7968e36abf67011e558f0800b54e083860f5650933012268ee15aedf014a1948cfec45c198e0eec52c26c737b6f3d7f5ee6d8907ff3eec40c322ab470a85fdc9ceb680fd43d2cbece0b22299f128c22928cb8e73ec73202dbc50c8ad5f35620014948ffff5ea500481ed25655601000003000600000000000000fd490101008b221800030001fb6e4696c79d4583dc689c325749dcc80daa0dcc96575362efd88c2fb900000032ffffffffffff0332ffffffffffff038ac8d5acde692ee35f7cd635765f220406ca041ad75fb27411a7dc2edaf3141645b9e8288d105e10af4d9092bc13feb7685af0c929c9a6df8b7823e07904735069c3c6b2b2405270b8d8c569673d5c8292c88e2d0bb66b0cae7cf8c12b234a2629e461c73b5126a175b9c068ce10f46a864a79f5c135ec8b8b0c9271a7095ee509b8f1dc8cfd7a02ef6a2d9f28db8f824b93e45b7788b03e0e9887f44d6a9b2824cfd5372aff38417ba3474d0773453cb4edff1005ddb900843124e1111fd1649afd431f1b9ca2ae26f21522abfde0f9dbe714999fbb6ac9a699dd7031b717620ef565e988f3b663693de2f37204d099090a45f6aaa5895804f7241f90517bfed142fade654d0bf020410ea01fb7e8a2010000001100000005c5984744adff46309bdf8ffe901edd1fab1aff026c7b536aed5cd27a42f8cc61c1b253cdef2107eb38aed6bf7328ce17e8e7a7036e30759e4f55af70ef6fe764a2ee2ff0050d6a79ce334036c92be64b9a0fd476d9d2c68e0ffb66bcf3f5b80dfc92625c6da659a7a2d6e311880dba30483f8be3976b96018986bc5aad352df0905a1b755c11c6f7c3e9c0f33a0b4072470a09e516b80b8a28c49f36ada455730300000000205e41d3302f81d97c1b32ce9e7a2c1289de2c3c66132042f55f0f1bc85901000013e2004954a58ddd66679206126027311d463234be7014acc03ee4bee1d1dc2b6367e36ac271011e3f1f060000000020457cd9c1d50b7c4b78a188841cd702bb45a06ff5363399dfd3ad1c0519000000fd667ebeb312f7a555f973911b0e6f9ebcc403a6c34558101a02b764e85d9a160a68e36a346f011eb4800c000000002060c7a6b4653b58e04a7fa92dbb51b0fd5f00899e2cd38e4d9e3a892c400100007bab827b838aa5dbe12d99d8055afda612bad10adcdc754312bf938fdb900c277568e36a185c011e2b6603004723180000000020d4bf3af0c0f64e37c89ed6d1b4e88a25e4a13abbdcd58cef0ec4696625000000cf052ed6ddc485ae1cfd9e98058362c1f417374bf107b89a452452930cd26e991fc3e36aa103261d0134b022a23237e29d2186208ad8885aec4f73ef643c06bd3dac3b3041d77b718556d326e69ae163ba70e013d6dcf99ca69541030141a7c9c74ed6d6cf354c1e6be5223fd5968f5e7423f5a6edf10ac6e96fb027f16c9bf22c8097706461df84a6465c799b01000003000500010000000000000000000000000000000000000000000000000000000000000000ffffffff4a03472318042bc3e36a4f534d41dfb4fedbc775b12b96cb841552842ec1d885b6356a833ddf04aa456b157c3e6f0400000000000000081fde8256000000000b2f54616c6f6e506f6f6c2f000000000374698c03000000001976a914c94f9e3f6b2070deece22390eaa12239300bc74e88ac2ed5fd0300000000016a2e67a706000000001976a91464f2b2b84f62d68a2cd7f7f5fb2b5aa75ef716d788ac00000000cf040047231800410190e025171ecfa413d78e896f397f2a694f37aebca2bae6547cf1d1d22be5b1ffc6c29f89087124a9eb1e2ef0232f5370b43e91dee21ab1442d89fbefaf4e0090d87eadd89b917f0b0f2589f450ed7429e698456fca37ba22dadaaf1b14a9259fb805ea55f185d52db548c9aa8e30bf0d9a9d4fce9638e43c12130e237abe50b12211d355e80dfd0729d2f19ef7d17d74f9df269d50e6656fdcb01433e81e49e6239f4d6a2200000000000000000000000000000000000000000000000000000000000000000000000000001d000000053bba106d34f0367b07926e88fd8a3328a8602b306de2da9811cf1be1d6a42cc5c499ec6668497153a2208d9068b40d3643fd96e8673bd4cb3c1d84a80424b1a550e06a53ba0dd39a37a32f4e7c50862e05804562901996119d54e0d00292b460a06b7b1a3c4acc469519626badd23a0fd06e6bc030681653ee90bee73637ffefadcc66c7d32dc23663e3f6e004f9a48350906999a4ce28b1988e76e8c84d7fa001003d010000030006f478340cdf0b302203ab060fd7b43ab6d123451dd93b21002903f5ee1d00000019ffffff0119ffffff0198f684f5e3095278d7f4e9708e24659e986462872d923700cfd5db226ec7d73f881b114c9127f0db9021976055194e5f783d6002b82ee62e7a6201198f93f01403d572112bf5c92c59156ab2d1052c2aaa7f8b76405f461e2c6bc1ebb8bb7d2e05f55402d2024ac23e09532c3aa607a10f0a0db3a18c89a182dd6deeb1f0f38706083450b39f683fdd86ee63caef99ad4ec77264d36f10fc4ceb69943f3c07efc27a22301c5cd36a89685bf7c7099addb662b5e3052e36d8a99a47e865e63b47c85dee8bc32e3241373544e82cbbfb4b97b784c03473f6a31cc1b3f19a8f2f3216acd47b97a94744ee34db2dadcd055e125b07fd66025f2e95366a3dd73641938158b241cbe024e1c54242be8080638d150000006d0000000732c1665fb16355d2cbf1d408c9c084eaf79e42f8cdc806071e3b1f3391d2c4e7729940ae08223abc961a5f21e5c85285ce2e4dfad647908b5285bebfa8fec3af9cfbfb759e1bbaeaed8aed1f32073dc86e53a1cd4e60992b22fb88e21743a6c01dd9244a6f55ad85e93ec12a6b2a41ec33bd053e03c86a1e0e04c5bb3ed405e726e9e94ee32062cc934f7687e182748fbc75e9b11e0c19ded5637d90d3f1fd0da6cd8da5a3f4f824d5764bfe9df825be3b8d3fdb2f5e587931a582ea18f779c5f0096edecf1e61b6a25ebf7b83665665b0fb34bda0948c38aefcd1a06531e3b8" +} From 4add0ec18e80bc2f8d8873a0226b4b3d8199ab8c Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 29 Oct 2026 18:01:09 -0500 Subject: [PATCH 04/11] fix(sdk): keep refreshed checkpoints below Platform's Core height During a Platform halt Core keeps moving, so a checkpoint bounded only by the verified Core tip could land above the Core height Platform reports, and released SDKs could not prove any key Platform asks for until it caught up. The refresh now also asks up to 15 seed evonodes (from dash-network-seeds, not the relay) for Platform's Core height. The evonodes have passed their Platform probes and are spread across /24 networks in random order. The refresh uses the highest height that more than half of at least 3 answers are at or above, and the checkpoint must be 288 blocks below both that height and the verified tip. `write_together` takes its rename function so the rollback path is tested. Tests cover a halted Platform, its exact bound, the kept case, the majority rule, and the node spread. The CI path filter for the crate now includes the DAPI crates the example uses. Co-Authored-By: Claude Opus 5.5 --- .github/package-filters/rs-packages.yml | 3 + Cargo.lock | 3 + .../Cargo.toml | 4 + .../rs-sdk-trusted-context-provider/README.md | 11 +- .../examples/refresh_checkpoints.rs | 362 ++++++++++++++++-- 5 files changed, 341 insertions(+), 42 deletions(-) diff --git a/.github/package-filters/rs-packages.yml b/.github/package-filters/rs-packages.yml index bcc49765fa4..bc0605fa49b 100644 --- a/.github/package-filters/rs-packages.yml +++ b/.github/package-filters/rs-packages.yml @@ -154,6 +154,9 @@ dash-context-provider: &context_provider rs-sdk-trusted-context-provider: &sdk_trusted_context_provider - .github/workflows/tests* - packages/rs-sdk-trusted-context-provider/** + # The refresh_checkpoints example's dev-dependencies, anchored further down. + - packages/rs-dapi-client/** + - packages/dapi-grpc/** - *dash_async - *context_provider - *core_proof diff --git a/Cargo.lock b/Cargo.lock index 0a62fe3a87c..30e92621a89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6621,9 +6621,11 @@ name = "rs-sdk-trusted-context-provider" version = "5.0.1" dependencies = [ "arc-swap", + "dapi-grpc", "dash-async", "dash-context-provider", "dash-core-proof", + "dash-network-seeds", "data-contracts", "dpp", "futures", @@ -6632,6 +6634,7 @@ dependencies = [ "js-sys", "lru", "reqwest 0.12.28", + "rs-dapi-client", "serde", "serde_json", "thiserror 2.0.18", diff --git a/packages/rs-sdk-trusted-context-provider/Cargo.toml b/packages/rs-sdk-trusted-context-provider/Cargo.toml index dd1c1ffd83b..d3729838985 100644 --- a/packages/rs-sdk-trusted-context-provider/Cargo.toml +++ b/packages/rs-sdk-trusted-context-provider/Cargo.toml @@ -107,6 +107,10 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus [target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] tokio = { version = "1.40", features = ["macros", "rt-multi-thread"] } +# The `refresh_checkpoints` example asks seed evonodes for Platform's Core height. +dapi-grpc = { path = "../dapi-grpc", default-features = false, features = ["platform"] } +dash-network-seeds = { workspace = true } +rs-dapi-client = { path = "../rs-dapi-client" } [target.'cfg(target_arch = "wasm32")'.dev-dependencies] # Runs the browser transport's tests under Node.js or a browser. diff --git a/packages/rs-sdk-trusted-context-provider/README.md b/packages/rs-sdk-trusted-context-provider/README.md index f9f1b570372..1eb3d644630 100644 --- a/packages/rs-sdk-trusted-context-provider/README.md +++ b/packages/rs-sdk-trusted-context-provider/README.md @@ -281,10 +281,13 @@ PR; an older checkpoint stays valid and only makes proofs longer. The relay's quorum list only suggests the height to ask for. The new checkpoint must be at least half a Platform quorum lifetime (about 12 hours) -below the verified tip, so the Core heights Platform reports after the -release are above it. A relay cannot prove a ChainLock above the real tip, and -one that hides its tip only makes the checkpoint older. If Platform has -halted for longer than that, refresh after it resumes. +below both the verified tip and the Core height Platform reports, so the +heights Platform reports after the release are above it. A relay cannot prove +a ChainLock above the real tip, and one that hides its tip only makes the +checkpoint older. Platform's Core height, which stops while Platform halts, is +asked of up to 15 evonodes from the network's seed list, spread across /24 +networks in random order. At least 3 must answer, and the height used is the +highest one that more than half of the answers are at or above. A refresh relies on the same thing every proof does: that no quorum on the way from the old checkpoint signed a false ChainLock. A bad checkpoint would carry diff --git a/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs b/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs index 4a8a16a1cd9..d69692615be 100644 --- a/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs +++ b/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs @@ -11,12 +11,17 @@ //! with the evidence `tests/checkpoints.rs` checks. A new checkpoint is proven //! from the one it replaces, so a refresh adds no trust in the relay. //! -//! The relay's quorum list only suggests which height to ask for. What bounds -//! the new checkpoint is the verified tip: a relay cannot prove a ChainLock -//! above the real tip, and the checkpoint must be at least half a Platform -//! quorum lifetime (about 12 hours) below it, so the Core heights Platform -//! reports after a release are above it. A relay that hides its tip only -//! makes the checkpoint older. +//! The relay's quorum list only suggests which height to ask for. The new +//! checkpoint must be at least half a Platform quorum lifetime (about 12 +//! hours) below two heights, so the Core heights Platform reports after a +//! release are above it: +//! +//! - the verified tip. A relay cannot prove a ChainLock above the real tip, +//! and one that hides its tip only makes the checkpoint older; +//! - the Core height Platform reports, which stops while Platform halts. It +//! is asked of evonodes from the network's seed list, not the relay, and +//! the height used is the highest one that more than half of the answers +//! are at or above. //! //! `MAINNET_QUORUM_RELAY` or `TESTNET_QUORUM_RELAY` replaces a network's //! public relay, which must serve both `/quorums` and `/proofs`. A network @@ -24,20 +29,31 @@ //! `--report` appends one Markdown line per network to a file, for the //! release PR. +use dapi_grpc::platform::v0::{get_status_request, get_status_response, GetStatusRequest}; use dash_core_proof::{bootstrap, rpc_hex, QuorumProofChain, State, MAX_PROOF_BYTES}; +use dash_network_seeds::{Reachability, SslStatus}; use dpp::dashcore::sml::llmq_type::network::NetworkLLMQExt; use dpp::dashcore::Network; +use rs_dapi_client::{AddressList, DapiClient, DapiRequest, RequestSettings}; use rs_sdk_trusted_context_provider::types::QuorumsResponse; use rs_sdk_trusted_context_provider::{get_quorum_base_url, VerifiedHttpContextProvider}; use serde_json::json; +use std::collections::BTreeMap; +use std::hash::{BuildHasher, RandomState}; use std::io::Write; +use std::net::IpAddr; use std::path::{Path, PathBuf}; use std::process::ExitCode; +use std::str::FromStr; use std::time::Duration; /// Largest `/quorums` body read. A real list is a few KiB. const MAX_QUORUMS_BYTES: usize = 1 << 20; +/// Seed evonodes asked for Platform's Core height, and how many must answer. +const STATUS_NODES: usize = 15; +const STATUS_ANSWERS: usize = 3; + const USAGE: &str = "usage: refresh_checkpoints [--report ] [mainnet] [testnet]"; #[tokio::main] @@ -123,23 +139,122 @@ async fn refresh_embedded(client: &reqwest::Client, network: Network) -> Result< Ok(relay) => relay.trim_end_matches('/').to_string(), Err(_) => get_quorum_base_url(network, None).map_err(|error| error.to_string())?, }; + let platform_height = platform_core_height(network).await?; refresh( client, network, &relay, ¤t, + platform_height, Path::new(env!("CARGO_MANIFEST_DIR")), ) .await } -/// Move `current` forward through `relay`, writing the new checkpoint and its -/// evidence under `crate_dir`. +/// The Core height Platform reports, as most of the answering seed evonodes +/// report it. +async fn platform_core_height(network: Network) -> Result { + let settings = RequestSettings { + connect_timeout: Some(Duration::from_secs(5)), + timeout: Some(Duration::from_secs(10)), + retries: Some(0), + ..RequestSettings::default() + }; + let nodes = status_nodes(network); + let asked = nodes.len(); + let asks = nodes.into_iter().map(|uri| async move { + let address = AddressList::from_str(&uri).ok()?; + let request = GetStatusRequest { + version: Some(get_status_request::Version::V0( + get_status_request::GetStatusRequestV0 {}, + )), + }; + let client = DapiClient::new(address, settings); + match request + .execute(&client, settings) + .await + .ok()? + .inner + .version? + { + get_status_response::Version::V0(status) => status.chain?.core_chain_locked_height, + } + }); + let heights = futures::future::join_all(asks) + .await + .into_iter() + .flatten() + .collect(); + majority_height(heights).ok_or_else(|| { + format!( + "fewer than {STATUS_ANSWERS} of {asked} seed evonodes reported Platform's Core height" + ) + }) +} + +/// Up to [`STATUS_NODES`] seed evonodes whose Platform probes passed, taken +/// in turn from each /24 network in random order, so that one operator's +/// block makes up as little of the sample as the network allows. +fn status_nodes(network: Network) -> Vec { + let random = RandomState::new(); + let mut networks: BTreeMap> = BTreeMap::new(); + for seed in dash_network_seeds::evo_seeds(network) { + let (Some(port), Some(platform)) = (seed.platform_http_port, &seed.platform) else { + continue; + }; + let IpAddr::V4(ip) = seed.address.ip() else { + continue; + }; + if platform.reachable != Reachability::Ok || platform.ssl != SslStatus::Valid { + continue; + } + let [a, b, c, _] = ip.octets(); + networks + .entry(random.hash_one([a, b, c])) + .or_default() + .push((random.hash_one(ip), format!("https://{ip}:{port}"))); + } + let mut networks: Vec<_> = networks + .into_values() + .map(|mut nodes| { + nodes.sort_unstable_by_key(|(order, _)| *order); + nodes.into_iter().map(|(_, uri)| uri) + }) + .collect(); + let mut chosen = Vec::new(); + while chosen.len() < STATUS_NODES { + let before = chosen.len(); + for nodes in &mut networks { + if chosen.len() < STATUS_NODES { + chosen.extend(nodes.next()); + } + } + if chosen.len() == before { + break; + } + } + chosen +} + +/// The highest height that more than half of `heights` are at or above, if +/// at least [`STATUS_ANSWERS`] answered. Raising it takes more than half the +/// answers; a lagging node only lowers it. +fn majority_height(mut heights: Vec) -> Option { + if heights.len() < STATUS_ANSWERS { + return None; + } + heights.sort_unstable(); + Some(heights[(heights.len() - 1) / 2]) +} + +/// Move `current` forward through `relay`, below the Core height Platform +/// reports, writing the new checkpoint and its evidence under `crate_dir`. async fn refresh( client: &reqwest::Client, network: Network, relay: &str, current: &State, + platform_height: u32, crate_dir: &Path, ) -> Result { let name = network.to_string(); @@ -172,12 +287,13 @@ async fn refresh( // Height 0 asks for the relay's tip; the newest quorum is active there. let (tip, _) = prove(client, relay, network, current, 0, &newest.quorum_hash).await?; - let bound = tip.height.saturating_sub(depth); + let bound = tip.height.min(platform_height).saturating_sub(depth); + let below = format!( + "{depth} blocks below the verified tip {} and Platform's Core height {platform_height}", + tip.height + ); if bound <= current.height { - return Ok(format!( - "kept {}; the verified tip {} is not {depth} blocks past it", - current.height, tip.height - )); + return Ok(format!("kept {}; nothing newer is {below}", current.height)); } // The oldest active quorum is in every quorum root from shortly after @@ -196,17 +312,17 @@ async fn refresh( } if height > bound { return Err(format!( - "the oldest listed quorum ({}) is within {depth} blocks of the verified tip {}", - oldest.height, tip.height + "the oldest listed quorum ({}) is not {below}", + oldest.height )); } let (target, chain) = prove(client, relay, network, current, height, &oldest.quorum_hash).await?; - // Both heights are proven, so this holds whatever the relay listed. + // The target and tip are proven, so this holds whatever the relay listed. if target.height > bound { return Err(format!( - "the relay answered at {}, within {depth} blocks of the verified tip {}", - target.height, tip.height + "the relay answered at {}, not {below}", + target.height )); } let evidence = json!({ @@ -219,18 +335,21 @@ async fn refresh( }, }); - write_together(&[ - ( - crate_dir.join(format!("tests/data/checkpoint-evidence-{name}.json")), - to_json(&evidence)?, - ), - ( - crate_dir.join(format!("checkpoints/{name}.json")), - to_json(&target)?, - ), - ])?; + write_together( + &[ + ( + crate_dir.join(format!("tests/data/checkpoint-evidence-{name}.json")), + to_json(&evidence)?, + ), + ( + crate_dir.join(format!("checkpoints/{name}.json")), + to_json(&target)?, + ), + ], + |from, to| std::fs::rename(from, to), + )?; Ok(format!( - "{} -> {} (`{}`), verified tip {}", + "{} -> {} (`{}`), verified tip {}, Platform at {platform_height}", current.height, target.height, rpc_hex(&target.block_hash), @@ -313,7 +432,11 @@ fn to_json(value: &impl serde::Serialize) -> Result { /// destination first, and if a rename fails the files already renamed get /// their old contents back. A crash between two renames can still leave a /// mixed pair, which the `checkpoints` test then fails. -fn write_together(files: &[(PathBuf, String)]) -> Result<(), String> { +/// `rename` is `std::fs::rename` outside tests. +fn write_together( + files: &[(PathBuf, String)], + mut rename: impl FnMut(&Path, &Path) -> std::io::Result<()>, +) -> Result<(), String> { let mut staged: Vec<(PathBuf, &PathBuf, Vec)> = Vec::with_capacity(files.len()); for (path, text) in files { let staging = path.with_extension("json.new"); @@ -339,7 +462,7 @@ fn write_together(files: &[(PathBuf, String)]) -> Result<(), String> { } } for (index, (staging, path, _)) in staged.iter().enumerate() { - if let Err(error) = std::fs::rename(staging, path) { + if let Err(error) = rename(staging, path) { for (_, done, old) in &staged[..index] { if let Err(error) = std::fs::write(done, old) { eprintln!("{}: cannot restore: {error}", done.display()); @@ -458,23 +581,33 @@ mod tests { dir } + /// Both files hold their placeholders and no staging file is left. fn unchanged(dir: &Path) -> bool { [ "checkpoints/testnet.json", "tests/data/checkpoint-evidence-testnet.json", ] .iter() - .all(|file| std::fs::read_to_string(dir.join(file)).ok().as_deref() == Some("old\n")) - && !dir.join("checkpoints/testnet.json.new").exists() + .all(|file| { + std::fs::read_to_string(dir.join(file)).ok().as_deref() == Some("old\n") + && !dir.join(format!("{file}.new")).exists() + }) } + /// Refresh through `handler` while Platform is not the limit. async fn run(handler: Handler, dir: &Path) -> Result { + run_at(handler, dir, u32::MAX).await + } + + /// Refresh through `handler` with Platform at `platform_height`. + async fn run_at(handler: Handler, dir: &Path, platform_height: u32) -> Result { let (checkpoint, _, _) = fixtures(); refresh( &reqwest::Client::new(), Network::Testnet, &relay(handler), &checkpoint, + platform_height, dir, ) .await @@ -539,7 +672,7 @@ mod tests { .await .expect_err("a target at the tip"); assert!( - error.contains("within 288 blocks of the verified tip"), + error.contains("not 288 blocks below the verified tip"), "{error}" ); assert!(unchanged(&dir)); @@ -612,15 +745,168 @@ mod tests { assert!(unchanged(&dir)); } + #[tokio::test] + async fn should_stay_below_the_core_height_of_a_halted_platform() { + let (checkpoint, target, tip) = fixtures(); + let target_height = proven_height(&target, &checkpoint); + let dir = crate_dir("halted"); + // Core went on to the tip, but Platform stopped at the target. + let error = run_at(honest(target_height - 24, tip, target), &dir, target_height) + .await + .expect_err("a target at Platform's Core height"); + assert!( + error.contains(&format!("Platform's Core height {target_height}")), + "{error}" + ); + assert!(unchanged(&dir)); + } + + #[tokio::test] + async fn should_refuse_a_proven_target_above_the_bound_set_by_platform() { + let (checkpoint, target, tip) = fixtures(); + let target_height = proven_height(&target, &checkpoint); + let dir = crate_dir("platform-bound"); + // The ask passes the bound, but the relay answers one block above it. + let error = run_at( + honest(target_height - 100, tip, target), + &dir, + target_height + 287, + ) + .await + .expect_err("a target above Platform's bound"); + assert!( + error.contains(&format!("answered at {target_height}")), + "{error}" + ); + assert!(unchanged(&dir)); + } + + #[tokio::test] + async fn should_accept_a_target_exactly_at_the_bound_set_by_platform() { + let (checkpoint, target, tip) = fixtures(); + let target_height = proven_height(&target, &checkpoint); + let dir = crate_dir("platform-boundary"); + let message = run_at( + honest(target_height - 24, tip, target), + &dir, + target_height + 288, + ) + .await + .expect("a refresh"); + assert!( + message.contains(&format!("-> {target_height}")), + "{message}" + ); + } + + #[tokio::test] + async fn should_keep_the_checkpoint_while_platform_is_not_past_it() { + let (checkpoint, target, tip) = fixtures(); + let dir = crate_dir("kept"); + let message = run_at( + honest(proven_height(&target, &checkpoint) - 24, tip, target), + &dir, + checkpoint.height + 288, + ) + .await + .expect("kept"); + assert!(message.starts_with("kept 1574768"), "{message}"); + assert!(unchanged(&dir)); + } + + #[test] + fn should_take_the_height_more_than_half_the_answers_reach() { + assert_eq!(majority_height(vec![]), None); + assert_eq!(majority_height(vec![7, 9]), None); + assert_eq!(majority_height(vec![9, 5, 7]), Some(7)); + // Two of five raising it, or one lagging, cannot move it. + assert_eq!(majority_height(vec![100, 100, 100, 900, 900]), Some(100)); + assert_eq!(majority_height(vec![1, 100, 100, 100, 100]), Some(100)); + // With an even count, half the answers raising it cannot either. + assert_eq!(majority_height(vec![100, 100, 900, 900]), Some(100)); + } + + #[test] + fn should_ask_probed_seed_evonodes_from_every_network_block() { + let subnet = |uri: &str| { + let host = uri.trim_start_matches("https://"); + host.rsplit_once('.').map(|(subnet, _)| subnet.to_string()) + }; + for network in [Network::Mainnet, Network::Testnet] { + let nodes = status_nodes(network); + assert_eq!(nodes.len(), STATUS_NODES, "{network}"); + let mut unique = nodes.clone(); + unique.sort(); + unique.dedup(); + assert_eq!(unique.len(), nodes.len(), "{network}"); + // Every /24 with a probed node is asked before any is asked twice. + let blocks: std::collections::BTreeSet<_> = dash_network_seeds::evo_seeds(network) + .iter() + .filter(|seed| { + seed.platform_http_port.is_some() + && seed.platform.as_ref().is_some_and(|platform| { + platform.reachable == Reachability::Ok + && platform.ssl == SslStatus::Valid + }) + }) + .filter_map(|seed| subnet(&seed.address.ip().to_string())) + .collect(); + let asked: std::collections::BTreeSet<_> = + nodes.iter().filter_map(|uri| subnet(uri)).collect(); + assert_eq!(asked.len(), blocks.len().min(STATUS_NODES), "{network}"); + } + } + + fn files(dir: &Path) -> [(PathBuf, String); 2] { + [ + ( + dir.join("tests/data/checkpoint-evidence-testnet.json"), + "new\n".to_string(), + ), + (dir.join("checkpoints/testnet.json"), "new\n".to_string()), + ] + } + + #[test] + fn should_replace_both_files() { + let dir = crate_dir("replace"); + write_together(&files(&dir), |from, to| std::fs::rename(from, to)).expect("written"); + for (path, _) in files(&dir) { + assert_eq!(std::fs::read_to_string(&path).expect("a file"), "new\n"); + assert!(!path.with_extension("json.new").exists()); + } + } + #[test] fn should_leave_every_file_unchanged_when_staging_one_fails() { let dir = crate_dir("staging"); - let error = write_together(&[ - (dir.join("checkpoints/testnet.json"), "new\n".to_string()), - (dir.join("missing/testnet.json"), "new\n".to_string()), - ]) + let [first, _] = files(&dir); + let error = write_together( + &[ + first, + (dir.join("missing/testnet.json"), "new\n".to_string()), + ], + |from, to| std::fs::rename(from, to), + ) .expect_err("a missing destination"); assert!(error.contains("missing"), "{error}"); assert!(unchanged(&dir)); } + + #[test] + fn should_restore_a_replaced_file_when_a_later_rename_fails() { + let dir = crate_dir("rollback"); + let mut renames = 0; + let error = write_together(&files(&dir), |from, to| { + renames += 1; + if renames == 2 { + return Err(std::io::Error::other("refused")); + } + std::fs::rename(from, to) + }) + .expect_err("a failed rename"); + assert!(error.contains("refused"), "{error}"); + assert_eq!(renames, 2); + assert!(unchanged(&dir)); + } } From 0cf9b253c0448d4f79caf769d044aaa0337e2b80 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 29 Oct 2026 18:44:39 -0500 Subject: [PATCH 05/11] fix(sdk): require a majority of the evonodes asked for Platform's Core height The Platform-height majority was taken over the evonodes that answered, so with only three of fifteen answering, two could set it. A height now counts only if more than half of the evonodes asked (and at least three) report at least it; one that does not answer supports nothing, and if too few answer the network keeps its checkpoint. Also from review: - skip seeds whose Platform liveness probe failed; - split the `getStatus` parsing and the sampling out of the transport, and test missing fields, silent nodes and the threshold; - exit with status 2 when the `--report` file cannot be written, and stop `release.sh` on it, since the report would no longer match the files; - put the README's example arguments after `--`. Co-Authored-By: Claude Opus 5.5 --- .../rs-sdk-trusted-context-provider/README.md | 10 +- .../examples/refresh_checkpoints.rs | 217 +++++++++++++----- scripts/release/release.sh | 9 +- 3 files changed, 176 insertions(+), 60 deletions(-) diff --git a/packages/rs-sdk-trusted-context-provider/README.md b/packages/rs-sdk-trusted-context-provider/README.md index 1eb3d644630..4fa0543aaa3 100644 --- a/packages/rs-sdk-trusted-context-provider/README.md +++ b/packages/rs-sdk-trusted-context-provider/README.md @@ -259,7 +259,7 @@ pass it back to `with_checkpoint` on the next start, so its proofs stay short. order). `yarn release` moves both forward with ```sh -cargo run -p rs-sdk-trusted-context-provider --example refresh_checkpoints [mainnet] [testnet] +cargo run -p rs-sdk-trusted-context-provider --example refresh_checkpoints -- [mainnet] [testnet] ``` For each network it asks the public relay for two proofs from the current @@ -285,9 +285,11 @@ below both the verified tip and the Core height Platform reports, so the heights Platform reports after the release are above it. A relay cannot prove a ChainLock above the real tip, and one that hides its tip only makes the checkpoint older. Platform's Core height, which stops while Platform halts, is -asked of up to 15 evonodes from the network's seed list, spread across /24 -networks in random order. At least 3 must answer, and the height used is the -highest one that more than half of the answers are at or above. +asked of up to 15 evonodes from the network's seed list whose probes passed, +spread across /24 networks in random order. The height used is the highest +one that more than half of the evonodes asked (and at least 3) report at or +above; an evonode that does not answer supports no height, so if too few +answer the network keeps its checkpoint. A refresh relies on the same thing every proof does: that no quorum on the way from the old checkpoint signed a false ChainLock. A bad checkpoint would carry diff --git a/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs b/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs index d69692615be..4130447e3be 100644 --- a/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs +++ b/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs @@ -25,13 +25,15 @@ //! //! `MAINNET_QUORUM_RELAY` or `TESTNET_QUORUM_RELAY` replaces a network's //! public relay, which must serve both `/quorums` and `/proofs`. A network -//! that fails keeps its checkpoint, and the exit status is nonzero. +//! that fails keeps its checkpoint, and the exit status is 1. //! `--report` appends one Markdown line per network to a file, for the -//! release PR. +//! release PR; if that write fails, the exit status is 2. -use dapi_grpc::platform::v0::{get_status_request, get_status_response, GetStatusRequest}; +use dapi_grpc::platform::v0::{ + get_status_request, get_status_response, GetStatusRequest, GetStatusResponse, +}; use dash_core_proof::{bootstrap, rpc_hex, QuorumProofChain, State, MAX_PROOF_BYTES}; -use dash_network_seeds::{Reachability, SslStatus}; +use dash_network_seeds::{PlatformLiveness, PlatformStatus, Reachability, SslStatus}; use dpp::dashcore::sml::llmq_type::network::NetworkLLMQExt; use dpp::dashcore::Network; use rs_dapi_client::{AddressList, DapiClient, DapiRequest, RequestSettings}; @@ -54,6 +56,9 @@ const MAX_QUORUMS_BYTES: usize = 1 << 20; const STATUS_NODES: usize = 15; const STATUS_ANSWERS: usize = 3; +/// The exit status when the `--report` file could not be written. +const REPORT_FAILED: u8 = 2; + const USAGE: &str = "usage: refresh_checkpoints [--report ] [mainnet] [testnet]"; #[tokio::main] @@ -96,6 +101,7 @@ async fn main() -> ExitCode { }; let mut status = ExitCode::SUCCESS; + let mut report_failed = false; for network in networks { let line = match refresh_embedded(&client, network).await { Ok(message) => { @@ -123,10 +129,15 @@ async fn main() -> ExitCode { .and_then(|mut file| writeln!(file, "{line}")); if let Err(error) = written { eprintln!("{}: {error}", path.display()); - status = ExitCode::FAILURE; + report_failed = true; } } } + if report_failed { + // `release.sh` stops on this, since the report no longer matches + // the files. + return ExitCode::from(REPORT_FAILED); + } status } @@ -139,8 +150,8 @@ async fn refresh_embedded(client: &reqwest::Client, network: Network) -> Result< Ok(relay) => relay.trim_end_matches('/').to_string(), Err(_) => get_quorum_base_url(network, None).map_err(|error| error.to_string())?, }; - let platform_height = platform_core_height(network).await?; - refresh( + let (platform_height, answered, asked) = platform_core_height(network).await?; + let message = refresh( client, network, &relay, @@ -148,48 +159,65 @@ async fn refresh_embedded(client: &reqwest::Client, network: Network) -> Result< platform_height, Path::new(env!("CARGO_MANIFEST_DIR")), ) - .await + .await?; + Ok(format!( + "{message} ({answered} of {asked} seed evonodes answered)" + )) } -/// The Core height Platform reports, as most of the answering seed evonodes -/// report it. -async fn platform_core_height(network: Network) -> Result { +/// The Core height Platform reports, as more than half of the seed evonodes +/// asked report it, with how many answered and how many were asked. +async fn platform_core_height(network: Network) -> Result<(u32, usize, usize), String> { let settings = RequestSettings { connect_timeout: Some(Duration::from_secs(5)), timeout: Some(Duration::from_secs(10)), retries: Some(0), ..RequestSettings::default() }; - let nodes = status_nodes(network); - let asked = nodes.len(); - let asks = nodes.into_iter().map(|uri| async move { - let address = AddressList::from_str(&uri).ok()?; + sample_platform_height(status_nodes(network), |uri| async move { let request = GetStatusRequest { version: Some(get_status_request::Version::V0( get_status_request::GetStatusRequestV0 {}, )), }; - let client = DapiClient::new(address, settings); - match request - .execute(&client, settings) - .await - .ok()? - .inner - .version? - { - get_status_response::Version::V0(status) => status.chain?.core_chain_locked_height, - } - }); - let heights = futures::future::join_all(asks) + let client = DapiClient::new(AddressList::from_str(&uri).ok()?, settings); + status_height(request.execute(&client, settings).await.ok()?.inner) + }) + .await +} + +/// Ask every node with `ask` and take the height more than half of them +/// support. A node that does not answer supports no height. +async fn sample_platform_height( + nodes: Vec, + ask: Ask, +) -> Result<(u32, usize, usize), String> +where + Ask: Fn(String) -> Answer, + Answer: std::future::Future>, +{ + let asked = nodes.len(); + let heights: Vec = futures::future::join_all(nodes.into_iter().map(ask)) .await .into_iter() .flatten() .collect(); - majority_height(heights).ok_or_else(|| { - format!( - "fewer than {STATUS_ANSWERS} of {asked} seed evonodes reported Platform's Core height" - ) - }) + let answered = heights.len(); + majority_height(heights, asked) + .map(|height| (height, answered, asked)) + .ok_or_else(|| { + format!( + "only {answered} of {asked} seed evonodes reported Platform's Core height; \ + more than half must agree" + ) + }) +} + +/// Platform's Core height from a `getStatus` response, if it has one. +fn status_height(response: GetStatusResponse) -> Option { + match response.version? { + get_status_response::Version::V0(status) => status.chain?.core_chain_locked_height, + } } /// Up to [`STATUS_NODES`] seed evonodes whose Platform probes passed, taken @@ -205,7 +233,7 @@ fn status_nodes(network: Network) -> Vec { let IpAddr::V4(ip) = seed.address.ip() else { continue; }; - if platform.reachable != Reachability::Ok || platform.ssl != SslStatus::Valid { + if !probe_passed(platform) { continue; } let [a, b, c, _] = ip.octets(); @@ -236,15 +264,22 @@ fn status_nodes(network: Network) -> Vec { chosen } -/// The highest height that more than half of `heights` are at or above, if -/// at least [`STATUS_ANSWERS`] answered. Raising it takes more than half the -/// answers; a lagging node only lowers it. -fn majority_height(mut heights: Vec) -> Option { - if heights.len() < STATUS_ANSWERS { - return None; - } - heights.sort_unstable(); - Some(heights[(heights.len() - 1) / 2]) +/// Whether a seed's Platform probe connected, got an HTTP answer and saw a +/// valid certificate. +fn probe_passed(platform: &PlatformStatus) -> bool { + platform.reachable == Reachability::Ok + && platform.live == PlatformLiveness::Ok + && platform.ssl == SslStatus::Valid +} + +/// The highest height that more than half of the `asked` nodes, and at +/// least [`STATUS_ANSWERS`], report at or above. Raising it takes more than +/// half the nodes asked, whatever the others do; a lagging node only lowers +/// it, and a silent one supports nothing. +fn majority_height(mut heights: Vec, asked: usize) -> Option { + let needed = (asked / 2 + 1).max(STATUS_ANSWERS); + heights.sort_unstable_by(|a, b| b.cmp(a)); + heights.get(needed - 1).copied() } /// Move `current` forward through `relay`, below the Core height Platform @@ -815,15 +850,90 @@ mod tests { } #[test] - fn should_take_the_height_more_than_half_the_answers_reach() { - assert_eq!(majority_height(vec![]), None); - assert_eq!(majority_height(vec![7, 9]), None); - assert_eq!(majority_height(vec![9, 5, 7]), Some(7)); - // Two of five raising it, or one lagging, cannot move it. - assert_eq!(majority_height(vec![100, 100, 100, 900, 900]), Some(100)); - assert_eq!(majority_height(vec![1, 100, 100, 100, 100]), Some(100)); - // With an even count, half the answers raising it cannot either. - assert_eq!(majority_height(vec![100, 100, 900, 900]), Some(100)); + fn should_take_the_height_more_than_half_the_nodes_asked_reach() { + assert_eq!(majority_height(vec![], 15), None); + // Two raising it while one honest node answers: 3 of 15 is no majority. + assert_eq!(majority_height(vec![900, 900, 100], 15), None); + assert_eq!(majority_height(vec![100; 7], 15), None); + assert_eq!(majority_height(vec![100; 8], 15), Some(100)); + // Seven of fifteen raising it cannot move it; eight can. + let mut heights = vec![900; 7]; + heights.extend([100; 8]); + assert_eq!(majority_height(heights.clone(), 15), Some(100)); + heights[7] = 900; + assert_eq!(majority_height(heights, 15), Some(900)); + // Lagging nodes lower it only once they leave no majority above; + // half of an even count cannot raise it. + assert_eq!(majority_height(vec![100, 100, 1, 100, 100], 5), Some(100)); + assert_eq!(majority_height(vec![100, 1, 1, 1, 100], 5), Some(1)); + assert_eq!(majority_height(vec![900, 900, 100, 100], 4), Some(100)); + // Never fewer than three answers. + assert_eq!(majority_height(vec![100, 100], 2), None); + assert_eq!(majority_height(vec![100, 100, 100], 3), Some(100)); + } + + fn status(chain: Option>) -> GetStatusResponse { + use get_status_response::get_status_response_v0::Chain; + GetStatusResponse { + version: Some(get_status_response::Version::V0( + get_status_response::GetStatusResponseV0 { + chain: chain.map(|core_chain_locked_height| Chain { + core_chain_locked_height, + ..Chain::default() + }), + ..Default::default() + }, + )), + } + } + + #[test] + fn should_read_platforms_core_height_from_a_status_response() { + assert_eq!( + status_height(status(Some(Some(1_581_918)))), + Some(1_581_918) + ); + // A node whose Drive is down reports its chain without the height. + assert_eq!(status_height(status(Some(None))), None); + assert_eq!(status_height(status(None)), None); + assert_eq!(status_height(GetStatusResponse { version: None }), None); + } + + #[tokio::test] + async fn should_count_silent_nodes_as_supporting_no_height() { + let nodes = |count: usize| (0..count).map(|node| format!("node{node}")).collect(); + // Eight of fifteen answer, two of them high: the honest six still + // leave no majority for the high value. + let answers = |uri: String| async move { + match uri + .trim_start_matches("node") + .parse::() + .expect("an index") + { + 0 | 1 => Some(900), + 2..=7 => Some(100), + _ => None, + } + }; + assert_eq!( + sample_platform_height(nodes(15), answers).await, + Ok((100, 8, 15)) + ); + // Three answers of fifteen, two of them high, decide nothing. + let error = sample_platform_height(nodes(15), |uri: String| async move { + match uri.as_str() { + "node0" | "node1" => Some(900), + "node2" => Some(100), + _ => None, + } + }) + .await + .expect_err("no majority"); + assert!(error.contains("only 3 of 15"), "{error}"); + let error = sample_platform_height(Vec::new(), |_| async { Some(100) }) + .await + .expect_err("no nodes"); + assert!(error.contains("only 0 of 0"), "{error}"); } #[test] @@ -844,10 +954,7 @@ mod tests { .iter() .filter(|seed| { seed.platform_http_port.is_some() - && seed.platform.as_ref().is_some_and(|platform| { - platform.reachable == Reachability::Ok - && platform.ssl == SslStatus::Valid - }) + && seed.platform.as_ref().is_some_and(probe_passed) }) .filter_map(|seed| subnet(&seed.address.ip().to_string())) .collect(); diff --git a/scripts/release/release.sh b/scripts/release/release.sh index 0c74439e6b6..8c34f57f2e7 100755 --- a/scripts/release/release.sh +++ b/scripts/release/release.sh @@ -98,7 +98,14 @@ fi # See packages/rs-sdk-trusted-context-provider/README.md. # The per-network results go into the release PR's description. CHECKPOINT_REPORT=$(mktemp) -if ! cargo run --quiet --package rs-sdk-trusted-context-provider --example refresh_checkpoints -- --report "$CHECKPOINT_REPORT"; then +if cargo run --quiet --package rs-sdk-trusted-context-provider --example refresh_checkpoints -- --report "$CHECKPOINT_REPORT"; then + : +elif [ $? -eq 2 ]; then + # The report no longer matches the checkpoint files, so the release PR + # would misdescribe them. + echo "ERROR: the checkpoint refresh could not write $CHECKPOINT_REPORT; stopping." >&2 + exit 1 +else CHECKPOINTS_KEPT=1 echo "WARNING: an embedded quorum-proof checkpoint was not refreshed; see above." >&2 fi From 847c31413e790e59484a8c971ab5b83e5edd4316 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 29 Oct 2026 19:39:56 -0500 Subject: [PATCH 06/11] fix(sdk): authenticate Platform's Core height before refreshing checkpoints The refresh bounded the new checkpoint by a Core height that seed evonodes reported through `getStatus`, unauthenticated, so an operator holding most of a sample could raise it during a Platform halt. It now reads the height from proved `getEpochsInfo` responses: the height is part of the state a Platform quorum signs, and the quorum's key is proven from the current checkpoint by this crate's provider before drive-proof-verifier checks the GroveDB proof and the Tenderdash signature. A verified height is one Platform committed, so the highest is used and one answer is enough. Each node is verified in its own task, so a panic only loses that answer. `release.sh` now continues only when a network kept its checkpoint (status 1). A failed report, any other failure or an interrupt stops the release with the committed checkpoints restored, and after the refresh it runs the `checkpoints` test, so a pair left mismatched by a crash between the two renames is never committed. Tests use a captured testnet response and the relay proof of its quorum: the authentic height verifies, while a raised height, another quorum type, or a response without a proof is refused. Co-Authored-By: Claude Opus 5.5 --- .github/package-filters/rs-packages.yml | 1 + Cargo.lock | 1 + .../Cargo.toml | 4 +- .../rs-sdk-trusted-context-provider/README.md | 25 +- .../examples/refresh_checkpoints.rs | 379 ++++++++++++------ .../tests/data/platform-epochs-testnet.json | 6 + scripts/release/release.sh | 34 +- 7 files changed, 303 insertions(+), 147 deletions(-) create mode 100644 packages/rs-sdk-trusted-context-provider/tests/data/platform-epochs-testnet.json diff --git a/.github/package-filters/rs-packages.yml b/.github/package-filters/rs-packages.yml index bc0605fa49b..c34ec37d9a0 100644 --- a/.github/package-filters/rs-packages.yml +++ b/.github/package-filters/rs-packages.yml @@ -157,6 +157,7 @@ rs-sdk-trusted-context-provider: &sdk_trusted_context_provider # The refresh_checkpoints example's dev-dependencies, anchored further down. - packages/rs-dapi-client/** - packages/dapi-grpc/** + - packages/rs-drive-proof-verifier/** - *dash_async - *context_provider - *core_proof diff --git a/Cargo.lock b/Cargo.lock index 30e92621a89..623595c34a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6628,6 +6628,7 @@ dependencies = [ "dash-network-seeds", "data-contracts", "dpp", + "drive-proof-verifier", "futures", "gloo-timers", "hex", diff --git a/packages/rs-sdk-trusted-context-provider/Cargo.toml b/packages/rs-sdk-trusted-context-provider/Cargo.toml index d3729838985..ab661e803fc 100644 --- a/packages/rs-sdk-trusted-context-provider/Cargo.toml +++ b/packages/rs-sdk-trusted-context-provider/Cargo.toml @@ -107,9 +107,11 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus [target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] tokio = { version = "1.40", features = ["macros", "rt-multi-thread"] } -# The `refresh_checkpoints` example asks seed evonodes for Platform's Core height. +# The `refresh_checkpoints` example reads Platform's Core height from proved +# evonode responses. dapi-grpc = { path = "../dapi-grpc", default-features = false, features = ["platform"] } dash-network-seeds = { workspace = true } +drive-proof-verifier = { path = "../rs-drive-proof-verifier" } rs-dapi-client = { path = "../rs-dapi-client" } [target.'cfg(target_arch = "wasm32")'.dev-dependencies] diff --git a/packages/rs-sdk-trusted-context-provider/README.md b/packages/rs-sdk-trusted-context-provider/README.md index 4fa0543aaa3..b8b112f1325 100644 --- a/packages/rs-sdk-trusted-context-provider/README.md +++ b/packages/rs-sdk-trusted-context-provider/README.md @@ -267,29 +267,34 @@ checkpoint, one to the relay's tip and one to a block about a day below it (just past the oldest active Platform quorum's mining), verifies both, and writes the lower target as the new checkpoint. Each new checkpoint is proven from the one it replaces, so a refresh adds no trust in the relay. It also -rewrites -`tests/data/checkpoint-evidence-.json` from the proof: the target's +rewrites `tests/data/checkpoint-evidence-.json` from the proof: the target's raw header, its coinbase transaction and the coinbase's Merkle branch. The `checkpoints` test recomputes the block hash, the header's Merkle root and both roots from that evidence. `MAINNET_QUORUM_RELAY` or `TESTNET_QUORUM_RELAY` points a network at another relay serving `/quorums` and `/proofs`, for example a self-hosted one while -public mainnet `/proofs` returns 403. A network that fails keeps its +public mainnet `/proofs` returns 403 (the provider behind the Platform check +below accepts `http` only on loopback). A network that fails keeps its checkpoint, and the release prints a warning and records it in the release -PR; an older checkpoint stays valid and only makes proofs longer. +PR; an older checkpoint stays valid and only makes proofs longer. Any other +failure, including an interrupt, stops the release with the committed +checkpoints restored, and so does a checkpoint that no longer matches its +evidence. The relay's quorum list only suggests the height to ask for. The new checkpoint must be at least half a Platform quorum lifetime (about 12 hours) below both the verified tip and the Core height Platform reports, so the heights Platform reports after the release are above it. A relay cannot prove a ChainLock above the real tip, and one that hides its tip only makes the -checkpoint older. Platform's Core height, which stops while Platform halts, is -asked of up to 15 evonodes from the network's seed list whose probes passed, -spread across /24 networks in random order. The height used is the highest -one that more than half of the evonodes asked (and at least 3) report at or -above; an evonode that does not answer supports no height, so if too few -answer the network keeps its checkpoint. +checkpoint older. Platform's Core height stops while Platform halts. It is +read from proved `getEpochsInfo` responses of up to 5 evonodes from the +network's seed list: the height is part of the state a Platform quorum signs, +and this crate's provider proves that quorum's key from the current +checkpoint. Unless a threshold of a Platform quorum signed a false block, the +assumption behind every proof, a verified height is one Platform committed, +and no evonode can report a higher one. The highest verified height is used; +if no evonode gives one, the network keeps its checkpoint. A refresh relies on the same thing every proof does: that no quorum on the way from the old checkpoint signed a false ChainLock. A bad checkpoint would carry diff --git a/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs b/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs index 4130447e3be..889f54b7255 100644 --- a/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs +++ b/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs @@ -18,10 +18,11 @@ //! //! - the verified tip. A relay cannot prove a ChainLock above the real tip, //! and one that hides its tip only makes the checkpoint older; -//! - the Core height Platform reports, which stops while Platform halts. It -//! is asked of evonodes from the network's seed list, not the relay, and -//! the height used is the highest one that more than half of the answers -//! are at or above. +//! - the Core height Platform has committed, which stops while Platform +//! halts. It is read from proved responses of evonodes in the network's +//! seed list, each verified against a Platform quorum key proven from the +//! current checkpoint, so (unless a Platform quorum signed a false block) +//! no evonode can report one above Platform's own. //! //! `MAINNET_QUORUM_RELAY` or `TESTNET_QUORUM_RELAY` replaces a network's //! public relay, which must serve both `/quorums` and `/proofs`. A network @@ -30,12 +31,17 @@ //! release PR; if that write fails, the exit status is 2. use dapi_grpc::platform::v0::{ - get_status_request, get_status_response, GetStatusRequest, GetStatusResponse, + get_epochs_info_request, GetEpochsInfoRequest, GetEpochsInfoResponse, }; +use dapi_grpc::platform::VersionedGrpcResponse; +use dash_context_provider::ContextProvider; use dash_core_proof::{bootstrap, rpc_hex, QuorumProofChain, State, MAX_PROOF_BYTES}; use dash_network_seeds::{PlatformLiveness, PlatformStatus, Reachability, SslStatus}; use dpp::dashcore::sml::llmq_type::network::NetworkLLMQExt; use dpp::dashcore::Network; +use dpp::version::PlatformVersion; +use drive_proof_verifier::types::ExtendedEpochInfos; +use drive_proof_verifier::FromProof; use rs_dapi_client::{AddressList, DapiClient, DapiRequest, RequestSettings}; use rs_sdk_trusted_context_provider::types::QuorumsResponse; use rs_sdk_trusted_context_provider::{get_quorum_base_url, VerifiedHttpContextProvider}; @@ -44,6 +50,7 @@ use std::collections::BTreeMap; use std::hash::{BuildHasher, RandomState}; use std::io::Write; use std::net::IpAddr; +use std::num::NonZeroUsize; use std::path::{Path, PathBuf}; use std::process::ExitCode; use std::str::FromStr; @@ -52,9 +59,8 @@ use std::time::Duration; /// Largest `/quorums` body read. A real list is a few KiB. const MAX_QUORUMS_BYTES: usize = 1 << 20; -/// Seed evonodes asked for Platform's Core height, and how many must answer. -const STATUS_NODES: usize = 15; -const STATUS_ANSWERS: usize = 3; +/// Seed evonodes asked for Platform's Core height. +const PLATFORM_NODES: usize = 5; /// The exit status when the `--report` file could not be written. const REPORT_FAILED: u8 = 2; @@ -150,7 +156,8 @@ async fn refresh_embedded(client: &reqwest::Client, network: Network) -> Result< Ok(relay) => relay.trim_end_matches('/').to_string(), Err(_) => get_quorum_base_url(network, None).map_err(|error| error.to_string())?, }; - let (platform_height, answered, asked) = platform_core_height(network).await?; + let (platform_height, answered, asked) = + platform_core_height(network, &relay, ¤t).await?; let message = refresh( client, network, @@ -161,69 +168,145 @@ async fn refresh_embedded(client: &reqwest::Client, network: Network) -> Result< ) .await?; Ok(format!( - "{message} ({answered} of {asked} seed evonodes answered)" + "{message} ({answered} of {asked} seed evonodes gave a verified height)" )) } -/// The Core height Platform reports, as more than half of the seed evonodes -/// asked report it, with how many answered and how many were asked. -async fn platform_core_height(network: Network) -> Result<(u32, usize, usize), String> { +/// Platform's Core height, authenticated, with how many seed evonodes gave +/// one and how many were asked. +/// +/// Each evonode is asked for a proved `getEpochsInfo`. Its metadata's Core +/// height is part of the state a Platform quorum signs, and the quorum's key +/// is proven from `current` through `relay`. Unless a threshold of a Platform +/// quorum signed a false block, the assumption behind every proof, a +/// verified height is one Platform committed, so no evonode can report one +/// above Platform's own. The highest verified height is used, and one is +/// enough. +async fn platform_core_height( + network: Network, + relay: &str, + current: &State, +) -> Result<(u32, usize, usize), String> { + let provider = VerifiedHttpContextProvider::with_checkpoint( + network, + current.clone(), + vec![relay.to_string()], + NonZeroUsize::new(16).expect("nonzero"), + ) + .map_err(|error| error.to_string())?; let settings = RequestSettings { connect_timeout: Some(Duration::from_secs(5)), timeout: Some(Duration::from_secs(10)), retries: Some(0), ..RequestSettings::default() }; - sample_platform_height(status_nodes(network), |uri| async move { - let request = GetStatusRequest { - version: Some(get_status_request::Version::V0( - get_status_request::GetStatusRequestV0 {}, - )), - }; - let client = DapiClient::new(AddressList::from_str(&uri).ok()?, settings); - status_height(request.execute(&client, settings).await.ok()?.inner) + sample_platform_height(platform_nodes(network), |uri| { + let provider = provider.clone(); + // Each node gets its own task, so a panic while verifying one node's + // answer only loses that answer. + let ask = tokio::spawn({ + let uri = uri.clone(); + async move { + let address = AddressList::from_str(&uri).map_err(|error| error.to_string())?; + let client = DapiClient::new(address, settings); + let request = epochs_request(); + let response = request + .execute(&client, settings) + .await + .map_err(|error| error.inner.to_string())? + .inner; + authenticated_height(network, &provider, request, response).await + } + }); + async move { + ask.await + .map_err(|error| error.to_string()) + .and_then(|answer| answer) + .map_err(|error| format!("{uri}: {error}")) + } }) .await } -/// Ask every node with `ask` and take the height more than half of them -/// support. A node that does not answer supports no height. +/// A small proved request: the first epoch, which always exists. +fn epochs_request() -> GetEpochsInfoRequest { + GetEpochsInfoRequest { + version: Some(get_epochs_info_request::Version::V0( + get_epochs_info_request::GetEpochsInfoRequestV0 { + start_epoch: Some(0), + count: 1, + ascending: true, + prove: true, + }, + )), + } +} + +/// The Core height in `response`'s metadata, once its proof verifies against +/// a quorum key `provider` proves. +async fn authenticated_height( + network: Network, + provider: &VerifiedHttpContextProvider, + request: GetEpochsInfoRequest, + response: GetEpochsInfoResponse, +) -> Result { + let proof = response.proof().map_err(|error| error.to_string())?; + let metadata = response.metadata().map_err(|error| error.to_string())?; + let height = metadata.core_chain_locked_height; + let quorum_hash: [u8; 32] = proof + .quorum_hash + .as_slice() + .try_into() + .map_err(|_| "a quorum hash of the wrong length".to_string())?; + // The verifier only reads the provider's cache, so prove the key first. + if let Some(fetch) = provider.fetch_quorum_public_key(proof.quorum_type, quorum_hash, height) { + fetch.await.map_err(|error| error.to_string())?; + } + // The version only picks how the GroveDB proof is read; the quorum signs + // the version and the root hash, so a wrong pick fails the signature. + let version = PlatformVersion::get(metadata.protocol_version) + .unwrap_or_else(|_| PlatformVersion::latest()); + ExtendedEpochInfos::maybe_from_proof_with_metadata( + request, response, network, version, provider, + ) + .map_err(|error| error.to_string())?; + Ok(height) +} + +/// Ask every node with `ask` and take the highest height. Each is +/// authenticated, so any answer is safe, and one is enough. async fn sample_platform_height( nodes: Vec, ask: Ask, ) -> Result<(u32, usize, usize), String> where Ask: Fn(String) -> Answer, - Answer: std::future::Future>, + Answer: std::future::Future>, { let asked = nodes.len(); - let heights: Vec = futures::future::join_all(nodes.into_iter().map(ask)) + let (heights, errors): (Vec<_>, Vec<_>) = futures::future::join_all(nodes.into_iter().map(ask)) .await .into_iter() - .flatten() - .collect(); + .partition(Result::is_ok); let answered = heights.len(); - majority_height(heights, asked) - .map(|height| (height, answered, asked)) - .ok_or_else(|| { - format!( - "only {answered} of {asked} seed evonodes reported Platform's Core height; \ - more than half must agree" - ) - }) -} - -/// Platform's Core height from a `getStatus` response, if it has one. -fn status_height(response: GetStatusResponse) -> Option { - match response.version? { - get_status_response::Version::V0(status) => status.chain?.core_chain_locked_height, + match heights.into_iter().flatten().max() { + Some(height) => Ok((height, answered, asked)), + None => Err(format!( + "none of {asked} seed evonodes gave an authenticated Platform Core height{}", + errors + .into_iter() + .find_map(Result::err) + .map(|error| format!(", for example {error}")) + .unwrap_or_default() + )), } } -/// Up to [`STATUS_NODES`] seed evonodes whose Platform probes passed, taken -/// in turn from each /24 network in random order, so that one operator's -/// block makes up as little of the sample as the network allows. -fn status_nodes(network: Network) -> Vec { +/// Up to [`PLATFORM_NODES`] seed evonodes whose Platform probes passed, taken +/// in turn from each /24 network in random order. Their answers are +/// authenticated, so the spread is only so one operator's outage leaves +/// others to ask. +fn platform_nodes(network: Network) -> Vec { let random = RandomState::new(); let mut networks: BTreeMap> = BTreeMap::new(); for seed in dash_network_seeds::evo_seeds(network) { @@ -250,10 +333,10 @@ fn status_nodes(network: Network) -> Vec { }) .collect(); let mut chosen = Vec::new(); - while chosen.len() < STATUS_NODES { + while chosen.len() < PLATFORM_NODES { let before = chosen.len(); for nodes in &mut networks { - if chosen.len() < STATUS_NODES { + if chosen.len() < PLATFORM_NODES { chosen.extend(nodes.next()); } } @@ -272,16 +355,6 @@ fn probe_passed(platform: &PlatformStatus) -> bool { && platform.ssl == SslStatus::Valid } -/// The highest height that more than half of the `asked` nodes, and at -/// least [`STATUS_ANSWERS`], report at or above. Raising it takes more than -/// half the nodes asked, whatever the others do; a lagging node only lowers -/// it, and a silent one supports nothing. -fn majority_height(mut heights: Vec, asked: usize) -> Option { - let needed = (asked / 2 + 1).max(STATUS_ANSWERS); - heights.sort_unstable_by(|a, b| b.cmp(a)); - heights.get(needed - 1).copied() -} - /// Move `current` forward through `relay`, below the Core height Platform /// reports, writing the new checkpoint and its evidence under `crate_dir`. async fn refresh( @@ -849,91 +922,136 @@ mod tests { assert!(unchanged(&dir)); } - #[test] - fn should_take_the_height_more_than_half_the_nodes_asked_reach() { - assert_eq!(majority_height(vec![], 15), None); - // Two raising it while one honest node answers: 3 of 15 is no majority. - assert_eq!(majority_height(vec![900, 900, 100], 15), None); - assert_eq!(majority_height(vec![100; 7], 15), None); - assert_eq!(majority_height(vec![100; 8], 15), Some(100)); - // Seven of fifteen raising it cannot move it; eight can. - let mut heights = vec![900; 7]; - heights.extend([100; 8]); - assert_eq!(majority_height(heights.clone(), 15), Some(100)); - heights[7] = 900; - assert_eq!(majority_height(heights, 15), Some(900)); - // Lagging nodes lower it only once they leave no majority above; - // half of an even count cannot raise it. - assert_eq!(majority_height(vec![100, 100, 1, 100, 100], 5), Some(100)); - assert_eq!(majority_height(vec![100, 1, 1, 1, 100], 5), Some(1)); - assert_eq!(majority_height(vec![900, 900, 100, 100], 4), Some(100)); - // Never fewer than three answers. - assert_eq!(majority_height(vec![100, 100], 2), None); - assert_eq!(majority_height(vec![100, 100, 100], 3), Some(100)); - } - - fn status(chain: Option>) -> GetStatusResponse { - use get_status_response::get_status_response_v0::Chain; - GetStatusResponse { - version: Some(get_status_response::Version::V0( - get_status_response::GetStatusResponseV0 { - chain: chain.map(|core_chain_locked_height| Chain { - core_chain_locked_height, - ..Chain::default() - }), - ..Default::default() - }, - )), + /// A real proved testnet `getEpochsInfo` response, its Core height, and + /// the relay proof of its signing quorum from the fixtures' checkpoint. + fn platform_fixture() -> (u32, GetEpochsInfoResponse, Vec) { + use dapi_grpc::Message; + let value: Value = + serde_json::from_str(include_str!("../tests/data/platform-epochs-testnet.json")) + .expect("fixture JSON"); + let bytes = |key: &str| hex::decode(value[key].as_str().expect("hex")).expect("hex"); + ( + value["core_chain_locked_height"] + .as_u64() + .expect("a height") as u32, + GetEpochsInfoResponse::decode(bytes("response_hex").as_slice()).expect("a response"), + bytes("quorum_proof_hex"), + ) + } + + /// Authenticate `response` with a provider whose relay serves the + /// fixture's quorum proof. + async fn verify_platform_height(response: GetEpochsInfoResponse) -> Result { + let (checkpoint, _, _) = fixtures(); + let (_, _, quorum_proof) = platform_fixture(); + let provider = VerifiedHttpContextProvider::with_checkpoint( + Network::Testnet, + checkpoint, + vec![relay(Box::new(move |_, _| (200, quorum_proof.clone())))], + NonZeroUsize::new(4).expect("nonzero"), + ) + .expect("a provider"); + authenticated_height(Network::Testnet, &provider, epochs_request(), response).await + } + + fn metadata( + response: &mut GetEpochsInfoResponse, + ) -> &mut dapi_grpc::platform::v0::ResponseMetadata { + match response.version.as_mut().expect("a version") { + dapi_grpc::platform::v0::get_epochs_info_response::Version::V0(v0) => { + v0.metadata.as_mut().expect("metadata") + } } } - #[test] - fn should_read_platforms_core_height_from_a_status_response() { - assert_eq!( - status_height(status(Some(Some(1_581_918)))), - Some(1_581_918) - ); - // A node whose Drive is down reports its chain without the height. - assert_eq!(status_height(status(Some(None))), None); - assert_eq!(status_height(status(None)), None); - assert_eq!(status_height(GetStatusResponse { version: None }), None); + #[tokio::test] + async fn should_authenticate_platforms_core_height_from_a_proved_response() { + let (height, response, _) = platform_fixture(); + assert_eq!(verify_platform_height(response).await, Ok(height)); + } + + #[tokio::test] + async fn should_refuse_a_proved_response_whose_core_height_was_changed() { + let (height, mut response, _) = platform_fixture(); + // One block higher still gets the quorum's key, but the quorum + // signed the real height. + metadata(&mut response).core_chain_locked_height = height + 1; + let error = verify_platform_height(response) + .await + .expect_err("a raised height"); + assert!(error.contains("invalid signature"), "{error}"); + } + + #[tokio::test] + async fn should_refuse_a_proof_signed_by_a_quorum_of_another_type() { + use dapi_grpc::platform::v0::get_epochs_info_response::{ + get_epochs_info_response_v0, Version, + }; + let (_, mut response, _) = platform_fixture(); + let Some(Version::V0(v0)) = response.version.as_mut() else { + panic!("a version"); + }; + let Some(get_epochs_info_response_v0::Result::Proof(proof)) = v0.result.as_mut() else { + panic!("a proof"); + }; + proof.quorum_type = 4; + let error = verify_platform_height(response) + .await + .expect_err("another quorum type"); + assert!(error.contains("is not a Platform quorum"), "{error}"); + } + + #[tokio::test] + async fn should_refuse_epochs_returned_instead_of_a_proof() { + use dapi_grpc::platform::v0::get_epochs_info_response::{ + get_epochs_info_response_v0, Version, + }; + let (_, mut response, _) = platform_fixture(); + let Some(Version::V0(v0)) = response.version.as_mut() else { + panic!("a version"); + }; + v0.result = Some(get_epochs_info_response_v0::Result::Epochs( + get_epochs_info_response_v0::EpochInfos::default(), + )); + let error = verify_platform_height(response) + .await + .expect_err("no proof"); + assert!(error.contains("proof"), "{error}"); + } + + #[tokio::test] + async fn should_refuse_a_response_without_a_proof() { + let error = verify_platform_height(GetEpochsInfoResponse { version: None }) + .await + .expect_err("no proof"); + assert!(error.contains("version"), "{error}"); } #[tokio::test] - async fn should_count_silent_nodes_as_supporting_no_height() { + async fn should_take_the_highest_authenticated_height_and_need_only_one() { let nodes = |count: usize| (0..count).map(|node| format!("node{node}")).collect(); - // Eight of fifteen answer, two of them high: the honest six still - // leave no majority for the high value. let answers = |uri: String| async move { - match uri - .trim_start_matches("node") - .parse::() - .expect("an index") - { - 0 | 1 => Some(900), - 2..=7 => Some(100), - _ => None, + match uri.as_str() { + "node0" => Ok(100), + "node1" => Ok(105), + _ => Err(format!("{uri}: unreachable")), } }; assert_eq!( - sample_platform_height(nodes(15), answers).await, - Ok((100, 8, 15)) + sample_platform_height(nodes(5), answers).await, + Ok((105, 2, 5)) ); - // Three answers of fifteen, two of them high, decide nothing. - let error = sample_platform_height(nodes(15), |uri: String| async move { - match uri.as_str() { - "node0" | "node1" => Some(900), - "node2" => Some(100), - _ => None, - } + let error = sample_platform_height(nodes(5), |uri: String| async move { + Err::(format!("{uri}: bad proof")) }) .await - .expect_err("no majority"); - assert!(error.contains("only 3 of 15"), "{error}"); - let error = sample_platform_height(Vec::new(), |_| async { Some(100) }) + .expect_err("no authenticated height"); + assert!(error.contains("none of 5"), "{error}"); + assert!(error.contains("node0: bad proof"), "{error}"); + let error = sample_platform_height(Vec::new(), |_| async { Ok(100) }) .await .expect_err("no nodes"); - assert!(error.contains("only 0 of 0"), "{error}"); + assert!(error.contains("none of 0"), "{error}"); } #[test] @@ -943,8 +1061,11 @@ mod tests { host.rsplit_once('.').map(|(subnet, _)| subnet.to_string()) }; for network in [Network::Mainnet, Network::Testnet] { - let nodes = status_nodes(network); - assert_eq!(nodes.len(), STATUS_NODES, "{network}"); + let nodes = platform_nodes(network); + assert!( + !nodes.is_empty() && nodes.len() <= PLATFORM_NODES, + "{network}" + ); let mut unique = nodes.clone(); unique.sort(); unique.dedup(); @@ -960,7 +1081,7 @@ mod tests { .collect(); let asked: std::collections::BTreeSet<_> = nodes.iter().filter_map(|uri| subnet(uri)).collect(); - assert_eq!(asked.len(), blocks.len().min(STATUS_NODES), "{network}"); + assert_eq!(asked.len(), blocks.len().min(PLATFORM_NODES), "{network}"); } } diff --git a/packages/rs-sdk-trusted-context-provider/tests/data/platform-epochs-testnet.json b/packages/rs-sdk-trusted-context-provider/tests/data/platform-epochs-testnet.json new file mode 100644 index 00000000000..1eb146b74af --- /dev/null +++ b/packages/rs-sdk-trusted-context-provider/tests/data/platform-epochs-testnet.json @@ -0,0 +1,6 @@ +{ + "note": "A proved testnet getEpochsInfo response (epoch 0) and the relay proof of its signing quorum from the proof-testnet.json checkpoint, for refresh_checkpoints' authentication tests.", + "core_chain_locked_height": 1581959, + "response_hex": "0a890d12de0c0ab30b0100fb010001f9451c63ac339afe736dcacf6f9a9ca784779dbc72603890435f437b6d4df684020cf08f9afe7462c2aded3c6b4a62fb97ac05a49c61f6f87f843ae676966ca46f10011e82337b080e784e639a151623daf0aac0b4bf66975d8439de7343235b2f7f2a040130000f0401022a77fd000004cdb76ff56000ba2a2a1984c311d9cfbe49559cda0e6c4544d6835b859a34fa994885761b06ac1001ca180f71ab1d89e7c6cb2911b0d2603b471215234066abd57de9d0f6b5af68ab1111027d9e392ad52b7d6f2846aa583ee9ef956b3e3f02650e6c573255212662d5645b1001f3cdb7c79b1d057fd50a24e2722992aba42c0a20bada1f54e70ab821ec3889031101013000fb0454040201000006040101740000518145a42e63455d53d9fe124910d8d6e0c7617f6abbcaec65c50b739ace0a7a0402010100060401017300003d410791d0065ae497a80d7204e98c06d13737487d87e3d8ee7c8fce8ce0ef5510040201020006040101730000b6500a7feed4ef08a27067207ab9aa7135a0a503962f1a213c4a8b4036e539d111040201030006040101730000b6500a7feed4ef08a27067207ab9aa7135a0a503962f1a213c4a8b4036e539d110040201040006040101730000b6500a7feed4ef08a27067207ab9aa7135a0a503962f1a213c4a8b4036e539d102749bf1c80c2bc86463c92f050f3a4925ed1c0377b19a09c342ee9dfefcf3211110012f570f81bbaaa5698ade69272390bc9765d4b6bb242f53477d274f862e10cbfc111102de338e64c5994559c0181b11e474ea455db248acfb695d7077b130fd6e8f63ed10013fcc19108bb2cbaa45e05288c0e322177172025d03679d4b4ef880934a43d1c91102aeaf21eb72b37a33dbac8df8cb4cdf87c6404112d7b56dae0cb6976ee79b598010010883f8563615a0942dbce515ca8b207314db447de88083b5c92d7b85d10ae9081102cc567bf63c70b89743d9faea7b009f10a310878d124ce1b33745c7dfa15f54a310018dc8bc8bda067446a8b362f9e94827f6574ccc729ed93d4615b769229351e25b11024633c5011e096854946e072bfb2e049d98ae0a606286f80cacd16129f80ffa491001766b613b5560a58068173682a866a750d4b0672ae4384028682b1ec29d416f6b1102cabac2f1d6b9557ba9e5051906fb01cb51617b60e6d3081d68fdee6cd88089651001a480087dd89a2dd49e750d04a9b2df9f07c40e37da404d919ed3a9ae8ed944e91102acb129e4276fd108b87b5f90d5775a80cbe2833d4b9f78605738805d9fd4d9d01001b4aa2f893331aa88ca7eeaae980973f8c0426727858eb31a04f88fbf7ec8f2ac11029a053ee11e80b24021ca3fe9899e56edf3f5411af814ba08ce5dddb889a26096100122e446d2d3cade3f87a929f02c1e02fc88621ffd0efb9fd8915d40cc76f46dd71102a523fd5897b341ad7a030b6ee37da9058c63a7259b22fdec7625bef49725259e100196d2e9fac271a365db6c20b944804e9834863399403c8b786a7236a7bb0f399f110291c1e6658e0925ffbde7feaaaeb2be3360ee1a6a21b9e8672de84b3aba2c31521001a57d97e5794c74f528814d2319970e2602eef944b51d15798e2bd601804e2b3c11028c7013693ecf766c067ad796e48def0c388c2790d78788980141eb7422bb2f2b1001f95bfc432ca95089b94b3f11c0276e7edf399d1146204dc210962453052410251102a11c771ba7926388aae31d60e1a5c13ba6cc9c762a74aa23c44b2a403ebc4b95100143e177571c4f8f0925d21c813a5e48df6727019a4933b853a7ff6a42139bc5de110216dd9709026d1eafa8549602781fad96f688e1ac0e8253c5652b4966b0e7163e100169b131b55240a843c542078ab22a3d590fa7ee6885c4ced4192d614109541bfd1101020100004c030163000700040010a30f00030168000b000800000000000000010010030174000b000800000190c8a584a80010030176000700040000000100030178000b000800000000000003e80011110012200000004a032cd0cff0c379d6ce41b51580c46d425894b9a810c5c03f0b34687c1a6089a963751ca29f5c5339f69e06dd7de997e891e6b42d5621f1ae7b527704eb18d7e4aef14cbb5e79f7fa1b4e42fe52ad0539e2f494edf9499b69946434bdce00cbf44fc29b8e7df80a9daaeef62fdc43439a05614481384bee5f76aafb48d26d2a20747adf55c1d08eea5965213674473523a74860751e5d09465098270eb8eda92630061a2608e7ef271087c76018969c0120a5fdd1d19834280e320f646173682d746573746e65742d3531", + "quorum_proof_hex": "49310000444153484e433032017007180062bbe124d6aa0bc64fad93fd32ff9bd6b6fd805d74c5c4b11a7ea148660000008cafb54808bd6207875b674b7cabfa62f05928a85225ccf8dc2c7dd5dd8397d1c5f2dce8209b615d406568ef8b467ba2873d42a13706531760d0ae25b1ec6b5e43010000030001a36c8f433399c91765a161579f3581042422e983846375e424f47c6f0000000032ffffffffffff0332ffffffffffff03a51893239a63e5eef0983af313b6712c537d987db09433697e4e20c465650edfa27d31415ed2374643bd7770a94f4bc72af5ca8a03f64397d0ca7e68912ec6bf025df2324e4788d8f75d16ddf302ea61a1fc610571f7c92b099285e466729bbbe9a9c0b4063df1b04d6a22380440c46b65813cb857e796c5aa2b69142ec57fbe0a617bcb712c85490247b1109ea68a8ae749bbd2e8acd1bee68532a63d0e686f07ee98ca6df3e3f1a1f6f74598a1aa5eb39fd68c61165bdd6a5a983a01e17bdbba73851ed52227c6110a65df8a2f1e1796eece4a0fcbfdf4fdaf960411787a110b814c44072f5581d844373d8ed731a677ec24a09caccc1124ef76fd515980cd8006a08aaa37f35d697e879ca2038a2d2b0000006d000000077a62b476b57fec70744a81f589950ce4bd04acae0bbbc8211c1bff4f3a336ff3bd03983ba422d5979a2358f2755c6dd1cbc3944a8245c0e0739c6ed0394ef3cfcd34ac5b429af38e488cc991591a9e0f4bf855b2101aa9aa3c72aec0d2ae47a8d512a037d78330b85a937eb9e15959bcb7b6ef1dd635ca68d4bb1426109826cc10f107c74afdf43fb344c5d26ddaa381a9177e7008f8b27c244f6d0dcad3bed3b6947349c68c99dedf77f7fff75ad54c22dd8a9731421a0e195716dc082f3c2d4068b9da7d03134f7cc1fc8305a7811c3e7dd08365578f04ebdd5222a0f1b7e90e00db08180000000020d4ee16f728e3d2dbe889fea7ef248d64073eca271206694bcf22762b0400000020e2dbcc49200759d9c39a72c79bb407d3df703e72d9b20dc3777895bced19eabe6ad56ae7c5001ea52902008df875ee3138dec51f26f46d5412893b07303bb9b1cbb15350f47080440e115a6daf1ad2e2b2f16689649737a1ecefec026c8899c01783d591878cad3c9606763d8ca59e06778d4c9f319cc2b2049a55ab7c03ff14debe95a3f75b441a86cf5e5601000003000600000000000000fd49010100db081800030001399999407c2e615d1b748723ac9359485754ac5f158e2280bdf38b601700000032ffffffffffff0332ffffffffffff0394c67dcaa70a91b31d281c380e051dc97cff3779cceb8b5db7fe222a3e2f6723ef290c9da57634ef70cd4b1cfcbc817f21bd736f00c1033629054cb78ecab8db3a011600d451bacdbc7187115d88aad894ba2dbea90e446c67568e0520dad89f4cf309cc9e4b5afbefea0bbf561104deca51dea17fe05cd12c60b50ef19b1fbd17f7a5a0c1b533c2605884bf7c033d2aaffbf9c4e5a567caffc017c7fc7bbfd7b177730dd1583bc939f9fcda49d7b08aa7bf05164244af9dd667be359ae5c7a893c17b958048692a14526515a7d7da91b34d09045982fc914b56124e48cea6931960612346a9bd3aba0bddf82e6c231bfd8a79d517ca008ac258d565442bb1e121f931b500f60ec5ce90a6b3c76d847b0100000004000000021e4ca4985cb770d03fc12067e78480c170b3eacdb9b604abb6661794209c590ea8d4e7f17877174ee36a084719cf458b22d2710c8d2b62325156d049241f6c1b00001d0b180000000020884032b52f4bb1f5a1e3d965c2aefa95c3b4fe78d9dd95c2d3888e6c73000000d42a14cc705c3bf036c749c951ae2abe79a0c40ad51356d5e8489ad0c3c8067187a5d66af4c3001eaaab090092af6b4a402484a3f58dce67c0ed86b16cd7ec1de519bf26895640c126113c781f461a024cdc6d9f2cee38a3b9406656163b7ccd72168697b67f62b6bf6271f75cbcf76b3ab68b88e729f41264104e3323d4a814f33fbcf51a855f817c0f8d5e5601000003000600000000000000fd490101001c0b180003000190e99d95e1f645f2bd29a1f6e63e04758df76a2af780206f928146430d00000032ffffffffffff0332ffffffffffff03b73d638e5471eac8c27d7967738f84927b294698092b3e2886eae0c688ee8914b91943f8f76e3d76a2ed6638639d1738264d90c59b16ce249a440ba7301224f547f9c4a679313637d4b541523a2ad7d88f42583b87bbadffd25b11a27663bd13377fc40c9c40b90e59dab2902f3d506feee61c5ecf1ca5378e0c5f0cc426a07908f2983bc6020aabf7922a8d4714390b7208b99d20e7bd1cd5c5d5e612921b2dc8bc9e11eae11066f5327fc86265bda2a3683dde2fa82fe757d6c749851b0984c220dcfd7c99aff2489a68c407e70fcb796884835d00d79f7b46b119b7bb081b19889141724f5244a22b0a18370cabb9a30cbcfd06984581176468fd093f48d7fb1332535f49883d0eea5540c81e86f6010000000400000002b7a94165dacb30f0632e740b5921ad330ea845361a1dfcf83e584f35468e57cfce7bc0bf4bdb874889e7d42488da85ccbdf53ddb92d430a2730b03d655f61f5701000000002088c25e56721e4bf7bd28641de90b890a97ed8d88e05d0ab69978246f9d000000f305bea6028bedf2495f5b48bcfe5bf07586fa94e7b559bdaaef4d0f8206c5ca60a5d66a27c9001e52e906005b0d1800000000203be80b2da319518146eb2da3eefced36550dd4b076ee07daa8112abcf100000046e44661b169dba55dbd68716d1ce8c3c96cad41b9b36b493a4e425146be3d0f59d6d76a523a011ead9b0e0086ac3100239ec4fb945145811c70c4027a32cb5bd56f54a5b953d9efeb1d810693fd49accfaa8846d558358d2f45ee970d142d9ce09693d428242f67b45f1ae2fd8169bc9e60227ecb9bb2bdae407c916a9554c2f81707aa6276e5ebdca6a50f5601000003000600000000000000fd490101005b0d180003000113e78d688eeda3aa4b037bcb150c2580ff72306bfc0f1317d15bb777b100000032ffffffffffff0332ffffffffffff03a3d73c18724f5e3abb500e2a9670518f44472ab2b1280ad25bf9e3f58d211e80c4fa8034037aa30550eb7605b7b4e2c30224e116b4eb30e0f71e00e46160d9578fd8999770a88d0a8f18743bf4208d9bb0f2e85dfdc21cc58fec1902d138eb938f884ce54aea718c6434f6869be5a221b8886316254bde0d1df669bd34d0a1670af073d982fd22a82a82060368438173c199dc9f3addfaffbbecd200d9dc4090e11f8a7b470772d02dccd7c4ae6b03cb829c4f37eae9a1e8c7336096fec8824487746b3a919065615393c4f4ea5680537122cc542ee21aaa40495e5975e4be8b193c507ac95b5ff20fdaca44d8f0af7f7872ad2ded15d526a2a759d472927b0bcfe7a72db94ade09415be081d774f357010000000400000002feb2ad28b1b1c04f18cb0f9813cfc6883d07a7a894f80724df57bf37af637fd9953914d183c09cd57e2c248f0bc61e359d9c525eaf6a4dc3fff9e6de89ff126e00006d0f180000000020e6ac9e6be33e61f672a8d3dc6a68af3c15011e880e232fe37a5266b13a000000e0f214c16568f276049465f28d6cc1a6ba54703277a7657714970d6117cf05db28fdd86a693d011e45b2020082887c682c2940a8427c33784cf5b9ab68001d0b3ae43f9fbee3253acaadcd4a868969fe5622619f2cda2007d2d1baaf14c5dc8b70c997a8a0287ef6957cb26af8508e597008cba73e3872921c5187276f6b086e57ccaa648b2193c4d2ecf2015601000003000600000000000000fd490101006b0f180003000135cfd63f1f955afe02842849a6711d8c44a1f609bf7a1d07c30ab868e300000032ffffffffffff0332ffffffffffff03a09ad2beec622c017f3a3a1ab0968e008302a37cfe9aeb3e0f1eaceb0b630f984044592d7a272bc5f4dcb664fedb19863b0aef37ee5d597e1985a755e523feec642801ba5041fad6b4c75ba2cf59fac0841e043e3cdd976f3edad1d23c7856111b9008753e8a106513d77865abd631f6bfd096c04aaa840ec0fa952b78430f7201ecca5eb903576379a0546824ec08cc542ee5cd043a989f43f0aa19651bc75bccd0c4ec6ba77be37051822322881b22b6e86547a1aa6957b8a52f6e402d172a2a4bd8e27450001aa97397959ea22cb4dc094b04a9c6554c5a8d136462d9f6f10ebaadcd94ae69ada8e34284f70e6a290f58bbe17d1a17ab3864fc555d1d35ab9e6b096004bc809f646eabfe743d5aad010000000800000003e3094b67368c1fe6f48c8a426df3dd95bd3cc0a5ed9826b3c0178b343d8a1d41d2b78b1fb412832023225bacbcc1d7ced5c8c18e5e627432d8b1c5eb4fbff101955333026f0961e7cf4303dbd455083abcece0dd00efab9fa901f4da8ad018d9020000000020ea43c0b8c89ba96f447d19608287e5006101c21315a59c5c934deed4030000001919f9794609e954f0cdbeaa195e055e2c4720eacaed53b58e683f2cd895c0efecfad86af50a011eb3280800000000201764588dbf63f9bf1d04c63ecf9b07fa45473a99dcafffcdf3df140064000000bd99f537925c7cd2144d3d74d2c2b5a6f6dac903b0771920239f52f705e3715911fdd86a3715011e4bdd060004111800000000205a8f44f3174593f01a81cdb5b73ea9bc4f01529dbdb867ce651fc05eb400000031d4116f9ac1c349eeb6e41ea5aad7fc8b704384d5aa17a7fe6c533c83f26eb832ddd96a2016011ef1a60d00a42d4d99e47c8df5113de94c1114723f2dd6ffbc29f45bf62a937af990b5f9f0d1f4a1fad6528d7bebc09c0d870c2b4b07b995a3ef10381ec6709f62d8ea0ffe04e835a271b876ff21a663ca576841d6c42eb666323f425b7a3c33166643619e5601000003000600000000000000fd4901010003111800030001b0c7b2e281ab081c9ed7865d6aafc202f0ea4e0dbc8c61e87ca21fba0601000032ffffffffffff0332ffffffffffff03a3bcd6f8055c980bfd61ad614fbb23a4b004810f08b28f2f4325d61958fc1e569c0f70dbfee75d13399c6ae3df57a14900f9bbbbdada18d432e15b3a4f578c7c98cb95895ef91bbe9859a148da4392ed85d4f00abbdbc2eec4d98ec5d8879e170a31490711a18af6acc6435933d9fbf80d043f2d3cd378fe27b63f0fdec8c6d019aebaa46d27fa6a4dcb37db521a6a19e4b93d28382cf433ea4ff2aa3feac7b7cff1076f1ac77a581f98bea8be40ce2282b96cbe2111f8a67292e4f4e6febb39b8f2135105b9a509f4bb3067a8273f783a98bc0786cf360cecc7f5680b8aaebb034f6dcf43110de02b7d763cd768dbea9288bbf856c8794660649cbfc28d3c6f7da26556174e50c7cedd92c40a09346b010000000400000002e8277a2be0f7a529a1ac2d0a4ce22569e5543b1ba5c926fd80cf6ebf26543a6df8a442e481ebaf3fa7e21767dc4ee04bfd238490c0eac41b64d43b0865eba7c9010000000020f4295b4438d59c9363a206cdab2a3ccf685221db4cdc1ab58d670867a7000000e1eb2ece5e4ef3e747ab905ef092ddc4abee6029f80a4249ca4774b00872e0b61bddd96a4a1e011e606906004313180000000020a3341868203ded8202621208d2b1f15f6278dc5c4f460af1576ddf8342000000ed98aad8b99173694d06ff136e09798fba9a8226ca503efe2d1bd2ace9a835808313db6a3f74011e29340d0081654d6cc0b8e31b07e5fb439689b3097467af284d4c4b92e6a0c66e7d425b9087bc6be0aa223489693800ee670c5ccd10644060d1b91ace439e7a9780db85a641b09d12ec7f0c0955037ce0d3bdeed75a1d88b51ec0acf15736211590f954905601000003000600000000000000fd4901010043131800030001cb284f39cb5ea6e28d0354edb8f8674aa4dec8b1efa4b920bb74dd9fde00000032ffffffffffff0332ffffffffffff03a1c6a9ba74cf9268322255be1ba10b63bfccb75255e1486f457d74f8b974efdb1f5548e2565dae8299d8c98cf4a9f01d90cd51e00432000a1c56e2bb1df149e9a56137e7ce86128305c9876a656594fb9356877448e1a377f447ae0025043f7710a936e4fb8ada33c72f89ae219d148034d0c36c12b68892ec4a87501b85c6f20aa31721915cf20773f280f27179d8a05d1d1e04afb16605f1dcc9dc13158a51f85722e1d5dd827dc371c66743c12047a67c4b682f7268c151d1a6fbcdb8e26b6dae33a588cd4568f2425757461a1413d97162631aedc849093efea1598f869d061478bf3b29212cd6240ec799a7054cc623a26e0f649185d9f937e0eec6cfd4b03f4981e4c3b05e5918c00e73af055e0100000004000000022fd98250a38b49870143d550afde85fd6adc5a0339e37d4728f1b1f131c8e56ed3b6af49207220df276b20a77077ab5b8d0e8703ac8d2fcc175d4898bd90b4eb000083151800000000203bcab625167aeebd092f25e24b2c50757aee7d5dd6d6e9535ee355a4670000009927d9f340ccf24d989f2a92208cf5180128f67ae7389a569227e41715c19dc1fe58dc6a5485011e234c0b0094f5385cc7587b3659717dda8f8147a50d35fe58dc77392df2ca9ae378813f1543292ac7040c13c6dc9c5f735a6658d8050581b1217363e60f3039c2fb5ec59ad33830b0e03027c3c045af03be0383812ecb9207b1c4be8bd79ebb22851d55355601000003000600000000000000fd4901010083151800030001d3bded84637e1abbec27152b0f63ce05f64eb09dcf40b1f9dd48f6b72400000032ffefffffffff0332ffffffffffff03b96cd4c0a09afd95e1e9eb8e785b00e19918e4d748038cdf7429a0cd34a172d176f087074b295d7811c7147e04240fe383955ffbe210848bd6a0c928bacf3c2bdba86e8045d2250a453249a9189ae77097edc93344e1ed0b224507fb65388d1bed46242878de4ae9e3811969d05f6a6c26d7f92333f3d30fd0f9a57b934f0d121435be5ed53d895c9fc0430d8bf002cd21040d13cfb4330b0c9a8bdfc0107121183d38ae79f0a3b990f62a8905484d2da76b4d93450c7d2c747e49be5d1785eea9ca74ba5dca3e01feb1ae0ac248e99bd059478b5812ef35f84f70b2757dd2b9125b208fdaf6f8d8418a6bceecf247f676de955bd3f19a6d24da944187be6309d21b6893f7db3aac858c19238d4138b3010000000500000003caf77c38f3640a0f6e5099bcf786948cb0d38a80d7152ee8d049a4edb533564ca2767577c0263fcefc4cf701a8390d967400c24ac12c0210ec7264d1e5fd79049f417115da2e4a81ce7446fd98e8ad0cd1ec7f2a9b39164952356f2d77db77d600009317180000000020b11c178bf9f6f27e17befc0834b7ae023680bb7be99bb769651412d23c00000012fec1208066ef75e1321503acdca40e9c3e518c2007ba9fee16334c5869ce756176dd6a5ec8001e322c01008251768b43db38797624226710a499f71d2a09047e9731d65ce319f02d2b8090299e7c9beabdd658524896ad421e929a0152ffb557a0da9dbb1db0c9fc73609d7697f63730d471204042e03a856e58447d78e2e5ac5ade0fecfa6c6b4017bb565601000003000600000000000000fd490101009317180003000187dd321749ae4f4921cdd00a7472fc03fcdd3b061bf250a033f36be25900000032ffffffffffff0332ffffffffffff03b918ef595cabf2a05257484b85f17374fe23c3a5e31ba461ae895da39da548e72a7c4b74a9939856944f6a2ed6258c879d039aa9ab63f1c000fbf346b80fd218abccbcbbfc7897ab438da5d67c240ea995b08914b479510b835b0296a4c12fa50d0e34dcb3614f03ca8d73f8d26bad7c09278264c29a21330f32c8046180bba90c320ef31bbddf05f3dae13634479c72c24995f9ac2e7422bfecb5bd13b0d9f3a3c9f88a717427612253bb44d2a7691bb1efa1640e68de2bdf6ca331b2d43a0543c9ba4b0c1143b0449ca93455795c10757c42660e806aae00371e73f63868b90361fbe96addc143310db1d965f3cf46ba0d9fc0c85fe4bdb333fd6e4959a3779bb6c0e3d2bcc2f6b628f276cadaad090100000005000000035d51f04b38e96685a807948c4bf75f006c1aacdeb91091742b3a5c340e2866540728ae8a72c9cc9110a4edf4ee910e481566a64a96e4e04ed598f8c0aa0b13166185ad9ec8b81d5d6fcecaccd2f09978256cd16a11826efbf66625418abcc2640000a5191800000000209d41a1534c4197b85221e7569571daa991dcb269aca4ca0f562070b4d90000005bfad1d7535bdabb8e9400b407cb4580641c32400a0bd2e352b780ff6e7cf167119dde6aac00011e82020b008d883d1ddcee0143754a740dca1fe02d26c82643fc82a366fb6f9e30bbe87df9dd1ee2a09e766f52d003050db1e514b216013e3aa247852028e226e1c2eedfade001e9427818f25370e86d12cdcf5fabb2ef15d7896434e5992719233955a2725601000003000600000000000000fd49010100a3191800030001ad83bfbaf2c5eb05f8b31e2bc8aa5c48930e7c99a4220c379c984ad70201000032ffffffffffff0332ffffffffffff03a18bf4d78d2d851aaba084b2de0a6a01d5df6bc0ec10e60e405a74660afaa0e2874fd028a87dce60390fda90ef3bde90bb134a44c9550dba7a274e847eef886f3e265b22b2d99288e4c9118b52f1caeeadf71dd1f3a1ac2221b5ec3d4b12a6561d98cf4d9a7b27ce653ec733f67352f0a04e4c73ea2cce45a566362f9ef3f2a916a16168b76d6a9ebe0854693b1a85bed84a33db1699194ad338cc0698a2d7a8977f3f3f4cb01fcbcba43d8aa02630b183be8afcfda8e736bcbf075b14910bf25db3b5b6b5990c0035a40e0cba8b9cb91b082e727d2c11978c297ed10d0c6c57170565736ea956b7fe28300629b04780521ae419697bc2eee76991f238e025e80b52db6a6a22d5b778acb7dd423259000100000006000000037798fbcc82bd4a857a05cfd1835326c0ca16d40af1e86f6bcae6941c9a9e6840b234ff95c9f43c20346fd9cedb4dcfa6068381e67f23dd4aab2b65e21577162dd39102e2f27b2df06512864c573487ef7a24e227e6caed4786f25dfbcc6763e4020000000020132a5a6a06be1e95cfb73f0e241a65119d8e18d26f5a5c0a7402cb7e07000000ef087389c837b26fc6179ecda54800150ff0c0f3733a2c7b591154857747443f7d9bde6ac2fb001ee1850e0000000020127849bb54a9c7a50ca662151127de91212d0b6cdc5f384f1696e8fdd20000007e438aa343bfb642faac4c60957e8bfdbf7141bd4b0f050a1c5e4888651d6a9ef49cde6aaafe001eb0f30600e81b1800000000204b0a090d508a0e5086440e9d73ddb274021919fe683403c2f5689f861c000000dff5af6ffde7e8f948494e475722ef220ede01c8ee75bcbad9995f5a15a1904672d2df6a4ace001e589d0200a5ebb53634ad43263e0caf7054e1965c4f17a41c95e37c61c456056a4e7d5a20ec68df4b5a30d4f8e1fc3bf7f3053a2d0c0f3943e67036c0da12d1c664efbbe3a65ba685d3b1b00aecdfcf631d2d84fca654a2a9f1b7077497ac52e05ff5f6d25601000003000600000000000000fd49010100e31b18000300018bcc929986c96aa428ac63ce4bd25520244172325ef4e28759aa95351700000032ffffffffffff0332ffffffffffff0391d53a49af51a781573bfa563817cebff72ca951c4c8d8955d5a72fd80b2dfd8f5d98828104da4446adf2f84ab7aa414f6083f6e0cd37cb1f957538cbb5087dd80032dd69002f40e8740bbf7081f4a99a90051e8fb327247feeb4120c9edeefd8cdd33b206072ca577f1d80d17fc1dfe9f0f07583c604766de84d4477c5cac4a14e0e0d49c30d110c7127af43a6f0ec6ad57eea803c5817a99de595444d11237ecaf2fea6bca9925c2bb922c243bea8183080d49465a181fc48c357a78ebfd9c487e8c8f08e7aa67714ea2884854b4f3596e0af66568bdf6bad358ee02b89f3a0caa4c2e9089661d2c2fde0bc4fb3463cee9aa8c704fe4c1e03517b159966fb3efcc48cdb5f4d75202fee4e2bba0f16c010000003400000006daf10af0ed2d20e8dafb9fbb635f9e7b0803dcdd7fdf603bdc234d963df14c81ea38c0c8b415e448069cec255a74f5d6576059bf64ea2ce00259ce57e586f33e6cb9335a4d88eca612825ddb3e7d20e0bcd376251f845d85da78c8c076ed96fe4a55ac61812c9b462ab77e2ca803779d8935bf31cc2a6817e9e8d10e0f88336fcea1e08263ff92bfa098f514908e7a01dccffdd8a5c19b55a5531424a63635a9891d8d06b2990a6a256803b39abca4cc6ff29189d47752b18272a580369a5191050000000020ca8ca9a6f6554be982c5f7afb3305cdb7720cb4cbf87426e70a92d9020000000c5326c2a8be0c601ace3b66740185d955fdc4e75f32a1febfbb6c95a23d3aced17d0df6a21fa001e9f150d0000000020738da062d736d9b74f50422e3432c07255d4cc60b9a59fd611e501fbc30000003702a7755682fd0310b70792e8df461e71a2595b29e02b61ac7e70269e1f3b414ed0df6a651d011e8cb90100000000205c0b62eec35706c4599bc8a513d37bf34d012a327721f20dc01e086b460000000e727bf6e9e4b4a5087f2a222dd23e3276213de489a8fb27bdf7a7d2eea662e53fd1df6a9d25011e96f4080000000020aac54bafe96468717c24754b8682c3b2ea39a97c9640ee2ef093484ab3000000c28959791fe2cbedba0527963ca4c6921555cc9178521a2d21524d169a3866a68bd1df6ae004011ee682040000000020046260e6679dc301afc2e7cfae540816612206be91dc32624d2ceb22b60000005ddcbabf8f443cd541911b5c57154540c0aa97617ce4abb38723c2d8392e5b3055d2df6accc5001e24150200271e1800000000202de4a2b088325cf12bc0c665bd059c997972ba4c04b77cdeaf94ceb43f000000cf06c50a6fad103c3c18f56086789594df4aa7fe0168a118297763377133694dce07e16a4eea001ead65040086acc80196606443a1fa40c81cb6d9d866fb609405ff72ee6b547699f57d65867f2318ab199a37d4d7493471fa4bd9f21826fa290d20e75504f4e71a95be5be4fc9fd994a0c908c40e101989037f1c6f6450dde0c77dcd83c656c2306a75a1c95601000003000600000000000000fd49010100231e18000300016bf9f7dc25e0a1a2115bc895f15e42647cd113248af3d89dec69b94d7900000032ffffffffffff0332ffffffffffff03abaed5a687052489de5b5acb88dc0fbd59ceceb99a35050a0eb00b6d0c7b04cf2824f452e2512845c50d0d1a7ffe6438588556637938a34db77e91fd92ebb18e554dd93f4fda6cba8a327fbb91fce2b5b20bc74b2a5d837e08e5512420639060b54653aebb6deb78261ca0f485897b97e6643bdd38be6c62005d3bfdafe189d9152ee3bc105886a3116ff81485c63e346601dbefec045d924fa62649b3beb1fcdbda28a4e800128959893e8855d7d2d184fe462ef39bb707f6691df1de3eb9bc01bad363b2ccaafa44bc6522c224270adf03d481877129bbef1b248296513d7c17bf41edcefd26710cf2e0626c759818b75539922f890c133e726cd17f3e14ab3a44ca6b6bcfa3b363fd6c6b090ffce1010000000e00000004ca66204ad94053e0366b02ba8e1b0df6976c6ba2051080e155cc4a1d432aae5ef77f463be745899b399fa8dc04ece425ebaba19686037f5fa0cfdff73684874c65edcf2241031d0841a8b66876d661994e78afc54cb0daaef4e2e1c15e1d6acf2e09d7831531df3fe3ad350061970db8ba5679dffdf45032c66cb0da7897197204000000002043712683fd3e68347f31ec8e3113a22f99a347e98edad22da41f9d1d5c000000f05f5153966604c6ef32f2d24bf2ea18fa5968f039cabafa27395dfc88687d062107e16ab608011e708a0300000000207851233b063768a9098d8c817184e040452e4cf0b9e594febd89882ed1000000938c8cb2d9da0988ade4bb2b26ffddbf9c11b252064fb984e16620960486a36da407e16a44f7001e397b040000000020b4164fb94101101e9162eb803dd2a120608aab148326a1f37d0208741e00000073d795605b1fbea98704825562cc87dc5c211c37d78073dc93daeaaf370b8914a907e16a71fc001ec912000000000020567c5c4bf6a4ac7826e445df899097cc02addc1d74ebbbffbfa007f2f00000009e5e454c4947cbbe5e850bb01af8a274a26287a987e7c45357231a2d3e6d2d59c907e16af9ea001ed56d04008f1f1800000000201d3684dce1ba4ad4cace16129c6d0190010e6975db4967dd4a4836979600000008d5b11f6aab70635c1c66f0ca0bce889ff2573971ba9b1d134e2bf2825062fceecfe16a012a011ed41a0600b211d3d21e6c8bc61fdfcdb841ff8c307572df59a1b3d31372607219fc92c7f23c60bf00e27112b8f2918924b90461641198260515c3e5b466c026b758cff6097a8907a01d66c63c550f0a4ae085007d3aa20fa97e7665f0a283e12f3db3bdda5601000003000600000000000000fd490101008c1f180003000108b108d186da79b7e5576b934d6e6f62a864d176e4779305ed3927bab900000032ffffffffffff0332ffffffffffff03b9c15c6d917b2b4028ec05f54eea6b5cbcd3219c3e5064ba515d8126166f192d2bdf41e08ab227945291519ed7e0345213b9e65a8a53d82338a2fe9c6e8690f4f3ac8e8e1c2ceb047fd71c6bb6caad78a6427b489824115065b85db3b509f1bed84ecfb7098116a8672a94a0140aa82753160203a1f0d2d8cf89869ba33e331917e057998dafaebaf5d87d21da280cfa527dda8e45086bb902e6192710aad93601f538dc4dcc71c3fadd33e1325f7b178efe702871468030ed914d10877d86f3cf251d47c3ee0a639fcbc245b18127dce56244146a4fa87cdb9fe629544885e00095d5ca30fb4022e0b2fd0f2a408b59360ba42a36908911b7577eb6a5b30698d1eb1311fab79e7b4d6aee3fb278b02c010000000e0000000483ffb768525c92474e656329eda4fa33b763c6cc4c3e604a5bfc9a92d4f3c247070d20e2370a72276bf731324f58de0b09f4961f480445dc62340f8b2352561543760e8a2d7a90b4b3301d94c3860fb734805f55c2b8933fca2fc30a25f42803a493615355babf761bd612a7691f1c492357c0de771a0b12de55050e16f06f3b030000000020b5247e6a7d3162eb8b8075482b78d96a2c9c6aaf90cbe7a601a9b73fe300000051ef99e4da5c6e0a5c80295d395391002e4d6646f042c17378465c61fe6f69c56fcfe16aa829011eec7e09000000002032b11baab52b2afa2a01f4d6145325defa35fe9f992920aa3bbc9a322c000000119d5166366969dbbdfcc412ccfa8655ea17bee1e2de250cfa98b0d6d316692389cfe16a2e2a011e3b1c030000000020fb921428ee23f72da17699fcf4abeb7caed251aec7a789662581155f1f01000089d39adbd9c8a289eb8edd5a4fb97838afc62b477d0968a99871298d54a653badfcfe16af42c011eee5e020027211800000000208eda0714077f2ce1db13cfa971b4168db288e8f6d442c03924843e5e2a000000a949412717a23384241f4077937d3d7bcb8064c7a76891522fd88b5f486714ac7dabe26aac4f011e697d0200a6677efba8ed4ef619eaf6576c70585310a759d830adfe17953f129b9a57e0c74f137b66c6d5c356c6cc22b7b0b3bfc007ace1e796eb0dd02cb4bbc1d61e0525732fa8b0b6eb8fa6802f72ae1d0a3e0f94f94bed1ff5b280272234770585bcfe5601000003000600000000000000fd49010100232118000300017e29b624f441b3483ce2ec31292a076ebbd19331c15fd0d417c022028000000032ffffffffffff0332ffffffffffff03b25530b4f713daba30a0f4716641a55eb029c62657c7c16b314153566d80b0bccce7300dc35c87ae1ee9a82335f9ac198e93f9e218e52cc22de6f1c6a233622c249306305fec9ba925306336ae537356a5ed9e8e370bdb9ec5d019ba1934d8a27cb22ecb7389552debfeb079ec30edc1cae1bd5e8c2161fd21ba58616c6ea4f610fd0bcde2dbc29464b2e698f7e4bb450cbc6dcd4265bc318733fdc738427cab27667d78b59b571c19f2a207c1fc581680bb986d4d555fe64f836b16e103dc354160cae5f9889effb585e2e986d1f231768b36ab63c2eb92eef7744b97081c900fa8e9a2ec639639e997c3001d103dcd1b5d6b7d2121aa76244cea39393b7cef8745a2a1b7d5860a7d1bf962a3b2dc28010000000700000003780052641661a9cf375d6fc0fdb7de62bd33796a7006fd1ac255722241c180c6566cd03a34b17bc3cabf35f4b7f71bb987bfda13b8f75e957405592150d6a733574ae9b2bac68a9dc619076d86fc3ec214ebed95b3a33b9d0cf05761b22bc8890400000000207e36138dbf9dc768561f5b660032ddd7b659a7e3a6922bf0f87ae596230000008a5e18bba18652dc174d0de28ade6cd0b779eb478dca782906e4c4709a5b052360aae26adc81011edfd50c00000000203349e093595e059746d570cccb7ca755ffa160e86780d364fa87ff765b01000044559a9de0bd4e93864e61023e1b7997979f71c867495b43c5e7448ccfa590cd9faae26af171011ecfbb0200000000204ca85011fc962e82a81e637f9341a9bec92921ce76f2034140745ff950010000506edefe683f7e20ba34d3b7d059a1fab8962bc2c751334209fa7b0907e7cf09d4aae26a893d011e1541040000000020b9fd6bd257d528608798e7141583159236d524eb14b31e78c93548272f010000480ba18ba85e0f99b6eaca3a3ffab815c384c7cacd843162804d05276dc7e59576abe26a103e011e6bae06003b2318000000002005f615a9e28fc7be42956033ce0fa3e1d92d7d038323f7e0e3edbbe308000000d3b66f7ca97997515de8fc9c66b257c3725bdd387ee1a34589df42fea759c1004cbce36a406b2f1d0200eaa3a169e166c5c54309301c7c30bc9b5615bb4f10d5598b4fa452b0b60398f9211cd49dae94f88344f8ccad4b076e7ee86e0e975cf43fd456bd037e74037aa3ac87c7c30a205bcc2037cbb37541cc2c8ead62ef6520e8e11083302f9af689b96df55601000003000600000000000000fd4901010034231800030001f478340cdf0b302203ab060fd7b43ab6d123451dd93b21002903f5ee1d00000032ffffffffffff0332ffffffffffff039733fa17012ed7fdb5c05e5d01003cd1a7686cbe92092363effcfda56b7ff18ac494c19653ea13546807a758efcdf48b5ba1d00e5bd03956b52e8e8adff83aee46fcf96daaa6906679429fd30730fdbdb6c709902f2c6b9e90f5491208a23efb13ff4441471dbcd9cbf6776bd38f9bc89832a5b6810c82837d8ca2dfcf892d6319b723d63fe91ad24f5c8d06800edf993b79937d1c5322a6864de21acff5d8dfeca59cddad7aaf5556601b1990b47e6db122fd7e3ca97343c2d9b0a67f191b42c3aed3678d1a0b87f5aa05f75682ba1602e9a7eb22139e1a46dae0a8215c6a8b105f92824f6774bd15f425d874fb6d531c802898b187ad28ff9b92c57ed8216743d5a1bdc396123450cec2628b39aaa1010000002c000000063f2a698218f2260555ab55db92517e46bf642d567c27cda07d3446b72fc70c51f16c0dea85bcb524ce0a6d199448b3e5ba550779c7d46cb189ad6c04fc4915f1cba2006b9ab8def5c0cf9a17cb8a638eff650e27f5bc08b0c1b5f374e1f12666bccce213810c18d021f2ed8fba658dfebac15cf2cf6ab6b3750d82bb7133932e6cfa00f3c198bf8cb0000b0e75e04b1a6d02222aab26f2015708536f340b75003adde8f2c4b0a580072530d701fb1e5be9f1a59e556224ee328049f9962d7857070000000020a3535a502e8b252f233cd1d16ce0b8b453df4882785ac2bae9dfc6d3290000004accaf6d6ecaf85ab9e0b598c7b25072f1bdc2806c9489705f298fadbbb38bf4adb9e36adc9b331d016c3ef600000020ea732a400b2071307475087b8f2ecfae2c100f60493397c4579be8522d0000004ace2a0c0ab7b15ea39615f2afffaf70694f18df0aed54e316ba188c56b05c0779bae36afc37361d01e5e94f00000020970e2daf5ec00c7c2bfd7e33f902cb3245d5edb3bf9df3fa4fbb6ad40000000010593a8b52fbf96487adbaea0d2b0831462ee1211e2e6e279aa17ab6478e810ca4bae36a3de1311d0015342800000020ed32ab13eb4c33bc884059244df49af846d371f781cf25b6dcdbc28c08000000bb736087bad86f41fa7d6d1a46ec28f102fdd8bb0f73e747a2b18e79fee11ed2babae36a9b322f1d001edd02000000201dc1c27f9eef1308f5ef5b997bca04b0898d1867681fb3e6d76aaa190d000000328ca6b3778d0b4d95a0553507e0d8342a24b5c115613cab085065848950dd9d5ebbe36a71782f1d0a650e0000000020be06e9e62f4d8b6a9aa396762a478d369890542653d615f5e26c92180f000000d593f9460f888b4d86d9abc254e291e10c852ffbbd177d268bb6501d5e1446636dbbe36a88ff2f1d000f04cb0000002014653584aaef45cbf419a3e8f9d729983631618458e2f58bedb5921005000000e1c0170aec54afb8495912c0d9cc57c562e898f8addfe8c36c04d247f5ccaa398dbbe36ac4a32f1d1ea5050087231800000000203f6635a730e334226c3dcf3fc22e21c72903fcf56c749cfe50d732990b00000087edf7bd5000d1b4f499f07065c7cafeda0e0bda30aac412a19021edf52701a8d9e3e36a8efb1f1d00aa0ba5a2016e74f9426d2d563e1b508683827c37c4ea6e6f99955c619eef4d3ee5b13cc8ae0490b6e98bb03248339ca1342481072de8e213f25a84ec63b2f77c8e8c9c70787d218d0ce6be113a6656ed8922fb4b10615542443a98105140a418b9b8c89b01000003000500010000000000000000000000000000000000000000000000000000000000000000ffffffff4a038723180429e4e36a4f534d41d22231fe62d0034c0f5622b410a7206081d9127c7baf5add515f2168e4f2de6e0400000000000000081fde8256000000000b2f54616c6f6e506f6f6c2f00000000037c558c03000000001976a914c94f9e3f6b2070deece22390eaa12239300bc74e88ac2ed5fd0300000000016a442ba706000000001976a91464f2b2b84f62d68a2cd7f7f5fb2b5aa75ef716d788ac00000000cf040087231800410190e025171ecfa413d78e896f397f2a694f37aebca2bae6547cf1d1d22be5ff00d67c159f65f681530c81996ed1954dce5c005fbd9821d76d92addde3804301b7e2a85ff6bb9fc3868ebfe6cd1157c13e2f9049757268822bd05a0a7584d7f1e99057e0e61ac6a0eff5205fc4646e4000385625169e15c3a59608b00700ce4431b961110f7ea51cafbadaa885dff960cdae4e5feaaa328400c674cb42232d6588f885496b220000000000000000000000000000000000000000000000000000000000000000000000000000030000000226df9274d4a067150f5a4d5c049d91174dccbd35b8ca58f30753233034063ad4145f1bb8298299fd366894475dc89e9a565e5bb253d33a702c68513e9be17e6501003d0100000300067c68340b3fc0c510a8b99458426dc48015b541ced679c3f0cfd02c034a00000019ffffff0119ffffff018e23923723b6299442ebe663da1ad959f25e868ebb9730ac63290036bae8ab71d01fe61853d45690611163254cc21cae1b84685c09e638f019755ede99bd3c3935e2b988f24fc7f198b8dcb4b915101483e00dc44a90a404b609098c3148f2971e19ebdc17738c160886542ac208921593832dbd35c60daa1af35c3f058926cb142322df17af3942649deaedfcd022b975f7577a39760407f19e62ab985fe061cec649a03eca7810c144f182482451fcb1d0332f50ab7fb8e1e83a26e785e3f88f887569d12259f66361127e4f2073bc3a7938a3a710b126d1abf267a5d20cd8073ff41141f332219b4c93fd1e2932e2659fe9904d7b1c14d6dcd70a768728276b406c521760d3680ba3011628fdb10a250000006d000000075e5c6b563fb9248176548efeba7febedd9704b9b2853597fb6d37d30e9658c56b26c208b27c5d5a7d2700ade3a19ef018122a4c9c421cef629727e31eb7d0db3b4ff91ba79dc5f8db3553cfb30205d220a5858b0865170d2b8f3a8d1be309cc8685ce1b476ec058663569c7ff211a4eeca53b547be03c6929cf281a464026e8facb247a09e471b1257b9a6f20d761c8e73f99dd5e4aedf3c0a8ec1854555d404919a9e53228903b535dbcd2e78a81c96ab57924f9134237925f6788588f6cb0b6df7503ce3cd6df6e5fe0979a994a02e5b3c86de040766fc89d7995dc2e1eefe" +} diff --git a/scripts/release/release.sh b/scripts/release/release.sh index 8c34f57f2e7..27c59fe8a14 100755 --- a/scripts/release/release.sh +++ b/scripts/release/release.sh @@ -98,17 +98,37 @@ fi # See packages/rs-sdk-trusted-context-provider/README.md. # The per-network results go into the release PR's description. CHECKPOINT_REPORT=$(mktemp) +CHECKPOINTS="$DIR/../../packages/rs-sdk-trusted-context-provider" +# Put the embedded checkpoints back as committed, then stop. +stop_restoring_checkpoints() { + git checkout -- "$CHECKPOINTS/checkpoints" \ + "$CHECKPOINTS/tests/data/checkpoint-evidence-mainnet.json" \ + "$CHECKPOINTS/tests/data/checkpoint-evidence-testnet.json" + rm -f "$CHECKPOINTS"/checkpoints/*.json.new "$CHECKPOINTS"/tests/data/*.json.new + echo "ERROR: $1; the embedded checkpoints are restored. Stopping." >&2 + exit 1 +} +# Ctrl-C reaches this script too, possibly between the two files' renames. +trap 'stop_restoring_checkpoints "interrupted"' INT TERM if cargo run --quiet --package rs-sdk-trusted-context-provider --example refresh_checkpoints -- --report "$CHECKPOINT_REPORT"; then : -elif [ $? -eq 2 ]; then - # The report no longer matches the checkpoint files, so the release PR - # would misdescribe them. - echo "ERROR: the checkpoint refresh could not write $CHECKPOINT_REPORT; stopping." >&2 - exit 1 else - CHECKPOINTS_KEPT=1 - echo "WARNING: an embedded quorum-proof checkpoint was not refreshed; see above." >&2 + case $? in + 1) + # Only an expected per-network failure, which keeps that checkpoint. + CHECKPOINTS_KEPT=1 + echo "WARNING: an embedded quorum-proof checkpoint was not refreshed; see above." >&2 + ;; + 2) stop_restoring_checkpoints "the checkpoint refresh could not write $CHECKPOINT_REPORT" ;; + *) stop_restoring_checkpoints "the checkpoint refresh failed unexpectedly" ;; + esac +fi +# Each checkpoint must still match its evidence, even if the refresh was cut +# short between replacing the two files. +if ! cargo test --quiet --package rs-sdk-trusted-context-provider --test checkpoints; then + stop_restoring_checkpoints "an embedded checkpoint does not match its evidence" fi +trap - INT TERM cargo metadata --format-version 1 > /dev/null From 9085afaeab122b1fae318a4548631c15adae66b4 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 29 Oct 2026 20:15:49 -0500 Subject: [PATCH 07/11] fix(sdk): fall back only for newer protocol versions; restore checkpoints until the release commit - The refresh reads a Platform response with the latest version tables only when its protocol version is newer than this build's, and refuses an unknown older one instead of guessing. - The epoch request is built twice instead of being reused after `execute` takes it (it is `Copy` today, but that is incidental). - `release.sh` keeps restoring the embedded checkpoints on any exit, including an interrupt at the changelog prompt or a later failure, until the release commit is made. Co-Authored-By: Claude Opus 5.5 --- .../examples/refresh_checkpoints.rs | 20 +++++++++++-------- scripts/release/release.sh | 13 ++++++++---- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs b/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs index 889f54b7255..8ab5f04943e 100644 --- a/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs +++ b/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs @@ -39,7 +39,7 @@ use dash_core_proof::{bootstrap, rpc_hex, QuorumProofChain, State, MAX_PROOF_BYT use dash_network_seeds::{PlatformLiveness, PlatformStatus, Reachability, SslStatus}; use dpp::dashcore::sml::llmq_type::network::NetworkLLMQExt; use dpp::dashcore::Network; -use dpp::version::PlatformVersion; +use dpp::version::{PlatformVersion, LATEST_VERSION}; use drive_proof_verifier::types::ExtendedEpochInfos; use drive_proof_verifier::FromProof; use rs_dapi_client::{AddressList, DapiClient, DapiRequest, RequestSettings}; @@ -209,13 +209,13 @@ async fn platform_core_height( async move { let address = AddressList::from_str(&uri).map_err(|error| error.to_string())?; let client = DapiClient::new(address, settings); - let request = epochs_request(); - let response = request + // Built twice rather than reused after `execute` takes it. + let response = epochs_request() .execute(&client, settings) .await .map_err(|error| error.inner.to_string())? .inner; - authenticated_height(network, &provider, request, response).await + authenticated_height(network, &provider, epochs_request(), response).await } }); async move { @@ -262,10 +262,14 @@ async fn authenticated_height( if let Some(fetch) = provider.fetch_quorum_public_key(proof.quorum_type, quorum_hash, height) { fetch.await.map_err(|error| error.to_string())?; } - // The version only picks how the GroveDB proof is read; the quorum signs - // the version and the root hash, so a wrong pick fails the signature. - let version = PlatformVersion::get(metadata.protocol_version) - .unwrap_or_else(|_| PlatformVersion::latest()); + // A version newer than this build's is read with the latest tables, as + // the SDK does: the quorum signs the version and the root hash, so a wrong + // reading fails the signature. An unknown older one is refused. + let version = if metadata.protocol_version > LATEST_VERSION { + PlatformVersion::latest() + } else { + PlatformVersion::get(metadata.protocol_version).map_err(|error| error.to_string())? + }; ExtendedEpochInfos::maybe_from_proof_with_metadata( request, response, network, version, provider, ) diff --git a/scripts/release/release.sh b/scripts/release/release.sh index 27c59fe8a14..cb39d5bb05e 100755 --- a/scripts/release/release.sh +++ b/scripts/release/release.sh @@ -99,17 +99,22 @@ fi # The per-network results go into the release PR's description. CHECKPOINT_REPORT=$(mktemp) CHECKPOINTS="$DIR/../../packages/rs-sdk-trusted-context-provider" -# Put the embedded checkpoints back as committed, then stop. -stop_restoring_checkpoints() { +# Put the embedded checkpoints back as committed. +restore_checkpoints() { git checkout -- "$CHECKPOINTS/checkpoints" \ "$CHECKPOINTS/tests/data/checkpoint-evidence-mainnet.json" \ "$CHECKPOINTS/tests/data/checkpoint-evidence-testnet.json" rm -f "$CHECKPOINTS"/checkpoints/*.json.new "$CHECKPOINTS"/tests/data/*.json.new +} +stop_restoring_checkpoints() { + restore_checkpoints echo "ERROR: $1; the embedded checkpoints are restored. Stopping." >&2 exit 1 } -# Ctrl-C reaches this script too, possibly between the two files' renames. +# Until the release commit, any exit leaves the checkpoints as committed: an +# interrupt (possibly between the two files' renames) or a later failure. trap 'stop_restoring_checkpoints "interrupted"' INT TERM +trap restore_checkpoints EXIT if cargo run --quiet --package rs-sdk-trusted-context-provider --example refresh_checkpoints -- --report "$CHECKPOINT_REPORT"; then : else @@ -128,7 +133,6 @@ fi if ! cargo test --quiet --package rs-sdk-trusted-context-provider --test checkpoints; then stop_restoring_checkpoints "an embedded checkpoint does not match its evidence" fi -trap - INT TERM cargo metadata --format-version 1 > /dev/null @@ -171,6 +175,7 @@ git checkout -b release_"$NEW_PACKAGE_VERSION" # commit changes git commit -am "chore(release): update changelog and version to $NEW_PACKAGE_VERSION" +trap - INT TERM EXIT # push changes git push -u origin release_"$NEW_PACKAGE_VERSION" From dcf2a2108ae4505c72db8bc64dfa423884fe2a60 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 29 Oct 2026 21:15:20 -0500 Subject: [PATCH 08/11] fix(sdk): stop the release when the checkpoint refresh cannot start The refresh tool exited with status 1 for bad arguments or a failed HTTP client build, before any network ran, and `release.sh` treats 1 as "a network kept its checkpoint" and continues. Those now exit with 3, which stops the release, and `release.sh` also requires a report line for both networks before continuing past a 1. The refresh example's dependencies are added to the package filter the tests workflow actually reads. Co-Authored-By: Claude Opus 5.5 --- .../rs-packages-no-workflows.yml | 4 ++++ .../examples/refresh_checkpoints.rs | 20 ++++++++++++------- scripts/release/release.sh | 6 +++++- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/.github/package-filters/rs-packages-no-workflows.yml b/.github/package-filters/rs-packages-no-workflows.yml index 13da21762bc..402ea910da2 100644 --- a/.github/package-filters/rs-packages-no-workflows.yml +++ b/.github/package-filters/rs-packages-no-workflows.yml @@ -129,6 +129,10 @@ dash-context-provider: &context_provider rs-sdk-trusted-context-provider: &sdk_trusted_context_provider - packages/rs-sdk-trusted-context-provider/** + # The refresh_checkpoints example's dev-dependencies, anchored further down. + - packages/rs-dapi-client/** + - packages/dapi-grpc/** + - packages/rs-drive-proof-verifier/** - *dash_async - *context_provider - *core_proof diff --git a/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs b/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs index 8ab5f04943e..d8bf46e0fea 100644 --- a/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs +++ b/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs @@ -26,9 +26,10 @@ //! //! `MAINNET_QUORUM_RELAY` or `TESTNET_QUORUM_RELAY` replaces a network's //! public relay, which must serve both `/quorums` and `/proofs`. A network -//! that fails keeps its checkpoint, and the exit status is 1. -//! `--report` appends one Markdown line per network to a file, for the -//! release PR; if that write fails, the exit status is 2. +//! that fails keeps its checkpoint, and once every network has run the exit +//! status is 1. `--report` appends one Markdown line per network to a file, +//! for the release PR; if that write fails, the exit status is 2. Bad +//! arguments, or no HTTP client, give 3 before any network runs. use dapi_grpc::platform::v0::{ get_epochs_info_request, GetEpochsInfoRequest, GetEpochsInfoResponse, @@ -62,8 +63,13 @@ const MAX_QUORUMS_BYTES: usize = 1 << 20; /// Seed evonodes asked for Platform's Core height. const PLATFORM_NODES: usize = 5; +/// The exit status when every network ran and at least one kept its +/// checkpoint; `release.sh` continues past only this one. +const NETWORK_KEPT: u8 = 1; /// The exit status when the `--report` file could not be written. const REPORT_FAILED: u8 = 2; +/// The exit status when no network ran: bad arguments, or no HTTP client. +const SETUP_FAILED: u8 = 3; const USAGE: &str = "usage: refresh_checkpoints [--report ] [mainnet] [testnet]"; @@ -80,12 +86,12 @@ async fn main() -> ExitCode { Some(path) => report = Some(PathBuf::from(path)), None => { eprintln!("{USAGE}"); - return ExitCode::FAILURE; + return ExitCode::from(SETUP_FAILED); } }, _ => { eprintln!("{USAGE}"); - return ExitCode::FAILURE; + return ExitCode::from(SETUP_FAILED); } } } @@ -102,7 +108,7 @@ async fn main() -> ExitCode { Ok(client) => client, Err(error) => { eprintln!("cannot build an HTTP client: {error}"); - return ExitCode::FAILURE; + return ExitCode::from(SETUP_FAILED); } }; @@ -116,7 +122,7 @@ async fn main() -> ExitCode { } Err(error) => { eprintln!("{network}: checkpoint not refreshed: {error}"); - status = ExitCode::FAILURE; + status = ExitCode::from(NETWORK_KEPT); // Relay text stays in a code span, so it cannot pose as // Markdown in the release PR. format!( diff --git a/scripts/release/release.sh b/scripts/release/release.sh index cb39d5bb05e..9467f03d2fd 100755 --- a/scripts/release/release.sh +++ b/scripts/release/release.sh @@ -120,7 +120,11 @@ if cargo run --quiet --package rs-sdk-trusted-context-provider --example refresh else case $? in 1) - # Only an expected per-network failure, which keeps that checkpoint. + # Only an expected per-network failure, which keeps that checkpoint, + # and only once both networks have reported. + if [ "$(grep -c '^- ' "$CHECKPOINT_REPORT")" -ne 2 ]; then + stop_restoring_checkpoints "the checkpoint refresh did not report on both networks" + fi CHECKPOINTS_KEPT=1 echo "WARNING: an embedded quorum-proof checkpoint was not refreshed; see above." >&2 ;; From 8ab9578315b4fd4dd579dece8ea5f1b277bbe24f Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 31 Oct 2026 03:35:04 +0000 Subject: [PATCH 09/11] fix(sdk): give the checkpoint refresh a cache that holds the active quorums The verified provider now refuses a key cache smaller than the active Platform quorums (24 on mainnet and testnet). The refresh built its provider with 16 keys and its tests with 4, so the release step and five of its tests failed with CacheTooSmall. Both now use 32, the size the provider's docs recommend. Co-Authored-By: Claude Opus 5.5 --- .../examples/refresh_checkpoints.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs b/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs index d8bf46e0fea..4343694d34c 100644 --- a/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs +++ b/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs @@ -197,7 +197,7 @@ async fn platform_core_height( network, current.clone(), vec![relay.to_string()], - NonZeroUsize::new(16).expect("nonzero"), + NonZeroUsize::new(32).expect("nonzero"), ) .map_err(|error| error.to_string())?; let settings = RequestSettings { @@ -958,7 +958,7 @@ mod tests { Network::Testnet, checkpoint, vec![relay(Box::new(move |_, _| (200, quorum_proof.clone())))], - NonZeroUsize::new(4).expect("nonzero"), + NonZeroUsize::new(32).expect("nonzero"), ) .expect("a provider"); authenticated_height(Network::Testnet, &provider, epochs_request(), response).await From 6453c51c7d405df064ca5e024dc1c8edbba19c46 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 2 Nov 2026 14:28:54 -0600 Subject: [PATCH 10/11] fix(sdk): stop the release when the checkpoint files cannot be written A failure to write a network's checkpoint files was reported like a relay failure, status 1, which the release continues past with the old checkpoint. It is a local problem with the checkout, so the refresh now reports it with its own status, 4, and the release stops with the committed checkpoints restored. Also covers two paths the tests missed: a proof of undeclared length refused as it streams in, and a staging write that fails partway, through a write seam beside the existing rename one. Co-Authored-By: Claude Opus 5.5 --- .../rs-sdk-trusted-context-provider/README.md | 6 +- .../examples/refresh_checkpoints.rs | 201 +++++++++++++++--- scripts/release/release.sh | 1 + 3 files changed, 174 insertions(+), 34 deletions(-) diff --git a/packages/rs-sdk-trusted-context-provider/README.md b/packages/rs-sdk-trusted-context-provider/README.md index b8b112f1325..ebd0dadc768 100644 --- a/packages/rs-sdk-trusted-context-provider/README.md +++ b/packages/rs-sdk-trusted-context-provider/README.md @@ -278,9 +278,9 @@ public mainnet `/proofs` returns 403 (the provider behind the Platform check below accepts `http` only on loopback). A network that fails keeps its checkpoint, and the release prints a warning and records it in the release PR; an older checkpoint stays valid and only makes proofs longer. Any other -failure, including an interrupt, stops the release with the committed -checkpoints restored, and so does a checkpoint that no longer matches its -evidence. +failure, including checkpoint files that cannot be written and an +interrupt, stops the release with the committed checkpoints restored, and +so does a checkpoint that no longer matches its evidence. The relay's quorum list only suggests the height to ask for. The new checkpoint must be at least half a Platform quorum lifetime (about 12 hours) diff --git a/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs b/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs index 4343694d34c..abab8421fab 100644 --- a/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs +++ b/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs @@ -70,6 +70,35 @@ const NETWORK_KEPT: u8 = 1; const REPORT_FAILED: u8 = 2; /// The exit status when no network ran: bad arguments, or no HTTP client. const SETUP_FAILED: u8 = 3; +/// The exit status when a network's checkpoint files could not be written: +/// a local failure, not one of the relay's, so `release.sh` stops. +const WRITE_FAILED: u8 = 4; + +/// Why a network's checkpoint was not refreshed. +#[derive(Debug)] +enum Failure { + /// The relay, the evonodes or a proof failed, or nothing newer is far + /// enough below the tip: the network keeps its checkpoint, which stays + /// valid. + Kept(String), + /// The checkpoint files could not be written. The files are as they + /// were, unless restoring them failed too. + Write(String), +} + +impl From for Failure { + fn from(error: String) -> Self { + Self::Kept(error) + } +} + +impl std::fmt::Display for Failure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Kept(error) | Self::Write(error) => f.write_str(error), + } + } +} const USAGE: &str = "usage: refresh_checkpoints [--report ] [mainnet] [testnet]"; @@ -114,22 +143,31 @@ async fn main() -> ExitCode { let mut status = ExitCode::SUCCESS; let mut report_failed = false; + let mut write_failed = false; for network in networks { let line = match refresh_embedded(&client, network).await { Ok(message) => { println!("{network}: {message}"); format!("- {network}: {message}") } - Err(error) => { + // Relay text stays in a code span, so it cannot pose as + // Markdown in the release PR. + Err(Failure::Kept(error)) => { eprintln!("{network}: checkpoint not refreshed: {error}"); status = ExitCode::from(NETWORK_KEPT); - // Relay text stays in a code span, so it cannot pose as - // Markdown in the release PR. format!( "- {network}: **not refreshed**, kept the previous checkpoint: `{}`", error.replace('`', "'") ) } + Err(Failure::Write(error)) => { + eprintln!("{network}: cannot write the checkpoint files: {error}"); + write_failed = true; + format!( + "- {network}: **not refreshed**, the checkpoint files could not be written: `{}`", + error.replace('`', "'") + ) + } }; // Written as each network finishes, so the report matches the files // even if a later network's run is cut short. @@ -150,11 +188,14 @@ async fn main() -> ExitCode { // the files. return ExitCode::from(REPORT_FAILED); } + if write_failed { + return ExitCode::from(WRITE_FAILED); + } status } /// Refresh this crate's embedded checkpoint for `network`. -async fn refresh_embedded(client: &reqwest::Client, network: Network) -> Result { +async fn refresh_embedded(client: &reqwest::Client, network: Network) -> Result { let current = VerifiedHttpContextProvider::embedded_checkpoint(network) .map_err(|error| error.to_string())?; let variable = format!("{}_QUORUM_RELAY", network.to_string().to_uppercase()); @@ -374,7 +415,7 @@ async fn refresh( current: &State, platform_height: u32, crate_dir: &Path, -) -> Result { +) -> Result { let name = network.to_string(); let quorum_type = network.platform_type(); let params = quorum_type.params(); @@ -394,13 +435,13 @@ async fn refresh( serde_json::from_slice(&read_capped(response, MAX_QUORUMS_BYTES, &url).await?) .map_err(|error| format!("{url}: {error}"))?; if !quorums.success { - return Err(format!("{url} reports failure")); + return Err(Failure::Kept(format!("{url} reports failure"))); } let (Some(oldest), Some(newest)) = ( quorums.data.iter().min_by_key(|quorum| quorum.height), quorums.data.iter().max_by_key(|quorum| quorum.height), ) else { - return Err(format!("{url} lists no quorums")); + return Err(Failure::Kept(format!("{url} lists no quorums"))); }; // Height 0 asks for the relay's tip; the newest quorum is active there. @@ -429,19 +470,19 @@ async fn refresh( )); } if height > bound { - return Err(format!( + return Err(Failure::Kept(format!( "the oldest listed quorum ({}) is not {below}", oldest.height - )); + ))); } let (target, chain) = prove(client, relay, network, current, height, &oldest.quorum_hash).await?; // The target and tip are proven, so this holds whatever the relay listed. if target.height > bound { - return Err(format!( + return Err(Failure::Kept(format!( "the relay answered at {}, not {below}", target.height - )); + ))); } let evidence = json!({ "header": hex::encode(chain.target.header), @@ -464,8 +505,10 @@ async fn refresh( to_json(&target)?, ), ], + |path, text| std::fs::write(path, text), |from, to| std::fs::rename(from, to), - )?; + ) + .map_err(Failure::Write)?; Ok(format!( "{} -> {} (`{}`), verified tip {}, Platform at {platform_height}", current.height, @@ -550,9 +593,11 @@ fn to_json(value: &impl serde::Serialize) -> Result { /// destination first, and if a rename fails the files already renamed get /// their old contents back. A crash between two renames can still leave a /// mixed pair, which the `checkpoints` test then fails. -/// `rename` is `std::fs::rename` outside tests. +/// `write` and `rename` are `std::fs::write` and `std::fs::rename` outside +/// tests. fn write_together( files: &[(PathBuf, String)], + mut write: impl FnMut(&Path, &str) -> std::io::Result<()>, mut rename: impl FnMut(&Path, &Path) -> std::io::Result<()>, ) -> Result<(), String> { let mut staged: Vec<(PathBuf, &PathBuf, Vec)> = Vec::with_capacity(files.len()); @@ -561,13 +606,11 @@ fn write_together( let written = std::fs::read(path) .map_err(|error| format!("{}: {error}", path.display())) .and_then(|old| { - std::fs::write(&staging, text) - .map(|()| old) - .map_err(|error| { - // A failed write can leave a partial file behind. - let _ = std::fs::remove_file(&staging); - format!("{}: {error}", staging.display()) - }) + write(&staging, text).map(|()| old).map_err(|error| { + // A failed write can leave a partial file behind. + let _ = std::fs::remove_file(&staging); + format!("{}: {error}", staging.display()) + }) }); match written { Ok(old) => staged.push((staging, path, old)), @@ -642,6 +685,12 @@ mod tests { /// A relay on loopback answering each request with `handler(path, body)`. fn relay(handler: Handler) -> String { + relay_with(handler, true) + } + + /// As [`relay`], but without a `Content-Length` when `declare_length` is + /// false: the body then ends when the connection closes. + fn relay_with(handler: Handler, declare_length: bool) -> String { let listener = TcpListener::bind("127.0.0.1:0").expect("a loopback port"); let address = listener.local_addr().expect("an address"); std::thread::spawn(move || { @@ -671,10 +720,14 @@ mod tests { let request = serde_json::from_slice(&body).unwrap_or(Value::Null); let (status, body) = handler(&path, &request); let mut stream = reader.into_inner(); + let length = if declare_length { + format!("Content-Length: {}\r\n", body.len()) + } else { + String::new() + }; let _ = write!( stream, - "HTTP/1.1 {status} X\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - body.len() + "HTTP/1.1 {status} X\r\n{length}Connection: close\r\n\r\n" ); let _ = stream.write_all(&body); } @@ -719,11 +772,18 @@ mod tests { /// Refresh through `handler` with Platform at `platform_height`. async fn run_at(handler: Handler, dir: &Path, platform_height: u32) -> Result { + run_through(&relay(handler), dir, platform_height) + .await + .map_err(|failure| failure.to_string()) + } + + /// Refresh through the relay at `url` with Platform at `platform_height`. + async fn run_through(url: &str, dir: &Path, platform_height: u32) -> Result { let (checkpoint, _, _) = fixtures(); refresh( &reqwest::Client::new(), Network::Testnet, - &relay(handler), + url, &checkpoint, platform_height, dir, @@ -853,6 +913,51 @@ mod tests { assert!(unchanged(&dir)); } + /// Without a declared length, the body is refused as it streams in, once + /// it passes the limit. + #[tokio::test] + async fn should_refuse_an_oversized_proof_of_undeclared_length() { + let dir = crate_dir("oversized-streamed"); + let relay = relay_with( + Box::new(|path, _| match path { + "/quorums" => (200, quorums(1_576_744, true)), + _ => (200, vec![0; MAX_PROOF_BYTES + 1]), + }), + false, + ); + let error = run_through(&relay, &dir, u32::MAX) + .await + .expect_err("an oversized body"); + assert!( + matches!(&error, Failure::Kept(reason) if reason.contains("over")), + "{error:?}" + ); + assert!(unchanged(&dir)); + } + + /// A checkout the refresh cannot write to is not a relay failure: the + /// release must stop rather than carry on with the old checkpoint. + #[tokio::test] + async fn should_report_unwritable_checkpoint_files_apart_from_relay_failures() { + let (checkpoint, target, tip) = fixtures(); + let target_height = proven_height(&target, &checkpoint); + let dir = crate_dir("unwritable"); + std::fs::remove_file(dir.join("checkpoints/testnet.json")).expect("a placeholder"); + let relay = relay(honest(target_height - 24, tip, target)); + + let error = run_through(&relay, &dir, u32::MAX) + .await + .expect_err("no checkpoint file to replace"); + + assert!(matches!(error, Failure::Write(_)), "{error:?}"); + assert!(!dir.join("checkpoints/testnet.json").exists()); + assert_eq!( + std::fs::read_to_string(dir.join("tests/data/checkpoint-evidence-testnet.json")) + .expect("the evidence"), + "old\n" + ); + } + #[tokio::test] async fn should_report_a_relay_error() { let dir = crate_dir("relay-error"); @@ -1108,7 +1213,12 @@ mod tests { #[test] fn should_replace_both_files() { let dir = crate_dir("replace"); - write_together(&files(&dir), |from, to| std::fs::rename(from, to)).expect("written"); + write_together( + &files(&dir), + |path, text| std::fs::write(path, text), + |from, to| std::fs::rename(from, to), + ) + .expect("written"); for (path, _) in files(&dir) { assert_eq!(std::fs::read_to_string(&path).expect("a file"), "new\n"); assert!(!path.with_extension("json.new").exists()); @@ -1124,6 +1234,7 @@ mod tests { first, (dir.join("missing/testnet.json"), "new\n".to_string()), ], + |path, text| std::fs::write(path, text), |from, to| std::fs::rename(from, to), ) .expect_err("a missing destination"); @@ -1131,17 +1242,45 @@ mod tests { assert!(unchanged(&dir)); } + /// A staging write that fails partway leaves both destinations as they + /// were and removes every staging file, including the partial one. + #[test] + fn should_leave_every_file_unchanged_when_a_staging_write_fails_partway() { + let dir = crate_dir("partial-staging"); + let mut writes = 0; + let error = write_together( + &files(&dir), + |path, text| { + writes += 1; + if writes == 2 { + std::fs::write(path, &text[..1])?; + return Err(std::io::Error::other("disk full")); + } + std::fs::write(path, text) + }, + |from, to| std::fs::rename(from, to), + ) + .expect_err("a failed staging write"); + assert!(error.contains("disk full"), "{error}"); + assert_eq!(writes, 2); + assert!(unchanged(&dir)); + } + #[test] fn should_restore_a_replaced_file_when_a_later_rename_fails() { let dir = crate_dir("rollback"); let mut renames = 0; - let error = write_together(&files(&dir), |from, to| { - renames += 1; - if renames == 2 { - return Err(std::io::Error::other("refused")); - } - std::fs::rename(from, to) - }) + let error = write_together( + &files(&dir), + |path, text| std::fs::write(path, text), + |from, to| { + renames += 1; + if renames == 2 { + return Err(std::io::Error::other("refused")); + } + std::fs::rename(from, to) + }, + ) .expect_err("a failed rename"); assert!(error.contains("refused"), "{error}"); assert_eq!(renames, 2); diff --git a/scripts/release/release.sh b/scripts/release/release.sh index 9467f03d2fd..4f1ab616930 100755 --- a/scripts/release/release.sh +++ b/scripts/release/release.sh @@ -129,6 +129,7 @@ else echo "WARNING: an embedded quorum-proof checkpoint was not refreshed; see above." >&2 ;; 2) stop_restoring_checkpoints "the checkpoint refresh could not write $CHECKPOINT_REPORT" ;; + 4) stop_restoring_checkpoints "the checkpoint refresh could not write the checkpoint files" ;; *) stop_restoring_checkpoints "the checkpoint refresh failed unexpectedly" ;; esac fi From 8715a972d9c20fb9fa07925796ad28f1e750038b Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 2 Nov 2026 14:32:18 -0600 Subject: [PATCH 11/11] fix(sdk): keep the release's stop message when restoring checkpoints fails Under `set -e` a failed `git checkout` in the restore exited before the reason for stopping was printed; it is now reported instead. Also lists status 4 in the refresh tool's header and pins its new tests to the exact failure. Co-Authored-By: Claude Opus 5.5 --- .../examples/refresh_checkpoints.rs | 16 +++++++++++----- scripts/release/release.sh | 4 +++- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs b/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs index abab8421fab..d03aad137ff 100644 --- a/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs +++ b/packages/rs-sdk-trusted-context-provider/examples/refresh_checkpoints.rs @@ -29,7 +29,8 @@ //! that fails keeps its checkpoint, and once every network has run the exit //! status is 1. `--report` appends one Markdown line per network to a file, //! for the release PR; if that write fails, the exit status is 2. Bad -//! arguments, or no HTTP client, give 3 before any network runs. +//! arguments, or no HTTP client, give 3 before any network runs. Checkpoint +//! files that cannot be written give 4. use dapi_grpc::platform::v0::{ get_epochs_info_request, GetEpochsInfoRequest, GetEpochsInfoResponse, @@ -928,17 +929,19 @@ mod tests { let error = run_through(&relay, &dir, u32::MAX) .await .expect_err("an oversized body"); + let over = format!("is over {MAX_PROOF_BYTES} bytes"); assert!( - matches!(&error, Failure::Kept(reason) if reason.contains("over")), + matches!(&error, Failure::Kept(reason) if reason.contains(&over)), "{error:?}" ); assert!(unchanged(&dir)); } - /// A checkout the refresh cannot write to is not a relay failure: the - /// release must stop rather than carry on with the old checkpoint. + /// Checkpoint files the refresh cannot replace, here a missing one, are + /// not a relay failure: the release must stop rather than carry on with + /// the old checkpoint. #[tokio::test] - async fn should_report_unwritable_checkpoint_files_apart_from_relay_failures() { + async fn should_report_a_checkpoint_write_failure_apart_from_relay_failures() { let (checkpoint, target, tip) = fixtures(); let target_height = proven_height(&target, &checkpoint); let dir = crate_dir("unwritable"); @@ -951,6 +954,9 @@ mod tests { assert!(matches!(error, Failure::Write(_)), "{error:?}"); assert!(!dir.join("checkpoints/testnet.json").exists()); + assert!(!dir + .join("tests/data/checkpoint-evidence-testnet.json.new") + .exists()); assert_eq!( std::fs::read_to_string(dir.join("tests/data/checkpoint-evidence-testnet.json")) .expect("the evidence"), diff --git a/scripts/release/release.sh b/scripts/release/release.sh index 4f1ab616930..89a51a8f108 100755 --- a/scripts/release/release.sh +++ b/scripts/release/release.sh @@ -101,9 +101,11 @@ CHECKPOINT_REPORT=$(mktemp) CHECKPOINTS="$DIR/../../packages/rs-sdk-trusted-context-provider" # Put the embedded checkpoints back as committed. restore_checkpoints() { + # Reported, not fatal under `set -e`, so a caller's message still prints. git checkout -- "$CHECKPOINTS/checkpoints" \ "$CHECKPOINTS/tests/data/checkpoint-evidence-mainnet.json" \ - "$CHECKPOINTS/tests/data/checkpoint-evidence-testnet.json" + "$CHECKPOINTS/tests/data/checkpoint-evidence-testnet.json" || + echo "ERROR: could not restore the embedded checkpoints; check them before committing." >&2 rm -f "$CHECKPOINTS"/checkpoints/*.json.new "$CHECKPOINTS"/tests/data/*.json.new } stop_restoring_checkpoints() {