quantus-cli 2.3.0

Command line interface and library for interacting with the Quantus Network
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
//! Cold-wallet (air-gapped) transaction signing over QR codes.
//!
//! Wire protocol (shared with the Quantus mobile app, the Quantus cold wallet
//! app and the Keystone firmware):
//!
//! 1. The CLI displays the **raw, unhashed** Substrate V4 signing payload as a
//!    `ur:quantus-sign-request` QR (animated when multi-part): `call ‖ era ‖ nonce ‖ tip ‖ mode ‖
//!    specVersion ‖ txVersion ‖ genesis ‖ blockHash ‖ Option<metadataHash>`. Raw (not hashed) so
//!    the device can parse and show the user what they are signing.
//! 2. The cold wallet signs `payload.len() > 256 ? blake2b_256(payload) : payload` with ML-DSA-87
//!    and answers with an animated UR containing `signature[4627] ‖ public_key[2592]`.
//! 3. The CLI validates the response against the wallet's stored address, assembles the V4
//!    extrinsic with the *identical* parameters, and submits.
//!
//! Invariant: nonce and block context are captured **once** into [`TxContext`];
//! the QR payload and the final extrinsic are both built from it. Refetching
//! anything in between would silently invalidate the signature.

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},
};

/// Transactions stay valid this many blocks after the anchor block. Matches the
/// hot path's `.mortal(256)` in `common::submit_transaction`.
pub const MORTALITY_BLOCKS: u64 = 256;

/// Cold-wallet parsers reject payloads above this size.
pub const MAX_COLD_PAYLOAD: usize = 8 * 1024;

/// `ML-DSA-87 signature (4627) ‖ public key (2592)` — the only valid response size.
pub const SIGNATURE_RESPONSE_LEN: usize = Dilithium87SignatureWithPublic::TOTAL_LEN;

/// How long to wait for the signed response before giving up.
const RESPONSE_TIMEOUT: Duration = Duration::from_secs(300);

/// Chain context captured once per signing session. Both the QR payload and
/// the submitted extrinsic derive from this exact snapshot.
pub struct TxContext {
	pub nonce: u64,
	pub block_number: u64,
	pub block_hash: subxt::utils::H256,
	pub tip: u128,
}

/// Where the sign-request goes out and the signature response comes in.
#[derive(Default)]
pub struct ColdIo {
	/// Also write the request UR parts to this file (one per line). Hidden
	/// flag, used for scripting/testing.
	pub request_out: Option<PathBuf>,
	/// Read the response from a file or stdin instead of the camera.
	pub response_in: Option<UrSource>,
	/// Camera device index when scanning with the camera.
	pub camera_index: u32,
}

static COLD_IO: std::sync::OnceLock<ColdIo> = std::sync::OnceLock::new();

impl ColdIo {
	/// Build from the global CLI flags. `"-"` for `response_in` means stdin.
	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,
		}
	}

	/// Install the process-wide I/O config. Called once from `main` with the
	/// global CLI flags so the submit stage can reach it without threading it
	/// through every command.
	pub fn install(self) {
		let _ = COLD_IO.set(self);
	}

	/// The installed config, or the default (camera 0, no file overrides) for
	/// library and test users that never call `install`.
	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
	}
}

/// Warn when global cold-signing flags were passed with a hot `--from`.
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(())
}

/// Why a signature response was rejected.
enum ResponseError {
	/// Wrong size — almost always an incomplete or corrupted scan. Rescanning
	/// is safe because the payload hasn't changed.
	BadLength(usize),
	/// Structurally invalid signature/public key bytes; rescan-safe.
	Malformed(String),
	/// Signed by a different key than the cold wallet's address. Not retryable.
	WrongSigner { got: String },
	/// Correct key, invalid signature over this payload. Not retryable.
	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(),
		}
	}
}

/// Capture nonce + latest (best) block once. `nonce_override` honors `--nonce`.
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 })
}

/// Extrinsic params for this context. Deterministic — called once for the raw
/// payload and once for the submitted extrinsic, and both must encode
/// identically.
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()
}

/// Build the raw, unhashed V4 signer payload that goes into the QR.
///
/// Built by hand because subxt's `PartialTransaction::signer_payload()`
/// blake2-hashes payloads over 256 bytes (and its raw-bytes path is private),
/// while the cold wallet needs the raw bytes to parse and display the call.
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); // era ‖ nonce ‖ tip ‖ mode
	encoder.encode_implicit_to(&mut raw); // specV ‖ txV ‖ genesis ‖ blockHash ‖ metadataHash

	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)
}

/// The bytes the cold wallet actually signs (and the chain verifies):
/// payloads over 256 bytes are Blake2b-256 hashed first.
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()
	}
}

/// Split and verify a signature response. Checks length, structure, that the
/// embedded public key matches the cold wallet's address (poseidon binding),
/// and that the signature verifies over this exact payload.
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(())
}

