Skip to main content

quantus_cli/cli/
wormhole.rs

1use crate::{
2	chain::{
3		client::{ChainConfig, QuantusClient},
4		quantus_subxt::{self as quantus_node, api::wormhole},
5	},
6	cli::{
7		common::{submit_transaction, ExecutionMode},
8		send::get_balance,
9	},
10	log_error, log_print, log_success, log_verbose,
11	wallet::{password, QuantumKeyPair, WalletManager},
12};
13use clap::Subcommand;
14use indicatif::{ProgressBar, ProgressStyle};
15use plonky2::plonk::proof::ProofWithPublicInputs;
16use qp_rusty_crystals_hdwallet::{
17	derive_wormhole_from_mnemonic, generate_mnemonic, SensitiveBytes32, WormholePair,
18	QUANTUS_WORMHOLE_CHAIN_ID,
19};
20use qp_wormhole_aggregator::{
21	aggregator::{AggregationBackend, CircuitType},
22	config::CircuitBinsConfig,
23};
24use qp_wormhole_circuit::{
25	inputs::{CircuitInputs, ParseAggregatedPublicInputs, PrivateCircuitInputs},
26	nullifier::Nullifier,
27};
28use qp_wormhole_inputs::{AggregatedPublicCircuitInputs, PublicCircuitInputs};
29use qp_wormhole_prover::WormholeProver;
30use qp_zk_circuits_common::{
31	circuit::{C, D, F},
32	storage_proof::prepare_proof_for_circuit,
33	utils::{digest_to_bytes, BytesDigest},
34};
35use rand::RngCore;
36use sp_core::crypto::{AccountId32, Ss58Codec};
37use std::path::Path;
38use subxt::{
39	backend::legacy::rpc_methods::ReadProof,
40	blocks::Block,
41	ext::{
42		codec::Encode,
43		jsonrpsee::{core::client::ClientT, rpc_params},
44	},
45	utils::{to_hex, AccountId32 as SubxtAccountId},
46	OnlineClient,
47};
48
49// Re-export constants and functions from wormhole_lib module for backward compatibility
50use crate::wormhole_lib;
51pub use crate::wormhole_lib::{
52	compute_output_amount, NATIVE_ASSET_ID, SCALE_DOWN_FACTOR, VOLUME_FEE_BPS,
53};
54
55/// Parse a hex-encoded secret string into a 32-byte array
56pub fn parse_secret_hex(secret_hex: &str) -> Result<[u8; 32], String> {
57	let secret_bytes = hex::decode(secret_hex.trim_start_matches("0x"))
58		.map_err(|e| format!("Invalid secret hex: {}", e))?;
59
60	if secret_bytes.len() != 32 {
61		return Err(format!("Secret must be exactly 32 bytes, got {} bytes", secret_bytes.len()));
62	}
63
64	secret_bytes
65		.try_into()
66		.map_err(|_| "Failed to convert secret to 32-byte array".to_string())
67}
68
69/// Parse an exit account from either hex or SS58 format
70pub fn parse_exit_account(exit_account_str: &str) -> Result<[u8; 32], String> {
71	if let Some(hex_str) = exit_account_str.strip_prefix("0x") {
72		let bytes = hex::decode(hex_str).map_err(|e| format!("Invalid exit account hex: {}", e))?;
73
74		if bytes.len() != 32 {
75			return Err(format!("Exit account must be 32 bytes, got {} bytes", bytes.len()));
76		}
77
78		bytes.try_into().map_err(|_| "Failed to convert exit account".to_string())
79	} else {
80		// Try to parse as SS58
81		let account_id = AccountId32::from_ss58check(exit_account_str)
82			.map_err(|e| format!("Invalid SS58 address: {}", e))?;
83
84		Ok(account_id.into())
85	}
86}
87
88/// Quantize a funding amount from 12 decimal places to 2 decimal places
89/// Returns an error if the quantized value doesn't fit in u32
90pub fn quantize_funding_amount(amount: u128) -> Result<u32, String> {
91	wormhole_lib::quantize_amount(amount).map_err(|e| e.message)
92}
93
94/// Read and decode a hex-encoded proof file
95pub fn read_proof_file(path: &str) -> Result<Vec<u8>, String> {
96	let proof_hex =
97		std::fs::read_to_string(path).map_err(|e| format!("Failed to read proof file: {}", e))?;
98
99	hex::decode(proof_hex.trim()).map_err(|e| format!("Failed to decode proof hex: {}", e))
100}
101
102/// Write a proof to a hex-encoded file
103pub fn write_proof_file(path: &str, proof_bytes: &[u8]) -> Result<(), String> {
104	let proof_hex = hex::encode(proof_bytes);
105	std::fs::write(path, proof_hex).map_err(|e| format!("Failed to write proof file: {}", e))
106}
107
108/// Format a balance amount from raw units (12 decimals) to human-readable format
109pub fn format_balance(amount: u128) -> String {
110	let whole = amount / 1_000_000_000_000;
111	let frac = (amount % 1_000_000_000_000) / 10_000_000_000; // 2 decimal places
112	format!("{}.{:02} DEV", whole, frac)
113}
114
115/// Randomly partition a total amount into n parts.
116/// Each part will be at least `min_per_part` and the sum equals `total`.
117/// Returns amounts aligned to SCALE_DOWN_FACTOR for clean quantization.
118pub fn random_partition(total: u128, n: usize, min_per_part: u128) -> Vec<u128> {
119	use rand::Rng;
120
121	if n == 0 {
122		return vec![];
123	}
124	if n == 1 {
125		return vec![total];
126	}
127
128	// Ensure minimum is achievable
129	let min_total = min_per_part * n as u128;
130	if total < min_total {
131		// Fall back to equal distribution if total is too small
132		let per_part = total / n as u128;
133		let remainder = total % n as u128;
134		let mut parts: Vec<u128> = vec![per_part; n];
135		// Add remainder to last part
136		parts[n - 1] += remainder;
137		return parts;
138	}
139
140	// Amount available for random distribution after ensuring minimums
141	let distributable = total - min_total;
142
143	// Generate n-1 random cut points in [0, distributable]
144	let mut rng = rand::rng();
145	let mut cuts: Vec<u128> = (0..n - 1).map(|_| rng.random_range(0..=distributable)).collect();
146	cuts.sort();
147
148	// Convert cuts to amounts
149	let mut parts = Vec::with_capacity(n);
150	let mut prev = 0u128;
151	for cut in cuts {
152		parts.push(min_per_part + (cut - prev));
153		prev = cut;
154	}
155	parts.push(min_per_part + (distributable - prev));
156
157	// Note: Input 'total' is already in quantized units (e.g., 998 = 9.98 DEV).
158	// No further alignment is needed - just ensure the sum equals total.
159	let sum: u128 = parts.iter().sum();
160	let diff = total as i128 - sum as i128;
161	if diff != 0 {
162		// Add/subtract difference from a random part
163		let idx = rng.random_range(0..n);
164		parts[idx] = (parts[idx] as i128 + diff).max(0) as u128;
165	}
166
167	parts
168}
169
170/// Output assignment for a single proof (supports dual outputs)
171#[derive(Debug, Clone)]
172pub struct ProofOutputAssignment {
173	/// Amount for output 1 (quantized, 2 decimal places)
174	pub output_amount_1: u32,
175	/// Exit account for output 1
176	pub exit_account_1: [u8; 32],
177	/// Amount for output 2 (quantized, 0 if unused)
178	pub output_amount_2: u32,
179	/// Exit account for output 2 (all zeros if unused)
180	pub exit_account_2: [u8; 32],
181}
182
183/// Compute random output assignments for a set of proofs.
184///
185/// This takes the input amounts for each proof and randomly distributes the outputs
186/// across the target exit accounts. Each proof can have up to 2 outputs.
187///
188/// # Algorithm:
189/// 1. Compute total output amount (sum of inputs after fee deduction)
190/// 2. Randomly partition total output across all target addresses
191/// 3. Greedily assign outputs to proofs, using dual outputs when necessary
192///
193/// # Arguments
194/// * `input_amounts` - The input amount for each proof (in planck, before fee)
195/// * `target_accounts` - The exit accounts to distribute outputs to
196/// * `fee_bps` - Fee in basis points
197///
198/// # Returns
199/// A vector of output assignments, one per proof
200pub fn compute_random_output_assignments(
201	input_amounts: &[u128],
202	target_accounts: &[[u8; 32]],
203	fee_bps: u32,
204) -> Vec<ProofOutputAssignment> {
205	use rand::seq::SliceRandom;
206
207	let num_proofs = input_amounts.len();
208	let num_targets = target_accounts.len();
209
210	if num_proofs == 0 || num_targets == 0 {
211		return vec![];
212	}
213
214	// Step 1: Compute output amounts per proof (after fee deduction)
215	let proof_outputs: Vec<u32> = input_amounts
216		.iter()
217		.map(|&input| {
218			let input_quantized = quantize_funding_amount(input).unwrap_or(0);
219			compute_output_amount(input_quantized, fee_bps)
220		})
221		.collect();
222
223	let total_output: u64 = proof_outputs.iter().map(|&x| x as u64).sum();
224
225	// Step 2: Randomly partition total output across target accounts
226	// Minimum 3 quantized units (0.03 DEV) per target. After fee deduction in the
227	// next round: compute_output_amount(3, 10) = 3 * 9990 / 10000 = 2, which is safe.
228	// With 2: compute_output_amount(2, 10) = 1, borderline.
229	// With 1: compute_output_amount(1, 10) = 0, causes circuit failure.
230	let min_per_target = 3u128;
231	let target_amounts_u128 = random_partition(total_output as u128, num_targets, min_per_target);
232	let target_amounts: Vec<u32> = target_amounts_u128.iter().map(|&x| x as u32).collect();
233
234	// Step 3: Assign outputs to proofs.
235	// Each proof can have at most 2 outputs to different targets.
236	//
237	// Strategy:
238	//   Pass 1 - Guarantee every target gets at least one output slot by round-robin
239	//            assigning each target as output_1 of successive proofs.
240	//   Pass 2 - Fill remaining capacity (output_2 slots and any leftover amounts)
241	//            greedily from targets that still have remaining allocation.
242	//
243	// This ensures every target address appears in at least one proof output,
244	// which is critical for the multiround flow where each target is a next-round
245	// wormhole address that must receive minted tokens.
246
247	let mut rng = rand::rng();
248
249	// Track remaining needs per target
250	let mut target_remaining: Vec<u32> = target_amounts.clone();
251
252	// Pre-allocate assignments with output_1 = full proof output, output_2 = 0
253	let mut assignments: Vec<ProofOutputAssignment> = proof_outputs
254		.iter()
255		.map(|&po| ProofOutputAssignment {
256			output_amount_1: po,
257			exit_account_1: [0u8; 32],
258			output_amount_2: 0,
259			exit_account_2: [0u8; 32],
260		})
261		.collect();
262
263	// Pass 1: Round-robin assign each target to a proof's output_1.
264	// If num_targets <= num_proofs, each target gets its own proof.
265	// If num_targets > num_proofs, later targets share proofs via output_2.
266	let mut shuffled_targets: Vec<usize> = (0..num_targets).collect();
267	shuffled_targets.shuffle(&mut rng);
268
269	for (assign_idx, &tidx) in shuffled_targets.iter().enumerate() {
270		let proof_idx = assign_idx % num_proofs;
271		let assignment = &mut assignments[proof_idx];
272
273		if assignment.exit_account_1 == [0u8; 32] {
274			// First target for this proof -> use output_1
275			let assign = assignment.output_amount_1.min(target_remaining[tidx]);
276			assignment.exit_account_1 = target_accounts[tidx];
277			// We'll fix up the exact amounts in pass 2; for now just mark the account
278			assignment.output_amount_1 = assign;
279			target_remaining[tidx] -= assign;
280		} else if assignment.exit_account_2 == [0u8; 32] {
281			// Second target for this proof -> use output_2
282			let avail = proof_outputs[proof_idx].saturating_sub(assignment.output_amount_1);
283			let assign = avail.min(target_remaining[tidx]);
284			assignment.exit_account_2 = target_accounts[tidx];
285			assignment.output_amount_2 = assign;
286			target_remaining[tidx] -= assign;
287		}
288		// If both slots taken, skip (shouldn't happen when num_targets <= 2*num_proofs)
289	}
290
291	// Pass 2: Distribute any remaining target allocations into available proof outputs.
292	// Also ensure each proof's output_1 + output_2 == proof_outputs[i].
293	for proof_idx in 0..num_proofs {
294		let total_proof_output = proof_outputs[proof_idx];
295		let current_sum =
296			assignments[proof_idx].output_amount_1 + assignments[proof_idx].output_amount_2;
297		let mut shortfall = total_proof_output.saturating_sub(current_sum);
298
299		if shortfall > 0 {
300			// Add shortfall to output_1 (its account is already set)
301			assignments[proof_idx].output_amount_1 += shortfall;
302			shortfall = 0;
303		}
304
305		// If output_1_account is still [0;32] (shouldn't happen), assign first target as fallback
306		if assignments[proof_idx].exit_account_1 == [0u8; 32] && num_targets > 0 {
307			assignments[proof_idx].exit_account_1 = target_accounts[0];
308		}
309
310		let _ = shortfall; // suppress unused warning
311	}
312
313	assignments
314}
315
316/// Result of checking proof verification events
317pub struct VerificationResult {
318	pub success: bool,
319	pub exit_amount: Option<u128>,
320	pub error_message: Option<String>,
321}
322
323/// Check for proof verification events in a transaction
324/// Returns whether ProofVerified event was found and the exit amount
325async fn check_proof_verification_events(
326	client: &subxt::OnlineClient<ChainConfig>,
327	block_hash: &subxt::utils::H256,
328	tx_hash: &subxt::utils::H256,
329	verbose: bool,
330) -> crate::error::Result<VerificationResult> {
331	use crate::chain::quantus_subxt::api::system::events::ExtrinsicFailed;
332	use colored::Colorize;
333
334	let block = client.blocks().at(*block_hash).await.map_err(|e| {
335		crate::error::QuantusError::NetworkError(format!("Failed to get block: {e:?}"))
336	})?;
337
338	let extrinsics = block.extrinsics().await.map_err(|e| {
339		crate::error::QuantusError::NetworkError(format!("Failed to get extrinsics: {e:?}"))
340	})?;
341
342	// Find our extrinsic index
343	let our_extrinsic_index = extrinsics
344		.iter()
345		.enumerate()
346		.find(|(_, ext)| ext.hash() == *tx_hash)
347		.map(|(idx, _)| idx);
348
349	let events = block.events().await.map_err(|e| {
350		crate::error::QuantusError::NetworkError(format!("Failed to fetch events: {e:?}"))
351	})?;
352
353	let metadata = client.metadata();
354
355	let mut verification_result =
356		VerificationResult { success: false, exit_amount: None, error_message: None };
357
358	if verbose {
359		log_print!("");
360		log_print!("📋 Transaction Events:");
361	}
362
363	if let Some(ext_idx) = our_extrinsic_index {
364		for event_result in events.iter() {
365			let event = event_result.map_err(|e| {
366				crate::error::QuantusError::NetworkError(format!("Failed to decode event: {e:?}"))
367			})?;
368
369			// Only process events for our extrinsic
370			if let subxt::events::Phase::ApplyExtrinsic(event_ext_idx) = event.phase() {
371				if event_ext_idx != ext_idx as u32 {
372					continue;
373				}
374
375				// Display event in verbose mode
376				if verbose {
377					log_print!(
378						"  📌 {}.{}",
379						event.pallet_name().bright_cyan(),
380						event.variant_name().bright_yellow()
381					);
382
383					// Try to decode and display event details
384					if let Ok(typed_event) =
385						event.as_root_event::<crate::chain::quantus_subxt::api::Event>()
386					{
387						log_print!("     📝 {:?}", typed_event);
388					}
389				}
390
391				// Check for ProofVerified event
392				if let Ok(Some(proof_verified)) =
393					event.as_event::<wormhole::events::ProofVerified>()
394				{
395					verification_result.success = true;
396					verification_result.exit_amount = Some(proof_verified.exit_amount);
397				}
398
399				// Check for ExtrinsicFailed event
400				if let Ok(Some(ExtrinsicFailed { dispatch_error, .. })) =
401					event.as_event::<ExtrinsicFailed>()
402				{
403					let error_msg = format_dispatch_error(&dispatch_error, &metadata);
404					verification_result.success = false;
405					verification_result.error_message = Some(error_msg);
406				}
407			}
408		}
409	}
410
411	if verbose {
412		log_print!("");
413	}
414
415	Ok(verification_result)
416}
417
418/// Format dispatch error for display
419fn format_dispatch_error(
420	error: &crate::chain::quantus_subxt::api::runtime_types::sp_runtime::DispatchError,
421	metadata: &subxt::Metadata,
422) -> String {
423	use crate::chain::quantus_subxt::api::runtime_types::sp_runtime::DispatchError;
424
425	match error {
426		DispatchError::Module(module_error) => {
427			let pallet_name = metadata
428				.pallet_by_index(module_error.index)
429				.map(|p| p.name())
430				.unwrap_or("Unknown");
431			let error_index = module_error.error[0];
432
433			// Try to decode the error name and docs from metadata
434			let error_info = metadata.pallet_by_index(module_error.index).and_then(|p| {
435				p.error_variant_by_index(error_index)
436					.map(|v| (v.name.clone(), v.docs.join(" ")))
437			});
438
439			match error_info {
440				Some((name, docs)) if !docs.is_empty() => {
441					format!("{}::{} ({})", pallet_name, name, docs)
442				},
443				Some((name, _)) => format!("{}::{}", pallet_name, name),
444				None => format!("{}::Error[{}]", pallet_name, error_index),
445			}
446		},
447		DispatchError::BadOrigin => "BadOrigin".to_string(),
448		DispatchError::CannotLookup => "CannotLookup".to_string(),
449		DispatchError::Other => "Other".to_string(),
450		_ => format!("{:?}", error),
451	}
452}
453
454#[derive(Subcommand, Debug)]
455pub enum WormholeCommands {
456	/// Derive the unspendable wormhole address from a secret
457	Address {
458		/// Secret (32-byte hex string) - used to derive the unspendable account
459		#[arg(long)]
460		secret: String,
461	},
462	/// Generate a wormhole proof from an existing transfer
463	Prove {
464		/// Secret (32-byte hex string) used for the transfer
465		#[arg(long)]
466		secret: String,
467
468		/// Funding amount that was transferred
469		#[arg(long)]
470		amount: u128,
471
472		/// Exit account (where funds will be withdrawn, hex or SS58)
473		#[arg(long)]
474		exit_account: String,
475
476		/// Block hash to generate proof against (hex)
477		#[arg(long)]
478		block: String,
479
480		/// Transfer count from the transfer event
481		#[arg(long)]
482		transfer_count: u64,
483
484		/// Funding account (sender of transfer, hex or SS58)
485		#[arg(long)]
486		funding_account: String,
487
488		/// Output file for the proof (default: proof.hex)
489		#[arg(short, long, default_value = "proof.hex")]
490		output: String,
491	},
492	/// Aggregate multiple wormhole proofs into a single proof
493	Aggregate {
494		/// Input proof files (hex-encoded)
495		#[arg(short, long, num_args = 1..)]
496		proofs: Vec<String>,
497
498		/// Output file for the aggregated proof (default: aggregated_proof.hex)
499		#[arg(short, long, default_value = "aggregated_proof.hex")]
500		output: String,
501	},
502	/// Verify an aggregated wormhole proof on-chain
503	VerifyAggregated {
504		/// Path to the aggregated proof file (hex-encoded)
505		#[arg(short, long, default_value = "aggregated_proof.hex")]
506		proof: String,
507	},
508	/// Parse and display the contents of a proof file (for debugging)
509	ParseProof {
510		/// Path to the proof file (hex-encoded)
511		#[arg(short, long)]
512		proof: String,
513
514		/// Parse as aggregated proof (default: false, parses as leaf proof)
515		#[arg(long)]
516		aggregated: bool,
517
518		/// Verify the proof cryptographically (local verification, not on-chain)
519		#[arg(long)]
520		verify: bool,
521	},
522	/// Run a multi-round wormhole test: wallet -> wormhole -> ... -> wallet
523	Multiround {
524		/// Number of proofs per round (default: 2, max: 8)
525		#[arg(short, long, default_value = "2")]
526		num_proofs: usize,
527
528		/// Number of rounds (default: 2)
529		#[arg(short, long, default_value = "2")]
530		rounds: usize,
531
532		/// Total amount in DEV to partition across all proofs (default: 100)
533		#[arg(short, long, default_value = "100")]
534		amount: f64,
535
536		/// Wallet name to use for funding and final exit
537		#[arg(short, long)]
538		wallet: String,
539
540		/// Password for the wallet
541		#[arg(short, long)]
542		password: Option<String>,
543
544		/// Read password from file
545		#[arg(long)]
546		password_file: Option<String>,
547
548		/// Keep proof files after completion
549		#[arg(short, long)]
550		keep_files: bool,
551
552		/// Output directory for proof files
553		#[arg(short, long, default_value = "/tmp/wormhole_multiround")]
554		output_dir: String,
555
556		/// Dry run - show what would be done without executing
557		#[arg(long)]
558		dry_run: bool,
559	},
560	/// Dissolve a large wormhole deposit into many small outputs for better privacy.
561	///
562	/// Creates a tree of wormhole transactions: each layer splits outputs into two,
563	/// doubling the number of outputs until all are below the target size. This moves
564	/// funds from a high-amount bucket (few deposits, low privacy score) into the
565	/// low-amount bucket (many miner rewards, high privacy score).
566	Dissolve {
567		/// Amount in DEV to dissolve
568		#[arg(short, long)]
569		amount: f64,
570
571		/// Target output size in DEV (stop splitting when all outputs are below this)
572		#[arg(short, long, default_value = "1.0")]
573		target_size: f64,
574
575		/// Wallet name to use for funding
576		#[arg(short, long)]
577		wallet: String,
578
579		/// Password for the wallet
580		#[arg(short, long)]
581		password: Option<String>,
582
583		/// Read password from file
584		#[arg(long)]
585		password_file: Option<String>,
586
587		/// Keep proof files after completion
588		#[arg(short, long)]
589		keep_files: bool,
590
591		/// Output directory for proof files
592		#[arg(short, long, default_value = "/tmp/wormhole_dissolve")]
593		output_dir: String,
594	},
595	/// Fuzz test the leaf verification by attempting invalid proofs
596	Fuzz {
597		/// Wallet name to use for funding
598		#[arg(short, long)]
599		wallet: String,
600
601		/// Password for the wallet
602		#[arg(short, long)]
603		password: Option<String>,
604
605		/// Read password from file
606		#[arg(long)]
607		password_file: Option<String>,
608
609		/// Amount in DEV to use for the test transfer (default: 1.0)
610		#[arg(short, long, default_value = "1.0")]
611		amount: f64,
612	},
613}
614
615pub async fn handle_wormhole_command(
616	command: WormholeCommands,
617	node_url: &str,
618) -> crate::error::Result<()> {
619	match command {
620		WormholeCommands::Address { secret } => show_wormhole_address(secret),
621		WormholeCommands::Prove {
622			secret,
623			amount,
624			exit_account,
625			block,
626			transfer_count,
627			funding_account,
628			output,
629		} => {
630			log_print!("Generating proof from existing transfer...");
631
632			// Connect to node
633			let quantus_client = QuantusClient::new(node_url).await.map_err(|e| {
634				crate::error::QuantusError::Generic(format!("Failed to connect: {}", e))
635			})?;
636
637			// Parse exit account
638			let exit_account_bytes =
639				parse_exit_account(&exit_account).map_err(crate::error::QuantusError::Generic)?;
640
641			// Quantize amount and compute output (single output, no change)
642			let input_amount_quantized =
643				quantize_funding_amount(amount).map_err(crate::error::QuantusError::Generic)?;
644			let output_amount = compute_output_amount(input_amount_quantized, VOLUME_FEE_BPS);
645
646			let output_assignment = ProofOutputAssignment {
647				output_amount_1: output_amount,
648				exit_account_1: exit_account_bytes,
649				output_amount_2: 0,
650				exit_account_2: [0u8; 32],
651			};
652
653			let prove_start = std::time::Instant::now();
654			generate_proof(
655				&secret,
656				amount,
657				&output_assignment,
658				&block,
659				transfer_count,
660				&funding_account,
661				&output,
662				&quantus_client,
663			)
664			.await?;
665			let prove_elapsed = prove_start.elapsed();
666			log_print!("Proof generation: {:.2}s", prove_elapsed.as_secs_f64());
667			Ok(())
668		},
669		WormholeCommands::Aggregate { proofs, output } => aggregate_proofs(proofs, output).await,
670		WormholeCommands::VerifyAggregated { proof } =>
671			verify_aggregated_proof(proof, node_url).await,
672		WormholeCommands::ParseProof { proof, aggregated, verify } =>
673			parse_proof_file(proof, aggregated, verify).await,
674		WormholeCommands::Multiround {
675			num_proofs,
676			rounds,
677			amount,
678			wallet,
679			password,
680			password_file,
681			keep_files,
682			output_dir,
683			dry_run,
684		} => {
685			// Convert DEV to planck and align to SCALE_DOWN_FACTOR for clean quantization
686			let amount_planck = (amount * 1_000_000_000_000.0) as u128;
687			let amount_aligned = (amount_planck / SCALE_DOWN_FACTOR) * SCALE_DOWN_FACTOR;
688			run_multiround(
689				num_proofs,
690				rounds,
691				amount_aligned,
692				wallet,
693				password,
694				password_file,
695				keep_files,
696				output_dir,
697				dry_run,
698				node_url,
699			)
700			.await
701		},
702		WormholeCommands::Dissolve {
703			amount,
704			target_size,
705			wallet,
706			password,
707			password_file,
708			keep_files,
709			output_dir,
710		} => {
711			let amount_planck = (amount * 1_000_000_000_000.0) as u128;
712			let amount_aligned = (amount_planck / SCALE_DOWN_FACTOR) * SCALE_DOWN_FACTOR;
713			let target_planck = (target_size * 1_000_000_000_000.0) as u128;
714			let target_aligned = (target_planck / SCALE_DOWN_FACTOR) * SCALE_DOWN_FACTOR;
715			run_dissolve(
716				amount_aligned,
717				target_aligned,
718				wallet,
719				password,
720				password_file,
721				keep_files,
722				output_dir,
723				node_url,
724			)
725			.await
726		},
727		WormholeCommands::Fuzz { wallet, password, password_file, amount } => {
728			let amount_planck = (amount * 1_000_000_000_000.0) as u128;
729			let amount_aligned = (amount_planck / SCALE_DOWN_FACTOR) * SCALE_DOWN_FACTOR;
730			run_fuzz_test(wallet, password, password_file, amount_aligned, node_url).await
731		},
732	}
733}
734
735/// Key for TransferProof storage - uniquely identifies a transfer.
736/// Uses (to, transfer_count) since transfer_count is atomic per recipient.
737/// This is hashed with Blake2_256 to form the storage key suffix.
738pub type TransferProofKey = (AccountId32, u64);
739
740/// Full transfer data including amount - used to compute the leaf_inputs_hash via Poseidon2.
741/// This is what the ZK circuit verifies.
742pub type TransferProofData = (u32, u64, AccountId32, AccountId32, u128);
743
744/// Derive and display the unspendable wormhole address from a secret.
745/// Users can then send funds to this address using `quantus send`.
746fn show_wormhole_address(secret_hex: String) -> crate::error::Result<()> {
747	use colored::Colorize;
748
749	let secret_array =
750		parse_secret_hex(&secret_hex).map_err(crate::error::QuantusError::Generic)?;
751	let secret: BytesDigest = secret_array.try_into().map_err(|e| {
752		crate::error::QuantusError::Generic(format!("Failed to convert secret: {:?}", e))
753	})?;
754
755	let unspendable_account =
756		qp_wormhole_circuit::unspendable_account::UnspendableAccount::from_secret(secret)
757			.account_id;
758	let unspendable_account_bytes_digest =
759		qp_zk_circuits_common::utils::digest_to_bytes(unspendable_account);
760	let unspendable_account_bytes: [u8; 32] = unspendable_account_bytes_digest
761		.as_ref()
762		.try_into()
763		.expect("BytesDigest is always 32 bytes");
764
765	let account_id = sp_core::crypto::AccountId32::new(unspendable_account_bytes);
766	let ss58_address =
767		account_id.to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(189));
768
769	log_print!("{}", "Wormhole Address".bright_cyan());
770	log_print!("  SS58:  {}", ss58_address.bright_green());
771	log_print!("  Hex:   0x{}", hex::encode(unspendable_account_bytes));
772	log_print!("");
773	log_print!("To fund this address:");
774	log_print!("  quantus send --from <wallet> --to {} --amount <amount>", ss58_address);
775
776	Ok(())
777}
778
779async fn at_best_block(
780	quantus_client: &QuantusClient,
781) -> anyhow::Result<Block<ChainConfig, OnlineClient<ChainConfig>>> {
782	let best_block = quantus_client.get_latest_block().await?;
783	let block = quantus_client.client().blocks().at(best_block).await?;
784	Ok(block)
785}
786
787async fn aggregate_proofs(
788	proof_files: Vec<String>,
789	output_file: String,
790) -> crate::error::Result<()> {
791	use qp_wormhole_aggregator::aggregator::{AggregationBackend, CircuitType, Layer0Aggregator};
792
793	use std::path::Path;
794
795	log_print!("Aggregating {} proofs...", proof_files.len());
796
797	// Load config first to validate and calculate padding needs
798	let bins_dir = Path::new("generated-bins");
799	let agg_config = CircuitBinsConfig::load(bins_dir).map_err(|e| {
800		crate::error::QuantusError::Generic(format!(
801			"Failed to load circuit bins config from {:?}: {}",
802			bins_dir, e
803		))
804	})?;
805
806	// Validate number of proofs before doing expensive work
807	if proof_files.len() > agg_config.num_leaf_proofs {
808		return Err(crate::error::QuantusError::Generic(format!(
809			"Too many proofs: {} provided, max {} supported by circuit",
810			proof_files.len(),
811			agg_config.num_leaf_proofs
812		)));
813	}
814
815	let num_padding_proofs = agg_config.num_leaf_proofs - proof_files.len();
816
817	log_print!("  Loading aggregator and generating {} dummy proofs...", num_padding_proofs);
818
819	let mut aggregator = Layer0Aggregator::new(bins_dir).map_err(|e| {
820		crate::error::QuantusError::Generic(format!(
821			"Failed to load aggregator from pre-built bins: {}",
822			e
823		))
824	})?;
825
826	log_verbose!("Aggregation config: num_leaf_proofs={}", aggregator.batch_size());
827	let common_data = aggregator.load_common_data(CircuitType::Leaf).map_err(|e| {
828		crate::error::QuantusError::Generic(format!("Failed to load leaf circuit data: {}", e))
829	})?;
830
831	// Load and add proofs using helper function
832	for (idx, proof_file) in proof_files.iter().enumerate() {
833		log_verbose!("Loading proof {}/{}: {}", idx + 1, proof_files.len(), proof_file);
834
835		let proof_bytes = read_proof_file(proof_file).map_err(|e| {
836			crate::error::QuantusError::Generic(format!("Failed to load {}: {}", proof_file, e))
837		})?;
838
839		let proof = ProofWithPublicInputs::<F, C, D>::from_bytes(proof_bytes, &common_data)
840			.map_err(|e| {
841				crate::error::QuantusError::Generic(format!(
842					"Failed to deserialize proof from {}: {}",
843					proof_file, e
844				))
845			})?;
846
847		aggregator.push_proof(proof).map_err(|e| {
848			crate::error::QuantusError::Generic(format!("Failed to add proof: {}", e))
849		})?;
850	}
851
852	log_print!("  Running aggregation...");
853	let agg_start = std::time::Instant::now();
854	let aggregated_proof = aggregator
855		.aggregate()
856		.map_err(|e| crate::error::QuantusError::Generic(format!("Aggregation failed: {}", e)))?;
857	let agg_elapsed = agg_start.elapsed();
858	log_print!("  Aggregation: {:.2}s", agg_elapsed.as_secs_f64());
859
860	// Parse and display aggregated public inputs
861	let aggregated_public_inputs =
862		AggregatedPublicCircuitInputs::try_from_felts(aggregated_proof.public_inputs.as_slice())
863			.map_err(|e| {
864				crate::error::QuantusError::Generic(format!(
865					"Failed to parse aggregated public inputs: {}",
866					e
867				))
868			})?;
869
870	log_verbose!("Aggregated public inputs: {:#?}", aggregated_public_inputs);
871
872	// Log exit accounts and amounts that will be minted
873	log_print!("  Exit accounts in aggregated proof:");
874	for (idx, account_data) in aggregated_public_inputs.account_data.iter().enumerate() {
875		let exit_bytes: &[u8] = account_data.exit_account.as_ref();
876		let is_dummy = exit_bytes.iter().all(|&b| b == 0) || account_data.summed_output_amount == 0;
877		if is_dummy {
878			log_verbose!("    [{}] DUMMY (skipped)", idx);
879		} else {
880			// De-quantize to show actual amount that will be minted
881			let dequantized_amount =
882				(account_data.summed_output_amount as u128) * SCALE_DOWN_FACTOR;
883			log_print!(
884				"    [{}] {} -> {} quantized ({} planck = {})",
885				idx,
886				hex::encode(exit_bytes),
887				account_data.summed_output_amount,
888				dequantized_amount,
889				format_balance(dequantized_amount)
890			);
891		}
892	}
893
894	// Verify the aggregated proof locally
895	log_verbose!("Verifying aggregated proof locally...");
896	aggregator.verify(aggregated_proof.clone()).map_err(|e| {
897		crate::error::QuantusError::Generic(format!("Aggregated proof verification failed: {}", e))
898	})?;
899
900	// Save aggregated proof using helper function
901	write_proof_file(&output_file, &aggregated_proof.to_bytes()).map_err(|e| {
902		crate::error::QuantusError::Generic(format!("Failed to write proof: {}", e))
903	})?;
904
905	log_success!("Aggregation complete!");
906	log_success!("Output: {}", output_file);
907	log_print!(
908		"Aggregated {} proofs into 1 proof with {} exit accounts",
909		proof_files.len(),
910		aggregated_public_inputs.account_data.len()
911	);
912
913	Ok(())
914}
915
916#[derive(Debug, Clone, Copy)]
917enum IncludedAt {
918	Best,
919	Finalized,
920}
921
922impl IncludedAt {
923	fn label(self) -> &'static str {
924		match self {
925			IncludedAt::Best => "best block",
926			IncludedAt::Finalized => "finalized block",
927		}
928	}
929}
930
931fn read_hex_proof_file_to_bytes(proof_file: &str) -> crate::error::Result<Vec<u8>> {
932	let proof_hex = std::fs::read_to_string(proof_file).map_err(|e| {
933		crate::error::QuantusError::Generic(format!("Failed to read proof file: {}", e))
934	})?;
935
936	let proof_bytes = hex::decode(proof_hex.trim())
937		.map_err(|e| crate::error::QuantusError::Generic(format!("Failed to decode hex: {}", e)))?;
938
939	Ok(proof_bytes)
940}
941
942/// Submit unsigned verify_aggregated_proof(proof_bytes) and return (included_at, block_hash,
943/// tx_hash).
944async fn submit_unsigned_verify_aggregated_proof(
945	quantus_client: &QuantusClient,
946	proof_bytes: Vec<u8>,
947) -> crate::error::Result<(IncludedAt, subxt::utils::H256, subxt::utils::H256)> {
948	use subxt::tx::TxStatus;
949
950	let verify_tx = quantus_node::api::tx().wormhole().verify_aggregated_proof(proof_bytes);
951
952	let unsigned_tx = quantus_client.client().tx().create_unsigned(&verify_tx).map_err(|e| {
953		crate::error::QuantusError::Generic(format!("Failed to create unsigned tx: {}", e))
954	})?;
955
956	let mut tx_progress = unsigned_tx
957		.submit_and_watch()
958		.await
959		.map_err(|e| crate::error::QuantusError::Generic(format!("Failed to submit tx: {}", e)))?;
960
961	while let Some(Ok(status)) = tx_progress.next().await {
962		match status {
963			TxStatus::InBestBlock(tx_in_block) => {
964				return Ok((
965					IncludedAt::Best,
966					tx_in_block.block_hash(),
967					tx_in_block.extrinsic_hash(),
968				));
969			},
970			TxStatus::InFinalizedBlock(tx_in_block) => {
971				return Ok((
972					IncludedAt::Finalized,
973					tx_in_block.block_hash(),
974					tx_in_block.extrinsic_hash(),
975				));
976			},
977			TxStatus::Error { message } | TxStatus::Invalid { message } => {
978				return Err(crate::error::QuantusError::Generic(format!(
979					"Transaction failed: {}",
980					message
981				)));
982			},
983			_ => continue,
984		}
985	}
986
987	Err(crate::error::QuantusError::Generic("Transaction stream ended unexpectedly".to_string()))
988}
989
990/// Collect wormhole events for our extrinsic (by tx_hash) in a given block.
991/// Returns (found_proof_verified, native_transfers).
992async fn collect_wormhole_events_for_extrinsic(
993	quantus_client: &QuantusClient,
994	block_hash: subxt::utils::H256,
995	tx_hash: subxt::utils::H256,
996) -> crate::error::Result<(bool, Vec<wormhole::events::NativeTransferred>)> {
997	use crate::chain::quantus_subxt::api::system::events::ExtrinsicFailed;
998
999	let block =
1000		quantus_client.client().blocks().at(block_hash).await.map_err(|e| {
1001			crate::error::QuantusError::Generic(format!("Failed to get block: {}", e))
1002		})?;
1003
1004	let events = block
1005		.events()
1006		.await
1007		.map_err(|e| crate::error::QuantusError::Generic(format!("Failed to get events: {}", e)))?;
1008
1009	let extrinsics = block.extrinsics().await.map_err(|e| {
1010		crate::error::QuantusError::Generic(format!("Failed to get extrinsics: {}", e))
1011	})?;
1012
1013	let our_ext_idx = extrinsics
1014		.iter()
1015		.enumerate()
1016		.find(|(_, ext)| ext.hash() == tx_hash)
1017		.map(|(idx, _)| idx as u32)
1018		.ok_or_else(|| {
1019			crate::error::QuantusError::Generic(
1020				"Could not find submitted extrinsic in included block".to_string(),
1021			)
1022		})?;
1023
1024	let mut transfer_events = Vec::new();
1025	let mut found_proof_verified = false;
1026
1027	log_verbose!("  Events for our extrinsic (idx={}):", our_ext_idx);
1028
1029	for event_result in events.iter() {
1030		let event = event_result.map_err(|e| {
1031			crate::error::QuantusError::Generic(format!("Failed to decode event: {}", e))
1032		})?;
1033
1034		if let subxt::events::Phase::ApplyExtrinsic(ext_idx) = event.phase() {
1035			if ext_idx == our_ext_idx {
1036				log_print!("    Event: {}::{}", event.pallet_name(), event.variant_name());
1037
1038				// Decode ExtrinsicFailed to get the specific error
1039				if let Ok(Some(ExtrinsicFailed { dispatch_error, .. })) =
1040					event.as_event::<ExtrinsicFailed>()
1041				{
1042					let metadata = quantus_client.client().metadata();
1043					let error_msg = format_dispatch_error(&dispatch_error, &metadata);
1044					log_print!("    DispatchError: {}", error_msg);
1045				}
1046
1047				if let Ok(Some(_)) = event.as_event::<wormhole::events::ProofVerified>() {
1048					found_proof_verified = true;
1049				}
1050
1051				if let Ok(Some(transfer)) = event.as_event::<wormhole::events::NativeTransferred>()
1052				{
1053					transfer_events.push(transfer);
1054				}
1055			}
1056		}
1057	}
1058
1059	Ok((found_proof_verified, transfer_events))
1060}
1061
1062async fn verify_aggregated_proof(proof_file: String, node_url: &str) -> crate::error::Result<()> {
1063	log_print!("Verifying aggregated wormhole proof on-chain...");
1064
1065	let proof_bytes = read_hex_proof_file_to_bytes(&proof_file)?;
1066	log_verbose!("Aggregated proof size: {} bytes", proof_bytes.len());
1067
1068	// Connect to node
1069	let quantus_client = QuantusClient::new(node_url)
1070		.await
1071		.map_err(|e| crate::error::QuantusError::Generic(format!("Failed to connect: {}", e)))?;
1072	log_verbose!("Connected to node");
1073
1074	log_verbose!("Submitting unsigned aggregated verification transaction...");
1075
1076	let (included_at, block_hash, tx_hash) =
1077		submit_unsigned_verify_aggregated_proof(&quantus_client, proof_bytes).await?;
1078
1079	// One unified check (no best/finalized copy-paste)
1080	let result = check_proof_verification_events(
1081		quantus_client.client(),
1082		&block_hash,
1083		&tx_hash,
1084		crate::log::is_verbose(),
1085	)
1086	.await?;
1087
1088	if result.success {
1089		log_success!("Aggregated proof verified successfully on-chain!");
1090		if let Some(amount) = result.exit_amount {
1091			log_success!("Total exit amount: {}", format_balance(amount));
1092		}
1093
1094		log_print!("  Block: 0x{}", hex::encode(block_hash.0));
1095		log_print!("  Extrinsic: 0x{}", hex::encode(tx_hash.0));
1096		log_verbose!("Included in {}: {:?}", included_at.label(), block_hash);
1097		return Ok(());
1098	}
1099
1100	let error_msg = result.error_message.unwrap_or_else(|| {
1101		"Aggregated proof verification failed - no ProofVerified event found".to_string()
1102	});
1103	log_error!("❌ {}", error_msg);
1104	Err(crate::error::QuantusError::Generic(error_msg))
1105}
1106
1107// ============================================================================
1108// Multi-round wormhole flow implementation
1109// ============================================================================
1110
1111/// Information about a transfer needed for proof generation
1112#[derive(Debug, Clone)]
1113#[allow(dead_code)]
1114struct TransferInfo {
1115	/// Block hash where the transfer was included
1116	block_hash: subxt::utils::H256,
1117	/// Transfer count for this specific transfer
1118	transfer_count: u64,
1119	/// Amount transferred
1120	amount: u128,
1121	/// The wormhole address (destination of transfer)
1122	wormhole_address: SubxtAccountId,
1123	/// The funding account (source of transfer)
1124	funding_account: SubxtAccountId,
1125}
1126
1127/// Derive a wormhole secret using HD derivation
1128/// Path: m/44'/189189189'/0'/round'/index'
1129fn derive_wormhole_secret(
1130	mnemonic: &str,
1131	round: usize,
1132	index: usize,
1133) -> Result<WormholePair, crate::error::QuantusError> {
1134	// QUANTUS_WORMHOLE_CHAIN_ID already includes the ' (e.g., "189189189'")
1135	let path = format!("m/44'/{}/0'/{}'/{}'", QUANTUS_WORMHOLE_CHAIN_ID, round, index);
1136	derive_wormhole_from_mnemonic(mnemonic, None, &path)
1137		.map_err(|e| crate::error::QuantusError::Generic(format!("HD derivation failed: {:?}", e)))
1138}
1139
1140/// Calculate the amount for a given round, accounting for fees
1141/// Each round deducts 0.1% fee (10 bps)
1142/// Round 1: fee applied once, Round 2: fee applied twice, etc.
1143fn calculate_round_amount(initial_amount: u128, round: usize) -> u128 {
1144	let mut amount = initial_amount;
1145	for _ in 0..round {
1146		// Output = Input * (10000 - 10) / 10000
1147		amount = amount * 9990 / 10000;
1148	}
1149	amount
1150}
1151
1152/// Get the minting account from chain constants
1153async fn get_minting_account(
1154	client: &OnlineClient<ChainConfig>,
1155) -> Result<SubxtAccountId, crate::error::QuantusError> {
1156	let minting_account_addr = quantus_node::api::constants().wormhole().minting_account();
1157	let minting_account = client.constants().at(&minting_account_addr).map_err(|e| {
1158		crate::error::QuantusError::Generic(format!("Failed to get minting account: {}", e))
1159	})?;
1160	Ok(minting_account)
1161}
1162
1163/// Parse transfer info from NativeTransferred events in a block and updates block hash for all
1164/// transfers
1165fn parse_transfer_events(
1166	events: &[wormhole::events::NativeTransferred],
1167	expected_addresses: &[SubxtAccountId],
1168	block_hash: subxt::utils::H256,
1169) -> Result<Vec<TransferInfo>, crate::error::QuantusError> {
1170	let mut transfer_infos = Vec::new();
1171
1172	for expected_addr in expected_addresses {
1173		// Find the event matching this address
1174		let matching_event = events.iter().find(|e| &e.to == expected_addr).ok_or_else(|| {
1175			crate::error::QuantusError::Generic(format!(
1176				"No transfer event found for address {:?}",
1177				expected_addr
1178			))
1179		})?;
1180
1181		transfer_infos.push(TransferInfo {
1182			block_hash,
1183			transfer_count: matching_event.transfer_count,
1184			amount: matching_event.amount,
1185			wormhole_address: expected_addr.clone(),
1186			funding_account: matching_event.from.clone(),
1187		});
1188	}
1189
1190	Ok(transfer_infos)
1191}
1192
1193/// Configuration for multiround execution
1194struct MultiroundConfig {
1195	num_proofs: usize,
1196	rounds: usize,
1197	amount: u128,
1198	output_dir: String,
1199	keep_files: bool,
1200}
1201
1202/// Wallet context for multiround execution
1203struct MultiroundWalletContext {
1204	wallet_name: String,
1205	wallet_address: String,
1206	wallet_account_id: SubxtAccountId,
1207	keypair: QuantumKeyPair,
1208	mnemonic: String,
1209}
1210
1211/// Validate multiround parameters
1212fn validate_multiround_params(
1213	num_proofs: usize,
1214	rounds: usize,
1215	max_proofs: usize,
1216) -> crate::error::Result<()> {
1217	if !(1..=max_proofs).contains(&num_proofs) {
1218		return Err(crate::error::QuantusError::Generic(format!(
1219			"num_proofs must be between 1 and {} (got: {})",
1220			max_proofs, num_proofs
1221		)));
1222	}
1223	if rounds < 1 {
1224		return Err(crate::error::QuantusError::Generic(format!(
1225			"rounds must be at least 1 (got: {})",
1226			rounds
1227		)));
1228	}
1229	Ok(())
1230}
1231
1232/// Load wallet and prepare context for multiround execution
1233fn load_multiround_wallet(
1234	wallet_name: &str,
1235	password: Option<String>,
1236	password_file: Option<String>,
1237) -> crate::error::Result<MultiroundWalletContext> {
1238	let wallet_manager = WalletManager::new()?;
1239	let wallet_password = password::get_wallet_password(wallet_name, password, password_file)?;
1240	let wallet_data = wallet_manager.load_wallet(wallet_name, &wallet_password)?;
1241	let wallet_address = wallet_data.keypair.to_account_id_ss58check();
1242	let wallet_account_id = SubxtAccountId(wallet_data.keypair.to_account_id_32().into());
1243
1244	// Get or generate mnemonic for HD derivation
1245	let mnemonic = match wallet_data.mnemonic {
1246		Some(m) => {
1247			log_verbose!("Using wallet mnemonic for HD derivation");
1248			m
1249		},
1250		None => {
1251			log_print!("Wallet has no mnemonic - generating random mnemonic for wormhole secrets");
1252			let mut entropy = [0u8; 32];
1253			rand::rng().fill_bytes(&mut entropy);
1254			let sensitive_entropy = SensitiveBytes32::from(&mut entropy);
1255			let m = generate_mnemonic(sensitive_entropy).map_err(|e| {
1256				crate::error::QuantusError::Generic(format!("Failed to generate mnemonic: {:?}", e))
1257			})?;
1258			log_verbose!("Generated mnemonic (not saved): {}", m);
1259			m
1260		},
1261	};
1262
1263	Ok(MultiroundWalletContext {
1264		wallet_name: wallet_name.to_string(),
1265		wallet_address,
1266		wallet_account_id,
1267		keypair: wallet_data.keypair,
1268		mnemonic,
1269	})
1270}
1271
1272/// Print multiround configuration summary
1273fn print_multiround_config(
1274	config: &MultiroundConfig,
1275	wallet: &MultiroundWalletContext,
1276	num_leaf_proofs: usize,
1277) {
1278	use colored::Colorize;
1279
1280	log_print!("{}", "Configuration:".bright_cyan());
1281	log_print!("  Wallet: {}", wallet.wallet_name);
1282	log_print!("  Wallet address: {}", wallet.wallet_address);
1283	log_print!(
1284		"  Total amount: {} ({}) - randomly partitioned across {} proofs",
1285		config.amount,
1286		format_balance(config.amount),
1287		config.num_proofs
1288	);
1289	log_print!("  Proofs per round: {}", config.num_proofs);
1290	log_print!("  Rounds: {}", config.rounds);
1291	log_print!("  Aggregation: num_leaf_proofs={}", num_leaf_proofs);
1292	log_print!("  Output directory: {}", config.output_dir);
1293	log_print!("  Keep files: {}", config.keep_files);
1294	log_print!("");
1295
1296	// Show expected amounts per round
1297	log_print!("{}", "Expected amounts per round:".bright_cyan());
1298	for r in 1..=config.rounds {
1299		let round_amount = calculate_round_amount(config.amount, r);
1300		log_print!("  Round {}: {} ({})", r, round_amount, format_balance(round_amount));
1301	}
1302	log_print!("");
1303}
1304
1305/// Execute initial transfers from wallet to wormhole addresses (round 1 only).
1306///
1307/// Sends all transfers in a single batched extrinsic using `utility.batch()`,
1308/// then parses the `NativeTransferred` events to extract transfer info for proof generation.
1309async fn execute_initial_transfers(
1310	quantus_client: &QuantusClient,
1311	wallet: &MultiroundWalletContext,
1312	secrets: &[WormholePair],
1313	amount: u128,
1314	num_proofs: usize,
1315) -> crate::error::Result<Vec<TransferInfo>> {
1316	use colored::Colorize;
1317	use quantus_node::api::runtime_types::{
1318		pallet_balances::pallet::Call as BalancesCall, quantus_runtime::RuntimeCall,
1319	};
1320
1321	log_print!("{}", "Step 1: Sending batched transfer to wormhole addresses...".bright_yellow());
1322
1323	// Randomly partition the total amount among proofs
1324	// Each partition must meet the on-chain minimum transfer amount
1325	// Minimum per partition is 0.02 DEV (2 quantized units) to ensure non-trivial amounts
1326	let partition_amounts = random_partition(amount, num_proofs, 3 * SCALE_DOWN_FACTOR);
1327	log_print!("  Random partition of {} ({}):", amount, format_balance(amount));
1328	for (i, &amt) in partition_amounts.iter().enumerate() {
1329		log_print!("    Proof {}: {} ({})", i + 1, amt, format_balance(amt));
1330	}
1331
1332	// Build batch of transfer calls
1333	let mut calls = Vec::with_capacity(num_proofs);
1334	for (i, secret) in secrets.iter().enumerate() {
1335		let wormhole_address = SubxtAccountId(secret.address);
1336		let transfer_call = RuntimeCall::Balances(BalancesCall::transfer_allow_death {
1337			dest: subxt::ext::subxt_core::utils::MultiAddress::Id(wormhole_address),
1338			value: partition_amounts[i],
1339		});
1340		calls.push(transfer_call);
1341	}
1342
1343	let batch_tx = quantus_node::api::tx().utility().batch(calls);
1344
1345	let quantum_keypair = QuantumKeyPair {
1346		public_key: wallet.keypair.public_key.clone(),
1347		private_key: wallet.keypair.private_key.clone(),
1348	};
1349
1350	log_print!("  Submitting batch of {} transfers...", num_proofs);
1351
1352	submit_transaction(
1353		quantus_client,
1354		&quantum_keypair,
1355		batch_tx,
1356		None,
1357		ExecutionMode { finalized: false, wait_for_transaction: true },
1358	)
1359	.await
1360	.map_err(|e| crate::error::QuantusError::Generic(format!("Batch transfer failed: {}", e)))?;
1361
1362	// Get the block and find all NativeTransferred events
1363	let client = quantus_client.client();
1364	let block = at_best_block(quantus_client)
1365		.await
1366		.map_err(|e| crate::error::QuantusError::Generic(format!("Failed to get block: {}", e)))?;
1367	let block_hash = block.hash();
1368
1369	let events_api =
1370		client.events().at(block_hash).await.map_err(|e| {
1371			crate::error::QuantusError::Generic(format!("Failed to get events: {}", e))
1372		})?;
1373
1374	// Match each secret's wormhole address to its NativeTransferred event
1375	let funding_account: SubxtAccountId = SubxtAccountId(wallet.keypair.to_account_id_32().into());
1376	let mut transfers = Vec::with_capacity(num_proofs);
1377
1378	for (i, secret) in secrets.iter().enumerate() {
1379		let event = events_api
1380			.find::<wormhole::events::NativeTransferred>()
1381			.find(|e| if let Ok(evt) = e { evt.to.0 == secret.address } else { false })
1382			.ok_or_else(|| {
1383				crate::error::QuantusError::Generic(format!(
1384					"No NativeTransferred event found for wormhole address {} (proof {})",
1385					hex::encode(secret.address),
1386					i + 1
1387				))
1388			})?
1389			.map_err(|e| {
1390				crate::error::QuantusError::Generic(format!("Event decode error: {}", e))
1391			})?;
1392
1393		transfers.push(TransferInfo {
1394			block_hash,
1395			transfer_count: event.transfer_count,
1396			amount: partition_amounts[i],
1397			wormhole_address: SubxtAccountId(secret.address),
1398			funding_account: funding_account.clone(),
1399		});
1400	}
1401
1402	log_success!(
1403		"  {} transfers submitted in a single batch (block {})",
1404		num_proofs,
1405		hex::encode(block_hash.0)
1406	);
1407
1408	Ok(transfers)
1409}
1410
1411/// Generate proofs for a round with random output partitioning
1412async fn generate_round_proofs(
1413	quantus_client: &QuantusClient,
1414	secrets: &[WormholePair],
1415	transfers: &[TransferInfo],
1416	exit_accounts: &[SubxtAccountId],
1417	round_dir: &str,
1418	num_proofs: usize,
1419) -> crate::error::Result<Vec<String>> {
1420	use colored::Colorize;
1421
1422	log_print!("{}", "Step 2: Generating proofs...".bright_yellow());
1423
1424	// All proofs in an aggregation batch must use the same block for storage proofs.
1425	let proof_block = at_best_block(quantus_client)
1426		.await
1427		.map_err(|e| crate::error::QuantusError::Generic(format!("Failed to get block: {}", e)))?;
1428	let proof_block_hash = proof_block.hash();
1429	log_print!("  Using block {} for all proofs", hex::encode(proof_block_hash.0));
1430
1431	// Collect input amounts and exit accounts for random assignment
1432	let input_amounts: Vec<u128> = transfers.iter().map(|t| t.amount).collect();
1433	let exit_account_bytes: Vec<[u8; 32]> = exit_accounts.iter().map(|a| a.0).collect();
1434
1435	// Compute random output assignments (each proof can have 2 outputs)
1436	let output_assignments =
1437		compute_random_output_assignments(&input_amounts, &exit_account_bytes, VOLUME_FEE_BPS);
1438
1439	// Log the random partition
1440	log_print!("  Random output partition:");
1441	for (i, assignment) in output_assignments.iter().enumerate() {
1442		let amt1_planck = (assignment.output_amount_1 as u128) * SCALE_DOWN_FACTOR;
1443		if assignment.output_amount_2 > 0 {
1444			let amt2_planck = (assignment.output_amount_2 as u128) * SCALE_DOWN_FACTOR;
1445			log_print!(
1446				"    Proof {}: {} ({}) -> 0x{}..., {} ({}) -> 0x{}...",
1447				i + 1,
1448				assignment.output_amount_1,
1449				format_balance(amt1_planck),
1450				hex::encode(&assignment.exit_account_1[..4]),
1451				assignment.output_amount_2,
1452				format_balance(amt2_planck),
1453				hex::encode(&assignment.exit_account_2[..4])
1454			);
1455		} else {
1456			log_print!(
1457				"    Proof {}: {} ({}) -> 0x{}...",
1458				i + 1,
1459				assignment.output_amount_1,
1460				format_balance(amt1_planck),
1461				hex::encode(&assignment.exit_account_1[..4])
1462			);
1463		}
1464	}
1465
1466	let pb = ProgressBar::new(num_proofs as u64);
1467	pb.set_style(
1468		ProgressStyle::default_bar()
1469			.template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
1470			.unwrap()
1471			.progress_chars("#>-"),
1472	);
1473
1474	let proof_gen_start = std::time::Instant::now();
1475	let mut proof_files = Vec::new();
1476	for (i, (secret, transfer)) in secrets.iter().zip(transfers.iter()).enumerate() {
1477		pb.set_message(format!("Proof {}/{}", i + 1, num_proofs));
1478
1479		let proof_file = format!("{}/proof_{}.hex", round_dir, i + 1);
1480
1481		// Use the funding account from the transfer info
1482		let funding_account_hex = format!("0x{}", hex::encode(transfer.funding_account.0));
1483
1484		let single_start = std::time::Instant::now();
1485
1486		// Generate proof with dual output assignment
1487		generate_proof(
1488			&hex::encode(secret.secret),
1489			transfer.amount, // Use actual transfer amount for storage key
1490			&output_assignments[i],
1491			&format!("0x{}", hex::encode(proof_block_hash.0)),
1492			transfer.transfer_count,
1493			&funding_account_hex,
1494			&proof_file,
1495			quantus_client,
1496		)
1497		.await?;
1498
1499		let single_elapsed = single_start.elapsed();
1500		log_verbose!("  Proof {} generated in {:.2}s", i + 1, single_elapsed.as_secs_f64());
1501
1502		proof_files.push(proof_file);
1503		pb.inc(1);
1504	}
1505	pb.finish_with_message("Proofs generated");
1506	let proof_gen_elapsed = proof_gen_start.elapsed();
1507	log_print!(
1508		"  Proof generation: {:.2}s ({} proofs, {:.2}s avg)",
1509		proof_gen_elapsed.as_secs_f64(),
1510		num_proofs,
1511		proof_gen_elapsed.as_secs_f64() / num_proofs as f64,
1512	);
1513
1514	Ok(proof_files)
1515}
1516
1517/// Derive wormhole secrets for a round
1518fn derive_round_secrets(
1519	mnemonic: &str,
1520	round: usize,
1521	num_proofs: usize,
1522) -> crate::error::Result<Vec<WormholePair>> {
1523	let pb = ProgressBar::new(num_proofs as u64);
1524	pb.set_style(
1525		ProgressStyle::default_bar()
1526			.template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
1527			.unwrap()
1528			.progress_chars("#>-"),
1529	);
1530	pb.set_message("Deriving secrets...");
1531
1532	let mut secrets = Vec::new();
1533	for i in 1..=num_proofs {
1534		let secret = derive_wormhole_secret(mnemonic, round, i)?;
1535		secrets.push(secret);
1536		pb.inc(1);
1537	}
1538	pb.finish_with_message("Secrets derived");
1539
1540	Ok(secrets)
1541}
1542
1543/// Verify final balance and print summary
1544fn verify_final_balance(
1545	initial_balance: u128,
1546	final_balance: u128,
1547	total_sent: u128,
1548	rounds: usize,
1549	num_proofs: usize,
1550) {
1551	use colored::Colorize;
1552
1553	log_print!("{}", "Balance Verification:".bright_cyan());
1554
1555	// Total received in final round: apply fee deduction for each round
1556	let total_received = calculate_round_amount(total_sent, rounds);
1557
1558	// Expected net change (may be negative due to fees)
1559	let expected_change = total_received as i128 - total_sent as i128;
1560	let actual_change = final_balance as i128 - initial_balance as i128;
1561
1562	log_print!("  Initial balance: {} ({})", initial_balance, format_balance(initial_balance));
1563	log_print!("  Final balance:   {} ({})", final_balance, format_balance(final_balance));
1564	log_print!("");
1565	log_print!("  Total sent (round 1):     {} ({})", total_sent, format_balance(total_sent));
1566	log_print!(
1567		"  Total received (round {}): {} ({})",
1568		rounds,
1569		total_received,
1570		format_balance(total_received)
1571	);
1572	log_print!("");
1573
1574	// Format signed amounts for display
1575	let expected_change_str = if expected_change >= 0 {
1576		format!("+{}", expected_change)
1577	} else {
1578		format!("{}", expected_change)
1579	};
1580	let actual_change_str = if actual_change >= 0 {
1581		format!("+{}", actual_change)
1582	} else {
1583		format!("{}", actual_change)
1584	};
1585
1586	log_print!("  Expected change: {} planck", expected_change_str);
1587	log_print!("  Actual change:   {} planck", actual_change_str);
1588	log_print!("");
1589
1590	// Allow some tolerance for transaction fees
1591	let tolerance = (total_sent / 100).max(1_000_000_000_000); // 1% or 1 QNT minimum
1592
1593	let diff = (actual_change - expected_change).unsigned_abs();
1594	if diff <= tolerance {
1595		log_success!(
1596			"  {} Balance verification PASSED (within tolerance of {} planck)",
1597			"✓".bright_green(),
1598			tolerance
1599		);
1600	} else {
1601		log_print!(
1602			"  {} Balance verification: difference of {} planck (tolerance: {} planck)",
1603			"!".bright_yellow(),
1604			diff,
1605			tolerance
1606		);
1607		log_print!(
1608			"    Note: Transaction fees for {} initial transfers may account for the difference",
1609			num_proofs
1610		);
1611	}
1612	log_print!("");
1613}
1614
1615/// Run the multi-round wormhole flow
1616#[allow(clippy::too_many_arguments)]
1617async fn run_multiround(
1618	num_proofs: usize,
1619	rounds: usize,
1620	amount: u128,
1621	wallet_name: String,
1622	password: Option<String>,
1623	password_file: Option<String>,
1624	keep_files: bool,
1625	output_dir: String,
1626	dry_run: bool,
1627	node_url: &str,
1628) -> crate::error::Result<()> {
1629	use colored::Colorize;
1630
1631	log_print!("");
1632	log_print!("==================================================");
1633	log_print!("  Wormhole Multi-Round Flow Test");
1634	log_print!("==================================================");
1635	log_print!("");
1636
1637	// Load aggregation config from generated-bins/config.json
1638	let bins_dir = Path::new("generated-bins");
1639	let agg_config = CircuitBinsConfig::load(bins_dir).map_err(|e| {
1640		crate::error::QuantusError::Generic(format!("Failed to load aggregation config: {}", e))
1641	})?;
1642
1643	// Validate parameters
1644	validate_multiround_params(num_proofs, rounds, agg_config.num_leaf_proofs)?;
1645
1646	// Load wallet
1647	let wallet = load_multiround_wallet(&wallet_name, password, password_file)?;
1648
1649	// Create config struct
1650	let config =
1651		MultiroundConfig { num_proofs, rounds, amount, output_dir: output_dir.clone(), keep_files };
1652
1653	// Print configuration
1654	print_multiround_config(&config, &wallet, agg_config.num_leaf_proofs);
1655	log_print!("  Dry run: {}", dry_run);
1656	log_print!("");
1657
1658	// Create output directory
1659	std::fs::create_dir_all(&output_dir).map_err(|e| {
1660		crate::error::QuantusError::Generic(format!("Failed to create output directory: {}", e))
1661	})?;
1662
1663	if dry_run {
1664		return run_multiround_dry_run(
1665			&wallet.mnemonic,
1666			num_proofs,
1667			rounds,
1668			amount,
1669			&wallet.wallet_address,
1670		);
1671	}
1672
1673	// Connect to node
1674	let quantus_client = QuantusClient::new(node_url).await.map_err(|e| {
1675		crate::error::QuantusError::Generic(format!("Failed to connect to node: {}", e))
1676	})?;
1677	let client = quantus_client.client();
1678
1679	// Get minting account from chain
1680	let minting_account = get_minting_account(client).await?;
1681	log_verbose!("Minting account: {:?}", minting_account);
1682
1683	// Record initial wallet balance for verification
1684	let initial_balance = get_balance(&quantus_client, &wallet.wallet_address).await?;
1685	log_print!("{}", "Initial Balance:".bright_cyan());
1686	log_print!("  Wallet balance: {} ({})", initial_balance, format_balance(initial_balance));
1687	log_print!("");
1688
1689	// Track transfer info for the current round
1690	let mut current_transfers: Vec<TransferInfo> = Vec::new();
1691
1692	for round in 1..=rounds {
1693		let is_final = round == rounds;
1694
1695		log_print!("");
1696		log_print!("--------------------------------------------------");
1697		log_print!(
1698			"  {} Round {} of {} {}",
1699			">>>".bright_blue(),
1700			round,
1701			rounds,
1702			"<<<".bright_blue()
1703		);
1704		log_print!("--------------------------------------------------");
1705		log_print!("");
1706
1707		// Create round output directory
1708		let round_dir = format!("{}/round{}", output_dir, round);
1709		std::fs::create_dir_all(&round_dir).map_err(|e| {
1710			crate::error::QuantusError::Generic(format!("Failed to create round directory: {}", e))
1711		})?;
1712
1713		// Derive secrets for this round
1714		let secrets = derive_round_secrets(&wallet.mnemonic, round, num_proofs)?;
1715
1716		// Determine exit accounts
1717		let exit_accounts: Vec<SubxtAccountId> = if is_final {
1718			log_print!("Final round - all proofs exit to wallet: {}", wallet.wallet_address);
1719			vec![wallet.wallet_account_id.clone(); num_proofs]
1720		} else {
1721			log_print!(
1722				"Intermediate round - proofs exit to round {} wormhole addresses",
1723				round + 1
1724			);
1725			let mut addrs = Vec::new();
1726			for i in 1..=num_proofs {
1727				let next_secret = derive_wormhole_secret(&wallet.mnemonic, round + 1, i)?;
1728				addrs.push(SubxtAccountId(next_secret.address));
1729			}
1730			addrs
1731		};
1732
1733		// Step 1: Get transfer info (execute transfers for round 1, reuse from previous round
1734		// otherwise)
1735		if round == 1 {
1736			current_transfers =
1737				execute_initial_transfers(&quantus_client, &wallet, &secrets, amount, num_proofs)
1738					.await?;
1739
1740			// Log balance immediately after funding transfers
1741			let balance_after_funding =
1742				get_balance(&quantus_client, &wallet.wallet_address).await?;
1743			let funding_deducted = initial_balance.saturating_sub(balance_after_funding);
1744			log_print!(
1745				"  Balance after funding: {} ({}) [deducted: {} planck]",
1746				balance_after_funding,
1747				format_balance(balance_after_funding),
1748				funding_deducted
1749			);
1750		} else {
1751			log_print!("{}", "Step 1: Using transfer info from previous round...".bright_yellow());
1752			log_print!("  Found {} transfer(s) from previous round", current_transfers.len());
1753		}
1754
1755		// Step 2: Generate proofs with random output partitioning
1756		let proof_files = generate_round_proofs(
1757			&quantus_client,
1758			&secrets,
1759			&current_transfers,
1760			&exit_accounts,
1761			&round_dir,
1762			num_proofs,
1763		)
1764		.await?;
1765
1766		// Step 3: Aggregate proofs
1767		log_print!("{}", "Step 3: Aggregating proofs...".bright_yellow());
1768
1769		let aggregated_file = format!("{}/aggregated.hex", round_dir);
1770		aggregate_proofs(proof_files, aggregated_file.clone()).await?;
1771
1772		log_print!("  Aggregated proof saved to {}", aggregated_file);
1773
1774		// Step 4: Verify aggregated proof on-chain
1775		log_print!("{}", "Step 4: Submitting aggregated proof on-chain...".bright_yellow());
1776
1777		let (verification_block, extrinsic_hash, transfer_events) =
1778			verify_aggregated_and_get_events(&aggregated_file, &quantus_client).await?;
1779
1780		log_print!(
1781			"  {} Proof verified in block {} (extrinsic: 0x{})",
1782			"✓".bright_green(),
1783			hex::encode(verification_block.0),
1784			hex::encode(extrinsic_hash.0)
1785		);
1786
1787		// If not final round, prepare transfer info for next round
1788		if !is_final {
1789			log_print!("{}", "Step 5: Capturing transfer info for next round...".bright_yellow());
1790
1791			// Parse events to get transfer info for next round's wormhole addresses
1792			let next_round_addresses: Vec<SubxtAccountId> = (1..=num_proofs)
1793				.map(|i| {
1794					let next_secret =
1795						derive_wormhole_secret(&wallet.mnemonic, round + 1, i).unwrap();
1796					SubxtAccountId(next_secret.address)
1797				})
1798				.collect();
1799
1800			current_transfers =
1801				parse_transfer_events(&transfer_events, &next_round_addresses, verification_block)?;
1802
1803			log_print!(
1804				"  Captured {} transfer(s) for round {}",
1805				current_transfers.len(),
1806				round + 1
1807			);
1808		}
1809
1810		// Log balance after this round
1811		let balance_after_round = get_balance(&quantus_client, &wallet.wallet_address).await?;
1812		let change_from_initial = balance_after_round as i128 - initial_balance as i128;
1813		let change_str = if change_from_initial >= 0 {
1814			format!("+{}", change_from_initial)
1815		} else {
1816			format!("{}", change_from_initial)
1817		};
1818		log_print!("");
1819		log_print!(
1820			"  Balance after round {}: {} ({}) [change: {} planck]",
1821			round,
1822			balance_after_round,
1823			format_balance(balance_after_round),
1824			change_str
1825		);
1826
1827		log_print!("");
1828		log_print!("  {} Round {} complete!", "✓".bright_green(), round);
1829	}
1830
1831	log_print!("");
1832	log_print!("==================================================");
1833	log_success!("  All {} rounds completed successfully!", rounds);
1834	log_print!("==================================================");
1835	log_print!("");
1836
1837	// Final balance verification
1838	let final_balance = get_balance(&quantus_client, &wallet.wallet_address).await?;
1839	verify_final_balance(initial_balance, final_balance, amount, rounds, num_proofs);
1840
1841	if keep_files {
1842		log_print!("Proof files preserved in: {}", output_dir);
1843	} else {
1844		log_print!("Cleaning up proof files...");
1845		std::fs::remove_dir_all(&output_dir).ok();
1846	}
1847
1848	Ok(())
1849}
1850
1851/// Generate a wormhole proof with dual outputs (used for random partitioning in multiround)
1852///
1853/// This function fetches the necessary data from the chain and delegates to
1854/// `wormhole_lib::generate_proof` for the actual proof generation.
1855async fn generate_proof(
1856	secret_hex: &str,
1857	funding_amount: u128,
1858	output_assignment: &ProofOutputAssignment,
1859	block_hash_str: &str,
1860	transfer_count: u64,
1861	funding_account_str: &str,
1862	output_file: &str,
1863	quantus_client: &QuantusClient,
1864) -> crate::error::Result<()> {
1865	// Parse inputs
1866	let secret = parse_secret_hex(secret_hex).map_err(crate::error::QuantusError::Generic)?;
1867	let funding_account_bytes =
1868		parse_exit_account(funding_account_str).map_err(crate::error::QuantusError::Generic)?;
1869
1870	let block_hash_bytes: [u8; 32] = hex::decode(block_hash_str.trim_start_matches("0x"))
1871		.map_err(|e| crate::error::QuantusError::Generic(format!("Invalid block hash: {}", e)))?
1872		.try_into()
1873		.map_err(|_| {
1874			crate::error::QuantusError::Generic("Block hash must be 32 bytes".to_string())
1875		})?;
1876
1877	// Compute wormhole address using wormhole_lib
1878	let wormhole_address = wormhole_lib::compute_wormhole_address(&secret)
1879		.map_err(|e| crate::error::QuantusError::Generic(e.message))?;
1880
1881	// Compute storage key using wormhole_lib
1882	let storage_key = wormhole_lib::compute_storage_key(&wormhole_address, transfer_count);
1883
1884	// Fetch data from chain
1885	let block_hash = subxt::utils::H256::from(block_hash_bytes);
1886	let client = quantus_client.client();
1887
1888	// Get block
1889	let blocks =
1890		client.blocks().at(block_hash).await.map_err(|e| {
1891			crate::error::QuantusError::Generic(format!("Failed to get block: {}", e))
1892		})?;
1893
1894	// Verify storage key exists
1895	let storage_api = client.storage().at(block_hash);
1896	let val = storage_api
1897		.fetch_raw(storage_key.clone())
1898		.await
1899		.map_err(|e| crate::error::QuantusError::Generic(e.to_string()))?;
1900	if val.is_none() {
1901		return Err(crate::error::QuantusError::Generic(
1902			"Storage key not found - transfer may not exist in this block".to_string(),
1903		));
1904	}
1905
1906	// Get storage proof from chain
1907	let proof_params = rpc_params![vec![to_hex(&storage_key)], block_hash];
1908	let read_proof: ReadProof<sp_core::H256> = quantus_client
1909		.rpc_client()
1910		.request("state_getReadProof", proof_params)
1911		.await
1912		.map_err(|e| crate::error::QuantusError::Generic(e.to_string()))?;
1913
1914	// Extract header data
1915	let header = blocks.header();
1916	let parent_hash: [u8; 32] = header.parent_hash.0;
1917	let state_root: [u8; 32] = header.state_root.0;
1918	let extrinsics_root: [u8; 32] = header.extrinsics_root.0;
1919	let digest = header.digest.encode();
1920	let block_number = header.number;
1921
1922	// Convert proof nodes
1923	let proof_nodes: Vec<Vec<u8>> = read_proof.proof.iter().map(|p| p.0.clone()).collect();
1924
1925	// Build ProofGenerationInput using wormhole_lib types
1926	let input = wormhole_lib::ProofGenerationInput {
1927		secret,
1928		transfer_count,
1929		funding_account: funding_account_bytes,
1930		wormhole_address,
1931		funding_amount,
1932		block_hash: block_hash_bytes,
1933		block_number,
1934		parent_hash,
1935		state_root,
1936		extrinsics_root,
1937		digest,
1938		proof_nodes,
1939		exit_account_1: output_assignment.exit_account_1,
1940		exit_account_2: output_assignment.exit_account_2,
1941		output_amount_1: output_assignment.output_amount_1,
1942		output_amount_2: output_assignment.output_amount_2,
1943		volume_fee_bps: VOLUME_FEE_BPS,
1944		asset_id: NATIVE_ASSET_ID,
1945	};
1946
1947	// Generate proof using wormhole_lib
1948	let bins_dir = Path::new("generated-bins");
1949	let result = wormhole_lib::generate_proof(
1950		&input,
1951		&bins_dir.join("prover.bin"),
1952		&bins_dir.join("common.bin"),
1953	)
1954	.map_err(|e| crate::error::QuantusError::Generic(e.message))?;
1955
1956	// Write proof to file
1957	let proof_hex = hex::encode(result.proof_bytes);
1958	std::fs::write(output_file, proof_hex).map_err(|e| {
1959		crate::error::QuantusError::Generic(format!("Failed to write proof: {}", e))
1960	})?;
1961
1962	Ok(())
1963}
1964
1965/// Verify an aggregated proof and return the block hash, extrinsic hash, and transfer events
1966async fn verify_aggregated_and_get_events(
1967	proof_file: &str,
1968	quantus_client: &QuantusClient,
1969) -> crate::error::Result<(
1970	subxt::utils::H256,
1971	subxt::utils::H256,
1972	Vec<wormhole::events::NativeTransferred>,
1973)> {
1974	use qp_wormhole_verifier::WormholeVerifier;
1975
1976	let proof_bytes = read_hex_proof_file_to_bytes(proof_file)?;
1977
1978	// Verify locally before submitting on-chain
1979	log_verbose!("Verifying aggregated proof locally before on-chain submission...");
1980	let bins_dir = Path::new("generated-bins");
1981	let verifier = WormholeVerifier::new_from_files(
1982		&bins_dir.join("aggregated_verifier.bin"),
1983		&bins_dir.join("aggregated_common.bin"),
1984	)
1985	.map_err(|e| {
1986		crate::error::QuantusError::Generic(format!("Failed to load aggregated verifier: {}", e))
1987	})?;
1988
1989	let proof = qp_wormhole_verifier::ProofWithPublicInputs::<
1990		qp_wormhole_verifier::F,
1991		qp_wormhole_verifier::C,
1992		{ qp_wormhole_verifier::D },
1993	>::from_bytes(proof_bytes.clone(), &verifier.circuit_data.common)
1994	.map_err(|e| {
1995		crate::error::QuantusError::Generic(format!(
1996			"Failed to deserialize aggregated proof: {}",
1997			e
1998		))
1999	})?;
2000
2001	verifier.verify(proof).map_err(|e| {
2002		crate::error::QuantusError::Generic(format!(
2003			"Local aggregated proof verification failed: {}",
2004			e
2005		))
2006	})?;
2007	log_verbose!("Local verification passed!");
2008
2009	// Submit unsigned tx + wait for inclusion (best or finalized)
2010	let (included_at, block_hash, tx_hash) =
2011		submit_unsigned_verify_aggregated_proof(quantus_client, proof_bytes).await?;
2012
2013	log_verbose!(
2014		"Submitted tx included in {}: block={:?}, tx={:?}",
2015		included_at.label(),
2016		block_hash,
2017		tx_hash
2018	);
2019
2020	// Collect events for our extrinsic only
2021	let (found_proof_verified, transfer_events) =
2022		collect_wormhole_events_for_extrinsic(quantus_client, block_hash, tx_hash).await?;
2023
2024	if !found_proof_verified {
2025		return Err(crate::error::QuantusError::Generic(
2026			"Proof verification failed - no ProofVerified event".to_string(),
2027		));
2028	}
2029
2030	// Log minted amounts
2031	log_print!("  Tokens minted (from NativeTransferred events):");
2032	for (idx, transfer) in transfer_events.iter().enumerate() {
2033		let to_hex = hex::encode(transfer.to.0);
2034		log_print!(
2035			"    [{}] {} -> {} planck ({})",
2036			idx,
2037			to_hex,
2038			transfer.amount,
2039			format_balance(transfer.amount)
2040		);
2041	}
2042
2043	Ok((block_hash, tx_hash, transfer_events))
2044}
2045
2046/// Dry run - show what would happen without executing
2047fn run_multiround_dry_run(
2048	mnemonic: &str,
2049	num_proofs: usize,
2050	rounds: usize,
2051	amount: u128,
2052	wallet_address: &str,
2053) -> crate::error::Result<()> {
2054	use colored::Colorize;
2055
2056	log_print!("");
2057	log_print!("{}", "=== DRY RUN MODE ===".bright_yellow());
2058	log_print!("No transactions will be executed.");
2059	log_print!("");
2060
2061	for round in 1..=rounds {
2062		let is_final = round == rounds;
2063		let round_amount = calculate_round_amount(amount, round);
2064
2065		log_print!("");
2066		log_print!("{}", format!("Round {}", round).bright_cyan());
2067		log_print!("  Total amount: {} ({})", round_amount, format_balance(round_amount));
2068
2069		// Show sample random partition for round 1
2070		if round == 1 {
2071			let partition = random_partition(amount, num_proofs, 3 * SCALE_DOWN_FACTOR);
2072			log_print!("  Sample random partition (actual partition will differ):");
2073			for (i, &amt) in partition.iter().enumerate() {
2074				log_print!("    Proof {}: {} ({})", i + 1, amt, format_balance(amt));
2075			}
2076		}
2077		log_print!("");
2078
2079		log_print!("  Wormhole addresses (to be funded):");
2080		for i in 1..=num_proofs {
2081			let secret = derive_wormhole_secret(mnemonic, round, i)?;
2082			let address = sp_core::crypto::AccountId32::new(secret.address)
2083				.to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(189));
2084			log_print!("    [{}] {}", i, address);
2085			log_verbose!("        secret: 0x{}", hex::encode(secret.secret));
2086		}
2087
2088		log_print!("");
2089		log_print!("  Exit accounts:");
2090		if is_final {
2091			log_print!("    All proofs exit to wallet: {}", wallet_address);
2092		} else {
2093			for i in 1..=num_proofs {
2094				let next_secret = derive_wormhole_secret(mnemonic, round + 1, i)?;
2095				let address = sp_core::crypto::AccountId32::new(next_secret.address)
2096					.to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(189));
2097				log_print!("    [{}] {} (round {} wormhole)", i, address, round + 1);
2098			}
2099		}
2100	}
2101
2102	log_print!("");
2103	log_print!("{}", "=== END DRY RUN ===".bright_yellow());
2104	log_print!("");
2105
2106	Ok(())
2107}
2108
2109/// Parse and display the contents of a proof file for debugging
2110async fn parse_proof_file(
2111	proof_file: String,
2112	aggregated: bool,
2113	verify: bool,
2114) -> crate::error::Result<()> {
2115	use qp_wormhole_verifier::WormholeVerifier;
2116
2117	log_print!("Parsing proof file: {}", proof_file);
2118
2119	// Read proof bytes
2120	let proof_bytes = read_proof_file(&proof_file)
2121		.map_err(|e| crate::error::QuantusError::Generic(format!("Failed to read proof: {}", e)))?;
2122
2123	log_print!("Proof size: {} bytes", proof_bytes.len());
2124
2125	let bins_dir = Path::new("generated-bins");
2126
2127	if aggregated {
2128		// Load aggregated verifier
2129		let verifier = WormholeVerifier::new_from_files(
2130			&bins_dir.join("aggregated_verifier.bin"),
2131			&bins_dir.join("aggregated_common.bin"),
2132		)
2133		.map_err(|e| {
2134			crate::error::QuantusError::Generic(format!("Failed to load verifier: {}", e))
2135		})?;
2136
2137		// Deserialize proof using verifier's types
2138		let proof = qp_wormhole_verifier::ProofWithPublicInputs::<
2139			qp_wormhole_verifier::F,
2140			qp_wormhole_verifier::C,
2141			{ qp_wormhole_verifier::D },
2142		>::from_bytes(proof_bytes.clone(), &verifier.circuit_data.common)
2143		.map_err(|e| {
2144			crate::error::QuantusError::Generic(format!(
2145				"Failed to deserialize aggregated proof: {:?}",
2146				e
2147			))
2148		})?;
2149
2150		log_print!("\nPublic inputs count: {}", proof.public_inputs.len());
2151		log_verbose!("\nPublic inputs count: {}", proof.public_inputs.len());
2152
2153		// Try to parse as aggregated
2154		match qp_wormhole_verifier::parse_aggregated_public_inputs(&proof) {
2155			Ok(agg_inputs) => {
2156				log_print!("\n=== Parsed Aggregated Public Inputs ===");
2157				log_print!("Asset ID: {}", agg_inputs.asset_id);
2158				log_print!("Volume Fee BPS: {}", agg_inputs.volume_fee_bps);
2159				log_print!(
2160					"Block Hash: 0x{}",
2161					hex::encode(agg_inputs.block_data.block_hash.as_ref())
2162				);
2163				log_print!("Block Number: {}", agg_inputs.block_data.block_number);
2164				log_print!("\nAccount Data ({} accounts):", agg_inputs.account_data.len());
2165				for (i, acct) in agg_inputs.account_data.iter().enumerate() {
2166					log_print!(
2167						"  [{}] amount={}, exit=0x{}",
2168						i,
2169						acct.summed_output_amount,
2170						hex::encode(acct.exit_account.as_ref())
2171					);
2172				}
2173				log_print!("\nNullifiers ({} nullifiers):", agg_inputs.nullifiers.len());
2174				for (i, nullifier) in agg_inputs.nullifiers.iter().enumerate() {
2175					log_print!("  [{}] 0x{}", i, hex::encode(nullifier.as_ref()));
2176				}
2177			},
2178			Err(e) => {
2179				log_print!("Failed to parse as aggregated inputs: {}", e);
2180			},
2181		}
2182
2183		// Verify if requested
2184		if verify {
2185			log_print!("\n=== Verifying Proof ===");
2186			match verifier.verify(proof) {
2187				Ok(()) => {
2188					log_success!("Proof verification PASSED");
2189				},
2190				Err(e) => {
2191					log_error!("Proof verification FAILED: {}", e);
2192					return Err(crate::error::QuantusError::Generic(format!(
2193						"Proof verification failed: {}",
2194						e
2195					)));
2196				},
2197			}
2198		}
2199	} else {
2200		// Load leaf verifier
2201		let verifier = WormholeVerifier::new_from_files(
2202			&bins_dir.join("verifier.bin"),
2203			&bins_dir.join("common.bin"),
2204		)
2205		.map_err(|e| {
2206			crate::error::QuantusError::Generic(format!("Failed to load verifier: {}", e))
2207		})?;
2208
2209		// Deserialize proof using verifier's types
2210		let proof = qp_wormhole_verifier::ProofWithPublicInputs::<
2211			qp_wormhole_verifier::F,
2212			qp_wormhole_verifier::C,
2213			{ qp_wormhole_verifier::D },
2214		>::from_bytes(proof_bytes, &verifier.circuit_data.common)
2215		.map_err(|e| {
2216			crate::error::QuantusError::Generic(format!("Failed to deserialize proof: {:?}", e))
2217		})?;
2218
2219		log_print!("\nPublic inputs count: {}", proof.public_inputs.len());
2220
2221		let pi = qp_wormhole_verifier::parse_public_inputs(&proof).map_err(|e| {
2222			crate::error::QuantusError::Generic(format!("Failed to parse public inputs: {}", e))
2223		})?;
2224
2225		log_print!("\n=== Parsed Leaf Public Inputs ===");
2226		log_print!("Asset ID: {}", pi.asset_id);
2227		log_print!("Output Amount 1: {}", pi.output_amount_1);
2228		log_print!("Output Amount 2: {}", pi.output_amount_2);
2229		log_print!("Volume Fee BPS: {}", pi.volume_fee_bps);
2230		log_print!("Nullifier: 0x{}", hex::encode(pi.nullifier.as_ref()));
2231		log_print!("Exit Account 1: 0x{}", hex::encode(pi.exit_account_1.as_ref()));
2232		log_print!("Exit Account 2: 0x{}", hex::encode(pi.exit_account_2.as_ref()));
2233		log_print!("Block Hash: 0x{}", hex::encode(pi.block_hash.as_ref()));
2234		log_print!("Block Number: {}", pi.block_number);
2235
2236		// Verify if requested
2237		if verify {
2238			log_print!("\n=== Verifying Proof ===");
2239			match verifier.verify(proof) {
2240				Ok(()) => {
2241					log_success!("Proof verification PASSED");
2242				},
2243				Err(e) => {
2244					log_error!("Proof verification FAILED: {}", e);
2245					return Err(crate::error::QuantusError::Generic(format!(
2246						"Proof verification failed: {}",
2247						e
2248					)));
2249				},
2250			}
2251		}
2252	}
2253
2254	Ok(())
2255}
2256
2257/// A pending wormhole output that can be used as input for the next dissolve layer.
2258#[derive(Debug, Clone)]
2259struct DissolveOutput {
2260	/// The secret used to derive the wormhole address
2261	secret: [u8; 32],
2262	/// Amount in planck
2263	amount: u128,
2264	/// Transfer count from the NativeTransferred event
2265	transfer_count: u64,
2266	/// Funding account (sender)
2267	funding_account: SubxtAccountId,
2268	/// Block hash where the transfer was recorded (needed for storage proof)
2269	proof_block_hash: subxt::utils::H256,
2270}
2271
2272/// Dissolve a large wormhole deposit into many small outputs for better privacy.
2273///
2274/// Creates a tree of wormhole transactions where each layer doubles the number of outputs
2275/// by splitting each input into 2 via the dual-output proof mechanism.
2276///
2277/// ```text
2278/// Layer 0: 1 input  → 2 outputs
2279/// Layer 1: 2 inputs → 4 outputs
2280/// Layer 2: 4 inputs → 8 outputs
2281/// ...
2282/// Layer N: 2^(N-1) inputs → 2^N outputs (all below target_size)
2283/// ```
2284///
2285/// Each layer: batch inputs into groups of ≤16, generate proofs, aggregate, verify on-chain.
2286/// The final outputs are small enough to blend with the miner reward noise floor.
2287#[allow(clippy::too_many_arguments)]
2288async fn run_dissolve(
2289	amount: u128,
2290	target_size: u128,
2291	wallet_name: String,
2292	password: Option<String>,
2293	password_file: Option<String>,
2294	keep_files: bool,
2295	output_dir: String,
2296	node_url: &str,
2297) -> crate::error::Result<()> {
2298	use colored::Colorize;
2299
2300	log_print!("");
2301	log_print!("==================================================");
2302	log_print!("  Wormhole Dissolve");
2303	log_print!("==================================================");
2304	log_print!("");
2305
2306	// Calculate number of layers needed
2307	let mut num_outputs = 1u128;
2308	let mut layers = 0usize;
2309	while amount / num_outputs > target_size {
2310		num_outputs *= 2;
2311		layers += 1;
2312	}
2313	let final_output_count = num_outputs as usize;
2314
2315	log_print!("  Amount: {} ({})", amount, format_balance(amount));
2316	log_print!("  Target size: {} ({})", target_size, format_balance(target_size));
2317	log_print!("  Layers: {}", layers);
2318	log_print!("  Final outputs: {}", final_output_count);
2319	log_print!(
2320		"  Approximate output size: {} ({})",
2321		amount / num_outputs,
2322		format_balance(amount / num_outputs)
2323	);
2324	log_print!("");
2325
2326	// Load wallet (reuse multiround wallet loader for HD derivation)
2327	let wallet = load_multiround_wallet(&wallet_name, password, password_file)?;
2328	let funding_account = wallet.wallet_account_id.clone();
2329
2330	// Connect to node
2331	let quantus_client = QuantusClient::new(node_url)
2332		.await
2333		.map_err(|e| crate::error::QuantusError::Generic(format!("Failed to connect: {}", e)))?;
2334
2335	// Create output directory
2336	std::fs::create_dir_all(&output_dir).map_err(|e| {
2337		crate::error::QuantusError::Generic(format!("Failed to create output directory: {}", e))
2338	})?;
2339
2340	// Load aggregation config
2341	let bins_dir = std::path::Path::new("generated-bins");
2342	let agg_config = CircuitBinsConfig::load(bins_dir).map_err(|e| {
2343		crate::error::QuantusError::Generic(format!(
2344			"Failed to load aggregation circuit config: {}",
2345			e
2346		))
2347	})?;
2348
2349	// === Layer 0: Initial funding ===
2350	log_print!("{}", "Layer 0: Initial funding".bright_yellow());
2351
2352	let initial_secret = derive_wormhole_secret(&wallet.mnemonic, 0, 1)?;
2353	let wormhole_address = SubxtAccountId(initial_secret.address);
2354
2355	// Transfer to the wormhole address
2356	let transfer_tx = quantus_node::api::tx().balances().transfer_allow_death(
2357		subxt::ext::subxt_core::utils::MultiAddress::Id(wormhole_address.clone()),
2358		amount,
2359	);
2360
2361	let quantum_keypair = QuantumKeyPair {
2362		public_key: wallet.keypair.public_key.clone(),
2363		private_key: wallet.keypair.private_key.clone(),
2364	};
2365
2366	submit_transaction(
2367		&quantus_client,
2368		&quantum_keypair,
2369		transfer_tx,
2370		None,
2371		ExecutionMode { finalized: false, wait_for_transaction: true },
2372	)
2373	.await
2374	.map_err(|e| crate::error::QuantusError::Generic(format!("Initial transfer failed: {}", e)))?;
2375
2376	// Get block and event
2377	let block = at_best_block(&quantus_client)
2378		.await
2379		.map_err(|e| crate::error::QuantusError::Generic(format!("Failed to get block: {}", e)))?;
2380	let block_hash = block.hash();
2381	let events_api =
2382		quantus_client.client().events().at(block_hash).await.map_err(|e| {
2383			crate::error::QuantusError::Generic(format!("Failed to get events: {}", e))
2384		})?;
2385	let event = events_api
2386		.find::<wormhole::events::NativeTransferred>()
2387		.find(|e| if let Ok(evt) = e { evt.to.0 == initial_secret.address } else { false })
2388		.ok_or_else(|| crate::error::QuantusError::Generic("No transfer event found".to_string()))?
2389		.map_err(|e| crate::error::QuantusError::Generic(format!("Event decode error: {}", e)))?;
2390
2391	let mut current_outputs = vec![DissolveOutput {
2392		secret: initial_secret.secret,
2393		amount,
2394		transfer_count: event.transfer_count,
2395		funding_account: funding_account.clone(),
2396		proof_block_hash: block_hash,
2397	}];
2398
2399	log_success!("  Funded 1 wormhole address with {}", format_balance(amount));
2400
2401	// === Layers 1..N: Split outputs ===
2402	for layer in 1..=layers {
2403		let num_inputs = current_outputs.len();
2404		let layer_dir = format!("{}/layer{}", output_dir, layer);
2405		std::fs::create_dir_all(&layer_dir).map_err(|e| {
2406			crate::error::QuantusError::Generic(format!("Failed to create layer directory: {}", e))
2407		})?;
2408
2409		log_print!("");
2410		log_print!(
2411			"{} Layer {}/{}: {} inputs → {} outputs {}",
2412			">>>".bright_blue(),
2413			layer,
2414			layers,
2415			num_inputs,
2416			num_inputs * 2,
2417			"<<<".bright_blue()
2418		);
2419
2420		// Derive secrets for this layer's outputs (2 per input)
2421		let mut next_secrets: Vec<WormholePair> = Vec::new();
2422		for i in 0..(num_inputs * 2) {
2423			next_secrets.push(derive_wormhole_secret(&wallet.mnemonic, layer, i + 1)?);
2424		}
2425
2426		// Process inputs in batches of ≤16 (aggregation batch size)
2427		let batch_size = agg_config.num_leaf_proofs;
2428		let mut all_next_outputs: Vec<DissolveOutput> = Vec::new();
2429		let num_batches = num_inputs.div_ceil(batch_size);
2430
2431		for batch_idx in 0..num_batches {
2432			let batch_start = batch_idx * batch_size;
2433			let batch_end = (batch_start + batch_size).min(num_inputs);
2434			let batch_inputs = &current_outputs[batch_start..batch_end];
2435			let batch_num_proofs = batch_inputs.len();
2436
2437			log_print!("  Batch {}/{}: {} proofs", batch_idx + 1, num_batches, batch_num_proofs);
2438
2439			// Each input splits into 2 outputs (equal split)
2440			let mut proof_files = Vec::new();
2441			let proof_gen_start = std::time::Instant::now();
2442
2443			// All inputs in a batch must use the same block for proof generation.
2444			// Use the proof_block_hash from the first input (all inputs in a batch
2445			// were created in the same verification block from the previous layer).
2446			let batch_proof_block_hash = batch_inputs[0].proof_block_hash;
2447
2448			for (i, input) in batch_inputs.iter().enumerate() {
2449				let global_idx = batch_start + i;
2450				let exit_1_idx = global_idx * 2;
2451				let exit_2_idx = global_idx * 2 + 1;
2452
2453				let input_quantized = quantize_funding_amount(input.amount)
2454					.map_err(crate::error::QuantusError::Generic)?;
2455				let total_output = compute_output_amount(input_quantized, VOLUME_FEE_BPS);
2456				let output_1 = total_output / 2;
2457				let output_2 = total_output - output_1;
2458
2459				let assignment = ProofOutputAssignment {
2460					output_amount_1: output_1.max(1),
2461					exit_account_1: next_secrets[exit_1_idx].address,
2462					output_amount_2: output_2.max(1),
2463					exit_account_2: next_secrets[exit_2_idx].address,
2464				};
2465
2466				let proof_file = format!("{}/batch{}_proof{}.hex", layer_dir, batch_idx, i);
2467
2468				generate_proof(
2469					&hex::encode(input.secret),
2470					input.amount,
2471					&assignment,
2472					&format!("0x{}", hex::encode(batch_proof_block_hash.0)),
2473					input.transfer_count,
2474					&format!("0x{}", hex::encode(input.funding_account.0)),
2475					&proof_file,
2476					&quantus_client,
2477				)
2478				.await?;
2479
2480				proof_files.push(proof_file);
2481			}
2482
2483			let proof_gen_elapsed = proof_gen_start.elapsed();
2484			log_print!(
2485				"    Proof generation: {:.2}s ({} proofs)",
2486				proof_gen_elapsed.as_secs_f64(),
2487				batch_num_proofs
2488			);
2489
2490			// Aggregate
2491			log_print!("    Aggregating...");
2492			let aggregated_file = format!("{}/batch{}_aggregated.hex", layer_dir, batch_idx);
2493			aggregate_proofs_to_file(&proof_files, &aggregated_file)?;
2494
2495			// Verify on-chain
2496			log_print!("    Verifying on-chain...");
2497			let (verification_block, _extrinsic_hash, transfer_events) =
2498				verify_aggregated_and_get_events(&aggregated_file, &quantus_client).await?;
2499
2500			log_success!("    Verified in block 0x{}", hex::encode(verification_block.0));
2501
2502			// Collect next layer's outputs from the transfer events
2503			// Use the verification_block as the proof_block_hash for the next layer
2504			for (i, _input) in batch_inputs.iter().enumerate() {
2505				let global_idx = batch_start + i;
2506				let exit_1_idx = global_idx * 2;
2507				let exit_2_idx = global_idx * 2 + 1;
2508
2509				for (secret_idx, target_address) in [
2510					(exit_1_idx, &next_secrets[exit_1_idx]),
2511					(exit_2_idx, &next_secrets[exit_2_idx]),
2512				] {
2513					let event = transfer_events
2514						.iter()
2515						.find(|e| e.to.0 == target_address.address)
2516						.ok_or_else(|| {
2517						crate::error::QuantusError::Generic(format!(
2518							"No transfer event for output {} at layer {}",
2519							secret_idx, layer
2520						))
2521					})?;
2522
2523					all_next_outputs.push(DissolveOutput {
2524						secret: target_address.secret,
2525						amount: event.amount,
2526						transfer_count: event.transfer_count,
2527						funding_account: event.from.clone(),
2528						proof_block_hash: verification_block,
2529					});
2530				}
2531			}
2532		}
2533
2534		log_print!("  Layer {} complete: {} outputs", layer, all_next_outputs.len());
2535
2536		current_outputs = all_next_outputs;
2537	}
2538
2539	// Summary
2540	log_print!("");
2541	log_print!("==================================================");
2542	log_success!("  Dissolve complete!");
2543	log_print!("==================================================");
2544	log_print!("");
2545	log_print!("  Final outputs: {}", current_outputs.len());
2546	let min_output = current_outputs.iter().map(|o| o.amount).min().unwrap_or(0);
2547	let max_output = current_outputs.iter().map(|o| o.amount).max().unwrap_or(0);
2548	let total_output: u128 = current_outputs.iter().map(|o| o.amount).sum();
2549	log_print!(
2550		"  Output range: {} - {} ({})",
2551		format_balance(min_output),
2552		format_balance(max_output),
2553		format_balance(total_output)
2554	);
2555
2556	if keep_files {
2557		log_print!("  Proof files preserved in: {}", output_dir);
2558	} else {
2559		log_print!("  Cleaning up proof files...");
2560		std::fs::remove_dir_all(&output_dir).ok();
2561	}
2562
2563	Ok(())
2564}
2565
2566/// Helper to aggregate proof files and write the result
2567fn aggregate_proofs_to_file(proof_files: &[String], output_file: &str) -> crate::error::Result<()> {
2568	use qp_wormhole_aggregator::aggregator::Layer0Aggregator;
2569
2570	let bins_dir = std::path::Path::new("generated-bins");
2571	let mut aggregator = Layer0Aggregator::new(bins_dir).map_err(|e| {
2572		crate::error::QuantusError::Generic(format!("Failed to create aggregator: {}", e))
2573	})?;
2574
2575	let common_data = aggregator.load_common_data(CircuitType::Leaf).map_err(|e| {
2576		crate::error::QuantusError::Generic(format!("Failed to load common data: {}", e))
2577	})?;
2578
2579	for proof_file in proof_files {
2580		let proof_bytes = read_hex_proof_file_to_bytes(proof_file)?;
2581		let proof = ProofWithPublicInputs::<F, C, D>::from_bytes(proof_bytes, &common_data)
2582			.map_err(|e| {
2583				crate::error::QuantusError::Generic(format!(
2584					"Failed to deserialize proof from {}: {:?}",
2585					proof_file, e
2586				))
2587			})?;
2588		aggregator.push_proof(proof).map_err(|e| {
2589			crate::error::QuantusError::Generic(format!("Failed to push proof: {}", e))
2590		})?;
2591	}
2592
2593	let agg_start = std::time::Instant::now();
2594	let proof = aggregator
2595		.aggregate()
2596		.map_err(|e| crate::error::QuantusError::Generic(format!("Aggregation failed: {}", e)))?;
2597	let agg_elapsed = agg_start.elapsed();
2598	log_print!("    Aggregation: {:.2}s", agg_elapsed.as_secs_f64());
2599
2600	let proof_hex = hex::encode(proof.to_bytes());
2601	std::fs::write(output_file, &proof_hex).map_err(|e| {
2602		crate::error::QuantusError::Generic(format!("Failed to write proof: {}", e))
2603	})?;
2604
2605	Ok(())
2606}
2607
2608/// Goldilocks field order (2^64 - 2^32 + 1)
2609const GOLDILOCKS_ORDER: u64 = 0xFFFFFFFF00000001;
2610
2611/// Fuzz inputs: (amount, from, to, transfer_count, secret)
2612type FuzzInputs = (u128, [u8; 32], [u8; 32], u64, [u8; 32]);
2613
2614/// Represents a fuzz test case
2615struct FuzzCase {
2616	name: &'static str,
2617	description: &'static str,
2618	/// Whether this case should pass (true) or fail (false)
2619	/// Cases within quantization threshold should pass
2620	expect_pass: bool,
2621}
2622
2623/// Seeded random number generator for reproducible fuzz tests
2624struct SeededRng {
2625	state: u64,
2626}
2627
2628impl SeededRng {
2629	fn new(seed: u64) -> Self {
2630		Self { state: seed }
2631	}
2632
2633	fn next_u64(&mut self) -> u64 {
2634		// Simple xorshift64 PRNG
2635		self.state ^= self.state << 13;
2636		self.state ^= self.state >> 7;
2637		self.state ^= self.state << 17;
2638		self.state
2639	}
2640
2641	fn next_u128(&mut self) -> u128 {
2642		let high = self.next_u64() as u128;
2643		let low = self.next_u64() as u128;
2644		(high << 64) | low
2645	}
2646}
2647
2648/// Add a u64 value to the first 8-byte chunk of an address (little-endian)
2649fn add_to_address_chunk(addr: &mut [u8; 32], chunk_idx: usize, value: u64) {
2650	let start = chunk_idx * 8;
2651	let end = start + 8;
2652	let current = u64::from_le_bytes(addr[start..end].try_into().unwrap());
2653	let new_value = current.wrapping_add(value);
2654	addr[start..end].copy_from_slice(&new_value.to_le_bytes());
2655}
2656
2657/// Generate all fuzz cases with their fuzzed inputs
2658#[allow(clippy::vec_init_then_push)]
2659fn generate_fuzz_cases(
2660	amount: u128,
2661	from: [u8; 32],
2662	to: [u8; 32],
2663	count: u64,
2664	secret: [u8; 32],
2665	rng: &mut SeededRng,
2666) -> Vec<(FuzzCase, FuzzInputs)> {
2667	let random_u64 = rng.next_u64();
2668	let random_u128 = rng.next_u128();
2669	let mut random_addr = [0u8; 32];
2670	for chunk in random_addr.chunks_mut(8) {
2671		chunk.copy_from_slice(&rng.next_u64().to_le_bytes());
2672	}
2673	let mut random_secret = [0u8; 32];
2674	for chunk in random_secret.chunks_mut(8) {
2675		chunk.copy_from_slice(&rng.next_u64().to_le_bytes());
2676	}
2677
2678	let mut cases = Vec::new();
2679
2680	// ============================================================
2681	// AMOUNT FUZZING
2682	// ============================================================
2683
2684	// Check if amount is at a quantization boundary
2685	let amount_quantized = amount / SCALE_DOWN_FACTOR;
2686	let amount_plus_one_quantized = (amount + 1) / SCALE_DOWN_FACTOR;
2687	let amount_minus_one_quantized = amount.saturating_sub(1) / SCALE_DOWN_FACTOR;
2688
2689	// Amount + 1 planck - passes only if it stays in the same quantization bucket
2690	let plus_one_same_bucket = amount_quantized == amount_plus_one_quantized;
2691	cases.push((
2692		FuzzCase {
2693			name: "amount_plus_one_planck",
2694			description: "Amount + 1 planck",
2695			expect_pass: plus_one_same_bucket,
2696		},
2697		(amount + 1, from, to, count, secret),
2698	));
2699
2700	// Amount - 1 planck - passes only if it stays in the same quantization bucket
2701	let minus_one_same_bucket = amount_quantized == amount_minus_one_quantized;
2702	cases.push((
2703		FuzzCase {
2704			name: "amount_minus_one_planck",
2705			description: "Amount - 1 planck",
2706			expect_pass: minus_one_same_bucket,
2707		},
2708		(amount.saturating_sub(1), from, to, count, secret),
2709	));
2710
2711	// Amount + SCALE_DOWN_FACTOR (one quantized unit, should FAIL)
2712	cases.push((
2713		FuzzCase {
2714			name: "amount_plus_one_quant_unit",
2715			description: "Amount + 1 quantized unit",
2716			expect_pass: false,
2717		},
2718		(amount + SCALE_DOWN_FACTOR, from, to, count, secret),
2719	));
2720
2721	// Amount - SCALE_DOWN_FACTOR (one quantized unit, should FAIL)
2722	cases.push((
2723		FuzzCase {
2724			name: "amount_minus_one_quant_unit",
2725			description: "Amount - 1 quantized unit",
2726			expect_pass: false,
2727		},
2728		(amount.saturating_sub(SCALE_DOWN_FACTOR), from, to, count, secret),
2729	));
2730
2731	// Amount * 2 (should FAIL)
2732	cases.push((
2733		FuzzCase { name: "amount_doubled", description: "Amount * 2", expect_pass: false },
2734		(amount * 2, from, to, count, secret),
2735	));
2736
2737	// Amount = 0 (should FAIL)
2738	cases.push((
2739		FuzzCase { name: "amount_zero", description: "Amount = 0", expect_pass: false },
2740		(0, from, to, count, secret),
2741	));
2742
2743	// Amount + random large value (should FAIL)
2744	cases.push((
2745		FuzzCase {
2746			name: "amount_plus_random",
2747			description: "Amount + random u128",
2748			expect_pass: false,
2749		},
2750		(amount.wrapping_add(random_u128), from, to, count, secret),
2751	));
2752
2753	// Amount + Goldilocks order (overflow attack, should FAIL)
2754	cases.push((
2755		FuzzCase {
2756			name: "amount_plus_goldilocks",
2757			description: "Amount + Goldilocks field order",
2758			expect_pass: false,
2759		},
2760		(amount.wrapping_add(GOLDILOCKS_ORDER as u128), from, to, count, secret),
2761	));
2762
2763	// ============================================================
2764	// TRANSFER COUNT FUZZING
2765	// ============================================================
2766
2767	// Count + 1 (should FAIL)
2768	cases.push((
2769		FuzzCase { name: "count_plus_one", description: "Transfer count + 1", expect_pass: false },
2770		(amount, from, to, count.wrapping_add(1), secret),
2771	));
2772
2773	// Count - 1 with wrapping (0 -> u64::MAX, should FAIL)
2774	cases.push((
2775		FuzzCase {
2776			name: "count_minus_one_wrap",
2777			description: "Transfer count - 1 (wrapping)",
2778			expect_pass: false,
2779		},
2780		(amount, from, to, count.wrapping_sub(1), secret),
2781	));
2782
2783	// Count = u64::MAX (should FAIL)
2784	cases.push((
2785		FuzzCase {
2786			name: "count_max",
2787			description: "Transfer count = u64::MAX",
2788			expect_pass: false,
2789		},
2790		(amount, from, to, u64::MAX, secret),
2791	));
2792
2793	// Count + random (should FAIL)
2794	cases.push((
2795		FuzzCase {
2796			name: "count_plus_random",
2797			description: "Transfer count + random u64",
2798			expect_pass: false,
2799		},
2800		(amount, from, to, count.wrapping_add(random_u64), secret),
2801	));
2802
2803	// Count + Goldilocks order (overflow attack, should FAIL)
2804	cases.push((
2805		FuzzCase {
2806			name: "count_plus_goldilocks",
2807			description: "Transfer count + Goldilocks field order",
2808			expect_pass: false,
2809		},
2810		(amount, from, to, count.wrapping_add(GOLDILOCKS_ORDER), secret),
2811	));
2812
2813	// ============================================================
2814	// FROM ADDRESS FUZZING
2815	// ============================================================
2816
2817	// From: single bit flip (should FAIL)
2818	let mut from_bit_flip = from;
2819	from_bit_flip[0] ^= 0x01;
2820	cases.push((
2821		FuzzCase {
2822			name: "from_single_bit_flip",
2823			description: "From address single bit flip",
2824			expect_pass: false,
2825		},
2826		(amount, from_bit_flip, to, count, secret),
2827	));
2828
2829	// From: zeroed (should FAIL)
2830	cases.push((
2831		FuzzCase { name: "from_zeroed", description: "From address zeroed", expect_pass: false },
2832		(amount, [0u8; 32], to, count, secret),
2833	));
2834
2835	// From: add 1 to first chunk (should FAIL)
2836	let mut from_plus_one = from;
2837	add_to_address_chunk(&mut from_plus_one, 0, 1);
2838	cases.push((
2839		FuzzCase {
2840			name: "from_plus_one",
2841			description: "From address + 1 (first chunk)",
2842			expect_pass: false,
2843		},
2844		(amount, from_plus_one, to, count, secret),
2845	));
2846
2847	// From: random address (should FAIL)
2848	cases.push((
2849		FuzzCase { name: "from_random", description: "From address random", expect_pass: false },
2850		(amount, random_addr, to, count, secret),
2851	));
2852
2853	// From: add Goldilocks order to each chunk (should FAIL)
2854	for chunk_idx in 0..4 {
2855		let mut from_goldilocks = from;
2856		add_to_address_chunk(&mut from_goldilocks, chunk_idx, GOLDILOCKS_ORDER);
2857		cases.push((
2858			FuzzCase {
2859				name: match chunk_idx {
2860					0 => "from_goldilocks_chunk0",
2861					1 => "from_goldilocks_chunk1",
2862					2 => "from_goldilocks_chunk2",
2863					_ => "from_goldilocks_chunk3",
2864				},
2865				description: match chunk_idx {
2866					0 => "From + Goldilocks order (chunk 0)",
2867					1 => "From + Goldilocks order (chunk 1)",
2868					2 => "From + Goldilocks order (chunk 2)",
2869					_ => "From + Goldilocks order (chunk 3)",
2870				},
2871				expect_pass: false,
2872			},
2873			(amount, from_goldilocks, to, count, secret),
2874		));
2875	}
2876
2877	// ============================================================
2878	// EXIT ACCOUNT FUZZING
2879	// ============================================================
2880	// NOTE: The exit_account is a PUBLIC INPUT that specifies where funds should go
2881	// after proof verification. It is intentionally NOT validated against the storage
2882	// proof's leaf hash. The security comes from:
2883	// 1. The nullifier prevents double-spending
2884	// 2. Only someone with the `secret` can create a valid proof
2885	// 3. The exit_account being public means verifiers see where funds go
2886	//
2887	// The `to_account` IN THE LEAF HASH is the unspendable_account (derived from secret),
2888	// which IS validated via the circuit's connect_hashes constraint.
2889	//
2890	// These tests confirm that exit_account can be set to any value (as designed).
2891
2892	// Exit account: single bit flip (SHOULD PASS - exit_account is not validated)
2893	let mut exit_bit_flip = to;
2894	exit_bit_flip[0] ^= 0x01;
2895	cases.push((
2896		FuzzCase {
2897			name: "exit_single_bit_flip",
2898			description: "Exit account single bit flip (not validated)",
2899			expect_pass: true,
2900		},
2901		(amount, from, exit_bit_flip, count, secret),
2902	));
2903
2904	// Exit account: zeroed (SHOULD PASS - exit_account is not validated)
2905	cases.push((
2906		FuzzCase {
2907			name: "exit_zeroed",
2908			description: "Exit account zeroed (not validated)",
2909			expect_pass: true,
2910		},
2911		(amount, from, [0u8; 32], count, secret),
2912	));
2913
2914	// Exit account: add 1 to first chunk (SHOULD PASS - exit_account is not validated)
2915	let mut exit_plus_one = to;
2916	add_to_address_chunk(&mut exit_plus_one, 0, 1);
2917	cases.push((
2918		FuzzCase {
2919			name: "exit_plus_one",
2920			description: "Exit account + 1 (not validated)",
2921			expect_pass: true,
2922		},
2923		(amount, from, exit_plus_one, count, secret),
2924	));
2925
2926	// Exit account: add Goldilocks order to each chunk (SHOULD PASS - not validated)
2927	for chunk_idx in 0..4 {
2928		let mut exit_goldilocks = to;
2929		add_to_address_chunk(&mut exit_goldilocks, chunk_idx, GOLDILOCKS_ORDER);
2930		cases.push((
2931			FuzzCase {
2932				name: match chunk_idx {
2933					0 => "exit_goldilocks_chunk0",
2934					1 => "exit_goldilocks_chunk1",
2935					2 => "exit_goldilocks_chunk2",
2936					_ => "exit_goldilocks_chunk3",
2937				},
2938				description: match chunk_idx {
2939					0 => "Exit + Goldilocks (chunk 0, not validated)",
2940					1 => "Exit + Goldilocks (chunk 1, not validated)",
2941					2 => "Exit + Goldilocks (chunk 2, not validated)",
2942					_ => "Exit + Goldilocks (chunk 3, not validated)",
2943				},
2944				expect_pass: true,
2945			},
2946			(amount, from, exit_goldilocks, count, secret),
2947		));
2948	}
2949
2950	// ============================================================
2951	// SWAPPED/COMBINED
2952	// ============================================================
2953
2954	// Swapped from/to (should FAIL)
2955	cases.push((
2956		FuzzCase {
2957			name: "swapped_from_to",
2958			description: "From and To addresses swapped",
2959			expect_pass: false,
2960		},
2961		(amount, to, from, count, secret),
2962	));
2963
2964	// All inputs fuzzed with random offsets (should FAIL)
2965	let mut all_fuzzed_from = from;
2966	let mut all_fuzzed_to = to;
2967	all_fuzzed_from[31] ^= 0xFF;
2968	all_fuzzed_to[31] ^= 0xFF;
2969	cases.push((
2970		FuzzCase {
2971			name: "all_random_fuzzed",
2972			description: "All inputs fuzzed with random values",
2973			expect_pass: false,
2974		},
2975		(
2976			amount.wrapping_add(random_u128),
2977			all_fuzzed_from,
2978			all_fuzzed_to,
2979			count.wrapping_add(random_u64),
2980			secret,
2981		),
2982	));
2983
2984	// All inputs + Goldilocks order (should FAIL)
2985	let mut all_gold_from = from;
2986	let mut all_gold_to = to;
2987	add_to_address_chunk(&mut all_gold_from, 0, GOLDILOCKS_ORDER);
2988	add_to_address_chunk(&mut all_gold_to, 0, GOLDILOCKS_ORDER);
2989	cases.push((
2990		FuzzCase {
2991			name: "all_goldilocks_fuzzed",
2992			description: "All inputs + Goldilocks order",
2993			expect_pass: false,
2994		},
2995		(
2996			amount.wrapping_add(GOLDILOCKS_ORDER as u128),
2997			all_gold_from,
2998			all_gold_to,
2999			count.wrapping_add(GOLDILOCKS_ORDER),
3000			secret,
3001		),
3002	));
3003
3004	// ============================================================
3005	// SECRET FUZZING (tests unspendable_account validation)
3006	// ============================================================
3007	// The secret is used to derive the unspendable_account, which IS validated
3008	// against the storage proof's leaf hash via the circuit's connect_hashes constraint.
3009	// A wrong secret will derive a wrong unspendable_account, causing proof failure.
3010
3011	// Secret: single bit flip (should FAIL - wrong unspendable_account)
3012	let mut secret_bit_flip = secret;
3013	secret_bit_flip[0] ^= 0x01;
3014	cases.push((
3015		FuzzCase {
3016			name: "secret_single_bit_flip",
3017			description: "Secret single bit flip (wrong unspendable_account)",
3018			expect_pass: false,
3019		},
3020		(amount, from, to, count, secret_bit_flip),
3021	));
3022
3023	// Secret: zeroed (should FAIL)
3024	cases.push((
3025		FuzzCase { name: "secret_zeroed", description: "Secret zeroed", expect_pass: false },
3026		(amount, from, to, count, [0u8; 32]),
3027	));
3028
3029	// Secret: random (should FAIL)
3030	cases.push((
3031		FuzzCase { name: "secret_random", description: "Secret random value", expect_pass: false },
3032		(amount, from, to, count, random_secret),
3033	));
3034
3035	// Secret: add 1 to first byte (should FAIL)
3036	let mut secret_plus_one = secret;
3037	secret_plus_one[0] = secret_plus_one[0].wrapping_add(1);
3038	cases.push((
3039		FuzzCase {
3040			name: "secret_plus_one",
3041			description: "Secret + 1 (first byte)",
3042			expect_pass: false,
3043		},
3044		(amount, from, to, count, secret_plus_one),
3045	));
3046
3047	// Secret: add Goldilocks order to first chunk (overflow attack, should FAIL)
3048	let mut secret_goldilocks = secret;
3049	add_to_address_chunk(&mut secret_goldilocks, 0, GOLDILOCKS_ORDER);
3050	cases.push((
3051		FuzzCase {
3052			name: "secret_plus_goldilocks",
3053			description: "Secret + Goldilocks field order (chunk 0)",
3054			expect_pass: false,
3055		},
3056		(amount, from, to, count, secret_goldilocks),
3057	));
3058
3059	cases
3060}
3061
3062/// Run fuzz tests on the leaf verification circuit.
3063///
3064/// This function:
3065/// 1. Executes a real transfer to a wormhole address
3066/// 2. Attempts to generate proofs with fuzzed inputs (wrong amount, wrong address, etc.)
3067/// 3. Verifies that proof preparation fails for each fuzzed case (except within-threshold cases)
3068/// 4. Reports which cases correctly passed/failed
3069async fn run_fuzz_test(
3070	wallet_name: String,
3071	password: Option<String>,
3072	password_file: Option<String>,
3073	amount: u128,
3074	node_url: &str,
3075) -> crate::error::Result<()> {
3076	use colored::Colorize;
3077
3078	log_print!("");
3079	log_print!("==================================================");
3080	log_print!("  Wormhole Fuzz Test");
3081	log_print!("==================================================");
3082	log_print!("");
3083
3084	// Use block timestamp as seed for reproducibility within same block
3085	let seed = std::time::SystemTime::now()
3086		.duration_since(std::time::UNIX_EPOCH)
3087		.unwrap()
3088		.as_secs();
3089	log_print!("  Random seed: {}", seed);
3090
3091	// Load wallet
3092	let wallet = load_multiround_wallet(&wallet_name, password, password_file)?;
3093
3094	// Connect to node
3095	let quantus_client = QuantusClient::new(node_url)
3096		.await
3097		.map_err(|e| crate::error::QuantusError::Generic(format!("Failed to connect: {}", e)))?;
3098
3099	// Step 1: Generate a secret and derive wormhole address
3100	log_print!("{}", "Step 1: Creating test transfer...".bright_yellow());
3101
3102	let mut secret_bytes = [0u8; 32];
3103	rand::rng().fill_bytes(&mut secret_bytes);
3104	let secret: BytesDigest = secret_bytes.try_into().map_err(|e| {
3105		crate::error::QuantusError::Generic(format!("Failed to convert secret: {:?}", e))
3106	})?;
3107
3108	let unspendable_account =
3109		qp_wormhole_circuit::unspendable_account::UnspendableAccount::from_secret(secret)
3110			.account_id;
3111	let unspendable_account_bytes_digest =
3112		qp_zk_circuits_common::utils::digest_to_bytes(unspendable_account);
3113	let unspendable_account_bytes: [u8; 32] = *unspendable_account_bytes_digest;
3114
3115	let wormhole_address = SubxtAccountId(unspendable_account_bytes);
3116
3117	// Execute transfer
3118	let transfer_tx = quantus_node::api::tx().balances().transfer_allow_death(
3119		subxt::ext::subxt_core::utils::MultiAddress::Id(wormhole_address.clone()),
3120		amount,
3121	);
3122
3123	let quantum_keypair = QuantumKeyPair {
3124		public_key: wallet.keypair.public_key.clone(),
3125		private_key: wallet.keypair.private_key.clone(),
3126	};
3127
3128	submit_transaction(
3129		&quantus_client,
3130		&quantum_keypair,
3131		transfer_tx,
3132		None,
3133		ExecutionMode { finalized: false, wait_for_transaction: true },
3134	)
3135	.await
3136	.map_err(|e| crate::error::QuantusError::Generic(format!("Transfer failed: {}", e)))?;
3137
3138	// Get block and event
3139	let block = at_best_block(&quantus_client)
3140		.await
3141		.map_err(|e| crate::error::QuantusError::Generic(format!("Failed to get block: {}", e)))?;
3142	let block_hash = block.hash();
3143	let events_api =
3144		quantus_client.client().events().at(block_hash).await.map_err(|e| {
3145			crate::error::QuantusError::Generic(format!("Failed to get events: {}", e))
3146		})?;
3147
3148	let event = events_api
3149		.find::<wormhole::events::NativeTransferred>()
3150		.find(|e| if let Ok(evt) = e { evt.to.0 == unspendable_account_bytes } else { false })
3151		.ok_or_else(|| crate::error::QuantusError::Generic("No transfer event found".to_string()))?
3152		.map_err(|e| crate::error::QuantusError::Generic(format!("Event decode error: {}", e)))?;
3153
3154	let transfer_count = event.transfer_count;
3155	let funding_account: [u8; 32] = wallet.keypair.to_account_id_32().into();
3156
3157	log_success!(
3158		"  Transfer complete: {} to wormhole, transfer_count={}",
3159		format_balance(amount),
3160		transfer_count
3161	);
3162	log_print!("  Block: 0x{}", hex::encode(block_hash.0));
3163
3164	// Step 2: Test that correct inputs work (sanity check)
3165	log_print!("");
3166	log_print!("{}", "Step 2: Verifying correct inputs work...".bright_yellow());
3167
3168	// Step 2: Get block header data needed for proof generation
3169	log_print!("{}", "Step 2: Fetching block header data...".bright_yellow());
3170
3171	let client = quantus_client.client();
3172	let blocks =
3173		client.blocks().at(block_hash).await.map_err(|e| {
3174			crate::error::QuantusError::Generic(format!("Failed to get block: {}", e))
3175		})?;
3176	let header = blocks.header();
3177
3178	let state_root = BytesDigest::try_from(header.state_root.as_bytes())
3179		.map_err(|e| crate::error::QuantusError::Generic(e.to_string()))?;
3180	let parent_hash = BytesDigest::try_from(header.parent_hash.as_bytes())
3181		.map_err(|e| crate::error::QuantusError::Generic(e.to_string()))?;
3182	let extrinsics_root = BytesDigest::try_from(header.extrinsics_root.as_bytes())
3183		.map_err(|e| crate::error::QuantusError::Generic(e.to_string()))?;
3184	let digest: [u8; 110] =
3185		header.digest.encode().try_into().map_err(|_| {
3186			crate::error::QuantusError::Generic("Failed to encode digest".to_string())
3187		})?;
3188	let block_number = header.number;
3189	let state_root_hex = hex::encode(header.state_root.0);
3190
3191	// Build storage key for fetching proof
3192	let pallet_hash = sp_core::twox_128(b"Wormhole");
3193	let storage_hash = sp_core::twox_128(b"TransferProof");
3194	let mut final_key = Vec::with_capacity(32 + 32);
3195	final_key.extend_from_slice(&pallet_hash);
3196	final_key.extend_from_slice(&storage_hash);
3197	let key_tuple: TransferProofKey = (AccountId32::new(unspendable_account_bytes), transfer_count);
3198	let encoded_key = key_tuple.encode();
3199	let key_hash = sp_core::blake2_256(&encoded_key);
3200	final_key.extend_from_slice(&key_hash);
3201
3202	// Fetch storage proof from RPC
3203	let proof_params = rpc_params![vec![to_hex(&final_key)], block_hash];
3204	let read_proof: ReadProof<sp_core::H256> = quantus_client
3205		.rpc_client()
3206		.request("state_getReadProof", proof_params)
3207		.await
3208		.map_err(|e| crate::error::QuantusError::Generic(e.to_string()))?;
3209
3210	log_success!("  Block header and storage proof fetched");
3211
3212	// Prover bins directory
3213	let bins_dir = Path::new("generated-bins");
3214
3215	// Step 3: Test that correct inputs work (sanity check with actual proof generation)
3216	log_print!("");
3217	log_print!("{}", "Step 3: Verifying correct inputs generate valid proof...".bright_yellow());
3218
3219	let correct_result = try_generate_fuzz_proof(
3220		bins_dir,
3221		&read_proof,
3222		&state_root_hex,
3223		block_hash,
3224		secret,
3225		amount,
3226		funding_account,
3227		unspendable_account_bytes,
3228		transfer_count,
3229		state_root,
3230		parent_hash,
3231		extrinsics_root,
3232		digest,
3233		block_number,
3234	);
3235
3236	match correct_result {
3237		Ok(_) => log_success!("  Correct inputs: PASSED (ZK proof generated successfully)"),
3238		Err(e) => {
3239			return Err(crate::error::QuantusError::Generic(format!(
3240				"FATAL: Correct inputs failed proof generation: {}",
3241				e
3242			)));
3243		},
3244	}
3245
3246	// Step 4: Generate and run fuzz cases
3247	log_print!("");
3248	log_print!("{}", "Step 4: Running fuzz cases (generating ZK proofs)...".bright_yellow());
3249	log_print!(
3250		"  NOTE: Each case attempts actual ZK proof generation - this tests circuit constraints"
3251	);
3252	log_print!("");
3253
3254	let mut rng = SeededRng::new(seed);
3255	let secret_bytes: [u8; 32] = secret.as_ref().try_into().expect("secret is 32 bytes");
3256	let fuzz_cases = generate_fuzz_cases(
3257		amount,
3258		funding_account,
3259		unspendable_account_bytes,
3260		transfer_count,
3261		secret_bytes,
3262		&mut rng,
3263	);
3264
3265	log_print!("  Total fuzz cases: {}", fuzz_cases.len());
3266	log_print!("");
3267
3268	let mut passed = 0;
3269	let mut failed = 0;
3270
3271	for (case, (fuzzed_amount, fuzzed_from, fuzzed_to, fuzzed_count, fuzzed_secret)) in &fuzz_cases
3272	{
3273		// Convert fuzzed_secret bytes to BytesDigest
3274		let fuzzed_secret_digest: BytesDigest =
3275			(*fuzzed_secret).try_into().expect("fuzzed_secret is 32 bytes");
3276
3277		// Use catch_unwind to handle panics from field overflow validation
3278		let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3279			try_generate_fuzz_proof(
3280				bins_dir,
3281				&read_proof,
3282				&state_root_hex,
3283				block_hash,
3284				fuzzed_secret_digest,
3285				*fuzzed_amount,
3286				*fuzzed_from,
3287				*fuzzed_to,
3288				*fuzzed_count,
3289				state_root,
3290				parent_hash,
3291				extrinsics_root,
3292				digest,
3293				block_number,
3294			)
3295		}));
3296
3297		let succeeded = match &result {
3298			Ok(Ok(_)) => true,
3299			Ok(Err(_)) => false,
3300			Err(_) => false, // Panic counts as failure
3301		};
3302
3303		if succeeded == case.expect_pass {
3304			// Correct behavior
3305			log_print!("  {} {}: {}", "OK".bright_green(), case.name, case.description);
3306			passed += 1;
3307		} else {
3308			// Wrong behavior
3309			let problem = if case.expect_pass {
3310				match result {
3311					Ok(Err(e)) => format!("Expected to pass but failed: {}", e),
3312					Err(_) => "Expected to pass but panicked".to_string(),
3313					_ => "Unknown error".to_string(),
3314				}
3315			} else {
3316				"Expected circuit to reject but proof succeeded!".to_string()
3317			};
3318			log_print!(
3319				"  {} {}: {} - {}",
3320				"FAIL".bright_red(),
3321				case.name,
3322				case.description,
3323				problem.red()
3324			);
3325			failed += 1;
3326		}
3327	}
3328
3329	// Summary
3330	log_print!("");
3331	log_print!("==================================================");
3332	log_print!("  Fuzz Test Results");
3333	log_print!("==================================================");
3334	log_print!("");
3335	log_print!("  Total cases: {}", fuzz_cases.len());
3336	log_print!("  Passed: {} (correct behavior)", format!("{}", passed).bright_green());
3337
3338	if failed > 0 {
3339		log_print!("  Failed: {} (incorrect behavior)", format!("{}", failed).bright_red());
3340		log_print!("");
3341		return Err(crate::error::QuantusError::Generic(format!(
3342			"Fuzz test failed: {} cases had incorrect behavior",
3343			failed
3344		)));
3345	}
3346
3347	log_print!("");
3348	log_success!("All fuzz cases behaved correctly!");
3349
3350	Ok(())
3351}
3352
3353/// Try to generate a ZK proof with the given (possibly fuzzed) inputs.
3354/// This actually runs the ZK prover to test that circuit constraints reject invalid inputs.
3355///
3356/// Returns Ok(()) if proof generation succeeds, Err if it fails.
3357#[allow(clippy::too_many_arguments)]
3358fn try_generate_fuzz_proof(
3359	bins_dir: &Path,
3360	read_proof: &ReadProof<sp_core::H256>,
3361	state_root_hex: &str,
3362	block_hash: subxt::utils::H256,
3363	secret: BytesDigest,
3364	fuzzed_amount: u128,
3365	fuzzed_from: [u8; 32],
3366	fuzzed_to: [u8; 32],
3367	fuzzed_count: u64,
3368	state_root: BytesDigest,
3369	parent_hash: BytesDigest,
3370	extrinsics_root: BytesDigest,
3371	digest: [u8; 110],
3372	block_number: u32,
3373) -> Result<(), String> {
3374	use subxt::ext::codec::Encode;
3375
3376	// Compute the fuzzed leaf_inputs_hash
3377	let from_account = AccountId32::new(fuzzed_from);
3378	let to_account = AccountId32::new(fuzzed_to);
3379	let transfer_data_tuple =
3380		(NATIVE_ASSET_ID, fuzzed_count, from_account.clone(), to_account.clone(), fuzzed_amount);
3381	let encoded_data = transfer_data_tuple.encode();
3382	let fuzzed_leaf_hash =
3383		qp_poseidon::PoseidonHasher::hash_storage::<TransferProofData>(&encoded_data);
3384
3385	// Prepare storage proof (now just logs warning on mismatch, doesn't fail)
3386	let processed_storage_proof = prepare_proof_for_circuit(
3387		read_proof.proof.iter().map(|proof| proof.0.clone()).collect(),
3388		state_root_hex.to_string(),
3389		fuzzed_leaf_hash,
3390	)
3391	.map_err(|e| e.to_string())?;
3392
3393	// Quantize input amount (may panic for invalid amounts, caught by caller)
3394	let input_amount_quantized: u32 = quantize_funding_amount(fuzzed_amount)?;
3395
3396	// Compute output amount (single output for simplicity)
3397	let output_amount = compute_output_amount(input_amount_quantized, VOLUME_FEE_BPS);
3398
3399	// Generate unspendable account from secret
3400	let unspendable_account =
3401		qp_wormhole_circuit::unspendable_account::UnspendableAccount::from_secret(secret)
3402			.account_id;
3403	let unspendable_account_bytes_digest =
3404		qp_zk_circuits_common::utils::digest_to_bytes(unspendable_account);
3405
3406	// Build circuit inputs with fuzzed data
3407	let inputs = CircuitInputs {
3408		private: PrivateCircuitInputs {
3409			secret,
3410			transfer_count: fuzzed_count,
3411			funding_account: BytesDigest::try_from(fuzzed_from.as_ref())
3412				.map_err(|e| e.to_string())?,
3413			storage_proof: processed_storage_proof,
3414			unspendable_account: unspendable_account_bytes_digest,
3415			parent_hash,
3416			state_root,
3417			extrinsics_root,
3418			digest,
3419			input_amount: input_amount_quantized,
3420		},
3421		public: PublicCircuitInputs {
3422			output_amount_1: output_amount,
3423			output_amount_2: 0,
3424			volume_fee_bps: VOLUME_FEE_BPS,
3425			nullifier: digest_to_bytes(Nullifier::from_preimage(secret, fuzzed_count).hash),
3426			exit_account_1: BytesDigest::try_from(fuzzed_to.as_ref()).map_err(|e| e.to_string())?,
3427			exit_account_2: BytesDigest::try_from([0u8; 32].as_ref()).map_err(|e| e.to_string())?,
3428			block_hash: BytesDigest::try_from(block_hash.as_ref()).map_err(|e| e.to_string())?,
3429			block_number,
3430			asset_id: NATIVE_ASSET_ID,
3431		},
3432	};
3433
3434	// Load prover (need fresh instance for each proof since commit() takes ownership)
3435	let prover =
3436		WormholeProver::new_from_files(&bins_dir.join("prover.bin"), &bins_dir.join("common.bin"))
3437			.map_err(|e| format!("Failed to load prover: {}", e))?;
3438
3439	// Try to generate the proof - this is where circuit constraints are checked
3440	let prover_next = prover.commit(&inputs).map_err(|e| e.to_string())?;
3441	let _proof: ProofWithPublicInputs<_, _, 2> =
3442		prover_next.prove().map_err(|e| format!("Circuit rejected: {}", e))?;
3443
3444	Ok(())
3445}
3446#[cfg(test)]
3447mod tests {
3448	use super::*;
3449	use std::collections::HashSet;
3450	use tempfile::NamedTempFile;
3451
3452	#[test]
3453	fn test_compute_output_amount() {
3454		// 0.1% fee (10 bps): output = input * 9990 / 10000
3455		assert_eq!(compute_output_amount(1000, 10), 999);
3456		assert_eq!(compute_output_amount(10000, 10), 9990);
3457
3458		// 1% fee (100 bps): output = input * 9900 / 10000
3459		assert_eq!(compute_output_amount(1000, 100), 990);
3460		assert_eq!(compute_output_amount(10000, 100), 9900);
3461
3462		// 0% fee
3463		assert_eq!(compute_output_amount(1000, 0), 1000);
3464
3465		// Edge cases
3466		assert_eq!(compute_output_amount(0, 10), 0);
3467		assert_eq!(compute_output_amount(1, 10), 0); // rounds down
3468		assert_eq!(compute_output_amount(100, 10), 99);
3469	}
3470
3471	#[test]
3472	fn test_parse_secret_hex() {
3473		// Valid hex with and without 0x prefix
3474		let secret = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
3475		assert!(parse_secret_hex(secret).is_ok());
3476		assert!(parse_secret_hex(&format!("0x{}", secret)).is_ok());
3477
3478		// Wrong length
3479		assert!(parse_secret_hex("0123456789abcdef").unwrap_err().contains("32 bytes"));
3480
3481		// Invalid hex characters
3482		assert!(parse_secret_hex("ghij".repeat(16).as_str())
3483			.unwrap_err()
3484			.contains("Invalid secret hex"));
3485	}
3486
3487	#[test]
3488	fn test_parse_exit_account() {
3489		// Valid hex account
3490		let hex = "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
3491		assert!(parse_exit_account(hex).is_ok());
3492
3493		// Wrong length
3494		assert!(parse_exit_account("0x0123456789abcdef").unwrap_err().contains("32 bytes"));
3495
3496		// Invalid SS58
3497		assert!(parse_exit_account("not_valid").unwrap_err().contains("Invalid SS58"));
3498	}
3499
3500	#[test]
3501	fn test_quantize_funding_amount() {
3502		// Basic quantization: 1 token (12 decimals) -> 100 (2 decimals)
3503		assert_eq!(quantize_funding_amount(1_000_000_000_000).unwrap(), 100);
3504
3505		// Zero and small amounts
3506		assert_eq!(quantize_funding_amount(0).unwrap(), 0);
3507		assert_eq!(quantize_funding_amount(5_000_000_000).unwrap(), 0); // < 10^10
3508
3509		// Max valid and overflow
3510		let max_valid = (u32::MAX as u128) * SCALE_DOWN_FACTOR;
3511		assert_eq!(quantize_funding_amount(max_valid).unwrap(), u32::MAX);
3512		assert!(quantize_funding_amount(max_valid + SCALE_DOWN_FACTOR)
3513			.unwrap_err()
3514			.contains("exceeds u32::MAX"));
3515	}
3516
3517	#[test]
3518	fn test_proof_file_roundtrip() {
3519		let temp_file = NamedTempFile::new().unwrap();
3520		let path = temp_file.path().to_str().unwrap();
3521		let proof_bytes = vec![0x01, 0x02, 0x03, 0xaa, 0xbb, 0xcc];
3522
3523		write_proof_file(path, &proof_bytes).unwrap();
3524		assert_eq!(read_proof_file(path).unwrap(), proof_bytes);
3525	}
3526
3527	#[test]
3528	fn test_read_proof_file_errors() {
3529		// File not found
3530		assert!(read_proof_file("/nonexistent/path/proof.hex")
3531			.unwrap_err()
3532			.contains("Failed to read"));
3533
3534		// Invalid hex content
3535		let temp_file = NamedTempFile::new().unwrap();
3536		std::fs::write(temp_file.path(), "not valid hex!").unwrap();
3537		assert!(read_proof_file(temp_file.path().to_str().unwrap())
3538			.unwrap_err()
3539			.contains("Failed to decode"));
3540	}
3541
3542	#[test]
3543	fn test_fee_calculation_edge_cases() {
3544		// Test the circuit fee constraint: output_amount * 10000 <= input_amount * (10000 -
3545		// volume_fee_bps) This is equivalent to: output <= input * (1 - fee_rate)
3546
3547		// Small amounts where fee rounds to zero
3548		let input_small: u32 = 100;
3549		let output_small = compute_output_amount(input_small, VOLUME_FEE_BPS);
3550		assert_eq!(output_small, 99);
3551		// Verify constraint: 99 * 10000 = 990000 <= 100 * 9990 = 999000 ✓
3552		assert!(
3553			(output_small as u64) * 10000 <= (input_small as u64) * (10000 - VOLUME_FEE_BPS as u64)
3554		);
3555
3556		// Medium amounts
3557		let input_medium: u32 = 10000;
3558		let output_medium = compute_output_amount(input_medium, VOLUME_FEE_BPS);
3559		assert_eq!(output_medium, 9990);
3560		assert!(
3561			(output_medium as u64) * 10000 <=
3562				(input_medium as u64) * (10000 - VOLUME_FEE_BPS as u64)
3563		);
3564
3565		// Large amounts near u32::MAX
3566		let input_large: u32 = u32::MAX / 2;
3567		let output_large = compute_output_amount(input_large, VOLUME_FEE_BPS);
3568		assert!(
3569			(output_large as u64) * 10000 <= (input_large as u64) * (10000 - VOLUME_FEE_BPS as u64)
3570		);
3571
3572		// Test with different fee rates
3573		for fee_bps in [0u32, 1, 10, 50, 100, 500, 1000] {
3574			let input: u32 = 100000;
3575			let output = compute_output_amount(input, fee_bps);
3576			assert!(
3577				(output as u64) * 10000 <= (input as u64) * (10000 - fee_bps as u64),
3578				"Fee constraint violated for fee_bps={}: {} * 10000 > {} * {}",
3579				fee_bps,
3580				output,
3581				input,
3582				10000 - fee_bps
3583			);
3584		}
3585	}
3586
3587	#[test]
3588	fn test_nullifier_determinism() {
3589		use qp_wormhole_circuit::nullifier::Nullifier;
3590		use qp_zk_circuits_common::utils::BytesDigest;
3591
3592		let secret: BytesDigest = [1u8; 32].try_into().expect("valid secret");
3593		let transfer_count = 42u64;
3594
3595		// Generate nullifier multiple times - should be identical
3596		let nullifier1 = Nullifier::from_preimage(secret, transfer_count);
3597		let nullifier2 = Nullifier::from_preimage(secret, transfer_count);
3598		let nullifier3 = Nullifier::from_preimage(secret, transfer_count);
3599
3600		assert_eq!(nullifier1.hash, nullifier2.hash);
3601		assert_eq!(nullifier2.hash, nullifier3.hash);
3602
3603		// Different transfer count should produce different nullifier
3604		let nullifier_different = Nullifier::from_preimage(secret, transfer_count + 1);
3605		assert_ne!(nullifier1.hash, nullifier_different.hash);
3606
3607		// Different secret should produce different nullifier
3608		let different_secret: BytesDigest = [2u8; 32].try_into().expect("valid secret");
3609		let nullifier_different_secret = Nullifier::from_preimage(different_secret, transfer_count);
3610		assert_ne!(nullifier1.hash, nullifier_different_secret.hash);
3611	}
3612
3613	#[test]
3614	fn test_unspendable_account_determinism() {
3615		use qp_wormhole_circuit::unspendable_account::UnspendableAccount;
3616		use qp_zk_circuits_common::utils::BytesDigest;
3617
3618		let secret: BytesDigest = [1u8; 32].try_into().expect("valid secret");
3619
3620		// Generate unspendable account multiple times - should be identical
3621		let account1 = UnspendableAccount::from_secret(secret);
3622		let account2 = UnspendableAccount::from_secret(secret);
3623
3624		assert_eq!(account1.account_id, account2.account_id);
3625
3626		// Different secret should produce different account
3627		let different_secret: BytesDigest = [2u8; 32].try_into().expect("valid secret");
3628		let account_different = UnspendableAccount::from_secret(different_secret);
3629		assert_ne!(account1.account_id, account_different.account_id);
3630	}
3631
3632	// Note: Integration tests for proof generation, serialization, aggregation, and
3633	// multi-account aggregation have been moved to qp-zk-circuits/wormhole/tests/
3634	// where the test-helpers crate (with TestInputs) is available as a workspace dep.
3635
3636	/// Test that public inputs parsing matches expected structure
3637	#[test]
3638	fn test_public_inputs_structure() {
3639		use qp_wormhole_inputs::{
3640			ASSET_ID_INDEX, BLOCK_HASH_END_INDEX, BLOCK_HASH_START_INDEX, BLOCK_NUMBER_INDEX,
3641			EXIT_ACCOUNT_1_END_INDEX, EXIT_ACCOUNT_1_START_INDEX, EXIT_ACCOUNT_2_END_INDEX,
3642			EXIT_ACCOUNT_2_START_INDEX, NULLIFIER_END_INDEX, NULLIFIER_START_INDEX,
3643			OUTPUT_AMOUNT_1_INDEX, OUTPUT_AMOUNT_2_INDEX, PUBLIC_INPUTS_FELTS_LEN,
3644			VOLUME_FEE_BPS_INDEX,
3645		};
3646
3647		// Verify expected public inputs layout for dual-output circuit
3648		assert_eq!(PUBLIC_INPUTS_FELTS_LEN, 21, "Public inputs should be 21 field elements");
3649		assert_eq!(ASSET_ID_INDEX, 0, "Asset ID should be first");
3650		assert_eq!(OUTPUT_AMOUNT_1_INDEX, 1, "Output amount 1 should be at index 1");
3651		assert_eq!(OUTPUT_AMOUNT_2_INDEX, 2, "Output amount 2 should be at index 2");
3652		assert_eq!(VOLUME_FEE_BPS_INDEX, 3, "Volume fee BPS should be at index 3");
3653		assert_eq!(NULLIFIER_START_INDEX, 4, "Nullifier should start at index 4");
3654		assert_eq!(NULLIFIER_END_INDEX, 8, "Nullifier should end at index 8");
3655		assert_eq!(EXIT_ACCOUNT_1_START_INDEX, 8, "Exit account 1 should start at index 8");
3656		assert_eq!(EXIT_ACCOUNT_1_END_INDEX, 12, "Exit account 1 should end at index 12");
3657		assert_eq!(EXIT_ACCOUNT_2_START_INDEX, 12, "Exit account 2 should start at index 12");
3658		assert_eq!(EXIT_ACCOUNT_2_END_INDEX, 16, "Exit account 2 should end at index 16");
3659		assert_eq!(BLOCK_HASH_START_INDEX, 16, "Block hash should start at index 16");
3660		assert_eq!(BLOCK_HASH_END_INDEX, 20, "Block hash should end at index 20");
3661		assert_eq!(BLOCK_NUMBER_INDEX, 20, "Block number should be at index 20");
3662	}
3663
3664	/// Test that constants match expected on-chain configuration
3665	#[test]
3666	fn test_constants_match_chain_config() {
3667		// Volume fee rate should be 10 bps (0.1%)
3668		assert_eq!(VOLUME_FEE_BPS, 10, "Volume fee should be 10 bps");
3669
3670		// Native asset ID should be 0
3671		assert_eq!(NATIVE_ASSET_ID, 0, "Native asset ID should be 0");
3672
3673		// Scale down factor should be 10^10 (12 decimals -> 2 decimals)
3674		assert_eq!(SCALE_DOWN_FACTOR, 10_000_000_000, "Scale down factor should be 10^10");
3675
3676		// Verify scale down: 1 token with 12 decimals = 10^12 units
3677		// After quantization: 10^12 / 10^10 = 100 (which is 1.00 in 2 decimal places)
3678		let one_token_12_decimals: u128 = 1_000_000_000_000;
3679		let quantized = quantize_funding_amount(one_token_12_decimals).unwrap();
3680		assert_eq!(quantized, 100, "1 token should quantize to 100 (1.00 with 2 decimals)");
3681	}
3682
3683	#[test]
3684	fn test_volume_fee_bps_constant() {
3685		// Ensure VOLUME_FEE_BPS matches expected value (10 bps = 0.1%)
3686		assert_eq!(VOLUME_FEE_BPS, 10);
3687	}
3688
3689	#[test]
3690	fn test_aggregation_config_deserialization_matches_upstream_format() {
3691		// This test verifies that our local AggregationConfig struct can deserialize
3692		// the same JSON format that the upstream CircuitBinsConfig produces.
3693		// If the upstream adds/removes/renames fields, this test will catch it.
3694		let json = r#"{
3695			"num_leaf_proofs": 8,
3696			"num_layer0_proofs": null
3697		}"#;
3698
3699		let config: CircuitBinsConfig = serde_json::from_str(json).unwrap();
3700		assert_eq!(config.num_leaf_proofs, 8);
3701		assert_eq!(config.num_layer0_proofs, None);
3702	}
3703
3704	fn mk_accounts(n: usize) -> Vec<[u8; 32]> {
3705		(0..n)
3706			.map(|i| {
3707				let mut a = [0u8; 32];
3708				a[0] = (i as u8).wrapping_add(1); // avoid [0;32]
3709				a
3710			})
3711			.collect()
3712	}
3713
3714	fn proof_outputs_for_inputs(input_amounts: &[u128], fee_bps: u32) -> Vec<u32> {
3715		input_amounts
3716			.iter()
3717			.map(|&input| {
3718				let input_quantized = quantize_funding_amount(input).unwrap_or(0);
3719				compute_output_amount(input_quantized, fee_bps)
3720			})
3721			.collect()
3722	}
3723
3724	fn total_output_for_inputs(input_amounts: &[u128], fee_bps: u32) -> u64 {
3725		proof_outputs_for_inputs(input_amounts, fee_bps)
3726			.into_iter()
3727			.map(|x| x as u64)
3728			.sum()
3729	}
3730
3731	/// Find some input that yields at least `min_out` quantized output after fee.
3732	/// Keeps tests robust even if quantization constants change.
3733	fn find_input_for_min_output(fee_bps: u32, min_out: u32) -> u128 {
3734		let mut input: u128 = 1;
3735		for _ in 0..80 {
3736			let q = quantize_funding_amount(input).unwrap_or(0);
3737			let out = compute_output_amount(q, fee_bps);
3738			if out >= min_out {
3739				return input;
3740			}
3741			// grow fast but safely
3742			input = input.saturating_mul(10);
3743		}
3744		panic!("Could not find input producing output >= {}", min_out);
3745	}
3746
3747	// --------------------------
3748	// random_partition tests
3749	// --------------------------
3750
3751	#[test]
3752	fn random_partition_n0() {
3753		let parts = random_partition(100, 0, 1);
3754		assert!(parts.is_empty());
3755	}
3756
3757	#[test]
3758	fn random_partition_n1() {
3759		let total = 12345u128;
3760		let parts = random_partition(total, 1, 9999);
3761		assert_eq!(parts, vec![total]);
3762	}
3763
3764	#[test]
3765	fn random_partition_total_less_than_min_total_falls_back_to_equalish() {
3766		// total < min_per_part * n => fallback path
3767		let total = 5u128;
3768		let n = 10usize;
3769		let min_per_part = 1u128;
3770
3771		let parts = random_partition(total, n, min_per_part);
3772
3773		assert_eq!(parts.len(), n);
3774		assert_eq!(parts.iter().sum::<u128>(), total);
3775
3776		// fallback behavior: per_part=0, remainder=5, last gets remainder
3777		for part in parts.iter().take(n - 1) {
3778			assert_eq!(*part, 0);
3779		}
3780		assert_eq!(parts[n - 1], 5);
3781	}
3782
3783	#[test]
3784	fn random_partition_min_achievable_invariants_hold() {
3785		let total = 100u128;
3786		let n = 10usize;
3787		let min_per_part = 3u128;
3788
3789		for _ in 0..200 {
3790			let parts = random_partition(total, n, min_per_part);
3791			assert_eq!(parts.len(), n);
3792			assert_eq!(parts.iter().sum::<u128>(), total);
3793			assert!(parts.iter().all(|&p| p >= min_per_part));
3794		}
3795	}
3796
3797	#[test]
3798	fn random_partition_distributable_zero_all_min() {
3799		let n = 10usize;
3800		let min_per_part = 3u128;
3801		let total = min_per_part * n as u128;
3802
3803		let parts = random_partition(total, n, min_per_part);
3804
3805		assert_eq!(parts.len(), n);
3806		assert_eq!(parts.iter().sum::<u128>(), total);
3807		assert!(parts.iter().all(|&p| p == min_per_part));
3808	}
3809
3810	// --------------------------
3811	// compute_random_output_assignments tests
3812	// --------------------------
3813
3814	#[test]
3815	fn compute_random_output_assignments_empty_inputs_or_targets() {
3816		let targets = mk_accounts(3);
3817		assert!(compute_random_output_assignments(&[], &targets, 0).is_empty());
3818
3819		let inputs = vec![1u128, 2u128, 3u128];
3820		assert!(compute_random_output_assignments(&inputs, &[], 0).is_empty());
3821	}
3822
3823	#[test]
3824	fn compute_random_output_assignments_basic_invariants() {
3825		let fee_bps = 0u32;
3826
3827		// ensure non-zero outputs for meaningful checks
3828		let input = find_input_for_min_output(fee_bps, 5);
3829		let input_amounts = vec![input, input, input, input, input];
3830		let targets = mk_accounts(4);
3831
3832		let assignments = compute_random_output_assignments(&input_amounts, &targets, fee_bps);
3833		assert_eq!(assignments.len(), input_amounts.len());
3834
3835		let proof_outputs = proof_outputs_for_inputs(&input_amounts, fee_bps);
3836
3837		// per-proof sum matches
3838		for (i, a) in assignments.iter().enumerate() {
3839			let per_proof_sum = a.output_amount_1 as u64 + a.output_amount_2 as u64;
3840			assert_eq!(per_proof_sum, proof_outputs[i] as u64);
3841
3842			// If an output amount is non-zero, the account must be in targets
3843			if a.output_amount_1 > 0 {
3844				assert!(targets.contains(&a.exit_account_1));
3845			} else {
3846				// if amount is zero, account can be zero or anything; current impl keeps default
3847				// [0;32]
3848			}
3849			if a.output_amount_2 > 0 {
3850				assert!(targets.contains(&a.exit_account_2));
3851				assert_ne!(a.exit_account_2, a.exit_account_1); // should be different if both used
3852			} else {
3853				// current impl keeps default [0;32]
3854				assert_eq!(a.exit_account_2, [0u8; 32]);
3855			}
3856		}
3857
3858		// total sum matches
3859		let total_assigned: u64 = assignments
3860			.iter()
3861			.map(|a| a.output_amount_1 as u64 + a.output_amount_2 as u64)
3862			.sum();
3863
3864		let total_expected = total_output_for_inputs(&input_amounts, fee_bps);
3865		assert_eq!(total_assigned, total_expected);
3866	}
3867
3868	#[test]
3869	fn compute_random_output_assignments_more_targets_than_capacity_still_conserves_funds() {
3870		// Capacity: each proof can hit at most 2 targets, so distinct-used-targets <= 2 *
3871		// num_proofs. Set num_targets > 2*num_proofs and ensure total_output >= num_targets so
3872		// the partition *wants* to give each target >= 1 (though algorithm can't satisfy it).
3873		let fee_bps = 0u32;
3874		let num_proofs = 1usize;
3875		let num_targets = 5usize;
3876
3877		let input = find_input_for_min_output(fee_bps, 10); // ensure total_output is "big enough"
3878		let input_amounts = vec![input; num_proofs];
3879		let targets = mk_accounts(num_targets);
3880
3881		let assignments = compute_random_output_assignments(&input_amounts, &targets, fee_bps);
3882		assert_eq!(assignments.len(), num_proofs);
3883
3884		// total preserved
3885		let total_assigned: u64 = assignments
3886			.iter()
3887			.map(|a| a.output_amount_1 as u64 + a.output_amount_2 as u64)
3888			.sum();
3889		let total_expected = total_output_for_inputs(&input_amounts, fee_bps);
3890		assert_eq!(total_assigned, total_expected);
3891
3892		// used targets bounded by 2*num_proofs and thus < num_targets
3893		let mut used = HashSet::new();
3894		for a in &assignments {
3895			if a.output_amount_1 > 0 {
3896				used.insert(a.exit_account_1);
3897			}
3898			if a.output_amount_2 > 0 {
3899				used.insert(a.exit_account_2);
3900			}
3901		}
3902		assert!(used.len() <= 2 * num_proofs);
3903		assert!(used.len() < num_targets);
3904	}
3905
3906	#[test]
3907	fn compute_random_output_assignments_total_output_less_than_num_targets_does_not_panic_and_conserves(
3908	) {
3909		// This forces random_partition into its fallback branch inside
3910		// compute_random_output_assignments because min_per_target = 1 and total_output <
3911		// num_targets.
3912		let fee_bps = 0u32;
3913
3914		let num_targets = 50usize;
3915		let targets = mk_accounts(num_targets);
3916
3917		// Try to get very small total output: two proofs with output likely >= 1 each,
3918		// but still far less than 50.
3919		let input = find_input_for_min_output(fee_bps, 1);
3920		let input_amounts = vec![input, input];
3921
3922		let assignments = compute_random_output_assignments(&input_amounts, &targets, fee_bps);
3923		assert_eq!(assignments.len(), input_amounts.len());
3924
3925		let total_assigned: u64 = assignments
3926			.iter()
3927			.map(|a| a.output_amount_1 as u64 + a.output_amount_2 as u64)
3928			.sum();
3929		let total_expected = total_output_for_inputs(&input_amounts, fee_bps);
3930		assert_eq!(total_assigned, total_expected);
3931
3932		// For each assignment: if non-zero amount then account must be in targets.
3933		for a in &assignments {
3934			if a.output_amount_1 > 0 {
3935				assert!(targets.contains(&a.exit_account_1));
3936			}
3937			if a.output_amount_2 > 0 {
3938				assert!(targets.contains(&a.exit_account_2));
3939				assert_ne!(a.exit_account_2, a.exit_account_1);
3940			}
3941		}
3942	}
3943}