From 250ef8a0138acdd5fc129b266f9867beec2b001f Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Fri, 4 Sep 2026 00:36:07 +0800 Subject: [PATCH 1/2] fix: read metadata and runtime version at the head, not the finalized block subxt pins a client's metadata and runtime version to the latest finalized block. QPoW finality trails the head by ~100 blocks, so for roughly 20 minutes after a runtime upgrade enacts the CLI keeps talking to the old runtime: - governance config reads stale. After Heisenberg enacted spec 148, tech-referenda config still listed only track 0 with the 144 decision deposit of 1000 UNIT, so the fast_upgrade track looked like it had not shipped. - calls and storage added by the upgrade appear absent. - transaction_version is wrong, which signs extrinsics the chain rejects. That matters here because 144 -> 148 moves it from 3 to 6. Re-point both at the head after connecting, matching every other read path in the CLI (#152 did the same for collect-rewards proofs). A failure to read them is an error rather than a fallback, since silently continuing would leave the client on finalized metadata -- the bug this fixes. --- src/chain/client.rs | 70 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/src/chain/client.rs b/src/chain/client.rs index a72874a..2312e3b 100644 --- a/src/chain/client.rs +++ b/src/chain/client.rs @@ -140,6 +140,14 @@ impl QuantusClient { // Create SubXT client using the configured RPC client let client = OnlineClient::::from_rpc_client(rpc_client).await?; + // subxt pins metadata and runtime version to the latest *finalized* block. QPoW + // finality trails the head by a long way (~100 blocks), so a client left on that + // default reports the pre-upgrade runtime for ~20 minutes after an upgrade + // enacts: governance config reads stale, calls added by the upgrade look absent, + // and `transaction_version` is wrong, which signs extrinsics the chain rejects. + // Re-point both at the head, matching every other read path in the CLI (#152). + Self::retarget_to_head(&client, &ws_client, &display_node_url).await?; + // Reject non-Quantus / older-unsupported runtimes before encode/sign. Newer-than-table // Quantus specs are allowed with a warning (see validate_runtime_identity). if enforce_runtime_identity { @@ -164,6 +172,68 @@ impl QuantusClient { Ok(QuantusClient { client, rpc_client: ws_client, node_url: node_url.to_string() }) } + /// Re-point a freshly built client's metadata and runtime version at the chain head. + /// + /// Failing here would leave the client silently on finalized-block metadata, so a + /// lookup failure is an error rather than a fallback. + async fn retarget_to_head( + client: &OnlineClient, + ws_client: &WsClient, + display_node_url: &str, + ) -> Result<(), QuantusError> { + use codec::Decode; + use jsonrpsee::core::client::ClientT; + + // No block argument: both RPCs answer at the head. + let metadata_hex: String = ws_client + .request::("state_getMetadata", []) + .await + .map_err(|e| { + QuantusError::NetworkError(format!( + "Failed to fetch runtime metadata at the head from {display_node_url}: {e:?}" + )) + })?; + let metadata_bytes = hex::decode(metadata_hex.trim_start_matches("0x")).map_err(|e| { + QuantusError::NetworkError(format!("Runtime metadata is not valid hex: {e:?}")) + })?; + let metadata = subxt::Metadata::decode(&mut &metadata_bytes[..]).map_err(|e| { + QuantusError::NetworkError(format!("Failed to decode runtime metadata: {e:?}")) + })?; + + let version: serde_json::Value = ws_client + .request::("state_getRuntimeVersion", []) + .await + .map_err(|e| { + QuantusError::NetworkError(format!( + "Failed to fetch runtime version at the head from {display_node_url}: {e:?}" + )) + })?; + let field = |name: &str| -> Result { + version + .get(name) + .and_then(serde_json::Value::as_u64) + .and_then(|v| u32::try_from(v).ok()) + .ok_or_else(|| { + QuantusError::NetworkError(format!( + "Runtime version from {display_node_url} has no usable `{name}`" + )) + }) + }; + let runtime_version = subxt::client::RuntimeVersion { + spec_version: field("specVersion")?, + transaction_version: field("transactionVersion")?, + }; + + log_verbose!( + "๐Ÿ“ก Using head runtime: spec {} / tx {}", + runtime_version.spec_version, + runtime_version.transaction_version + ); + client.set_metadata(metadata); + client.set_runtime_version(runtime_version); + Ok(()) + } + /// Get reference to the underlying SubXT client /// The FIPS 204 context the connected runtime verifies extrinsic signatures under. Read from /// the runtime version subxt already cached at connect, so this costs no RPC. From 8fa7ab1ec7142debdb1574582142d69e9702fde0 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Fri, 4 Sep 2026 22:15:57 +0800 Subject: [PATCH 2/2] Build the client from one head snapshot, and decode old blocks with their own runtime Review of #154 found two problems with retargeting the shared client at the head. Every historical read decoded with head metadata. After an upgrade, `events --finalized`, `events --block`, `block analyze` and the SDK's finalized path failed on blocks the old runtime produced ("Could not decode Phase"). Add `QuantusClient::at_block(hash)`: a client whose metadata and runtime version come from that block. Same runtime as the head reuses the existing client, so the common case costs one `state_getRuntimeVersion`. Route the historical readers through it. The head snapshot was not coherent. `state_getMetadata` and `state_getRuntimeVersion` each resolved the head on their own, so an upgrade landing between them paired old metadata with the new version, and the identity gate made a third unpinned request. Take one best-block hash, read version and metadata at that hash, validate the same version response, and build the client with `from_backend_with`. Metadata is negotiated the way subxt does it (v16 first), instead of dropping to v14 via `state_getMetadata`. subxt already read the runtime version at the head; only metadata was pinned to the finalized block. The earlier comment claiming both were finalized was wrong. Tests: a mock node serving spec 148 at the head and 144 at the finalized block checks that connect names the head hash for both reads, and that `at_block` pins a pre-upgrade block without touching the head client or refetching for a same-runtime block. jsonrpsee's server feature is a dev-dependency only. --- Cargo.lock | 39 +++ Cargo.toml | 1 + LIBRARY_USAGE.md | 10 + src/chain/client.rs | 350 ++++++++++++++++---------- src/cli/block.rs | 42 ++-- src/cli/events.rs | 2 +- src/cli/exercise/scenarios/upgrade.rs | 3 +- src/cli/storage.rs | 14 +- src/cli/wormhole.rs | 44 ++-- 9 files changed, 323 insertions(+), 182 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cc5cbef..92c61d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3174,9 +3174,11 @@ dependencies = [ "jsonrpsee-client-transport", "jsonrpsee-core", "jsonrpsee-http-client", + "jsonrpsee-server", "jsonrpsee-types", "jsonrpsee-wasm-client", "jsonrpsee-ws-client", + "tokio", ] [[package]] @@ -3218,7 +3220,9 @@ dependencies = [ "http-body", "http-body-util", "jsonrpsee-types", + "parking_lot", "pin-project", + "rand 0.8.6", "rustc-hash 2.1.3", "serde", "serde_json", @@ -3254,6 +3258,33 @@ dependencies = [ "url", ] +[[package]] +name = "jsonrpsee-server" +version = "0.24.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c625c78b8d545478370b6e7a2a191b0d921f831a9eef38dc1e7efb57e7a5472f" +dependencies = [ + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "jsonrpsee-core", + "jsonrpsee-types", + "pin-project", + "route-recognizer", + "serde", + "serde_json", + "soketto", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tokio-util", + "tower 0.4.13", + "tracing", +] + [[package]] name = "jsonrpsee-types" version = "0.24.11" @@ -5354,6 +5385,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" +[[package]] +name = "route-recognizer" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afab94fb28594581f62d981211a9a4d53cc8130bbcbbb89a0440d9b8e81a7746" + [[package]] name = "rpassword" version = "7.5.4" @@ -6300,6 +6337,7 @@ dependencies = [ "base64", "bytes", "futures", + "http", "httparse", "log", "rand 0.8.6", @@ -7218,6 +7256,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5a7e16d..9ff358a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -142,6 +142,7 @@ qp-wormhole-circuit-builder = { version = "4.3.0" } sha2 = "0.10" [dev-dependencies] +jsonrpsee = { version = "0.24", features = ["server"] } qp-poseidon-core = "3.1.0" serial_test = "3.1" tempfile = "3.8.1" diff --git a/LIBRARY_USAGE.md b/LIBRARY_USAGE.md index 95a7b16..af55012 100644 --- a/LIBRARY_USAGE.md +++ b/LIBRARY_USAGE.md @@ -51,6 +51,16 @@ async fn main() -> Result<(), Box> { } ``` +`QuantusClient::new` reads the runtime version and metadata from the current best block, so a +runtime upgrade is visible as soon as it enacts. To decode an older block, ask for a client pinned +to that block. It shares the connection and only fetches metadata when the block ran a different +runtime: + +```rust +let at_finalized = client.at_block(finalized_hash).await?; +let events = at_finalized.client().blocks().at(finalized_hash).await?.events().await?; +``` + ### 3. Loading a Wallet for Transactions ```rust diff --git a/src/chain/client.rs b/src/chain/client.rs index 2312e3b..d671f31 100644 --- a/src/chain/client.rs +++ b/src/chain/client.rs @@ -4,14 +4,19 @@ //! across all CLI modules. use crate::{error::QuantusError, log_verbose}; -use jsonrpsee::ws_client::{WsClient, WsClientBuilder}; +use jsonrpsee::{ + core::client::ClientT, + ws_client::{WsClient, WsClientBuilder}, +}; use qp_dilithium_crypto::types::DilithiumSignatureScheme; use sp_core::crypto::AccountId32; use sp_runtime::{traits::IdentifyAccount, MultiAddress}; use std::{sync::Arc, time::Duration}; use subxt::{ - backend::rpc::RpcClient, + backend::{legacy::LegacyBackend, rpc::RpcClient, Backend, BackendExt}, + client::RuntimeVersion, config::{substrate::SubstrateHeader, DefaultExtrinsicParams}, + utils::H256, Config, OnlineClient, }; use subxt_metadata::Metadata as SubxtMetadata; @@ -131,107 +136,72 @@ impl QuantusClient { QuantusError::NetworkError(error_msg) })?; - // Wrap WS client in Arc for sharing let ws_client = Arc::new(ws_client); + let backend = Self::backend(&ws_client); - // Create RPC client wrapper for subxt - let rpc_client = RpcClient::new(ws_client.clone()); - - // Create SubXT client using the configured RPC client - let client = OnlineClient::::from_rpc_client(rpc_client).await?; - - // subxt pins metadata and runtime version to the latest *finalized* block. QPoW - // finality trails the head by a long way (~100 blocks), so a client left on that - // default reports the pre-upgrade runtime for ~20 minutes after an upgrade - // enacts: governance config reads stale, calls added by the upgrade look absent, - // and `transaction_version` is wrong, which signs extrinsics the chain rejects. - // Re-point both at the head, matching every other read path in the CLI (#152). - Self::retarget_to_head(&client, &ws_client, &display_node_url).await?; + // subxt's own constructor pins metadata to the latest finalized block. QPoW finality + // trails the head by ~100 blocks, so for ~20 minutes after an upgrade enacts that + // metadata describes the old runtime while the head already runs the new one. Read + // the runtime version and metadata from one best-block hash instead, so the pair + // cannot straddle an upgrade either. + let best = best_block_hash(&ws_client).await?; + let (version_json, runtime_version) = fetch_runtime_version(&ws_client, Some(best)).await?; // Reject non-Quantus / older-unsupported runtimes before encode/sign. Newer-than-table // Quantus specs are allowed with a warning (see validate_runtime_identity). if enforce_runtime_identity { - use jsonrpsee::core::client::ClientT; - let runtime_version: serde_json::Value = ws_client - .request::("state_getRuntimeVersion", []) - .await - .map_err(|e| { - QuantusError::NetworkError(format!("Failed to fetch runtime version: {e:?}")) - })?; - crate::config::validate_runtime_version_value(&runtime_version).map_err( - |e| match e { - QuantusError::NetworkError(msg) => - QuantusError::NetworkError(format!("{msg} (from {display_node_url})")), - other => other, - }, - )?; + crate::config::validate_runtime_version_value(&version_json).map_err(|e| match e { + QuantusError::NetworkError(msg) => + QuantusError::NetworkError(format!("{msg} (from {display_node_url})")), + other => other, + })?; } + log_verbose!( + "๐Ÿ“ก Head {:?} runs spec {} / tx {}", + best, + runtime_version.spec_version, + runtime_version.transaction_version + ); + let genesis_hash = backend.genesis_hash().await?; + let metadata = fetch_metadata_at(&backend, best).await?; + let client = + OnlineClient::from_backend_with(genesis_hash, runtime_version, metadata, backend)?; + log_verbose!("โœ… Connected to Quantus node successfully!"); Ok(QuantusClient { client, rpc_client: ws_client, node_url: node_url.to_string() }) } - /// Re-point a freshly built client's metadata and runtime version at the chain head. - /// - /// Failing here would leave the client silently on finalized-block metadata, so a - /// lookup failure is an error rather than a fallback. - async fn retarget_to_head( - client: &OnlineClient, - ws_client: &WsClient, - display_node_url: &str, - ) -> Result<(), QuantusError> { - use codec::Decode; - use jsonrpsee::core::client::ClientT; - - // No block argument: both RPCs answer at the head. - let metadata_hex: String = ws_client - .request::("state_getMetadata", []) - .await - .map_err(|e| { - QuantusError::NetworkError(format!( - "Failed to fetch runtime metadata at the head from {display_node_url}: {e:?}" - )) - })?; - let metadata_bytes = hex::decode(metadata_hex.trim_start_matches("0x")).map_err(|e| { - QuantusError::NetworkError(format!("Runtime metadata is not valid hex: {e:?}")) - })?; - let metadata = subxt::Metadata::decode(&mut &metadata_bytes[..]).map_err(|e| { - QuantusError::NetworkError(format!("Failed to decode runtime metadata: {e:?}")) - })?; - - let version: serde_json::Value = ws_client - .request::("state_getRuntimeVersion", []) - .await - .map_err(|e| { - QuantusError::NetworkError(format!( - "Failed to fetch runtime version at the head from {display_node_url}: {e:?}" - )) - })?; - let field = |name: &str| -> Result { - version - .get(name) - .and_then(serde_json::Value::as_u64) - .and_then(|v| u32::try_from(v).ok()) - .ok_or_else(|| { - QuantusError::NetworkError(format!( - "Runtime version from {display_node_url} has no usable `{name}`" - )) - }) - }; - let runtime_version = subxt::client::RuntimeVersion { - spec_version: field("specVersion")?, - transaction_version: field("transactionVersion")?, - }; + fn backend(ws_client: &Arc) -> Arc> { + Arc::new(LegacyBackend::builder().build(RpcClient::new(ws_client.clone()))) + } + /// A client whose metadata and runtime version are the ones `hash` was produced under. + /// + /// Use it to decode that block's events, extrinsics and storage. Reads at the head keep + /// using `self`, which is returned unchanged when `hash` runs the same runtime. Never + /// sign with the result: the chain verifies signatures against the head runtime. + pub async fn at_block(&self, hash: H256) -> crate::error::Result { + let (_, runtime_version) = fetch_runtime_version(&self.rpc_client, Some(hash)).await?; + if runtime_version == self.client.runtime_version() { + return Ok(self.clone()); + } log_verbose!( - "๐Ÿ“ก Using head runtime: spec {} / tx {}", + "๐Ÿ“ก Block {:?} runs spec {} / tx {}; decoding it with that runtime's metadata", + hash, runtime_version.spec_version, runtime_version.transaction_version ); - client.set_metadata(metadata); - client.set_runtime_version(runtime_version); - Ok(()) + let backend = Self::backend(&self.rpc_client); + let metadata = fetch_metadata_at(&backend, hash).await?; + let client = OnlineClient::from_backend_with( + self.client.genesis_hash(), + runtime_version, + metadata, + backend, + )?; + Ok(Self { client, rpc_client: self.rpc_client.clone(), node_url: self.node_url.clone() }) } /// Get reference to the underlying SubXT client @@ -263,19 +233,7 @@ impl QuantusClient { /// This bypasses SubXT's default behavior of using finalized blocks pub async fn get_latest_block(&self) -> crate::error::Result { log_verbose!("๐Ÿ” Fetching latest block hash via RPC..."); - - // Use RPC call to get the latest block hash - use jsonrpsee::core::client::ClientT; - let latest_hash: subxt::utils::H256 = self - .rpc_client - .request::("chain_getBlockHash", []) - .await - .map_err(|e| { - crate::error::QuantusError::NetworkError(format!( - "Failed to fetch latest block hash: {e:?}" - )) - })?; - + let latest_hash = best_block_hash(&self.rpc_client).await?; log_verbose!("๐Ÿ“ฆ Latest block hash: {:?}", latest_hash); Ok(latest_hash) } @@ -329,7 +287,6 @@ impl QuantusClient { pub async fn get_genesis_hash(&self) -> crate::error::Result { log_verbose!("๐Ÿ” Fetching genesis hash via RPC..."); - use jsonrpsee::core::client::ClientT; let genesis_hash: subxt::utils::H256 = self .rpc_client .request::("chain_getBlockHash", [0u32]) @@ -347,39 +304,19 @@ impl QuantusClient { /// Get runtime version using RPC call pub async fn get_runtime_version(&self) -> crate::error::Result<(u32, u32)> { log_verbose!("๐Ÿ” Fetching runtime version via RPC..."); - - use jsonrpsee::core::client::ClientT; - let runtime_version: serde_json::Value = self - .rpc_client - .request::("state_getRuntimeVersion", []) - .await - .map_err(|e| { - crate::error::QuantusError::NetworkError(format!( - "Failed to fetch runtime version: {e:?}" - )) - })?; - - let spec_version = runtime_version["specVersion"].as_u64().ok_or_else(|| { - crate::error::QuantusError::NetworkError("Failed to parse spec version".to_string()) - })? as u32; - - let transaction_version = - runtime_version["transactionVersion"].as_u64().ok_or_else(|| { - crate::error::QuantusError::NetworkError( - "Failed to parse transaction version".to_string(), - ) - })? as u32; - - log_verbose!("๐Ÿ”ง Runtime version: spec={}, tx={}", spec_version, transaction_version); - Ok((spec_version, transaction_version)) + let (_, version) = fetch_runtime_version(&self.rpc_client, None).await?; + log_verbose!( + "๐Ÿ”ง Runtime version: spec={}, tx={}", + version.spec_version, + version.transaction_version + ); + Ok((version.spec_version, version.transaction_version)) } /// Get runtime hash using RPC call (if available) pub async fn get_runtime_hash(&self) -> crate::error::Result> { log_verbose!("๐Ÿ” Fetching runtime hash via RPC..."); - use jsonrpsee::core::client::ClientT; - // Try different possible RPC calls for runtime hash let possible_calls = ["state_getRuntimeHash", "state_getRuntime", "chain_getRuntimeHash"]; @@ -406,6 +343,55 @@ impl QuantusClient { } } +async fn best_block_hash(ws_client: &WsClient) -> crate::error::Result { + ws_client.request::("chain_getBlockHash", []).await.map_err(|e| { + QuantusError::NetworkError(format!("Failed to fetch latest block hash: {e:?}")) + }) +} + +/// `state_getRuntimeVersion` at `at`, or at the head for `None`: the raw JSON and the parsed pair. +async fn fetch_runtime_version( + ws_client: &WsClient, + at: Option, +) -> crate::error::Result<(serde_json::Value, RuntimeVersion)> { + let value: serde_json::Value = + ws_client.request("state_getRuntimeVersion", [at]).await.map_err(|e| { + QuantusError::NetworkError(format!("Failed to fetch runtime version: {e:?}")) + })?; + let version = parse_runtime_version(&value)?; + Ok((value, version)) +} + +fn parse_runtime_version(value: &serde_json::Value) -> crate::error::Result { + let field = |name: &str| { + value + .get(name) + .and_then(serde_json::Value::as_u64) + .and_then(|v| u32::try_from(v).ok()) + .ok_or_else(|| { + QuantusError::NetworkError(format!("Runtime version has no usable `{name}`")) + }) + }; + Ok(RuntimeVersion { + spec_version: field("specVersion")?, + transaction_version: field("transactionVersion")?, + }) +} + +/// The newest metadata version the runtime at `at` serves, negotiated the way subxt does. +async fn fetch_metadata_at( + backend: &LegacyBackend, + at: H256, +) -> crate::error::Result { + for version in subxt_metadata::SUPPORTED_METADATA_VERSIONS { + match backend.metadata_at_version(version, at).await { + Ok(metadata) => return Ok(metadata), + Err(e) => log_verbose!("Metadata v{} unavailable at {:?}: {}", version, at, e), + } + } + Ok(backend.legacy_metadata(at).await?) +} + /// Scheme-aware subxt signer (ML-DSA-65 or ML-DSA-87). /// /// Pairs are boxed: Dilithium secret material is multiโ€‘KB, and an unboxed enum @@ -459,6 +445,118 @@ impl subxt::tx::Signer for QuantusSigner { #[cfg(test)] mod tests { use super::*; + use codec::Encode; + use jsonrpsee::{ + server::{RpcModule, Server, ServerHandle}, + types::ErrorObjectOwned, + }; + use serde_json::json; + use std::sync::Mutex; + + const OLD_RUNTIME: RuntimeVersion = + RuntimeVersion { spec_version: 144, transaction_version: 3 }; + const NEW_RUNTIME: RuntimeVersion = + RuntimeVersion { spec_version: 148, transaction_version: 6 }; + const GENESIS: H256 = H256([0x01; 32]); + const FINALIZED: H256 = H256([0x44; 32]); + const HEAD: H256 = H256([0x48; 32]); + + fn runtime_at(hash: H256) -> RuntimeVersion { + if hash == HEAD { + NEW_RUNTIME + } else { + OLD_RUNTIME + } + } + + /// A node caught mid-upgrade the way Heisenberg is for ~20 minutes after every enactment: + /// the head runs the new runtime, the finalized block still runs the old one. Records the + /// block named by every metadata request. + async fn mock_node() -> (String, Arc>>, ServerHandle) { + let metadata_requests = Arc::new(Mutex::new(Vec::new())); + let server = Server::builder().build("127.0.0.1:0").await.expect("bind mock node"); + let url = format!("ws://{}", server.local_addr().expect("mock node address")); + let mut module = RpcModule::new(metadata_requests.clone()); + module + .register_method("chain_getBlockHash", |params, _, _| { + let number: Option = params.sequence().optional_next()?; + Ok::<_, ErrorObjectOwned>(if number == Some(0) { GENESIS } else { HEAD }) + }) + .expect("register"); + module + .register_method("chain_getFinalizedHead", |_, _, _| { + Ok::<_, ErrorObjectOwned>(FINALIZED) + }) + .expect("register"); + module + .register_method("state_getRuntimeVersion", |params, _, _| { + let at: Option = params.sequence().optional_next()?; + let version = runtime_at(at.unwrap_or(HEAD)); + Ok::<_, ErrorObjectOwned>(json!({ + "specName": crate::config::EXPECTED_RUNTIME_SPEC_NAME, + "specVersion": version.spec_version, + "transactionVersion": version.transaction_version, + })) + }) + .expect("register"); + module + .register_method("state_call", |params, requests: &Arc>>, _| { + let mut params = params.sequence(); + let function: String = params.next()?; + let _encoded_args: String = params.next()?; + let at: Option = params.optional_next()?; + assert_eq!(function, "Metadata_metadata_at_version"); + requests + .lock() + .expect("lock") + .push(at.expect("metadata request must name a block")); + let metadata: &[u8] = include_bytes!("../quantus_metadata.scale"); + Ok::<_, ErrorObjectOwned>(format!( + "0x{}", + hex::encode(Some(metadata.to_vec()).encode()) + )) + }) + .expect("register"); + (url, metadata_requests, server.start(module)) + } + + #[tokio::test] + async fn connect_reads_runtime_version_and_metadata_from_one_head_block() { + let (url, metadata_requests, _node) = mock_node().await; + let client = QuantusClient::new(&url).await.expect("connect"); + assert_eq!(client.client().runtime_version(), NEW_RUNTIME); + assert_eq!(client.client().genesis_hash(), GENESIS); + assert_eq!(*metadata_requests.lock().expect("lock"), vec![HEAD]); + } + + #[tokio::test] + async fn at_block_decodes_a_pre_upgrade_block_with_the_runtime_that_produced_it() { + let (url, metadata_requests, _node) = mock_node().await; + let head = QuantusClient::new(&url).await.expect("connect"); + + let old = head.at_block(FINALIZED).await.expect("client at the finalized block"); + assert_eq!(old.client().runtime_version(), OLD_RUNTIME); + assert_eq!(head.client().runtime_version(), NEW_RUNTIME, "head client must be untouched"); + assert_eq!(*metadata_requests.lock().expect("lock"), vec![HEAD, FINALIZED]); + + let same = head.at_block(HEAD).await.expect("client at the head block"); + assert_eq!(same.client().runtime_version(), NEW_RUNTIME); + assert_eq!( + *metadata_requests.lock().expect("lock"), + vec![HEAD, FINALIZED], + "same runtime: metadata is not fetched again" + ); + } + + #[test] + fn parse_runtime_version_rejects_missing_fields() { + assert!(parse_runtime_version(&json!({ "specVersion": 148 })).is_err()); + assert_eq!( + parse_runtime_version(&json!({ "specVersion": 148, "transactionVersion": 6 })) + .expect("parse"), + NEW_RUNTIME + ); + } #[tokio::test] async fn quantus_client_new_redacts_userinfo_in_invalid_url_error() { diff --git a/src/cli/block.rs b/src/cli/block.rs index 5fdc6bd..1a7084b 100644 --- a/src/cli/block.rs +++ b/src/cli/block.rs @@ -113,33 +113,31 @@ async fn handle_block_analyze_command( let quantus_client = QuantusClient::new(node_url).await?; - // Determine which block to analyze - let (block_number, block_hash) = if let Some(num) = number { - // Convert number to hash using our storage function - let hash = storage::resolve_block_hash(&quantus_client, &num.to_string()).await?; - (num, hash) + let block_hash = if let Some(num) = number { + storage::resolve_block_hash(&quantus_client, &num.to_string()).await? } else if let Some(h) = hash { - // Parse hash and get block number from storage - let parsed_hash = storage::resolve_block_hash(&quantus_client, &h).await?; - // Get block number by querying System::Number at that block - let storage_at = quantus_client.client().storage().at(parsed_hash); - let number_addr = crate::chain::quantus_subxt::api::storage().system().number(); - let block_num = storage_at.fetch_or_default(&number_addr).await.map_err(|e| { - QuantusError::NetworkError(format!("Failed to get block number: {e:?}")) - })?; - (block_num, parsed_hash) + storage::resolve_block_hash(&quantus_client, &h).await? } else if latest { - // Use latest block - let hash = quantus_client.get_latest_block().await?; - let storage_at = quantus_client.client().storage().at(hash); - let number_addr = crate::chain::quantus_subxt::api::storage().system().number(); - let block_num = storage_at.fetch_or_default(&number_addr).await.map_err(|e| { - QuantusError::NetworkError(format!("Failed to get latest block number: {e:?}")) - })?; - (block_num, hash) + quantus_client.get_latest_block().await? } else { return Err(QuantusError::Generic("Must specify --number, --hash, or --latest".to_string())); }; + let quantus_client = quantus_client.at_block(block_hash).await?; + let block_number = match number { + Some(num) => num, + None => { + let number_addr = crate::chain::quantus_subxt::api::storage().system().number(); + quantus_client + .client() + .storage() + .at(block_hash) + .fetch_or_default(&number_addr) + .await + .map_err(|e| { + QuantusError::NetworkError(format!("Failed to get block number: {e:?}")) + })? + }, + }; log_print!("๐Ÿ“ฆ Block #{} - {:#x}", block_number, block_hash); log_print!(""); diff --git a/src/cli/events.rs b/src/cli/events.rs index 1eb8fac..d202510 100644 --- a/src/cli/events.rs +++ b/src/cli/events.rs @@ -119,7 +119,7 @@ pub async fn handle_events_command( log_print!("๐Ÿ”ฎ Quantus CLI"); log_print!("๐ŸŽฏ Found Block #{}", block_number); - // Get events from the block + let quantus_client = quantus_client.at_block(block_hash).await?; let events = quantus_client.client().blocks().at(block_hash).await?.events().await?; log_print!("๐Ÿ“‹ Block Events:"); diff --git a/src/cli/exercise/scenarios/upgrade.rs b/src/cli/exercise/scenarios/upgrade.rs index eb70012..c1c381e 100644 --- a/src/cli/exercise/scenarios/upgrade.rs +++ b/src/cli/exercise/scenarios/upgrade.rs @@ -367,7 +367,8 @@ async fn find_code_updated_since( } for block_hash in chain.into_iter().rev() { - let events = ctx.client.client().blocks().at(block_hash).await?.events().await?; + let at_block = ctx.client.at_block(block_hash).await?; + let events = at_block.client().blocks().at(block_hash).await?.events().await?; if events .find_first::() .map_err(|e| QuantusError::Generic(format!("failed to decode events: {e:?}")))? diff --git a/src/cli/storage.rs b/src/cli/storage.rs index 6cc0483..2f787a8 100644 --- a/src/cli/storage.rs +++ b/src/cli/storage.rs @@ -504,18 +504,18 @@ pub async fn iterate_storage_entries( limit.to_string().bright_yellow() ); - // Validate pallet exists - validate_pallet_exists(quantus_client.client(), pallet_name)?; - - // Determine block hash to use let block_hash = if let Some(block_id) = block_identifier { resolve_block_hash(quantus_client, &block_id).await? } else { quantus_client.get_latest_block().await? }; + let at_block = quantus_client.at_block(block_hash).await?; + let quantus_client = &at_block; log_verbose!("๐Ÿ“ฆ Using block: {:?}", block_hash); + validate_pallet_exists(quantus_client.client(), pallet_name)?; + // Try to get storage metadata to show what type of storage this is let metadata = quantus_client.client().metadata(); let pallet = metadata.pallet_by_name(pallet_name).unwrap(); @@ -714,13 +714,15 @@ async fn get_storage_by_parts( log_print!("๐Ÿ”‘ With key: {}", key_value.bright_yellow()); } - validate_pallet_exists(quantus_client.client(), &pallet)?; - let block_hash = if let Some(block_id) = &block { resolve_block_hash(quantus_client, block_id).await? } else { quantus_client.get_latest_block().await? }; + let at_block = quantus_client.at_block(block_hash).await?; + let quantus_client = &at_block; + + validate_pallet_exists(quantus_client.client(), &pallet)?; let entry_count = count_storage_entries(quantus_client, &pallet, &name, block_hash).await?; let is_storage_value = entry_count == 1; diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index 49d9149..5f3f083 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -1282,7 +1282,8 @@ fn show_wormhole_address(secret_file: String) -> crate::error::Result<()> { Ok(()) } -/// Fetch the latest finalized block as a fully materialised subxt `Block`. +/// Fetch the latest finalized block as a fully materialised subxt `Block`, decoded with the +/// runtime that produced it (still the pre-upgrade one while the head has moved on). /// /// Uses [`crate::error::Result`] (not `anyhow`) so it composes with the rest /// of the SDK surface. Network/decoding failures are wrapped in @@ -1299,7 +1300,8 @@ pub async fn at_finalized_block( "Failed to fetch finalized block hash: {e:?}" )) })?; - let block = quantus_client.client().blocks().at(finalized_block).await.map_err(|e| { + let at_finalized = quantus_client.at_block(finalized_block).await?; + let block = at_finalized.client().blocks().at(finalized_block).await.map_err(|e| { crate::error::QuantusError::NetworkError(format!( "Failed to fetch finalized block {finalized_block:?}: {e:?}" )) @@ -2324,22 +2326,17 @@ async fn execute_initial_transfers( // Query transfer counts BEFORE submitting the batch. // The transfer_count used in the proof is the count at the time of transfer, // which equals the count before the transfer (since it increments after). - let client = quantus_client.client(); - let tip_block_hash = wormhole_tip_block(quantus_client, execution_mode) - .await - .map_err(|e| { - crate::error::QuantusError::Generic(format!( - "Failed to get tip block for transfer counts: {}", - e - )) - })? - .hash(); + let tip_block = wormhole_tip_block(quantus_client, execution_mode).await.map_err(|e| { + crate::error::QuantusError::Generic(format!( + "Failed to get tip block for transfer counts: {}", + e + )) + })?; let mut transfer_counts_before: Vec = Vec::with_capacity(num_proofs); for secret in secrets.iter() { let wormhole_address = SubxtAccountId(*secret.address()); - let count = client + let count = tip_block .storage() - .at(tip_block_hash) .fetch(&quantus_node::api::storage().wormhole().transfer_count(wormhole_address)) .await .map_err(|e| { @@ -3777,19 +3774,14 @@ async fn run_dissolve( let initial_secret = derive_wormhole_secret(&wallet.mnemonic, 0, 1)?; let wormhole_address = SubxtAccountId(*initial_secret.address()); - let tip_block_hash = wormhole_tip_block(&quantus_client, execution_mode) - .await - .map_err(|e| { - crate::error::QuantusError::Generic(format!( - "Failed to get tip block for dissolve transfer count: {}", - e - )) - })? - .hash(); - let transfer_count_before = quantus_client - .client() + let tip_block = wormhole_tip_block(&quantus_client, execution_mode).await.map_err(|e| { + crate::error::QuantusError::Generic(format!( + "Failed to get tip block for dissolve transfer count: {}", + e + )) + })?; + let transfer_count_before = tip_block .storage() - .at(tip_block_hash) .fetch(&quantus_node::api::storage().wormhole().transfer_count(wormhole_address.clone())) .await .map_err(|e| {