use crate::{
chain::{client::QuantusClient, quantus_subxt},
cli::common::{
resolve_address_with_subxt_account_id, resolve_to_subxt_account_id, SubxtAccountId32,
},
error::Result,
log_info, log_print, log_success, log_verbose,
};
use colored::Colorize;
pub struct AccountBalanceData {
pub free: u128,
pub reserved: u128,
pub frozen: u128,
}
pub async fn get_account_data(
quantus_client: &QuantusClient,
account_address: &str,
) -> Result<AccountBalanceData> {
use quantus_subxt::api;
log_verbose!("💰 Querying balance for account: {}", account_address.bright_green());
let account_id = resolve_to_subxt_account_id(account_address)?;
let storage_addr = api::storage().system().account(account_id);
let latest_block_hash = quantus_client.get_latest_block().await?;
let storage_at = quantus_client.client().storage().at(latest_block_hash);
let account_info = storage_at.fetch_or_default(&storage_addr).await.map_err(|e| {
crate::error::QuantusError::NetworkError(format!("Failed to fetch account info: {e:?}"))
})?;
Ok(AccountBalanceData {
free: account_info.data.free,
reserved: account_info.data.reserved,
frozen: account_info.data.frozen,
})
}
pub async fn get_balance(quantus_client: &QuantusClient, account_address: &str) -> Result<u128> {
let data = get_account_data(quantus_client, account_address).await?;
Ok(data.free)
}
pub async fn get_chain_properties(quantus_client: &QuantusClient) -> Result<(String, u8)> {
match crate::cli::system::get_complete_chain_info(quantus_client.node_url()).await {
Ok(chain_info) => {
log_verbose!(
"💰 Token: {} with {} decimals",
chain_info.token.symbol,
chain_info.token.decimals
);
Ok((chain_info.token.symbol, chain_info.token.decimals))
},
Err(e) => {
log_verbose!("❌ ChainHead API failed: {:?}", e);
Err(e)
},
}
}
pub async fn format_balance_with_symbol(
quantus_client: &QuantusClient,
amount: u128,
) -> Result<String> {
let (symbol, decimals) = get_chain_properties(quantus_client).await?;
let formatted_amount = format_balance(amount, decimals);
Ok(format!("{formatted_amount} {symbol}"))
}
pub fn format_balance(amount: u128, decimals: u8) -> String {
if decimals == 0 {
return amount.to_string();
}
let divisor = 10_u128.pow(decimals as u32);
let whole_part = amount / divisor;
let fractional_part = amount % divisor;
if fractional_part == 0 {
whole_part.to_string()
} else {
let fractional_str = format!("{:0width$}", fractional_part, width = decimals as usize);
let fractional_str = fractional_str.trim_end_matches('0');
if fractional_str.is_empty() {
whole_part.to_string()
} else {
format!("{whole_part}.{fractional_str}")
}
}
}
pub async fn parse_amount(quantus_client: &QuantusClient, amount_str: &str) -> Result<u128> {
let (_, decimals) = get_chain_properties(quantus_client).await?;
parse_amount_with_decimals(amount_str, decimals)
}
pub fn parse_amount_with_decimals(amount_str: &str, decimals: u8) -> Result<u128> {
let amount_part = amount_str.trim();
if amount_part.is_empty() {
return Err(crate::error::QuantusError::Generic("Amount cannot be empty".to_string()));
}
if amount_part.starts_with('-') {
return Err(crate::error::QuantusError::Generic("Amount cannot be negative".to_string()));
}
let normalized_amount_part = amount_part.strip_prefix('+').unwrap_or(amount_part);
let mut parts = normalized_amount_part.split('.');
let whole_part = parts.next().unwrap_or_default();
let fractional_part = parts.next();
if parts.next().is_some() {
return Err(crate::error::QuantusError::Generic(format!(
"Invalid amount format: '{amount_part}'. Use plain decimal strings like '10', '10.5', or '0.0001'"
)));
}
if whole_part.is_empty() && fractional_part.is_none() {
return Err(crate::error::QuantusError::Generic(format!(
"Invalid amount format: '{amount_part}'. Use plain decimal strings like '10', '10.5', or '0.0001'"
)));
}
if !whole_part.is_empty() && !whole_part.chars().all(|ch| ch.is_ascii_digit()) {
return Err(crate::error::QuantusError::Generic(format!(
"Invalid amount format: '{amount_part}'. Use plain decimal strings like '10', '10.5', or '0.0001'"
)));
}
let fractional_part = fractional_part.unwrap_or_default();
if !fractional_part.is_empty() && !fractional_part.chars().all(|ch| ch.is_ascii_digit()) {
return Err(crate::error::QuantusError::Generic(format!(
"Invalid amount format: '{amount_part}'. Use plain decimal strings like '10', '10.5', or '0.0001'"
)));
}
if whole_part.is_empty() && fractional_part.is_empty() {
return Err(crate::error::QuantusError::Generic(format!(
"Invalid amount format: '{amount_part}'. Use plain decimal strings like '10', '10.5', or '0.0001'"
)));
}
if fractional_part.len() > decimals as usize {
return Err(crate::error::QuantusError::Generic(format!(
"Too many decimal places. Maximum {decimals} decimal places allowed for this chain"
)));
}
let multiplier = 10_u128.checked_pow(decimals as u32).ok_or_else(|| {
crate::error::QuantusError::Generic(format!("Unsupported chain decimals value: {decimals}"))
})?;
let whole_value = if whole_part.is_empty() {
0
} else {
whole_part.parse::<u128>().map_err(|_| {
crate::error::QuantusError::Generic(format!(
"Amount is too large to represent: '{amount_part}'"
))
})?
};
let whole_raw = whole_value.checked_mul(multiplier).ok_or_else(|| {
crate::error::QuantusError::Generic(format!(
"Amount is too large to represent: '{amount_part}'"
))
})?;
let fractional_raw = if fractional_part.is_empty() {
0
} else {
let fractional_value = fractional_part.parse::<u128>().map_err(|_| {
crate::error::QuantusError::Generic(format!(
"Amount is too large to represent: '{amount_part}'"
))
})?;
let padding = decimals as usize - fractional_part.len();
let scale = 10_u128.checked_pow(padding as u32).ok_or_else(|| {
crate::error::QuantusError::Generic(format!(
"Unsupported chain decimals value: {decimals}"
))
})?;
fractional_value.checked_mul(scale).ok_or_else(|| {
crate::error::QuantusError::Generic(format!(
"Amount is too large to represent: '{amount_part}'"
))
})?
};
let raw_amount = whole_raw.checked_add(fractional_raw).ok_or_else(|| {
crate::error::QuantusError::Generic(format!(
"Amount is too large to represent: '{amount_part}'"
))
})?;
if raw_amount == 0 {
return Err(crate::error::QuantusError::Generic(
"Amount too small to represent in chain units".to_string(),
));
}
Ok(raw_amount)
}
pub async fn validate_and_format_amount(
quantus_client: &QuantusClient,
amount_str: &str,
) -> Result<(u128, String)> {
let raw_amount = parse_amount(quantus_client, amount_str).await?;
let formatted = format_balance_with_symbol(quantus_client, raw_amount).await?;
Ok((raw_amount, formatted))
}
pub(crate) fn checked_add(lhs: u128, rhs: u128, context: &str) -> Result<u128> {
lhs.checked_add(rhs).ok_or_else(|| {
crate::error::QuantusError::Generic(format!("Value overflow while computing {context}"))
})
}
pub fn effective_tip_amount(tip: Option<u128>) -> u128 {
tip.unwrap_or_default()
}
pub(crate) fn positive_tip_amount(tip: Option<u128>) -> Option<u128> {
tip.filter(|tip_amount| *tip_amount > 0)
}
fn build_transfer_call_for_account_id(
to_account_id: SubxtAccountId32,
amount: u128,
) -> impl subxt::tx::Payload {
quantus_subxt::api::tx().balances().transfer_allow_death(
subxt::ext::subxt_core::utils::MultiAddress::Id(to_account_id),
amount,
)
}
pub(crate) fn build_batch_transfer_call(
transfers: &[(String, u128)],
) -> Result<impl subxt::tx::Payload> {
use quantus_subxt::api::runtime_types::{
pallet_balances::pallet::Call as BalancesCall, quantus_runtime::RuntimeCall,
};
let mut calls = Vec::with_capacity(transfers.len());
for (to_address, amount) in transfers {
let to_account_id = resolve_to_subxt_account_id(to_address)?;
calls.push(RuntimeCall::Balances(BalancesCall::transfer_allow_death {
dest: subxt::ext::subxt_core::utils::MultiAddress::Id(to_account_id),
value: *amount,
}));
}
Ok(quantus_subxt::api::tx().utility().batch(calls))
}
pub async fn estimate_transaction_partial_fee<Call>(
quantus_client: &QuantusClient,
from_keypair: &crate::wallet::QuantumKeyPair,
call: &Call,
tip: Option<u128>,
) -> Result<u128>
where
Call: subxt::tx::Payload,
{
let signer = from_keypair.to_subxt_signer().map_err(|e| {
crate::error::QuantusError::NetworkError(format!("Failed to convert keypair: {e:?}"))
})?;
use subxt::config::DefaultExtrinsicParamsBuilder;
let mut params_builder = DefaultExtrinsicParamsBuilder::new().mortal(256);
if let Some(tip_amount) = tip {
params_builder = params_builder.tip(tip_amount);
}
let mut tx_client = quantus_client.client().tx();
let signed_tx =
tx_client
.create_signed(call, &signer, params_builder.build())
.await
.map_err(|e| {
crate::error::QuantusError::NetworkError(format!(
"Failed to prepare transaction for fee estimation: {e:?}"
))
})?;
signed_tx.partial_fee_estimate().await.map_err(|e| {
crate::error::QuantusError::NetworkError(format!(
"Failed to estimate transaction fee: {e:?}"
))
})
}
pub(crate) async fn ensure_balance_covers_call<Call>(
quantus_client: &QuantusClient,
keypair: &crate::wallet::QuantumKeyPair,
call: &Call,
balance: u128,
exact_required: u128,
submit_tip: Option<u128>,
label: &str,
) -> Result<()>
where
Call: subxt::tx::Payload,
{
if balance < exact_required {
return Err(crate::error::QuantusError::InsufficientBalance {
available: balance,
required: exact_required,
});
}
match estimate_transaction_partial_fee(quantus_client, keypair, call, submit_tip).await {
Ok(estimated_fee) => {
let estimated_total =
checked_add(exact_required, estimated_fee, "required balance including fee")?;
if balance < estimated_total {
let formatted_balance = format_balance_with_symbol(quantus_client, balance).await?;
let formatted_needed =
format_balance_with_symbol(quantus_client, estimated_total).await?;
let formatted_fee =
format_balance_with_symbol(quantus_client, estimated_fee).await?;
if let Some(tip_amount) = submit_tip {
let formatted_tip =
format_balance_with_symbol(quantus_client, tip_amount).await?;
return Err(crate::error::QuantusError::Generic(format!(
"Insufficient balance for {label}. Have: {formatted_balance}, Need: {formatted_needed} (tip: {formatted_tip}, estimated fee: {formatted_fee})"
)));
}
return Err(crate::error::QuantusError::Generic(format!(
"Insufficient balance for {label}. Have: {formatted_balance}, Need: {formatted_needed} (estimated fee: {formatted_fee})"
)));
}
let formatted_estimated_fee =
format_balance_with_symbol(quantus_client, estimated_fee).await?;
log_verbose!("💸 Estimated network fee: {}", formatted_estimated_fee.bright_cyan());
},
Err(err) =>
if submit_tip.is_some() {
log_verbose!(
"⚠️ Fee estimation unavailable; proceeding with exact amount+tip check only: {}",
err
);
} else {
log_verbose!(
"⚠️ Fee estimation unavailable; proceeding with exact amount check only: {}",
err
);
},
}
Ok(())
}
async fn submit_transfer_call<Call>(
quantus_client: &QuantusClient,
from_keypair: &crate::wallet::QuantumKeyPair,
transfer_call: Call,
submit_tip: Option<u128>,
nonce: Option<u32>,
execution_mode: crate::cli::common::ExecutionMode,
) -> Result<subxt::utils::H256>
where
Call: subxt::tx::Payload,
{
if let Some(manual_nonce) = nonce {
log_verbose!("🔢 Using manual nonce: {}", manual_nonce);
crate::cli::common::submit_transaction_with_nonce(
quantus_client,
from_keypair,
transfer_call,
submit_tip,
manual_nonce,
execution_mode,
)
.await
} else {
crate::cli::common::submit_transaction(
quantus_client,
from_keypair,
transfer_call,
submit_tip,
execution_mode,
)
.await
}
}
#[allow(dead_code)] pub async fn transfer(
quantus_client: &QuantusClient,
from_keypair: &crate::wallet::QuantumKeyPair,
to_address: &str,
amount: u128,
tip: Option<u128>,
execution_mode: crate::cli::common::ExecutionMode,
) -> Result<subxt::utils::H256> {
transfer_with_nonce(quantus_client, from_keypair, to_address, amount, tip, None, execution_mode)
.await
}
pub async fn transfer_with_nonce(
quantus_client: &QuantusClient,
from_keypair: &crate::wallet::QuantumKeyPair,
to_address: &str,
amount: u128,
tip: Option<u128>,
nonce: Option<u32>,
execution_mode: crate::cli::common::ExecutionMode,
) -> Result<subxt::utils::H256> {
log_verbose!("🚀 Creating transfer transaction...");
log_verbose!(" From: {}", from_keypair.to_account_id_ss58check().bright_cyan());
log_verbose!(" To: {}", to_address.bright_green());
log_verbose!(" Amount: {}", amount);
let (resolved_address, to_account_id) = resolve_address_with_subxt_account_id(to_address)?;
log_verbose!(" Resolved to: {}", resolved_address.bright_green());
log_verbose!("✍️ Creating balance transfer extrinsic...");
let transfer_call = build_transfer_call_for_account_id(to_account_id, amount);
let submit_tip = positive_tip_amount(tip);
let tx_hash = submit_transfer_call(
quantus_client,
from_keypair,
transfer_call,
submit_tip,
nonce,
execution_mode,
)
.await?;
log_verbose!("📋 Transaction submitted: {:?}", tx_hash);
Ok(tx_hash)
}
pub(crate) async fn validate_batch_transfer_request(
quantus_client: &QuantusClient,
from_keypair: &crate::wallet::QuantumKeyPair,
transfers: &[(String, u128)],
) -> Result<()> {
log_verbose!("🚀 Preparing batch transfer transaction with {} transfers...", transfers.len());
log_verbose!(" From: {}", from_keypair.to_account_id_ss58check().bright_cyan());
if transfers.is_empty() {
return Err(crate::error::QuantusError::Generic(
"No transfers provided for batch".to_string(),
));
}
let (safe_limit, recommended_limit) =
get_batch_limits(quantus_client).await.unwrap_or((500, 1000));
if transfers.len() as u32 > recommended_limit {
return Err(crate::error::QuantusError::Generic(format!(
"Too many transfers in batch ({}) - chain limit is ~{} (safe: {})",
transfers.len(),
recommended_limit,
safe_limit
)));
}
if transfers.len() as u32 > safe_limit {
log_verbose!(
"⚠️ Large batch ({} transfers) - approaching chain limits (safe: {}, max: {})",
transfers.len(),
safe_limit,
recommended_limit
);
}
for (to_address, amount) in transfers {
resolve_to_subxt_account_id(to_address)?;
log_verbose!(" To: {} Amount: {}", to_address.bright_green(), amount);
}
Ok(())
}
pub(crate) async fn submit_prebuilt_batch_transfer_call<Call>(
quantus_client: &QuantusClient,
from_keypair: &crate::wallet::QuantumKeyPair,
transfers: &[(String, u128)],
batch_call: Call,
tip: Option<u128>,
execution_mode: crate::cli::common::ExecutionMode,
) -> Result<subxt::utils::H256>
where
Call: subxt::tx::Payload,
{
log_verbose!("📤 Submitting batch extrinsic with {} calls...", transfers.len());
let tx_hash = crate::cli::common::submit_transaction(
quantus_client,
from_keypair,
batch_call,
positive_tip_amount(tip),
execution_mode,
)
.await?;
log_verbose!("📋 Batch transaction submitted: {:?}", tx_hash);
Ok(tx_hash)
}
pub async fn batch_transfer(
quantus_client: &QuantusClient,
from_keypair: &crate::wallet::QuantumKeyPair,
transfers: Vec<(String, u128)>, tip: Option<u128>,
execution_mode: crate::cli::common::ExecutionMode,
) -> Result<subxt::utils::H256> {
validate_batch_transfer_request(quantus_client, from_keypair, &transfers).await?;
log_verbose!("✍️ Creating batch extrinsic with {} calls...", transfers.len());
let batch_call = build_batch_transfer_call(&transfers)?;
submit_prebuilt_batch_transfer_call(
quantus_client,
from_keypair,
&transfers,
batch_call,
tip,
execution_mode,
)
.await
}
pub async fn handle_send_command(
from_wallet: String,
to_address: String,
amount_str: &str,
node_url: &str,
password: Option<String>,
password_file: Option<String>,
tip: Option<String>,
nonce: Option<u32>,
execution_mode: crate::cli::common::ExecutionMode,
) -> Result<()> {
let quantus_client = QuantusClient::new(node_url).await?;
let (amount, formatted_amount) =
validate_and_format_amount(&quantus_client, amount_str).await?;
let (resolved_address, to_account_id) = resolve_address_with_subxt_account_id(&to_address)?;
log_info!("🚀 Initiating transfer of {} to {}", formatted_amount, resolved_address);
log_verbose!(
"🚀 {} Sending {} to {}",
"SEND".bright_cyan().bold(),
formatted_amount.bright_yellow().bold(),
resolved_address.bright_green()
);
log_verbose!("📦 Using wallet: {}", from_wallet.bright_blue().bold());
let keypair = crate::wallet::load_keypair_from_wallet(&from_wallet, password, password_file)?;
let from_account_id = keypair.to_account_id_ss58check();
let balance = get_balance(&quantus_client, &from_account_id).await?;
let formatted_balance = format_balance_with_symbol(&quantus_client, balance).await?;
log_verbose!("💰 Current balance: {}", formatted_balance.bright_yellow());
let tip_amount = if let Some(tip_str) = &tip {
Some(parse_amount(&quantus_client, tip_str).await?)
} else {
None
};
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,
&keypair,
&transfer_call,
balance,
exact_required,
submit_tip,
"send",
)
.await?;
log_verbose!("✍️ {} Signing transaction...", "SIGN".bright_magenta().bold());
let tx_hash = submit_transfer_call(
&quantus_client,
&keypair,
transfer_call,
submit_tip,
nonce,
execution_mode,
)
.await?;
let transaction_stage = execution_mode.transaction_stage();
log_print!(
"✅ {} Transaction {}. Hash: {:?}",
"SUCCESS".bright_green().bold(),
transaction_stage.status_label(),
tx_hash
);
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.",
transaction_stage.success_detail()
);
return Ok(());
}
log_success!(
"🎉 {} Transaction {}.",
"FINISHED".bright_green().bold(),
transaction_stage.success_detail()
);
let new_balance = get_balance(&quantus_client, &from_account_id).await?;
let formatted_new_balance = format_balance_with_symbol(&quantus_client, new_balance).await?;
let fee_paid = balance.saturating_sub(new_balance).saturating_sub(amount);
if fee_paid > 0 {
let formatted_fee = format_balance_with_symbol(&quantus_client, fee_paid).await?;
log_verbose!("💸 Transaction fee: {}", formatted_fee.bright_cyan());
}
log_print!("💰 New balance: {}", formatted_new_balance.bright_yellow());
Ok(())
}
pub async fn load_transfers_from_file(file_path: &str) -> Result<Vec<(String, u128)>> {
use serde_json;
use std::fs;
#[derive(serde::Deserialize)]
struct TransferEntry {
to: String,
amount: String,
}
let content = fs::read_to_string(file_path).map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to read batch file: {e:?}"))
})?;
let entries: Vec<TransferEntry> = serde_json::from_str(&content).map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to parse batch file JSON: {e:?}"))
})?;
let mut transfers = Vec::new();
for entry in entries {
let amount = entry.amount.parse::<u128>().map_err(|e| {
crate::error::QuantusError::Generic(format!("Invalid amount '{}': {e:?}", entry.amount))
})?;
transfers.push((entry.to, amount));
}
Ok(transfers)
}
pub async fn get_batch_limits(quantus_client: &QuantusClient) -> Result<(u32, u32)> {
let constants = quantus_client.client().constants();
let block_weight_limit = constants
.at(&quantus_subxt::api::constants().system().block_weights())
.map(|weights| weights.max_block.ref_time)
.unwrap_or(2_000_000_000_000);
let transfer_weight = 1_500_000_000u64; let max_transfers_by_weight = (block_weight_limit / transfer_weight) as u32;
let max_extrinsic_length = constants
.at(&quantus_subxt::api::constants().system().block_length())
.map(|length| length.max.normal)
.unwrap_or(5_242_880);
let transfer_size = 100u32; let max_transfers_by_size = max_extrinsic_length / transfer_size;
let recommended_limit = std::cmp::min(max_transfers_by_weight, max_transfers_by_size);
let safe_limit = recommended_limit / 2;
log_verbose!(
"📊 Chain limits: weight allows ~{}, size allows ~{}",
max_transfers_by_weight,
max_transfers_by_size
);
log_verbose!("📊 Recommended batch size: {} (safe: {})", recommended_limit, safe_limit);
Ok((safe_limit, recommended_limit))
}
#[cfg(test)]
mod tests {
use super::{effective_tip_amount, parse_amount_with_decimals};
#[test]
fn parses_exact_decimal_amounts() {
assert_eq!(parse_amount_with_decimals("0.1", 12).unwrap(), 100_000_000_000);
assert_eq!(parse_amount_with_decimals("0.000000000001", 12).unwrap(), 1);
assert_eq!(parse_amount_with_decimals("1.000000000000", 12).unwrap(), 1_000_000_000_000);
}
#[test]
fn accepts_single_leading_plus_for_positive_amounts() {
assert_eq!(parse_amount_with_decimals("+1.0", 12).unwrap(), 1_000_000_000_000);
assert_eq!(parse_amount_with_decimals("+0.000000000001", 12).unwrap(), 1);
}
#[test]
fn rejects_malformed_and_invalid_amounts() {
assert!(parse_amount_with_decimals("", 12).is_err());
assert!(parse_amount_with_decimals("-1", 12).is_err());
assert!(parse_amount_with_decimals("abc", 12).is_err());
assert!(parse_amount_with_decimals("1e3", 12).is_err());
assert!(parse_amount_with_decimals("1.2.3", 12).is_err());
assert!(parse_amount_with_decimals("0", 12).is_err());
assert!(parse_amount_with_decimals("0.000000000000", 12).is_err());
assert!(parse_amount_with_decimals("0.0000000000001", 12).is_err());
}
#[test]
fn rejects_invalid_plus_prefixed_amounts() {
assert!(parse_amount_with_decimals("+", 12).is_err());
assert!(parse_amount_with_decimals("++1", 12).is_err());
assert!(parse_amount_with_decimals("+0", 12).is_err());
}
#[test]
fn handles_u128_boundaries_exactly() {
assert_eq!(parse_amount_with_decimals(&u128::MAX.to_string(), 0).unwrap(), u128::MAX);
let factor = 10_u128.pow(12);
let whole = u128::MAX / factor;
let fractional = u128::MAX % factor;
let max_value = format!("{whole}.{:012}", fractional);
assert_eq!(parse_amount_with_decimals(&max_value, 12).unwrap(), u128::MAX);
let overflow = format!("{}.0", whole + 1);
assert!(parse_amount_with_decimals(&overflow, 12).is_err());
}
#[test]
fn default_tip_amount_is_zero() {
assert_eq!(effective_tip_amount(None), 0);
}
#[test]
fn provided_tip_amount_is_preserved() {
assert_eq!(effective_tip_amount(Some(42)), 42);
}
}