Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 54 additions & 2 deletions src/chain/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u128, QuantusError> {
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<u64>, codec::Compact<u64>, u8, u128)>(
"TransactionPaymentApi_query_info",
Some(&params),
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.
Expand All @@ -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<subxt::utils::H256> {
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);
Expand Down Expand Up @@ -343,6 +384,17 @@ impl QuantusClient {
}
}

/// Finalized block hash via RPC.
async fn finalized_block_hash(ws_client: &WsClient) -> crate::error::Result<H256> {
use jsonrpsee::core::client::ClientT;
ws_client
.request::<H256, [(); 0]>("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<H256> {
ws_client.request::<H256, [(); 0]>("chain_getBlockHash", []).await.map_err(|e| {
QuantusError::NetworkError(format!("Failed to fetch latest block hash: {e:?}"))
Expand Down
2 changes: 1 addition & 1 deletion src/cli/batch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(());
Expand Down
2 changes: 1 addition & 1 deletion src/cli/cold_signing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,7 @@ async fn estimate_fee_with_dummy_signature<Call: subxt::tx::Payload>(
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
Expand Down
33 changes: 33 additions & 0 deletions src/cli/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 };
Expand Down
32 changes: 27 additions & 5 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,

/// 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)]
Expand Down Expand Up @@ -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,
Expand Down
111 changes: 87 additions & 24 deletions src/cli/send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<impl subxt::tx::Payload> {
Expand Down Expand Up @@ -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<Call>(
Expand Down Expand Up @@ -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<bool>,
node_url: &str,
password: Option<String>,
password_file: Option<String>,
Expand All @@ -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)?;
Expand Down Expand Up @@ -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<dyn subxt::tx::Payload>,
(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<dyn subxt::tx::Payload>;
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());
Expand All @@ -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).
Expand All @@ -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(());
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading