diff --git a/src/chain/client.rs b/src/chain/client.rs index d671f31..12b346d 100644 --- a/src/chain/client.rs +++ b/src/chain/client.rs @@ -204,6 +204,38 @@ impl QuantusClient { Ok(Self { client, rpc_client: self.rpc_client.clone(), node_url: self.node_url.clone() }) } + /// Partial fee for an already-signed extrinsic, computed at the **head**. + /// + /// subxt's own `partial_fee_estimate` calls `TransactionPaymentApi_query_info` at + /// `latest_finalized_block_ref`. QPoW finality trails the head by ~100 blocks, so + /// for ~20 minutes after a runtime upgrade that changes fees the estimate is the + /// *old* runtime's. That made `send` refuse transfers it could afford, quoting a + /// fee 10x the real one right after mainnet 152 -> 153 cut `FEE_SCALE`. + /// + /// Uses [`Self::get_latest_block`], so `--finalized` moves it with every other read. + pub async fn partial_fee(&self, encoded_tx: &[u8]) -> Result { + use codec::Encode; + + let mut params = encoded_tx.to_vec(); + (encoded_tx.len() as u32).encode_to(&mut params); + let head = self.get_latest_block().await?; + + // RuntimeDispatchInfo: { weight_ref_time, weight_proof_size, class, partial_fee } + let (_, _, _, partial_fee) = self + .client + .backend() + .call_decoding::<(codec::Compact, codec::Compact, u8, u128)>( + "TransactionPaymentApi_query_info", + Some(¶ms), + head, + ) + .await + .map_err(|e| { + QuantusError::NetworkError(format!("Failed to estimate transaction fee: {e:?}")) + })?; + Ok(partial_fee) + } + /// 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. @@ -229,9 +261,28 @@ impl QuantusClient { &self.rpc_client } - /// Get the latest block (best block) using RPC call - /// This bypasses SubXT's default behavior of using finalized blocks + /// The block every read in the CLI is taken at: [`Self::head_or_finalized`] under the + /// `--finalized` switch `main` installs. pub async fn get_latest_block(&self) -> crate::error::Result { + self.head_or_finalized(crate::cli::common::ExecutionMode::reads_at_finalized()) + .await + } + + /// The finalized block when `finalized`, the head otherwise. + /// + /// subxt's own default is the finalized block, which on this chain is ~100 blocks (~20 + /// minutes) behind — stale state, and after a runtime upgrade the previous runtime's state + /// entirely. Callers whose reads must match the [`ExecutionMode`] they were handed pass its + /// `finalized` here: library callers never install the switch. + /// + /// [`ExecutionMode`]: crate::cli::common::ExecutionMode + pub async fn head_or_finalized(&self, finalized: bool) -> crate::error::Result { + if finalized { + log_verbose!("🔍 Fetching finalized block hash via RPC..."); + let hash = finalized_block_hash(&self.rpc_client).await?; + log_verbose!("đŸ“Ļ Finalized block hash: {:?}", hash); + return Ok(hash); + } log_verbose!("🔍 Fetching latest block hash via RPC..."); let latest_hash = best_block_hash(&self.rpc_client).await?; log_verbose!("đŸ“Ļ Latest block hash: {:?}", latest_hash); @@ -257,8 +308,9 @@ impl QuantusClient { ) -> crate::error::Result { log_verbose!("🔍 Fetching account nonce from best block via RPC..."); - // Get latest block hash first - let latest_block_hash = self.get_latest_block().await?; + // The head even under `--finalized`: signing with the finalized block's nonce makes the + // extrinsic outdated whenever the account has anything unfinalized. + let latest_block_hash = self.head_or_finalized(false).await?; log_verbose!("đŸ“Ļ Latest block hash for nonce query: {:?}", latest_block_hash); // Convert sp_core::AccountId32 to subxt::utils::AccountId32 @@ -313,6 +365,14 @@ impl QuantusClient { Ok((version.spec_version, version.transaction_version)) } + /// `state_getRuntimeVersion` at `at`: the raw JSON and the parsed pair. + pub async fn get_runtime_version_at( + &self, + at: H256, + ) -> crate::error::Result<(serde_json::Value, RuntimeVersion)> { + fetch_runtime_version(&self.rpc_client, Some(at)).await + } + /// 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..."); @@ -343,6 +403,17 @@ impl QuantusClient { } } +/// Finalized block hash via RPC. +async fn finalized_block_hash(ws_client: &WsClient) -> crate::error::Result { + use jsonrpsee::core::client::ClientT; + ws_client + .request::("chain_getFinalizedHead", []) + .await + .map_err(|e| { + QuantusError::NetworkError(format!("Failed to fetch finalized block hash: {e:?}")) + }) +} + 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:?}")) @@ -443,7 +514,7 @@ impl subxt::tx::Signer for QuantusSigner { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use codec::Encode; use jsonrpsee::{ @@ -458,8 +529,8 @@ mod tests { 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]); + pub(crate) const FINALIZED: H256 = H256([0x44; 32]); + pub(crate) const HEAD: H256 = H256([0x48; 32]); fn runtime_at(hash: H256) -> RuntimeVersion { if hash == HEAD { @@ -472,7 +543,7 @@ mod tests { /// 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) { + pub(crate) 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")); @@ -488,6 +559,17 @@ mod tests { Ok::<_, ErrorObjectOwned>(FINALIZED) }) .expect("register"); + module + .register_method("chain_getHeader", |_, _, _| { + Ok::<_, ErrorObjectOwned>(json!({ + "parentHash": GENESIS, + "number": "0x1", + "stateRoot": GENESIS, + "extrinsicsRoot": GENESIS, + "digest": { "logs": [] }, + })) + }) + .expect("register"); module .register_method("state_getRuntimeVersion", |params, _, _| { let at: Option = params.sequence().optional_next()?; diff --git a/src/cli/batch.rs b/src/cli/batch.rs index bece446..cf2f1cf 100644 --- a/src/cli/batch.rs +++ b/src/cli/batch.rs @@ -194,7 +194,7 @@ async fn handle_batch_send_command( if !execution_mode.should_watch_transaction() { log_print!( - "â„šī¸ The batch transaction was {} but this command did not wait for block inclusion. Use --wait-for-transaction or --finalized-tx to wait before returning.", + "â„šī¸ The batch transaction was {} but this command did not wait for block inclusion. Use --wait-for-transaction or --finalized to wait before returning.", transaction_stage.success_detail() ); return Ok(()); diff --git a/src/cli/cold_signing.rs b/src/cli/cold_signing.rs index 280f118..3cf046f 100644 --- a/src/cli/cold_signing.rs +++ b/src/cli/cold_signing.rs @@ -439,7 +439,7 @@ async fn estimate_fee_with_dummy_signature( client.client().tx().create_v4_partial_offline(call, build_params(ctx)).ok()?; let tx = partial .sign_with_account_and_signature(account, &DilithiumSignatureScheme::Dilithium87(dummy)); - tx.partial_fee_estimate().await.ok() + client.partial_fee(tx.encoded()).await.ok() } /// Fee estimate for a cold wallet when no [`TxContext`] exists yet (balance diff --git a/src/cli/common.rs b/src/cli/common.rs index 5b966ef..6221dad 100644 --- a/src/cli/common.rs +++ b/src/cli/common.rs @@ -34,7 +34,28 @@ pub enum TransactionStage { Finalized, } +static GLOBAL_MODE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + impl ExecutionMode { + /// Install the finality choice process-wide, once, from `main`. + /// + /// Waiting for a transaction is driven by the [`ExecutionMode`] threaded through each + /// command, but reads are taken in ~80 places that have no reason to carry one. Rather + /// than thread it everywhere, the same flag is published here and read by + /// `QuantusClient::get_latest_block`, so one switch governs both. + pub fn install(self) { + GLOBAL_MODE.store(self.finalized, std::sync::atomic::Ordering::Relaxed); + } + + /// Whether reads should be taken at the finalized block rather than the head. + /// + /// False by default: QPoW finality trails the head by ~100 blocks, so finalized + /// reads serve state ~20 minutes stale — after a runtime upgrade, the *previous* + /// runtime's state. + pub fn reads_at_finalized() -> bool { + GLOBAL_MODE.load(std::sync::atomic::Ordering::Relaxed) + } + pub fn transaction_stage(self) -> TransactionStage { if self.finalized { TransactionStage::Finalized @@ -188,9 +209,9 @@ fn should_check_execution_success( } /// Require the watched extrinsic to be present in the reported block. -/// Returns its index for event scanning, or an error if the hash is absent. -fn require_extrinsic_index(our_extrinsic_index: Option) -> Result { - our_extrinsic_index.ok_or_else(|| { +/// Returns it for event scanning, or an error if the hash is absent. +fn require_extrinsic(our_extrinsic: Option) -> Result { + our_extrinsic.ok_or_else(|| { crate::error::QuantusError::NetworkError( "Extrinsic hash not found in reported block".to_string(), ) @@ -693,6 +714,31 @@ pub async fn submit_transaction_with_nonce( nonce: u32, execution_mode: ExecutionMode, ) -> crate::error::Result +where + Call: subxt::tx::Payload, +{ + let (tx_hash, _included_in) = submit_transaction_with_nonce_and_inclusion_block( + quantus_client, + signer, + call, + tip, + nonce, + execution_mode, + ) + .await?; + Ok(tx_hash) +} + +/// Like [`submit_transaction_with_nonce`], but also returns the inclusion block, as +/// [`submit_transaction_with_inclusion_block`] does. +pub async fn submit_transaction_with_nonce_and_inclusion_block( + quantus_client: &crate::chain::client::QuantusClient, + signer: &crate::wallet::WalletSigner, + call: Call, + tip: Option, + nonce: u32, + execution_mode: ExecutionMode, +) -> crate::error::Result<(subxt::utils::H256, Option)> where Call: subxt::tx::Payload, { @@ -712,8 +758,7 @@ where execution_mode, crate::cli::cold_signing::ColdIo::global(), ) - .await - .map(|(tx_hash, _included_in)| tx_hash), + .await, }; ensure_keypair_scheme_supported(quantus_client, from_keypair).await?; let signer = from_keypair.to_subxt_signer(quantus_client.signing_context()).map_err(|e| { @@ -754,14 +799,14 @@ where Ok(mut tx_progress) => { let tx_hash = tx_progress.extrinsic_hash(); crate::log_print!("✅ Transaction submitted: {:?}", tx_hash); - let _included_in = wait_tx_inclusion( + let included_in = wait_tx_inclusion( &mut tx_progress, quantus_client.client(), &tx_hash, execution_mode.transaction_stage(), ) .await?; - Ok(tx_hash) + Ok((tx_hash, Some(included_in))) }, Err(e) => { log_error!("❌ Failed to submit transaction with manual nonce {}: {e:?}", nonce); @@ -772,7 +817,7 @@ where match quantus_client.client().tx().sign_and_submit(&call, &signer, params).await { Ok(tx_hash) => { crate::log_print!("✅ Transaction submitted: {:?}", tx_hash); - Ok(tx_hash) + Ok((tx_hash, None)) }, Err(e) => { log_error!("❌ Failed to submit transaction: {e:?}"); @@ -1089,13 +1134,12 @@ pub async fn submit_preimage( Ok(()) } -pub(crate) async fn check_execution_success( +/// The first `E` the extrinsic `tx_hash` emitted in `block_hash`, if any. +pub(crate) async fn find_extrinsic_event( client: &OnlineClient, block_hash: &subxt::utils::H256, tx_hash: &subxt::utils::H256, -) -> Result<()> { - use crate::chain::quantus_subxt::api::system::events::ExtrinsicFailed; - +) -> Result> { let block = client.blocks().at(*block_hash).await.map_err(|e| { crate::error::QuantusError::NetworkError(format!("Failed to get block: {e:?}")) })?; @@ -1104,38 +1148,34 @@ pub(crate) async fn check_execution_success( crate::error::QuantusError::NetworkError(format!("Failed to get extrinsics: {e:?}")) })?; - let our_extrinsic_index = extrinsics - .iter() - .enumerate() - .find(|(_, ext)| ext.hash() == *tx_hash) - .map(|(idx, _)| idx); - - let events = block.events().await.map_err(|e| { - crate::error::QuantusError::NetworkError(format!("Failed to fetch events: {e:?}")) - })?; + let events = require_extrinsic(extrinsics.iter().find(|ext| ext.hash() == *tx_hash))? + .events() + .await + .map_err(|e| { + crate::error::QuantusError::NetworkError(format!("Failed to fetch events: {e:?}")) + })?; - let ext_idx = require_extrinsic_index(our_extrinsic_index)?; + events.find_first::().map_err(|e| { + crate::error::QuantusError::NetworkError(format!("Failed to decode event: {e:?}")) + }) +} - let metadata = client.metadata(); - for event_result in events.iter() { - let event = event_result.map_err(|e| { - crate::error::QuantusError::NetworkError(format!("Failed to decode event: {e:?}")) - })?; +pub(crate) async fn check_execution_success( + client: &OnlineClient, + block_hash: &subxt::utils::H256, + tx_hash: &subxt::utils::H256, +) -> Result<()> { + use crate::chain::quantus_subxt::api::system::events::ExtrinsicFailed; - if let subxt::events::Phase::ApplyExtrinsic(event_ext_idx) = event.phase() { - if event_ext_idx == ext_idx as u32 { - if let Ok(Some(ExtrinsicFailed { dispatch_error, .. })) = - event.as_event::() - { - let error_msg = format_dispatch_error(&dispatch_error, &metadata); - crate::log_error!(" Transaction failed: {}", error_msg); - return Err(crate::error::QuantusError::NetworkError(format!( - "Transaction execution failed: {}", - error_msg - ))); - } - } - } + if let Some(ExtrinsicFailed { dispatch_error, .. }) = + find_extrinsic_event(client, block_hash, tx_hash).await? + { + let error_msg = format_dispatch_error(&dispatch_error, &client.metadata()); + crate::log_error!(" Transaction failed: {}", error_msg); + return Err(crate::error::QuantusError::NetworkError(format!( + "Transaction execution failed: {}", + error_msg + ))); } Ok(()) @@ -1167,6 +1207,18 @@ mod tests { assert_eq!(delay_seconds_to_millis(1).unwrap(), 1_000); } + #[test] + fn reads_follow_the_installed_finality_flag() { + // Default: reads take the head, so state is never ~100 blocks stale. + assert!(!ExecutionMode::reads_at_finalized()); + + ExecutionMode { finalized: true, wait_for_transaction: false }.install(); + assert!(ExecutionMode::reads_at_finalized()); + + ExecutionMode { finalized: false, wait_for_transaction: true }.install(); + assert!(!ExecutionMode::reads_at_finalized()); + } + #[test] fn finalized_mode_implies_waiting_for_finalization() { let mode = ExecutionMode { finalized: true, wait_for_transaction: false }; @@ -1311,7 +1363,7 @@ mod tests { #[test] fn missing_extrinsic_hash_in_reported_block_is_error() { - let err = require_extrinsic_index(None).expect_err("absent extrinsic must not succeed"); + let err = require_extrinsic::(None).expect_err("absent extrinsic must not succeed"); match err { crate::error::QuantusError::NetworkError(msg) => { assert!( @@ -1322,7 +1374,7 @@ mod tests { other => panic!("expected NetworkError, got {other:?}"), } - assert_eq!(require_extrinsic_index(Some(3)).unwrap(), 3); + assert_eq!(require_extrinsic(Some(3)).unwrap(), 3); } #[test] diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 318cd40..d07a1bf 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -46,9 +46,20 @@ pub enum Commands { #[arg(short, long)] to: String, - /// Amount to send (e.g., "10", "10.5", "0.0001") - #[arg(short, long)] - amount: String, + /// Amount to send (e.g., "10", "10.5", "0.0001"). Omit with `--all`. + #[arg(short, long, required_unless_present = "all", conflicts_with = "all")] + amount: Option, + + /// Send the whole free balance, letting the chain deduct the fee exactly. + /// + /// Uses `Balances::transfer_all`, so no fee estimate has to be guessed and + /// nothing is stranded. The account is reaped unless `--keep-alive`. + #[arg(long)] + all: bool, + + /// With `--all`, leave the existential deposit behind so the account survives. + #[arg(long, requires = "all")] + keep_alive: bool, /// Wallet name to send from #[arg(short, long)] @@ -385,11 +396,22 @@ pub async fn execute_command( ) -> crate::error::Result<()> { match command { Commands::Wallet(wallet_cmd) => wallet::handle_wallet_command(wallet_cmd, node_url).await, - Commands::Send { from, to, amount, password, password_file, tip, nonce } => + Commands::Send { + from, + to, + amount, + all, + keep_alive, + password, + password_file, + tip, + nonce, + } => send::handle_send_command( from, to, - &amount, + amount.as_deref(), + all.then_some(keep_alive), node_url, password, password_file, diff --git a/src/cli/send.rs b/src/cli/send.rs index 05c0225..d9d13f8 100644 --- a/src/cli/send.rs +++ b/src/cli/send.rs @@ -256,6 +256,18 @@ fn build_transfer_call_for_account_id( ) } +/// Move the whole free balance, with the chain deducting the exact fee. +/// +/// `keep_alive` leaves the existential deposit behind instead of reaping the account. +fn build_transfer_all_call_for_account_id( + to_account_id: SubxtAccountId32, + keep_alive: bool, +) -> impl subxt::tx::Payload { + quantus_subxt::api::tx() + .balances() + .transfer_all(subxt::ext::subxt_core::utils::MultiAddress::Id(to_account_id), keep_alive) +} + pub(crate) fn build_batch_transfer_call( transfers: &[(String, u128)], ) -> Result { @@ -329,11 +341,7 @@ where )) })?; - signed_tx.partial_fee_estimate().await.map_err(|e| { - crate::error::QuantusError::NetworkError(format!( - "Failed to estimate transaction fee: {e:?}" - )) - }) + quantus_client.partial_fee(signed_tx.encoded()).await } pub(crate) async fn ensure_balance_covers_call( @@ -405,13 +413,13 @@ async fn submit_transfer_call( submit_tip: Option, nonce: Option, execution_mode: crate::cli::common::ExecutionMode, -) -> Result +) -> Result<(subxt::utils::H256, Option)> where Call: subxt::tx::Payload, { if let Some(manual_nonce) = nonce { log_verbose!("đŸ”ĸ Using manual nonce: {}", manual_nonce); - crate::cli::common::submit_transaction_with_nonce( + crate::cli::common::submit_transaction_with_nonce_and_inclusion_block( quantus_client, signer, transfer_call, @@ -421,7 +429,7 @@ where ) .await } else { - crate::cli::common::submit_transaction( + crate::cli::common::submit_transaction_with_inclusion_block( quantus_client, signer, transfer_call, @@ -474,7 +482,7 @@ pub async fn transfer_with_nonce( let submit_tip = positive_tip_amount(tip); // Submit the transaction with optional manual nonce - let tx_hash = submit_transfer_call( + let (tx_hash, _) = submit_transfer_call( quantus_client, signer, transfer_call, @@ -584,10 +592,12 @@ pub async fn batch_transfer( /// Handle the send command #[allow(clippy::too_many_arguments)] +/// `sweep_keep_alive` is `Some(keep_alive)` for `--all`, `None` for a fixed amount. pub async fn handle_send_command( from_wallet: String, to_address: String, - amount_str: &str, + amount_str: Option<&str>, + sweep_keep_alive: Option, node_url: &str, password: Option, password_file: Option, @@ -598,9 +608,15 @@ pub async fn handle_send_command( // Create quantus chain client let quantus_client = QuantusClient::new(node_url).await?; - // Parse and validate the amount - let (amount, formatted_amount) = - validate_and_format_amount(&quantus_client, amount_str).await?; + // Parse and validate the amount. With `--all` the chain computes it, so there is + // nothing to validate and nothing to reserve against. + let (amount, formatted_amount) = match amount_str { + Some(raw) => { + let (value, formatted) = validate_and_format_amount(&quantus_client, raw).await?; + (Some(value), formatted) + }, + None => (None, "the entire free balance".to_string()), + }; // Resolve the destination address (could be wallet name or SS58 address) let (resolved_address, to_account_id) = resolve_address_with_subxt_account_id(&to_address)?; @@ -635,24 +651,40 @@ pub async fn handle_send_command( }; let effective_tip = effective_tip_amount(tip_amount); let submit_tip = positive_tip_amount(tip_amount); - let exact_required = checked_add(amount, effective_tip, "required send balance")?; - let transfer_call = build_transfer_call_for_account_id(to_account_id, amount); - ensure_balance_covers_call( - &quantus_client, - &signer, - &transfer_call, - balance, - exact_required, - submit_tip, - "send", - ) - .await?; + + let transfer_call = match (amount, sweep_keep_alive) { + // `transfer_all` asks the chain to move everything minus the exact fee, so the + // balance precheck an estimate would drive has nothing to check. + (None, Some(keep_alive)) => + Box::new(build_transfer_all_call_for_account_id(to_account_id, keep_alive)) + as Box, + (Some(value), _) => { + let exact_required = checked_add(value, effective_tip, "required send balance")?; + let call = Box::new(build_transfer_call_for_account_id(to_account_id, value)) + as Box; + ensure_balance_covers_call( + &quantus_client, + &signer, + &call, + balance, + exact_required, + submit_tip, + "send", + ) + .await?; + call + }, + (None, None) => + return Err(crate::error::QuantusError::Generic( + "send needs either --amount or --all".to_string(), + )), + }; // Create and submit transaction log_verbose!("âœī¸ {} Signing transaction...", "SIGN".bright_magenta().bold()); // Submit transaction - let tx_hash = submit_transfer_call( + let (tx_hash, included_in) = submit_transfer_call( &quantus_client, &signer, transfer_call, @@ -662,17 +694,15 @@ pub async fn handle_send_command( ) .await?; - print_send_result(&quantus_client, &from_account_id, balance, amount, tx_hash, execution_mode) - .await + print_send_result(&quantus_client, &from_account_id, tx_hash, included_in, execution_mode).await } /// Print the post-submission summary (status, new balance, fee). async fn print_send_result( quantus_client: &QuantusClient, from_account_id: &str, - balance_before: u128, - amount: u128, tx_hash: subxt::utils::H256, + included_in: Option, execution_mode: crate::cli::common::ExecutionMode, ) -> Result<()> { let transaction_stage = execution_mode.transaction_stage(); @@ -683,13 +713,13 @@ async fn print_send_result( tx_hash ); - if !execution_mode.should_watch_transaction() { + let Some(block_hash) = included_in else { log_print!( - "â„šī¸ The transaction was {} but this command did not wait for block inclusion. Use --wait-for-transaction or --finalized-tx to wait before returning.", + "â„šī¸ The transaction was {} but this command did not wait for block inclusion. Use --wait-for-transaction or --finalized to wait before returning.", transaction_stage.success_detail() ); return Ok(()); - } + }; log_success!( "🎉 {} Transaction {}.", @@ -701,10 +731,23 @@ async fn print_send_result( let new_balance = get_balance(quantus_client, from_account_id).await?; let formatted_new_balance = format_balance_with_symbol(quantus_client, new_balance).await?; - // Calculate and display transaction fee in verbose mode - let fee_paid = balance_before.saturating_sub(new_balance).saturating_sub(amount); - if fee_paid > 0 { - let formatted_fee = format_balance_with_symbol(quantus_client, fee_paid).await?; + // What the chain took, tip included, read from the included transaction: a balance delta + // cannot separate it from what `--all` moved. Not `TransactionFeePaid`, which omits the + // refund an account reaped by `--all` forfeits. + if crate::log::is_verbose() { + use quantus_subxt::api::mining_rewards::events::FeesCollected; + let collected = crate::cli::common::find_extrinsic_event::( + quantus_client.client(), + &block_hash, + &tx_hash, + ) + .await? + .ok_or_else(|| { + crate::error::QuantusError::Generic(format!( + "Transaction {tx_hash:?} emitted no MiningRewards::FeesCollected event" + )) + })?; + let formatted_fee = format_balance_with_symbol(quantus_client, collected.amount).await?; log_verbose!("💸 Transaction fee: {}", formatted_fee.bright_cyan()); } @@ -768,6 +811,30 @@ pub async fn get_batch_limits(quantus_client: &QuantusClient) -> Result<(u32, u3 #[cfg(test)] mod tests { + /// `--all` must build `transfer_all`, not a transfer of a guessed amount: the + /// chain deducts the exact fee, which is the whole point of the flag. + #[test] + fn transfer_all_builds_the_transfer_all_call() { + use super::{build_transfer_all_call_for_account_id, SubxtAccountId32}; + use subxt::tx::Payload; + let dest = SubxtAccountId32::from([9u8; 32]); + let call = build_transfer_all_call_for_account_id(dest, false); + let details = call.validation_details().expect("static payload"); + assert_eq!(details.pallet_name, "Balances"); + assert_eq!(details.call_name, "transfer_all"); + } + + #[test] + fn fixed_amount_still_builds_transfer_allow_death() { + use super::{build_transfer_call_for_account_id, SubxtAccountId32}; + use subxt::tx::Payload; + let dest = SubxtAccountId32::from([9u8; 32]); + let call = build_transfer_call_for_account_id(dest, 1_000); + let details = call.validation_details().expect("static payload"); + assert_eq!(details.pallet_name, "Balances"); + assert_eq!(details.call_name, "transfer_allow_death"); + } + use super::{ build_batch_transfer_call, effective_tip_amount, format_balance, limits_from_batched_calls_limit, parse_amount_with_decimals, diff --git a/src/cli/system.rs b/src/cli/system.rs index 5efd2c1..97da7b1 100644 --- a/src/cli/system.rs +++ b/src/cli/system.rs @@ -181,16 +181,21 @@ pub async fn get_detailed_chain_params( let genesis_hash = quantus_client.get_genesis_hash().await?; log_print!(" đŸ§Ŧ Genesis hash: {}", genesis_hash.to_string().bright_cyan()); - // Get runtime version - let (spec_version, transaction_version) = quantus_client.get_runtime_version().await?; - log_print!(" 📋 Spec version: {}", spec_version.to_string().bright_green()); - log_print!(" 🔄 Transaction version: {}", transaction_version.to_string().bright_yellow()); + // Runtime and header both come from the block reads are taken at, so `--finalized` moves + // them together. + let at = quantus_client.get_latest_block().await?; + let (runtime_info, version) = quantus_client.get_runtime_version_at(at).await?; + log_print!(" 📋 Spec version: {}", version.spec_version.to_string().bright_green()); + log_print!( + " 🔄 Transaction version: {}", + version.transaction_version.to_string().bright_yellow() + ); // Get current block info use jsonrpsee::core::client::ClientT; let current_block: serde_json::Value = quantus_client .rpc_client() - .request::("chain_getHeader", []) + .request::("chain_getHeader", [at]) .await .map_err(|e| { crate::error::QuantusError::NetworkError(format!( @@ -214,15 +219,6 @@ pub async fn get_detailed_chain_params( } } - // Get full runtime info - let runtime_info: serde_json::Value = quantus_client - .rpc_client() - .request::("state_getRuntimeVersion", []) - .await - .map_err(|e| { - crate::error::QuantusError::NetworkError(format!("Failed to fetch runtime info: {e:?}")) - })?; - if show_raw_data { log_verbose!("📋 Full runtime info: {:?}", runtime_info); } diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index 28544f1..d075361 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -1189,7 +1189,7 @@ pub enum WormholeCommands { } /// Wait mode for wormhole steps that must observe inclusion (events / next-round inputs). -/// Honors `--finalized-tx`; otherwise waits for best-block inclusion only. +/// Honors `--finalized`; otherwise waits for best-block inclusion only. fn wormhole_inclusion_mode(execution_mode: ExecutionMode) -> ExecutionMode { ExecutionMode { wait_for_transaction: true, ..execution_mode } } @@ -1208,16 +1208,13 @@ fn included_at_for_stage(stage: TransactionStage) -> IncludedAt { /// Tip block used for pre-submit storage reads and for ZK Merkle proof generation. /// /// Must match the wait mode: if funding/verify only waited for best-block inclusion, -/// freshly written leaves are not yet in the finalized tree. +/// freshly written leaves are not yet in the finalized tree. So it follows the mode passed +/// in, not the switch `main` installs, which library callers never set. async fn wormhole_tip_block( quantus_client: &QuantusClient, execution_mode: ExecutionMode, -) -> crate::error::Result>> { - if execution_mode.finalized { - at_finalized_block(quantus_client).await - } else { - at_best_block(quantus_client).await - } +) -> crate::error::Result { + quantus_client.head_or_finalized(execution_mode.finalized).await } pub async fn handle_wormhole_command( @@ -1473,41 +1470,34 @@ fn show_wormhole_address(secret_file: String) -> crate::error::Result<()> { /// Uses [`crate::error::Result`] (not `anyhow`) so it composes with the rest /// of the SDK surface. Network/decoding failures are wrapped in /// [`crate::error::QuantusError::NetworkError`]. +#[allow(dead_code)] // SDK re-export; CLI proofs use `wormhole_tip_block`. pub async fn at_finalized_block( quantus_client: &QuantusClient, ) -> crate::error::Result>> { - let finalized_block: subxt::utils::H256 = quantus_client - .rpc_client() - .request("chain_getFinalizedHead", rpc_params![]) - .await - .map_err(|e| { - crate::error::QuantusError::NetworkError(format!( - "Failed to fetch finalized block hash: {e:?}" - )) - })?; - let block = quantus_client.client().blocks().at(finalized_block).await.map_err(|e| { - crate::error::QuantusError::NetworkError(format!( - "Failed to fetch finalized block {finalized_block:?}: {e:?}" - )) - })?; - Ok(block) + head_or_finalized_block(quantus_client, true).await } -/// Fetch the latest (best) block as a fully materialised subxt `Block`. +/// Fetch the latest (best) block as a fully materialised subxt `Block`, whatever +/// `--finalized` installed. /// /// Uses [`crate::error::Result`] (not `anyhow`) so it composes with the rest /// of the SDK surface. Network/decoding failures are wrapped in /// [`crate::error::QuantusError::NetworkError`]. +#[allow(dead_code)] // SDK re-export; CLI proofs use `wormhole_tip_block`. pub async fn at_best_block( quantus_client: &QuantusClient, ) -> crate::error::Result>> { - let best_block = quantus_client.get_latest_block().await?; - let block = quantus_client.client().blocks().at(best_block).await.map_err(|e| { - crate::error::QuantusError::NetworkError(format!( - "Failed to fetch best block {best_block:?}: {e:?}" - )) - })?; - Ok(block) + head_or_finalized_block(quantus_client, false).await +} + +async fn head_or_finalized_block( + quantus_client: &QuantusClient, + finalized: bool, +) -> crate::error::Result>> { + let hash = quantus_client.head_or_finalized(finalized).await?; + quantus_client.client().blocks().at(hash).await.map_err(|e| { + crate::error::QuantusError::NetworkError(format!("Failed to fetch block {hash:?}: {e:?}")) + }) } /// Load leaf-circuit common data for deserializing leaf proofs from `bins_dir`. @@ -2732,15 +2722,12 @@ async fn execute_initial_transfers( // 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_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 + )) + })?; let mut transfer_counts_before: Vec = Vec::with_capacity(num_proofs); for secret in secrets.iter() { let wormhole_address = SubxtAccountId(*secret.address()); @@ -2853,12 +2840,11 @@ async fn generate_round_proofs( log_print!("{}", "Step 2: Generating proofs...".bright_yellow()); // All proofs in an aggregation batch must use the same tip block for storage - // proofs. Use best (not finalized) unless `--finalized-tx`, otherwise freshly + // proofs. Use best (not finalized) unless `--finalized`, otherwise freshly // included leaves from the funding/verify step are missing from the tree. - let proof_block = wormhole_tip_block(quantus_client, execution_mode) + let proof_block_hash = wormhole_tip_block(quantus_client, execution_mode) .await .map_err(|e| crate::error::QuantusError::Generic(format!("Failed to get block: {}", e)))?; - let proof_block_hash = proof_block.hash(); log_print!( " Using {} block {} for all proofs", if execution_mode.finalized { "finalized" } else { "best" }, @@ -3564,7 +3550,7 @@ pub fn decode_full_leaf_data(leaf_data: &[u8]) -> crate::error::Result<([u8; 32] /// Verify an aggregated proof and return the block hash, extrinsic hash, and transfer events. /// /// Waits for best-block inclusion by default. Prefer -/// [`verify_private_batch_and_get_events_until`] when `--finalized-tx` is set. +/// [`verify_private_batch_and_get_events_until`] when `--finalized` is set. #[allow(dead_code)] // SDK re-export; CLI uses the `_until` variant. pub async fn verify_private_batch_and_get_events( proof_file: &str, @@ -3683,7 +3669,7 @@ pub async fn verify_private_batch_and_get_events_until( /// Verify a public-batch proof and return the block hash, extrinsic hash, and transfer events. /// /// Waits for best-block inclusion by default. Prefer -/// [`verify_public_batch_and_get_events_until`] when `--finalized-tx` is set. +/// [`verify_public_batch_and_get_events_until`] when `--finalized` is set. #[allow(dead_code)] // SDK re-export; CLI uses the `_until` variant. pub async fn verify_public_batch_and_get_events( proof_file: &str, @@ -4184,15 +4170,13 @@ 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| { + 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() .storage() @@ -4909,7 +4893,6 @@ mod tests { // written leaves are missing from the tree. assert_eq!(IncludedAt::Finalized.label(), "finalized block"); assert_ne!(IncludedAt::Best.label(), IncludedAt::Finalized.label()); - let _: *const () = at_finalized_block as *const (); let _: *const () = at_best_block as *const (); assert_eq!(wormhole_inclusion_stage(ExecutionMode::default()), TransactionStage::Included); assert_eq!( @@ -4921,6 +4904,30 @@ mod tests { ); } + /// Library callers reach the proof paths through `handle_wormhole_command` without `main` + /// installing `--finalized`, so the tip must come from the mode they pass. + #[tokio::test] + async fn tip_block_follows_the_supplied_mode_not_the_installed_switch() { + use crate::chain::client::tests::{mock_node, FINALIZED, HEAD}; + + let (url, _, _node) = mock_node().await; + let client = QuantusClient::new(&url).await.expect("connect"); + let finalized = ExecutionMode { finalized: true, wait_for_transaction: false }; + assert_eq!(wormhole_tip_block(&client, finalized).await.expect("tip"), FINALIZED); + assert_eq!(wormhole_tip_block(&client, ExecutionMode::default()).await.expect("tip"), HEAD); + } + + /// SDK callers pick the block by name, so neither helper follows the installed switch. + #[tokio::test] + async fn sdk_block_helpers_fetch_the_block_they_name() { + use crate::chain::client::tests::{mock_node, FINALIZED, HEAD}; + + let (url, _, _node) = mock_node().await; + let client = QuantusClient::new(&url).await.expect("connect"); + assert_eq!(at_finalized_block(&client).await.expect("finalized").hash(), FINALIZED); + assert_eq!(at_best_block(&client).await.expect("best").hash(), HEAD); + } + #[test] fn unsigned_verify_submitters_use_bounded_inclusion_wait() { // The unbounded `while let Some(Ok(status)) = tx_progress.next()` loops diff --git a/src/main.rs b/src/main.rs index 6b59d5a..20bd4df 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,11 +43,12 @@ struct Cli { #[arg(long, global = true, default_value = "ws://127.0.0.1:9944")] node_url: String, - /// Wait for transaction finalization before returning - /// Implies `--wait-for-transaction` - /// NOTE: waiting for finalized transaction may take a while in PoW chain - #[arg(long, global = true, default_value = "false")] - finalized_tx: bool, + /// Use the finalized block for every read, and wait for finalization before returning + /// + /// Off by default: QPoW finality trails the head by ~100 blocks, so finalized reads + /// serve state ~20 minutes stale. Implies `--wait-for-transaction`. + #[arg(long, global = true, alias = "finalized-tx", default_value = "false")] + finalized: bool, /// Wait for transaction inclusion in a best block before returning /// Default: false @@ -82,15 +83,19 @@ async fn main() -> Result<(), QuantusError> { log_verbose!(""); // Display warning about finalization - if cli.finalized_tx { - log_print!("âš ī¸ Warning: Waiting for finalized block may take a while in PoW chain."); + if cli.finalized { + log_print!("âš ī¸ Warning: reads and waits use the finalized block; on this PoW chain it"); + log_print!(" trails the head by ~100 blocks, so this may take a while."); } // Create execution mode from CLI args let execution_mode = cli::common::ExecutionMode { - finalized: cli.finalized_tx, + finalized: cli.finalized, wait_for_transaction: cli.wait_for_transaction, }; + // Published process-wide so reads honour the same flag without being threaded + // through every call site. + execution_mode.install(); // Cold-wallet QR I/O config for the submit stage (used only when the // signing wallet is watch-only).