/// Run the full cold signing flow for `call` and submit the result.
///
/// Generic over the call payload; the shared submit stage in `cli::common`
/// routes every extrinsic here when the signer is a cold (watch-only) wallet.
#[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;

	// 1. Capture chain context ONCE; everything below derives from it.
	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));

	// Fee preview: the fee depends on tx length and weight, not signature
	// content, and Dilithium signatures have a fixed length — so a zeroed
	// signature estimates accurately. Best-effort only.
	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),
		}
	}

	// 2. Address the payload to the account that must sign it, encode as UR, and
	// hand it to the cold wallet. Without the envelope a signer holding several
	// accounts cannot tell which key this wants, and one holding none of them
	// cannot tell that it holds the wrong key.
	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:?}")))?;

	// A response file existing before this request is handed out is necessarily
	// from an earlier session (the response depends on this request); together
	// with consume-on-read in the scanner this keeps every roundtrip fresh.
	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());
	}

	// Skip the QR display and prompts when scripted (stdin is not a terminal);
	// the request file above is the handoff instead.
	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…"
		);
	}

	// 3. Collect and validate the signature response. Fall back to the camera
	// only when no explicit source was given.
	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());

	// 4. Assemble the extrinsic from the SAME context and submit.
	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:?}"))
		})?;

	// Cross-check: subxt's signer payload for the partial tx must equal the
	// (hashed-if-long) form of the QR payload, or the two constructions drifted.
	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
}

/// Estimate the fee by assembling a throwaway extrinsic with an all-zero
/// (correct-length) signature. Returns `None` on any failure — the preview is
/// informational and must never block signing.
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()
}

/// Fee estimate for a cold wallet when no [`TxContext`] exists yet (balance
/// preflight before signing). Same dummy-signature trick; the fee depends on
/// length and weight, not on nonce or signature content.
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(),
		))
	}
}

/// `quantus developer cold-sign-sim` — act as the cold wallet side using a
/// local hot wallet. Reads a sign-request UR (file or stdin), signs it per the
/// protocol, and writes the response UR (file or stdout).
///
/// Unlike real devices, the simulator does not enforce a genesis-hash
/// allowlist, which is what makes end-to-end testing on a dev node possible.
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<()> {
	// 1. Read the request UR (polling the file allows scripted pipelines).
	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));

	// 2. Sign exactly like the cold wallet app / Keystone firmware, which
	// includes refusing a request addressed to an account they do not hold.
	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);
	// Mirrors the device: Keystone firmware always signs under the extrinsic context, so a
	// simulated signature is only good for a runtime that verifies with it.
	let sig_with_public =
		crate::chain::signing::sign_ml_dsa_87(&pair, &msg, Some(crate::chain::signing::EXTRINSIC));

	// 3. Emit the response UR.
	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) => {
			// Atomic write: pollers must never observe a half-written response.
			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::*;

	/// The context a spec-148 runtime verifies under; what the device signs with.
	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)
	}

	/// The hardware flow signs whatever `build_raw_signer_payload` produces, so the
	/// executed call has to be inside it for the device to display it.
	#[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");

		// The inner call follows inline — no compact length prefix, unlike approve.
		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");
	}

	/// The raw payload must follow the exact field layout the cold-wallet
	/// parsers expect (see quantus_payload_parser.dart / Keystone parser.rs).
	#[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();

		// Call: pallet 2 (Balances), call 0 (transfer_allow_death),
		// MultiAddress::Id tag 0, 32-byte dest, compact amount
		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");
		// Compact(1_000_000_000_000) = 0x07 0x0010a5d4e8
		let mut expected_tail = vec![0x07u8, 0x00, 0x10, 0xa5, 0xd4, 0xe8];
		// Era: mortal, period 256 → two bytes (low nibble 0b0101 = 5 for 2^(5+4)=... )
		// Era::mortal(256, 1000): period=256 → encoded via Substrate era encoding.
		let era = sp_runtime::generic::Era::mortal(256, 1000);
		expected_tail.extend(codec::Encode::encode(&era));
		// Compact nonce 7, compact tip 0, metadata mode 0
		expected_tail.extend(codec::Encode::encode(&codec::Compact(7u64)));
		expected_tail.extend(codec::Encode::encode(&codec::Compact(0u128)));
		expected_tail.push(0u8); // CheckMetadataHash mode = disabled

		// Implicit: specVersion, txVersion (LE u32), genesis, era anchor block hash,
		// metadataHash Option = None
		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); // metadataHash: None

		assert_eq!(
			&raw[35..],
			&expected_tail[..],
			"payload tail (extensions) must match the cold-wallet parser layout"
		);
	}

	/// Pin our manual raw-payload builder to subxt's canonical signer payload:
	/// identical below 256 bytes, blake2b-256 of ours above.
	#[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(),
		);

		// Small payload: subxt's signer payload IS the raw payload
		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);

		// Large payload (remark > 256 bytes): subxt hashes, and so does signable_payload
		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();

		// Simulate the cold wallet: sign the signable form with alice's key
		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);

		// Valid response verifies
		let validated = validate_signature_response(&raw, &response, &alice_account(), CTX)
			.ok()
			.unwrap();
		assert_eq!(validated.to_bytes(), response);

		// Truncated response → BadLength (rescan-safe)
		let err = validate_signature_response(&raw, &response[..1000], &alice_account(), CTX)
			.err()
			.unwrap();
		assert!(err.rescan_safe());
		assert!(matches!(err, ResponseError::BadLength(1000)));

		// Signed by a different key → WrongSigner (abort)
		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 { .. }));

		// Right key, tampered payload → BadSignature (abort)
		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));
	}

	/// The full simulator loop: request UR → sign → response UR → validate →
	/// assemble a submittable extrinsic offline.
	#[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();

		// CLI → UR → cold wallet, through the envelope that names the signer, so
		// this asserts the wire format the wallets actually read.
		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);

		// Cold wallet signs and answers over UR
		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");

		// CLI decodes, validates, assembles
		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);

		// V4 signed extrinsic: version byte 0x84 after the compact length prefix
		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());
	}
}