use crate::{
chain::client::{ChainConfig, QuantusClient},
error::{QuantusError, Result},
log_print, log_verbose,
qr::{display_ur_until_enter, render_ur_frames, scan_ur, SignRequest, UrSource},
};
use colored::Colorize;
use qp_dilithium_crypto::types::{Dilithium87SignatureWithPublic, DilithiumSignatureScheme};
use sp_core::crypto::AccountId32;
use sp_runtime::traits::IdentifyAccount;
use std::{io::IsTerminal, path::PathBuf, time::Duration};
use subxt::{
client::OfflineClientT,
config::{DefaultExtrinsicParamsBuilder, ExtrinsicParams, ExtrinsicParamsEncoder},
};
pub const MORTALITY_BLOCKS: u64 = 256;
pub const MAX_COLD_PAYLOAD: usize = 8 * 1024;
pub const SIGNATURE_RESPONSE_LEN: usize = Dilithium87SignatureWithPublic::TOTAL_LEN;
const RESPONSE_TIMEOUT: Duration = Duration::from_secs(300);
pub struct TxContext {
pub nonce: u64,
pub block_number: u64,
pub block_hash: subxt::utils::H256,
pub tip: u128,
}
#[derive(Default)]
pub struct ColdIo {
pub request_out: Option<PathBuf>,
pub response_in: Option<UrSource>,
pub camera_index: u32,
}
static COLD_IO: std::sync::OnceLock<ColdIo> = std::sync::OnceLock::new();
impl ColdIo {
pub fn from_flags(
request_out: Option<String>,
response_in: Option<String>,
camera_index: u32,
) -> Self {
Self {
request_out: request_out.map(PathBuf::from),
response_in: response_in.map(|s| {
if s == "-" {
UrSource::StdinLines
} else {
UrSource::File(PathBuf::from(s))
}
}),
camera_index,
}
}
pub fn install(self) {
let _ = COLD_IO.set(self);
}
pub fn global() -> &'static ColdIo {
COLD_IO.get_or_init(ColdIo::default)
}
fn has_overrides(&self) -> bool {
self.request_out.is_some() || self.response_in.is_some() || self.camera_index != 0
}
}
pub fn warn_if_cold_flags_unused() {
if ColdIo::global().has_overrides() {
log_print!(
"⚠️ --cold-request-out / --cold-response-in / --camera-index are ignored for hot wallets"
);
}
}
fn require_interactive_or_request_out(interactive: bool, io: &ColdIo) -> Result<()> {
if !interactive && io.request_out.is_none() {
return Err(QuantusError::Generic(
"Non-interactive cold signing requires --cold-request-out so the sign request can be handed to the device".to_string(),
));
}
Ok(())
}
enum ResponseError {
BadLength(usize),
Malformed(String),
WrongSigner { got: String },
BadSignature,
}
impl ResponseError {
fn rescan_safe(&self) -> bool {
matches!(self, ResponseError::BadLength(_) | ResponseError::Malformed(_))
}
fn message(&self, wallet_name: &str) -> String {
match self {
ResponseError::BadLength(got) => format!(
"Response has {got} bytes, expected {SIGNATURE_RESPONSE_LEN} (signature ‖ public key). The scan was likely incomplete or picked up the wrong QR — rescan the response on the cold wallet."
),
ResponseError::Malformed(e) => format!(
"Response bytes do not parse as an ML-DSA-87 signature + public key ({e}) — rescan the response."
),
ResponseError::WrongSigner { got } => format!(
"Response was signed by a DIFFERENT key ({got}) than cold wallet '{wallet_name}'. Aborting — check that the right device/account signed."
),
ResponseError::BadSignature =>
"Signature does not verify over this transaction. Aborting — the cold wallet may have signed a stale QR; run the command again to generate a fresh one."
.to_string(),
}
}
}
pub async fn capture_tx_context(
client: &QuantusClient,
signer: &AccountId32,
tip: u128,
nonce_override: Option<u32>,
) -> Result<TxContext> {
let nonce = match nonce_override {
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 = client.client().blocks().at(block_hash).await?;
let block_number = block.header().number as u64;
Ok(TxContext { nonce, block_number, block_hash, tip })
}
fn build_params(
ctx: &TxContext,
) -> <<ChainConfig as subxt::Config>::ExtrinsicParams as ExtrinsicParams<ChainConfig>>::Params {
DefaultExtrinsicParamsBuilder::<ChainConfig>::new()
.nonce(ctx.nonce)
.tip(ctx.tip)
.mortal_from_unchecked(MORTALITY_BLOCKS, ctx.block_number, ctx.block_hash)
.build()
}
pub fn build_raw_signer_payload<Call: subxt::tx::Payload>(
state: &subxt::client::ClientState<ChainConfig>,
call: &Call,
ctx: &TxContext,
) -> Result<Vec<u8>> {
let mut raw = call
.encode_call_data(&state.metadata)
.map_err(|e| QuantusError::Generic(format!("Failed to encode call data: {e:?}")))?;
let encoder = <<ChainConfig as subxt::Config>::ExtrinsicParams as ExtrinsicParams<
ChainConfig,
>>::new(state, build_params(ctx))
.map_err(|e| QuantusError::Generic(format!("Failed to build extrinsic params: {e:?}")))?;
encoder.encode_signer_payload_value_to(&mut raw); encoder.encode_implicit_to(&mut raw);
if raw.len() > MAX_COLD_PAYLOAD {
return Err(QuantusError::Generic(format!(
"Signing payload is {} bytes; cold wallets reject payloads over {} bytes",
raw.len(),
MAX_COLD_PAYLOAD
)));
}
Ok(raw)
}
pub fn signable_payload(raw: &[u8]) -> Vec<u8> {
if raw.len() > 256 {
<sp_runtime::traits::BlakeTwo256 as sp_runtime::traits::Hash>::hash(raw)
.as_ref()
.to_vec()
} else {
raw.to_vec()
}
}
fn validate_signature_response(
raw_payload: &[u8],
response: &[u8],
expected_account: &AccountId32,
context: Option<&[u8]>,
) -> std::result::Result<Dilithium87SignatureWithPublic, ResponseError> {
if response.len() != SIGNATURE_RESPONSE_LEN {
return Err(ResponseError::BadLength(response.len()));
}
let sig_with_public = Dilithium87SignatureWithPublic::from_bytes(response)
.map_err(|e| ResponseError::Malformed(format!("{e:?}")))?;
let derived_account = sig_with_public.public().into_account();
if derived_account != *expected_account {
use crate::cli::address_format::QuantusSS58;
return Err(ResponseError::WrongSigner { got: derived_account.to_quantus_ss58() });
}
let msg = signable_payload(raw_payload);
if !crate::chain::signing::verify_ml_dsa_87(&sig_with_public, &msg, context) {
return Err(ResponseError::BadSignature);
}
Ok(sig_with_public)
}
fn confirm_or_abort(prompt: &str) -> Result<()> {
use std::io::Write;
print!("{prompt}");
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if input.trim().eq_ignore_ascii_case("q") {
return Err(QuantusError::Generic("Aborted by user".to_string()));
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn sign_and_submit_cold<Call: subxt::tx::Payload>(
client: &QuantusClient,
wallet_name: &str,
cold_address_ss58: &str,
call: &Call,
tip: Option<u128>,
nonce_override: Option<u32>,
execution_mode: crate::cli::common::ExecutionMode,
io: &ColdIo,
) -> Result<(subxt::utils::H256, Option<subxt::utils::H256>)> {
use sp_core::crypto::Ss58Codec;
let account = AccountId32::from_ss58check_with_version(cold_address_ss58)
.map_err(|e| {
QuantusError::Generic(format!(
"Cold wallet '{wallet_name}' has an invalid stored address: {e:?}"
))
})?
.0;
let ctx = capture_tx_context(client, &account, tip.unwrap_or(0), nonce_override).await?;
let state = client.client().client_state();
let raw_payload = build_raw_signer_payload(&state, call, &ctx)?;
log_print!("🧊 Cold wallet signing with '{}'", wallet_name.bright_blue().bold());
log_print!(" Signer: {}", cold_address_ss58.bright_cyan());
log_print!(" Nonce: {}", ctx.nonce);
if ctx.tip > 0 {
log_print!(" Tip: {}", ctx.tip);
}
log_print!(" Valid for {} blocks from block #{}", MORTALITY_BLOCKS, ctx.block_number);
log_print!(" Payload: {} bytes", raw_payload.len());
log_verbose!(" Payload hex: 0x{}", hex::encode(&raw_payload));
if let Some(fee) = estimate_fee_with_dummy_signature(client, call, &ctx, &account).await {
match crate::cli::send::format_balance_with_symbol(client, fee).await {
Ok(formatted) => log_print!(" Estimated fee: {}", formatted.bright_cyan()),
Err(_) => log_print!(" Estimated fee: {} (raw units)", fee),
}
}
let request = SignRequest::new(cold_address_ss58, raw_payload.clone());
let parts = quantus_ur::encode_bytes(&request.encode())
.map_err(|e| QuantusError::Generic(format!("Failed to UR-encode payload: {e:?}")))?;
if let Some(UrSource::File(path)) = &io.response_in {
match std::fs::remove_file(path) {
Ok(()) => log_print!("🧹 Removed stale response file {}", path.display()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {},
Err(e) => return Err(e.into()),
}
}
if let Some(path) = &io.request_out {
let content = parts.join("\n") + "\n";
std::fs::write(path, content)?;
log_print!("📤 Sign request written to {}", path.display());
}
let interactive = std::io::stdin().is_terminal();
require_interactive_or_request_out(interactive, io)?;
if interactive {
let frames = render_ur_frames(&parts)?;
log_print!("");
display_ur_until_enter(
&frames,
"📱 Scan this QR with your cold wallet, then press Enter here…",
)
.await?;
confirm_or_abort(
"✍️ Sign the transaction on the cold wallet. Ready to scan its response? [Enter to scan / q to abort]: ",
)?;
} else {
log_print!(
"🤖 Non-interactive session: skipping QR display, waiting for the signature response…"
);
}
let source = match &io.response_in {
Some(source) => source.clone(),
None => default_response_source(io)?,
};
let sig_with_public = loop {
let response = scan_ur(&source, RESPONSE_TIMEOUT).await?;
log_verbose!("📥 Received {} response bytes", response.len());
match validate_signature_response(
&raw_payload,
&response,
&account,
client.signing_context(),
) {
Ok(swp) => break swp,
Err(err) => {
let msg = err.message(wallet_name);
if err.rescan_safe() && interactive {
log_print!("⚠️ {}", msg);
confirm_or_abort("Rescan? [Enter to rescan / q to abort]: ")?;
continue;
}
return Err(QuantusError::Generic(msg));
},
}
};
log_print!("✅ Signature verified against cold wallet '{}'", wallet_name.bright_blue());
let mut partial = client
.client()
.tx()
.create_v4_partial_offline(call, build_params(&ctx))
.map_err(|e| {
QuantusError::Generic(format!("Failed to build partial transaction: {e:?}"))
})?;
if partial.signer_payload() != signable_payload(&raw_payload) {
return Err(QuantusError::Generic(
"Internal error: submitted transaction params differ from the QR payload; nothing was submitted".to_string(),
));
}
let signature = DilithiumSignatureScheme::Dilithium87(sig_with_public);
let submittable = partial.sign_with_account_and_signature(&account, &signature);
crate::cli::common::submit_prepared_transaction(client, submittable, execution_mode).await
}
async fn estimate_fee_with_dummy_signature<Call: subxt::tx::Payload>(
client: &QuantusClient,
call: &Call,
ctx: &TxContext,
account: &AccountId32,
) -> Option<u128> {
let dummy = Dilithium87SignatureWithPublic::from_bytes(&[0u8; SIGNATURE_RESPONSE_LEN]).ok()?;
let mut partial =
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()
}
pub(crate) async fn estimate_cold_fee<Call: subxt::tx::Payload>(
client: &QuantusClient,
call: &Call,
account: &AccountId32,
tip: u128,
) -> Option<u128> {
let ctx = capture_tx_context(client, account, tip, None).await.ok()?;
estimate_fee_with_dummy_signature(client, call, &ctx, account).await
}
fn default_response_source(io: &ColdIo) -> Result<UrSource> {
#[cfg(feature = "camera")]
{
Ok(UrSource::Camera { index: io.camera_index })
}
#[cfg(not(feature = "camera"))]
{
let _ = io;
Err(QuantusError::Generic(
"This build has no camera support (built without the `camera` feature). Pass --cold-response-in <file|-> to read the response from a file or stdin".to_string(),
))
}
}
pub async fn handle_cold_sign_sim(
wallet: String,
request_file: Option<String>,
response_file: Option<String>,
password: Option<String>,
password_file: Option<String>,
) -> Result<()> {
let request_source = match &request_file {
Some(path) => UrSource::File(PathBuf::from(path)),
None => UrSource::StdinLines,
};
let request = SignRequest::decode(&scan_ur(&request_source, Duration::from_secs(60)).await?)?;
let payload = request.payload.clone();
if payload.len() < 2 {
return Err(QuantusError::Generic(format!(
"Request payload is only {} bytes — not a signing payload",
payload.len()
)));
}
log_print!("🧾 Sign request: {} bytes", payload.len());
log_print!(" Signer: {}", request.signer.bright_cyan());
log_print!(" Pallet index: {}, call index: {}", payload[0], payload[1]);
log_verbose!(" Payload hex: 0x{}", hex::encode(&payload));
let keypair = crate::wallet::load_keypair_from_wallet(&wallet, password, password_file)?;
let signing_address = keypair.try_to_account_id_ss58check()?;
if signing_address != request.signer {
return Err(QuantusError::Generic(format!(
"This request is for {}, but wallet '{wallet}' is {signing_address}. Nothing was signed.",
request.signer
)));
}
if keypair.scheme != crate::wallet::DilithiumScheme::MlDsa87 {
return Err(QuantusError::Generic(
"cold-sign-sim and real devices sign ML-DSA-87 only; use an ML-DSA-87 wallet"
.to_string(),
));
}
let pair = keypair.to_resonance_pair()?;
let msg = signable_payload(&payload);
let sig_with_public =
crate::chain::signing::sign_ml_dsa_87(&pair, &msg, Some(crate::chain::signing::EXTRINSIC));
let parts = quantus_ur::encode_bytes(&sig_with_public.to_bytes())
.map_err(|e| QuantusError::Generic(format!("Failed to UR-encode response: {e:?}")))?;
match &response_file {
Some(path) => {
let tmp = format!("{path}.tmp");
std::fs::write(&tmp, parts.join("\n") + "\n")?;
std::fs::rename(&tmp, path)?;
log_print!("📤 Signature response ({} UR parts) written to {}", parts.len(), path);
},
None =>
for part in &parts {
println!("{part}");
},
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const CTX: Option<&[u8]> = Some(crate::chain::signing::EXTRINSIC);
use crate::chain::quantus_subxt::api;
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,
}
}
fn test_ctx() -> TxContext {
TxContext {
nonce: 7,
block_number: 1000,
block_hash: subxt::utils::H256::from([0x11u8; 32]),
tip: 0,
}
}
fn alice_account() -> AccountId32 {
let pair = qp_dilithium_crypto::crystal_alice();
crate::wallet::QuantumKeyPair::from_resonance_pair(&pair)
.try_to_account_id_32()
.expect("valid keypair")
}
fn transfer_call() -> impl subxt::tx::Payload {
let dest: subxt::utils::AccountId32 =
subxt::utils::AccountId32(*b"01234567890123456789012345678901");
api::tx()
.balances()
.transfer_allow_death(MultiAddress::Id(dest), 1_000_000_000_000u128)
}
fn multisig_execute_call(inner_amount: u128) -> impl subxt::tx::Payload {
let dest: subxt::utils::AccountId32 =
subxt::utils::AccountId32(*b"01234567890123456789012345678901");
let inner = api::runtime_types::quantus_runtime::RuntimeCall::Balances(
api::runtime_types::pallet_balances::pallet::Call::transfer_allow_death {
dest: MultiAddress::Id(dest),
value: inner_amount,
},
);
api::tx().multisig().execute(subxt::utils::AccountId32([0x99u8; 32]), 7, inner)
}
#[test]
fn test_cold_payload_carries_the_executed_call() {
let raw = build_raw_signer_payload(
&test_client_state(),
&multisig_execute_call(1_000_000_000_000),
&test_ctx(),
)
.unwrap();
assert_eq!(raw[0], 19, "Multisig pallet index");
assert_eq!(raw[1], 6, "execute call index");
assert_eq!(&raw[2..34], &[0x99u8; 32], "multisig address");
assert_eq!(&raw[34..38], 7u32.to_le_bytes(), "proposal id");
assert_eq!(raw[38], 2, "Balances pallet index");
assert_eq!(raw[39], 0, "transfer_allow_death call index");
assert_eq!(raw[40], 0, "MultiAddress::Id tag");
assert_eq!(&raw[41..73], b"01234567890123456789012345678901");
assert_eq!(&raw[73..79], &[0x07u8, 0x00, 0x10, 0xa5, 0xd4, 0xe8], "compact amount");
}
#[test]
fn test_raw_payload_golden_layout() {
let state = test_client_state();
let ctx = test_ctx();
let raw = build_raw_signer_payload(&state, &transfer_call(), &ctx).unwrap();
assert_eq!(raw[0], 2, "Balances pallet index");
assert_eq!(raw[1], 0, "transfer_allow_death call index");
assert_eq!(raw[2], 0, "MultiAddress::Id tag");
assert_eq!(&raw[3..35], b"01234567890123456789012345678901");
let mut expected_tail = vec![0x07u8, 0x00, 0x10, 0xa5, 0xd4, 0xe8];
let era = sp_runtime::generic::Era::mortal(256, 1000);
expected_tail.extend(codec::Encode::encode(&era));
expected_tail.extend(codec::Encode::encode(&codec::Compact(7u64)));
expected_tail.extend(codec::Encode::encode(&codec::Compact(0u128)));
expected_tail.push(0u8);
expected_tail.extend(136u32.to_le_bytes());
expected_tail.extend(3u32.to_le_bytes());
expected_tail.extend([0x49u8; 32]);
expected_tail.extend([0x11u8; 32]);
expected_tail.push(0u8);
assert_eq!(
&raw[35..],
&expected_tail[..],
"payload tail (extensions) must match the cold-wallet parser layout"
);
}
#[test]
fn test_raw_payload_matches_subxt_signer_payload() {
let state = test_client_state();
let ctx = test_ctx();
let client = OfflineClient::<ChainConfig>::new(
state.genesis_hash,
state.runtime_version,
state.metadata.clone(),
);
let call = transfer_call();
let raw = build_raw_signer_payload(&state, &call, &ctx).unwrap();
assert!(raw.len() <= 256);
let partial = client.tx().create_v4_partial_offline(&call, build_params(&ctx)).unwrap();
assert_eq!(partial.signer_payload(), raw);
assert_eq!(signable_payload(&raw), raw);
let big_call = api::tx().system().remark(vec![0xAB; 400]);
let big_raw = build_raw_signer_payload(&state, &big_call, &ctx).unwrap();
assert!(big_raw.len() > 256);
let big_partial =
client.tx().create_v4_partial_offline(&big_call, build_params(&ctx)).unwrap();
assert_eq!(big_partial.signer_payload(), signable_payload(&big_raw));
assert_ne!(signable_payload(&big_raw), big_raw);
assert_eq!(signable_payload(&big_raw).len(), 32);
}
#[test]
fn test_validate_signature_response_roundtrip() {
let state = test_client_state();
let ctx = test_ctx();
let call = transfer_call();
let raw = build_raw_signer_payload(&state, &call, &ctx).unwrap();
let alice = qp_dilithium_crypto::crystal_alice();
let msg = signable_payload(&raw);
let swp = crate::chain::signing::sign_ml_dsa_87(&alice, &msg, CTX);
let response = swp.to_bytes();
assert_eq!(response.len(), SIGNATURE_RESPONSE_LEN);
let validated = validate_signature_response(&raw, &response, &alice_account(), CTX)
.ok()
.unwrap();
assert_eq!(validated.to_bytes(), response);
let err = validate_signature_response(&raw, &response[..1000], &alice_account(), CTX)
.err()
.unwrap();
assert!(err.rescan_safe());
assert!(matches!(err, ResponseError::BadLength(1000)));
let bob = qp_dilithium_crypto::dilithium_bob();
let bob_swp = crate::chain::signing::sign_ml_dsa_87(&bob, &msg, CTX);
let err = validate_signature_response(&raw, &bob_swp.to_bytes(), &alice_account(), CTX)
.err()
.unwrap();
assert!(!err.rescan_safe());
assert!(matches!(err, ResponseError::WrongSigner { .. }));
let mut other_ctx = test_ctx();
other_ctx.nonce = 8;
let other_raw = build_raw_signer_payload(&state, &call, &other_ctx).unwrap();
let err = validate_signature_response(&other_raw, &response, &alice_account(), CTX)
.err()
.unwrap();
assert!(!err.rescan_safe());
assert!(matches!(err, ResponseError::BadSignature));
}
#[test]
fn test_end_to_end_offline_sign_and_assemble() {
let state = test_client_state();
let ctx = test_ctx();
let call = transfer_call();
let raw = build_raw_signer_payload(&state, &call, &ctx).unwrap();
let alice_address = {
use sp_core::crypto::Ss58Codec;
alice_account().to_ss58check()
};
let request = SignRequest::new(alice_address.clone(), raw.clone());
let request_parts = quantus_ur::encode_bytes(&request.encode()).unwrap();
let received_request =
SignRequest::decode(&quantus_ur::decode_bytes(&request_parts).unwrap()).unwrap();
assert_eq!(received_request.signer, alice_address, "the request must name its signer");
let received = received_request.payload;
assert_eq!(received, raw);
let alice = qp_dilithium_crypto::crystal_alice();
let swp = crate::chain::signing::sign_ml_dsa_87(&alice, &signable_payload(&received), CTX);
let response_parts = quantus_ur::encode_bytes(&swp.to_bytes()).unwrap();
assert!(response_parts.len() > 1, "7219-byte response must be multi-part");
let response = quantus_ur::decode_bytes(&response_parts).unwrap();
let validated = validate_signature_response(&raw, &response, &alice_account(), CTX)
.ok()
.unwrap();
let client = OfflineClient::<ChainConfig>::new(
state.genesis_hash,
state.runtime_version,
state.metadata.clone(),
);
let mut partial = client.tx().create_v4_partial_offline(&call, build_params(&ctx)).unwrap();
assert_eq!(partial.signer_payload(), signable_payload(&raw));
let signature = DilithiumSignatureScheme::Dilithium87(validated);
let submittable = partial.sign_with_account_and_signature(&alice_account(), &signature);
let encoded = submittable.encoded().to_vec();
assert!(!encoded.is_empty());
use codec::Decode;
let mut cursor = &encoded[..];
let _len = codec::Compact::<u32>::decode(&mut cursor).unwrap();
assert_eq!(cursor[0], 0b1000_0000 | 4, "signed V4 extrinsic marker");
}
#[test]
fn non_interactive_cold_signing_requires_request_out() {
let io = ColdIo::default();
assert!(require_interactive_or_request_out(false, &io).is_err());
assert!(require_interactive_or_request_out(true, &io).is_ok());
let with_out =
ColdIo { request_out: Some(PathBuf::from("/tmp/request.ur")), ..ColdIo::default() };
assert!(require_interactive_or_request_out(false, &with_out).is_ok());
}
}