diff --git a/README.md b/README.md
index 056c6ad..35e91fa 100644
--- a/README.md
+++ b/README.md
@@ -766,6 +766,9 @@ quantus send --from crystal_alice --to
--amount 10 --tip 0.1
# With manual nonce
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
```
Transaction status terms:
@@ -1146,6 +1149,9 @@ quantus multisig propose transfer \
--expiry \
--from signer_wallet
+# --keep-alive proposes transfer_keep_alive instead of the default transfer_allow_death
+# (the transfer fails rather than reaping the multisig; not for high-security multisigs)
+
# Amount formats supported:
# 10 → 10 QUAN
# 10.5 → 10.5 QUAN
diff --git a/src/chain/mod.rs b/src/chain/mod.rs
index babfbcb..e7bc905 100644
--- a/src/chain/mod.rs
+++ b/src/chain/mod.rs
@@ -8,3 +8,9 @@
pub mod client;
pub mod quantus_subxt;
pub mod signing;
+
+#[cfg(test)]
+pub(crate) fn test_metadata() -> subxt::Metadata {
+ let bytes: &[u8] = include_bytes!("../quantus_metadata.scale");
+ ::decode(&mut &bytes[..]).expect("valid checked-in metadata")
+}
diff --git a/src/chain/signing.rs b/src/chain/signing.rs
index fa18a82..f754d1e 100644
--- a/src/chain/signing.rs
+++ b/src/chain/signing.rs
@@ -13,6 +13,8 @@ use qp_dilithium_crypto::types::{
Dilithium65Pair, Dilithium65Signature, Dilithium65SignatureWithPublic, Dilithium87Pair,
Dilithium87Signature, Dilithium87SignatureWithPublic,
};
+use qp_rusty_crystals_dilithium::SensitiveBytes32;
+use rand::TryRngCore;
use sp_core::{ByteArray, Pair};
/// The FIPS 204 context runtimes from spec 148 bind extrinsic signatures to. Mirrors
@@ -26,6 +28,17 @@ pub fn context_for_runtime(spec_version: u32, transaction_version: u32) -> Optio
.then_some(EXTRINSIC)
}
+/// Fresh randomness for one signature. Deterministic ML-DSA plus a nonce reset by reaping would
+/// re-sign a byte-identical extrinsic (same hash, the first one replayable); hedging also closes
+/// the known-mask fault attacks on deterministic signing.
+pub(crate) fn hedge() -> SensitiveBytes32 {
+ let mut hedge = SensitiveBytes32::zeroed();
+ rand::rngs::OsRng
+ .try_fill_bytes(hedge.as_mut_bytes())
+ .expect("OS randomness for the signature hedge");
+ hedge
+}
+
macro_rules! context_sign {
($sign:ident, $pair:ty, $signature:ty, $sig_with_public:ty, $module:ident) => {
/// Sign `message` under `context`, the way the connected runtime expects.
@@ -33,8 +46,9 @@ macro_rules! context_sign {
let secret =
qp_rusty_crystals_dilithium::$module::SecretKey::from_bytes(pair.secret_bytes())
.expect("wallet secret key must parse");
- let signature =
- secret.sign(message, context, None).expect("the context is well-formed");
+ let signature = secret
+ .sign(message, context, Some(&hedge()))
+ .expect("the context is well-formed");
let signature = <$signature as TryFrom<&[u8]>>::try_from(signature.as_ref())
.expect("signature length is fixed");
<$sig_with_public>::new(signature, pair.public())
@@ -115,15 +129,32 @@ mod tests {
assert_ne!(with_context.to_bytes(), without_context.to_bytes());
}
- /// The pre-148 runtimes verify with no context at all, so the CLI must keep signing for them
- /// the old way. `sp_core::Pair::sign` in the published `qp-dilithium-crypto` is that old way.
+ /// The pre-148 runtimes verify with no context at all, the way `sp_core::Pair::verify` in the
+ /// published `qp-dilithium-crypto` does, so the CLI must keep signing for them that way.
#[test]
- fn pre_148_runtimes_get_the_contextless_signature_pair_sign_makes() {
+ fn pre_148_runtimes_verify_the_contextless_signature() {
let alice = qp_dilithium_crypto::crystal_alice();
- assert_eq!(
- sign_ml_dsa_87(&alice, b"payload", None).to_bytes(),
- ::sign(&alice, b"payload").to_bytes()
- );
+ let signature = sign_ml_dsa_87(&alice, b"payload", None);
+ assert!(::verify(&signature, b"payload", &alice.public()));
+ }
+
+ /// Signing is hedged with fresh randomness: two signatures over one payload differ, so two
+ /// otherwise identical extrinsics never share a hash, and both still verify.
+ #[test]
+ fn two_signings_of_the_same_payload_differ_and_both_verify() {
+ let alice = qp_dilithium_crypto::crystal_alice();
+ let first = sign_ml_dsa_87(&alice, b"payload", CTX);
+ let second = sign_ml_dsa_87(&alice, b"payload", CTX);
+ assert_ne!(first.to_bytes(), second.to_bytes());
+ assert!(verify_ml_dsa_87(&first, b"payload", CTX));
+ assert!(verify_ml_dsa_87(&second, b"payload", CTX));
+
+ let pair = Dilithium65Pair::from_seed(&[7u8; 32]).expect("seed is well-formed");
+ let first = sign_ml_dsa_65(&pair, b"payload", CTX);
+ let second = sign_ml_dsa_65(&pair, b"payload", CTX);
+ assert_ne!(first.to_bytes(), second.to_bytes());
+ assert!(verify_ml_dsa_65(&first, b"payload", CTX));
+ assert!(verify_ml_dsa_65(&second, b"payload", CTX));
}
#[test]
diff --git a/src/cli/airdrop.rs b/src/cli/airdrop.rs
index 9c60299..7de4928 100644
--- a/src/cli/airdrop.rs
+++ b/src/cli/airdrop.rs
@@ -1490,7 +1490,7 @@ fn build_dilithium_claim(
let secret = SecretKey::from_bytes(secret_key)
.map_err(|_| QuantusError::Generic("invalid ML-DSA-87 secret key".into()))?;
let signature = secret
- .sign(&msg, Some(CLAIM_CONTEXT), None)
+ .sign(&msg, Some(CLAIM_CONTEXT), Some(&crate::chain::signing::hedge()))
.map_err(|e| QuantusError::Generic(format!("ML-DSA sign failed: {e}")))?;
let scheme = DilithiumHash::ALL
.iter()
diff --git a/src/cli/cold_signing.rs b/src/cli/cold_signing.rs
index d7864e7..9e16c64 100644
--- a/src/cli/cold_signing.rs
+++ b/src/cli/cold_signing.rs
@@ -711,16 +711,13 @@ mod tests {
use subxt::{client::ClientState, config::substrate::H256, utils::MultiAddress, OfflineClient};
fn test_client_state() -> ClientState {
- let metadata_bytes: &[u8] = include_bytes!("../quantus_metadata.scale");
- let metadata = ::decode(&mut &metadata_bytes[..])
- .expect("valid metadata");
ClientState {
genesis_hash: H256::from([0x49u8; 32]),
runtime_version: subxt::client::RuntimeVersion {
spec_version: 136,
transaction_version: 3,
},
- metadata,
+ metadata: crate::chain::test_metadata(),
}
}
diff --git a/src/cli/mod.rs b/src/cli/mod.rs
index 6796e39..bd1360f 100644
--- a/src/cli/mod.rs
+++ b/src/cli/mod.rs
@@ -70,6 +70,11 @@ pub enum Commands {
/// Manual nonce override (use with caution - must be exact next nonce for account)
#[arg(long)]
nonce: Option,
+
+ /// Send with transfer_keep_alive instead of the default transfer_allow_death: the
+ /// transfer fails rather than reaping the sender below the existential deposit
+ #[arg(long)]
+ keep_alive: bool,
},
/// Print the QR a cold wallet scans to sign a call for an account, and stop
@@ -395,7 +400,7 @@ 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, password, password_file, tip, nonce, keep_alive } =>
send::handle_send_command(
from,
to,
@@ -405,6 +410,7 @@ pub async fn execute_command(
password_file,
tip,
nonce,
+ keep_alive,
execution_mode,
)
.await,
diff --git a/src/cli/multisig.rs b/src/cli/multisig.rs
index f579913..f7b1b49 100644
--- a/src/cli/multisig.rs
+++ b/src/cli/multisig.rs
@@ -136,6 +136,11 @@ pub enum ProposeSubcommand {
/// Read password from file
#[arg(long)]
password_file: Option,
+
+ /// Propose transfer_keep_alive instead of the default transfer_allow_death: the
+ /// transfer fails rather than reaping the multisig below the existential deposit
+ #[arg(long)]
+ keep_alive: bool,
},
/// Propose a custom transaction (full flexibility)
@@ -973,6 +978,7 @@ pub async fn handle_multisig_command(
from,
password,
password_file,
+ keep_alive,
} =>
handle_propose_transfer(
address,
@@ -982,6 +988,7 @@ pub async fn handle_multisig_command(
from,
password,
password_file,
+ keep_alive,
node_url,
execution_mode,
)
@@ -1359,13 +1366,15 @@ async fn handle_propose_transfer(
from: String,
password: Option,
password_file: Option,
+ keep_alive: bool,
node_url: &str,
execution_mode: ExecutionMode,
) -> crate::error::Result<()> {
log_print!("📝 {} Creating transfer proposal...", "MULTISIG".bright_magenta().bold());
// Resolve recipient address (wallet name or SS58)
- let to_address = crate::cli::common::resolve_address(&to)?;
+ let (to_address, to_account_id) =
+ crate::cli::common::resolve_address_with_subxt_account_id(&to)?;
// Parse amount (supports both human format "10" and raw "10000000000000")
let amount_u128: u128 = parse_amount(&amount)?;
@@ -1407,26 +1416,24 @@ async fn handle_propose_transfer(
.high_security_accounts(multisig_account_id);
let is_high_security = storage_at.fetch(&hs_query).await?.is_some();
+ use subxt::tx::Payload;
if is_high_security {
+ if keep_alive {
+ return Err(crate::error::QuantusError::Generic(
+ "--keep-alive does not apply to a high-security multisig: its transfers are proposed as ReversibleTransfers::schedule_transfer".to_string(),
+ ));
+ }
// HS enabled: use ReversibleTransfers::schedule_transfer (delayed + reversible)
log_print!(
"🛡️ {} High-Security detected: using delayed transfer (ReversibleTransfers::schedule_transfer)",
"HS".bright_green().bold()
);
- // Build schedule_transfer call data directly
- let (to_id, _) = SpAccountId32::from_ss58check_with_version(&to_address).map_err(|e| {
- crate::error::QuantusError::Generic(format!("Invalid recipient address: {:?}", e))
- })?;
- let to_bytes: [u8; 32] = *to_id.as_ref();
- let to_account_id = subxt::ext::subxt_core::utils::AccountId32::from(to_bytes);
let dest = subxt::utils::MultiAddress::Id(to_account_id);
-
let schedule_call = quantus_subxt::api::tx()
.reversible_transfers()
.schedule_transfer(dest, amount_u128);
- use subxt::tx::Payload;
let call_data = schedule_call
.encode_call_data(&quantus_client.client().metadata())
.map_err(|e| {
@@ -1446,7 +1453,13 @@ async fn handle_propose_transfer(
)
.await
} else {
- // No HS: use standard Balances::transfer_allow_death
+ // No HS: the Balances transfer `quantus send` makes, proposed by name
+ let call = crate::cli::send::build_transfer_call_for_account_id(
+ to_account_id,
+ amount_u128,
+ keep_alive,
+ );
+ let details = call.validation_details().expect("static balances call names itself");
let args_json = serde_json::to_string(&vec![
serde_json::Value::String(to_address),
serde_json::Value::String(amount_u128.to_string()),
@@ -1457,8 +1470,8 @@ async fn handle_propose_transfer(
handle_propose(
multisig_address,
- "Balances".to_string(),
- "transfer_allow_death".to_string(),
+ details.pallet_name.to_string(),
+ details.call_name.to_string(),
Some(args_json),
expiry,
from,
@@ -3335,6 +3348,7 @@ mod tests {
#[cfg(test)]
mod execute_call_tests {
use super::*;
+ use crate::chain::test_metadata;
use codec::Encode;
use quantus_subxt::api::runtime_types::{
pallet_balances::pallet::Call as BalancesCall, quantus_runtime::RuntimeCall,
@@ -3370,12 +3384,6 @@ mod execute_call_tests {
assert!(build_propose_tx(address, transfer_call().encode(), 5000).is_ok());
}
- fn test_metadata() -> subxt::Metadata {
- use codec::Decode;
- let bytes: &[u8] = include_bytes!("../quantus_metadata.scale");
- subxt::Metadata::decode(&mut &bytes[..]).expect("valid checked-in metadata")
- }
-
/// Indices come from the metadata, never from a hardcoded table.
fn call_indices(md: &subxt::Metadata, pallet: &str, call: &str) -> (u8, u8) {
let p = md.pallets().find(|p| p.name() == pallet).expect("pallet present");
diff --git a/src/cli/runtime.rs b/src/cli/runtime.rs
index 2acf896..b3413f0 100644
--- a/src/cli/runtime.rs
+++ b/src/cli/runtime.rs
@@ -526,12 +526,7 @@ pub async fn handle_runtime_command(
#[cfg(test)]
mod tests {
use super::*;
- use codec::Decode;
-
- fn metadata() -> subxt::Metadata {
- let bytes: &[u8] = include_bytes!("../quantus_metadata.scale");
- subxt::Metadata::decode(&mut &bytes[..]).expect("valid checked-in metadata")
- }
+ use crate::chain::test_metadata as metadata;
#[test]
fn authorization_preimage_contains_only_the_runtime_hash() {
diff --git a/src/cli/send.rs b/src/cli/send.rs
index 05c0225..3bb6902 100644
--- a/src/cli/send.rs
+++ b/src/cli/send.rs
@@ -246,14 +246,18 @@ pub(crate) fn positive_tip_amount(tip: Option) -> Option {
tip.filter(|tip_amount| *tip_amount > 0)
}
-fn build_transfer_call_for_account_id(
+pub(crate) 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,
- )
+ keep_alive: bool,
+) -> Box {
+ let dest = subxt::ext::subxt_core::utils::MultiAddress::Id(to_account_id);
+ let balances = quantus_subxt::api::tx().balances();
+ if keep_alive {
+ Box::new(balances.transfer_keep_alive(dest, amount))
+ } else {
+ Box::new(balances.transfer_allow_death(dest, amount))
+ }
}
pub(crate) fn build_batch_transfer_call(
@@ -470,7 +474,7 @@ pub async fn transfer_with_nonce(
log_verbose!("✍️ Creating balance transfer extrinsic...");
- let transfer_call = build_transfer_call_for_account_id(to_account_id, amount);
+ let transfer_call = build_transfer_call_for_account_id(to_account_id, amount, false);
let submit_tip = positive_tip_amount(tip);
// Submit the transaction with optional manual nonce
@@ -593,6 +597,7 @@ pub async fn handle_send_command(
password_file: Option,
tip: Option,
nonce: Option,
+ keep_alive: bool,
execution_mode: crate::cli::common::ExecutionMode,
) -> Result<()> {
// Create quantus chain client
@@ -636,7 +641,7 @@ 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);
+ let transfer_call = build_transfer_call_for_account_id(to_account_id, amount, keep_alive);
ensure_balance_covers_call(
&quantus_client,
&signer,
@@ -769,8 +774,9 @@ pub async fn get_batch_limits(quantus_client: &QuantusClient) -> Result<(u32, u3
#[cfg(test)]
mod tests {
use super::{
- build_batch_transfer_call, effective_tip_amount, format_balance,
- limits_from_batched_calls_limit, parse_amount_with_decimals,
+ 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,
};
use subxt::tx::Payload;
@@ -788,6 +794,24 @@ mod tests {
);
}
+ /// `send` keeps `transfer_allow_death` (call index 0) as its default; `--keep-alive` changes
+ /// the call index alone, to `transfer_keep_alive` (3).
+ #[test]
+ fn send_transfers_allow_death_unless_keep_alive() {
+ let metadata = crate::chain::test_metadata();
+ let dest = SubxtAccountId32::from([7u8; 32]);
+ let allow_death = build_transfer_call_for_account_id(dest.clone(), 1, false)
+ .encode_call_data(&metadata)
+ .unwrap();
+ let keep_alive = build_transfer_call_for_account_id(dest, 1, true)
+ .encode_call_data(&metadata)
+ .unwrap();
+ let balances = metadata.pallet_by_name("Balances").unwrap().index();
+ assert_eq!(allow_death[..2], [balances, 0], "default is transfer_allow_death");
+ assert_eq!(keep_alive[..2], [balances, 3], "--keep-alive is transfer_keep_alive");
+ assert_eq!(allow_death[2..], keep_alive[2..], "only the call index differs");
+ }
+
#[test]
fn parses_exact_decimal_amounts() {
assert_eq!(parse_amount_with_decimals("0.1", 12).unwrap(), 100_000_000_000);