From e87b9d77801ded7d636c4c20b481eb859baeec45 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Wed, 23 Sep 2026 23:10:44 +0800 Subject: [PATCH 1/6] fix: read at the head by default, add a global --finalized, add send --all Sending 1.99 QTC from an account holding exactly 2 QTC was refused right after mainnet enacted spec 153: Insufficient balance for send. Have: 2 QTC, Need: 2.000167025 QTC (estimated fee: 0.010167025 QTC) subxt's partial_fee_estimate calls TransactionPaymentApi_query_info at latest_finalized_block_ref. QPoW finality trails the head by ~100 blocks, so the estimate was computed by spec 152 -- the runtime the upgrade had just replaced -- and 153 cut FEE_SCALE tenfold. The real fee was ~0.001 QTC. The transfer was affordable; only the quote was stale. Estimate against the same block as every other read, via QuantusClient::partial_fee. Unify the finality choice. ExecutionMode::finalized already decided how long to wait for a transaction; it now also decides which block reads are taken at. Reads happen in ~80 places that have no reason to carry an ExecutionMode, so main publishes the flag once with ExecutionMode::install and QuantusClient::get_latest_block consults it -- one switch for waits and reads. wormhole_tip_block's own head/finalized branch and at_finalized_block go away, since get_latest_block already answers that question. --finalized-tx becomes --finalized (kept as an alias) because it no longer only governs transactions. Add send --all, which submits Balances::transfer_all and lets the chain deduct the exact fee. Sweeping an account by subtracting an estimate cannot be done reliably -- too low strands dust, too high is refused -- and --keep-alive chooses between reaping the account and leaving the existential deposit. --- src/chain/client.rs | 56 +++++++++++++++++++- src/cli/batch.rs | 2 +- src/cli/cold_signing.rs | 2 +- src/cli/common.rs | 33 ++++++++++++ src/cli/mod.rs | 32 ++++++++++-- src/cli/send.rs | 111 +++++++++++++++++++++++++++++++--------- src/cli/wormhole.rs | 50 ++++-------------- src/lib.rs | 8 +-- src/main.rs | 21 +++++--- 9 files changed, 230 insertions(+), 85 deletions(-) diff --git a/src/chain/client.rs b/src/chain/client.rs index d671f31d..1ea648e7 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,18 @@ 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. + /// + /// The head by default; the finalized block under `--finalized`. 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. pub async fn get_latest_block(&self) -> crate::error::Result { + if crate::cli::common::ExecutionMode::reads_at_finalized() { + log_verbose!("🔍 Fetching finalized block hash via RPC (--finalized)..."); + 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); @@ -343,6 +384,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:?}")) diff --git a/src/cli/batch.rs b/src/cli/batch.rs index bece446a..cf2f1cf3 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 280f1183..3cf046ff 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 5b966ef9..acb654d5 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 @@ -1167,6 +1188,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 }; diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 318cd400..d07a1bf3 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 05c02256..d032de4e 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( @@ -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,18 +651,34 @@ 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()); @@ -662,8 +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, + balance, + amount.unwrap_or(balance), + tx_hash, + execution_mode, + ) + .await } /// Print the post-submission summary (status, new balance, fee). @@ -685,7 +724,7 @@ async fn print_send_result( if !execution_mode.should_watch_transaction() { 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(()); @@ -768,6 +807,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/wormhole.rs b/src/cli/wormhole.rs index 28544f17..f9cd4482 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,12 @@ 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. `get_latest_block` already +/// honours `--finalized`, so this is the same block every other read uses. 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 - } + at_best_block(quantus_client).await } pub async fn handle_wormhole_command( @@ -1468,31 +1464,6 @@ fn show_wormhole_address(secret_file: String) -> crate::error::Result<()> { Ok(()) } -/// Fetch the latest finalized block as a fully materialised subxt `Block`. -/// -/// 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`]. -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) -} - /// Fetch the latest (best) block as a fully materialised subxt `Block`. /// /// Uses [`crate::error::Result`] (not `anyhow`) so it composes with the rest @@ -2732,7 +2703,7 @@ 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) + let tip_block_hash = wormhole_tip_block(quantus_client) .await .map_err(|e| { crate::error::QuantusError::Generic(format!( @@ -2853,9 +2824,9 @@ 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 = wormhole_tip_block(quantus_client) .await .map_err(|e| crate::error::QuantusError::Generic(format!("Failed to get block: {}", e)))?; let proof_block_hash = proof_block.hash(); @@ -3564,7 +3535,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 +3654,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,7 +4155,7 @@ 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) + let tip_block_hash = wormhole_tip_block(&quantus_client) .await .map_err(|e| { crate::error::QuantusError::Generic(format!( @@ -4909,7 +4880,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!( diff --git a/src/lib.rs b/src/lib.rs index 76fd6691..bd005279 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -60,10 +60,10 @@ pub use wormhole_lib::{ // into `chain::quantus_subxt::api::wormhole::events::*`. pub use chain::quantus_subxt::api::wormhole::events::NativeTransferred; pub use cli::wormhole::{ - aggregate_proofs, at_best_block, at_finalized_block, compute_merkle_positions, - decode_full_leaf_data, get_zk_merkle_proof, parse_transfer_events, prepare_public_batches, - read_proof_file, submit_unsigned_verify_private_batch, verify_private_batch_and_get_events, - write_proof_file, IncludedAt, TransferInfo, + aggregate_proofs, at_best_block, compute_merkle_positions, decode_full_leaf_data, + get_zk_merkle_proof, parse_transfer_events, prepare_public_batches, read_proof_file, + submit_unsigned_verify_private_batch, verify_private_batch_and_get_events, write_proof_file, + IncludedAt, TransferInfo, }; // Re-export collect rewards library for SDK usage diff --git a/src/main.rs b/src/main.rs index 6b59d5a9..20bd4df0 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). From e4f6a2bf040005faa29daad01110c0b9d8302e76 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Wed, 30 Sep 2026 11:42:09 +0800 Subject: [PATCH 2/6] fix: address review on the global --finalized switch - Wormhole proof tips follow the ExecutionMode the flow is handed (QuantusClient::head_or_finalized). Library callers never install the switch, and the proof block must match the wait. - system --runtime reads the runtime version and header at one selected block, so --finalized moves both. - send reports the fee from the included transaction's MiningRewards::FeesCollected. A balance delta cannot separate it from what --all moved, and TransactionFeePaid omits the refund a reaped account forfeits. - The signing nonce is always read at the head. Under --finalized the finalized nonce made the extrinsic outdated whenever the account had anything unfinalized. --- src/chain/client.rs | 45 ++++++++++---- src/cli/common.rs | 82 ++++++++++++-------------- src/cli/exercise/scenarios/negative.rs | 2 +- src/cli/send.rs | 46 ++++++++------- src/cli/system.rs | 24 ++++---- src/cli/wormhole.rs | 49 ++++++++------- 6 files changed, 135 insertions(+), 113 deletions(-) diff --git a/src/chain/client.rs b/src/chain/client.rs index 1ea648e7..8a2382cf 100644 --- a/src/chain/client.rs +++ b/src/chain/client.rs @@ -261,14 +261,24 @@ impl QuantusClient { &self.rpc_client } - /// The block every read in the CLI is taken at. - /// - /// The head by default; the finalized block under `--finalized`. 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. + /// 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 { - if crate::cli::common::ExecutionMode::reads_at_finalized() { - log_verbose!("🔍 Fetching finalized block hash via RPC (--finalized)..."); + 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); @@ -298,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 @@ -354,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..."); @@ -495,7 +514,7 @@ impl subxt::tx::Signer for QuantusSigner { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use codec::Encode; use jsonrpsee::{ @@ -510,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 { @@ -524,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")); diff --git a/src/cli/common.rs b/src/cli/common.rs index acb654d5..1834ba6f 100644 --- a/src/cli/common.rs +++ b/src/cli/common.rs @@ -209,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(), ) @@ -713,7 +713,7 @@ pub async fn submit_transaction_with_nonce( tip: Option, nonce: u32, execution_mode: ExecutionMode, -) -> crate::error::Result +) -> crate::error::Result<(subxt::utils::H256, Option)> where Call: subxt::tx::Payload, { @@ -733,8 +733,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| { @@ -775,14 +774,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); @@ -793,7 +792,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:?}"); @@ -1110,13 +1109,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:?}")) })?; @@ -1125,38 +1123,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(()) @@ -1344,7 +1338,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!( @@ -1355,7 +1349,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/exercise/scenarios/negative.rs b/src/cli/exercise/scenarios/negative.rs index 975d1f48..2ec6949e 100644 --- a/src/cli/exercise/scenarios/negative.rs +++ b/src/cli/exercise/scenarios/negative.rs @@ -99,7 +99,7 @@ async fn stale_nonce(ctx: &mut ExerciseCtx) -> Result { ) .await { - Ok(hash) => Err(QuantusError::Generic(format!( + Ok((hash, _)) => Err(QuantusError::Generic(format!( "stale-nonce transaction unexpectedly accepted ({hash:?})" ))), Err(e) => { diff --git a/src/cli/send.rs b/src/cli/send.rs index d032de4e..abe87685 100644 --- a/src/cli/send.rs +++ b/src/cli/send.rs @@ -413,7 +413,7 @@ 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, { @@ -429,7 +429,7 @@ where ) .await } else { - crate::cli::common::submit_transaction( + crate::cli::common::submit_transaction_with_inclusion_block( quantus_client, signer, transfer_call, @@ -482,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, @@ -684,7 +684,7 @@ pub async fn handle_send_command( 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, @@ -694,24 +694,15 @@ pub async fn handle_send_command( ) .await?; - print_send_result( - &quantus_client, - &from_account_id, - balance, - amount.unwrap_or(balance), - 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(); @@ -722,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 to wait before returning.", transaction_stage.success_detail() ); return Ok(()); - } + }; log_success!( "🎉 {} Transaction {}.", @@ -740,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()); } diff --git a/src/cli/system.rs b/src/cli/system.rs index 5efd2c16..97da7b1e 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 f9cd4482..38ff3025 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -1208,12 +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. `get_latest_block` already -/// honours `--finalized`, so this is the same block every other read uses. +/// 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, -) -> crate::error::Result>> { - at_best_block(quantus_client).await + execution_mode: ExecutionMode, +) -> crate::error::Result { + quantus_client.head_or_finalized(execution_mode.finalized).await } pub async fn handle_wormhole_command( @@ -1469,6 +1470,7 @@ 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_best_block( quantus_client: &QuantusClient, ) -> crate::error::Result>> { @@ -2703,15 +2705,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) - .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()); @@ -2826,10 +2825,9 @@ async fn generate_round_proofs( // All proofs in an aggregation batch must use the same tip block for storage // 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) + 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" }, @@ -4155,15 +4153,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) - .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() @@ -4891,6 +4887,19 @@ 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); + } + #[test] fn unsigned_verify_submitters_use_bounded_inclusion_wait() { // The unbounded `while let Some(Ok(status)) = tx_progress.next()` loops From 5bbdbec5dcac2dbb108f5792f6fe369e952cb900 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Wed, 30 Sep 2026 17:32:12 +0800 Subject: [PATCH 3/6] fix: keep the SDK block helpers and the manual-nonce submit signature - Restore at_finalized_block and its lib.rs re-export, and pin at_best_block to the head. Both fetch the block they name via head_or_finalized, whatever --finalized installed. - submit_transaction_with_nonce returns the hash again. send uses the new submit_transaction_with_nonce_and_inclusion_block, mirroring submit_transaction and submit_transaction_with_inclusion_block. --- src/chain/client.rs | 11 +++++++ src/cli/common.rs | 25 +++++++++++++++ src/cli/exercise/scenarios/negative.rs | 2 +- src/cli/send.rs | 2 +- src/cli/wormhole.rs | 44 +++++++++++++++++++++----- src/lib.rs | 8 ++--- 6 files changed, 78 insertions(+), 14 deletions(-) diff --git a/src/chain/client.rs b/src/chain/client.rs index 8a2382cf..12b346d6 100644 --- a/src/chain/client.rs +++ b/src/chain/client.rs @@ -559,6 +559,17 @@ pub(crate) 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/common.rs b/src/cli/common.rs index 1834ba6f..6221dada 100644 --- a/src/cli/common.rs +++ b/src/cli/common.rs @@ -713,6 +713,31 @@ pub async fn submit_transaction_with_nonce( tip: Option, 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, diff --git a/src/cli/exercise/scenarios/negative.rs b/src/cli/exercise/scenarios/negative.rs index 2ec6949e..975d1f48 100644 --- a/src/cli/exercise/scenarios/negative.rs +++ b/src/cli/exercise/scenarios/negative.rs @@ -99,7 +99,7 @@ async fn stale_nonce(ctx: &mut ExerciseCtx) -> Result { ) .await { - Ok((hash, _)) => Err(QuantusError::Generic(format!( + Ok(hash) => Err(QuantusError::Generic(format!( "stale-nonce transaction unexpectedly accepted ({hash:?})" ))), Err(e) => { diff --git a/src/cli/send.rs b/src/cli/send.rs index abe87685..d9d13f8a 100644 --- a/src/cli/send.rs +++ b/src/cli/send.rs @@ -419,7 +419,7 @@ where { 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, diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index 38ff3025..d0753611 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -1465,7 +1465,20 @@ fn show_wormhole_address(secret_file: String) -> crate::error::Result<()> { Ok(()) } -/// Fetch the latest (best) block as a fully materialised subxt `Block`. +/// Fetch the latest finalized block as a fully materialised subxt `Block`. +/// +/// 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>> { + head_or_finalized_block(quantus_client, true).await +} + +/// 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 @@ -1474,13 +1487,17 @@ fn show_wormhole_address(secret_file: String) -> crate::error::Result<()> { 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`. @@ -4900,6 +4917,17 @@ mod tests { 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/lib.rs b/src/lib.rs index bd005279..76fd6691 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -60,10 +60,10 @@ pub use wormhole_lib::{ // into `chain::quantus_subxt::api::wormhole::events::*`. pub use chain::quantus_subxt::api::wormhole::events::NativeTransferred; pub use cli::wormhole::{ - aggregate_proofs, at_best_block, compute_merkle_positions, decode_full_leaf_data, - get_zk_merkle_proof, parse_transfer_events, prepare_public_batches, read_proof_file, - submit_unsigned_verify_private_batch, verify_private_batch_and_get_events, write_proof_file, - IncludedAt, TransferInfo, + aggregate_proofs, at_best_block, at_finalized_block, compute_merkle_positions, + decode_full_leaf_data, get_zk_merkle_proof, parse_transfer_events, prepare_public_batches, + read_proof_file, submit_unsigned_verify_private_batch, verify_private_batch_and_get_events, + write_proof_file, IncludedAt, TransferInfo, }; // Re-export collect rewards library for SDK usage From a89e0ea6afcdcac8b965f4868a63b058cdbb74a3 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Mon, 19 Oct 2026 17:36:20 +0800 Subject: [PATCH 4/6] fix: scope --finalized per task, keep the send API, anchor cold mortality at the head ExecutionMode::install, a process-wide flag only main set, becomes ExecutionMode::scope: a task-local that execute_command enters, so a library call to the dispatcher reads at the block its mode names and concurrent callers never overwrite each other's choice. capture_tx_context anchors the 256-block mortal era at the head even under --finalized, like the nonce: anchored ~100 blocks back the cold signer would have ~156 blocks to answer, none once finality lags by 256. handle_send_command keeps its fixed-amount signature; send --all goes through the new handle_send_all_command. Both take the one --keep-alive flag from #181, which for a sweep is transfer_all's keep_alive. --- LIBRARY_USAGE.md | 1 + README.md | 12 +++- src/chain/client.rs | 6 +- src/cli/cold_signing.rs | 28 ++++++++- src/cli/common.rs | 46 ++++++++------ src/cli/mod.rs | 60 +++++++++++++----- src/cli/send.rs | 135 +++++++++++++++++++++++++++------------- src/cli/wormhole.rs | 26 ++++---- src/main.rs | 3 - 9 files changed, 220 insertions(+), 97 deletions(-) diff --git a/LIBRARY_USAGE.md b/LIBRARY_USAGE.md index af55012e..b50881bd 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 35e91fa9..8b4c12bd 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 @@ -758,8 +760,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 @@ -769,6 +771,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 12b346d6..9e1b707f 100644 --- a/src/chain/client.rs +++ b/src/chain/client.rs @@ -262,7 +262,9 @@ impl QuantusClient { } /// The block every read in the CLI is taken at: [`Self::head_or_finalized`] under the - /// `--finalized` switch `main` installs. + /// [`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 @@ -273,7 +275,7 @@ impl QuantusClient { /// 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. + /// `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 { diff --git a/src/cli/cold_signing.rs b/src/cli/cold_signing.rs index f4671890..e3f3ee2f 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 }) @@ -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::tests::{mock_node, HEAD}, + cli::common::ExecutionMode, + }; + + let (url, _, _node) = mock_node().await; + let client = QuantusClient::new(&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 6221dada..7e07b625 100644 --- a/src/cli/common.rs +++ b/src/cli/common.rs @@ -34,26 +34,29 @@ pub enum TransactionStage { Finalized, } -static GLOBAL_MODE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +tokio::task_local! { + static READS_AT_FINALIZED: bool; +} impl ExecutionMode { - /// Install the finality choice process-wide, once, from `main`. + /// 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. 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); + /// 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 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. + /// 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 { - GLOBAL_MODE.load(std::sync::atomic::Ordering::Relaxed) + READS_AT_FINALIZED.try_with(|finalized| *finalized).unwrap_or(false) } pub fn transaction_stage(self) -> TransactionStage { @@ -1207,15 +1210,22 @@ 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. + #[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()); - ExecutionMode { finalized: true, wait_for_transaction: false }.install(); - assert!(ExecutionMode::reads_at_finalized()); - - ExecutionMode { finalized: false, wait_for_transaction: true }.install(); + // 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()); } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 449c3e74..8c722fe1 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -397,12 +397,23 @@ 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, @@ -416,21 +427,38 @@ pub async fn execute_command( tip, nonce, keep_alive, - } => - send::handle_send_command( - from, - to, - amount.as_deref(), - all.then_some(keep_alive), - node_url, - password, - password_file, - tip, - nonce, - keep_alive, - execution_mode, - ) - .await, + } => 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 8cdf2e54..f1896a09 100644 --- a/src/cli/send.rs +++ b/src/cli/send.rs @@ -266,10 +266,13 @@ pub(crate) fn build_transfer_call_for_account_id( 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) +) -> 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( @@ -594,14 +597,71 @@ 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)] -/// `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, + 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, + 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>, - sweep_keep_alive: Option, node_url: &str, password: Option, password_file: Option, @@ -657,13 +717,11 @@ pub async fn handle_send_command( let effective_tip = effective_tip_amount(tip_amount); let submit_tip = positive_tip_amount(tip_amount); - let transfer_call = match (amount, sweep_keep_alive) { + 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, Some(keep_alive)) => - Box::new(build_transfer_all_call_for_account_id(to_account_id, keep_alive)) - as Box, - (Some(value), _) => { + 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( @@ -678,10 +736,6 @@ pub async fn handle_send_command( .await?; call }, - (None, None) => - return Err(crate::error::QuantusError::Generic( - "send needs either --amount or --all".to_string(), - )), }; // Create and submit transaction @@ -815,34 +869,10 @@ 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, false); - 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, 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; @@ -878,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/wormhole.rs b/src/cli/wormhole.rs index d0753611..3b8a9e09 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -1209,7 +1209,8 @@ fn included_at_for_stage(stage: TransactionStage) -> IncludedAt { /// /// Must match the wait mode: if funding/verify only waited for best-block inclusion, /// 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. +/// 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, @@ -1477,8 +1478,8 @@ pub async fn at_finalized_block( head_or_finalized_block(quantus_client, true).await } -/// Fetch the latest (best) block as a fully materialised subxt `Block`, whatever -/// `--finalized` installed. +/// 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 @@ -4904,28 +4905,31 @@ 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. + /// 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_installed_switch() { + async fn tip_block_follows_the_supplied_mode_not_the_scoped_one() { 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); + 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 the installed switch. + /// 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::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); + 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] diff --git a/src/main.rs b/src/main.rs index dcc5de21..ce6d3753 100644 --- a/src/main.rs +++ b/src/main.rs @@ -94,9 +94,6 @@ async fn main() -> Result<(), QuantusError> { 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). From a350116980dafb3aa2b82d4de4919d79c1c1253d Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Mon, 19 Oct 2026 18:41:37 +0800 Subject: [PATCH 5/6] fix: align exercise waits with its reads, keep --finalized-tx after events ExerciseCtx::wait_mode hardcoded best-block inclusion while its balance checks follow the scoped read block. Under --finalized, setup returned from the inclusion wait, read the old finalized balances and aborted on a zero funding delta. Waits now go to the finality reads are taken at, via ExecutionMode::wait_at_read_finality. events has its own --finalized, which shadows the global flag and its finalized-tx alias. The local flag carries the alias too, so `events --finalized-tx` parses again. events also read the header for the reported block number at the best block while the hash followed the scoped mode. Finalized and latest now share one branch that reads the header at the chosen hash. --- src/cli/common.rs | 16 ++++++++++++ src/cli/events.rs | 50 +++++++++----------------------------- src/cli/exercise/runner.rs | 3 ++- src/cli/mod.rs | 2 +- src/main.rs | 26 ++++++++++++++++++++ 5 files changed, 56 insertions(+), 41 deletions(-) diff --git a/src/cli/common.rs b/src/cli/common.rs index 7e07b625..8e460b86 100644 --- a/src/cli/common.rs +++ b/src/cli/common.rs @@ -59,6 +59,12 @@ impl ExecutionMode { READS_AT_FINALIZED.try_with(|finalized| *finalized).unwrap_or(false) } + /// Wait for each transaction to the finality reads are taken at, so a read after the wait + /// sees what it waited for. + pub fn wait_at_read_finality() -> Self { + Self { finalized: Self::reads_at_finalized(), wait_for_transaction: true } + } + pub fn transaction_stage(self) -> TransactionStage { if self.finalized { TransactionStage::Finalized @@ -1229,6 +1235,16 @@ mod tests { assert!(!ExecutionMode::reads_at_finalized()); } + #[tokio::test] + async fn waits_follow_the_scoped_read_finality() { + for finalized in [false, true] { + let mode = ExecutionMode { finalized, wait_for_transaction: false } + .scope(async { ExecutionMode::wait_at_read_finality() }) + .await; + assert_eq!(mode, ExecutionMode { finalized, wait_for_transaction: true }); + } + } + #[test] fn finalized_mode_implies_waiting_for_finalization() { let mode = ExecutionMode { finalized: true, wait_for_transaction: false }; diff --git a/src/cli/events.rs b/src/cli/events.rs index d2025108..4198da99 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/runner.rs b/src/cli/exercise/runner.rs index df0409ba..b90e03ba 100644 --- a/src/cli/exercise/runner.rs +++ b/src/cli/exercise/runner.rs @@ -43,8 +43,9 @@ pub struct ExerciseCtx { } impl ExerciseCtx { + /// Every balance check follows a wait, so the wait must reach the block the check reads. pub fn wait_mode(&self) -> ExecutionMode { - ExecutionMode { finalized: false, wait_for_transaction: true } + ExecutionMode::wait_at_read_finality() } /// Raw units drawn from the root account so far, net of anything swept back to it and of diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 8c722fe1..657c4b7b 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -235,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") diff --git a/src/main.rs b/src/main.rs index ce6d3753..beae0a26 100644 --- a/src/main.rs +++ b/src/main.rs @@ -138,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); + } +} From 220567d1ad6ce27cd1c9159242734b707e10b7e0 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Mon, 19 Oct 2026 18:51:05 +0800 Subject: [PATCH 6/6] fix(exercise): refuse --finalized instead of following it Following the finalized block broke exercise's short-lived proposals. Multisig and treasury proposals expire 3 to 100 blocks past the block read, and each step waits on the last, so finality's ~100-block lag put those deadlines in the past after setup had already moved funds. Exercise goes back to head reads and inclusion waits, and handle_exercise_command refuses a finalized scope up front rather than ignore the flag. --- src/cli/common.rs | 16 ---------------- src/cli/exercise/mod.rs | 30 ++++++++++++++++++++++++++++++ src/cli/exercise/runner.rs | 3 +-- 3 files changed, 31 insertions(+), 18 deletions(-) diff --git a/src/cli/common.rs b/src/cli/common.rs index 8e460b86..7e07b625 100644 --- a/src/cli/common.rs +++ b/src/cli/common.rs @@ -59,12 +59,6 @@ impl ExecutionMode { READS_AT_FINALIZED.try_with(|finalized| *finalized).unwrap_or(false) } - /// Wait for each transaction to the finality reads are taken at, so a read after the wait - /// sees what it waited for. - pub fn wait_at_read_finality() -> Self { - Self { finalized: Self::reads_at_finalized(), wait_for_transaction: true } - } - pub fn transaction_stage(self) -> TransactionStage { if self.finalized { TransactionStage::Finalized @@ -1235,16 +1229,6 @@ mod tests { assert!(!ExecutionMode::reads_at_finalized()); } - #[tokio::test] - async fn waits_follow_the_scoped_read_finality() { - for finalized in [false, true] { - let mode = ExecutionMode { finalized, wait_for_transaction: false } - .scope(async { ExecutionMode::wait_at_read_finality() }) - .await; - assert_eq!(mode, ExecutionMode { finalized, wait_for_transaction: true }); - } - } - #[test] fn finalized_mode_implies_waiting_for_finalization() { let mode = ExecutionMode { finalized: true, wait_for_transaction: false }; diff --git a/src/cli/exercise/mod.rs b/src/cli/exercise/mod.rs index 4205908f..e08f98df 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/exercise/runner.rs b/src/cli/exercise/runner.rs index b90e03ba..df0409ba 100644 --- a/src/cli/exercise/runner.rs +++ b/src/cli/exercise/runner.rs @@ -43,9 +43,8 @@ pub struct ExerciseCtx { } impl ExerciseCtx { - /// Every balance check follows a wait, so the wait must reach the block the check reads. pub fn wait_mode(&self) -> ExecutionMode { - ExecutionMode::wait_at_read_finality() + ExecutionMode { finalized: false, wait_for_transaction: true } } /// Raw units drawn from the root account so far, net of anything swept back to it and of