From e87b9d77801ded7d636c4c20b481eb859baeec45 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Wed, 23 Sep 2026 23:10:44 +0800 Subject: [PATCH] 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).