quantus_cli/chain/
client.rs

1//! Common client utilities to eliminate code duplication
2//!
3//! This module provides shared functionality for creating and managing clients
4//! across all CLI modules.
5
6use crate::{error::QuantusError, log_verbose};
7use jsonrpsee::ws_client::{WsClient, WsClientBuilder};
8use qp_dilithium_crypto::types::DilithiumSignatureScheme;
9use qp_poseidon::PoseidonHasher;
10use sp_core::{crypto::AccountId32, ByteArray};
11use sp_runtime::{traits::IdentifyAccount, MultiAddress};
12use std::{sync::Arc, time::Duration};
13use subxt::{
14	backend::rpc::RpcClient,
15	config::{substrate::SubstrateHeader, DefaultExtrinsicParams},
16	Config, OnlineClient,
17};
18use subxt_metadata::Metadata as SubxtMetadata;
19
20#[derive(Debug, Clone, Copy)]
21pub struct SubxtPoseidonHasher;
22
23impl subxt::config::Hasher for SubxtPoseidonHasher {
24	type Output = sp_core::H256;
25
26	fn new(_metadata: &SubxtMetadata) -> Self {
27		SubxtPoseidonHasher
28	}
29
30	fn hash(&self, bytes: &[u8]) -> Self::Output {
31		<PoseidonHasher as sp_runtime::traits::Hash>::hash(bytes)
32	}
33}
34
35/// Configuration of the chain
36pub enum ChainConfig {}
37impl Config for ChainConfig {
38	type AccountId = AccountId32;
39	type Address = MultiAddress<Self::AccountId, ()>;
40	type Signature = DilithiumSignatureScheme;
41	type Hasher = SubxtPoseidonHasher;
42	type Header = SubstrateHeader<u32, SubxtPoseidonHasher>;
43	type AssetId = u32;
44	type ExtrinsicParams = DefaultExtrinsicParams<Self>;
45}
46
47/// Wrapper around OnlineClient that also stores the node URL and RPC client
48#[derive(Clone)]
49pub struct QuantusClient {
50	client: OnlineClient<ChainConfig>,
51	rpc_client: Arc<WsClient>,
52	node_url: String,
53}
54
55impl QuantusClient {
56	/// Create a new QuantusClient by connecting to the specified node URL
57	pub async fn new(node_url: &str) -> crate::error::Result<Self> {
58		log_verbose!("🔗 Connecting to Quantus node: {}", node_url);
59
60		// Validate URL format and provide helpful error messages
61		if !node_url.starts_with("ws://") && !node_url.starts_with("wss://") {
62			return Err(QuantusError::NetworkError(format!(
63                "Invalid WebSocket URL: '{node_url}'. URL must start with 'ws://' (unsecured) or 'wss://' (secured)"
64            )));
65		}
66
67		// Provide helpful hints for common URL issues
68		if node_url.starts_with("ws://") &&
69			(node_url.contains("a.i.res.fm") || node_url.contains("a.t.res.fm"))
70		{
71			log_verbose!(
72				"💡 Hint: Remote nodes typically require secure WebSocket connections (wss://)"
73			);
74		}
75
76		// Create WS client with custom timeouts
77		let ws_client = WsClientBuilder::default()
78            // TODO: Make these configurable in a separate change
79            // These timeouts should be configurable via CLI or config file
80            .connection_timeout(Duration::from_secs(30))
81            .request_timeout(Duration::from_secs(30))
82            .build(node_url)
83            .await
84            .map_err(|e| {
85                // Provide more helpful error messages for common issues
86                let error_str = format!("{e:?}");
87                let error_msg = if error_str.contains("TimedOut") || error_str.contains("timed out") {
88                    if node_url.starts_with("ws://") && (node_url.contains("a.i.res.fm") || node_url.contains("a.t.res.fm")) {
89                        format!(
90                            "Connection timed out. This remote node requires secure WebSocket connections (wss://). Try using 'wss://{}' instead of 'ws://{}'",
91                            node_url.strip_prefix("ws://").unwrap_or(node_url),
92                            node_url.strip_prefix("ws://").unwrap_or(node_url)
93                        )
94                    } else {
95                        format!("Connection timed out. Please check if the node is running and accessible at: {node_url}")
96                    }
97                } else if error_str.contains("HTTP") {
98                    format!("HTTP error: {error_str}. This might indicate the node doesn't support WebSocket connections")
99                } else {
100                    format!("Failed to create RPC client: {error_str}")
101                };
102                QuantusError::NetworkError(error_msg)
103            })?;
104
105		// Wrap WS client in Arc for sharing
106		let ws_client = Arc::new(ws_client);
107
108		// Create RPC client wrapper for subxt
109		let rpc_client = RpcClient::new(ws_client.clone());
110
111		// Create SubXT client using the configured RPC client
112		let client = OnlineClient::<ChainConfig>::from_rpc_client(rpc_client).await?;
113
114		log_verbose!("✅ Connected to Quantus node successfully!");
115
116		Ok(QuantusClient { client, rpc_client: ws_client, node_url: node_url.to_string() })
117	}
118
119	/// Get reference to the underlying SubXT client
120	pub fn client(&self) -> &OnlineClient<ChainConfig> {
121		&self.client
122	}
123
124	/// Get the node URL
125	pub fn node_url(&self) -> &str {
126		&self.node_url
127	}
128
129	/// Get reference to the RPC client
130	pub fn rpc_client(&self) -> &WsClient {
131		&self.rpc_client
132	}
133
134	/// Get the latest block (best block) using RPC call
135	/// This bypasses SubXT's default behavior of using finalized blocks
136	pub async fn get_latest_block(&self) -> crate::error::Result<subxt::utils::H256> {
137		log_verbose!("🔍 Fetching latest block hash via RPC...");
138
139		// Use RPC call to get the latest block hash
140		use jsonrpsee::core::client::ClientT;
141		let latest_hash: subxt::utils::H256 = self
142			.rpc_client
143			.request::<subxt::utils::H256, [(); 0]>("chain_getBlockHash", [])
144			.await
145			.map_err(|e| {
146				crate::error::QuantusError::NetworkError(format!(
147					"Failed to fetch latest block hash: {e:?}"
148				))
149			})?;
150
151		log_verbose!("📦 Latest block hash: {:?}", latest_hash);
152		Ok(latest_hash)
153	}
154
155	/// Get account nonce from the best block (latest) using direct RPC call
156	/// This bypasses SubXT's default behavior of using finalized blocks
157	pub async fn get_account_nonce_from_best_block(
158		&self,
159		account_id: &AccountId32,
160	) -> crate::error::Result<u64> {
161		log_verbose!("🔍 Fetching account nonce from best block via RPC...");
162
163		// Get latest block hash first
164		let latest_block_hash = self.get_latest_block().await?;
165		log_verbose!("📦 Latest block hash for nonce query: {:?}", latest_block_hash);
166
167		// Convert sp_core::AccountId32 to subxt::utils::AccountId32
168		let account_bytes: [u8; 32] = *account_id.as_ref();
169		let subxt_account_id = subxt::utils::AccountId32::from(account_bytes);
170
171		// Use SubXT's storage API to query nonce at the best block
172		use crate::chain::quantus_subxt::api;
173		let storage_addr = api::storage().system().account(subxt_account_id);
174
175		let storage_at = self.client.storage().at(latest_block_hash);
176
177		let account_info = storage_at.fetch_or_default(&storage_addr).await?;
178
179		log_verbose!("✅ Nonce from best block: {}", account_info.nonce);
180		Ok(account_info.nonce as u64)
181	}
182
183	/// Get genesis hash using RPC call
184	pub async fn get_genesis_hash(&self) -> crate::error::Result<subxt::utils::H256> {
185		log_verbose!("🔍 Fetching genesis hash via RPC...");
186
187		use jsonrpsee::core::client::ClientT;
188		let genesis_hash: subxt::utils::H256 = self
189			.rpc_client
190			.request::<subxt::utils::H256, [u32; 1]>("chain_getBlockHash", [0u32])
191			.await
192			.map_err(|e| {
193				crate::error::QuantusError::NetworkError(format!(
194					"Failed to fetch genesis hash: {e:?}"
195				))
196			})?;
197
198		log_verbose!("🧬 Genesis hash: {:?}", genesis_hash);
199		Ok(genesis_hash)
200	}
201
202	/// Get runtime version using RPC call
203	pub async fn get_runtime_version(&self) -> crate::error::Result<(u32, u32)> {
204		log_verbose!("🔍 Fetching runtime version via RPC...");
205
206		use jsonrpsee::core::client::ClientT;
207		let runtime_version: serde_json::Value = self
208			.rpc_client
209			.request::<serde_json::Value, [(); 0]>("state_getRuntimeVersion", [])
210			.await
211			.map_err(|e| {
212				crate::error::QuantusError::NetworkError(format!(
213					"Failed to fetch runtime version: {e:?}"
214				))
215			})?;
216
217		let spec_version = runtime_version["specVersion"].as_u64().ok_or_else(|| {
218			crate::error::QuantusError::NetworkError("Failed to parse spec version".to_string())
219		})? as u32;
220
221		let transaction_version =
222			runtime_version["transactionVersion"].as_u64().ok_or_else(|| {
223				crate::error::QuantusError::NetworkError(
224					"Failed to parse transaction version".to_string(),
225				)
226			})? as u32;
227
228		log_verbose!("🔧 Runtime version: spec={}, tx={}", spec_version, transaction_version);
229		Ok((spec_version, transaction_version))
230	}
231
232	/// Get runtime hash using RPC call (if available)
233	pub async fn get_runtime_hash(&self) -> crate::error::Result<Option<String>> {
234		log_verbose!("🔍 Fetching runtime hash via RPC...");
235
236		use jsonrpsee::core::client::ClientT;
237
238		// Try different possible RPC calls for runtime hash
239		let possible_calls = ["state_getRuntimeHash", "state_getRuntime", "chain_getRuntimeHash"];
240
241		for call_name in &possible_calls {
242			match self.rpc_client.request::<serde_json::Value, [(); 0]>(call_name, []).await {
243				Ok(result) => {
244					log_verbose!("✅ Found runtime hash via {}", call_name);
245					if let Some(hash) = result.as_str() {
246						return Ok(Some(hash.to_string()));
247					} else if let Some(hash_obj) = result.get("hash") {
248						if let Some(hash) = hash_obj.as_str() {
249							return Ok(Some(hash.to_string()));
250						}
251					}
252				},
253				Err(_e) => {
254					log_verbose!("❌ {} failed: {:?}", call_name, _e);
255				},
256			}
257		}
258
259		log_verbose!("⚠️  No runtime hash RPC call available");
260		Ok(None)
261	}
262}
263
264// Implement subxt::tx::Signer for ResonancePair
265impl subxt::tx::Signer<ChainConfig> for qp_dilithium_crypto::types::DilithiumPair {
266	fn account_id(&self) -> <ChainConfig as Config>::AccountId {
267		let resonance_public =
268			qp_dilithium_crypto::types::DilithiumPublic::from_slice(self.public.as_slice())
269				.expect("Invalid public key");
270		<qp_dilithium_crypto::types::DilithiumPublic as IdentifyAccount>::into_account(
271			resonance_public,
272		)
273	}
274
275	fn sign(&self, signer_payload: &[u8]) -> <ChainConfig as Config>::Signature {
276		// Use the sign method from the trait implemented for ResonancePair
277		// sp_core::Pair::sign returns ResonanceSignatureWithPublic, which we need to wrap in
278		// ResonanceSignatureScheme
279		let signature_with_public =
280			<qp_dilithium_crypto::types::DilithiumPair as sp_core::Pair>::sign(
281				self,
282				signer_payload,
283			);
284		qp_dilithium_crypto::types::DilithiumSignatureScheme::Dilithium(signature_with_public)
285	}
286}