use crate::{
chain::{
client::{ChainConfig, QuantusClient},
quantus_subxt::{self as quantus_node, api::wormhole},
},
cli::{
address_format::{bytes_to_quantus_ss58, slice_to_quantus_ss58},
common::{ExecutionMode, TransactionStage},
send::get_balance,
},
log_error, log_print, log_success, log_verbose,
wallet::{password, QuantumKeyPair, WalletManager},
};
use clap::Subcommand;
use indicatif::{ProgressBar, ProgressStyle};
use plonky2::plonk::proof::ProofWithPublicInputs;
use qp_rusty_crystals_hdwallet::{
derive_wormhole_from_mnemonic, WormholePair, QUANTUS_WORMHOLE_CHAIN_ID,
};
use qp_wormhole_aggregator::config::CircuitBinsConfig;
use qp_wormhole_circuit::inputs::ParsePrivateBatchPublicInputs;
use qp_wormhole_inputs::PrivateBatchPublicInputs;
use qp_zk_circuits_common::{
circuit::{C, D, F},
utils::BytesDigest,
};
use sp_core::crypto::{AccountId32, Ss58Codec};
use subxt::{
blocks::Block,
ext::{
codec::Encode,
jsonrpsee::{core::client::ClientT, rpc_params},
},
utils::AccountId32 as SubxtAccountId,
OnlineClient,
};
use crate::wormhole_lib;
pub use crate::wormhole_lib::{
compute_output_amount, NATIVE_ASSET_ID, SCALE_DOWN_FACTOR, VOLUME_FEE_BPS,
};
pub type Hash256 = [u8; 32];
#[derive(Debug, Clone)]
pub struct ZkMerkleProofRpc {
pub leaf_index: u64,
pub leaf_data: Vec<u8>,
pub leaf_hash: Hash256,
pub siblings: Vec<[Hash256; 3]>,
pub root: Hash256,
pub depth: u8,
}
impl<'de> serde::Deserialize<'de> for ZkMerkleProofRpc {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(serde::Deserialize)]
struct RawZkMerkleProofRpc {
leaf_index: u64,
#[serde(with = "byte_array")]
leaf_data: Vec<u8>,
#[serde(with = "hash_array")]
leaf_hash: Hash256,
#[serde(with = "siblings_format")]
siblings: Vec<[Hash256; 3]>,
#[serde(with = "hash_array")]
root: Hash256,
depth: u8,
}
let raw = <RawZkMerkleProofRpc as serde::Deserialize>::deserialize(deserializer)?;
if raw.depth as usize != raw.siblings.len() {
return Err(serde::de::Error::custom(format!(
"depth {} does not match siblings length {}",
raw.depth,
raw.siblings.len()
)));
}
Ok(Self {
leaf_index: raw.leaf_index,
leaf_data: raw.leaf_data,
leaf_hash: raw.leaf_hash,
siblings: raw.siblings,
root: raw.root,
depth: raw.depth,
})
}
}
mod byte_array {
use serde::{Deserialize, Deserializer};
const ZK_LEAF_DATA_LEN: usize = 60;
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
where
D: Deserializer<'de>,
{
let bytes = Vec::<u8>::deserialize(deserializer)?;
if bytes.len() != ZK_LEAF_DATA_LEN {
return Err(serde::de::Error::custom(format!(
"expected {} bytes, got {}",
ZK_LEAF_DATA_LEN,
bytes.len()
)));
}
Ok(bytes)
}
}
mod hash_array {
use serde::{Deserialize, Deserializer};
pub fn deserialize<'de, D>(deserializer: D) -> Result<[u8; 32], D::Error>
where
D: Deserializer<'de>,
{
let bytes: Vec<u8> = Deserialize::deserialize(deserializer)?;
bytes.try_into().map_err(|v: Vec<u8>| {
serde::de::Error::custom(format!("expected 32 bytes, got {}", v.len()))
})
}
}
mod siblings_format {
use qp_zk_circuits_common::zk_merkle::MAX_DEPTH;
use serde::{Deserialize, Deserializer};
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<[[u8; 32]; 3]>, D::Error>
where
D: Deserializer<'de>,
{
let levels: Vec<Vec<Vec<u8>>> = Deserialize::deserialize(deserializer)?;
if levels.len() > MAX_DEPTH {
return Err(serde::de::Error::custom(format!(
"proof depth {} exceeds max {}",
levels.len(),
MAX_DEPTH
)));
}
levels
.into_iter()
.map(|level| {
if level.len() != 3 {
return Err(serde::de::Error::custom(format!(
"expected 3 siblings per level, got {}",
level.len()
)));
}
let mut siblings = [[0u8; 32]; 3];
for (i, bytes) in level.into_iter().enumerate() {
siblings[i] = bytes.try_into().map_err(|v: Vec<u8>| {
serde::de::Error::custom(format!("expected 32 bytes, got {}", v.len()))
})?;
}
Ok(siblings)
})
.collect()
}
}
pub async fn get_zk_merkle_proof(
quantus_client: &QuantusClient,
leaf_index: u64,
at_block: subxt::utils::H256,
) -> crate::error::Result<ZkMerkleProofRpc> {
try_get_zk_merkle_proof(quantus_client, leaf_index, at_block)
.await?
.ok_or_else(|| {
crate::error::QuantusError::Generic(format!(
"Leaf index {} not found in ZK tree at block {:?}",
leaf_index, at_block
))
})
}
pub async fn try_get_zk_merkle_proof(
quantus_client: &QuantusClient,
leaf_index: u64,
at_block: subxt::utils::H256,
) -> crate::error::Result<Option<ZkMerkleProofRpc>> {
let proof_params = rpc_params![leaf_index, at_block];
let proof: Option<ZkMerkleProofRpc> = quantus_client
.rpc_client()
.request("zkTree_getMerkleProof", proof_params)
.await
.map_err(|e| {
crate::error::QuantusError::Generic(format!(
"RPC error fetching proof at block {:?}: {}",
at_block, e
))
})?;
let Some(proof) = proof else {
return Ok(None);
};
if proof.leaf_index != leaf_index {
return Err(crate::error::QuantusError::Generic(format!(
"ZK Merkle proof leaf_index mismatch: requested {}, got {}",
leaf_index, proof.leaf_index
)));
}
debug_assert_eq!(proof.depth as usize, proof.siblings.len());
Ok(Some(proof))
}
pub fn compute_merkle_positions(
unsorted_siblings: &[[Hash256; 3]],
leaf_hash: Hash256,
) -> (Vec<[Hash256; 3]>, Vec<u8>) {
use qp_zk_circuits_common::zk_merkle::hash_node_presorted;
let mut current_hash = leaf_hash;
let mut sorted_siblings = Vec::with_capacity(unsorted_siblings.len());
let mut positions = Vec::with_capacity(unsorted_siblings.len());
for level_siblings in unsorted_siblings.iter() {
let mut all_four: [Hash256; 4] =
[current_hash, level_siblings[0], level_siblings[1], level_siblings[2]];
all_four.sort();
let pos = all_four
.iter()
.position(|h| *h == current_hash)
.expect("current hash must be in the array") as u8;
positions.push(pos);
let sorted_sibs: [Hash256; 3] = {
let mut sibs = [[0u8; 32]; 3];
let mut sib_idx = 0;
for (i, h) in all_four.iter().enumerate() {
if i as u8 != pos {
sibs[sib_idx] = *h;
sib_idx += 1;
}
}
sibs
};
sorted_siblings.push(sorted_sibs);
current_hash = hash_node_presorted(&all_four)
.expect("merkle node children from chain must be valid field elements");
}
(sorted_siblings, positions)
}
pub fn parse_secret_hex(secret_hex: &str) -> Result<[u8; 32], String> {
let hex_str = secret_hex.trim_start_matches("0x");
if !hex_str.len().is_multiple_of(2) {
return Err("Invalid secret hex: odd number of digits".to_string());
}
if hex_str.len() != 64 {
return Err(format!("Secret must be exactly 32 bytes, got {} bytes", hex_str.len() / 2));
}
let mut secret = [0u8; 32];
match hex::decode_to_slice(hex_str, &mut secret) {
Ok(()) => Ok(secret),
Err(e) => {
crate::wallet::keystore::zeroize_bytes(&mut secret);
Err(format!("Invalid secret hex: {}", e))
},
}
}
fn read_secret_hex_file(path: &str) -> crate::error::Result<String> {
let secret_hex = password::read_secret_file(path, "secret")?;
parse_secret_hex(&secret_hex).map_err(crate::error::QuantusError::Generic)?;
Ok(secret_hex)
}
pub fn parse_exit_account(exit_account_str: &str) -> Result<[u8; 32], String> {
if let Some(hex_str) = exit_account_str.strip_prefix("0x") {
let bytes = hex::decode(hex_str).map_err(|e| format!("Invalid exit account hex: {}", e))?;
if bytes.len() != 32 {
return Err(format!("Exit account must be 32 bytes, got {} bytes", bytes.len()));
}
bytes.try_into().map_err(|_| "Failed to convert exit account".to_string())
} else {
let account_id = AccountId32::from_ss58check(exit_account_str)
.map_err(|e| format!("Invalid SS58 address: {}", e))?;
Ok(account_id.into())
}
}
pub fn quantize_funding_amount(amount: u128) -> Result<u32, String> {
wormhole_lib::quantize_amount(amount).map_err(|e| e.message)
}
pub fn read_proof_file(path: &str) -> Result<Vec<u8>, String> {
let proof_hex =
std::fs::read_to_string(path).map_err(|e| format!("Failed to read proof file: {}", e))?;
hex::decode(proof_hex.trim()).map_err(|e| format!("Failed to decode proof hex: {}", e))
}
pub fn write_proof_file(path: &str, proof_bytes: &[u8]) -> Result<(), String> {
let proof_hex = hex::encode(proof_bytes);
std::fs::write(path, proof_hex).map_err(|e| format!("Failed to write proof file: {}", e))
}
pub fn format_balance(amount: u128) -> String {
let whole = amount / 1_000_000_000_000;
let frac = (amount % 1_000_000_000_000) / 10_000_000_000; format!("{}.{:02} DEV", whole, frac)
}
pub fn random_partition(total: u128, n: usize, min_per_part: u128) -> Vec<u128> {
use rand::Rng;
if n == 0 {
return vec![];
}
if n == 1 {
return vec![total];
}
let min_total = min_per_part * n as u128;
if total < min_total {
let per_part = total / n as u128;
let remainder = total % n as u128;
let mut parts: Vec<u128> = vec![per_part; n];
parts[n - 1] += remainder;
return parts;
}
let distributable = total - min_total;
let mut rng = rand::rng();
let mut cuts: Vec<u128> = (0..n - 1).map(|_| rng.random_range(0..=distributable)).collect();
cuts.sort();
let mut parts = Vec::with_capacity(n);
let mut prev = 0u128;
for cut in cuts {
parts.push(min_per_part + (cut - prev));
prev = cut;
}
parts.push(min_per_part + (distributable - prev));
let sum: u128 = parts.iter().sum();
let diff = total as i128 - sum as i128;
if diff != 0 {
let idx = rng.random_range(0..n);
parts[idx] = (parts[idx] as i128 + diff).max(0) as u128;
}
parts
}
#[derive(Debug, Clone)]
pub struct ProofOutputAssignment {
pub output_amount_1: u32,
pub exit_account_1: [u8; 32],
pub output_amount_2: u32,
pub exit_account_2: [u8; 32],
}
pub fn compute_random_output_assignments(
input_amounts: &[u128],
target_accounts: &[[u8; 32]],
fee_bps: u32,
) -> Result<Vec<ProofOutputAssignment>, String> {
use rand::seq::SliceRandom;
let num_proofs = input_amounts.len();
let num_targets = target_accounts.len();
if num_proofs == 0 || num_targets == 0 {
return Ok(vec![]);
}
let mut proof_outputs: Vec<u32> = Vec::with_capacity(input_amounts.len());
for (i, &input) in input_amounts.iter().enumerate() {
let input_quantized = quantize_funding_amount(input).map_err(|e| {
format!("Failed to quantize input amount {} for proof {}: {}", input, i, e)
})?;
proof_outputs.push(compute_output_amount(input_quantized, fee_bps));
}
let total_output: u64 = proof_outputs.iter().map(|&x| x as u64).sum();
let min_per_target = 3u128;
let target_amounts_u128 = random_partition(total_output as u128, num_targets, min_per_target);
let target_amounts: Vec<u32> = target_amounts_u128.iter().map(|&x| x as u32).collect();
let mut rng = rand::rng();
let mut target_remaining: Vec<u32> = target_amounts.clone();
let mut assignments: Vec<ProofOutputAssignment> = proof_outputs
.iter()
.map(|&po| ProofOutputAssignment {
output_amount_1: po,
exit_account_1: [0u8; 32],
output_amount_2: 0,
exit_account_2: [0u8; 32],
})
.collect();
let mut shuffled_targets: Vec<usize> = (0..num_targets).collect();
shuffled_targets.shuffle(&mut rng);
for (assign_idx, &tidx) in shuffled_targets.iter().enumerate() {
let proof_idx = assign_idx % num_proofs;
let assignment = &mut assignments[proof_idx];
if assignment.exit_account_1 == [0u8; 32] {
let assign = assignment.output_amount_1.min(target_remaining[tidx]);
assignment.exit_account_1 = target_accounts[tidx];
assignment.output_amount_1 = assign;
target_remaining[tidx] -= assign;
} else if assignment.exit_account_2 == [0u8; 32] {
let avail = proof_outputs[proof_idx].saturating_sub(assignment.output_amount_1);
let assign = avail.min(target_remaining[tidx]);
assignment.exit_account_2 = target_accounts[tidx];
assignment.output_amount_2 = assign;
target_remaining[tidx] -= assign;
}
}
for proof_idx in 0..num_proofs {
let total_proof_output = proof_outputs[proof_idx];
let current_sum =
assignments[proof_idx].output_amount_1 + assignments[proof_idx].output_amount_2;
let mut shortfall = total_proof_output.saturating_sub(current_sum);
if shortfall > 0 {
assignments[proof_idx].output_amount_1 += shortfall;
shortfall = 0;
}
if assignments[proof_idx].exit_account_1 == [0u8; 32] && num_targets > 0 {
assignments[proof_idx].exit_account_1 = target_accounts[0];
}
let _ = shortfall; }
let min_needed = min_per_target as u32;
let mut received: std::collections::HashMap<[u8; 32], u32> = std::collections::HashMap::new();
for assignment in &assignments {
if assignment.exit_account_1 != [0u8; 32] {
*received.entry(assignment.exit_account_1).or_default() += assignment.output_amount_1;
}
if assignment.exit_account_2 != [0u8; 32] {
*received.entry(assignment.exit_account_2).or_default() += assignment.output_amount_2;
}
}
let mut demand: std::collections::HashMap<[u8; 32], u32> = std::collections::HashMap::new();
for (tidx, &addr) in target_accounts.iter().enumerate() {
*demand.entry(addr).or_default() += target_amounts[tidx];
}
if (total_output as u128) >= min_per_target * received.len() as u128 {
let mut wanted_addrs: Vec<[u8; 32]> = demand.keys().copied().collect();
wanted_addrs.sort_by_key(|addr| received.get(addr).copied().unwrap_or(0));
for addr in wanted_addrs {
let wanted = demand[&addr].max(min_needed);
let mut deficit = wanted.saturating_sub(received.get(&addr).copied().unwrap_or(0));
while deficit > 0 {
let donation = (0..num_proofs)
.filter_map(|donor_idx| {
let donor = &assignments[donor_idx];
if donor.exit_account_2 != [0u8; 32] ||
donor.exit_account_1 == addr ||
donor.exit_account_1 == [0u8; 32]
{
return None;
}
let donor_addr = donor.exit_account_1;
let donor_total = received.get(&donor_addr).copied().unwrap_or(0);
let donor_keeps =
demand.get(&donor_addr).copied().unwrap_or(0).max(min_needed);
let spare =
donor.output_amount_1.min(donor_total.saturating_sub(donor_keeps));
if spare == 0 {
return None;
}
Some((donor_idx, spare))
})
.max_by_key(|&(_, spare)| spare);
let Some((donor_idx, spare)) = donation else {
break;
};
let take = spare.min(deficit);
let donor = &mut assignments[donor_idx];
let donor_addr = donor.exit_account_1;
donor.output_amount_1 -= take;
donor.exit_account_2 = addr;
donor.output_amount_2 = take;
*received.entry(donor_addr).or_default() -= take;
*received.entry(addr).or_default() += take;
deficit -= take;
}
}
}
let unique_targets = demand.len();
if num_targets <= 2 * num_proofs &&
(total_output as u128) >= min_per_target * unique_targets as u128
{
for &addr in demand.keys() {
let got = received.get(&addr).copied().unwrap_or(0);
if got < min_needed {
return Err(format!(
"Unable to fund next-round address {} with at least {} quantized \
units (got {got}); no remaining proof has a free second output \
slot with spare funds. Reduce the number of proofs or increase \
their amounts before submitting.",
hex::encode(addr),
min_per_target,
));
}
}
}
Ok(assignments)
}
pub struct VerificationResult {
pub success: bool,
pub exit_amount: Option<u128>,
pub error_message: Option<String>,
}
fn apply_proof_verified_to_result(result: &mut VerificationResult, exit_amount: u128) {
result.exit_amount = Some(exit_amount);
if result.error_message.is_none() {
result.success = true;
}
}
fn apply_extrinsic_failed_to_result(result: &mut VerificationResult, error_msg: String) {
result.success = false;
result.error_message = Some(error_msg);
}
fn require_proof_verification_extrinsic_index(
our_extrinsic_index: Option<usize>,
) -> crate::error::Result<usize> {
our_extrinsic_index.ok_or_else(|| {
crate::error::QuantusError::Generic(
"Could not find submitted extrinsic in included block".to_string(),
)
})
}
fn finalize_wormhole_event_collection(
found_proof_verified: bool,
dispatch_error_message: Option<String>,
transfer_events: Vec<wormhole::events::NativeTransferred>,
) -> crate::error::Result<(bool, Vec<wormhole::events::NativeTransferred>)> {
if let Some(error_msg) = dispatch_error_message {
return Err(crate::error::QuantusError::Generic(error_msg));
}
Ok((found_proof_verified, transfer_events))
}
async fn check_proof_verification_events(
client: &subxt::OnlineClient<ChainConfig>,
block_hash: &subxt::utils::H256,
tx_hash: &subxt::utils::H256,
verbose: bool,
) -> crate::error::Result<VerificationResult> {
use crate::chain::quantus_subxt::api::system::events::ExtrinsicFailed;
use colored::Colorize;
let block = client.blocks().at(*block_hash).await.map_err(|e| {
crate::error::QuantusError::NetworkError(format!("Failed to get block: {e:?}"))
})?;
let extrinsics = block.extrinsics().await.map_err(|e| {
crate::error::QuantusError::NetworkError(format!("Failed to get extrinsics: {e:?}"))
})?;
let our_extrinsic_index = extrinsics
.iter()
.enumerate()
.find(|(_, ext)| ext.hash() == *tx_hash)
.map(|(idx, _)| idx);
let ext_idx = require_proof_verification_extrinsic_index(our_extrinsic_index)?;
let events = block.events().await.map_err(|e| {
crate::error::QuantusError::NetworkError(format!("Failed to fetch events: {e:?}"))
})?;
let metadata = client.metadata();
let mut verification_result =
VerificationResult { success: false, exit_amount: None, error_message: None };
if verbose {
log_print!("");
log_print!("📋 Transaction Events:");
}
for event_result in events.iter() {
let event = event_result.map_err(|e| {
crate::error::QuantusError::NetworkError(format!("Failed to decode event: {e:?}"))
})?;
if let subxt::events::Phase::ApplyExtrinsic(event_ext_idx) = event.phase() {
if event_ext_idx != ext_idx as u32 {
continue;
}
if verbose {
log_print!(
" 📌 {}.{}",
event.pallet_name().bright_cyan(),
event.variant_name().bright_yellow()
);
if let Ok(typed_event) =
event.as_root_event::<crate::chain::quantus_subxt::api::Event>()
{
log_print!(" 📝 {:?}", typed_event);
}
}
if let Ok(Some(proof_verified)) = event.as_event::<wormhole::events::ProofVerified>() {
apply_proof_verified_to_result(
&mut verification_result,
proof_verified.exit_amount,
);
}
if let Ok(Some(ExtrinsicFailed { dispatch_error, .. })) =
event.as_event::<ExtrinsicFailed>()
{
let error_msg = format_dispatch_error(&dispatch_error, &metadata);
apply_extrinsic_failed_to_result(&mut verification_result, error_msg);
}
}
}
if verbose {
log_print!("");
}
Ok(verification_result)
}
fn format_dispatch_error(
error: &crate::chain::quantus_subxt::api::runtime_types::sp_runtime::DispatchError,
metadata: &subxt::Metadata,
) -> String {
use crate::chain::quantus_subxt::api::runtime_types::sp_runtime::DispatchError;
match error {
DispatchError::Module(module_error) => {
let pallet_name = metadata
.pallet_by_index(module_error.index)
.map(|p| p.name())
.unwrap_or("Unknown");
let error_index = module_error.error[0];
let error_info = metadata.pallet_by_index(module_error.index).and_then(|p| {
p.error_variant_by_index(error_index)
.map(|v| (v.name.clone(), v.docs.join(" ")))
});
match error_info {
Some((name, docs)) if !docs.is_empty() => {
format!("{}::{} ({})", pallet_name, name, docs)
},
Some((name, _)) => format!("{}::{}", pallet_name, name),
None => format!("{}::Error[{}]", pallet_name, error_index),
}
},
DispatchError::BadOrigin => "BadOrigin".to_string(),
DispatchError::CannotLookup => "CannotLookup".to_string(),
DispatchError::Other => "Other".to_string(),
_ => format!("{:?}", error),
}
}
#[derive(Subcommand, Debug)]
pub enum WormholeCommands {
Address {
#[arg(long)]
secret_file: String,
},
Prove {
#[arg(long)]
secret_file: String,
#[arg(long)]
amount: u128,
#[arg(long)]
exit_account: String,
#[arg(long)]
block: String,
#[arg(long)]
transfer_count: u64,
#[arg(long)]
leaf_index: u64,
#[arg(long)]
funding_account: String,
#[arg(short, long, default_value = "proof.hex")]
output: String,
},
Aggregate {
#[arg(short, long, num_args = 1..)]
proofs: Vec<String>,
#[arg(short, long, default_value = "aggregated_proof.hex")]
output: String,
},
VerifyAggregated {
#[arg(short, long, default_value = "aggregated_proof.hex")]
proof: String,
},
AggregatePublic {
#[arg(short, long, num_args = 1..)]
proofs: Vec<String>,
#[arg(short, long)]
aggregator: String,
#[arg(short, long, default_value = "public_batch_proof.hex")]
output: String,
},
VerifyPublicBatch {
#[arg(short, long, default_value = "public_batch_proof.hex")]
proof: String,
},
PreparePublicBatches {
#[arg(short = 'n', long, default_value_t = 10)]
count: usize,
#[arg(short, long, default_value = "1.0")]
amount: f64,
#[arg(long, default_value_t = 1)]
num_proofs: usize,
#[arg(short, long)]
wallet: String,
#[arg(short, long, hide = true)]
password: Option<String>,
#[arg(long)]
password_file: Option<String>,
#[arg(short, long, default_value = "/tmp/wormhole_public_batches")]
output_dir: String,
},
ParseProof {
#[arg(short, long)]
proof: String,
#[arg(long, conflicts_with = "public_batch")]
aggregated: bool,
#[arg(long)]
public_batch: bool,
#[arg(long)]
verify: bool,
},
Multiround {
#[arg(short, long, default_value = "2")]
num_proofs: usize,
#[arg(short, long, default_value = "2")]
rounds: usize,
#[arg(short, long, default_value = "100")]
amount: f64,
#[arg(short, long)]
wallet: String,
#[arg(short, long, hide = true)]
password: Option<String>,
#[arg(long)]
password_file: Option<String>,
#[arg(short, long)]
keep_files: bool,
#[arg(short, long, default_value = "/tmp/wormhole_multiround")]
output_dir: String,
#[arg(long)]
dry_run: bool,
#[arg(long)]
public: bool,
},
Dissolve {
#[arg(short, long)]
amount: f64,
#[arg(short, long, default_value = "1.0")]
target_size: f64,
#[arg(short, long)]
wallet: String,
#[arg(short, long, hide = true)]
password: Option<String>,
#[arg(long)]
password_file: Option<String>,
#[arg(short, long)]
keep_files: bool,
#[arg(short, long, default_value = "/tmp/wormhole_dissolve")]
output_dir: String,
},
CollectRewards {
#[arg(short, long, required_unless_present_any = ["mnemonic_file", "secret_file"], conflicts_with_all = ["mnemonic_file", "secret_file"])]
wallet: Option<String>,
#[arg(long, required_unless_present_any = ["wallet", "secret_file"], conflicts_with_all = ["wallet", "secret_file"])]
mnemonic_file: Option<String>,
#[arg(long, required_unless_present_any = ["wallet", "mnemonic_file"], conflicts_with_all = ["wallet", "mnemonic_file"])]
secret_file: Option<String>,
#[arg(short, long, hide = true)]
password: Option<String>,
#[arg(long)]
password_file: Option<String>,
#[arg(short, long)]
amount: Option<f64>,
#[arg(long)]
destination: Option<String>,
#[arg(long, default_value = "https://sub2.quantus.com/v1/graphql")]
subsquid_url: String,
#[arg(long, default_value = "0")]
wormhole_index: usize,
#[arg(long)]
dry_run: bool,
#[arg(long)]
at_block: Option<u32>,
},
CheckNullifier {
#[arg(long, required_unless_present = "wallet")]
secret_file: Option<String>,
#[arg(short, long, required_unless_present = "secret_file")]
wallet: Option<String>,
#[arg(short, long, hide = true)]
password: Option<String>,
#[arg(long)]
password_file: Option<String>,
#[arg(long, default_value = "0")]
wormhole_index: usize,
#[arg(long)]
transfer_counts: String,
#[arg(long, default_value = "https://sub2.quantus.com/v1/graphql")]
subsquid_url: String,
},
}
fn wormhole_inclusion_mode(execution_mode: ExecutionMode) -> ExecutionMode {
ExecutionMode { wait_for_transaction: true, ..execution_mode }
}
fn wormhole_inclusion_stage(execution_mode: ExecutionMode) -> TransactionStage {
wormhole_inclusion_mode(execution_mode).transaction_stage()
}
fn included_at_for_stage(stage: TransactionStage) -> IncludedAt {
match stage {
TransactionStage::Finalized => IncludedAt::Finalized,
TransactionStage::Included | TransactionStage::Submitted => IncludedAt::Best,
}
}
async fn wormhole_tip_block(
quantus_client: &QuantusClient,
execution_mode: ExecutionMode,
) -> crate::error::Result<Block<ChainConfig, OnlineClient<ChainConfig>>> {
if execution_mode.finalized {
at_finalized_block(quantus_client).await
} else {
at_best_block(quantus_client).await
}
}
pub async fn handle_wormhole_command(
command: WormholeCommands,
node_url: &str,
execution_mode: ExecutionMode,
) -> crate::error::Result<()> {
match command {
WormholeCommands::Address { secret_file } => show_wormhole_address(secret_file),
WormholeCommands::Prove {
secret_file,
amount,
exit_account,
block,
transfer_count,
leaf_index,
funding_account,
output,
} => {
log_print!("Generating proof from existing transfer...");
let quantus_client = QuantusClient::new(node_url).await.map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to connect: {}", e))
})?;
let exit_account_bytes =
parse_exit_account(&exit_account).map_err(crate::error::QuantusError::Generic)?;
let input_amount_quantized =
quantize_funding_amount(amount).map_err(crate::error::QuantusError::Generic)?;
let output_amount = compute_output_amount(input_amount_quantized, VOLUME_FEE_BPS);
let output_assignment = ProofOutputAssignment {
output_amount_1: output_amount,
exit_account_1: exit_account_bytes,
output_amount_2: 0,
exit_account_2: [0u8; 32],
};
let secret = read_secret_hex_file(&secret_file)?;
let prove_start = std::time::Instant::now();
generate_proof(
&secret,
amount,
&output_assignment,
&block,
transfer_count,
&funding_account,
leaf_index,
&output,
&quantus_client,
)
.await?;
let prove_elapsed = prove_start.elapsed();
log_print!("Proof generation: {:.2}s", prove_elapsed.as_secs_f64());
Ok(())
},
WormholeCommands::Aggregate { proofs, output } => aggregate_proofs(proofs, output).await,
WormholeCommands::VerifyAggregated { proof } =>
verify_private_batch(proof, node_url, execution_mode).await,
WormholeCommands::AggregatePublic { proofs, aggregator, output } =>
aggregate_public_batch(proofs, aggregator, output).await,
WormholeCommands::VerifyPublicBatch { proof } =>
verify_public_batch(proof, node_url, execution_mode).await,
WormholeCommands::PreparePublicBatches {
count,
amount,
num_proofs,
wallet,
password,
password_file,
output_dir,
} => {
let amount_planck = (amount * 1_000_000_000_000.0) as u128;
let amount_aligned = (amount_planck / SCALE_DOWN_FACTOR) * SCALE_DOWN_FACTOR;
let quantus_client = QuantusClient::new(node_url).await.map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to connect: {e}"))
})?;
let paths = prepare_public_batches(
&quantus_client,
&wallet,
password,
password_file,
count,
amount_aligned,
num_proofs,
&output_dir,
execution_mode,
)
.await?;
log_success!("Prepared {} public-batch proof(s):", paths.len());
for p in &paths {
log_print!(" {p}");
}
Ok(())
},
WormholeCommands::ParseProof { proof, aggregated, public_batch, verify } =>
parse_proof_file(proof, aggregated, public_batch, verify).await,
WormholeCommands::Multiround {
num_proofs,
rounds,
amount,
wallet,
password,
password_file,
keep_files,
output_dir,
dry_run,
public,
} => {
let amount_planck = (amount * 1_000_000_000_000.0) as u128;
let amount_aligned = (amount_planck / SCALE_DOWN_FACTOR) * SCALE_DOWN_FACTOR;
run_multiround(
num_proofs,
rounds,
amount_aligned,
wallet,
password,
password_file,
keep_files,
output_dir,
dry_run,
public,
node_url,
execution_mode,
)
.await
},
WormholeCommands::Dissolve {
amount,
target_size,
wallet,
password,
password_file,
keep_files,
output_dir,
} => {
let amount_planck = (amount * 1_000_000_000_000.0) as u128;
let amount_aligned = (amount_planck / SCALE_DOWN_FACTOR) * SCALE_DOWN_FACTOR;
let target_planck = (target_size * 1_000_000_000_000.0) as u128;
let target_aligned = (target_planck / SCALE_DOWN_FACTOR) * SCALE_DOWN_FACTOR;
run_dissolve(
amount_aligned,
target_aligned,
wallet,
password,
password_file,
keep_files,
output_dir,
node_url,
execution_mode,
)
.await
},
WormholeCommands::CollectRewards {
wallet,
mnemonic_file,
secret_file,
password,
password_file,
amount,
destination,
subsquid_url,
wormhole_index,
dry_run,
at_block,
} =>
run_collect_rewards(
wallet,
mnemonic_file,
secret_file,
password,
password_file,
amount,
destination,
subsquid_url,
wormhole_index,
dry_run,
node_url,
at_block,
)
.await,
WormholeCommands::CheckNullifier {
secret_file,
wallet,
password,
password_file,
wormhole_index,
transfer_counts,
subsquid_url,
} =>
run_check_nullifier(
secret_file,
wallet,
password,
password_file,
wormhole_index,
transfer_counts,
subsquid_url,
)
.await,
}
}
fn show_wormhole_address(secret_file: String) -> crate::error::Result<()> {
use colored::Colorize;
let secret_hex = read_secret_hex_file(&secret_file)?;
let secret_array =
parse_secret_hex(&secret_hex).map_err(crate::error::QuantusError::Generic)?;
let secret: BytesDigest = secret_array.try_into().map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to convert secret: {:?}", e))
})?;
let unspendable_account =
qp_wormhole_circuit::unspendable_account::UnspendableAccount::from_secret(secret)
.account_id;
let unspendable_account_bytes_digest =
qp_zk_circuits_common::utils::digest_to_bytes(unspendable_account);
let unspendable_account_bytes: [u8; 32] = unspendable_account_bytes_digest
.as_ref()
.try_into()
.expect("BytesDigest is always 32 bytes");
let account_id = sp_core::crypto::AccountId32::new(unspendable_account_bytes);
let ss58_address =
account_id.to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(189));
log_print!("{}", "Wormhole Address".bright_cyan());
log_print!(" SS58: {}", ss58_address.bright_green());
log_print!(" Hex: 0x{}", hex::encode(unspendable_account_bytes));
log_print!("");
log_print!("To fund this address:");
log_print!(" quantus send --from <wallet> --to {} --amount <amount>", ss58_address);
Ok(())
}
pub async fn at_finalized_block(
quantus_client: &QuantusClient,
) -> crate::error::Result<Block<ChainConfig, OnlineClient<ChainConfig>>> {
let finalized_block: subxt::utils::H256 = quantus_client
.rpc_client()
.request("chain_getFinalizedHead", rpc_params![])
.await
.map_err(|e| {
crate::error::QuantusError::NetworkError(format!(
"Failed to fetch finalized block hash: {e:?}"
))
})?;
let block = quantus_client.client().blocks().at(finalized_block).await.map_err(|e| {
crate::error::QuantusError::NetworkError(format!(
"Failed to fetch finalized block {finalized_block:?}: {e:?}"
))
})?;
Ok(block)
}
pub async fn at_best_block(
quantus_client: &QuantusClient,
) -> crate::error::Result<Block<ChainConfig, OnlineClient<ChainConfig>>> {
let best_block = quantus_client.get_latest_block().await?;
let block = quantus_client.client().blocks().at(best_block).await.map_err(|e| {
crate::error::QuantusError::NetworkError(format!(
"Failed to fetch best block {best_block:?}: {e:?}"
))
})?;
Ok(block)
}
fn load_leaf_common_data(
bins_dir: &std::path::Path,
) -> crate::error::Result<plonky2::plonk::circuit_data::CommonCircuitData<F, D>> {
use qp_wormhole_aggregator::common::utils::load_canonical_leaf_verifier_data;
let common_bytes = std::fs::read(bins_dir.join("common.bin")).map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to read common.bin: {}", e))
})?;
let verifier_bytes = std::fs::read(bins_dir.join("verifier.bin")).map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to read verifier.bin: {}", e))
})?;
let leaf = load_canonical_leaf_verifier_data(&common_bytes, &verifier_bytes).map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to load leaf circuit data: {}", e))
})?;
Ok(leaf.common)
}
fn cached_private_batch_prover(
) -> crate::error::Result<&'static qp_wormhole_aggregator::private_batch::prover::PrivateBatchProver>
{
use qp_wormhole_aggregator::private_batch::prover::PrivateBatchProver;
use std::sync::OnceLock;
static PROVER: OnceLock<PrivateBatchProver> = OnceLock::new();
if let Some(prover) = PROVER.get() {
return Ok(prover);
}
let bins_dir = crate::bins::ensure_bins_dir()?;
log_print!(" Building private-batch prover circuit (once per process)...");
let start = std::time::Instant::now();
let prover = PrivateBatchProver::new_from_binaries_dir(&bins_dir).map_err(|e| {
crate::error::QuantusError::Generic(format!(
"Failed to load private-batch prover from pre-built bins: {}",
e
))
})?;
log_print!(" Prover circuit built in {:.2}s", start.elapsed().as_secs_f64());
Ok(PROVER.get_or_init(|| prover))
}
fn cached_public_batch_prover(
) -> crate::error::Result<&'static qp_wormhole_aggregator::public_batch::prover::PublicBatchProver>
{
use qp_wormhole_aggregator::public_batch::prover::PublicBatchProver;
use std::sync::OnceLock;
static PROVER: OnceLock<PublicBatchProver> = OnceLock::new();
if let Some(prover) = PROVER.get() {
return Ok(prover);
}
let bins_dir = crate::bins::ensure_bins_dir()?;
log_print!(" Building public-batch prover circuit (once per process)...");
let start = std::time::Instant::now();
let prover = PublicBatchProver::new_from_binaries_dir(&bins_dir).map_err(|e| {
crate::error::QuantusError::Generic(format!(
"Failed to load public-batch prover from pre-built bins: {}",
e
))
})?;
log_print!(" Prover circuit built in {:.2}s", start.elapsed().as_secs_f64());
Ok(PROVER.get_or_init(|| prover))
}
pub async fn aggregate_proofs(
proof_files: Vec<String>,
output_file: String,
) -> crate::error::Result<()> {
log_print!("Aggregating {} proofs...", proof_files.len());
let bins_dir = crate::bins::ensure_bins_dir()?;
let agg_config = CircuitBinsConfig::load(&bins_dir).map_err(|e| {
crate::error::QuantusError::Generic(format!(
"Failed to load circuit bins config from {:?}: {}",
bins_dir, e
))
})?;
if proof_files.is_empty() {
return Err(crate::error::QuantusError::Generic(
"At least one leaf proof is required".to_string(),
));
}
if proof_files.len() > agg_config.num_leaf_proofs {
return Err(crate::error::QuantusError::Generic(format!(
"Too many proofs: {} provided, max {} supported by circuit",
proof_files.len(),
agg_config.num_leaf_proofs
)));
}
let num_padding_proofs = agg_config.num_leaf_proofs - proof_files.len();
if num_padding_proofs > 0 {
log_print!(" Padding with {} dummy leaf proof(s)...", num_padding_proofs);
}
log_verbose!("Aggregation config: num_leaf_proofs={}", agg_config.num_leaf_proofs);
let common_data = load_leaf_common_data(&bins_dir)?;
let mut proofs = Vec::with_capacity(proof_files.len());
for (idx, proof_file) in proof_files.iter().enumerate() {
log_verbose!("Loading proof {}/{}: {}", idx + 1, proof_files.len(), proof_file);
let proof_bytes = read_proof_file(proof_file).map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to load {}: {}", proof_file, e))
})?;
let proof = ProofWithPublicInputs::<F, C, D>::from_bytes(proof_bytes, &common_data)
.map_err(|e| {
crate::error::QuantusError::Generic(format!(
"Failed to deserialize proof from {}: {}",
proof_file, e
))
})?;
proofs.push(proof);
}
let prover = cached_private_batch_prover()?;
log_print!(" Running aggregation...");
let agg_start = std::time::Instant::now();
let aggregated_proof = prover
.aggregate(proofs)
.map_err(|e| crate::error::QuantusError::Generic(format!("Aggregation failed: {}", e)))?;
let agg_elapsed = agg_start.elapsed();
log_print!(" Aggregation: {:.2}s", agg_elapsed.as_secs_f64());
let aggregated_public_inputs =
PrivateBatchPublicInputs::try_from_felts(aggregated_proof.public_inputs.as_slice())
.map_err(|e| {
crate::error::QuantusError::Generic(format!(
"Failed to parse aggregated public inputs: {}",
e
))
})?;
log_verbose!("Aggregated public inputs: {:#?}", aggregated_public_inputs);
log_print!(" Exit accounts in aggregated proof:");
for (idx, account_data) in aggregated_public_inputs.account_data.iter().enumerate() {
let exit_bytes: &[u8] = account_data.exit_account.as_ref();
let is_dummy = exit_bytes.iter().all(|&b| b == 0) || account_data.summed_output_amount == 0;
if is_dummy {
log_verbose!(" [{}] DUMMY (skipped)", idx);
} else {
let dequantized_amount =
(account_data.summed_output_amount as u128) * SCALE_DOWN_FACTOR;
let ss58_address = slice_to_quantus_ss58(exit_bytes)?;
log_print!(
" [{}] {} -> {} quantized ({} planck = {})",
idx,
ss58_address,
account_data.summed_output_amount,
dequantized_amount,
format_balance(dequantized_amount)
);
}
}
log_verbose!("Verifying aggregated proof locally...");
let proof_bytes = aggregated_proof.to_bytes();
let verifier = crate::batch_verifier::load_private_batch_verifier(&bins_dir).map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to load private-batch verifier: {}", e))
})?;
let verify_proof = qp_wormhole_verifier::ProofWithPublicInputs::from_bytes(
proof_bytes.clone(),
&verifier.circuit_data.common,
)
.map_err(|e| {
crate::error::QuantusError::Generic(format!(
"Failed to deserialize aggregated proof for verification: {}",
e
))
})?;
verifier.verify(verify_proof).map_err(|e| {
crate::error::QuantusError::Generic(format!("Aggregated proof verification failed: {}", e))
})?;
write_proof_file(&output_file, &proof_bytes).map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to write proof: {}", e))
})?;
log_success!("Aggregation complete!");
log_success!("Output: {}", output_file);
log_print!(
"Aggregated {} proofs into 1 proof with {} exit accounts",
proof_files.len(),
aggregated_public_inputs.account_data.len()
);
Ok(())
}
const PREPARE_PUBLIC_BATCH_ROUND_BASE: usize = 1_000_000;
fn is_prepare_artifact_name(name: &str) -> bool {
name.starts_with("batch_") || (name.starts_with("public_batch_") && name.ends_with(".hex"))
}
fn existing_prepare_artifacts(output_dir: &std::path::Path) -> std::io::Result<Vec<String>> {
if !output_dir.exists() {
return Ok(Vec::new());
}
let mut hits = Vec::new();
for entry in std::fs::read_dir(output_dir)? {
let name = entry?.file_name();
let Some(name) = name.to_str() else { continue };
if is_prepare_artifact_name(name) {
hits.push(name.to_string());
}
}
hits.sort();
Ok(hits)
}
fn ensure_fresh_prepare_output_dir(output_dir: &str) -> crate::error::Result<()> {
let path = std::path::Path::new(output_dir);
if path.exists() && !path.is_dir() {
return Err(crate::error::QuantusError::Generic(format!(
"output path {output_dir} exists and is not a directory"
)));
}
let artifacts = existing_prepare_artifacts(path).map_err(|e| {
crate::error::QuantusError::Generic(format!(
"Failed to read output directory {output_dir}: {e}"
))
})?;
if artifacts.is_empty() {
return Ok(());
}
Err(crate::error::QuantusError::Generic(format!(
"output directory {output_dir} already contains unsubmitted proof artifacts ({}); \
pass a fresh --output-dir so a retry cannot overwrite the only recovery material \
for deposits already in the wormhole",
artifacts.join(", ")
)))
}
fn refuse_existing_path(path: &str) -> crate::error::Result<()> {
if std::path::Path::new(path).exists() {
Err(crate::error::QuantusError::Generic(format!(
"refusing to overwrite existing proof artifact {path}"
)))
} else {
Ok(())
}
}
#[allow(clippy::too_many_arguments)]
pub async fn prepare_public_batches(
quantus_client: &QuantusClient,
wallet_name: &str,
password: Option<String>,
password_file: Option<String>,
count: usize,
amount_planck: u128,
num_proofs: usize,
output_dir: &str,
execution_mode: ExecutionMode,
) -> crate::error::Result<Vec<String>> {
use colored::Colorize;
if count == 0 {
return Err(crate::error::QuantusError::Generic("count must be >= 1".into()));
}
if amount_planck < 3 * SCALE_DOWN_FACTOR {
return Err(crate::error::QuantusError::Generic(format!(
"amount too small: need at least {} planck (0.03 DEV) per batch",
3 * SCALE_DOWN_FACTOR
)));
}
ensure_fresh_prepare_output_dir(output_dir)?;
std::fs::create_dir_all(output_dir).map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to create output directory: {e}"))
})?;
let bins_dir = crate::bins::ensure_bins_dir()?;
let agg_config = CircuitBinsConfig::load(&bins_dir).map_err(|e| {
crate::error::QuantusError::Generic(format!(
"Failed to load circuit bins config from {:?}: {}",
bins_dir, e
))
})?;
validate_multiround_params(num_proofs, 1, agg_config.num_leaf_proofs)?;
let wallet = load_multiround_wallet(wallet_name, password, password_file)?;
let minting_account = get_minting_account(quantus_client.client()).await?;
let needed = amount_planck.saturating_mul(count as u128);
let free = get_balance(quantus_client, &wallet.wallet_address).await?;
if free < needed {
return Err(crate::error::QuantusError::Generic(format!(
"Insufficient balance to prepare {count} batch(es): have {} ({}), need at least {} ({}) for deposits alone (plus transfer fees)",
free,
format_balance(free),
needed,
format_balance(needed),
)));
}
log_print!("{}", "Preparing public-batch proofs (no on-chain verify)...".bright_cyan());
log_print!(" Wallet: {}", wallet.wallet_name);
log_print!(" Count: {count}");
log_print!(" Amount/batch: {} ({})", amount_planck, format_balance(amount_planck));
log_print!(" Leaf proofs/batch: {num_proofs}");
log_print!(" Output: {output_dir}");
log_print!("");
let mut paths = Vec::with_capacity(count);
for batch_idx in 0..count {
let round = PREPARE_PUBLIC_BATCH_ROUND_BASE.saturating_add(batch_idx);
let batch_dir = format!("{output_dir}/batch_{batch_idx:04}");
let public_batch_file = format!("{output_dir}/public_batch_{batch_idx:04}.hex");
refuse_existing_path(&batch_dir)?;
refuse_existing_path(&public_batch_file)?;
std::fs::create_dir_all(&batch_dir).map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to create {batch_dir}: {e}"))
})?;
log_print!(
"{}",
format!("=== Batch {}/{} (HD round {round}) ===", batch_idx + 1, count).bright_yellow()
);
let secrets = derive_round_secrets(&wallet.mnemonic, round, num_proofs)?;
let transfers = execute_initial_transfers(
quantus_client,
&wallet,
&secrets,
amount_planck,
num_proofs,
execution_mode,
)
.await?;
let exit_accounts = vec![wallet.wallet_account_id.clone(); num_proofs];
let RoundProofGeneration { proof_files, .. } = generate_round_proofs(
quantus_client,
&secrets,
&transfers,
&exit_accounts,
&minting_account,
&batch_dir,
num_proofs,
execution_mode,
)
.await?;
let aggregated_file = format!("{batch_dir}/aggregated.hex");
refuse_existing_path(&aggregated_file)?;
aggregate_proofs(proof_files, aggregated_file.clone()).await?;
refuse_existing_path(&public_batch_file)?;
aggregate_public_batch(
vec![aggregated_file],
wallet.wallet_address.clone(),
public_batch_file.clone(),
)
.await?;
log_success!(" Wrote {public_batch_file}");
paths.push(public_batch_file);
}
Ok(paths)
}
pub async fn aggregate_public_batch(
proof_files: Vec<String>,
aggregator_address_str: String,
output_file: String,
) -> crate::error::Result<()> {
use plonky2::field::types::PrimeField64;
use qp_wormhole_aggregator::public_batch::prover::PublicBatchInputs;
use qp_wormhole_inputs::PublicBatchPublicInputs;
log_print!("Aggregating {} private-batch proofs into a public batch...", proof_files.len());
let aggregator_bytes = parse_exit_account(&aggregator_address_str).map_err(|e| {
crate::error::QuantusError::Generic(format!("Invalid aggregator address: {}", e))
})?;
let aggregator_address = BytesDigest::try_from(aggregator_bytes).map_err(|e| {
crate::error::QuantusError::Generic(format!(
"Aggregator address is not representable as field elements (must be a hash-derived account): {}",
e
))
})?;
log_print!(
" Aggregator (fee rebate recipient): {}",
slice_to_quantus_ss58(&aggregator_bytes)?
);
let bins_dir = crate::bins::ensure_bins_dir()?;
let agg_config = CircuitBinsConfig::load(&bins_dir).map_err(|e| {
crate::error::QuantusError::Generic(format!(
"Failed to load circuit bins config from {:?}: {}",
bins_dir, e
))
})?;
let num_private_batch_proofs = agg_config.num_private_batch_proofs.ok_or_else(|| {
crate::error::QuantusError::Generic(
"Circuit binaries were generated without public-batch support; \
delete the generated-bins directory to regenerate them"
.to_string(),
)
})?;
if proof_files.is_empty() {
return Err(crate::error::QuantusError::Generic(
"At least one private-batch proof is required".to_string(),
));
}
if proof_files.len() > num_private_batch_proofs {
return Err(crate::error::QuantusError::Generic(format!(
"Too many proofs: {} provided, max {} supported by circuit",
proof_files.len(),
num_private_batch_proofs
)));
}
let prover = cached_public_batch_prover()?;
let common_data = prover.private_batch_common().clone();
let mut proofs = Vec::with_capacity(proof_files.len());
for (idx, proof_file) in proof_files.iter().enumerate() {
log_verbose!("Loading proof {}/{}: {}", idx + 1, proof_files.len(), proof_file);
let proof_bytes = read_hex_proof_file_to_bytes(proof_file)?;
let proof = ProofWithPublicInputs::<F, C, D>::from_bytes(proof_bytes, &common_data)
.map_err(|e| {
crate::error::QuantusError::Generic(format!(
"Failed to deserialize private-batch proof from {}: {}",
proof_file, e
))
})?;
proofs.push(proof);
}
let num_dummies = num_private_batch_proofs - proof_files.len();
if num_dummies > 0 {
log_print!(" Padding with {} dummy private-batch proof(s)...", num_dummies);
}
log_print!(" Running public-batch aggregation...");
let agg_start = std::time::Instant::now();
let public_batch_proof = prover
.prove_batch(PublicBatchInputs { proofs, aggregator_address })
.map_err(|e| {
crate::error::QuantusError::Generic(format!("Public-batch aggregation failed: {}", e))
})?;
log_print!(" Aggregation: {:.2}s", agg_start.elapsed().as_secs_f64());
let pi_u64s: Vec<u64> =
public_batch_proof.public_inputs.iter().map(|f| f.to_canonical_u64()).collect();
let public_inputs = PublicBatchPublicInputs::try_from_u64_slice(
&pi_u64s,
num_private_batch_proofs,
agg_config.num_leaf_proofs,
)
.map_err(|e| {
crate::error::QuantusError::Generic(format!(
"Failed to parse public-batch public inputs: {}",
e
))
})?;
log_verbose!("Public-batch public inputs: {:#?}", public_inputs);
log_print!(" Exit accounts in public batch:");
for (idx, account_data) in public_inputs.account_data.iter().enumerate() {
let exit_bytes: &[u8] = account_data.exit_account.as_ref();
let is_dummy = exit_bytes.iter().all(|&b| b == 0) || account_data.summed_output_amount == 0;
if is_dummy {
log_verbose!(" [{}] DUMMY (skipped)", idx);
} else {
let dequantized_amount =
(account_data.summed_output_amount as u128) * SCALE_DOWN_FACTOR;
log_print!(
" [{}] {} -> {}",
idx,
slice_to_quantus_ss58(exit_bytes)?,
format_balance(dequantized_amount)
);
}
}
log_verbose!("Verifying public-batch proof locally...");
prover.verifier_data().verify(public_batch_proof.clone()).map_err(|e| {
crate::error::QuantusError::Generic(format!(
"Public-batch proof verification failed: {}",
e
))
})?;
write_proof_file(&output_file, &public_batch_proof.to_bytes()).map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to write proof: {}", e))
})?;
log_success!("Public-batch aggregation complete!");
log_success!("Output: {}", output_file);
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IncludedAt {
#[allow(dead_code)]
Best,
Finalized,
}
impl IncludedAt {
pub fn label(self) -> &'static str {
match self {
IncludedAt::Best => "best block",
IncludedAt::Finalized => "finalized block",
}
}
}
impl std::fmt::Display for IncludedAt {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label())
}
}
fn read_hex_proof_file_to_bytes(proof_file: &str) -> crate::error::Result<Vec<u8>> {
let proof_hex = std::fs::read_to_string(proof_file).map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to read proof file: {}", e))
})?;
let proof_bytes = hex::decode(proof_hex.trim())
.map_err(|e| crate::error::QuantusError::Generic(format!("Failed to decode hex: {}", e)))?;
Ok(proof_bytes)
}
#[allow(dead_code)] pub async fn submit_unsigned_verify_private_batch(
quantus_client: &QuantusClient,
proof_bytes: Vec<u8>,
) -> crate::error::Result<(IncludedAt, subxt::utils::H256, subxt::utils::H256)> {
submit_unsigned_verify_private_batch_until(
quantus_client,
proof_bytes,
TransactionStage::Included,
)
.await
}
pub async fn submit_unsigned_verify_private_batch_until(
quantus_client: &QuantusClient,
proof_bytes: Vec<u8>,
target_stage: TransactionStage,
) -> crate::error::Result<(IncludedAt, subxt::utils::H256, subxt::utils::H256)> {
let stage = match target_stage {
TransactionStage::Finalized => TransactionStage::Finalized,
TransactionStage::Included | TransactionStage::Submitted => TransactionStage::Included,
};
let verify_tx = quantus_node::api::tx().wormhole().verify_private_batch(proof_bytes);
let unsigned_tx = quantus_client.client().tx().create_unsigned(&verify_tx).map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to create unsigned tx: {}", e))
})?;
let mut tx_progress = unsigned_tx
.submit_and_watch()
.await
.map_err(|e| crate::error::QuantusError::Generic(format!("Failed to submit tx: {}", e)))?;
let tx_hash = tx_progress.extrinsic_hash();
let block_hash = crate::cli::common::wait_tx_inclusion(
&mut tx_progress,
quantus_client.client(),
&tx_hash,
stage,
)
.await?;
Ok((included_at_for_stage(stage), block_hash, tx_hash))
}
async fn collect_wormhole_events_for_extrinsic(
quantus_client: &QuantusClient,
block_hash: subxt::utils::H256,
tx_hash: subxt::utils::H256,
) -> crate::error::Result<(bool, Vec<wormhole::events::NativeTransferred>)> {
use crate::chain::quantus_subxt::api::system::events::ExtrinsicFailed;
let block =
quantus_client.client().blocks().at(block_hash).await.map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to get block: {}", e))
})?;
let events = block
.events()
.await
.map_err(|e| crate::error::QuantusError::Generic(format!("Failed to get events: {}", e)))?;
let extrinsics = block.extrinsics().await.map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to get extrinsics: {}", e))
})?;
let our_ext_idx = extrinsics
.iter()
.enumerate()
.find(|(_, ext)| ext.hash() == tx_hash)
.map(|(idx, _)| idx as u32)
.ok_or_else(|| {
crate::error::QuantusError::Generic(
"Could not find submitted extrinsic in included block".to_string(),
)
})?;
let mut transfer_events = Vec::new();
let mut found_proof_verified = false;
let mut dispatch_error_message = None;
log_verbose!(" Events for our extrinsic (idx={}):", our_ext_idx);
for event_result in events.iter() {
let event = event_result.map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to decode event: {}", e))
})?;
if let subxt::events::Phase::ApplyExtrinsic(ext_idx) = event.phase() {
if ext_idx == our_ext_idx {
log_verbose!(" Event: {}::{}", event.pallet_name(), event.variant_name());
if let Ok(Some(ExtrinsicFailed { dispatch_error, .. })) =
event.as_event::<ExtrinsicFailed>()
{
let metadata = quantus_client.client().metadata();
let error_msg = format_dispatch_error(&dispatch_error, &metadata);
log_print!(" DispatchError: {}", error_msg);
dispatch_error_message = Some(error_msg);
}
if let Ok(Some(_)) = event.as_event::<wormhole::events::ProofVerified>() {
found_proof_verified = true;
}
if let Ok(Some(transfer)) = event.as_event::<wormhole::events::NativeTransferred>()
{
transfer_events.push(transfer);
}
}
}
}
finalize_wormhole_event_collection(
found_proof_verified,
dispatch_error_message,
transfer_events,
)
}
async fn verify_private_batch(
proof_file: String,
node_url: &str,
execution_mode: ExecutionMode,
) -> crate::error::Result<()> {
log_print!("Verifying aggregated wormhole proof on-chain...");
let proof_bytes = read_hex_proof_file_to_bytes(&proof_file)?;
log_verbose!("Aggregated proof size: {} bytes", proof_bytes.len());
let quantus_client = QuantusClient::new(node_url)
.await
.map_err(|e| crate::error::QuantusError::Generic(format!("Failed to connect: {}", e)))?;
log_verbose!("Connected to node");
log_verbose!("Submitting unsigned aggregated verification transaction...");
let (included_at, block_hash, tx_hash) = submit_unsigned_verify_private_batch_until(
&quantus_client,
proof_bytes,
wormhole_inclusion_stage(execution_mode),
)
.await?;
let result = check_proof_verification_events(
quantus_client.client(),
&block_hash,
&tx_hash,
crate::log::is_verbose(),
)
.await?;
if result.success {
log_success!("Aggregated proof verified successfully on-chain!");
if let Some(amount) = result.exit_amount {
log_success!("Total exit amount: {}", format_balance(amount));
}
log_print!(" Block: 0x{}", hex::encode(block_hash.0));
log_print!(" Extrinsic: 0x{}", hex::encode(tx_hash.0));
log_verbose!("Included in {}: {:?}", included_at.label(), block_hash);
return Ok(());
}
let error_msg = result.error_message.unwrap_or_else(|| {
"Aggregated proof verification failed - no ProofVerified event found".to_string()
});
log_error!("❌ {}", error_msg);
Err(crate::error::QuantusError::Generic(error_msg))
}
#[allow(dead_code)] pub async fn submit_unsigned_verify_public_batch(
quantus_client: &QuantusClient,
proof_bytes: Vec<u8>,
) -> crate::error::Result<(IncludedAt, subxt::utils::H256, subxt::utils::H256)> {
submit_unsigned_verify_public_batch_until(
quantus_client,
proof_bytes,
TransactionStage::Included,
)
.await
}
pub async fn submit_unsigned_verify_public_batch_until(
quantus_client: &QuantusClient,
proof_bytes: Vec<u8>,
target_stage: TransactionStage,
) -> crate::error::Result<(IncludedAt, subxt::utils::H256, subxt::utils::H256)> {
let stage = match target_stage {
TransactionStage::Finalized => TransactionStage::Finalized,
TransactionStage::Included | TransactionStage::Submitted => TransactionStage::Included,
};
let verify_tx = quantus_node::api::tx().wormhole().verify_public_batch(proof_bytes);
let unsigned_tx = quantus_client.client().tx().create_unsigned(&verify_tx).map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to create unsigned tx: {}", e))
})?;
let mut tx_progress = unsigned_tx
.submit_and_watch()
.await
.map_err(|e| crate::error::QuantusError::Generic(format!("Failed to submit tx: {}", e)))?;
let tx_hash = tx_progress.extrinsic_hash();
let block_hash = crate::cli::common::wait_tx_inclusion(
&mut tx_progress,
quantus_client.client(),
&tx_hash,
stage,
)
.await?;
Ok((included_at_for_stage(stage), block_hash, tx_hash))
}
async fn verify_public_batch(
proof_file: String,
node_url: &str,
execution_mode: ExecutionMode,
) -> crate::error::Result<()> {
log_print!("Verifying public-batch wormhole proof on-chain...");
let proof_bytes = read_hex_proof_file_to_bytes(&proof_file)?;
log_verbose!("Public-batch proof size: {} bytes", proof_bytes.len());
let quantus_client = QuantusClient::new(node_url)
.await
.map_err(|e| crate::error::QuantusError::Generic(format!("Failed to connect: {}", e)))?;
log_verbose!("Connected to node");
log_verbose!("Submitting unsigned public-batch verification transaction...");
let (included_at, block_hash, tx_hash) = submit_unsigned_verify_public_batch_until(
&quantus_client,
proof_bytes,
wormhole_inclusion_stage(execution_mode),
)
.await?;
let result = check_proof_verification_events(
quantus_client.client(),
&block_hash,
&tx_hash,
crate::log::is_verbose(),
)
.await?;
if result.success {
log_success!("Public-batch proof verified successfully on-chain!");
if let Some(amount) = result.exit_amount {
log_success!("Total exit amount: {}", format_balance(amount));
}
log_print!(" Block: 0x{}", hex::encode(block_hash.0));
log_print!(" Extrinsic: 0x{}", hex::encode(tx_hash.0));
log_verbose!("Included in {}: {:?}", included_at.label(), block_hash);
return Ok(());
}
let error_msg = result.error_message.unwrap_or_else(|| {
"Public-batch proof verification failed - no ProofVerified event found".to_string()
});
log_error!("❌ {}", error_msg);
Err(crate::error::QuantusError::Generic(error_msg))
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct TransferInfo {
pub block_hash: subxt::utils::H256,
pub transfer_count: u64,
pub amount: u128,
pub wormhole_address: SubxtAccountId,
pub funding_account: SubxtAccountId,
pub leaf_index: u64,
}
#[derive(Debug, Clone)]
struct ExpectedTransferEvent {
wormhole_address: SubxtAccountId,
funding_account: Option<SubxtAccountId>,
amount: Option<u128>,
transfer_count: Option<u64>,
leaf_index: Option<u64>,
}
struct RoundProofGeneration {
proof_files: Vec<String>,
expected_transfers: Vec<ExpectedTransferEvent>,
}
fn push_expected_transfer(
expected: &mut Vec<ExpectedTransferEvent>,
wormhole_address: SubxtAccountId,
funding_account: SubxtAccountId,
amount: u128,
transfer_count: Option<u64>,
leaf_index: Option<u64>,
) {
if let Some(existing) = expected.iter_mut().find(|e| {
e.wormhole_address == wormhole_address &&
e.funding_account.as_ref() == Some(&funding_account) &&
e.transfer_count == transfer_count &&
e.leaf_index == leaf_index
}) {
let current = existing.amount.unwrap_or(0);
existing.amount = Some(current.saturating_add(amount));
} else {
expected.push(ExpectedTransferEvent {
wormhole_address,
funding_account: Some(funding_account),
amount: Some(amount),
transfer_count,
leaf_index,
});
}
}
fn event_matches_expected(
event: &wormhole::events::NativeTransferred,
expected: &ExpectedTransferEvent,
) -> bool {
event.to == expected.wormhole_address &&
expected.funding_account.as_ref().is_none_or(|from| &event.from == from) &&
expected.amount.is_none_or(|amount| event.amount == amount) &&
expected.transfer_count.is_none_or(|count| event.transfer_count == count) &&
expected.leaf_index.is_none_or(|leaf| event.leaf_index == leaf)
}
fn parse_expected_transfer_events(
events: &[wormhole::events::NativeTransferred],
expected_transfers: &[ExpectedTransferEvent],
block_hash: subxt::utils::H256,
) -> Result<Vec<TransferInfo>, crate::error::QuantusError> {
let mut transfer_infos = Vec::with_capacity(expected_transfers.len());
for expected in expected_transfers {
let matches: Vec<&wormhole::events::NativeTransferred> =
events.iter().filter(|event| event_matches_expected(event, expected)).collect();
let matching_event = match matches.as_slice() {
[event] => *event,
[] => {
return Err(crate::error::QuantusError::Generic(format!(
"No transfer event found matching expected attributes for address {:?}",
expected.wormhole_address
)));
},
_ => {
return Err(crate::error::QuantusError::Generic(format!(
"Ambiguous transfer events matching expected attributes for address {:?}",
expected.wormhole_address
)));
},
};
transfer_infos.push(TransferInfo {
block_hash,
transfer_count: matching_event.transfer_count,
amount: matching_event.amount,
wormhole_address: expected.wormhole_address.clone(),
funding_account: matching_event.from.clone(),
leaf_index: matching_event.leaf_index,
});
}
Ok(transfer_infos)
}
fn derive_wormhole_secret(
mnemonic: &str,
round: usize,
index: usize,
) -> Result<WormholePair, crate::error::QuantusError> {
let path = format!("m/44'/{}/0'/{}'/{}'", QUANTUS_WORMHOLE_CHAIN_ID, round, index);
derive_wormhole_from_mnemonic(mnemonic, None, &path)
.map_err(|e| crate::error::QuantusError::Generic(format!("HD derivation failed: {:?}", e)))
}
fn calculate_round_amount(initial_amount: u128, round: usize) -> u128 {
let keep_bps = 10000u128.saturating_sub(VOLUME_FEE_BPS as u128);
let mut amount = initial_amount;
for _ in 0..round {
amount = amount.saturating_mul(keep_bps) / 10000;
}
amount
}
async fn get_minting_account(
client: &OnlineClient<ChainConfig>,
) -> Result<SubxtAccountId, crate::error::QuantusError> {
let minting_account_addr = quantus_node::api::constants().wormhole().minting_account();
let minting_account = client.constants().at(&minting_account_addr).map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to get minting account: {}", e))
})?;
Ok(minting_account)
}
#[allow(dead_code)]
pub fn parse_transfer_events(
events: &[wormhole::events::NativeTransferred],
expected_addresses: &[SubxtAccountId],
block_hash: subxt::utils::H256,
) -> Result<Vec<TransferInfo>, crate::error::QuantusError> {
let expected_transfers: Vec<ExpectedTransferEvent> = expected_addresses
.iter()
.cloned()
.map(|wormhole_address| ExpectedTransferEvent {
wormhole_address,
funding_account: None,
amount: None,
transfer_count: None,
leaf_index: None,
})
.collect();
parse_expected_transfer_events(events, &expected_transfers, block_hash)
}
struct MultiroundConfig {
num_proofs: usize,
rounds: usize,
amount: u128,
output_dir: String,
keep_files: bool,
}
struct MultiroundWalletContext {
wallet_name: String,
wallet_address: String,
wallet_account_id: SubxtAccountId,
keypair: QuantumKeyPair,
mnemonic: String,
}
fn validate_multiround_params(
num_proofs: usize,
rounds: usize,
max_proofs: usize,
) -> crate::error::Result<()> {
if !(1..=max_proofs).contains(&num_proofs) {
return Err(crate::error::QuantusError::Generic(format!(
"num_proofs must be between 1 and {} (got: {})",
max_proofs, num_proofs
)));
}
if rounds < 1 {
return Err(crate::error::QuantusError::Generic(format!(
"rounds must be at least 1 (got: {})",
rounds
)));
}
Ok(())
}
fn load_multiround_wallet(
wallet_name: &str,
password: Option<String>,
password_file: Option<String>,
) -> crate::error::Result<MultiroundWalletContext> {
let wallet_manager = WalletManager::new()?;
let wallet_password = password::get_wallet_password(wallet_name, password, password_file)?;
let mut wallet_data = wallet_manager.load_wallet(wallet_name, &wallet_password)?;
let wallet_address = wallet_data.keypair.try_to_account_id_ss58check()?;
let wallet_account_id = SubxtAccountId(wallet_data.keypair.try_to_account_id_32()?.into());
let mnemonic = wallet_data.take_mnemonic().ok_or_else(|| {
crate::error::QuantusError::Generic(
"Wallet does not contain a mnemonic. Use a wallet created from a mnemonic, or supply --mnemonic-file/--secret-file where supported.".to_string(),
)
})?;
log_verbose!("Using wallet mnemonic for HD derivation");
Ok(MultiroundWalletContext {
wallet_name: wallet_name.to_string(),
wallet_address,
wallet_account_id,
keypair: wallet_data.take_keypair(),
mnemonic,
})
}
fn print_multiround_config(
config: &MultiroundConfig,
wallet: &MultiroundWalletContext,
num_leaf_proofs: usize,
) {
use colored::Colorize;
log_print!("{}", "Configuration:".bright_cyan());
log_print!(" Wallet: {}", wallet.wallet_name);
log_print!(" Wallet address: {}", wallet.wallet_address);
log_print!(
" Total amount: {} ({}) - randomly partitioned across {} proofs",
config.amount,
format_balance(config.amount),
config.num_proofs
);
log_print!(" Proofs per round: {}", config.num_proofs);
log_print!(" Rounds: {}", config.rounds);
log_print!(" Aggregation: num_leaf_proofs={}", num_leaf_proofs);
log_print!(" Output directory: {}", config.output_dir);
log_print!(" Keep files: {}", config.keep_files);
log_print!("");
log_print!("{}", "Expected amounts per round:".bright_cyan());
for r in 1..=config.rounds {
let round_amount = calculate_round_amount(config.amount, r);
log_print!(" Round {}: {} ({})", r, round_amount, format_balance(round_amount));
}
log_print!("");
}
async fn execute_initial_transfers(
quantus_client: &QuantusClient,
wallet: &MultiroundWalletContext,
secrets: &[WormholePair],
amount: u128,
num_proofs: usize,
execution_mode: ExecutionMode,
) -> crate::error::Result<Vec<TransferInfo>> {
use colored::Colorize;
log_print!("{}", "Step 1: Sending batched transfer to wormhole addresses...".bright_yellow());
let partition_amounts = random_partition(amount, num_proofs, 3 * SCALE_DOWN_FACTOR);
let partitioned_total: u128 = partition_amounts.iter().sum();
if partitioned_total != amount {
return Err(crate::error::QuantusError::Generic(format!(
"internal error: random_partition summed to {partitioned_total}, expected {amount}"
)));
}
log_print!(" Random partition of {} ({}):", amount, format_balance(amount));
for (i, &amt) in partition_amounts.iter().enumerate() {
log_print!(" Proof {}: {} ({})", i + 1, amt, format_balance(amt));
}
let transfers: Vec<(String, u128)> = secrets
.iter()
.enumerate()
.map(|(i, secret)| (bytes_to_quantus_ss58(secret.address()), partition_amounts[i]))
.collect();
let batch_tx = crate::cli::send::build_batch_transfer_call(&transfers)?;
let balance_before = get_balance(quantus_client, &wallet.wallet_address).await?;
crate::cli::send::ensure_balance_covers_call(
quantus_client,
&crate::wallet::WalletSigner::Hot(wallet.keypair.clone()),
&batch_tx,
balance_before,
amount,
None,
"wormhole multiround funding batch",
)
.await?;
log_print!(" Submitting batch of {} transfers...", num_proofs);
let client = quantus_client.client();
let tip_block_hash = wormhole_tip_block(quantus_client, execution_mode)
.await
.map_err(|e| {
crate::error::QuantusError::Generic(format!(
"Failed to get tip block for transfer counts: {}",
e
))
})?
.hash();
let mut transfer_counts_before: Vec<u64> = Vec::with_capacity(num_proofs);
for secret in secrets.iter() {
let wormhole_address = SubxtAccountId(*secret.address());
let count = client
.storage()
.at(tip_block_hash)
.fetch(&quantus_node::api::storage().wormhole().transfer_count(wormhole_address))
.await
.map_err(|e| {
crate::error::QuantusError::Generic(format!(
"Failed to fetch transfer count for {}: {}",
hex::encode(secret.address()),
e
))
})?
.unwrap_or(0);
transfer_counts_before.push(count);
}
let wait_mode = wormhole_inclusion_mode(execution_mode);
let (_tx_hash, included_in) = crate::cli::common::submit_transaction_with_inclusion_block(
quantus_client,
&crate::wallet::WalletSigner::Hot(wallet.keypair.clone()),
batch_tx,
None,
wait_mode,
)
.await
.map_err(|e| crate::error::QuantusError::Generic(format!("Batch transfer failed: {}", e)))?;
let block_hash = included_in.ok_or_else(|| {
crate::error::QuantusError::Generic(
"Batch transfer watch returned no inclusion block".to_string(),
)
})?;
let events_api =
quantus_client.client().events().at(block_hash).await.map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to get events: {}", e))
})?;
let transfer_events: Vec<wormhole::events::NativeTransferred> = events_api
.find::<wormhole::events::NativeTransferred>()
.filter_map(|e| e.ok())
.collect();
let funding_account: SubxtAccountId =
SubxtAccountId(wallet.keypair.try_to_account_id_32()?.into());
let expected_transfers: Vec<ExpectedTransferEvent> = secrets
.iter()
.enumerate()
.map(|(i, secret)| ExpectedTransferEvent {
wormhole_address: SubxtAccountId(*secret.address()),
funding_account: Some(funding_account.clone()),
amount: Some(partition_amounts[i]),
transfer_count: Some(transfer_counts_before[i]),
leaf_index: None,
})
.collect();
let parsed = parse_expected_transfer_events(&transfer_events, &expected_transfers, block_hash)?;
let balance_after = get_balance(quantus_client, &wallet.wallet_address).await?;
let deducted = balance_before.saturating_sub(balance_after);
if deducted < amount {
return Err(crate::error::QuantusError::Generic(format!(
"Funding batch did not debit the wallet: free balance dropped by {} ({}) but \
{} ({}) was transferred. Have {}, now {}. Refusing to prove against a \
funding that did not leave this account.",
deducted,
format_balance(deducted),
amount,
format_balance(amount),
format_balance(balance_before),
format_balance(balance_after),
)));
}
log_success!(
" {} transfers submitted in a single batch ({} block {})",
num_proofs,
wait_mode.transaction_stage().status_label(),
hex::encode(block_hash.0)
);
log_print!(
" Balance after funding: {} ({}) [deducted: {} planck]",
balance_after,
format_balance(balance_after),
deducted
);
Ok(parsed)
}
async fn generate_round_proofs(
quantus_client: &QuantusClient,
secrets: &[WormholePair],
transfers: &[TransferInfo],
exit_accounts: &[SubxtAccountId],
minting_account: &SubxtAccountId,
round_dir: &str,
num_proofs: usize,
execution_mode: ExecutionMode,
) -> crate::error::Result<RoundProofGeneration> {
use colored::Colorize;
log_print!("{}", "Step 2: Generating proofs...".bright_yellow());
let proof_block = wormhole_tip_block(quantus_client, execution_mode)
.await
.map_err(|e| crate::error::QuantusError::Generic(format!("Failed to get block: {}", e)))?;
let proof_block_hash = proof_block.hash();
log_print!(
" Using {} block {} for all proofs",
if execution_mode.finalized { "finalized" } else { "best" },
hex::encode(proof_block_hash.0)
);
let input_amounts: Vec<u128> = transfers.iter().map(|t| t.amount).collect();
let exit_account_bytes: Vec<[u8; 32]> = exit_accounts.iter().map(|a| a.0).collect();
let output_assignments =
compute_random_output_assignments(&input_amounts, &exit_account_bytes, VOLUME_FEE_BPS)
.map_err(crate::error::QuantusError::Generic)?;
log_print!(" Random output partition:");
let mut expected_transfers = Vec::new();
for (i, assignment) in output_assignments.iter().enumerate() {
let amt1_planck = (assignment.output_amount_1 as u128) * SCALE_DOWN_FACTOR;
let ss58_1 = bytes_to_quantus_ss58(&assignment.exit_account_1);
if assignment.output_amount_1 > 0 {
push_expected_transfer(
&mut expected_transfers,
SubxtAccountId(assignment.exit_account_1),
minting_account.clone(),
amt1_planck,
None,
None,
);
}
if assignment.output_amount_2 > 0 {
let amt2_planck = (assignment.output_amount_2 as u128) * SCALE_DOWN_FACTOR;
let ss58_2 = bytes_to_quantus_ss58(&assignment.exit_account_2);
push_expected_transfer(
&mut expected_transfers,
SubxtAccountId(assignment.exit_account_2),
minting_account.clone(),
amt2_planck,
None,
None,
);
log_print!(
" Proof {}: {} ({}) -> {}, {} ({}) -> {}",
i + 1,
assignment.output_amount_1,
format_balance(amt1_planck),
ss58_1,
assignment.output_amount_2,
format_balance(amt2_planck),
ss58_2
);
} else {
log_print!(
" Proof {}: {} ({}) -> {}",
i + 1,
assignment.output_amount_1,
format_balance(amt1_planck),
ss58_1
);
}
}
let pb = ProgressBar::new(num_proofs as u64);
pb.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
.unwrap()
.progress_chars("#>-"),
);
let proof_gen_start = std::time::Instant::now();
let mut proof_files = Vec::new();
for (i, (secret, transfer)) in secrets.iter().zip(transfers.iter()).enumerate() {
pb.set_message(format!("Proof {}/{}", i + 1, num_proofs));
let proof_file = format!("{}/proof_{}.hex", round_dir, i + 1);
let funding_account_hex = format!("0x{}", hex::encode(transfer.funding_account.0));
let single_start = std::time::Instant::now();
let mut secret_hex = hex::encode(secret.secret().as_bytes());
let proof_result = generate_proof(
&secret_hex,
transfer.amount, &output_assignments[i],
&format!("0x{}", hex::encode(proof_block_hash.0)),
transfer.transfer_count,
&funding_account_hex,
transfer.leaf_index, &proof_file,
quantus_client,
)
.await;
crate::wallet::keystore::zeroize_string(&mut secret_hex);
proof_result?;
let single_elapsed = single_start.elapsed();
log_verbose!(" Proof {} generated in {:.2}s", i + 1, single_elapsed.as_secs_f64());
proof_files.push(proof_file);
pb.inc(1);
}
pb.finish_with_message("Proofs generated");
let proof_gen_elapsed = proof_gen_start.elapsed();
log_print!(
" Proof generation: {:.2}s ({} proofs, {:.2}s avg)",
proof_gen_elapsed.as_secs_f64(),
num_proofs,
proof_gen_elapsed.as_secs_f64() / num_proofs as f64,
);
Ok(RoundProofGeneration { proof_files, expected_transfers })
}
fn derive_round_secrets(
mnemonic: &str,
round: usize,
num_proofs: usize,
) -> crate::error::Result<Vec<WormholePair>> {
let pb = ProgressBar::new(num_proofs as u64);
pb.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
.unwrap()
.progress_chars("#>-"),
);
pb.set_message("Deriving secrets...");
let mut secrets = Vec::new();
for i in 1..=num_proofs {
let secret = derive_wormhole_secret(mnemonic, round, i)?;
secrets.push(secret);
pb.inc(1);
}
pb.finish_with_message("Secrets derived");
Ok(secrets)
}
fn verify_final_balance(
initial_balance: u128,
final_balance: u128,
total_sent: u128,
total_received: u128,
rounds: usize,
num_proofs: usize,
) -> crate::error::Result<()> {
use colored::Colorize;
log_print!("{}", "Balance Verification:".bright_cyan());
let expected_change = total_received as i128 - total_sent as i128;
let actual_change = final_balance as i128 - initial_balance as i128;
log_print!(" Initial balance: {} ({})", initial_balance, format_balance(initial_balance));
log_print!(" Final balance: {} ({})", final_balance, format_balance(final_balance));
log_print!("");
log_print!(" Total sent (round 1): {} ({})", total_sent, format_balance(total_sent));
log_print!(
" Total received (round {}): {} ({})",
rounds,
total_received,
format_balance(total_received)
);
log_print!("");
let expected_change_str = if expected_change >= 0 {
format!("+{expected_change}")
} else {
format!("{expected_change}")
};
let actual_change_str =
if actual_change >= 0 { format!("+{actual_change}") } else { format!("{actual_change}") };
log_print!(" Expected change: {expected_change_str} planck");
log_print!(" Actual change: {actual_change_str} planck");
log_print!("");
let tolerance = 1_000_000_000_000u128 .saturating_mul((1 + rounds as u128).max(2))
.max(total_sent / 1000);
let diff = (actual_change - expected_change).unsigned_abs();
if diff <= tolerance {
log_success!(
" {} Balance verification PASSED (within tolerance of {} planck)",
"✓".bright_green(),
tolerance
);
log_print!("");
Ok(())
} else {
log_print!("");
Err(crate::error::QuantusError::Generic(format!(
"Balance verification failed: actual change {actual_change_str} planck vs expected \
{expected_change_str} planck (diff {diff}, tolerance {tolerance}). \
Funding should debit ~{} and the final round should mint ~{} back \
({num_proofs} proofs, {rounds} rounds).",
format_balance(total_sent),
format_balance(total_received),
)))
}
}
#[allow(clippy::too_many_arguments)]
async fn run_multiround(
num_proofs: usize,
rounds: usize,
amount: u128,
wallet_name: String,
password: Option<String>,
password_file: Option<String>,
keep_files: bool,
output_dir: String,
dry_run: bool,
public: bool,
node_url: &str,
execution_mode: ExecutionMode,
) -> crate::error::Result<()> {
use colored::Colorize;
log_print!("");
log_print!("==================================================");
log_print!(" Wormhole Multi-Round Flow Test");
log_print!("==================================================");
log_print!("");
let bins_dir = crate::bins::ensure_bins_dir()?;
let agg_config = CircuitBinsConfig::load(&bins_dir).map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to load aggregation config: {}", e))
})?;
validate_multiround_params(num_proofs, rounds, agg_config.num_leaf_proofs)?;
let wallet = load_multiround_wallet(&wallet_name, password, password_file)?;
let config =
MultiroundConfig { num_proofs, rounds, amount, output_dir: output_dir.clone(), keep_files };
print_multiround_config(&config, &wallet, agg_config.num_leaf_proofs);
log_print!(" Dry run: {}", dry_run);
log_print!("");
std::fs::create_dir_all(&output_dir).map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to create output directory: {}", e))
})?;
if dry_run {
return run_multiround_dry_run(
&wallet.mnemonic,
num_proofs,
rounds,
amount,
&wallet.wallet_address,
);
}
let quantus_client = QuantusClient::new(node_url).await.map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to connect to node: {}", e))
})?;
let client = quantus_client.client();
let minting_account = get_minting_account(client).await?;
log_verbose!("Minting account: {:?}", minting_account);
let initial_balance = get_balance(&quantus_client, &wallet.wallet_address).await?;
log_print!("{}", "Initial Balance:".bright_cyan());
log_print!(" Wallet balance: {} ({})", initial_balance, format_balance(initial_balance));
log_print!("");
let mut current_transfers: Vec<TransferInfo> = Vec::new();
let mut final_exit_total: Option<u128> = None;
for round in 1..=rounds {
let is_final = round == rounds;
log_print!("");
log_print!("--------------------------------------------------");
log_print!(
" {} Round {} of {} {}",
">>>".bright_blue(),
round,
rounds,
"<<<".bright_blue()
);
log_print!("--------------------------------------------------");
log_print!("");
let round_dir = format!("{}/round{}", output_dir, round);
std::fs::create_dir_all(&round_dir).map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to create round directory: {}", e))
})?;
let secrets = derive_round_secrets(&wallet.mnemonic, round, num_proofs)?;
let exit_accounts: Vec<SubxtAccountId> = if is_final {
log_print!("Final round - all proofs exit to wallet: {}", wallet.wallet_address);
vec![wallet.wallet_account_id.clone(); num_proofs]
} else {
log_print!(
"Intermediate round - proofs exit to round {} wormhole addresses",
round + 1
);
let mut addrs = Vec::new();
for i in 1..=num_proofs {
let next_secret = derive_wormhole_secret(&wallet.mnemonic, round + 1, i)?;
addrs.push(SubxtAccountId(*next_secret.address()));
}
addrs
};
if round == 1 {
current_transfers = execute_initial_transfers(
&quantus_client,
&wallet,
&secrets,
amount,
num_proofs,
execution_mode,
)
.await?;
} else {
log_print!("{}", "Step 1: Using transfer info from previous round...".bright_yellow());
log_print!(" Found {} transfer(s) from previous round", current_transfers.len());
}
let RoundProofGeneration { proof_files, expected_transfers } = generate_round_proofs(
&quantus_client,
&secrets,
¤t_transfers,
&exit_accounts,
&minting_account,
&round_dir,
num_proofs,
execution_mode,
)
.await?;
log_print!("{}", "Step 3: Aggregating proofs...".bright_yellow());
let aggregated_file = format!("{}/aggregated.hex", round_dir);
aggregate_proofs(proof_files, aggregated_file.clone()).await?;
log_print!(" Aggregated proof saved to {}", aggregated_file);
let (verification_block, extrinsic_hash, transfer_events) = if public {
log_print!(
"{}",
"Step 4: Wrapping in a public batch and submitting on-chain...".bright_yellow()
);
let public_batch_file = format!("{}/public_batch.hex", round_dir);
aggregate_public_batch(
vec![aggregated_file.clone()],
wallet.wallet_address.clone(),
public_batch_file.clone(),
)
.await?;
verify_public_batch_and_get_events_until(
&public_batch_file,
&quantus_client,
wormhole_inclusion_stage(execution_mode),
)
.await?
} else {
log_print!("{}", "Step 4: Submitting aggregated proof on-chain...".bright_yellow());
verify_private_batch_and_get_events_until(
&aggregated_file,
&quantus_client,
wormhole_inclusion_stage(execution_mode),
)
.await?
};
log_print!(
" {} Proof verified in block {} (extrinsic: 0x{})",
"✓".bright_green(),
hex::encode(verification_block.0),
hex::encode(extrinsic_hash.0)
);
if !is_final {
log_print!("{}", "Step 5: Capturing transfer info for next round...".bright_yellow());
let next_round_addresses: Vec<SubxtAccountId> = (1..=num_proofs)
.map(|i| {
let next_secret =
derive_wormhole_secret(&wallet.mnemonic, round + 1, i).unwrap();
SubxtAccountId(*next_secret.address())
})
.collect();
let expected_ordered: Vec<ExpectedTransferEvent> = next_round_addresses
.iter()
.map(|addr| {
expected_transfers
.iter()
.find(|e| &e.wormhole_address == addr)
.cloned()
.ok_or_else(|| {
crate::error::QuantusError::Generic(format!(
"No expected transfer for next-round address {:?}",
addr
))
})
})
.collect::<Result<_, _>>()?;
current_transfers = parse_expected_transfer_events(
&transfer_events,
&expected_ordered,
verification_block,
)?;
log_print!(
" Captured {} transfer(s) for round {}",
current_transfers.len(),
round + 1
);
} else {
let minted: u128 = transfer_events.iter().map(|e| e.amount).sum();
if minted == 0 {
return Err(crate::error::QuantusError::Generic(
"Final round produced no NativeTransferred mint events to sum for \
balance verification"
.to_string(),
));
}
final_exit_total = Some(minted);
log_print!(" Final-round exits to wallet: {} ({})", minted, format_balance(minted));
}
let balance_after_round = get_balance(&quantus_client, &wallet.wallet_address).await?;
let change_from_initial = balance_after_round as i128 - initial_balance as i128;
let change_str = if change_from_initial >= 0 {
format!("+{}", change_from_initial)
} else {
format!("{}", change_from_initial)
};
log_print!("");
log_print!(
" Balance after round {}: {} ({}) [change: {} planck]",
round,
balance_after_round,
format_balance(balance_after_round),
change_str
);
log_print!("");
log_print!(" {} Round {} complete!", "✓".bright_green(), round);
}
log_print!("");
log_print!("==================================================");
log_success!(" All {} rounds completed successfully!", rounds);
log_print!("==================================================");
log_print!("");
let final_balance = get_balance(&quantus_client, &wallet.wallet_address).await?;
let total_received = final_exit_total.ok_or_else(|| {
crate::error::QuantusError::Generic(
"internal error: final-round exit total was not recorded".to_string(),
)
})?;
verify_final_balance(
initial_balance,
final_balance,
amount,
total_received,
rounds,
num_proofs,
)?;
if keep_files {
log_print!("Proof files preserved in: {}", output_dir);
} else {
log_print!("Cleaning up proof files...");
std::fs::remove_dir_all(&output_dir).ok();
}
Ok(())
}
async fn generate_proof(
secret_hex: &str,
_funding_amount: u128, output_assignment: &ProofOutputAssignment,
block_hash_str: &str,
transfer_count: u64,
_funding_account_str: &str, leaf_index: u64, output_file: &str,
quantus_client: &QuantusClient,
) -> crate::error::Result<()> {
let mut secret = parse_secret_hex(secret_hex).map_err(crate::error::QuantusError::Generic)?;
let block_hash_bytes: [u8; 32] = hex::decode(block_hash_str.trim_start_matches("0x"))
.map_err(|e| crate::error::QuantusError::Generic(format!("Invalid block hash: {}", e)))?
.try_into()
.map_err(|_| {
crate::error::QuantusError::Generic("Block hash must be 32 bytes".to_string())
})?;
let wormhole_address = wormhole_lib::compute_wormhole_address(&secret)
.map_err(|e| crate::error::QuantusError::Generic(e.message))?;
let block_hash = subxt::utils::H256::from(block_hash_bytes);
let client = quantus_client.client();
let blocks =
client.blocks().at(block_hash).await.map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to get block: {}", e))
})?;
let zk_proof = get_zk_merkle_proof(quantus_client, leaf_index, block_hash).await?;
let input_amount = decode_input_amount_from_leaf(&zk_proof.leaf_data)?;
let header = blocks.header();
let parent_hash: [u8; 32] = header.parent_hash.0;
let state_root: [u8; 32] = header.state_root.0;
let extrinsics_root: [u8; 32] = header.extrinsics_root.0;
let digest = header.digest.encode();
let block_number = header.number;
let (sorted_siblings, positions) =
compute_merkle_positions(&zk_proof.siblings, zk_proof.leaf_hash);
let mut input = wormhole_lib::ProofGenerationInput {
secret,
transfer_count,
wormhole_address,
input_amount,
block_hash: block_hash_bytes,
block_number,
parent_hash,
state_root,
extrinsics_root,
digest,
zk_tree_root: zk_proof.root,
zk_merkle_siblings: sorted_siblings,
zk_merkle_positions: positions,
exit_account_1: output_assignment.exit_account_1,
exit_account_2: output_assignment.exit_account_2,
output_amount_1: output_assignment.output_amount_1,
output_amount_2: output_assignment.output_amount_2,
volume_fee_bps: VOLUME_FEE_BPS,
asset_id: NATIVE_ASSET_ID,
};
crate::wallet::keystore::zeroize_bytes(&mut secret);
let bins_dir = crate::bins::ensure_bins_dir()?;
let result = wormhole_lib::generate_proof(
&mut input,
&bins_dir.join("prover.bin"),
&bins_dir.join("common.bin"),
)
.map_err(|e| crate::error::QuantusError::Generic(e.message))?;
let proof_hex = hex::encode(result.proof_bytes);
std::fs::write(output_file, proof_hex).map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to write proof: {}", e))
})?;
Ok(())
}
fn decode_input_amount_from_leaf(leaf_data: &[u8]) -> crate::error::Result<u32> {
if leaf_data.len() < 60 {
return Err(crate::error::QuantusError::Generic(format!(
"Invalid leaf data length: expected at least 60 bytes, got {}",
leaf_data.len()
)));
}
let amount_bytes: [u8; 16] = leaf_data[44..60].try_into().map_err(|_| {
crate::error::QuantusError::Generic("Failed to extract amount bytes".to_string())
})?;
let raw_amount = u128::from_le_bytes(amount_bytes);
const AMOUNT_SCALE_DOWN_FACTOR: u128 = 10_000_000_000;
let quantized = (raw_amount / AMOUNT_SCALE_DOWN_FACTOR) as u32;
Ok(quantized)
}
#[allow(dead_code)]
pub fn decode_full_leaf_data(leaf_data: &[u8]) -> crate::error::Result<([u8; 32], u64, u32, u128)> {
if leaf_data.len() < 60 {
return Err(crate::error::QuantusError::Generic(format!(
"Invalid leaf data length: expected at least 60 bytes, got {}",
leaf_data.len()
)));
}
let to_account: [u8; 32] = leaf_data[0..32].try_into().map_err(|_| {
crate::error::QuantusError::Generic("Failed to extract to_account".to_string())
})?;
let transfer_count = u64::from_le_bytes(leaf_data[32..40].try_into().map_err(|_| {
crate::error::QuantusError::Generic("Failed to extract transfer_count".to_string())
})?);
let asset_id = u32::from_le_bytes(leaf_data[40..44].try_into().map_err(|_| {
crate::error::QuantusError::Generic("Failed to extract asset_id".to_string())
})?);
let amount = u128::from_le_bytes(leaf_data[44..60].try_into().map_err(|_| {
crate::error::QuantusError::Generic("Failed to extract amount".to_string())
})?);
Ok((to_account, transfer_count, asset_id, amount))
}
#[allow(dead_code)] pub async fn verify_private_batch_and_get_events(
proof_file: &str,
quantus_client: &QuantusClient,
) -> crate::error::Result<(
subxt::utils::H256,
subxt::utils::H256,
Vec<wormhole::events::NativeTransferred>,
)> {
verify_private_batch_and_get_events_until(
proof_file,
quantus_client,
TransactionStage::Included,
)
.await
}
pub async fn verify_private_batch_and_get_events_until(
proof_file: &str,
quantus_client: &QuantusClient,
target_stage: TransactionStage,
) -> crate::error::Result<(
subxt::utils::H256,
subxt::utils::H256,
Vec<wormhole::events::NativeTransferred>,
)> {
let proof_bytes = read_hex_proof_file_to_bytes(proof_file)?;
log_verbose!("Verifying aggregated proof locally before on-chain submission...");
let bins_dir = crate::bins::ensure_bins_dir()?;
let common_bytes = std::fs::read(bins_dir.join("private_batch_common.bin")).map_err(|e| {
crate::error::QuantusError::Generic(format!(
"Failed to read private_batch_common.bin: {}",
e
))
})?;
let verifier_bytes =
std::fs::read(bins_dir.join("private_batch_verifier.bin")).map_err(|e| {
crate::error::QuantusError::Generic(format!(
"Failed to read private_batch_verifier.bin: {}",
e
))
})?;
log_verbose!(
"Circuit binaries: common_bytes.len={}, verifier_bytes.len={}, common_hash={}, verifier_hash={}",
common_bytes.len(),
verifier_bytes.len(),
hex::encode(blake3::hash(&common_bytes).as_bytes()),
hex::encode(blake3::hash(&verifier_bytes).as_bytes()),
);
let verifier = crate::batch_verifier::load_private_batch_verifier(&bins_dir).map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to load aggregated verifier: {}", e))
})?;
let proof = qp_wormhole_verifier::ProofWithPublicInputs::<
qp_wormhole_verifier::F,
qp_wormhole_verifier::C,
{ qp_wormhole_verifier::D },
>::from_bytes(proof_bytes.clone(), &verifier.circuit_data.common)
.map_err(|e| {
crate::error::QuantusError::Generic(format!(
"Failed to deserialize aggregated proof: {}",
e
))
})?;
verifier.verify(proof).map_err(|e| {
crate::error::QuantusError::Generic(format!(
"Local aggregated proof verification failed: {}",
e
))
})?;
log_verbose!("Local verification passed!");
let (included_at, block_hash, tx_hash) =
submit_unsigned_verify_private_batch_until(quantus_client, proof_bytes, target_stage)
.await?;
log_verbose!(
"Submitted tx included in {}: block={:?}, tx={:?}",
included_at.label(),
block_hash,
tx_hash
);
let (found_proof_verified, transfer_events) =
collect_wormhole_events_for_extrinsic(quantus_client, block_hash, tx_hash).await?;
if !found_proof_verified {
return Err(crate::error::QuantusError::Generic(
"Proof verification failed - no ProofVerified event".to_string(),
));
}
log_print!(" Tokens minted (from NativeTransferred events):");
for (idx, transfer) in transfer_events.iter().enumerate() {
let ss58_address = bytes_to_quantus_ss58(&transfer.to.0);
log_print!(
" [{}] {} -> {} planck ({})",
idx,
ss58_address,
transfer.amount,
format_balance(transfer.amount)
);
}
Ok((block_hash, tx_hash, transfer_events))
}
#[allow(dead_code)] pub async fn verify_public_batch_and_get_events(
proof_file: &str,
quantus_client: &QuantusClient,
) -> crate::error::Result<(
subxt::utils::H256,
subxt::utils::H256,
Vec<wormhole::events::NativeTransferred>,
)> {
verify_public_batch_and_get_events_until(proof_file, quantus_client, TransactionStage::Included)
.await
}
pub async fn verify_public_batch_and_get_events_until(
proof_file: &str,
quantus_client: &QuantusClient,
target_stage: TransactionStage,
) -> crate::error::Result<(
subxt::utils::H256,
subxt::utils::H256,
Vec<wormhole::events::NativeTransferred>,
)> {
let proof_bytes = read_hex_proof_file_to_bytes(proof_file)?;
log_verbose!("Verifying public-batch proof locally before on-chain submission...");
let bins_dir = crate::bins::ensure_bins_dir()?;
let verifier = crate::batch_verifier::load_public_batch_verifier(&bins_dir).map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to load public-batch verifier: {}", e))
})?;
let proof = qp_wormhole_verifier::ProofWithPublicInputs::<
qp_wormhole_verifier::F,
qp_wormhole_verifier::C,
{ qp_wormhole_verifier::D },
>::from_bytes(proof_bytes.clone(), &verifier.circuit_data.common)
.map_err(|e| {
crate::error::QuantusError::Generic(format!(
"Failed to deserialize public-batch proof: {}",
e
))
})?;
verifier.verify(proof).map_err(|e| {
crate::error::QuantusError::Generic(format!(
"Local public-batch proof verification failed: {}",
e
))
})?;
log_verbose!("Local verification passed!");
let (included_at, block_hash, tx_hash) =
submit_unsigned_verify_public_batch_until(quantus_client, proof_bytes, target_stage)
.await?;
log_verbose!(
"Submitted tx included in {}: block={:?}, tx={:?}",
included_at.label(),
block_hash,
tx_hash
);
let (found_proof_verified, transfer_events) =
collect_wormhole_events_for_extrinsic(quantus_client, block_hash, tx_hash).await?;
if !found_proof_verified {
return Err(crate::error::QuantusError::Generic(
"Public-batch proof verification failed - no ProofVerified event".to_string(),
));
}
log_print!(" Tokens minted (from NativeTransferred events):");
for (idx, transfer) in transfer_events.iter().enumerate() {
let ss58_address = bytes_to_quantus_ss58(&transfer.to.0);
log_print!(
" [{}] {} -> {} planck ({})",
idx,
ss58_address,
transfer.amount,
format_balance(transfer.amount)
);
}
Ok((block_hash, tx_hash, transfer_events))
}
fn run_multiround_dry_run(
mnemonic: &str,
num_proofs: usize,
rounds: usize,
amount: u128,
wallet_address: &str,
) -> crate::error::Result<()> {
use colored::Colorize;
log_print!("");
log_print!("{}", "=== DRY RUN MODE ===".bright_yellow());
log_print!("No transactions will be executed.");
log_print!("");
for round in 1..=rounds {
let is_final = round == rounds;
let round_amount = calculate_round_amount(amount, round);
log_print!("");
log_print!("{}", format!("Round {}", round).bright_cyan());
log_print!(" Total amount: {} ({})", round_amount, format_balance(round_amount));
if round == 1 {
let partition = random_partition(amount, num_proofs, 3 * SCALE_DOWN_FACTOR);
log_print!(" Sample random partition (actual partition will differ):");
for (i, &amt) in partition.iter().enumerate() {
log_print!(" Proof {}: {} ({})", i + 1, amt, format_balance(amt));
}
}
log_print!("");
log_print!(" Wormhole addresses (to be funded):");
for i in 1..=num_proofs {
let secret = derive_wormhole_secret(mnemonic, round, i)?;
let address = sp_core::crypto::AccountId32::new(*secret.address())
.to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(189));
log_print!(" [{}] {}", i, address);
}
log_print!("");
log_print!(" Exit accounts:");
if is_final {
log_print!(" All proofs exit to wallet: {}", wallet_address);
} else {
for i in 1..=num_proofs {
let next_secret = derive_wormhole_secret(mnemonic, round + 1, i)?;
let address = sp_core::crypto::AccountId32::new(*next_secret.address())
.to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(189));
log_print!(" [{}] {} (round {} wormhole)", i, address, round + 1);
}
}
}
log_print!("");
log_print!("{}", "=== END DRY RUN ===".bright_yellow());
log_print!("");
Ok(())
}
async fn parse_proof_file(
proof_file: String,
aggregated: bool,
public_batch: bool,
verify: bool,
) -> crate::error::Result<()> {
use qp_wormhole_verifier::WormholeVerifier;
log_print!("Parsing proof file: {}", proof_file);
let proof_bytes = read_proof_file(&proof_file)
.map_err(|e| crate::error::QuantusError::Generic(format!("Failed to read proof: {}", e)))?;
log_print!("Proof size: {} bytes", proof_bytes.len());
let bins_dir = crate::bins::ensure_bins_dir()?;
if public_batch {
use plonky2::field::types::PrimeField64;
use qp_wormhole_inputs::PublicBatchPublicInputs;
let agg_config = CircuitBinsConfig::load(&bins_dir).map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to load bins config: {}", e))
})?;
let num_private_batch_proofs = agg_config.num_private_batch_proofs.ok_or_else(|| {
crate::error::QuantusError::Generic(
"Circuit binaries lack public-batch support; regenerate them".to_string(),
)
})?;
let verifier =
crate::batch_verifier::load_public_batch_verifier(&bins_dir).map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to load verifier: {}", e))
})?;
let proof = qp_wormhole_verifier::ProofWithPublicInputs::<
qp_wormhole_verifier::F,
qp_wormhole_verifier::C,
{ qp_wormhole_verifier::D },
>::from_bytes(proof_bytes.clone(), &verifier.circuit_data.common)
.map_err(|e| {
crate::error::QuantusError::Generic(format!(
"Failed to deserialize public-batch proof: {:?}",
e
))
})?;
log_print!("\nPublic inputs count: {}", proof.public_inputs.len());
let pi_u64s: Vec<u64> = proof.public_inputs.iter().map(|f| f.to_canonical_u64()).collect();
match PublicBatchPublicInputs::try_from_u64_slice(
&pi_u64s,
num_private_batch_proofs,
agg_config.num_leaf_proofs,
) {
Ok(inputs) => {
log_print!("\n=== Parsed Public-Batch Public Inputs ===");
log_print!(
"Aggregator: 0x{} ({})",
hex::encode(inputs.aggregator_address.as_ref()),
slice_to_quantus_ss58(inputs.aggregator_address.as_ref())?
);
log_print!("Asset ID: {}", inputs.asset_id);
log_print!("Volume Fee BPS: {}", inputs.volume_fee_bps);
log_print!("Block Hash: 0x{}", hex::encode(inputs.block_data.block_hash.as_ref()));
log_print!("Block Number: {}", inputs.block_data.block_number);
log_print!("Total Exit Slots: {}", inputs.total_exit_slots);
log_print!("\nAccount Data ({} slots):", inputs.account_data.len());
for (i, acct) in inputs.account_data.iter().enumerate() {
log_print!(
" [{}] amount={}, exit=0x{}",
i,
acct.summed_output_amount,
hex::encode(acct.exit_account.as_ref())
);
}
log_print!("\nNullifiers ({} nullifiers):", inputs.nullifiers.len());
for (i, nullifier) in inputs.nullifiers.iter().enumerate() {
log_print!(" [{}] 0x{}", i, hex::encode(nullifier.as_ref()));
}
},
Err(e) => {
log_print!("Failed to parse as public-batch inputs: {}", e);
},
}
if verify {
log_print!("\n=== Verifying Proof ===");
match verifier.verify(proof) {
Ok(()) => log_success!("Proof verification PASSED"),
Err(e) => {
log_error!("Proof verification FAILED: {}", e);
return Err(crate::error::QuantusError::Generic(format!(
"Proof verification failed: {}",
e
)));
},
}
}
} else if aggregated {
let verifier =
crate::batch_verifier::load_private_batch_verifier(&bins_dir).map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to load verifier: {}", e))
})?;
let proof = qp_wormhole_verifier::ProofWithPublicInputs::<
qp_wormhole_verifier::F,
qp_wormhole_verifier::C,
{ qp_wormhole_verifier::D },
>::from_bytes(proof_bytes.clone(), &verifier.circuit_data.common)
.map_err(|e| {
crate::error::QuantusError::Generic(format!(
"Failed to deserialize aggregated proof: {:?}",
e
))
})?;
log_print!("\nPublic inputs count: {}", proof.public_inputs.len());
log_verbose!("\nPublic inputs count: {}", proof.public_inputs.len());
match qp_wormhole_verifier::parse_private_batch_public_inputs(&proof) {
Ok(agg_inputs) => {
log_print!("\n=== Parsed Aggregated Public Inputs ===");
log_print!("Asset ID: {}", agg_inputs.asset_id);
log_print!("Volume Fee BPS: {}", agg_inputs.volume_fee_bps);
log_print!(
"Block Hash: 0x{}",
hex::encode(agg_inputs.block_data.block_hash.as_ref())
);
log_print!("Block Number: {}", agg_inputs.block_data.block_number);
log_print!("\nAccount Data ({} accounts):", agg_inputs.account_data.len());
for (i, acct) in agg_inputs.account_data.iter().enumerate() {
log_print!(
" [{}] amount={}, exit=0x{}",
i,
acct.summed_output_amount,
hex::encode(acct.exit_account.as_ref())
);
}
log_print!("\nNullifiers ({} nullifiers):", agg_inputs.nullifiers.len());
for (i, nullifier) in agg_inputs.nullifiers.iter().enumerate() {
log_print!(" [{}] 0x{}", i, hex::encode(nullifier.as_ref()));
}
},
Err(e) => {
log_print!("Failed to parse as aggregated inputs: {}", e);
},
}
if verify {
log_print!("\n=== Verifying Proof ===");
match verifier.verify(proof) {
Ok(()) => {
log_success!("Proof verification PASSED");
},
Err(e) => {
log_error!("Proof verification FAILED: {}", e);
return Err(crate::error::QuantusError::Generic(format!(
"Proof verification failed: {}",
e
)));
},
}
}
} else {
let verifier = WormholeVerifier::new_from_files(
&bins_dir.join("verifier.bin"),
&bins_dir.join("common.bin"),
)
.map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to load verifier: {}", e))
})?;
let proof = qp_wormhole_verifier::ProofWithPublicInputs::<
qp_wormhole_verifier::F,
qp_wormhole_verifier::C,
{ qp_wormhole_verifier::D },
>::from_bytes(proof_bytes, &verifier.circuit_data.common)
.map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to deserialize proof: {:?}", e))
})?;
log_print!("\nPublic inputs count: {}", proof.public_inputs.len());
let pi = qp_wormhole_verifier::parse_public_inputs(&proof).map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to parse public inputs: {}", e))
})?;
log_print!("\n=== Parsed Leaf Public Inputs ===");
log_print!("Asset ID: {}", pi.asset_id);
log_print!("Output Amount 1: {}", pi.output_amount_1);
log_print!("Output Amount 2: {}", pi.output_amount_2);
log_print!("Volume Fee BPS: {}", pi.volume_fee_bps);
log_print!("Nullifier: 0x{}", hex::encode(pi.nullifier.as_ref()));
log_print!("Exit Account 1: 0x{}", hex::encode(pi.exit_account_1.as_ref()));
log_print!("Exit Account 2: 0x{}", hex::encode(pi.exit_account_2.as_ref()));
log_print!("Block Hash: 0x{}", hex::encode(pi.block_hash.as_ref()));
log_print!("Block Number: {}", pi.block_number);
if verify {
log_print!("\n=== Verifying Proof ===");
match verifier.verify(proof) {
Ok(()) => {
log_success!("Proof verification PASSED");
},
Err(e) => {
log_error!("Proof verification FAILED: {}", e);
return Err(crate::error::QuantusError::Generic(format!(
"Proof verification failed: {}",
e
)));
},
}
}
}
Ok(())
}
#[derive(Clone)]
struct DissolveOutput {
secret: [u8; 32],
amount: u128,
transfer_count: u64,
funding_account: SubxtAccountId,
proof_block_hash: subxt::utils::H256,
leaf_index: u64,
}
impl Drop for DissolveOutput {
fn drop(&mut self) {
crate::wallet::keystore::zeroize_bytes(&mut self.secret);
}
}
impl std::fmt::Debug for DissolveOutput {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DissolveOutput")
.field("secret", &"<redacted>")
.field("amount", &self.amount)
.field("transfer_count", &self.transfer_count)
.field("funding_account", &self.funding_account)
.field("proof_block_hash", &self.proof_block_hash)
.field("leaf_index", &self.leaf_index)
.finish()
}
}
#[allow(clippy::too_many_arguments)]
async fn run_dissolve(
amount: u128,
target_size: u128,
wallet_name: String,
password: Option<String>,
password_file: Option<String>,
keep_files: bool,
output_dir: String,
node_url: &str,
execution_mode: ExecutionMode,
) -> crate::error::Result<()> {
use colored::Colorize;
log_print!("");
log_print!("==================================================");
log_print!(" Wormhole Dissolve");
log_print!("==================================================");
log_print!("");
let mut num_outputs = 1u128;
let mut layers = 0usize;
while amount / num_outputs > target_size {
num_outputs *= 2;
layers += 1;
}
let final_output_count = num_outputs as usize;
log_print!(" Amount: {} ({})", amount, format_balance(amount));
log_print!(" Target size: {} ({})", target_size, format_balance(target_size));
log_print!(" Layers: {}", layers);
log_print!(" Final outputs: {}", final_output_count);
log_print!(
" Approximate output size: {} ({})",
amount / num_outputs,
format_balance(amount / num_outputs)
);
log_print!("");
let wallet = load_multiround_wallet(&wallet_name, password, password_file)?;
let funding_account = wallet.wallet_account_id.clone();
let quantus_client = QuantusClient::new(node_url)
.await
.map_err(|e| crate::error::QuantusError::Generic(format!("Failed to connect: {}", e)))?;
let minting_account = get_minting_account(quantus_client.client()).await?;
std::fs::create_dir_all(&output_dir).map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to create output directory: {}", e))
})?;
let bins_dir = crate::bins::ensure_bins_dir()?;
let agg_config = CircuitBinsConfig::load(&bins_dir).map_err(|e| {
crate::error::QuantusError::Generic(format!(
"Failed to load aggregation circuit config: {}",
e
))
})?;
log_print!("{}", "Layer 0: Initial funding".bright_yellow());
let initial_secret = derive_wormhole_secret(&wallet.mnemonic, 0, 1)?;
let wormhole_address = SubxtAccountId(*initial_secret.address());
let tip_block_hash = wormhole_tip_block(&quantus_client, execution_mode)
.await
.map_err(|e| {
crate::error::QuantusError::Generic(format!(
"Failed to get tip block for dissolve transfer count: {}",
e
))
})?
.hash();
let transfer_count_before = quantus_client
.client()
.storage()
.at(tip_block_hash)
.fetch(&quantus_node::api::storage().wormhole().transfer_count(wormhole_address.clone()))
.await
.map_err(|e| {
crate::error::QuantusError::Generic(format!(
"Failed to fetch transfer count for initial dissolve address: {}",
e
))
})?
.unwrap_or(0);
let transfer_tx = quantus_node::api::tx().balances().transfer_allow_death(
subxt::ext::subxt_core::utils::MultiAddress::Id(wormhole_address.clone()),
amount,
);
let quantum_keypair = QuantumKeyPair {
public_key: wallet.keypair.public_key.clone(),
private_key: wallet.keypair.private_key.clone(),
scheme: wallet.keypair.scheme,
};
let (_tx_hash, included_in) = crate::cli::common::submit_transaction_with_inclusion_block(
&quantus_client,
&crate::wallet::WalletSigner::Hot(quantum_keypair.clone()),
transfer_tx,
None,
wormhole_inclusion_mode(execution_mode),
)
.await
.map_err(|e| crate::error::QuantusError::Generic(format!("Initial transfer failed: {}", e)))?;
let block_hash = included_in.ok_or_else(|| {
crate::error::QuantusError::Generic(
"Initial transfer watch returned no inclusion block".to_string(),
)
})?;
let events_api =
quantus_client.client().events().at(block_hash).await.map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to get events: {}", e))
})?;
let transfer_events: Vec<wormhole::events::NativeTransferred> = events_api
.find::<wormhole::events::NativeTransferred>()
.filter_map(|e| e.ok())
.collect();
let expected_initial = [ExpectedTransferEvent {
wormhole_address: wormhole_address.clone(),
funding_account: Some(funding_account.clone()),
amount: Some(amount),
transfer_count: Some(transfer_count_before),
leaf_index: None,
}];
let initial_transfer =
parse_expected_transfer_events(&transfer_events, &expected_initial, block_hash)?
.into_iter()
.next()
.ok_or_else(|| {
crate::error::QuantusError::Generic("No initial transfer event found".to_string())
})?;
let mut current_outputs = vec![DissolveOutput {
secret: *initial_secret.secret().as_bytes(),
amount: initial_transfer.amount,
transfer_count: initial_transfer.transfer_count,
funding_account: initial_transfer.funding_account,
proof_block_hash: block_hash,
leaf_index: initial_transfer.leaf_index,
}];
log_success!(" Funded 1 wormhole address with {}", format_balance(amount));
for layer in 1..=layers {
let num_inputs = current_outputs.len();
let layer_dir = format!("{}/layer{}", output_dir, layer);
std::fs::create_dir_all(&layer_dir).map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to create layer directory: {}", e))
})?;
log_print!("");
log_print!(
"{} Layer {}/{}: {} inputs → {} outputs {}",
">>>".bright_blue(),
layer,
layers,
num_inputs,
num_inputs * 2,
"<<<".bright_blue()
);
let mut next_secrets: Vec<WormholePair> = Vec::new();
for i in 0..(num_inputs * 2) {
next_secrets.push(derive_wormhole_secret(&wallet.mnemonic, layer, i + 1)?);
}
let batch_size = agg_config.num_leaf_proofs;
let mut all_next_outputs: Vec<DissolveOutput> = Vec::new();
let num_batches = num_inputs.div_ceil(batch_size);
for batch_idx in 0..num_batches {
let batch_start = batch_idx * batch_size;
let batch_end = (batch_start + batch_size).min(num_inputs);
let batch_inputs = ¤t_outputs[batch_start..batch_end];
let batch_num_proofs = batch_inputs.len();
log_print!(" Batch {}/{}: {} proofs", batch_idx + 1, num_batches, batch_num_proofs);
let mut proof_files = Vec::new();
let proof_gen_start = std::time::Instant::now();
let batch_proof_block_hash = batch_inputs[0].proof_block_hash;
let mut expected_child_outputs: Vec<([u8; 32], ExpectedTransferEvent)> = Vec::new();
for (i, input) in batch_inputs.iter().enumerate() {
let global_idx = batch_start + i;
let exit_1_idx = global_idx * 2;
let exit_2_idx = global_idx * 2 + 1;
let input_quantized = quantize_funding_amount(input.amount)
.map_err(crate::error::QuantusError::Generic)?;
let total_output = compute_output_amount(input_quantized, VOLUME_FEE_BPS);
let output_1 = total_output / 2;
let output_2 = total_output - output_1;
let assignment = ProofOutputAssignment {
output_amount_1: output_1.max(1),
exit_account_1: *next_secrets[exit_1_idx].address(),
output_amount_2: output_2.max(1),
exit_account_2: *next_secrets[exit_2_idx].address(),
};
expected_child_outputs.push((
*next_secrets[exit_1_idx].secret().as_bytes(),
ExpectedTransferEvent {
wormhole_address: SubxtAccountId(*next_secrets[exit_1_idx].address()),
funding_account: Some(minting_account.clone()),
amount: Some((assignment.output_amount_1 as u128) * SCALE_DOWN_FACTOR),
transfer_count: None,
leaf_index: None,
},
));
expected_child_outputs.push((
*next_secrets[exit_2_idx].secret().as_bytes(),
ExpectedTransferEvent {
wormhole_address: SubxtAccountId(*next_secrets[exit_2_idx].address()),
funding_account: Some(minting_account.clone()),
amount: Some((assignment.output_amount_2 as u128) * SCALE_DOWN_FACTOR),
transfer_count: None,
leaf_index: None,
},
));
let proof_file = format!("{}/batch{}_proof{}.hex", layer_dir, batch_idx, i);
let mut secret_hex = hex::encode(input.secret);
let proof_result = generate_proof(
&secret_hex,
input.amount,
&assignment,
&format!("0x{}", hex::encode(batch_proof_block_hash.0)),
input.transfer_count,
&format!("0x{}", hex::encode(input.funding_account.0)),
input.leaf_index, &proof_file,
&quantus_client,
)
.await;
crate::wallet::keystore::zeroize_string(&mut secret_hex);
proof_result?;
proof_files.push(proof_file);
}
let proof_gen_elapsed = proof_gen_start.elapsed();
log_print!(
" Proof generation: {:.2}s ({} proofs)",
proof_gen_elapsed.as_secs_f64(),
batch_num_proofs
);
log_print!(" Aggregating...");
let aggregated_file = format!("{}/batch{}_aggregated.hex", layer_dir, batch_idx);
aggregate_proofs_to_file(&proof_files, &aggregated_file)?;
log_print!(" Verifying on-chain...");
let (verification_block, _extrinsic_hash, transfer_events) =
verify_private_batch_and_get_events_until(
&aggregated_file,
&quantus_client,
wormhole_inclusion_stage(execution_mode),
)
.await?;
log_success!(" Verified in block 0x{}", hex::encode(verification_block.0));
let expected_events: Vec<ExpectedTransferEvent> =
expected_child_outputs.iter().map(|(_, expected)| expected.clone()).collect();
let parsed_outputs = parse_expected_transfer_events(
&transfer_events,
&expected_events,
verification_block,
)?;
for ((secret, _expected), transfer) in
expected_child_outputs.into_iter().zip(parsed_outputs.into_iter())
{
all_next_outputs.push(DissolveOutput {
secret,
amount: transfer.amount,
transfer_count: transfer.transfer_count,
funding_account: transfer.funding_account,
proof_block_hash: verification_block,
leaf_index: transfer.leaf_index,
});
}
}
log_print!(" Layer {} complete: {} outputs", layer, all_next_outputs.len());
current_outputs = all_next_outputs;
}
log_print!("");
log_print!("==================================================");
log_success!(" Dissolve complete!");
log_print!("==================================================");
log_print!("");
log_print!(" Final outputs: {}", current_outputs.len());
let min_output = current_outputs.iter().map(|o| o.amount).min().unwrap_or(0);
let max_output = current_outputs.iter().map(|o| o.amount).max().unwrap_or(0);
let total_output: u128 = current_outputs.iter().map(|o| o.amount).sum();
log_print!(
" Output range: {} - {} ({})",
format_balance(min_output),
format_balance(max_output),
format_balance(total_output)
);
if keep_files {
log_print!(" Proof files preserved in: {}", output_dir);
} else {
log_print!(" Cleaning up proof files...");
std::fs::remove_dir_all(&output_dir).ok();
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn run_collect_rewards(
wallet_name: Option<String>,
mnemonic_file_arg: Option<String>,
secret_file_arg: Option<String>,
password: Option<String>,
password_file: Option<String>,
amount: Option<f64>,
destination: Option<String>,
subsquid_url: String,
wormhole_index: usize,
dry_run: bool,
node_url: &str,
at_block: Option<u32>,
) -> crate::error::Result<()> {
use crate::collect_rewards_lib::{
collect_rewards, CollectRewardsConfig, ProgressCallback, WormholeCredential,
};
use colored::Colorize;
log_print!("");
log_print!("==================================================");
log_print!(" Wormhole Collect Rewards");
log_print!("==================================================");
log_print!("");
let (credential, wallet_address) = if let Some(wallet_name) = wallet_name {
let wallet = load_multiround_wallet(&wallet_name, password, password_file)?;
(
WormholeCredential::Mnemonic { phrase: wallet.mnemonic, wormhole_index },
Some(wallet.wallet_address),
)
} else if let Some(mnemonic_file) = mnemonic_file_arg {
let mnemonic = password::read_mnemonic_file(&mnemonic_file)?;
(WormholeCredential::Mnemonic { phrase: mnemonic, wormhole_index }, None)
} else if let Some(secret_file) = secret_file_arg {
let secret = read_secret_hex_file(&secret_file)?;
(WormholeCredential::Secret { hex: secret }, None)
} else {
return Err(crate::error::QuantusError::Generic(
"Either --wallet, --mnemonic-file, or --secret-file must be provided".to_string(),
));
};
let destination_address = if let Some(dest) = &destination {
dest.clone()
} else if let Some(addr) = wallet_address.as_ref() {
addr.clone()
} else {
return Err(crate::error::QuantusError::Generic(
"--destination is required when using --mnemonic-file or --secret-file".to_string(),
));
};
let amount_planck = amount.map(|a| (a * 1_000_000_000_000.0) as u128);
if let Some(ref addr) = wallet_address {
log_print!(" Wallet: {}", addr.bright_yellow());
} else {
match &credential {
WormholeCredential::Mnemonic { .. } => {
log_print!(" Wallet: {}", "(from mnemonic)".bright_yellow());
},
WormholeCredential::Secret { .. } => {
log_print!(" Wallet: {}", "(from secret)".bright_yellow());
},
}
}
match &credential {
WormholeCredential::Mnemonic { wormhole_index, .. } => {
log_print!(" Wormhole index: {}", wormhole_index);
},
WormholeCredential::Secret { .. } => {
log_print!(" Wormhole index: {}", "(N/A - using direct secret)".dimmed());
},
}
log_print!(" Destination: {}", destination_address.bright_green());
log_print!(" Subsquid URL: {}", subsquid_url);
log_print!(" Node URL: {}", node_url);
log_print!("");
struct CliProgress;
impl ProgressCallback for CliProgress {
fn on_step(&self, step: &str, details: &str) {
use colored::Colorize;
match step {
"derive" => log_print!("{}", format!("Step 1: {}...", details).bright_yellow()),
"query" => log_print!("{}", format!("Step 2: {}...", details).bright_yellow()),
"connect" => log_print!("{}", format!("Step 3: {}...", details).bright_yellow()),
"nullifiers" => log_print!("{}", format!("Step 4: {}...", details).bright_yellow()),
"proofs" => log_print!("{}", format!("Step 5: {}...", details).bright_yellow()),
"submit" => log_print!("{}", format!("Step 6: {}...", details).bright_yellow()),
_ => log_print!(" {}: {}", step, details),
}
}
fn on_proof_generated(&self, index: usize, total: usize) {
log_print!(" [{}/{}] Proof generated", index, total);
}
fn on_batch_submitted(&self, batch_index: usize, total_batches: usize, amount: u128) {
use colored::Colorize;
log_print!(
" Batch {}/{}: {} withdrawn",
batch_index,
total_batches,
format_balance(amount).bright_cyan()
);
}
fn on_error(&self, message: &str) {
log_error!("{}", message);
}
}
let config = CollectRewardsConfig {
credential,
destination_address: destination_address.clone(),
subsquid_url,
node_url: node_url.to_string(),
bins_dir: crate::bins::ensure_bins_dir()?.to_string_lossy().into_owned(),
amount: amount_planck,
dry_run,
at_block,
};
let result = collect_rewards(config, &CliProgress)
.await
.map_err(|e| crate::error::QuantusError::Generic(e.message))?;
log_print!("");
if result.total_withdrawn > 0 {
log_print!("{}", "Withdrawal complete!".bright_green().bold());
log_print!(
" Total withdrawn: {} across {} batch(es)",
format_balance(result.total_withdrawn).bright_cyan(),
result.batches.len()
);
log_print!(" Transfers processed: {}", result.transfers_processed);
log_print!(" Destination: {}", destination_address.bright_green());
for (i, batch) in result.batches.iter().enumerate() {
log_print!(
" Batch {}: {} (tx: 0x{}...)",
i + 1,
format_balance(batch.amount_withdrawn).bright_cyan(),
&batch.tx_hash[..16]
);
}
} else if dry_run {
log_print!("{}", "Dry run complete - no transactions submitted.".bright_blue());
log_print!(" Transfers available: {}", result.transfers_processed);
} else {
log_print!("{}", "No transfers to withdraw.".bright_yellow());
}
Ok(())
}
fn aggregate_proofs_to_file(proof_files: &[String], output_file: &str) -> crate::error::Result<()> {
let bins_dir = crate::bins::ensure_bins_dir()?;
let common_data = load_leaf_common_data(&bins_dir)?;
let mut proofs = Vec::with_capacity(proof_files.len());
for proof_file in proof_files {
let proof_bytes = read_hex_proof_file_to_bytes(proof_file)?;
let proof = ProofWithPublicInputs::<F, C, D>::from_bytes(proof_bytes, &common_data)
.map_err(|e| {
crate::error::QuantusError::Generic(format!(
"Failed to deserialize proof from {}: {:?}",
proof_file, e
))
})?;
proofs.push(proof);
}
let prover = cached_private_batch_prover()?;
let agg_start = std::time::Instant::now();
let proof = prover
.aggregate(proofs)
.map_err(|e| crate::error::QuantusError::Generic(format!("Aggregation failed: {}", e)))?;
let agg_elapsed = agg_start.elapsed();
log_print!(" Aggregation: {:.2}s", agg_elapsed.as_secs_f64());
let proof_hex = hex::encode(proof.to_bytes());
std::fs::write(output_file, &proof_hex).map_err(|e| {
crate::error::QuantusError::Generic(format!("Failed to write proof: {}", e))
})?;
Ok(())
}
async fn run_check_nullifier(
secret_file: Option<String>,
wallet_name: Option<String>,
password: Option<String>,
password_file: Option<String>,
wormhole_index: usize,
transfer_counts_arg: String,
subsquid_url: String,
) -> crate::error::Result<()> {
use crate::subsquid::{compute_address_hash, SubsquidClient};
use colored::Colorize;
let secret = if let Some(path) = secret_file {
let hex = read_secret_hex_file(&path)?;
parse_secret_hex(&hex).map_err(crate::error::QuantusError::Generic)?
} else if let Some(wallet) = wallet_name {
let wallet_manager = WalletManager::new()?;
let wallet_password = password::get_wallet_password(&wallet, password, password_file)?;
let mut wallet_data = wallet_manager.load_wallet(&wallet, &wallet_password)?;
let mnemonic = wallet_data.take_mnemonic().ok_or_else(|| {
crate::error::QuantusError::Generic(
"Wallet does not contain a mnemonic. Use --secret-file instead.".to_string(),
)
})?;
let path = format!("m/44'/{}/0'/1'/{}'", QUANTUS_WORMHOLE_CHAIN_ID, wormhole_index);
let wormhole_pair = derive_wormhole_from_mnemonic(&mnemonic, None, &path).map_err(|e| {
crate::error::QuantusError::Generic(format!("HD derivation failed: {:?}", e))
})?;
let secret: [u8; 32] = *wormhole_pair.secret().as_bytes();
log_print!("Derived wormhole secret from wallet '{}' (index {})", wallet, wormhole_index);
secret
} else {
return Err(crate::error::QuantusError::Generic(
"Either --secret-file or --wallet must be provided".to_string(),
));
};
let transfer_counts: Vec<u64> = if transfer_counts_arg.contains('-') {
let parts: Vec<&str> = transfer_counts_arg.split('-').collect();
if parts.len() != 2 {
return Err(crate::error::QuantusError::Generic(
"Invalid range format. Use 'start-end' (e.g., '0-10')".to_string(),
));
}
let start: u64 = parts[0].parse().map_err(|_| {
crate::error::QuantusError::Generic(format!("Invalid start number: {}", parts[0]))
})?;
let end: u64 = parts[1].parse().map_err(|_| {
crate::error::QuantusError::Generic(format!("Invalid end number: {}", parts[1]))
})?;
(start..=end).collect()
} else {
vec![transfer_counts_arg.parse().map_err(|_| {
crate::error::QuantusError::Generic(format!(
"Invalid transfer count: {}",
transfer_counts_arg
))
})?]
};
log_print!("{}", "Checking Nullifiers".bright_cyan());
log_print!(" Transfer counts: {:?}", transfer_counts);
log_print!(" Subsquid URL: {}", subsquid_url);
log_print!("");
let mut nullifiers_to_check: Vec<(String, String, u64)> = Vec::new();
for tc in &transfer_counts {
let nullifier = wormhole_lib::compute_nullifier(&secret, *tc).map_err(|e| {
crate::error::QuantusError::Generic(format!(
"Failed to compute nullifier for transfer_count {}: {}",
tc, e.message
))
})?;
let nullifier_hex = hex::encode(nullifier);
let nullifier_hash = compute_address_hash(&nullifier);
nullifiers_to_check.push((nullifier_hex, nullifier_hash, *tc));
}
let client = SubsquidClient::new(subsquid_url)?;
let prefix_len = 8;
let nullifier_pairs: Vec<(String, String)> = nullifiers_to_check
.iter()
.map(|(nul, hash, _)| (nul.clone(), hash.clone()))
.collect();
let spent_set = client.check_nullifiers_spent(&nullifier_pairs, prefix_len).await?;
log_print!("{}", "Results:".bright_yellow());
let mut spent_count = 0;
let mut unspent_count = 0;
for (nullifier_hex, _nullifier_hash, tc) in &nullifiers_to_check {
let is_spent = spent_set.contains(nullifier_hex);
if is_spent {
log_print!(
" [{}] transfer_count={}: {} (nullifier: 0x{}...)",
"SPENT".bright_red(),
tc,
"Already withdrawn".red(),
&nullifier_hex[..16]
);
spent_count += 1;
} else {
log_print!(
" [{}] transfer_count={}: {} (nullifier: 0x{}...)",
"UNSPENT".bright_green(),
tc,
"Available for withdrawal".green(),
&nullifier_hex[..16]
);
unspent_count += 1;
}
}
log_print!("");
log_print!(
"Summary: {} spent, {} unspent out of {} checked",
spent_count,
unspent_count,
nullifiers_to_check.len()
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use qp_zk_circuits_common::zk_merkle::MAX_DEPTH;
use serde_json::json;
use std::collections::{HashMap, HashSet};
use tempfile::NamedTempFile;
fn hash_bytes(seed: u16) -> Vec<u8> {
let mut out = vec![0u8; 32];
for (i, byte) in out.iter_mut().enumerate() {
*byte = seed.wrapping_add(i as u16) as u8;
}
out
}
#[test]
fn malicious_zk_merkle_rpc_rejects_oversized_mismatched_depth() {
let sibling_levels: Vec<_> = (0..=u8::MAX as u16)
.map(|level| {
vec![
hash_bytes(level.wrapping_mul(3)),
hash_bytes(level.wrapping_mul(3).wrapping_add(1)),
hash_bytes(level.wrapping_mul(3).wrapping_add(2)),
]
})
.collect();
let malicious_rpc_response = json!({
"leaf_index": 7_u64,
"leaf_data": [42_u8],
"leaf_hash": hash_bytes(900),
"siblings": sibling_levels,
"root": hash_bytes(901),
"depth": 1_u8
});
let err = serde_json::from_value::<ZkMerkleProofRpc>(malicious_rpc_response)
.expect_err("oversized mismatched Merkle proof must be rejected");
let message = err.to_string();
assert!(
message.contains("exceeds max") ||
message.contains("expected 60 bytes") ||
message.contains("does not match siblings length"),
"unexpected rejection reason: {message}"
);
assert!(
MAX_DEPTH < u8::MAX as usize,
"test assumes circuit MAX_DEPTH is below attacker-supplied depth"
);
}
#[test]
fn zk_merkle_rpc_rejects_depth_sibling_mismatch() {
let siblings = vec![vec![hash_bytes(1), hash_bytes(2), hash_bytes(3)]];
let response = json!({
"leaf_index": 1_u64,
"leaf_data": vec![0_u8; 60],
"leaf_hash": hash_bytes(10),
"siblings": siblings,
"root": hash_bytes(11),
"depth": 2_u8
});
let err = serde_json::from_value::<ZkMerkleProofRpc>(response)
.expect_err("depth must match siblings length");
assert!(err.to_string().contains("does not match siblings length"));
}
#[test]
fn recursive_flows_align_tip_reads_with_tx_wait_mode() {
assert_eq!(IncludedAt::Finalized.label(), "finalized block");
assert_ne!(IncludedAt::Best.label(), IncludedAt::Finalized.label());
let _: *const () = at_finalized_block as *const ();
let _: *const () = at_best_block as *const ();
assert_eq!(wormhole_inclusion_stage(ExecutionMode::default()), TransactionStage::Included);
assert_eq!(
wormhole_inclusion_stage(ExecutionMode {
finalized: true,
wait_for_transaction: false,
}),
TransactionStage::Finalized
);
}
#[test]
fn unsigned_verify_submitters_use_bounded_inclusion_wait() {
let source = include_str!("wormhole.rs");
let private_fn = source
.split("pub async fn submit_unsigned_verify_private_batch_until")
.nth(1)
.and_then(|s| s.split("pub async fn ").next())
.expect("private batch submitter");
let public_fn = source
.split("pub async fn submit_unsigned_verify_public_batch_until")
.nth(1)
.and_then(|s| s.split("pub async fn ").next())
.expect("public batch submitter");
for body in [private_fn, public_fn] {
assert!(
body.contains("wait_tx_inclusion"),
"unsigned verify must use the bounded wait_tx_inclusion helper"
);
assert!(
!body.contains("tx_progress.next().await"),
"unsigned verify must not wait on an unbounded status stream"
);
assert!(
body.contains("&tx_hash,\n\t\tstage,\n\t)"),
"wait_tx_inclusion must receive the caller-selected stage parameter"
);
}
}
#[test]
fn test_compute_output_amount() {
assert_eq!(compute_output_amount(1000, 10), 999);
assert_eq!(compute_output_amount(10000, 10), 9990);
assert_eq!(compute_output_amount(1000, 100), 990);
assert_eq!(compute_output_amount(10000, 100), 9900);
assert_eq!(compute_output_amount(1000, 0), 1000);
assert_eq!(compute_output_amount(0, 10), 0);
assert_eq!(compute_output_amount(1, 10), 0); assert_eq!(compute_output_amount(100, 10), 99);
assert_eq!(compute_output_amount(10_000, VOLUME_FEE_BPS), 9_996);
}
#[test]
fn calculate_round_amount_uses_on_chain_volume_fee_bps() {
let one = 100_000_000_000_000u128;
let keep = 10000u128 - VOLUME_FEE_BPS as u128;
assert_eq!(calculate_round_amount(one, 0), one);
assert_eq!(calculate_round_amount(one, 1), one * keep / 10000);
assert_eq!(calculate_round_amount(one, 2), one * keep / 10000 * keep / 10000);
assert_ne!(calculate_round_amount(one, 2), one * 9990 / 10000 * 9990 / 10000);
}
#[test]
fn verify_final_balance_accepts_closed_loop_within_fee_tolerance() {
let sent = 100_000_000_000_000u128;
let received = calculate_round_amount(sent, 2);
let initial = 150_000_000_000_000u128;
let final_bal = initial - sent + received;
verify_final_balance(initial, final_bal, sent, received, 2, 2)
.expect("exact closed loop must pass");
}
#[test]
fn verify_final_balance_rejects_missing_funding_debit() {
let sent = 100_000_000_000_000u128;
let received = 99_890_000_000_000u128;
let initial = 10_990_000_000_000u128;
let final_bal = initial + received;
let err = verify_final_balance(initial, final_bal, sent, received, 2, 2)
.expect_err("missing funding debit must fail verification");
assert!(err.to_string().contains("Balance verification failed"), "unexpected error: {err}");
}
#[test]
fn test_parse_secret_hex() {
let secret = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
assert!(parse_secret_hex(secret).is_ok());
assert!(parse_secret_hex(&format!("0x{}", secret)).is_ok());
assert!(parse_secret_hex("0123456789abcdef").unwrap_err().contains("32 bytes"));
assert!(parse_secret_hex("ghij".repeat(16).as_str())
.unwrap_err()
.contains("Invalid secret hex"));
}
#[cfg(unix)]
#[test]
fn read_secret_hex_file_enforces_owner_only_permissions() {
use std::os::unix::fs::PermissionsExt;
let secret = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("secret.hex");
std::fs::write(&path, format!("{secret}\n")).expect("write secret file");
let path_str = path.to_string_lossy().into_owned();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
let msg = read_secret_hex_file(&path_str).unwrap_err().to_string();
assert!(
msg.contains("accessible by other users") && msg.contains("chmod 600"),
"expected restrictive-mode rejection with fix hint, got: {msg}"
);
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
assert_eq!(read_secret_hex_file(&path_str).expect("owner-only secret file"), secret);
}
#[test]
fn test_parse_exit_account() {
let hex = "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
assert!(parse_exit_account(hex).is_ok());
assert!(parse_exit_account("0x0123456789abcdef").unwrap_err().contains("32 bytes"));
assert!(parse_exit_account("not_valid").unwrap_err().contains("Invalid SS58"));
}
#[test]
fn test_quantize_funding_amount() {
assert_eq!(quantize_funding_amount(1_000_000_000_000).unwrap(), 100);
assert_eq!(quantize_funding_amount(0).unwrap(), 0);
assert_eq!(quantize_funding_amount(5_000_000_000).unwrap(), 0);
let max_valid = (u32::MAX as u128) * SCALE_DOWN_FACTOR;
assert_eq!(quantize_funding_amount(max_valid).unwrap(), u32::MAX);
assert!(quantize_funding_amount(max_valid + SCALE_DOWN_FACTOR)
.unwrap_err()
.contains("exceeds u32::MAX"));
}
#[test]
fn test_proof_file_roundtrip() {
let temp_file = NamedTempFile::new().unwrap();
let path = temp_file.path().to_str().unwrap();
let proof_bytes = vec![0x01, 0x02, 0x03, 0xaa, 0xbb, 0xcc];
write_proof_file(path, &proof_bytes).unwrap();
assert_eq!(read_proof_file(path).unwrap(), proof_bytes);
}
#[test]
fn prepare_output_dir_rejects_existing_public_batch_and_batch_dir() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path();
assert!(ensure_fresh_prepare_output_dir(path.to_str().unwrap()).is_ok());
std::fs::write(path.join("notes.txt"), b"ok").unwrap();
assert!(
ensure_fresh_prepare_output_dir(path.to_str().unwrap()).is_ok(),
"unrelated files must not block a fresh prepare"
);
std::fs::write(path.join("public_batch_0000.hex"), b"proof").unwrap();
let err = ensure_fresh_prepare_output_dir(path.to_str().unwrap()).unwrap_err().to_string();
assert!(
err.contains("public_batch_0000.hex") && err.contains("fresh --output-dir"),
"expected occupied-dir error, got: {err}"
);
std::fs::remove_file(path.join("public_batch_0000.hex")).unwrap();
std::fs::create_dir(path.join("batch_0000")).unwrap();
let err = ensure_fresh_prepare_output_dir(path.to_str().unwrap()).unwrap_err().to_string();
assert!(
err.contains("batch_0000"),
"expected batch dir to count as an artifact, got: {err}"
);
}
#[test]
fn refuse_existing_path_blocks_overwrite() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("public_batch_0000.hex");
let path_str = path.to_str().unwrap();
refuse_existing_path(path_str).expect("missing path is fine");
std::fs::write(&path, b"old").unwrap();
let err = refuse_existing_path(path_str).unwrap_err().to_string();
assert!(err.contains("refusing to overwrite"), "got: {err}");
}
#[test]
fn test_read_proof_file_errors() {
assert!(read_proof_file("/nonexistent/path/proof.hex")
.unwrap_err()
.contains("Failed to read"));
let temp_file = NamedTempFile::new().unwrap();
std::fs::write(temp_file.path(), "not valid hex!").unwrap();
assert!(read_proof_file(temp_file.path().to_str().unwrap())
.unwrap_err()
.contains("Failed to decode"));
}
#[test]
fn test_fee_calculation_edge_cases() {
let input_small: u32 = 100;
let output_small = compute_output_amount(input_small, VOLUME_FEE_BPS);
assert_eq!(output_small, 99);
assert!(
(output_small as u64) * 10000 <= (input_small as u64) * (10000 - VOLUME_FEE_BPS as u64)
);
let input_medium: u32 = 10000;
let output_medium = compute_output_amount(input_medium, VOLUME_FEE_BPS);
assert_eq!(output_medium, 9996);
assert!(
(output_medium as u64) * 10000 <=
(input_medium as u64) * (10000 - VOLUME_FEE_BPS as u64)
);
let input_large: u32 = u32::MAX / 2;
let output_large = compute_output_amount(input_large, VOLUME_FEE_BPS);
assert!(
(output_large as u64) * 10000 <= (input_large as u64) * (10000 - VOLUME_FEE_BPS as u64)
);
for fee_bps in [0u32, 1, 10, 50, 100, 500, 1000] {
let input: u32 = 100000;
let output = compute_output_amount(input, fee_bps);
assert!(
(output as u64) * 10000 <= (input as u64) * (10000 - fee_bps as u64),
"Fee constraint violated for fee_bps={}: {} * 10000 > {} * {}",
fee_bps,
output,
input,
10000 - fee_bps
);
}
}
#[test]
fn test_nullifier_determinism() {
use qp_wormhole_circuit::nullifier::Nullifier;
use qp_zk_circuits_common::utils::BytesDigest;
let secret: BytesDigest = [1u8; 32].try_into().expect("valid secret");
let transfer_count = 42u64;
let nullifier1 = Nullifier::from_preimage(secret, transfer_count);
let nullifier2 = Nullifier::from_preimage(secret, transfer_count);
let nullifier3 = Nullifier::from_preimage(secret, transfer_count);
assert_eq!(nullifier1.hash, nullifier2.hash);
assert_eq!(nullifier2.hash, nullifier3.hash);
let nullifier_different = Nullifier::from_preimage(secret, transfer_count + 1);
assert_ne!(nullifier1.hash, nullifier_different.hash);
let different_secret: BytesDigest = [2u8; 32].try_into().expect("valid secret");
let nullifier_different_secret = Nullifier::from_preimage(different_secret, transfer_count);
assert_ne!(nullifier1.hash, nullifier_different_secret.hash);
}
#[test]
fn test_unspendable_account_determinism() {
use qp_wormhole_circuit::unspendable_account::UnspendableAccount;
use qp_zk_circuits_common::utils::BytesDigest;
let secret: BytesDigest = [1u8; 32].try_into().expect("valid secret");
let account1 = UnspendableAccount::from_secret(secret);
let account2 = UnspendableAccount::from_secret(secret);
assert_eq!(account1.account_id, account2.account_id);
let different_secret: BytesDigest = [2u8; 32].try_into().expect("valid secret");
let account_different = UnspendableAccount::from_secret(different_secret);
assert_ne!(account1.account_id, account_different.account_id);
}
#[test]
fn test_public_inputs_structure() {
use qp_wormhole_inputs::{
ASSET_ID_INDEX, BLOCK_HASH_END_INDEX, BLOCK_HASH_START_INDEX, BLOCK_NUMBER_INDEX,
EXIT_ACCOUNT_1_END_INDEX, EXIT_ACCOUNT_1_START_INDEX, EXIT_ACCOUNT_2_END_INDEX,
EXIT_ACCOUNT_2_START_INDEX, INPUT_AMOUNT_INDEX, NULLIFIER_END_INDEX,
NULLIFIER_START_INDEX, OUTPUT_AMOUNT_1_INDEX, OUTPUT_AMOUNT_2_INDEX,
PUBLIC_INPUTS_FELTS_LEN, VOLUME_FEE_BPS_INDEX,
};
assert_eq!(PUBLIC_INPUTS_FELTS_LEN, 22, "Public inputs should be 22 field elements");
assert_eq!(ASSET_ID_INDEX, 0, "Asset ID should be first");
assert_eq!(OUTPUT_AMOUNT_1_INDEX, 1, "Output amount 1 should be at index 1");
assert_eq!(OUTPUT_AMOUNT_2_INDEX, 2, "Output amount 2 should be at index 2");
assert_eq!(VOLUME_FEE_BPS_INDEX, 3, "Volume fee BPS should be at index 3");
assert_eq!(NULLIFIER_START_INDEX, 4, "Nullifier should start at index 4");
assert_eq!(NULLIFIER_END_INDEX, 8, "Nullifier should end at index 8");
assert_eq!(EXIT_ACCOUNT_1_START_INDEX, 8, "Exit account 1 should start at index 8");
assert_eq!(EXIT_ACCOUNT_1_END_INDEX, 12, "Exit account 1 should end at index 12");
assert_eq!(EXIT_ACCOUNT_2_START_INDEX, 12, "Exit account 2 should start at index 12");
assert_eq!(EXIT_ACCOUNT_2_END_INDEX, 16, "Exit account 2 should end at index 16");
assert_eq!(BLOCK_HASH_START_INDEX, 16, "Block hash should start at index 16");
assert_eq!(BLOCK_HASH_END_INDEX, 20, "Block hash should end at index 20");
assert_eq!(BLOCK_NUMBER_INDEX, 20, "Block number should be at index 20");
assert_eq!(INPUT_AMOUNT_INDEX, 21, "Input amount should be at index 21");
}
#[test]
fn test_constants_match_chain_config() {
assert_eq!(VOLUME_FEE_BPS, 4, "Volume fee should be 4 bps");
assert_eq!(NATIVE_ASSET_ID, 0, "Native asset ID should be 0");
assert_eq!(SCALE_DOWN_FACTOR, 10_000_000_000, "Scale down factor should be 10^10");
let one_token_12_decimals: u128 = 1_000_000_000_000;
let quantized = quantize_funding_amount(one_token_12_decimals).unwrap();
assert_eq!(quantized, 100, "1 token should quantize to 100 (1.00 with 2 decimals)");
}
#[test]
fn test_volume_fee_bps_constant() {
assert_eq!(VOLUME_FEE_BPS, 4);
}
#[test]
fn test_aggregation_config_deserialization_matches_upstream_format() {
let json = r#"{
"num_leaf_proofs": 8,
"num_private_batch_proofs": null
}"#;
let config: CircuitBinsConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.num_leaf_proofs, 8);
assert_eq!(config.num_private_batch_proofs, None);
let legacy_json = r#"{
"num_leaf_proofs": 8,
"num_layer0_proofs": 4
}"#;
let legacy: CircuitBinsConfig = serde_json::from_str(legacy_json).unwrap();
assert_eq!(legacy.num_private_batch_proofs, Some(4));
}
fn mk_accounts(n: usize) -> Vec<[u8; 32]> {
(0..n)
.map(|i| {
let mut a = [0u8; 32];
a[0] = (i as u8).wrapping_add(1); a
})
.collect()
}
fn proof_outputs_for_inputs(input_amounts: &[u128], fee_bps: u32) -> Vec<u32> {
input_amounts
.iter()
.map(|&input| {
let input_quantized = quantize_funding_amount(input).unwrap_or(0);
compute_output_amount(input_quantized, fee_bps)
})
.collect()
}
fn total_output_for_inputs(input_amounts: &[u128], fee_bps: u32) -> u64 {
proof_outputs_for_inputs(input_amounts, fee_bps)
.into_iter()
.map(|x| x as u64)
.sum()
}
fn find_input_for_min_output(fee_bps: u32, min_out: u32) -> u128 {
let mut input: u128 = 1;
for _ in 0..80 {
let q = quantize_funding_amount(input).unwrap_or(0);
let out = compute_output_amount(q, fee_bps);
if out >= min_out {
return input;
}
input = input.saturating_mul(10);
}
panic!("Could not find input producing output >= {}", min_out);
}
#[test]
fn random_partition_n0() {
let parts = random_partition(100, 0, 1);
assert!(parts.is_empty());
}
#[test]
fn random_partition_n1() {
let total = 12345u128;
let parts = random_partition(total, 1, 9999);
assert_eq!(parts, vec![total]);
}
#[test]
fn random_partition_total_less_than_min_total_falls_back_to_equalish() {
let total = 5u128;
let n = 10usize;
let min_per_part = 1u128;
let parts = random_partition(total, n, min_per_part);
assert_eq!(parts.len(), n);
assert_eq!(parts.iter().sum::<u128>(), total);
for part in parts.iter().take(n - 1) {
assert_eq!(*part, 0);
}
assert_eq!(parts[n - 1], 5);
}
#[test]
fn random_partition_min_achievable_invariants_hold() {
let total = 100u128;
let n = 10usize;
let min_per_part = 3u128;
for _ in 0..200 {
let parts = random_partition(total, n, min_per_part);
assert_eq!(parts.len(), n);
assert_eq!(parts.iter().sum::<u128>(), total);
assert!(parts.iter().all(|&p| p >= min_per_part));
}
}
#[test]
fn random_partition_distributable_zero_all_min() {
let n = 10usize;
let min_per_part = 3u128;
let total = min_per_part * n as u128;
let parts = random_partition(total, n, min_per_part);
assert_eq!(parts.len(), n);
assert_eq!(parts.iter().sum::<u128>(), total);
assert!(parts.iter().all(|&p| p == min_per_part));
}
#[test]
fn compute_random_output_assignments_empty_inputs_or_targets() {
let targets = mk_accounts(3);
assert!(compute_random_output_assignments(&[], &targets, 0).unwrap().is_empty());
let inputs = vec![1u128, 2u128, 3u128];
assert!(compute_random_output_assignments(&inputs, &[], 0).unwrap().is_empty());
}
#[test]
fn compute_random_output_assignments_basic_invariants() {
let fee_bps = 0u32;
let input = find_input_for_min_output(fee_bps, 5);
let input_amounts = vec![input, input, input, input, input];
let targets = mk_accounts(4);
let assignments =
compute_random_output_assignments(&input_amounts, &targets, fee_bps).unwrap();
assert_eq!(assignments.len(), input_amounts.len());
let proof_outputs = proof_outputs_for_inputs(&input_amounts, fee_bps);
for (i, a) in assignments.iter().enumerate() {
let per_proof_sum = a.output_amount_1 as u64 + a.output_amount_2 as u64;
assert_eq!(per_proof_sum, proof_outputs[i] as u64);
if a.output_amount_1 > 0 {
assert!(targets.contains(&a.exit_account_1));
} else {
}
if a.output_amount_2 > 0 {
assert!(targets.contains(&a.exit_account_2));
assert_ne!(a.exit_account_2, a.exit_account_1); } else {
assert_eq!(a.exit_account_2, [0u8; 32]);
}
}
let total_assigned: u64 = assignments
.iter()
.map(|a| a.output_amount_1 as u64 + a.output_amount_2 as u64)
.sum();
let total_expected = total_output_for_inputs(&input_amounts, fee_bps);
assert_eq!(total_assigned, total_expected);
}
#[test]
fn compute_random_output_assignments_multiround_feedback_never_starves_a_target() {
let fee_bps = VOLUME_FEE_BPS;
let n = 7usize;
let mut inputs: Vec<u128> =
random_partition(100_000, n, 3).iter().map(|&q| q * SCALE_DOWN_FACTOR).collect();
for round in 0..200 {
let targets = mk_accounts(n);
let assignments =
compute_random_output_assignments(&inputs, &targets, fee_bps).unwrap();
let mut minted: HashMap<[u8; 32], u128> = HashMap::new();
for a in &assignments {
if a.output_amount_1 > 0 {
*minted.entry(a.exit_account_1).or_default() += a.output_amount_1 as u128;
}
if a.output_amount_2 > 0 {
*minted.entry(a.exit_account_2).or_default() += a.output_amount_2 as u128;
}
}
inputs = targets
.iter()
.map(|t| {
let m = minted.get(t).copied().unwrap_or(0);
assert!(m >= 3, "round {round}: address minted only {m} units");
m * SCALE_DOWN_FACTOR
})
.collect();
}
}
#[test]
fn compute_random_output_assignments_skewed_dust_proofs_fail_closed() {
let fee_bps = VOLUME_FEE_BPS;
let dust = 3 * SCALE_DOWN_FACTOR;
let rich = 100 * SCALE_DOWN_FACTOR;
let inputs = vec![dust, dust, dust, dust, rich];
let targets = mk_accounts(5);
let err = compute_random_output_assignments(&inputs, &targets, fee_bps)
.expect_err("four dust proofs plus one rich proof cannot fund every target");
assert!(err.contains("Unable to fund next-round address"), "unexpected error: {err}");
}
#[test]
fn compute_random_output_assignments_more_targets_than_capacity_still_conserves_funds() {
let fee_bps = 0u32;
let num_proofs = 1usize;
let num_targets = 5usize;
let input = find_input_for_min_output(fee_bps, 10); let input_amounts = vec![input; num_proofs];
let targets = mk_accounts(num_targets);
let assignments =
compute_random_output_assignments(&input_amounts, &targets, fee_bps).unwrap();
assert_eq!(assignments.len(), num_proofs);
let total_assigned: u64 = assignments
.iter()
.map(|a| a.output_amount_1 as u64 + a.output_amount_2 as u64)
.sum();
let total_expected = total_output_for_inputs(&input_amounts, fee_bps);
assert_eq!(total_assigned, total_expected);
let mut used = HashSet::new();
for a in &assignments {
if a.output_amount_1 > 0 {
used.insert(a.exit_account_1);
}
if a.output_amount_2 > 0 {
used.insert(a.exit_account_2);
}
}
assert!(used.len() <= 2 * num_proofs);
assert!(used.len() < num_targets);
}
#[test]
fn compute_random_output_assignments_total_output_less_than_num_targets_does_not_panic_and_conserves(
) {
let fee_bps = 0u32;
let num_targets = 50usize;
let targets = mk_accounts(num_targets);
let input = find_input_for_min_output(fee_bps, 1);
let input_amounts = vec![input, input];
let assignments =
compute_random_output_assignments(&input_amounts, &targets, fee_bps).unwrap();
assert_eq!(assignments.len(), input_amounts.len());
let total_assigned: u64 = assignments
.iter()
.map(|a| a.output_amount_1 as u64 + a.output_amount_2 as u64)
.sum();
let total_expected = total_output_for_inputs(&input_amounts, fee_bps);
assert_eq!(total_assigned, total_expected);
for a in &assignments {
if a.output_amount_1 > 0 {
assert!(targets.contains(&a.exit_account_1));
}
if a.output_amount_2 > 0 {
assert!(targets.contains(&a.exit_account_2));
assert_ne!(a.exit_account_2, a.exit_account_1);
}
}
}
#[derive(clap::Parser, Debug)]
struct CollectRewardsTestCli {
#[command(subcommand)]
cmd: WormholeCommands,
}
fn try_parse_collect_rewards(extra_args: &[&str]) -> Result<WormholeCommands, clap::Error> {
use clap::Parser;
let mut args = vec!["test", "collect-rewards"];
args.extend_from_slice(extra_args);
CollectRewardsTestCli::try_parse_from(args).map(|cli| cli.cmd)
}
#[test]
fn collect_rewards_requires_one_credential() {
let err = try_parse_collect_rewards(&[]).unwrap_err();
let s = err.to_string();
assert!(
s.contains("--wallet") || s.contains("--mnemonic-file") || s.contains("--secret-file"),
"expected missing-credential error, got: {s}"
);
}
#[test]
fn collect_rewards_accepts_each_credential_alone() {
assert!(try_parse_collect_rewards(&["--wallet", "w"]).is_ok());
assert!(try_parse_collect_rewards(&["--mnemonic-file", "mnemonic.txt"]).is_ok());
assert!(try_parse_collect_rewards(&["--secret-file", "secret.hex"]).is_ok());
}
#[test]
fn collect_rewards_credentials_mutually_exclusive() {
let pairs: &[(&str, &str, &str, &str)] = &[
("--wallet", "w", "--mnemonic-file", "m"),
("--wallet", "w", "--secret-file", "s"),
("--mnemonic-file", "m", "--secret-file", "s"),
];
for (a, av, b, bv) in pairs {
let err = try_parse_collect_rewards(&[a, av, b, bv]).unwrap_err().to_string();
assert!(
err.contains("cannot be used with"),
"expected conflict error for {a} + {b}, got: {err}"
);
}
}
#[test]
fn collect_rewards_rejects_mnemonic_cli_argument() {
let err = try_parse_collect_rewards(&["--mnemonic", "word ".repeat(24).trim()])
.unwrap_err()
.to_string();
assert!(
err.contains("unexpected argument") || err.contains("--mnemonic"),
"expected --mnemonic to be rejected, got: {err}"
);
}
#[test]
fn wormhole_rejects_secret_cli_argument() {
use clap::Parser;
#[derive(Parser, Debug)]
#[command(name = "quantus")]
struct TestCli {
#[command(subcommand)]
command: crate::cli::Commands,
}
let secret = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
for args in [
vec!["quantus", "wormhole", "address", "--secret", secret],
vec![
"quantus",
"wormhole",
"prove",
"--secret",
secret,
"--amount",
"1",
"--exit-account",
"0x1111111111111111111111111111111111111111111111111111111111111111",
"--block",
"0x2222222222222222222222222222222222222222222222222222222222222222",
"--transfer-count",
"0",
"--leaf-index",
"0",
"--funding-account",
"0x3333333333333333333333333333333333333333333333333333333333333333",
],
vec!["quantus", "wormhole", "collect-rewards", "--secret", secret],
vec![
"quantus",
"wormhole",
"check-nullifier",
"--secret",
secret,
"--transfer-counts",
"0",
],
] {
let result = TestCli::try_parse_from(args.clone());
assert!(result.is_err(), "wormhole must not accept --secret on argv; args={args:?}");
}
}
fn acct(seed: u8) -> SubxtAccountId {
SubxtAccountId([seed; 32])
}
#[test]
fn missing_extrinsic_in_proof_verification_block_is_error() {
let err = require_proof_verification_extrinsic_index(None)
.expect_err("missing extrinsic must error");
assert!(
err.to_string().contains("Could not find submitted extrinsic"),
"unexpected error: {err}"
);
assert_eq!(require_proof_verification_extrinsic_index(Some(2)).unwrap(), 2);
}
#[test]
fn proof_verified_after_extrinsic_failed_stays_unsuccessful() {
let mut result =
VerificationResult { success: false, exit_amount: None, error_message: None };
apply_extrinsic_failed_to_result(&mut result, "Wormhole::InvalidProof".to_string());
apply_proof_verified_to_result(&mut result, 42);
assert!(!result.success, "ExtrinsicFailed must dominate later ProofVerified");
assert_eq!(result.exit_amount, Some(42));
assert_eq!(result.error_message.as_deref(), Some("Wormhole::InvalidProof"));
}
#[test]
fn extrinsic_failed_after_proof_verified_clears_success() {
let mut result =
VerificationResult { success: false, exit_amount: None, error_message: None };
apply_proof_verified_to_result(&mut result, 99);
assert!(result.success);
apply_extrinsic_failed_to_result(&mut result, "dispatch failed".to_string());
assert!(!result.success, "later ExtrinsicFailed must clear success");
assert!(result.error_message.is_some());
}
#[test]
fn sdk_event_collection_errors_when_extrinsic_failed_even_if_proof_verified() {
let transfers = vec![wormhole::events::NativeTransferred {
from: acct(1),
to: acct(2),
amount: 10,
transfer_count: 1,
leaf_index: 1,
}];
let err = finalize_wormhole_event_collection(
true,
Some("Wormhole::InvalidProof".to_string()),
transfers,
)
.expect_err("SDK helpers must not treat failed extrinsics as verified");
assert!(err.to_string().contains("InvalidProof"));
}
#[test]
fn parse_expected_transfer_events_binds_by_from_and_amount_not_destination_alone() {
let shared_to = acct(0x42);
let attacker_from = acct(0xA1);
let intended_from = acct(0xB2);
let block_hash = subxt::utils::H256([0xCC; 32]);
let attacker_event = wormhole::events::NativeTransferred {
from: attacker_from.clone(),
to: shared_to.clone(),
amount: 111,
transfer_count: 7,
leaf_index: 70,
};
let intended_event = wormhole::events::NativeTransferred {
from: intended_from.clone(),
to: shared_to.clone(),
amount: 999_000,
transfer_count: 42,
leaf_index: 420,
};
let expected = [ExpectedTransferEvent {
wormhole_address: shared_to.clone(),
funding_account: Some(intended_from.clone()),
amount: Some(999_000),
transfer_count: Some(42),
leaf_index: None,
}];
let parsed = parse_expected_transfer_events(
&[
wormhole::events::NativeTransferred {
from: attacker_from.clone(),
to: shared_to.clone(),
amount: 111,
transfer_count: 7,
leaf_index: 70,
},
intended_event,
],
&expected,
block_hash,
)
.expect("expected attributes uniquely identify the intended transfer");
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].funding_account, intended_from);
assert_eq!(parsed[0].amount, 999_000);
assert_eq!(parsed[0].transfer_count, 42);
assert_eq!(parsed[0].leaf_index, 420);
assert_ne!(parsed[0].funding_account, attacker_from);
assert_ne!(parsed[0].amount, attacker_event.amount);
let ambiguous = parse_transfer_events(
&[
attacker_event,
wormhole::events::NativeTransferred {
from: intended_from,
to: shared_to.clone(),
amount: 999_000,
transfer_count: 42,
leaf_index: 420,
},
],
&[shared_to],
block_hash,
);
assert!(
ambiguous.is_err(),
"destination-only parse must not accept first-match among duplicate destinations"
);
assert!(
ambiguous.unwrap_err().to_string().contains("Ambiguous"),
"expected ambiguous-match error"
);
}
#[tokio::test]
#[serial_test::serial]
async fn load_multiround_wallet_errors_when_wallet_has_no_mnemonic() {
let home = tempfile::tempdir().unwrap();
std::env::set_var("HOME", home.path());
let wallet_manager = WalletManager::new().unwrap();
wallet_manager.create_developer_wallet("crystal_alice").await.unwrap();
let stored = wallet_manager.load_wallet("crystal_alice", "").unwrap();
assert!(
stored.mnemonic.is_none(),
"developer wallet must exercise the no-mnemonic secret path"
);
match load_multiround_wallet("crystal_alice", None, None) {
Ok(_) => {
panic!("wallet without mnemonic must error instead of generating an ephemeral one")
},
Err(err) => {
let msg = err.to_string();
assert!(
msg.contains("does not contain a mnemonic"),
"expected mnemonic-required error, got: {msg}"
);
},
}
}
}