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_circuit::{
21	inputs::{CircuitInputs, ParseAggregatedPublicInputs, PrivateCircuitInputs},
22	nullifier::Nullifier,
23};
24use qp_wormhole_inputs::{AggregatedPublicCircuitInputs, PublicCircuitInputs};
25use qp_wormhole_prover::WormholeProver;
26use qp_zk_circuits_common::{
27	circuit::{C, D, F},
28	storage_proof::prepare_proof_for_circuit,
29	utils::{digest_felts_to_bytes, BytesDigest},
30};
31use rand::RngCore;
32use sp_core::crypto::{AccountId32, Ss58Codec};
33use std::path::Path;
34use subxt::{
35	backend::legacy::rpc_methods::ReadProof,
36	blocks::Block,
37	ext::{
38		codec::Encode,
39		jsonrpsee::{core::client::ClientT, rpc_params},
40	},
41	utils::{to_hex, AccountId32 as SubxtAccountId},
42	OnlineClient,
43};
44
45/// Native asset id
46pub const NATIVE_ASSET_ID: u32 = 0;
47
48/// Scale down factor for quantizing amounts (10^10 to go from 12 to 2 decimal places)
49pub const SCALE_DOWN_FACTOR: u128 = 10_000_000_000;
50
51/// Volume fee rate in basis points (10 bps = 0.1%)
52/// This must match the on-chain VolumeFeeRateBps configuration
53pub const VOLUME_FEE_BPS: u32 = 10;
54
55/// SHA256 hashes of circuit binary files for integrity verification.
56/// Must match the BinaryHashes struct in qp-wormhole-aggregator/src/config.rs
57#[derive(Debug, Clone, serde::Deserialize, Default)]
58pub struct BinaryHashes {
59	pub prover: Option<String>,
60	pub aggregated_common: Option<String>,
61	pub aggregated_verifier: Option<String>,
62	pub dummy_proof: Option<String>,
63}
64
65/// Aggregation config loaded from generated-bins/config.json.
66/// Must match the CircuitBinsConfig struct in qp-wormhole-aggregator/src/config.rs
67#[derive(Debug, Clone, serde::Deserialize)]
68pub struct AggregationConfig {
69	pub num_leaf_proofs: usize,
70	#[serde(default)]
71	pub hashes: Option<BinaryHashes>,
72}
73
74impl AggregationConfig {
75	/// Load config from the generated-bins directory
76	pub fn load_from_bins() -> crate::error::Result<Self> {
77		let config_path = Path::new("generated-bins/config.json");
78		let config_str = std::fs::read_to_string(config_path).map_err(|e| {
79			crate::error::QuantusError::Generic(format!(
80				"Failed to read aggregation config from {}: {}. Run 'quantus developer build-circuits' first.",
81				config_path.display(),
82				e
83			))
84		})?;
85		serde_json::from_str(&config_str).map_err(|e| {
86			crate::error::QuantusError::Generic(format!(
87				"Failed to parse aggregation config: {}",
88				e
89			))
90		})
91	}
92
93	/// Verify that the binary files in generated-bins match the stored hashes.
94	pub fn verify_binary_hashes(&self) -> crate::error::Result<()> {
95		use sha2::{Digest, Sha256};
96
97		let Some(ref stored_hashes) = self.hashes else {
98			log_verbose!("   No hashes in config.json, skipping binary verification");
99			return Ok(());
100		};
101
102		let bins_dir = Path::new("generated-bins");
103		let mut mismatches = Vec::new();
104
105		let hash_file = |filename: &str| -> Option<String> {
106			let path = bins_dir.join(filename);
107			std::fs::read(&path).ok().map(|bytes| {
108				let hash = Sha256::digest(&bytes);
109				hex::encode(hash)
110			})
111		};
112
113		let checks = [
114			("aggregated_common.bin", &stored_hashes.aggregated_common),
115			("aggregated_verifier.bin", &stored_hashes.aggregated_verifier),
116			("prover.bin", &stored_hashes.prover),
117			("dummy_proof.bin", &stored_hashes.dummy_proof),
118		];
119		for (filename, expected_hash) in checks {
120			if let Some(ref expected) = expected_hash {
121				if let Some(actual) = hash_file(filename) {
122					if expected != &actual {
123						mismatches.push(format!("{}...", filename));
124					}
125				}
126			}
127		}
128
129		if mismatches.is_empty() {
130			log_verbose!("   Binary hashes verified successfully");
131			Ok(())
132		} else {
133			Err(crate::error::QuantusError::Generic(format!(
134				"Binary hash mismatch detected! The circuit binaries do not match config.json.\n\
135				 This can happen if binaries were regenerated but the CLI wasn't rebuilt.\n\
136				 Mismatches:\n  {}\n\n\
137				 To fix: Run 'quantus developer build-circuits' and then 'cargo build --release'",
138				mismatches.join("\n  ")
139			)))
140		}
141	}
142}
143
144/// Compute output amount after fee deduction
145/// output = input * (10000 - fee_bps) / 10000
146pub fn compute_output_amount(input_amount: u32, fee_bps: u32) -> u32 {
147	((input_amount as u64) * (10000 - fee_bps as u64) / 10000) as u32
148}
149
150/// Parse a hex-encoded secret string into a 32-byte array
151pub fn parse_secret_hex(secret_hex: &str) -> Result<[u8; 32], String> {
152	let secret_bytes = hex::decode(secret_hex.trim_start_matches("0x"))
153		.map_err(|e| format!("Invalid secret hex: {}", e))?;
154
155	if secret_bytes.len() != 32 {
156		return Err(format!("Secret must be exactly 32 bytes, got {} bytes", secret_bytes.len()));
157	}
158
159	secret_bytes
160		.try_into()
161		.map_err(|_| "Failed to convert secret to 32-byte array".to_string())
162}
163
164/// Parse an exit account from either hex or SS58 format
165pub fn parse_exit_account(exit_account_str: &str) -> Result<[u8; 32], String> {
166	if let Some(hex_str) = exit_account_str.strip_prefix("0x") {
167		let bytes = hex::decode(hex_str).map_err(|e| format!("Invalid exit account hex: {}", e))?;
168
169		if bytes.len() != 32 {
170			return Err(format!("Exit account must be 32 bytes, got {} bytes", bytes.len()));
171		}
172
173		bytes.try_into().map_err(|_| "Failed to convert exit account".to_string())
174	} else {
175		// Try to parse as SS58
176		let account_id = AccountId32::from_ss58check(exit_account_str)
177			.map_err(|e| format!("Invalid SS58 address: {}", e))?;
178
179		Ok(account_id.into())
180	}
181}
182
183/// Quantize a funding amount from 12 decimal places to 2 decimal places
184/// Returns an error if the quantized value doesn't fit in u32
185pub fn quantize_funding_amount(amount: u128) -> Result<u32, String> {
186	let quantized = amount / SCALE_DOWN_FACTOR;
187
188	quantized
189		.try_into()
190		.map_err(|_| format!("Funding amount {} too large after quantization", quantized))
191}
192
193/// Read and decode a hex-encoded proof file
194pub fn read_proof_file(path: &str) -> Result<Vec<u8>, String> {
195	let proof_hex =
196		std::fs::read_to_string(path).map_err(|e| format!("Failed to read proof file: {}", e))?;
197
198	hex::decode(proof_hex.trim()).map_err(|e| format!("Failed to decode proof hex: {}", e))
199}
200
201/// Write a proof to a hex-encoded file
202pub fn write_proof_file(path: &str, proof_bytes: &[u8]) -> Result<(), String> {
203	let proof_hex = hex::encode(proof_bytes);
204	std::fs::write(path, proof_hex).map_err(|e| format!("Failed to write proof file: {}", e))
205}
206
207/// Format a balance amount from raw units (12 decimals) to human-readable format
208pub fn format_balance(amount: u128) -> String {
209	let whole = amount / 1_000_000_000_000;
210	let frac = (amount % 1_000_000_000_000) / 10_000_000_000; // 2 decimal places
211	format!("{}.{:02} DEV", whole, frac)
212}
213
214/// Randomly partition a total amount into n parts.
215/// Each part will be at least `min_per_part` and the sum equals `total`.
216/// Returns amounts aligned to SCALE_DOWN_FACTOR for clean quantization.
217pub fn random_partition(total: u128, n: usize, min_per_part: u128) -> Vec<u128> {
218	use rand::Rng;
219
220	if n == 0 {
221		return vec![];
222	}
223	if n == 1 {
224		return vec![total];
225	}
226
227	// Ensure minimum is achievable
228	let min_total = min_per_part * n as u128;
229	if total < min_total {
230		// Fall back to equal distribution if total is too small
231		let per_part = total / n as u128;
232		let remainder = total % n as u128;
233		let mut parts: Vec<u128> = vec![per_part; n];
234		// Add remainder to last part
235		parts[n - 1] += remainder;
236		return parts;
237	}
238
239	// Amount available for random distribution after ensuring minimums
240	let distributable = total - min_total;
241
242	// Generate n-1 random cut points in [0, distributable]
243	let mut rng = rand::rng();
244	let mut cuts: Vec<u128> = (0..n - 1).map(|_| rng.random_range(0..=distributable)).collect();
245	cuts.sort();
246
247	// Convert cuts to amounts
248	let mut parts = Vec::with_capacity(n);
249	let mut prev = 0u128;
250	for cut in cuts {
251		parts.push(min_per_part + (cut - prev));
252		prev = cut;
253	}
254	parts.push(min_per_part + (distributable - prev));
255
256	// Note: Input 'total' is already in quantized units (e.g., 998 = 9.98 DEV).
257	// No further alignment is needed - just ensure the sum equals total.
258	let sum: u128 = parts.iter().sum();
259	let diff = total as i128 - sum as i128;
260	if diff != 0 {
261		// Add/subtract difference from a random part
262		let idx = rng.random_range(0..n);
263		parts[idx] = (parts[idx] as i128 + diff).max(0) as u128;
264	}
265
266	parts
267}
268
269/// Output assignment for a single proof (supports dual outputs)
270#[derive(Debug, Clone)]
271pub struct ProofOutputAssignment {
272	/// Amount for output 1 (quantized, 2 decimal places)
273	pub output_amount_1: u32,
274	/// Exit account for output 1
275	pub exit_account_1: [u8; 32],
276	/// Amount for output 2 (quantized, 0 if unused)
277	pub output_amount_2: u32,
278	/// Exit account for output 2 (all zeros if unused)
279	pub exit_account_2: [u8; 32],
280}
281
282/// Compute random output assignments for a set of proofs.
283///
284/// This takes the input amounts for each proof and randomly distributes the outputs
285/// across the target exit accounts. Each proof can have up to 2 outputs.
286///
287/// # Algorithm:
288/// 1. Compute total output amount (sum of inputs after fee deduction)
289/// 2. Randomly partition total output across all target addresses
290/// 3. Greedily assign outputs to proofs, using dual outputs when necessary
291///
292/// # Arguments
293/// * `input_amounts` - The input amount for each proof (in planck, before fee)
294/// * `target_accounts` - The exit accounts to distribute outputs to
295/// * `fee_bps` - Fee in basis points
296///
297/// # Returns
298/// A vector of output assignments, one per proof
299pub fn compute_random_output_assignments(
300	input_amounts: &[u128],
301	target_accounts: &[[u8; 32]],
302	fee_bps: u32,
303) -> Vec<ProofOutputAssignment> {
304	use rand::seq::SliceRandom;
305
306	let num_proofs = input_amounts.len();
307	let num_targets = target_accounts.len();
308
309	if num_proofs == 0 || num_targets == 0 {
310		return vec![];
311	}
312
313	// Step 1: Compute output amounts per proof (after fee deduction)
314	let proof_outputs: Vec<u32> = input_amounts
315		.iter()
316		.map(|&input| {
317			let input_quantized = quantize_funding_amount(input).unwrap_or(0);
318			compute_output_amount(input_quantized, fee_bps)
319		})
320		.collect();
321
322	let total_output: u64 = proof_outputs.iter().map(|&x| x as u64).sum();
323
324	// Step 2: Randomly partition total output across target accounts
325	// Minimum 1 quantized unit (0.01 DEV) per target to ensure all targets receive funds
326	let min_per_target = 1u128;
327	let target_amounts_u128 = random_partition(total_output as u128, num_targets, min_per_target);
328	let target_amounts: Vec<u32> = target_amounts_u128.iter().map(|&x| x as u32).collect();
329
330	// Step 3: Assign outputs to proofs.
331	// Each proof can have at most 2 outputs to different targets.
332	//
333	// Strategy:
334	//   Pass 1 - Guarantee every target gets at least one output slot by round-robin
335	//            assigning each target as output_1 of successive proofs.
336	//   Pass 2 - Fill remaining capacity (output_2 slots and any leftover amounts)
337	//            greedily from targets that still have remaining allocation.
338	//
339	// This ensures every target address appears in at least one proof output,
340	// which is critical for the multiround flow where each target is a next-round
341	// wormhole address that must receive minted tokens.
342
343	let mut rng = rand::rng();
344
345	// Track remaining needs per target
346	let mut target_remaining: Vec<u32> = target_amounts.clone();
347
348	// Pre-allocate assignments with output_1 = full proof output, output_2 = 0
349	let mut assignments: Vec<ProofOutputAssignment> = proof_outputs
350		.iter()
351		.map(|&po| ProofOutputAssignment {
352			output_amount_1: po,
353			exit_account_1: [0u8; 32],
354			output_amount_2: 0,
355			exit_account_2: [0u8; 32],
356		})
357		.collect();
358
359	// Pass 1: Round-robin assign each target to a proof's output_1.
360	// If num_targets <= num_proofs, each target gets its own proof.
361	// If num_targets > num_proofs, later targets share proofs via output_2.
362	let mut shuffled_targets: Vec<usize> = (0..num_targets).collect();
363	shuffled_targets.shuffle(&mut rng);
364
365	for (assign_idx, &tidx) in shuffled_targets.iter().enumerate() {
366		let proof_idx = assign_idx % num_proofs;
367		let assignment = &mut assignments[proof_idx];
368
369		if assignment.exit_account_1 == [0u8; 32] {
370			// First target for this proof -> use output_1
371			let assign = assignment.output_amount_1.min(target_remaining[tidx]);
372			assignment.exit_account_1 = target_accounts[tidx];
373			// We'll fix up the exact amounts in pass 2; for now just mark the account
374			assignment.output_amount_1 = assign;
375			target_remaining[tidx] -= assign;
376		} else if assignment.exit_account_2 == [0u8; 32] {
377			// Second target for this proof -> use output_2
378			let avail = proof_outputs[proof_idx].saturating_sub(assignment.output_amount_1);
379			let assign = avail.min(target_remaining[tidx]);
380			assignment.exit_account_2 = target_accounts[tidx];
381			assignment.output_amount_2 = assign;
382			target_remaining[tidx] -= assign;
383		}
384		// If both slots taken, skip (shouldn't happen when num_targets <= 2*num_proofs)
385	}
386
387	// Pass 2: Distribute any remaining target allocations into available proof outputs.
388	// Also ensure each proof's output_1 + output_2 == proof_outputs[i].
389	for proof_idx in 0..num_proofs {
390		let total_proof_output = proof_outputs[proof_idx];
391		let current_sum =
392			assignments[proof_idx].output_amount_1 + assignments[proof_idx].output_amount_2;
393		let mut shortfall = total_proof_output.saturating_sub(current_sum);
394
395		if shortfall > 0 {
396			// Add shortfall to output_1 (its account is already set)
397			assignments[proof_idx].output_amount_1 += shortfall;
398			shortfall = 0;
399		}
400
401		// If output_1_account is still [0;32] (shouldn't happen), assign first target as fallback
402		if assignments[proof_idx].exit_account_1 == [0u8; 32] && num_targets > 0 {
403			assignments[proof_idx].exit_account_1 = target_accounts[0];
404		}
405
406		let _ = shortfall; // suppress unused warning
407	}
408
409	assignments
410}
411
412/// Result of checking proof verification events
413pub struct VerificationResult {
414	pub success: bool,
415	pub exit_amount: Option<u128>,
416	pub error_message: Option<String>,
417}
418
419/// Check for proof verification events in a transaction
420/// Returns whether ProofVerified event was found and the exit amount
421async fn check_proof_verification_events(
422	client: &subxt::OnlineClient<ChainConfig>,
423	block_hash: &subxt::utils::H256,
424	tx_hash: &subxt::utils::H256,
425	verbose: bool,
426) -> crate::error::Result<VerificationResult> {
427	use crate::chain::quantus_subxt::api::system::events::ExtrinsicFailed;
428	use colored::Colorize;
429
430	let block = client.blocks().at(*block_hash).await.map_err(|e| {
431		crate::error::QuantusError::NetworkError(format!("Failed to get block: {e:?}"))
432	})?;
433
434	let extrinsics = block.extrinsics().await.map_err(|e| {
435		crate::error::QuantusError::NetworkError(format!("Failed to get extrinsics: {e:?}"))
436	})?;
437
438	// Find our extrinsic index
439	let our_extrinsic_index = extrinsics
440		.iter()
441		.enumerate()
442		.find(|(_, ext)| ext.hash() == *tx_hash)
443		.map(|(idx, _)| idx);
444
445	let events = block.events().await.map_err(|e| {
446		crate::error::QuantusError::NetworkError(format!("Failed to fetch events: {e:?}"))
447	})?;
448
449	let metadata = client.metadata();
450
451	let mut verification_result =
452		VerificationResult { success: false, exit_amount: None, error_message: None };
453
454	if verbose {
455		log_print!("");
456		log_print!("📋 Transaction Events:");
457	}
458
459	if let Some(ext_idx) = our_extrinsic_index {
460		for event_result in events.iter() {
461			let event = event_result.map_err(|e| {
462				crate::error::QuantusError::NetworkError(format!("Failed to decode event: {e:?}"))
463			})?;
464
465			// Only process events for our extrinsic
466			if let subxt::events::Phase::ApplyExtrinsic(event_ext_idx) = event.phase() {
467				if event_ext_idx != ext_idx as u32 {
468					continue;
469				}
470
471				// Display event in verbose mode
472				if verbose {
473					log_print!(
474						"  📌 {}.{}",
475						event.pallet_name().bright_cyan(),
476						event.variant_name().bright_yellow()
477					);
478
479					// Try to decode and display event details
480					if let Ok(typed_event) =
481						event.as_root_event::<crate::chain::quantus_subxt::api::Event>()
482					{
483						log_print!("     📝 {:?}", typed_event);
484					}
485				}
486
487				// Check for ProofVerified event
488				if let Ok(Some(proof_verified)) =
489					event.as_event::<wormhole::events::ProofVerified>()
490				{
491					verification_result.success = true;
492					verification_result.exit_amount = Some(proof_verified.exit_amount);
493				}
494
495				// Check for ExtrinsicFailed event
496				if let Ok(Some(ExtrinsicFailed { dispatch_error, .. })) =
497					event.as_event::<ExtrinsicFailed>()
498				{
499					let error_msg = format_dispatch_error(&dispatch_error, &metadata);
500					verification_result.success = false;
501					verification_result.error_message = Some(error_msg);
502				}
503			}
504		}
505	}
506
507	if verbose {
508		log_print!("");
509	}
510
511	Ok(verification_result)
512}
513
514/// Format dispatch error for display
515fn format_dispatch_error(
516	error: &crate::chain::quantus_subxt::api::runtime_types::sp_runtime::DispatchError,
517	metadata: &subxt::Metadata,
518) -> String {
519	use crate::chain::quantus_subxt::api::runtime_types::sp_runtime::DispatchError;
520
521	match error {
522		DispatchError::Module(module_error) => {
523			let pallet_name = metadata
524				.pallet_by_index(module_error.index)
525				.map(|p| p.name())
526				.unwrap_or("Unknown");
527			let error_index = module_error.error[0];
528			format!("{}::Error[{}]", pallet_name, error_index)
529		},
530		DispatchError::BadOrigin => "BadOrigin".to_string(),
531		DispatchError::CannotLookup => "CannotLookup".to_string(),
532		DispatchError::Other => "Other".to_string(),
533		_ => format!("{:?}", error),
534	}
535}
536
537#[derive(Subcommand, Debug)]
538pub enum WormholeCommands {
539	/// Derive the unspendable wormhole address from a secret
540	Address {
541		/// Secret (32-byte hex string) - used to derive the unspendable account
542		#[arg(long)]
543		secret: String,
544	},
545	/// Generate a wormhole proof from an existing transfer
546	Prove {
547		/// Secret (32-byte hex string) used for the transfer
548		#[arg(long)]
549		secret: String,
550
551		/// Funding amount that was transferred
552		#[arg(long)]
553		amount: u128,
554
555		/// Exit account (where funds will be withdrawn, hex or SS58)
556		#[arg(long)]
557		exit_account: String,
558
559		/// Block hash to generate proof against (hex)
560		#[arg(long)]
561		block: String,
562
563		/// Transfer count from the transfer event
564		#[arg(long)]
565		transfer_count: u64,
566
567		/// Funding account (sender of transfer, hex or SS58)
568		#[arg(long)]
569		funding_account: String,
570
571		/// Output file for the proof (default: proof.hex)
572		#[arg(short, long, default_value = "proof.hex")]
573		output: String,
574	},
575	/// Aggregate multiple wormhole proofs into a single proof
576	Aggregate {
577		/// Input proof files (hex-encoded)
578		#[arg(short, long, num_args = 1..)]
579		proofs: Vec<String>,
580
581		/// Output file for the aggregated proof (default: aggregated_proof.hex)
582		#[arg(short, long, default_value = "aggregated_proof.hex")]
583		output: String,
584	},
585	/// Verify an aggregated wormhole proof on-chain
586	VerifyAggregated {
587		/// Path to the aggregated proof file (hex-encoded)
588		#[arg(short, long, default_value = "aggregated_proof.hex")]
589		proof: String,
590	},
591	/// Parse and display the contents of a proof file (for debugging)
592	ParseProof {
593		/// Path to the proof file (hex-encoded)
594		#[arg(short, long)]
595		proof: String,
596
597		/// Parse as aggregated proof (default: false, parses as leaf proof)
598		#[arg(long)]
599		aggregated: bool,
600
601		/// Verify the proof cryptographically (local verification, not on-chain)
602		#[arg(long)]
603		verify: bool,
604	},
605	/// Run a multi-round wormhole test: wallet -> wormhole -> ... -> wallet
606	Multiround {
607		/// Number of proofs per round (default: 2, max: 8)
608		#[arg(short, long, default_value = "2")]
609		num_proofs: usize,
610
611		/// Number of rounds (default: 2)
612		#[arg(short, long, default_value = "2")]
613		rounds: usize,
614
615		/// Total amount in planck to partition across all proofs (default: 100 DEV)
616		#[arg(short, long, default_value = "100000000000000")]
617		amount: u128,
618
619		/// Wallet name to use for funding and final exit
620		#[arg(short, long)]
621		wallet: String,
622
623		/// Password for the wallet
624		#[arg(short, long)]
625		password: Option<String>,
626
627		/// Read password from file
628		#[arg(long)]
629		password_file: Option<String>,
630
631		/// Keep proof files after completion
632		#[arg(short, long)]
633		keep_files: bool,
634
635		/// Output directory for proof files
636		#[arg(short, long, default_value = "/tmp/wormhole_multiround")]
637		output_dir: String,
638
639		/// Dry run - show what would be done without executing
640		#[arg(long)]
641		dry_run: bool,
642	},
643}
644
645pub async fn handle_wormhole_command(
646	command: WormholeCommands,
647	node_url: &str,
648) -> crate::error::Result<()> {
649	match command {
650		WormholeCommands::Address { secret } => show_wormhole_address(secret),
651		WormholeCommands::Prove {
652			secret,
653			amount,
654			exit_account,
655			block,
656			transfer_count,
657			funding_account,
658			output,
659		} => {
660			log_print!("Generating proof from existing transfer...");
661
662			// Connect to node
663			let quantus_client = QuantusClient::new(node_url).await.map_err(|e| {
664				crate::error::QuantusError::Generic(format!("Failed to connect: {}", e))
665			})?;
666
667			// Parse exit account
668			let exit_account_bytes =
669				parse_exit_account(&exit_account).map_err(crate::error::QuantusError::Generic)?;
670
671			// Quantize amount and compute output (single output, no change)
672			let input_amount_quantized =
673				quantize_funding_amount(amount).map_err(crate::error::QuantusError::Generic)?;
674			let output_amount = compute_output_amount(input_amount_quantized, VOLUME_FEE_BPS);
675
676			let output_assignment = ProofOutputAssignment {
677				output_amount_1: output_amount,
678				exit_account_1: exit_account_bytes,
679				output_amount_2: 0,
680				exit_account_2: [0u8; 32],
681			};
682
683			let prove_start = std::time::Instant::now();
684			generate_proof(
685				&secret,
686				amount,
687				&output_assignment,
688				&block,
689				transfer_count,
690				&funding_account,
691				&output,
692				&quantus_client,
693			)
694			.await?;
695			let prove_elapsed = prove_start.elapsed();
696			log_print!("Proof generation: {:.2}s", prove_elapsed.as_secs_f64());
697			Ok(())
698		},
699		WormholeCommands::Aggregate { proofs, output } => aggregate_proofs(proofs, output).await,
700		WormholeCommands::VerifyAggregated { proof } =>
701			verify_aggregated_proof(proof, node_url).await,
702		WormholeCommands::ParseProof { proof, aggregated, verify } =>
703			parse_proof_file(proof, aggregated, verify).await,
704		WormholeCommands::Multiround {
705			num_proofs,
706			rounds,
707			amount,
708			wallet,
709			password,
710			password_file,
711			keep_files,
712			output_dir,
713			dry_run,
714		} =>
715			run_multiround(
716				num_proofs,
717				rounds,
718				amount,
719				wallet,
720				password,
721				password_file,
722				keep_files,
723				output_dir,
724				dry_run,
725				node_url,
726			)
727			.await,
728	}
729}
730
731pub type TransferProofKey = (u32, u64, AccountId32, AccountId32, u128);
732
733/// Derive and display the unspendable wormhole address from a secret.
734/// Users can then send funds to this address using `quantus send`.
735fn show_wormhole_address(secret_hex: String) -> crate::error::Result<()> {
736	use colored::Colorize;
737
738	let secret_array =
739		parse_secret_hex(&secret_hex).map_err(crate::error::QuantusError::Generic)?;
740	let secret: BytesDigest = secret_array.try_into().map_err(|e| {
741		crate::error::QuantusError::Generic(format!("Failed to convert secret: {:?}", e))
742	})?;
743
744	let unspendable_account =
745		qp_wormhole_circuit::unspendable_account::UnspendableAccount::from_secret(secret)
746			.account_id;
747	let unspendable_account_bytes_digest =
748		qp_zk_circuits_common::utils::digest_felts_to_bytes(unspendable_account);
749	let unspendable_account_bytes: [u8; 32] = unspendable_account_bytes_digest
750		.as_ref()
751		.try_into()
752		.expect("BytesDigest is always 32 bytes");
753
754	let account_id = sp_core::crypto::AccountId32::new(unspendable_account_bytes);
755	let ss58_address =
756		account_id.to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(189));
757
758	log_print!("{}", "Wormhole Address".bright_cyan());
759	log_print!("  SS58:  {}", ss58_address.bright_green());
760	log_print!("  Hex:   0x{}", hex::encode(unspendable_account_bytes));
761	log_print!("");
762	log_print!("To fund this address:");
763	log_print!("  quantus send --from <wallet> --to {} --amount <amount>", ss58_address);
764
765	Ok(())
766}
767
768async fn at_best_block(
769	quantus_client: &QuantusClient,
770) -> anyhow::Result<Block<ChainConfig, OnlineClient<ChainConfig>>> {
771	let best_block = quantus_client.get_latest_block().await?;
772	let block = quantus_client.client().blocks().at(best_block).await?;
773	Ok(block)
774}
775
776async fn aggregate_proofs(
777	proof_files: Vec<String>,
778	output_file: String,
779) -> crate::error::Result<()> {
780	use qp_wormhole_aggregator::aggregator::WormholeProofAggregator;
781	use qp_zk_circuits_common::aggregation::AggregationConfig as AggConfig;
782
783	use std::path::Path;
784
785	log_print!("Aggregating {} proofs...", proof_files.len());
786
787	// Load config first to validate and calculate padding needs
788	let bins_dir = Path::new("generated-bins");
789	let agg_config = AggregationConfig::load_from_bins()?;
790
791	// Verify binary hashes match config.json to detect stale binaries
792	log_verbose!("Verifying circuit binary integrity...");
793	agg_config.verify_binary_hashes()?;
794
795	// Validate number of proofs before doing expensive work
796	if proof_files.len() > agg_config.num_leaf_proofs {
797		return Err(crate::error::QuantusError::Generic(format!(
798			"Too many proofs: {} provided, max {} supported by circuit",
799			proof_files.len(),
800			agg_config.num_leaf_proofs
801		)));
802	}
803
804	let num_padding_proofs = agg_config.num_leaf_proofs - proof_files.len();
805
806	// Create progress bar for padding proof generation (if needed)
807	// Load aggregator from pre-built bins (reads config from config.json)
808	// This also generates the padding (dummy) proofs needed
809	log_print!("  Loading aggregator and generating {} dummy proofs...", num_padding_proofs);
810
811	let aggr_config = AggConfig::new(agg_config.num_leaf_proofs);
812	let mut aggregator = WormholeProofAggregator::from_prebuilt_dir(bins_dir, aggr_config)
813		.map_err(|e| {
814			crate::error::QuantusError::Generic(format!(
815				"Failed to load aggregator from pre-built bins: {}",
816				e
817			))
818		})?;
819
820	log_verbose!("Aggregation config: num_leaf_proofs={}", aggregator.config.num_leaf_proofs);
821	let common_data = aggregator.leaf_circuit_data.common.clone();
822
823	// Load and add proofs using helper function
824	for (idx, proof_file) in proof_files.iter().enumerate() {
825		log_verbose!("Loading proof {}/{}: {}", idx + 1, proof_files.len(), proof_file);
826
827		let proof_bytes = read_proof_file(proof_file).map_err(|e| {
828			crate::error::QuantusError::Generic(format!("Failed to load {}: {}", proof_file, e))
829		})?;
830
831		let proof = ProofWithPublicInputs::<F, C, D>::from_bytes(proof_bytes, &common_data)
832			.map_err(|e| {
833				crate::error::QuantusError::Generic(format!(
834					"Failed to deserialize proof from {}: {}",
835					proof_file, e
836				))
837			})?;
838
839		aggregator.push_proof(proof).map_err(|e| {
840			crate::error::QuantusError::Generic(format!("Failed to add proof: {}", e))
841		})?;
842	}
843
844	log_print!("  Running aggregation...");
845	let agg_start = std::time::Instant::now();
846	let aggregated_proof = aggregator
847		.aggregate()
848		.map_err(|e| crate::error::QuantusError::Generic(format!("Aggregation failed: {}", e)))?;
849	let agg_elapsed = agg_start.elapsed();
850	log_print!("  Aggregation: {:.2}s", agg_elapsed.as_secs_f64());
851
852	// Parse and display aggregated public inputs
853	let aggregated_public_inputs = AggregatedPublicCircuitInputs::try_from_felts(
854		aggregated_proof.proof.public_inputs.as_slice(),
855	)
856	.map_err(|e| {
857		crate::error::QuantusError::Generic(format!(
858			"Failed to parse aggregated public inputs: {}",
859			e
860		))
861	})?;
862
863	log_verbose!("Aggregated public inputs: {:#?}", aggregated_public_inputs);
864
865	// Log exit accounts and amounts that will be minted
866	log_print!("  Exit accounts in aggregated proof:");
867	for (idx, account_data) in aggregated_public_inputs.account_data.iter().enumerate() {
868		let exit_bytes: &[u8] = account_data.exit_account.as_ref();
869		let is_dummy = exit_bytes.iter().all(|&b| b == 0) || account_data.summed_output_amount == 0;
870		if is_dummy {
871			log_verbose!("    [{}] DUMMY (skipped)", idx);
872		} else {
873			// De-quantize to show actual amount that will be minted
874			let dequantized_amount =
875				(account_data.summed_output_amount as u128) * SCALE_DOWN_FACTOR;
876			log_print!(
877				"    [{}] {} -> {} quantized ({} planck = {})",
878				idx,
879				hex::encode(exit_bytes),
880				account_data.summed_output_amount,
881				dequantized_amount,
882				format_balance(dequantized_amount)
883			);
884		}
885	}
886
887	// Verify the aggregated proof locally
888	log_verbose!("Verifying aggregated proof locally...");
889	aggregated_proof
890		.circuit_data
891		.verify(aggregated_proof.proof.clone())
892		.map_err(|e| {
893			crate::error::QuantusError::Generic(format!(
894				"Aggregated proof verification failed: {}",
895				e
896			))
897		})?;
898
899	// Save aggregated proof using helper function
900	write_proof_file(&output_file, &aggregated_proof.proof.to_bytes()).map_err(|e| {
901		crate::error::QuantusError::Generic(format!("Failed to write proof: {}", e))
902	})?;
903
904	log_success!("Aggregation complete!");
905	log_success!("Output: {}", output_file);
906	log_print!(
907		"Aggregated {} proofs into 1 proof with {} exit accounts",
908		proof_files.len(),
909		aggregated_public_inputs.account_data.len()
910	);
911
912	Ok(())
913}
914
915#[derive(Debug, Clone, Copy)]
916enum IncludedAt {
917	Best,
918	Finalized,
919}
920
921impl IncludedAt {
922	fn label(self) -> &'static str {
923		match self {
924			IncludedAt::Best => "best block",
925			IncludedAt::Finalized => "finalized block",
926		}
927	}
928}
929
930fn read_hex_proof_file_to_bytes(proof_file: &str) -> crate::error::Result<Vec<u8>> {
931	let proof_hex = std::fs::read_to_string(proof_file).map_err(|e| {
932		crate::error::QuantusError::Generic(format!("Failed to read proof file: {}", e))
933	})?;
934
935	let proof_bytes = hex::decode(proof_hex.trim())
936		.map_err(|e| crate::error::QuantusError::Generic(format!("Failed to decode hex: {}", e)))?;
937
938	Ok(proof_bytes)
939}
940
941/// Submit unsigned verify_aggregated_proof(proof_bytes) and return (included_at, block_hash,
942/// tx_hash).
943async fn submit_unsigned_verify_aggregated_proof(
944	quantus_client: &QuantusClient,
945	proof_bytes: Vec<u8>,
946) -> crate::error::Result<(IncludedAt, subxt::utils::H256, subxt::utils::H256)> {
947	use subxt::tx::TxStatus;
948
949	let verify_tx = quantus_node::api::tx().wormhole().verify_aggregated_proof(proof_bytes);
950
951	let unsigned_tx = quantus_client.client().tx().create_unsigned(&verify_tx).map_err(|e| {
952		crate::error::QuantusError::Generic(format!("Failed to create unsigned tx: {}", e))
953	})?;
954
955	let mut tx_progress = unsigned_tx
956		.submit_and_watch()
957		.await
958		.map_err(|e| crate::error::QuantusError::Generic(format!("Failed to submit tx: {}", e)))?;
959
960	while let Some(Ok(status)) = tx_progress.next().await {
961		match status {
962			TxStatus::InBestBlock(tx_in_block) => {
963				return Ok((
964					IncludedAt::Best,
965					tx_in_block.block_hash(),
966					tx_in_block.extrinsic_hash(),
967				));
968			},
969			TxStatus::InFinalizedBlock(tx_in_block) => {
970				return Ok((
971					IncludedAt::Finalized,
972					tx_in_block.block_hash(),
973					tx_in_block.extrinsic_hash(),
974				));
975			},
976			TxStatus::Error { message } | TxStatus::Invalid { message } => {
977				return Err(crate::error::QuantusError::Generic(format!(
978					"Transaction failed: {}",
979					message
980				)));
981			},
982			_ => continue,
983		}
984	}
985
986	Err(crate::error::QuantusError::Generic("Transaction stream ended unexpectedly".to_string()))
987}
988
989/// Collect wormhole events for our extrinsic (by tx_hash) in a given block.
990/// Returns (found_proof_verified, native_transfers).
991async fn collect_wormhole_events_for_extrinsic(
992	quantus_client: &QuantusClient,
993	block_hash: subxt::utils::H256,
994	tx_hash: subxt::utils::H256,
995) -> crate::error::Result<(bool, Vec<wormhole::events::NativeTransferred>)> {
996	use crate::chain::quantus_subxt::api::system::events::ExtrinsicFailed;
997
998	let block =
999		quantus_client.client().blocks().at(block_hash).await.map_err(|e| {
1000			crate::error::QuantusError::Generic(format!("Failed to get block: {}", e))
1001		})?;
1002
1003	let events = block
1004		.events()
1005		.await
1006		.map_err(|e| crate::error::QuantusError::Generic(format!("Failed to get events: {}", e)))?;
1007
1008	let extrinsics = block.extrinsics().await.map_err(|e| {
1009		crate::error::QuantusError::Generic(format!("Failed to get extrinsics: {}", e))
1010	})?;
1011
1012	let our_ext_idx = extrinsics
1013		.iter()
1014		.enumerate()
1015		.find(|(_, ext)| ext.hash() == tx_hash)
1016		.map(|(idx, _)| idx as u32)
1017		.ok_or_else(|| {
1018			crate::error::QuantusError::Generic(
1019				"Could not find submitted extrinsic in included block".to_string(),
1020			)
1021		})?;
1022
1023	let mut transfer_events = Vec::new();
1024	let mut found_proof_verified = false;
1025
1026	log_verbose!("  Events for our extrinsic (idx={}):", our_ext_idx);
1027
1028	for event_result in events.iter() {
1029		let event = event_result.map_err(|e| {
1030			crate::error::QuantusError::Generic(format!("Failed to decode event: {}", e))
1031		})?;
1032
1033		if let subxt::events::Phase::ApplyExtrinsic(ext_idx) = event.phase() {
1034			if ext_idx == our_ext_idx {
1035				log_print!("    Event: {}::{}", event.pallet_name(), event.variant_name());
1036
1037				// Decode ExtrinsicFailed to get the specific error
1038				if let Ok(Some(ExtrinsicFailed { dispatch_error, .. })) =
1039					event.as_event::<ExtrinsicFailed>()
1040				{
1041					let metadata = quantus_client.client().metadata();
1042					let error_msg = format_dispatch_error(&dispatch_error, &metadata);
1043					log_print!("    DispatchError: {}", error_msg);
1044				}
1045
1046				if let Ok(Some(_)) = event.as_event::<wormhole::events::ProofVerified>() {
1047					found_proof_verified = true;
1048				}
1049
1050				if let Ok(Some(transfer)) = event.as_event::<wormhole::events::NativeTransferred>()
1051				{
1052					transfer_events.push(transfer);
1053				}
1054			}
1055		}
1056	}
1057
1058	Ok((found_proof_verified, transfer_events))
1059}
1060
1061async fn verify_aggregated_proof(proof_file: String, node_url: &str) -> crate::error::Result<()> {
1062	log_print!("Verifying aggregated wormhole proof on-chain...");
1063
1064	let proof_bytes = read_hex_proof_file_to_bytes(&proof_file)?;
1065	log_verbose!("Aggregated proof size: {} bytes", proof_bytes.len());
1066
1067	// Connect to node
1068	let quantus_client = QuantusClient::new(node_url)
1069		.await
1070		.map_err(|e| crate::error::QuantusError::Generic(format!("Failed to connect: {}", e)))?;
1071	log_verbose!("Connected to node");
1072
1073	log_verbose!("Submitting unsigned aggregated verification transaction...");
1074
1075	let (included_at, block_hash, tx_hash) =
1076		submit_unsigned_verify_aggregated_proof(&quantus_client, proof_bytes).await?;
1077
1078	// One unified check (no best/finalized copy-paste)
1079	let result = check_proof_verification_events(
1080		quantus_client.client(),
1081		&block_hash,
1082		&tx_hash,
1083		crate::log::is_verbose(),
1084	)
1085	.await?;
1086
1087	if result.success {
1088		log_success!("Aggregated proof verified successfully on-chain!");
1089		if let Some(amount) = result.exit_amount {
1090			log_success!("Total exit amount: {}", format_balance(amount));
1091		}
1092
1093		log_verbose!("Included in {}: {:?}", included_at.label(), block_hash);
1094		return Ok(());
1095	}
1096
1097	let error_msg = result.error_message.unwrap_or_else(|| {
1098		"Aggregated proof verification failed - no ProofVerified event found".to_string()
1099	});
1100	log_error!("❌ {}", error_msg);
1101	Err(crate::error::QuantusError::Generic(error_msg))
1102}
1103
1104// ============================================================================
1105// Multi-round wormhole flow implementation
1106// ============================================================================
1107
1108/// Information about a transfer needed for proof generation
1109#[derive(Debug, Clone)]
1110#[allow(dead_code)]
1111struct TransferInfo {
1112	/// Block hash where the transfer was included
1113	block_hash: subxt::utils::H256,
1114	/// Transfer count for this specific transfer
1115	transfer_count: u64,
1116	/// Amount transferred
1117	amount: u128,
1118	/// The wormhole address (destination of transfer)
1119	wormhole_address: SubxtAccountId,
1120	/// The funding account (source of transfer)
1121	funding_account: SubxtAccountId,
1122}
1123
1124/// Derive a wormhole secret using HD derivation
1125/// Path: m/44'/189189189'/0'/round'/index'
1126fn derive_wormhole_secret(
1127	mnemonic: &str,
1128	round: usize,
1129	index: usize,
1130) -> Result<WormholePair, crate::error::QuantusError> {
1131	// QUANTUS_WORMHOLE_CHAIN_ID already includes the ' (e.g., "189189189'")
1132	let path = format!("m/44'/{}/0'/{}'/{}'", QUANTUS_WORMHOLE_CHAIN_ID, round, index);
1133	derive_wormhole_from_mnemonic(mnemonic, None, &path)
1134		.map_err(|e| crate::error::QuantusError::Generic(format!("HD derivation failed: {:?}", e)))
1135}
1136
1137/// Calculate the amount for a given round, accounting for fees
1138/// Each round deducts 0.1% fee (10 bps)
1139/// Round 1: fee applied once, Round 2: fee applied twice, etc.
1140fn calculate_round_amount(initial_amount: u128, round: usize) -> u128 {
1141	let mut amount = initial_amount;
1142	for _ in 0..round {
1143		// Output = Input * (10000 - 10) / 10000
1144		amount = amount * 9990 / 10000;
1145	}
1146	amount
1147}
1148
1149/// Get the minting account from chain constants
1150async fn get_minting_account(
1151	client: &OnlineClient<ChainConfig>,
1152) -> Result<SubxtAccountId, crate::error::QuantusError> {
1153	let minting_account_addr = quantus_node::api::constants().wormhole().minting_account();
1154	let minting_account = client.constants().at(&minting_account_addr).map_err(|e| {
1155		crate::error::QuantusError::Generic(format!("Failed to get minting account: {}", e))
1156	})?;
1157	Ok(minting_account)
1158}
1159
1160/// Parse transfer info from NativeTransferred events in a block and updates block hash for all
1161/// transfers
1162fn parse_transfer_events(
1163	events: &[wormhole::events::NativeTransferred],
1164	expected_addresses: &[SubxtAccountId],
1165	block_hash: subxt::utils::H256,
1166) -> Result<Vec<TransferInfo>, crate::error::QuantusError> {
1167	let mut transfer_infos = Vec::new();
1168
1169	for expected_addr in expected_addresses {
1170		// Find the event matching this address
1171		let matching_event = events.iter().find(|e| &e.to == expected_addr).ok_or_else(|| {
1172			crate::error::QuantusError::Generic(format!(
1173				"No transfer event found for address {:?}",
1174				expected_addr
1175			))
1176		})?;
1177
1178		transfer_infos.push(TransferInfo {
1179			block_hash,
1180			transfer_count: matching_event.transfer_count,
1181			amount: matching_event.amount,
1182			wormhole_address: expected_addr.clone(),
1183			funding_account: matching_event.from.clone(),
1184		});
1185	}
1186
1187	Ok(transfer_infos)
1188}
1189
1190/// Configuration for multiround execution
1191struct MultiroundConfig {
1192	num_proofs: usize,
1193	rounds: usize,
1194	amount: u128,
1195	output_dir: String,
1196	keep_files: bool,
1197}
1198
1199/// Wallet context for multiround execution
1200struct MultiroundWalletContext {
1201	wallet_name: String,
1202	wallet_address: String,
1203	wallet_account_id: SubxtAccountId,
1204	keypair: QuantumKeyPair,
1205	mnemonic: String,
1206}
1207
1208/// Validate multiround parameters
1209fn validate_multiround_params(
1210	num_proofs: usize,
1211	rounds: usize,
1212	max_proofs: usize,
1213) -> crate::error::Result<()> {
1214	if !(1..=max_proofs).contains(&num_proofs) {
1215		return Err(crate::error::QuantusError::Generic(format!(
1216			"num_proofs must be between 1 and {} (got: {})",
1217			max_proofs, num_proofs
1218		)));
1219	}
1220	if rounds < 1 {
1221		return Err(crate::error::QuantusError::Generic(format!(
1222			"rounds must be at least 1 (got: {})",
1223			rounds
1224		)));
1225	}
1226	Ok(())
1227}
1228
1229/// Load wallet and prepare context for multiround execution
1230fn load_multiround_wallet(
1231	wallet_name: &str,
1232	password: Option<String>,
1233	password_file: Option<String>,
1234) -> crate::error::Result<MultiroundWalletContext> {
1235	let wallet_manager = WalletManager::new()?;
1236	let wallet_password = password::get_wallet_password(wallet_name, password, password_file)?;
1237	let wallet_data = wallet_manager.load_wallet(wallet_name, &wallet_password)?;
1238	let wallet_address = wallet_data.keypair.to_account_id_ss58check();
1239	let wallet_account_id = SubxtAccountId(wallet_data.keypair.to_account_id_32().into());
1240
1241	// Get or generate mnemonic for HD derivation
1242	let mnemonic = match wallet_data.mnemonic {
1243		Some(m) => {
1244			log_verbose!("Using wallet mnemonic for HD derivation");
1245			m
1246		},
1247		None => {
1248			log_print!("Wallet has no mnemonic - generating random mnemonic for wormhole secrets");
1249			let mut entropy = [0u8; 32];
1250			rand::rng().fill_bytes(&mut entropy);
1251			let sensitive_entropy = SensitiveBytes32::from(&mut entropy);
1252			let m = generate_mnemonic(sensitive_entropy).map_err(|e| {
1253				crate::error::QuantusError::Generic(format!("Failed to generate mnemonic: {:?}", e))
1254			})?;
1255			log_verbose!("Generated mnemonic (not saved): {}", m);
1256			m
1257		},
1258	};
1259
1260	Ok(MultiroundWalletContext {
1261		wallet_name: wallet_name.to_string(),
1262		wallet_address,
1263		wallet_account_id,
1264		keypair: wallet_data.keypair,
1265		mnemonic,
1266	})
1267}
1268
1269/// Print multiround configuration summary
1270fn print_multiround_config(
1271	config: &MultiroundConfig,
1272	wallet: &MultiroundWalletContext,
1273	agg_config: &AggregationConfig,
1274) {
1275	use colored::Colorize;
1276
1277	log_print!("{}", "Configuration:".bright_cyan());
1278	log_print!("  Wallet: {}", wallet.wallet_name);
1279	log_print!("  Wallet address: {}", wallet.wallet_address);
1280	log_print!(
1281		"  Total amount: {} ({}) - randomly partitioned across {} proofs",
1282		config.amount,
1283		format_balance(config.amount),
1284		config.num_proofs
1285	);
1286	log_print!("  Proofs per round: {}", config.num_proofs);
1287	log_print!("  Rounds: {}", config.rounds);
1288	log_print!("  Aggregation: num_leaf_proofs={}", agg_config.num_leaf_proofs);
1289	log_print!("  Output directory: {}", config.output_dir);
1290	log_print!("  Keep files: {}", config.keep_files);
1291	log_print!("");
1292
1293	// Show expected amounts per round
1294	log_print!("{}", "Expected amounts per round:".bright_cyan());
1295	for r in 1..=config.rounds {
1296		let round_amount = calculate_round_amount(config.amount, r);
1297		log_print!("  Round {}: {} ({})", r, round_amount, format_balance(round_amount));
1298	}
1299	log_print!("");
1300}
1301
1302/// Execute initial transfers from wallet to wormhole addresses (round 1 only).
1303///
1304/// Sends all transfers in a single batched extrinsic using `utility.batch()`,
1305/// then parses the `NativeTransferred` events to extract transfer info for proof generation.
1306async fn execute_initial_transfers(
1307	quantus_client: &QuantusClient,
1308	wallet: &MultiroundWalletContext,
1309	secrets: &[WormholePair],
1310	amount: u128,
1311	num_proofs: usize,
1312) -> crate::error::Result<Vec<TransferInfo>> {
1313	use colored::Colorize;
1314	use quantus_node::api::runtime_types::{
1315		pallet_balances::pallet::Call as BalancesCall, quantus_runtime::RuntimeCall,
1316	};
1317
1318	log_print!("{}", "Step 1: Sending batched transfer to wormhole addresses...".bright_yellow());
1319
1320	// Randomly partition the total amount among proofs
1321	// Each partition must meet the on-chain minimum transfer amount
1322	// Minimum per partition is 0.02 DEV (2 quantized units) to ensure non-trivial amounts
1323	let partition_amounts = random_partition(amount, num_proofs, 2 * SCALE_DOWN_FACTOR);
1324	log_print!("  Random partition of {} ({}):", amount, format_balance(amount));
1325	for (i, &amt) in partition_amounts.iter().enumerate() {
1326		log_print!("    Proof {}: {} ({})", i + 1, amt, format_balance(amt));
1327	}
1328
1329	// Build batch of transfer calls
1330	let mut calls = Vec::with_capacity(num_proofs);
1331	for (i, secret) in secrets.iter().enumerate() {
1332		let wormhole_address = SubxtAccountId(secret.address);
1333		let transfer_call = RuntimeCall::Balances(BalancesCall::transfer_allow_death {
1334			dest: subxt::ext::subxt_core::utils::MultiAddress::Id(wormhole_address),
1335			value: partition_amounts[i],
1336		});
1337		calls.push(transfer_call);
1338	}
1339
1340	let batch_tx = quantus_node::api::tx().utility().batch(calls);
1341
1342	let quantum_keypair = QuantumKeyPair {
1343		public_key: wallet.keypair.public_key.clone(),
1344		private_key: wallet.keypair.private_key.clone(),
1345	};
1346
1347	log_print!("  Submitting batch of {} transfers...", num_proofs);
1348
1349	submit_transaction(
1350		quantus_client,
1351		&quantum_keypair,
1352		batch_tx,
1353		None,
1354		ExecutionMode { finalized: false, wait_for_transaction: true },
1355	)
1356	.await
1357	.map_err(|e| crate::error::QuantusError::Generic(format!("Batch transfer failed: {}", e)))?;
1358
1359	// Get the block and find all NativeTransferred events
1360	let client = quantus_client.client();
1361	let block = at_best_block(quantus_client)
1362		.await
1363		.map_err(|e| crate::error::QuantusError::Generic(format!("Failed to get block: {}", e)))?;
1364	let block_hash = block.hash();
1365
1366	let events_api =
1367		client.events().at(block_hash).await.map_err(|e| {
1368			crate::error::QuantusError::Generic(format!("Failed to get events: {}", e))
1369		})?;
1370
1371	// Match each secret's wormhole address to its NativeTransferred event
1372	let funding_account: SubxtAccountId = SubxtAccountId(wallet.keypair.to_account_id_32().into());
1373	let mut transfers = Vec::with_capacity(num_proofs);
1374
1375	for (i, secret) in secrets.iter().enumerate() {
1376		let event = events_api
1377			.find::<wormhole::events::NativeTransferred>()
1378			.find(|e| if let Ok(evt) = e { evt.to.0 == secret.address } else { false })
1379			.ok_or_else(|| {
1380				crate::error::QuantusError::Generic(format!(
1381					"No NativeTransferred event found for wormhole address {} (proof {})",
1382					hex::encode(secret.address),
1383					i + 1
1384				))
1385			})?
1386			.map_err(|e| {
1387				crate::error::QuantusError::Generic(format!("Event decode error: {}", e))
1388			})?;
1389
1390		transfers.push(TransferInfo {
1391			block_hash,
1392			transfer_count: event.transfer_count,
1393			amount: partition_amounts[i],
1394			wormhole_address: SubxtAccountId(secret.address),
1395			funding_account: funding_account.clone(),
1396		});
1397	}
1398
1399	log_success!(
1400		"  {} transfers submitted in a single batch (block {})",
1401		num_proofs,
1402		hex::encode(block_hash.0)
1403	);
1404
1405	Ok(transfers)
1406}
1407
1408/// Generate proofs for a round with random output partitioning
1409async fn generate_round_proofs(
1410	quantus_client: &QuantusClient,
1411	secrets: &[WormholePair],
1412	transfers: &[TransferInfo],
1413	exit_accounts: &[SubxtAccountId],
1414	round_dir: &str,
1415	num_proofs: usize,
1416) -> crate::error::Result<Vec<String>> {
1417	use colored::Colorize;
1418
1419	log_print!("{}", "Step 2: Generating proofs...".bright_yellow());
1420
1421	// All proofs in an aggregation batch must use the same block for storage proofs.
1422	let proof_block = at_best_block(quantus_client)
1423		.await
1424		.map_err(|e| crate::error::QuantusError::Generic(format!("Failed to get block: {}", e)))?;
1425	let proof_block_hash = proof_block.hash();
1426	log_print!("  Using block {} for all proofs", hex::encode(proof_block_hash.0));
1427
1428	// Collect input amounts and exit accounts for random assignment
1429	let input_amounts: Vec<u128> = transfers.iter().map(|t| t.amount).collect();
1430	let exit_account_bytes: Vec<[u8; 32]> = exit_accounts.iter().map(|a| a.0).collect();
1431
1432	// Compute random output assignments (each proof can have 2 outputs)
1433	let output_assignments =
1434		compute_random_output_assignments(&input_amounts, &exit_account_bytes, VOLUME_FEE_BPS);
1435
1436	// Log the random partition
1437	log_print!("  Random output partition:");
1438	for (i, assignment) in output_assignments.iter().enumerate() {
1439		let amt1_planck = (assignment.output_amount_1 as u128) * SCALE_DOWN_FACTOR;
1440		if assignment.output_amount_2 > 0 {
1441			let amt2_planck = (assignment.output_amount_2 as u128) * SCALE_DOWN_FACTOR;
1442			log_print!(
1443				"    Proof {}: {} ({}) -> 0x{}..., {} ({}) -> 0x{}...",
1444				i + 1,
1445				assignment.output_amount_1,
1446				format_balance(amt1_planck),
1447				hex::encode(&assignment.exit_account_1[..4]),
1448				assignment.output_amount_2,
1449				format_balance(amt2_planck),
1450				hex::encode(&assignment.exit_account_2[..4])
1451			);
1452		} else {
1453			log_print!(
1454				"    Proof {}: {} ({}) -> 0x{}...",
1455				i + 1,
1456				assignment.output_amount_1,
1457				format_balance(amt1_planck),
1458				hex::encode(&assignment.exit_account_1[..4])
1459			);
1460		}
1461	}
1462
1463	let pb = ProgressBar::new(num_proofs as u64);
1464	pb.set_style(
1465		ProgressStyle::default_bar()
1466			.template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
1467			.unwrap()
1468			.progress_chars("#>-"),
1469	);
1470
1471	let proof_gen_start = std::time::Instant::now();
1472	let mut proof_files = Vec::new();
1473	for (i, (secret, transfer)) in secrets.iter().zip(transfers.iter()).enumerate() {
1474		pb.set_message(format!("Proof {}/{}", i + 1, num_proofs));
1475
1476		let proof_file = format!("{}/proof_{}.hex", round_dir, i + 1);
1477
1478		// Use the funding account from the transfer info
1479		let funding_account_hex = format!("0x{}", hex::encode(transfer.funding_account.0));
1480
1481		let single_start = std::time::Instant::now();
1482
1483		// Generate proof with dual output assignment
1484		generate_proof(
1485			&hex::encode(secret.secret),
1486			transfer.amount, // Use actual transfer amount for storage key
1487			&output_assignments[i],
1488			&format!("0x{}", hex::encode(proof_block_hash.0)),
1489			transfer.transfer_count,
1490			&funding_account_hex,
1491			&proof_file,
1492			quantus_client,
1493		)
1494		.await?;
1495
1496		let single_elapsed = single_start.elapsed();
1497		log_verbose!("  Proof {} generated in {:.2}s", i + 1, single_elapsed.as_secs_f64());
1498
1499		proof_files.push(proof_file);
1500		pb.inc(1);
1501	}
1502	pb.finish_with_message("Proofs generated");
1503	let proof_gen_elapsed = proof_gen_start.elapsed();
1504	log_print!(
1505		"  Proof generation: {:.2}s ({} proofs, {:.2}s avg)",
1506		proof_gen_elapsed.as_secs_f64(),
1507		num_proofs,
1508		proof_gen_elapsed.as_secs_f64() / num_proofs as f64,
1509	);
1510
1511	Ok(proof_files)
1512}
1513
1514/// Derive wormhole secrets for a round
1515fn derive_round_secrets(
1516	mnemonic: &str,
1517	round: usize,
1518	num_proofs: usize,
1519) -> crate::error::Result<Vec<WormholePair>> {
1520	let pb = ProgressBar::new(num_proofs as u64);
1521	pb.set_style(
1522		ProgressStyle::default_bar()
1523			.template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
1524			.unwrap()
1525			.progress_chars("#>-"),
1526	);
1527	pb.set_message("Deriving secrets...");
1528
1529	let mut secrets = Vec::new();
1530	for i in 1..=num_proofs {
1531		let secret = derive_wormhole_secret(mnemonic, round, i)?;
1532		secrets.push(secret);
1533		pb.inc(1);
1534	}
1535	pb.finish_with_message("Secrets derived");
1536
1537	Ok(secrets)
1538}
1539
1540/// Verify final balance and print summary
1541fn verify_final_balance(
1542	initial_balance: u128,
1543	final_balance: u128,
1544	total_sent: u128,
1545	rounds: usize,
1546	num_proofs: usize,
1547) {
1548	use colored::Colorize;
1549
1550	log_print!("{}", "Balance Verification:".bright_cyan());
1551
1552	// Total received in final round: apply fee deduction for each round
1553	let total_received = calculate_round_amount(total_sent, rounds);
1554
1555	// Expected net change (may be negative due to fees)
1556	let expected_change = total_received as i128 - total_sent as i128;
1557	let actual_change = final_balance as i128 - initial_balance as i128;
1558
1559	log_print!("  Initial balance: {} ({})", initial_balance, format_balance(initial_balance));
1560	log_print!("  Final balance:   {} ({})", final_balance, format_balance(final_balance));
1561	log_print!("");
1562	log_print!("  Total sent (round 1):     {} ({})", total_sent, format_balance(total_sent));
1563	log_print!(
1564		"  Total received (round {}): {} ({})",
1565		rounds,
1566		total_received,
1567		format_balance(total_received)
1568	);
1569	log_print!("");
1570
1571	// Format signed amounts for display
1572	let expected_change_str = if expected_change >= 0 {
1573		format!("+{}", expected_change)
1574	} else {
1575		format!("{}", expected_change)
1576	};
1577	let actual_change_str = if actual_change >= 0 {
1578		format!("+{}", actual_change)
1579	} else {
1580		format!("{}", actual_change)
1581	};
1582
1583	log_print!("  Expected change: {} planck", expected_change_str);
1584	log_print!("  Actual change:   {} planck", actual_change_str);
1585	log_print!("");
1586
1587	// Allow some tolerance for transaction fees
1588	let tolerance = (total_sent / 100).max(1_000_000_000_000); // 1% or 1 QNT minimum
1589
1590	let diff = (actual_change - expected_change).unsigned_abs();
1591	if diff <= tolerance {
1592		log_success!(
1593			"  {} Balance verification PASSED (within tolerance of {} planck)",
1594			"✓".bright_green(),
1595			tolerance
1596		);
1597	} else {
1598		log_print!(
1599			"  {} Balance verification: difference of {} planck (tolerance: {} planck)",
1600			"!".bright_yellow(),
1601			diff,
1602			tolerance
1603		);
1604		log_print!(
1605			"    Note: Transaction fees for {} initial transfers may account for the difference",
1606			num_proofs
1607		);
1608	}
1609	log_print!("");
1610}
1611
1612/// Run the multi-round wormhole flow
1613#[allow(clippy::too_many_arguments)]
1614async fn run_multiround(
1615	num_proofs: usize,
1616	rounds: usize,
1617	amount: u128,
1618	wallet_name: String,
1619	password: Option<String>,
1620	password_file: Option<String>,
1621	keep_files: bool,
1622	output_dir: String,
1623	dry_run: bool,
1624	node_url: &str,
1625) -> crate::error::Result<()> {
1626	use colored::Colorize;
1627
1628	log_print!("");
1629	log_print!("==================================================");
1630	log_print!("  Wormhole Multi-Round Flow Test");
1631	log_print!("==================================================");
1632	log_print!("");
1633
1634	// Load aggregation config from generated-bins/config.json
1635	let agg_config = AggregationConfig::load_from_bins()?;
1636
1637	// Validate parameters
1638	validate_multiround_params(num_proofs, rounds, agg_config.num_leaf_proofs)?;
1639
1640	// Load wallet
1641	let wallet = load_multiround_wallet(&wallet_name, password, password_file)?;
1642
1643	// Create config struct
1644	let config =
1645		MultiroundConfig { num_proofs, rounds, amount, output_dir: output_dir.clone(), keep_files };
1646
1647	// Print configuration
1648	print_multiround_config(&config, &wallet, &agg_config);
1649	log_print!("  Dry run: {}", dry_run);
1650	log_print!("");
1651
1652	// Create output directory
1653	std::fs::create_dir_all(&output_dir).map_err(|e| {
1654		crate::error::QuantusError::Generic(format!("Failed to create output directory: {}", e))
1655	})?;
1656
1657	if dry_run {
1658		return run_multiround_dry_run(
1659			&wallet.mnemonic,
1660			num_proofs,
1661			rounds,
1662			amount,
1663			&wallet.wallet_address,
1664		);
1665	}
1666
1667	// Connect to node
1668	let quantus_client = QuantusClient::new(node_url).await.map_err(|e| {
1669		crate::error::QuantusError::Generic(format!("Failed to connect to node: {}", e))
1670	})?;
1671	let client = quantus_client.client();
1672
1673	// Get minting account from chain
1674	let minting_account = get_minting_account(client).await?;
1675	log_verbose!("Minting account: {:?}", minting_account);
1676
1677	// Record initial wallet balance for verification
1678	let initial_balance = get_balance(&quantus_client, &wallet.wallet_address).await?;
1679	log_print!("{}", "Initial Balance:".bright_cyan());
1680	log_print!("  Wallet balance: {} ({})", initial_balance, format_balance(initial_balance));
1681	log_print!("");
1682
1683	// Track transfer info for the current round
1684	let mut current_transfers: Vec<TransferInfo> = Vec::new();
1685
1686	for round in 1..=rounds {
1687		let is_final = round == rounds;
1688
1689		log_print!("");
1690		log_print!("--------------------------------------------------");
1691		log_print!(
1692			"  {} Round {} of {} {}",
1693			">>>".bright_blue(),
1694			round,
1695			rounds,
1696			"<<<".bright_blue()
1697		);
1698		log_print!("--------------------------------------------------");
1699		log_print!("");
1700
1701		// Create round output directory
1702		let round_dir = format!("{}/round{}", output_dir, round);
1703		std::fs::create_dir_all(&round_dir).map_err(|e| {
1704			crate::error::QuantusError::Generic(format!("Failed to create round directory: {}", e))
1705		})?;
1706
1707		// Derive secrets for this round
1708		let secrets = derive_round_secrets(&wallet.mnemonic, round, num_proofs)?;
1709
1710		// Determine exit accounts
1711		let exit_accounts: Vec<SubxtAccountId> = if is_final {
1712			log_print!("Final round - all proofs exit to wallet: {}", wallet.wallet_address);
1713			vec![wallet.wallet_account_id.clone(); num_proofs]
1714		} else {
1715			log_print!(
1716				"Intermediate round - proofs exit to round {} wormhole addresses",
1717				round + 1
1718			);
1719			let mut addrs = Vec::new();
1720			for i in 1..=num_proofs {
1721				let next_secret = derive_wormhole_secret(&wallet.mnemonic, round + 1, i)?;
1722				addrs.push(SubxtAccountId(next_secret.address));
1723			}
1724			addrs
1725		};
1726
1727		// Step 1: Get transfer info (execute transfers for round 1, reuse from previous round
1728		// otherwise)
1729		if round == 1 {
1730			current_transfers =
1731				execute_initial_transfers(&quantus_client, &wallet, &secrets, amount, num_proofs)
1732					.await?;
1733
1734			// Log balance immediately after funding transfers
1735			let balance_after_funding =
1736				get_balance(&quantus_client, &wallet.wallet_address).await?;
1737			let funding_deducted = initial_balance.saturating_sub(balance_after_funding);
1738			log_print!(
1739				"  Balance after funding: {} ({}) [deducted: {} planck]",
1740				balance_after_funding,
1741				format_balance(balance_after_funding),
1742				funding_deducted
1743			);
1744		} else {
1745			log_print!("{}", "Step 1: Using transfer info from previous round...".bright_yellow());
1746			log_print!("  Found {} transfer(s) from previous round", current_transfers.len());
1747		}
1748
1749		// Step 2: Generate proofs with random output partitioning
1750		let proof_files = generate_round_proofs(
1751			&quantus_client,
1752			&secrets,
1753			&current_transfers,
1754			&exit_accounts,
1755			&round_dir,
1756			num_proofs,
1757		)
1758		.await?;
1759
1760		// Step 3: Aggregate proofs
1761		log_print!("{}", "Step 3: Aggregating proofs...".bright_yellow());
1762
1763		let aggregated_file = format!("{}/aggregated.hex", round_dir);
1764		aggregate_proofs(proof_files, aggregated_file.clone()).await?;
1765
1766		log_print!("  Aggregated proof saved to {}", aggregated_file);
1767
1768		// Step 4: Verify aggregated proof on-chain
1769		log_print!("{}", "Step 4: Submitting aggregated proof on-chain...".bright_yellow());
1770
1771		let (verification_block, transfer_events) =
1772			verify_aggregated_and_get_events(&aggregated_file, &quantus_client).await?;
1773
1774		log_print!(
1775			"  {} Proof verified in block {}",
1776			"✓".bright_green(),
1777			hex::encode(verification_block.0)
1778		);
1779
1780		// If not final round, prepare transfer info for next round
1781		if !is_final {
1782			log_print!("{}", "Step 5: Capturing transfer info for next round...".bright_yellow());
1783
1784			// Parse events to get transfer info for next round's wormhole addresses
1785			let next_round_addresses: Vec<SubxtAccountId> = (1..=num_proofs)
1786				.map(|i| {
1787					let next_secret =
1788						derive_wormhole_secret(&wallet.mnemonic, round + 1, i).unwrap();
1789					SubxtAccountId(next_secret.address)
1790				})
1791				.collect();
1792
1793			current_transfers =
1794				parse_transfer_events(&transfer_events, &next_round_addresses, verification_block)?;
1795
1796			log_print!(
1797				"  Captured {} transfer(s) for round {}",
1798				current_transfers.len(),
1799				round + 1
1800			);
1801		}
1802
1803		// Log balance after this round
1804		let balance_after_round = get_balance(&quantus_client, &wallet.wallet_address).await?;
1805		let change_from_initial = balance_after_round as i128 - initial_balance as i128;
1806		let change_str = if change_from_initial >= 0 {
1807			format!("+{}", change_from_initial)
1808		} else {
1809			format!("{}", change_from_initial)
1810		};
1811		log_print!("");
1812		log_print!(
1813			"  Balance after round {}: {} ({}) [change: {} planck]",
1814			round,
1815			balance_after_round,
1816			format_balance(balance_after_round),
1817			change_str
1818		);
1819
1820		log_print!("");
1821		log_print!("  {} Round {} complete!", "✓".bright_green(), round);
1822	}
1823
1824	log_print!("");
1825	log_print!("==================================================");
1826	log_success!("  All {} rounds completed successfully!", rounds);
1827	log_print!("==================================================");
1828	log_print!("");
1829
1830	// Final balance verification
1831	let final_balance = get_balance(&quantus_client, &wallet.wallet_address).await?;
1832	verify_final_balance(initial_balance, final_balance, amount, rounds, num_proofs);
1833
1834	if keep_files {
1835		log_print!("Proof files preserved in: {}", output_dir);
1836	} else {
1837		log_print!("Cleaning up proof files...");
1838		std::fs::remove_dir_all(&output_dir).ok();
1839	}
1840
1841	Ok(())
1842}
1843
1844/// Generate a wormhole proof with dual outputs (used for random partitioning in multiround)
1845async fn generate_proof(
1846	secret_hex: &str,
1847	funding_amount: u128,
1848	output_assignment: &ProofOutputAssignment,
1849	block_hash_str: &str,
1850	transfer_count: u64,
1851	funding_account_str: &str,
1852	output_file: &str,
1853	quantus_client: &QuantusClient,
1854) -> crate::error::Result<()> {
1855	// Parse secret
1856	let secret_array = parse_secret_hex(secret_hex).map_err(crate::error::QuantusError::Generic)?;
1857	let secret: BytesDigest = secret_array.try_into().map_err(|e| {
1858		crate::error::QuantusError::Generic(format!("Failed to convert secret: {:?}", e))
1859	})?;
1860
1861	// Parse funding account
1862	let funding_account_bytes =
1863		parse_exit_account(funding_account_str).map_err(crate::error::QuantusError::Generic)?;
1864	let funding_account = AccountId32::new(funding_account_bytes);
1865
1866	// Parse block hash
1867	let hash_bytes = hex::decode(block_hash_str.trim_start_matches("0x"))
1868		.map_err(|e| crate::error::QuantusError::Generic(format!("Invalid block hash: {}", e)))?;
1869	if hash_bytes.len() != 32 {
1870		return Err(crate::error::QuantusError::Generic(format!(
1871			"Block hash must be 32 bytes, got {}",
1872			hash_bytes.len()
1873		)));
1874	}
1875	let hash: [u8; 32] = hash_bytes.try_into().unwrap();
1876	let block_hash = subxt::utils::H256::from(hash);
1877
1878	let client = quantus_client.client();
1879
1880	// Generate unspendable account from secret
1881	let unspendable_account =
1882		qp_wormhole_circuit::unspendable_account::UnspendableAccount::from_secret(secret)
1883			.account_id;
1884	let unspendable_account_bytes_digest =
1885		qp_zk_circuits_common::utils::digest_felts_to_bytes(unspendable_account);
1886	let unspendable_account_bytes: [u8; 32] = unspendable_account_bytes_digest
1887		.as_ref()
1888		.try_into()
1889		.expect("BytesDigest is always 32 bytes");
1890
1891	let from_account = funding_account.clone();
1892	let to_account = AccountId32::new(unspendable_account_bytes);
1893
1894	// Get block
1895	let blocks =
1896		client.blocks().at(block_hash).await.map_err(|e| {
1897			crate::error::QuantusError::Generic(format!("Failed to get block: {}", e))
1898		})?;
1899
1900	// Build storage key
1901	let leaf_hash = qp_poseidon::PoseidonHasher::hash_storage::<TransferProofKey>(
1902		&(
1903			NATIVE_ASSET_ID,
1904			transfer_count,
1905			from_account.clone(),
1906			to_account.clone(),
1907			funding_amount,
1908		)
1909			.encode(),
1910	);
1911
1912	let proof_address = quantus_node::api::storage().wormhole().transfer_proof((
1913		NATIVE_ASSET_ID,
1914		transfer_count,
1915		SubxtAccountId(from_account.clone().into()),
1916		SubxtAccountId(to_account.clone().into()),
1917		funding_amount,
1918	));
1919
1920	let mut final_key = proof_address.to_root_bytes();
1921	final_key.extend_from_slice(&leaf_hash);
1922
1923	// Verify storage key exists
1924	let storage_api = client.storage().at(block_hash);
1925	let val = storage_api
1926		.fetch_raw(final_key.clone())
1927		.await
1928		.map_err(|e| crate::error::QuantusError::Generic(e.to_string()))?;
1929	if val.is_none() {
1930		return Err(crate::error::QuantusError::Generic(
1931			"Storage key not found - transfer may not exist in this block".to_string(),
1932		));
1933	}
1934
1935	// Get storage proof
1936	let proof_params = rpc_params![vec![to_hex(&final_key)], block_hash];
1937	let read_proof: ReadProof<sp_core::H256> = quantus_client
1938		.rpc_client()
1939		.request("state_getReadProof", proof_params)
1940		.await
1941		.map_err(|e| crate::error::QuantusError::Generic(e.to_string()))?;
1942
1943	let header = blocks.header();
1944	let state_root = BytesDigest::try_from(header.state_root.as_bytes())
1945		.map_err(|e| crate::error::QuantusError::Generic(e.to_string()))?;
1946	let parent_hash = BytesDigest::try_from(header.parent_hash.as_bytes())
1947		.map_err(|e| crate::error::QuantusError::Generic(e.to_string()))?;
1948	let extrinsics_root = BytesDigest::try_from(header.extrinsics_root.as_bytes())
1949		.map_err(|e| crate::error::QuantusError::Generic(e.to_string()))?;
1950	let digest =
1951		header.digest.encode().try_into().map_err(|_| {
1952			crate::error::QuantusError::Generic("Failed to encode digest".to_string())
1953		})?;
1954	let block_number = header.number;
1955
1956	// Prepare storage proof
1957	let processed_storage_proof = prepare_proof_for_circuit(
1958		read_proof.proof.iter().map(|proof| proof.0.clone()).collect(),
1959		hex::encode(header.state_root.0),
1960		leaf_hash,
1961	)
1962	.map_err(|e| crate::error::QuantusError::Generic(e.to_string()))?;
1963
1964	// Quantize input amount
1965	let input_amount_quantized: u32 =
1966		quantize_funding_amount(funding_amount).map_err(crate::error::QuantusError::Generic)?;
1967
1968	// Use the output assignment directly (already quantized)
1969	let inputs = CircuitInputs {
1970		private: PrivateCircuitInputs {
1971			secret,
1972			transfer_count,
1973			funding_account: BytesDigest::try_from(funding_account.as_ref() as &[u8])
1974				.map_err(|e| crate::error::QuantusError::Generic(e.to_string()))?,
1975			storage_proof: processed_storage_proof,
1976			unspendable_account: unspendable_account_bytes_digest,
1977			parent_hash,
1978			state_root,
1979			extrinsics_root,
1980			digest,
1981			input_amount: input_amount_quantized,
1982		},
1983		public: PublicCircuitInputs {
1984			output_amount_1: output_assignment.output_amount_1,
1985			output_amount_2: output_assignment.output_amount_2,
1986			volume_fee_bps: VOLUME_FEE_BPS,
1987			nullifier: digest_felts_to_bytes(Nullifier::from_preimage(secret, transfer_count).hash),
1988			exit_account_1: BytesDigest::try_from(output_assignment.exit_account_1.as_ref())
1989				.map_err(|e| crate::error::QuantusError::Generic(e.to_string()))?,
1990			exit_account_2: BytesDigest::try_from(output_assignment.exit_account_2.as_ref())
1991				.map_err(|e| crate::error::QuantusError::Generic(e.to_string()))?,
1992			block_hash: BytesDigest::try_from(block_hash.as_ref())
1993				.map_err(|e| crate::error::QuantusError::Generic(e.to_string()))?,
1994			block_number,
1995			asset_id: NATIVE_ASSET_ID,
1996		},
1997	};
1998
1999	// Load prover from pre-built bins
2000	let bins_dir = Path::new("generated-bins");
2001	let prover =
2002		WormholeProver::new_from_files(&bins_dir.join("prover.bin"), &bins_dir.join("common.bin"))
2003			.map_err(|e| {
2004				crate::error::QuantusError::Generic(format!("Failed to load prover: {}", e))
2005			})?;
2006	let prover_next = prover
2007		.commit(&inputs)
2008		.map_err(|e| crate::error::QuantusError::Generic(e.to_string()))?;
2009	let proof: ProofWithPublicInputs<_, _, 2> = prover_next.prove().map_err(|e| {
2010		crate::error::QuantusError::Generic(format!("Proof generation failed: {}", e))
2011	})?;
2012
2013	let proof_hex = hex::encode(proof.to_bytes());
2014	std::fs::write(output_file, proof_hex).map_err(|e| {
2015		crate::error::QuantusError::Generic(format!("Failed to write proof: {}", e))
2016	})?;
2017
2018	Ok(())
2019}
2020
2021/// Verify an aggregated proof and return the block hash and transfer events
2022async fn verify_aggregated_and_get_events(
2023	proof_file: &str,
2024	quantus_client: &QuantusClient,
2025) -> crate::error::Result<(subxt::utils::H256, Vec<wormhole::events::NativeTransferred>)> {
2026	use qp_wormhole_verifier::WormholeVerifier;
2027
2028	let proof_bytes = read_hex_proof_file_to_bytes(proof_file)?;
2029
2030	// Verify locally before submitting on-chain
2031	log_verbose!("Verifying aggregated proof locally before on-chain submission...");
2032	let bins_dir = Path::new("generated-bins");
2033	let verifier = WormholeVerifier::new_from_files(
2034		&bins_dir.join("aggregated_verifier.bin"),
2035		&bins_dir.join("aggregated_common.bin"),
2036	)
2037	.map_err(|e| {
2038		crate::error::QuantusError::Generic(format!("Failed to load aggregated verifier: {}", e))
2039	})?;
2040
2041	let proof = qp_wormhole_verifier::ProofWithPublicInputs::<
2042		qp_wormhole_verifier::F,
2043		qp_wormhole_verifier::C,
2044		{ qp_wormhole_verifier::D },
2045	>::from_bytes(proof_bytes.clone(), &verifier.circuit_data.common)
2046	.map_err(|e| {
2047		crate::error::QuantusError::Generic(format!(
2048			"Failed to deserialize aggregated proof: {}",
2049			e
2050		))
2051	})?;
2052
2053	verifier.verify(proof).map_err(|e| {
2054		crate::error::QuantusError::Generic(format!(
2055			"Local aggregated proof verification failed: {}",
2056			e
2057		))
2058	})?;
2059	log_verbose!("Local verification passed!");
2060
2061	// Submit unsigned tx + wait for inclusion (best or finalized)
2062	let (included_at, block_hash, tx_hash) =
2063		submit_unsigned_verify_aggregated_proof(quantus_client, proof_bytes).await?;
2064
2065	log_verbose!(
2066		"Submitted tx included in {}: block={:?}, tx={:?}",
2067		included_at.label(),
2068		block_hash,
2069		tx_hash
2070	);
2071
2072	// Collect events for our extrinsic only
2073	let (found_proof_verified, transfer_events) =
2074		collect_wormhole_events_for_extrinsic(quantus_client, block_hash, tx_hash).await?;
2075
2076	if !found_proof_verified {
2077		return Err(crate::error::QuantusError::Generic(
2078			"Proof verification failed - no ProofVerified event".to_string(),
2079		));
2080	}
2081
2082	// Log minted amounts
2083	log_print!("  Tokens minted (from NativeTransferred events):");
2084	for (idx, transfer) in transfer_events.iter().enumerate() {
2085		let to_hex = hex::encode(transfer.to.0);
2086		log_print!(
2087			"    [{}] {} -> {} planck ({})",
2088			idx,
2089			to_hex,
2090			transfer.amount,
2091			format_balance(transfer.amount)
2092		);
2093	}
2094
2095	Ok((block_hash, transfer_events))
2096}
2097
2098/// Dry run - show what would happen without executing
2099fn run_multiround_dry_run(
2100	mnemonic: &str,
2101	num_proofs: usize,
2102	rounds: usize,
2103	amount: u128,
2104	wallet_address: &str,
2105) -> crate::error::Result<()> {
2106	use colored::Colorize;
2107
2108	log_print!("");
2109	log_print!("{}", "=== DRY RUN MODE ===".bright_yellow());
2110	log_print!("No transactions will be executed.");
2111	log_print!("");
2112
2113	for round in 1..=rounds {
2114		let is_final = round == rounds;
2115		let round_amount = calculate_round_amount(amount, round);
2116
2117		log_print!("");
2118		log_print!("{}", format!("Round {}", round).bright_cyan());
2119		log_print!("  Total amount: {} ({})", round_amount, format_balance(round_amount));
2120
2121		// Show sample random partition for round 1
2122		if round == 1 {
2123			let partition = random_partition(amount, num_proofs, 2 * SCALE_DOWN_FACTOR);
2124			log_print!("  Sample random partition (actual partition will differ):");
2125			for (i, &amt) in partition.iter().enumerate() {
2126				log_print!("    Proof {}: {} ({})", i + 1, amt, format_balance(amt));
2127			}
2128		}
2129		log_print!("");
2130
2131		log_print!("  Wormhole addresses (to be funded):");
2132		for i in 1..=num_proofs {
2133			let secret = derive_wormhole_secret(mnemonic, round, i)?;
2134			let address = sp_core::crypto::AccountId32::new(secret.address)
2135				.to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(189));
2136			log_print!("    [{}] {}", i, address);
2137			log_verbose!("        secret: 0x{}", hex::encode(secret.secret));
2138		}
2139
2140		log_print!("");
2141		log_print!("  Exit accounts:");
2142		if is_final {
2143			log_print!("    All proofs exit to wallet: {}", wallet_address);
2144		} else {
2145			for i in 1..=num_proofs {
2146				let next_secret = derive_wormhole_secret(mnemonic, round + 1, i)?;
2147				let address = sp_core::crypto::AccountId32::new(next_secret.address)
2148					.to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(189));
2149				log_print!("    [{}] {} (round {} wormhole)", i, address, round + 1);
2150			}
2151		}
2152	}
2153
2154	log_print!("");
2155	log_print!("{}", "=== END DRY RUN ===".bright_yellow());
2156	log_print!("");
2157
2158	Ok(())
2159}
2160
2161/// Parse and display the contents of a proof file for debugging
2162async fn parse_proof_file(
2163	proof_file: String,
2164	aggregated: bool,
2165	verify: bool,
2166) -> crate::error::Result<()> {
2167	use qp_wormhole_verifier::WormholeVerifier;
2168
2169	log_print!("Parsing proof file: {}", proof_file);
2170
2171	// Read proof bytes
2172	let proof_bytes = read_proof_file(&proof_file)
2173		.map_err(|e| crate::error::QuantusError::Generic(format!("Failed to read proof: {}", e)))?;
2174
2175	log_print!("Proof size: {} bytes", proof_bytes.len());
2176
2177	let bins_dir = Path::new("generated-bins");
2178
2179	if aggregated {
2180		// Load aggregated verifier
2181		let verifier = WormholeVerifier::new_from_files(
2182			&bins_dir.join("aggregated_verifier.bin"),
2183			&bins_dir.join("aggregated_common.bin"),
2184		)
2185		.map_err(|e| {
2186			crate::error::QuantusError::Generic(format!("Failed to load verifier: {}", e))
2187		})?;
2188
2189		// Deserialize proof using verifier's types
2190		let proof = qp_wormhole_verifier::ProofWithPublicInputs::<
2191			qp_wormhole_verifier::F,
2192			qp_wormhole_verifier::C,
2193			{ qp_wormhole_verifier::D },
2194		>::from_bytes(proof_bytes.clone(), &verifier.circuit_data.common)
2195		.map_err(|e| {
2196			crate::error::QuantusError::Generic(format!(
2197				"Failed to deserialize aggregated proof: {:?}",
2198				e
2199			))
2200		})?;
2201
2202		log_print!("\nPublic inputs count: {}", proof.public_inputs.len());
2203		log_verbose!("\nPublic inputs count: {}", proof.public_inputs.len());
2204
2205		// Try to parse as aggregated
2206		match qp_wormhole_verifier::parse_aggregated_public_inputs(&proof) {
2207			Ok(agg_inputs) => {
2208				log_print!("\n=== Parsed Aggregated Public Inputs ===");
2209				log_print!("Asset ID: {}", agg_inputs.asset_id);
2210				log_print!("Volume Fee BPS: {}", agg_inputs.volume_fee_bps);
2211				log_print!(
2212					"Block Hash: 0x{}",
2213					hex::encode(agg_inputs.block_data.block_hash.as_ref())
2214				);
2215				log_print!("Block Number: {}", agg_inputs.block_data.block_number);
2216				log_print!("\nAccount Data ({} accounts):", agg_inputs.account_data.len());
2217				for (i, acct) in agg_inputs.account_data.iter().enumerate() {
2218					log_print!(
2219						"  [{}] amount={}, exit=0x{}",
2220						i,
2221						acct.summed_output_amount,
2222						hex::encode(acct.exit_account.as_ref())
2223					);
2224				}
2225				log_print!("\nNullifiers ({} nullifiers):", agg_inputs.nullifiers.len());
2226				for (i, nullifier) in agg_inputs.nullifiers.iter().enumerate() {
2227					log_print!("  [{}] 0x{}", i, hex::encode(nullifier.as_ref()));
2228				}
2229			},
2230			Err(e) => {
2231				log_print!("Failed to parse as aggregated inputs: {}", e);
2232			},
2233		}
2234
2235		// Verify if requested
2236		if verify {
2237			log_print!("\n=== Verifying Proof ===");
2238			match verifier.verify(proof) {
2239				Ok(()) => {
2240					log_success!("Proof verification PASSED");
2241				},
2242				Err(e) => {
2243					log_error!("Proof verification FAILED: {}", e);
2244					return Err(crate::error::QuantusError::Generic(format!(
2245						"Proof verification failed: {}",
2246						e
2247					)));
2248				},
2249			}
2250		}
2251	} else {
2252		// Load leaf verifier
2253		let verifier = WormholeVerifier::new_from_files(
2254			&bins_dir.join("verifier.bin"),
2255			&bins_dir.join("common.bin"),
2256		)
2257		.map_err(|e| {
2258			crate::error::QuantusError::Generic(format!("Failed to load verifier: {}", e))
2259		})?;
2260
2261		// Deserialize proof using verifier's types
2262		let proof = qp_wormhole_verifier::ProofWithPublicInputs::<
2263			qp_wormhole_verifier::F,
2264			qp_wormhole_verifier::C,
2265			{ qp_wormhole_verifier::D },
2266		>::from_bytes(proof_bytes, &verifier.circuit_data.common)
2267		.map_err(|e| {
2268			crate::error::QuantusError::Generic(format!("Failed to deserialize proof: {:?}", e))
2269		})?;
2270
2271		log_print!("\nPublic inputs count: {}", proof.public_inputs.len());
2272
2273		let pi = qp_wormhole_verifier::parse_public_inputs(&proof).map_err(|e| {
2274			crate::error::QuantusError::Generic(format!("Failed to parse public inputs: {}", e))
2275		})?;
2276
2277		log_print!("\n=== Parsed Leaf Public Inputs ===");
2278		log_print!("Asset ID: {}", pi.asset_id);
2279		log_print!("Output Amount 1: {}", pi.output_amount_1);
2280		log_print!("Output Amount 2: {}", pi.output_amount_2);
2281		log_print!("Volume Fee BPS: {}", pi.volume_fee_bps);
2282		log_print!("Nullifier: 0x{}", hex::encode(pi.nullifier.as_ref()));
2283		log_print!("Exit Account 1: 0x{}", hex::encode(pi.exit_account_1.as_ref()));
2284		log_print!("Exit Account 2: 0x{}", hex::encode(pi.exit_account_2.as_ref()));
2285		log_print!("Block Hash: 0x{}", hex::encode(pi.block_hash.as_ref()));
2286		log_print!("Block Number: {}", pi.block_number);
2287
2288		// Verify if requested
2289		if verify {
2290			log_print!("\n=== Verifying Proof ===");
2291			match verifier.verify(proof) {
2292				Ok(()) => {
2293					log_success!("Proof verification PASSED");
2294				},
2295				Err(e) => {
2296					log_error!("Proof verification FAILED: {}", e);
2297					return Err(crate::error::QuantusError::Generic(format!(
2298						"Proof verification failed: {}",
2299						e
2300					)));
2301				},
2302			}
2303		}
2304	}
2305
2306	Ok(())
2307}
2308
2309#[cfg(test)]
2310mod tests {
2311	use super::*;
2312	use std::collections::HashSet;
2313	use tempfile::NamedTempFile;
2314
2315	#[test]
2316	fn test_compute_output_amount() {
2317		// 0.1% fee (10 bps): output = input * 9990 / 10000
2318		assert_eq!(compute_output_amount(1000, 10), 999);
2319		assert_eq!(compute_output_amount(10000, 10), 9990);
2320
2321		// 1% fee (100 bps): output = input * 9900 / 10000
2322		assert_eq!(compute_output_amount(1000, 100), 990);
2323		assert_eq!(compute_output_amount(10000, 100), 9900);
2324
2325		// 0% fee
2326		assert_eq!(compute_output_amount(1000, 0), 1000);
2327
2328		// Edge cases
2329		assert_eq!(compute_output_amount(0, 10), 0);
2330		assert_eq!(compute_output_amount(1, 10), 0); // rounds down
2331		assert_eq!(compute_output_amount(100, 10), 99);
2332	}
2333
2334	#[test]
2335	fn test_parse_secret_hex() {
2336		// Valid hex with and without 0x prefix
2337		let secret = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
2338		assert!(parse_secret_hex(secret).is_ok());
2339		assert!(parse_secret_hex(&format!("0x{}", secret)).is_ok());
2340
2341		// Wrong length
2342		assert!(parse_secret_hex("0123456789abcdef").unwrap_err().contains("32 bytes"));
2343
2344		// Invalid hex characters
2345		assert!(parse_secret_hex("ghij".repeat(16).as_str())
2346			.unwrap_err()
2347			.contains("Invalid secret hex"));
2348	}
2349
2350	#[test]
2351	fn test_parse_exit_account() {
2352		// Valid hex account
2353		let hex = "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
2354		assert!(parse_exit_account(hex).is_ok());
2355
2356		// Wrong length
2357		assert!(parse_exit_account("0x0123456789abcdef").unwrap_err().contains("32 bytes"));
2358
2359		// Invalid SS58
2360		assert!(parse_exit_account("not_valid").unwrap_err().contains("Invalid SS58"));
2361	}
2362
2363	#[test]
2364	fn test_quantize_funding_amount() {
2365		// Basic quantization: 1 token (12 decimals) -> 100 (2 decimals)
2366		assert_eq!(quantize_funding_amount(1_000_000_000_000).unwrap(), 100);
2367
2368		// Zero and small amounts
2369		assert_eq!(quantize_funding_amount(0).unwrap(), 0);
2370		assert_eq!(quantize_funding_amount(5_000_000_000).unwrap(), 0); // < 10^10
2371
2372		// Max valid and overflow
2373		let max_valid = (u32::MAX as u128) * SCALE_DOWN_FACTOR;
2374		assert_eq!(quantize_funding_amount(max_valid).unwrap(), u32::MAX);
2375		assert!(quantize_funding_amount(max_valid + SCALE_DOWN_FACTOR)
2376			.unwrap_err()
2377			.contains("too large"));
2378	}
2379
2380	#[test]
2381	fn test_proof_file_roundtrip() {
2382		let temp_file = NamedTempFile::new().unwrap();
2383		let path = temp_file.path().to_str().unwrap();
2384		let proof_bytes = vec![0x01, 0x02, 0x03, 0xaa, 0xbb, 0xcc];
2385
2386		write_proof_file(path, &proof_bytes).unwrap();
2387		assert_eq!(read_proof_file(path).unwrap(), proof_bytes);
2388	}
2389
2390	#[test]
2391	fn test_read_proof_file_errors() {
2392		// File not found
2393		assert!(read_proof_file("/nonexistent/path/proof.hex")
2394			.unwrap_err()
2395			.contains("Failed to read"));
2396
2397		// Invalid hex content
2398		let temp_file = NamedTempFile::new().unwrap();
2399		std::fs::write(temp_file.path(), "not valid hex!").unwrap();
2400		assert!(read_proof_file(temp_file.path().to_str().unwrap())
2401			.unwrap_err()
2402			.contains("Failed to decode"));
2403	}
2404
2405	#[test]
2406	fn test_fee_calculation_edge_cases() {
2407		// Test the circuit fee constraint: output_amount * 10000 <= input_amount * (10000 -
2408		// volume_fee_bps) This is equivalent to: output <= input * (1 - fee_rate)
2409
2410		// Small amounts where fee rounds to zero
2411		let input_small: u32 = 100;
2412		let output_small = compute_output_amount(input_small, VOLUME_FEE_BPS);
2413		assert_eq!(output_small, 99);
2414		// Verify constraint: 99 * 10000 = 990000 <= 100 * 9990 = 999000 ✓
2415		assert!(
2416			(output_small as u64) * 10000 <= (input_small as u64) * (10000 - VOLUME_FEE_BPS as u64)
2417		);
2418
2419		// Medium amounts
2420		let input_medium: u32 = 10000;
2421		let output_medium = compute_output_amount(input_medium, VOLUME_FEE_BPS);
2422		assert_eq!(output_medium, 9990);
2423		assert!(
2424			(output_medium as u64) * 10000 <=
2425				(input_medium as u64) * (10000 - VOLUME_FEE_BPS as u64)
2426		);
2427
2428		// Large amounts near u32::MAX
2429		let input_large: u32 = u32::MAX / 2;
2430		let output_large = compute_output_amount(input_large, VOLUME_FEE_BPS);
2431		assert!(
2432			(output_large as u64) * 10000 <= (input_large as u64) * (10000 - VOLUME_FEE_BPS as u64)
2433		);
2434
2435		// Test with different fee rates
2436		for fee_bps in [0u32, 1, 10, 50, 100, 500, 1000] {
2437			let input: u32 = 100000;
2438			let output = compute_output_amount(input, fee_bps);
2439			assert!(
2440				(output as u64) * 10000 <= (input as u64) * (10000 - fee_bps as u64),
2441				"Fee constraint violated for fee_bps={}: {} * 10000 > {} * {}",
2442				fee_bps,
2443				output,
2444				input,
2445				10000 - fee_bps
2446			);
2447		}
2448	}
2449
2450	#[test]
2451	fn test_nullifier_determinism() {
2452		use qp_wormhole_circuit::nullifier::Nullifier;
2453		use qp_zk_circuits_common::utils::BytesDigest;
2454
2455		let secret: BytesDigest = [1u8; 32].try_into().expect("valid secret");
2456		let transfer_count = 42u64;
2457
2458		// Generate nullifier multiple times - should be identical
2459		let nullifier1 = Nullifier::from_preimage(secret, transfer_count);
2460		let nullifier2 = Nullifier::from_preimage(secret, transfer_count);
2461		let nullifier3 = Nullifier::from_preimage(secret, transfer_count);
2462
2463		assert_eq!(nullifier1.hash, nullifier2.hash);
2464		assert_eq!(nullifier2.hash, nullifier3.hash);
2465
2466		// Different transfer count should produce different nullifier
2467		let nullifier_different = Nullifier::from_preimage(secret, transfer_count + 1);
2468		assert_ne!(nullifier1.hash, nullifier_different.hash);
2469
2470		// Different secret should produce different nullifier
2471		let different_secret: BytesDigest = [2u8; 32].try_into().expect("valid secret");
2472		let nullifier_different_secret = Nullifier::from_preimage(different_secret, transfer_count);
2473		assert_ne!(nullifier1.hash, nullifier_different_secret.hash);
2474	}
2475
2476	#[test]
2477	fn test_unspendable_account_determinism() {
2478		use qp_wormhole_circuit::unspendable_account::UnspendableAccount;
2479		use qp_zk_circuits_common::utils::BytesDigest;
2480
2481		let secret: BytesDigest = [1u8; 32].try_into().expect("valid secret");
2482
2483		// Generate unspendable account multiple times - should be identical
2484		let account1 = UnspendableAccount::from_secret(secret);
2485		let account2 = UnspendableAccount::from_secret(secret);
2486
2487		assert_eq!(account1.account_id, account2.account_id);
2488
2489		// Different secret should produce different account
2490		let different_secret: BytesDigest = [2u8; 32].try_into().expect("valid secret");
2491		let account_different = UnspendableAccount::from_secret(different_secret);
2492		assert_ne!(account1.account_id, account_different.account_id);
2493	}
2494
2495	// Note: Integration tests for proof generation, serialization, aggregation, and
2496	// multi-account aggregation have been moved to qp-zk-circuits/wormhole/tests/
2497	// where the test-helpers crate (with TestInputs) is available as a workspace dep.
2498
2499	/// Test that public inputs parsing matches expected structure
2500	#[test]
2501	fn test_public_inputs_structure() {
2502		use qp_wormhole_inputs::{
2503			ASSET_ID_INDEX, BLOCK_HASH_END_INDEX, BLOCK_HASH_START_INDEX, BLOCK_NUMBER_INDEX,
2504			EXIT_ACCOUNT_1_END_INDEX, EXIT_ACCOUNT_1_START_INDEX, EXIT_ACCOUNT_2_END_INDEX,
2505			EXIT_ACCOUNT_2_START_INDEX, NULLIFIER_END_INDEX, NULLIFIER_START_INDEX,
2506			OUTPUT_AMOUNT_1_INDEX, OUTPUT_AMOUNT_2_INDEX, PUBLIC_INPUTS_FELTS_LEN,
2507			VOLUME_FEE_BPS_INDEX,
2508		};
2509
2510		// Verify expected public inputs layout for dual-output circuit
2511		assert_eq!(PUBLIC_INPUTS_FELTS_LEN, 21, "Public inputs should be 21 field elements");
2512		assert_eq!(ASSET_ID_INDEX, 0, "Asset ID should be first");
2513		assert_eq!(OUTPUT_AMOUNT_1_INDEX, 1, "Output amount 1 should be at index 1");
2514		assert_eq!(OUTPUT_AMOUNT_2_INDEX, 2, "Output amount 2 should be at index 2");
2515		assert_eq!(VOLUME_FEE_BPS_INDEX, 3, "Volume fee BPS should be at index 3");
2516		assert_eq!(NULLIFIER_START_INDEX, 4, "Nullifier should start at index 4");
2517		assert_eq!(NULLIFIER_END_INDEX, 8, "Nullifier should end at index 8");
2518		assert_eq!(EXIT_ACCOUNT_1_START_INDEX, 8, "Exit account 1 should start at index 8");
2519		assert_eq!(EXIT_ACCOUNT_1_END_INDEX, 12, "Exit account 1 should end at index 12");
2520		assert_eq!(EXIT_ACCOUNT_2_START_INDEX, 12, "Exit account 2 should start at index 12");
2521		assert_eq!(EXIT_ACCOUNT_2_END_INDEX, 16, "Exit account 2 should end at index 16");
2522		assert_eq!(BLOCK_HASH_START_INDEX, 16, "Block hash should start at index 16");
2523		assert_eq!(BLOCK_HASH_END_INDEX, 20, "Block hash should end at index 20");
2524		assert_eq!(BLOCK_NUMBER_INDEX, 20, "Block number should be at index 20");
2525	}
2526
2527	/// Test that constants match expected on-chain configuration
2528	#[test]
2529	fn test_constants_match_chain_config() {
2530		// Volume fee rate should be 10 bps (0.1%)
2531		assert_eq!(VOLUME_FEE_BPS, 10, "Volume fee should be 10 bps");
2532
2533		// Native asset ID should be 0
2534		assert_eq!(NATIVE_ASSET_ID, 0, "Native asset ID should be 0");
2535
2536		// Scale down factor should be 10^10 (12 decimals -> 2 decimals)
2537		assert_eq!(SCALE_DOWN_FACTOR, 10_000_000_000, "Scale down factor should be 10^10");
2538
2539		// Verify scale down: 1 token with 12 decimals = 10^12 units
2540		// After quantization: 10^12 / 10^10 = 100 (which is 1.00 in 2 decimal places)
2541		let one_token_12_decimals: u128 = 1_000_000_000_000;
2542		let quantized = quantize_funding_amount(one_token_12_decimals).unwrap();
2543		assert_eq!(quantized, 100, "1 token should quantize to 100 (1.00 with 2 decimals)");
2544	}
2545
2546	#[test]
2547	fn test_volume_fee_bps_constant() {
2548		// Ensure VOLUME_FEE_BPS matches expected value (10 bps = 0.1%)
2549		assert_eq!(VOLUME_FEE_BPS, 10);
2550	}
2551
2552	#[test]
2553	fn test_aggregation_config_deserialization_matches_upstream_format() {
2554		// This test verifies that our local AggregationConfig struct can deserialize
2555		// the same JSON format that the upstream CircuitBinsConfig produces.
2556		// If the upstream adds/removes/renames fields, this test will catch it.
2557		let json = r#"{
2558			"num_leaf_proofs": 8,
2559			"hashes": {
2560				"common": "aabbcc",
2561				"verifier": "ddeeff",
2562				"prover": "112233",
2563				"aggregated_common": "445566",
2564				"aggregated_verifier": "778899"
2565			}
2566		}"#;
2567
2568		let config: AggregationConfig = serde_json::from_str(json).unwrap();
2569		assert_eq!(config.num_leaf_proofs, 8);
2570
2571		let hashes = config.hashes.unwrap();
2572		assert_eq!(hashes.prover.as_deref(), Some("112233"));
2573		assert_eq!(hashes.aggregated_common.as_deref(), Some("445566"));
2574		assert_eq!(hashes.aggregated_verifier.as_deref(), Some("778899"));
2575	}
2576
2577	fn mk_accounts(n: usize) -> Vec<[u8; 32]> {
2578		(0..n)
2579			.map(|i| {
2580				let mut a = [0u8; 32];
2581				a[0] = (i as u8).wrapping_add(1); // avoid [0;32]
2582				a
2583			})
2584			.collect()
2585	}
2586
2587	fn proof_outputs_for_inputs(input_amounts: &[u128], fee_bps: u32) -> Vec<u32> {
2588		input_amounts
2589			.iter()
2590			.map(|&input| {
2591				let input_quantized = quantize_funding_amount(input).unwrap_or(0);
2592				compute_output_amount(input_quantized, fee_bps)
2593			})
2594			.collect()
2595	}
2596
2597	fn total_output_for_inputs(input_amounts: &[u128], fee_bps: u32) -> u64 {
2598		proof_outputs_for_inputs(input_amounts, fee_bps)
2599			.into_iter()
2600			.map(|x| x as u64)
2601			.sum()
2602	}
2603
2604	/// Find some input that yields at least `min_out` quantized output after fee.
2605	/// Keeps tests robust even if quantization constants change.
2606	fn find_input_for_min_output(fee_bps: u32, min_out: u32) -> u128 {
2607		let mut input: u128 = 1;
2608		for _ in 0..80 {
2609			let q = quantize_funding_amount(input).unwrap_or(0);
2610			let out = compute_output_amount(q, fee_bps);
2611			if out >= min_out {
2612				return input;
2613			}
2614			// grow fast but safely
2615			input = input.saturating_mul(10);
2616		}
2617		panic!("Could not find input producing output >= {}", min_out);
2618	}
2619
2620	// --------------------------
2621	// random_partition tests
2622	// --------------------------
2623
2624	#[test]
2625	fn random_partition_n0() {
2626		let parts = random_partition(100, 0, 1);
2627		assert!(parts.is_empty());
2628	}
2629
2630	#[test]
2631	fn random_partition_n1() {
2632		let total = 12345u128;
2633		let parts = random_partition(total, 1, 9999);
2634		assert_eq!(parts, vec![total]);
2635	}
2636
2637	#[test]
2638	fn random_partition_total_less_than_min_total_falls_back_to_equalish() {
2639		// total < min_per_part * n => fallback path
2640		let total = 5u128;
2641		let n = 10usize;
2642		let min_per_part = 1u128;
2643
2644		let parts = random_partition(total, n, min_per_part);
2645
2646		assert_eq!(parts.len(), n);
2647		assert_eq!(parts.iter().sum::<u128>(), total);
2648
2649		// fallback behavior: per_part=0, remainder=5, last gets remainder
2650		for part in parts.iter().take(n - 1) {
2651			assert_eq!(*part, 0);
2652		}
2653		assert_eq!(parts[n - 1], 5);
2654	}
2655
2656	#[test]
2657	fn random_partition_min_achievable_invariants_hold() {
2658		let total = 100u128;
2659		let n = 10usize;
2660		let min_per_part = 3u128;
2661
2662		for _ in 0..200 {
2663			let parts = random_partition(total, n, min_per_part);
2664			assert_eq!(parts.len(), n);
2665			assert_eq!(parts.iter().sum::<u128>(), total);
2666			assert!(parts.iter().all(|&p| p >= min_per_part));
2667		}
2668	}
2669
2670	#[test]
2671	fn random_partition_distributable_zero_all_min() {
2672		let n = 10usize;
2673		let min_per_part = 3u128;
2674		let total = min_per_part * n as u128;
2675
2676		let parts = random_partition(total, n, min_per_part);
2677
2678		assert_eq!(parts.len(), n);
2679		assert_eq!(parts.iter().sum::<u128>(), total);
2680		assert!(parts.iter().all(|&p| p == min_per_part));
2681	}
2682
2683	// --------------------------
2684	// compute_random_output_assignments tests
2685	// --------------------------
2686
2687	#[test]
2688	fn compute_random_output_assignments_empty_inputs_or_targets() {
2689		let targets = mk_accounts(3);
2690		assert!(compute_random_output_assignments(&[], &targets, 0).is_empty());
2691
2692		let inputs = vec![1u128, 2u128, 3u128];
2693		assert!(compute_random_output_assignments(&inputs, &[], 0).is_empty());
2694	}
2695
2696	#[test]
2697	fn compute_random_output_assignments_basic_invariants() {
2698		let fee_bps = 0u32;
2699
2700		// ensure non-zero outputs for meaningful checks
2701		let input = find_input_for_min_output(fee_bps, 5);
2702		let input_amounts = vec![input, input, input, input, input];
2703		let targets = mk_accounts(4);
2704
2705		let assignments = compute_random_output_assignments(&input_amounts, &targets, fee_bps);
2706		assert_eq!(assignments.len(), input_amounts.len());
2707
2708		let proof_outputs = proof_outputs_for_inputs(&input_amounts, fee_bps);
2709
2710		// per-proof sum matches
2711		for (i, a) in assignments.iter().enumerate() {
2712			let per_proof_sum = a.output_amount_1 as u64 + a.output_amount_2 as u64;
2713			assert_eq!(per_proof_sum, proof_outputs[i] as u64);
2714
2715			// If an output amount is non-zero, the account must be in targets
2716			if a.output_amount_1 > 0 {
2717				assert!(targets.contains(&a.exit_account_1));
2718			} else {
2719				// if amount is zero, account can be zero or anything; current impl keeps default
2720				// [0;32]
2721			}
2722			if a.output_amount_2 > 0 {
2723				assert!(targets.contains(&a.exit_account_2));
2724				assert_ne!(a.exit_account_2, a.exit_account_1); // should be different if both used
2725			} else {
2726				// current impl keeps default [0;32]
2727				assert_eq!(a.exit_account_2, [0u8; 32]);
2728			}
2729		}
2730
2731		// total sum matches
2732		let total_assigned: u64 = assignments
2733			.iter()
2734			.map(|a| a.output_amount_1 as u64 + a.output_amount_2 as u64)
2735			.sum();
2736
2737		let total_expected = total_output_for_inputs(&input_amounts, fee_bps);
2738		assert_eq!(total_assigned, total_expected);
2739	}
2740
2741	#[test]
2742	fn compute_random_output_assignments_more_targets_than_capacity_still_conserves_funds() {
2743		// Capacity: each proof can hit at most 2 targets, so distinct-used-targets <= 2 *
2744		// num_proofs. Set num_targets > 2*num_proofs and ensure total_output >= num_targets so
2745		// the partition *wants* to give each target >= 1 (though algorithm can't satisfy it).
2746		let fee_bps = 0u32;
2747		let num_proofs = 1usize;
2748		let num_targets = 5usize;
2749
2750		let input = find_input_for_min_output(fee_bps, 10); // ensure total_output is "big enough"
2751		let input_amounts = vec![input; num_proofs];
2752		let targets = mk_accounts(num_targets);
2753
2754		let assignments = compute_random_output_assignments(&input_amounts, &targets, fee_bps);
2755		assert_eq!(assignments.len(), num_proofs);
2756
2757		// total preserved
2758		let total_assigned: u64 = assignments
2759			.iter()
2760			.map(|a| a.output_amount_1 as u64 + a.output_amount_2 as u64)
2761			.sum();
2762		let total_expected = total_output_for_inputs(&input_amounts, fee_bps);
2763		assert_eq!(total_assigned, total_expected);
2764
2765		// used targets bounded by 2*num_proofs and thus < num_targets
2766		let mut used = HashSet::new();
2767		for a in &assignments {
2768			if a.output_amount_1 > 0 {
2769				used.insert(a.exit_account_1);
2770			}
2771			if a.output_amount_2 > 0 {
2772				used.insert(a.exit_account_2);
2773			}
2774		}
2775		assert!(used.len() <= 2 * num_proofs);
2776		assert!(used.len() < num_targets);
2777	}
2778
2779	#[test]
2780	fn compute_random_output_assignments_total_output_less_than_num_targets_does_not_panic_and_conserves(
2781	) {
2782		// This forces random_partition into its fallback branch inside
2783		// compute_random_output_assignments because min_per_target = 1 and total_output <
2784		// num_targets.
2785		let fee_bps = 0u32;
2786
2787		let num_targets = 50usize;
2788		let targets = mk_accounts(num_targets);
2789
2790		// Try to get very small total output: two proofs with output likely >= 1 each,
2791		// but still far less than 50.
2792		let input = find_input_for_min_output(fee_bps, 1);
2793		let input_amounts = vec![input, input];
2794
2795		let assignments = compute_random_output_assignments(&input_amounts, &targets, fee_bps);
2796		assert_eq!(assignments.len(), input_amounts.len());
2797
2798		let total_assigned: u64 = assignments
2799			.iter()
2800			.map(|a| a.output_amount_1 as u64 + a.output_amount_2 as u64)
2801			.sum();
2802		let total_expected = total_output_for_inputs(&input_amounts, fee_bps);
2803		assert_eq!(total_assigned, total_expected);
2804
2805		// For each assignment: if non-zero amount then account must be in targets.
2806		for a in &assignments {
2807			if a.output_amount_1 > 0 {
2808				assert!(targets.contains(&a.exit_account_1));
2809			}
2810			if a.output_amount_2 > 0 {
2811				assert!(targets.contains(&a.exit_account_2));
2812				assert_ne!(a.exit_account_2, a.exit_account_1);
2813			}
2814		}
2815	}
2816}