diff --git a/LIBRARY_USAGE.md b/LIBRARY_USAGE.md index af55012..b50881b 100644 --- a/LIBRARY_USAGE.md +++ b/LIBRARY_USAGE.md @@ -219,6 +219,7 @@ Passing `None` for the tip means no tip is attached. Use `Some(raw_tip)` only wh - `wait_for_transaction = false`: return after submission (`submitted`) - `wait_for_transaction = true`: return after best-block inclusion (`included`) - `finalized = true`: return after finalization (`finalized`); this implies waiting +- `finalized = true` also moves every chain read inside `cli::execute_command` to the finalized block; `ExecutionMode::scope` does the same for any future, and `QuantusClient` reads outside a scope take the head ### Service Architecture diff --git a/README.md b/README.md index 493293c..9c92712 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,8 @@ Every command supports `--verbose` for detailed debugging information: These options work on every command: - `--verbose` / `-v`: Enable debug logging with detailed output - `--node-url `: Specify node endpoint (default: `ws://127.0.0.1:9944`) +- `--wait-for-transaction`: Return once the transaction is in a best block +- `--finalized`: Read every query at the finalized block and wait for finalization before returning (implies `--wait-for-transaction`). Off by default: finality trails the head by ~100 blocks, so finalized reads are ~20 minutes stale - `--help` / `-h`: Show help for any command or subcommand ### Command Structure @@ -787,8 +789,8 @@ quantus send --from crystal_alice --to
--amount 10.5 # Wait for inclusion in a best block quantus send --from crystal_alice --to
--amount 10.5 --wait-for-transaction -# Wait for finalization (implies --wait-for-transaction) -quantus send --from crystal_alice --to
--amount 10.5 --finalized-tx +# Wait for finalization, reading at the finalized block (implies --wait-for-transaction) +quantus send --from crystal_alice --to
--amount 10.5 --finalized # Optional advanced: add a tip to prioritize the transaction quantus send --from crystal_alice --to
--amount 10 --tip 0.1 @@ -798,6 +800,12 @@ quantus send --from crystal_alice --to
--amount 10 --nonce 42 # Refuse to reap the sender: transfer_keep_alive instead of the default transfer_allow_death quantus send --from crystal_alice --to
--amount 10 --keep-alive + +# Sweep the whole free balance (transfer_all): the chain deducts the exact fee, reaping the sender +quantus send --from crystal_alice --to
--all + +# Sweep but leave the existential deposit so the sender survives +quantus send --from crystal_alice --to
--all --keep-alive ``` Transaction status terms: diff --git a/src/chain/client.rs b/src/chain/client.rs index c6a8dd3..a671a01 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,30 @@ 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 + /// [`ExecutionMode`] `execute_command` scopes, so `--finalized` moves every read. + /// + /// [`ExecutionMode`]: crate::cli::common::ExecutionMode 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 reaching them directly are outside any scope. + /// + /// [`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 +310,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 +367,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 +405,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:?}")) @@ -513,6 +586,17 @@ pub(crate) mod test_support { 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 9e16c64..cc182a7 100644 --- a/src/cli/cold_signing.rs +++ b/src/cli/cold_signing.rs @@ -174,7 +174,11 @@ impl ResponseError { } } -/// Capture nonce + latest (best) block once. `nonce_override` honors `--nonce`. +/// Capture nonce + best block once. `nonce_override` honors `--nonce`. +/// +/// Both come from the head even under `--finalized`: a [`MORTALITY_BLOCKS`] era anchored at +/// the finalized block, ~100 behind, leaves the cold signer ~156 blocks to answer, and none +/// at all once finality lags by 256. pub async fn capture_tx_context( client: &QuantusClient, signer: &AccountId32, @@ -185,7 +189,7 @@ pub async fn capture_tx_context( Some(n) => n as u64, None => client.get_account_nonce_from_best_block(signer).await?, }; - let block_hash = client.get_latest_block().await?; + let block_hash = client.head_or_finalized(false).await?; let block = client.client().blocks().at(block_hash).await?; let block_number = block.header().number as u64; Ok(TxContext { nonce, block_number, block_hash, tip }) @@ -491,7 +495,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 @@ -737,6 +741,26 @@ mod tests { .expect("valid keypair") } + /// The mortality anchor is the head even under `--finalized`, like the nonce: anchored + /// ~100 blocks back the signer would have ~156 blocks to answer, and none once finality + /// lags by 256. + #[tokio::test] + async fn tx_context_anchors_mortality_at_the_head_under_finalized() { + use crate::{ + chain::client::test_support::{mock_node, HEAD}, + cli::common::ExecutionMode, + }; + + let node = mock_node(false).await; + let client = QuantusClient::new(&node.url).await.expect("connect"); + let ctx = ExecutionMode { finalized: true, wait_for_transaction: false } + .scope(capture_tx_context(&client, &alice_account(), 0, Some(7))) + .await + .expect("context"); + assert_eq!(ctx.block_hash, HEAD); + assert_eq!(ctx.nonce, 7); + } + fn transfer_call() -> impl subxt::tx::Payload { let dest: subxt::utils::AccountId32 = subxt::utils::AccountId32(*b"01234567890123456789012345678901"); diff --git a/src/cli/common.rs b/src/cli/common.rs index 5b966ef..7e07b62 100644 --- a/src/cli/common.rs +++ b/src/cli/common.rs @@ -34,7 +34,31 @@ pub enum TransactionStage { Finalized, } +tokio::task_local! { + static READS_AT_FINALIZED: bool; +} + impl ExecutionMode { + /// Run `f` with this mode's finality governing every read inside it. + /// + /// 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. So the + /// dispatcher scopes the mode to its task and `QuantusClient::get_latest_block` consults + /// it: one switch governs both. Task-scoped rather than process-wide, so concurrent + /// library callers never see each other's choice. + pub async fn scope(self, f: F) -> F::Output { + READS_AT_FINALIZED.scope(self.finalized, f).await + } + + /// Whether reads should be taken at the finalized block rather than the head. + /// + /// False outside any [`Self::scope`], and 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 { + READS_AT_FINALIZED.try_with(|finalized| *finalized).unwrap_or(false) + } + pub fn transaction_stage(self) -> TransactionStage { if self.finalized { TransactionStage::Finalized @@ -188,9 +212,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 +717,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 +761,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 +802,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 +820,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 +1137,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 +1151,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 +1210,25 @@ mod tests { assert_eq!(delay_seconds_to_millis(1).unwrap(), 1_000); } + #[tokio::test] + async fn reads_follow_the_scoped_finality_flag() { + // Outside any scope: the head, so SDK reads are never ~100 blocks stale. + assert!(!ExecutionMode::reads_at_finalized()); + + // Concurrent scopes each see their own mode, not a process-wide last write. + tokio::join!( + ExecutionMode { finalized: true, wait_for_transaction: false }.scope(async { + tokio::task::yield_now().await; + assert!(ExecutionMode::reads_at_finalized()); + }), + ExecutionMode { finalized: false, wait_for_transaction: true }.scope(async { + tokio::task::yield_now().await; + assert!(!ExecutionMode::reads_at_finalized()); + }), + ); + assert!(!ExecutionMode::reads_at_finalized()); + } + #[test] fn finalized_mode_implies_waiting_for_finalization() { let mode = ExecutionMode { finalized: true, wait_for_transaction: false }; @@ -1311,7 +1373,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 +1384,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/events.rs b/src/cli/events.rs index d202510..4198da9 100644 --- a/src/cli/events.rs +++ b/src/cli/events.rs @@ -1,5 +1,7 @@ use crate::{ - chain::client::QuantusClient, cli::address_format::QuantusSS58, log_print, log_verbose, + chain::client::QuantusClient, + cli::{address_format::QuantusSS58, common::ExecutionMode}, + log_print, log_verbose, }; use colored::Colorize; use jsonrpsee::core::client::ClientT; @@ -57,53 +59,23 @@ pub async fn handle_events_command( 0 }; - (hash, block_num) - } else if finalized { - log_print!("📋 Querying events from finalized block"); - - // Get finalized head - let hash: subxt::utils::H256 = quantus_client - .rpc_client() - .request::("chain_getFinalizedHead", []) - .await - .map_err(|e| { - crate::error::QuantusError::NetworkError(format!( - "Failed to get finalized head: {e:?}" - )) - })?; - - // Get block number from finalized head - let block_header: serde_json::Value = quantus_client - .rpc_client() - .request::("chain_getHeader", [format!("0x{hash:x}")]) - .await - .map_err(|e| { - crate::error::QuantusError::NetworkError(format!( - "Failed to get finalized block header: {e:?}" - )) - })?; - - let block_num = if let Some(block_number_str) = block_header["number"].as_str() { - u64::from_str_radix(&block_number_str[2..], 16).map(|n| n as u32).unwrap_or(0) - } else { - 0 - }; - (hash, block_num) } else { - // Use latest block (default) - log_print!("📋 Querying events from latest block"); + let finalized = finalized || ExecutionMode::reads_at_finalized(); + log_print!( + "📋 Querying events from {} block", + if finalized { "finalized" } else { "latest" } + ); - let hash = quantus_client.get_latest_block().await?; + let hash = quantus_client.head_or_finalized(finalized).await?; - // Get block number from latest block let block_header: serde_json::Value = quantus_client .rpc_client() - .request::("chain_getHeader", []) + .request::("chain_getHeader", [format!("0x{hash:x}")]) .await .map_err(|e| { crate::error::QuantusError::NetworkError(format!( - "Failed to get latest block header: {e:?}" + "Failed to get block header for {hash:?}: {e:?}" )) })?; diff --git a/src/cli/exercise/mod.rs b/src/cli/exercise/mod.rs index 4205908..e08f98d 100644 --- a/src/cli/exercise/mod.rs +++ b/src/cli/exercise/mod.rs @@ -6,6 +6,7 @@ pub mod scenarios; use crate::{ chain::{client::QuantusClient, quantus_subxt}, + cli::common::ExecutionMode, error::{QuantusError, Result}, wallet::{DilithiumScheme, QuantumKeyPair}, }; @@ -151,6 +152,13 @@ impl Phase { } pub async fn handle_exercise_command(args: ExerciseArgs, node_url: &str) -> Result<()> { + // Proposals here expire a few blocks past the block read, and each step waits on the last: + // finality's ~100-block lag puts those deadlines in the past. + if ExecutionMode::reads_at_finalized() { + return Err(QuantusError::Generic( + "exercise reads and waits at the head; run it without --finalized".to_string(), + )); + } let seed = args.seed.unwrap_or_else(rand::random); let mut selected = args.phases.clone().unwrap_or_else(Phase::default_set); if (args.upgrade_wasm.is_some() || args.self_upgrade) && !selected.contains(&Phase::Upgrade) { @@ -558,3 +566,25 @@ async fn fund_ephemeral_accounts( ({scheme_65}× ml-dsa-65, {scheme_87}× ml-dsa-87){note}" )) } + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[derive(Parser)] + struct Cli { + #[command(flatten)] + args: ExerciseArgs, + } + + #[tokio::test] + async fn refuses_finalized_reads_before_touching_the_node() { + let args = Cli::parse_from(["exercise"]).args; + let err = ExecutionMode { finalized: true, wait_for_transaction: false } + .scope(handle_exercise_command(args, "ws://127.0.0.1:1")) + .await + .expect_err("a finalized scope must be refused"); + assert!(err.to_string().contains("without --finalized"), "{err}"); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index bd1360f..657c4b7 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -47,9 +47,14 @@ 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 with transfer_all, letting the chain deduct the exact + /// fee so nothing is stranded. Reaps the sender unless --keep-alive + #[arg(long)] + all: bool, /// Wallet name to send from #[arg(short, long)] @@ -71,8 +76,9 @@ pub enum Commands { #[arg(long)] nonce: Option, - /// Send with transfer_keep_alive instead of the default transfer_allow_death: the - /// transfer fails rather than reaping the sender below the existential deposit + /// Never reap the sender: transfer_keep_alive instead of the default + /// transfer_allow_death, so the transfer fails rather than drop the sender below the + /// existential deposit; with --all, leave the deposit behind #[arg(long)] keep_alive: bool, }, @@ -229,7 +235,7 @@ pub enum Commands { latest: bool, /// Query events from finalized block (full support) - #[arg(long)] + #[arg(long, alias = "finalized-tx")] finalized: bool, /// Filter events by pallet name (e.g., "Balances") @@ -391,29 +397,68 @@ pub enum DeveloperCommands { }, } -/// Execute a CLI command +/// Execute a CLI command, `execution_mode` governing its reads as well as its waits. pub async fn execute_command( command: Commands, node_url: &str, verbose: bool, execution_mode: common::ExecutionMode, +) -> crate::error::Result<()> { + execution_mode + .scope(run_command(command, node_url, verbose, execution_mode)) + .await +} + +async fn run_command( + command: Commands, + node_url: &str, + verbose: bool, + execution_mode: common::ExecutionMode, ) -> 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, keep_alive } => - send::handle_send_command( - from, - to, - &amount, - node_url, - password, - password_file, - tip, - nonce, - keep_alive, - execution_mode, - ) - .await, + Commands::Send { + from, + to, + amount, + all, + password, + password_file, + tip, + nonce, + keep_alive, + } => match (all, amount) { + (false, Some(amount)) => + send::handle_send_command( + from, + to, + &amount, + node_url, + password, + password_file, + tip, + nonce, + keep_alive, + execution_mode, + ) + .await, + (true, None) => + send::handle_send_all_command( + from, + to, + node_url, + password, + password_file, + tip, + nonce, + keep_alive, + execution_mode, + ) + .await, + _ => Err(crate::error::QuantusError::Generic( + "send takes exactly one of --amount or --all".to_string(), + )), + }, Commands::SigningQr { from, to, amount, call_data, tip, nonce } => signing_qr::handle_signing_qr_command(from, to, amount, call_data, tip, nonce, node_url) .await, diff --git a/src/cli/send.rs b/src/cli/send.rs index 3bb6902..f1896a0 100644 --- a/src/cli/send.rs +++ b/src/cli/send.rs @@ -260,6 +260,21 @@ pub(crate) 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, +) -> Box { + Box::new( + 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 { @@ -333,11 +348,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( @@ -409,13 +420,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, @@ -425,7 +436,7 @@ where ) .await } else { - crate::cli::common::submit_transaction( + crate::cli::common::submit_transaction_with_inclusion_block( quantus_client, signer, transfer_call, @@ -478,7 +489,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, @@ -586,7 +597,7 @@ pub async fn batch_transfer( // (Removed custom `AccountData` struct – we now use the runtime-generated type) -/// Handle the send command +/// Handle the send command for a fixed amount #[allow(clippy::too_many_arguments)] pub async fn handle_send_command( from_wallet: String, @@ -599,13 +610,78 @@ pub async fn handle_send_command( nonce: Option, keep_alive: bool, execution_mode: crate::cli::common::ExecutionMode, +) -> Result<()> { + send_from_wallet( + from_wallet, + to_address, + Some(amount_str), + node_url, + password, + password_file, + tip, + nonce, + keep_alive, + execution_mode, + ) + .await +} + +/// Handle `send --all`: sweep the whole free balance with `Balances::transfer_all`, the chain +/// deducting the exact fee. `keep_alive` leaves the existential deposit behind instead of +/// reaping the account. +#[allow(clippy::too_many_arguments)] +pub async fn handle_send_all_command( + from_wallet: String, + to_address: String, + node_url: &str, + password: Option, + password_file: Option, + tip: Option, + nonce: Option, + keep_alive: bool, + execution_mode: crate::cli::common::ExecutionMode, +) -> Result<()> { + send_from_wallet( + from_wallet, + to_address, + None, + node_url, + password, + password_file, + tip, + nonce, + keep_alive, + execution_mode, + ) + .await +} + +/// `amount_str` is `None` for a sweep. +#[allow(clippy::too_many_arguments)] +async fn send_from_wallet( + from_wallet: String, + to_address: String, + amount_str: Option<&str>, + node_url: &str, + password: Option, + password_file: Option, + tip: Option, + nonce: Option, + keep_alive: bool, + execution_mode: crate::cli::common::ExecutionMode, ) -> Result<()> { // 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)?; @@ -640,24 +716,33 @@ 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, keep_alive); - ensure_balance_covers_call( - &quantus_client, - &signer, - &transfer_call, - balance, - exact_required, - submit_tip, - "send", - ) - .await?; + + let transfer_call = match amount { + // `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 => build_transfer_all_call_for_account_id(to_account_id, keep_alive), + Some(value) => { + let exact_required = checked_add(value, effective_tip, "required send balance")?; + let call = build_transfer_call_for_account_id(to_account_id, value, keep_alive); + ensure_balance_covers_call( + &quantus_client, + &signer, + &call, + balance, + exact_required, + submit_tip, + "send", + ) + .await?; + call + }, + }; // 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, @@ -667,17 +752,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(); @@ -688,13 +771,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 {}.", @@ -706,10 +789,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()); } @@ -774,9 +870,9 @@ pub async fn get_batch_limits(quantus_client: &QuantusClient) -> Result<(u32, u3 #[cfg(test)] mod tests { use super::{ - build_batch_transfer_call, build_transfer_call_for_account_id, effective_tip_amount, - format_balance, limits_from_batched_calls_limit, parse_amount_with_decimals, - SubxtAccountId32, + build_batch_transfer_call, build_transfer_all_call_for_account_id, + build_transfer_call_for_account_id, effective_tip_amount, format_balance, + limits_from_batched_calls_limit, parse_amount_with_decimals, SubxtAccountId32, }; use subxt::tx::Payload; @@ -812,6 +908,25 @@ mod tests { assert_eq!(allow_death[2..], keep_alive[2..], "only the call index differs"); } + /// `--all` is `transfer_all` (call index 4), not a transfer of a guessed amount: the chain + /// deducts the exact fee. `--keep-alive` is its trailing flag byte. + #[test] + fn send_all_builds_transfer_all_with_keep_alive_flag() { + let metadata = crate::chain::test_metadata(); + let dest = SubxtAccountId32::from([9u8; 32]); + let reap = build_transfer_all_call_for_account_id(dest.clone(), false) + .encode_call_data(&metadata) + .unwrap(); + let keep = build_transfer_all_call_for_account_id(dest, true) + .encode_call_data(&metadata) + .unwrap(); + let balances = metadata.pallet_by_name("Balances").unwrap().index(); + assert_eq!(reap[..2], [balances, 4], "--all is transfer_all"); + assert_eq!(reap[reap.len() - 1], 0, "default reaps the sender"); + assert_eq!(keep[keep.len() - 1], 1, "--keep-alive leaves the existential deposit"); + assert_eq!(reap[..reap.len() - 1], keep[..keep.len() - 1], "only the flag differs"); + } + #[test] fn parses_exact_decimal_amounts() { assert_eq!(parse_amount_with_decimals("0.1", 12).unwrap(), 100_000_000_000); 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..9bc4434 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,14 @@ 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 scope `execute_command` sets, which library callers reaching +/// `handle_wormhole_command` directly never enter. 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 +1471,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`, even under +/// `--finalized`. /// /// 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 +2723,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 +2841,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 +3551,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 +3670,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 +4171,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 +4894,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 +4905,33 @@ mod tests { ); } + /// Library callers reach the proof paths through `handle_wormhole_command` outside the + /// scope `execute_command` sets, so the tip must come from the mode they pass. + #[tokio::test] + async fn tip_block_follows_the_supplied_mode_not_the_scoped_one() { + use crate::chain::client::test_support::{mock_node, FINALIZED, HEAD}; + + let node = mock_node(false).await; + let client = QuantusClient::new(&node.url).await.expect("connect"); + let finalized = ExecutionMode { finalized: true, wait_for_transaction: false }; + let tip = |mode| wormhole_tip_block(&client, mode); + assert_eq!(ExecutionMode::default().scope(tip(finalized)).await.expect("tip"), FINALIZED); + assert_eq!(finalized.scope(tip(ExecutionMode::default())).await.expect("tip"), HEAD); + } + + /// SDK callers pick the block by name, so neither helper follows a scoped mode. + #[tokio::test] + async fn sdk_block_helpers_fetch_the_block_they_name() { + use crate::chain::client::test_support::{mock_node, FINALIZED, HEAD}; + + let node = mock_node(false).await; + let client = QuantusClient::new(&node.url).await.expect("connect"); + let finalized = ExecutionMode { finalized: true, wait_for_transaction: false }; + let block = ExecutionMode::default().scope(at_finalized_block(&client)).await; + assert_eq!(block.expect("finalized").hash(), FINALIZED); + assert_eq!(finalized.scope(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 5e774ab..beae0a2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -44,11 +44,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 @@ -83,13 +84,14 @@ 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, }; @@ -136,3 +138,29 @@ async fn main() -> Result<(), QuantusError> { }, } } + +#[cfg(test)] +mod tests { + use super::*; + + /// `events` has its own `--finalized`, which shadows the global one and its alias there. + #[test] + fn finalized_tx_alias_parses_on_either_side_of_events() { + let before = Cli::try_parse_from(["quantus", "--finalized-tx", "events"]).expect("parse"); + assert!(before.finalized); + let after = Cli::try_parse_from(["quantus", "events", "--finalized-tx"]).expect("parse"); + assert!(matches!(after.command, Commands::Events { finalized: true, .. })); + let send = Cli::try_parse_from([ + "quantus", + "send", + "--finalized-tx", + "--all", + "--from", + "a", + "--to", + "b", + ]) + .expect("parse"); + assert!(send.finalized); + } +}