Skip to content
1 change: 1 addition & 0 deletions LIBRARY_USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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
Expand Down Expand Up @@ -787,8 +789,8 @@ quantus send --from crystal_alice --to <address> --amount 10.5
# Wait for inclusion in a best block
quantus send --from crystal_alice --to <address> --amount 10.5 --wait-for-transaction

# Wait for finalization (implies --wait-for-transaction)
quantus send --from crystal_alice --to <address> --amount 10.5 --finalized-tx
# Wait for finalization, reading at the finalized block (implies --wait-for-transaction)
quantus send --from crystal_alice --to <address> --amount 10.5 --finalized

# Optional advanced: add a tip to prioritize the transaction
quantus send --from crystal_alice --to <address> --amount 10 --tip 0.1
Expand All @@ -798,6 +800,12 @@ quantus send --from crystal_alice --to <address> --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 <address> --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 <address> --all

# Sweep but leave the existential deposit so the sender survives
quantus send --from crystal_alice --to <address> --all --keep-alive
```

Transaction status terms:
Expand Down
92 changes: 88 additions & 4 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,30 @@ impl QuantusClient {
&self.rpc_client
}

/// Get the latest block (best block) using RPC call
/// This bypasses SubXT's default behavior of using finalized blocks
/// The block every read in the CLI is taken at: [`Self::head_or_finalized`] under the
/// [`ExecutionMode`] `execute_command` scopes, so `--finalized` moves every read.
///
/// [`ExecutionMode`]: crate::cli::common::ExecutionMode
pub async fn get_latest_block(&self) -> crate::error::Result<subxt::utils::H256> {
self.head_or_finalized(crate::cli::common::ExecutionMode::reads_at_finalized())
.await
}

/// The finalized block when `finalized`, the head otherwise.
///
/// subxt's own default is the finalized block, which on this chain is ~100 blocks (~20
/// minutes) behind — stale state, and after a runtime upgrade the previous runtime's state
/// entirely. Callers whose reads must match the [`ExecutionMode`] they were handed pass its
/// `finalized` here: library callers reaching them directly are outside any scope.
///
/// [`ExecutionMode`]: crate::cli::common::ExecutionMode
pub async fn head_or_finalized(&self, finalized: bool) -> crate::error::Result<H256> {
if finalized {
log_verbose!("🔍 Fetching finalized block hash via RPC...");
let hash = finalized_block_hash(&self.rpc_client).await?;
log_verbose!("📦 Finalized block hash: {:?}", hash);
return Ok(hash);
}
log_verbose!("🔍 Fetching latest block hash via RPC...");
let latest_hash = best_block_hash(&self.rpc_client).await?;
log_verbose!("📦 Latest block hash: {:?}", latest_hash);
Expand All @@ -257,8 +310,9 @@ impl QuantusClient {
) -> crate::error::Result<u64> {
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
Expand Down Expand Up @@ -313,6 +367,14 @@ impl QuantusClient {
Ok((version.spec_version, version.transaction_version))
}

/// `state_getRuntimeVersion` at `at`: the raw JSON and the parsed pair.
pub async fn get_runtime_version_at(
&self,
at: H256,
) -> crate::error::Result<(serde_json::Value, RuntimeVersion)> {
fetch_runtime_version(&self.rpc_client, Some(at)).await
}

/// Get runtime hash using RPC call (if available)
pub async fn get_runtime_hash(&self) -> crate::error::Result<Option<String>> {
log_verbose!("🔍 Fetching runtime hash via RPC...");
Expand Down Expand Up @@ -343,6 +405,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 Expand Up @@ -513,6 +586,17 @@ pub(crate) mod test_support {
Ok::<_, ErrorObjectOwned>(FINALIZED)
})
.expect("register");
module
.register_method("chain_getHeader", |_, _, _| {
Ok::<_, ErrorObjectOwned>(json!({
"parentHash": GENESIS,
"number": "0x1",
"stateRoot": GENESIS,
"extrinsicsRoot": GENESIS,
"digest": { "logs": [] },
}))
})
.expect("register");
module
.register_method("state_getRuntimeVersion", |params, _, _| {
let at: Option<H256> = params.sequence().optional_next()?;
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
30 changes: 27 additions & 3 deletions src/cli/cold_signing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 })
Expand Down Expand Up @@ -491,7 +495,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 Expand Up @@ -737,6 +741,26 @@ mod tests {
.expect("valid keypair")
}

/// The mortality anchor is the head even under `--finalized`, like the nonce: anchored
/// ~100 blocks back the signer would have ~156 blocks to answer, and none once finality
/// lags by 256.
#[tokio::test]
async fn tx_context_anchors_mortality_at_the_head_under_finalized() {
use crate::{
chain::client::test_support::{mock_node, HEAD},
cli::common::ExecutionMode,
};

let node = mock_node(false).await;
let client = QuantusClient::new(&node.url).await.expect("connect");
let ctx = ExecutionMode { finalized: true, wait_for_transaction: false }
.scope(capture_tx_context(&client, &alice_account(), 0, Some(7)))
.await
.expect("context");
assert_eq!(ctx.block_hash, HEAD);
assert_eq!(ctx.nonce, 7);
}

fn transfer_call() -> impl subxt::tx::Payload {
let dest: subxt::utils::AccountId32 =
subxt::utils::AccountId32(*b"01234567890123456789012345678901");
Expand Down
Loading
Loading