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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -766,6 +766,9 @@ quantus send --from crystal_alice --to <address> --amount 10 --tip 0.1

# With manual nonce
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
```

Transaction status terms:
Expand Down Expand Up @@ -1146,6 +1149,9 @@ quantus multisig propose transfer \
--expiry <future_block_number> \
--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
Expand Down
6 changes: 6 additions & 0 deletions src/chain/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
<subxt::Metadata as codec::Decode>::decode(&mut &bytes[..]).expect("valid checked-in metadata")
}
49 changes: 40 additions & 9 deletions src/chain/signing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,15 +28,27 @@ 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.
pub fn $sign(pair: &$pair, message: &[u8], context: Option<&[u8]>) -> $sig_with_public {
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())
Expand Down Expand Up @@ -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(),
<Dilithium87Pair as Pair>::sign(&alice, b"payload").to_bytes()
);
let signature = sign_ml_dsa_87(&alice, b"payload", None);
assert!(<Dilithium87Pair as Pair>::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]
Expand Down
2 changes: 1 addition & 1 deletion src/cli/airdrop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
5 changes: 1 addition & 4 deletions src/cli/cold_signing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -711,16 +711,13 @@ mod tests {
use subxt::{client::ClientState, config::substrate::H256, utils::MultiAddress, OfflineClient};

fn test_client_state() -> ClientState<ChainConfig> {
let metadata_bytes: &[u8] = include_bytes!("../quantus_metadata.scale");
let metadata = <subxt::Metadata as codec::Decode>::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(),
}
}

Expand Down
8 changes: 7 additions & 1 deletion src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ pub enum Commands {
/// Manual nonce override (use with caution - must be exact next nonce for account)
#[arg(long)]
nonce: Option<u32>,

/// 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
Expand Down Expand Up @@ -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,
Expand All @@ -405,6 +410,7 @@ pub async fn execute_command(
password_file,
tip,
nonce,
keep_alive,
execution_mode,
)
.await,
Expand Down
44 changes: 26 additions & 18 deletions src/cli/multisig.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,11 @@ pub enum ProposeSubcommand {
/// Read password from file
#[arg(long)]
password_file: Option<String>,

/// 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)
Expand Down Expand Up @@ -973,6 +978,7 @@ pub async fn handle_multisig_command(
from,
password,
password_file,
keep_alive,
} =>
handle_propose_transfer(
address,
Expand All @@ -982,6 +988,7 @@ pub async fn handle_multisig_command(
from,
password,
password_file,
keep_alive,
node_url,
execution_mode,
)
Expand Down Expand Up @@ -1359,13 +1366,15 @@ async fn handle_propose_transfer(
from: String,
password: Option<String>,
password_file: Option<String>,
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)?;
Expand Down Expand Up @@ -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| {
Expand All @@ -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()),
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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");
Expand Down
7 changes: 1 addition & 6 deletions src/cli/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
44 changes: 34 additions & 10 deletions src/cli/send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,14 +246,18 @@ 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(
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<dyn subxt::tx::Payload + Send + Sync> {
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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -593,6 +597,7 @@ pub async fn handle_send_command(
password_file: Option<String>,
tip: Option<String>,
nonce: Option<u32>,
keep_alive: bool,
execution_mode: crate::cli::common::ExecutionMode,
) -> Result<()> {
// Create quantus chain client
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;

Expand All @@ -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);
Expand Down
Loading