From c6556e368daca4c5b6f2a27db55d3f1e4bd5f0c3 Mon Sep 17 00:00:00 2001 From: illuzen Date: Sun, 12 Apr 2026 17:34:40 +0800 Subject: [PATCH 01/11] new zk proof format --- Cargo.lock | 12 - Cargo.toml | 10 + src/chain/quantus_subxt.rs | 6 + src/cli/wormhole.rs | 1228 +++++++++------------------------ src/lib.rs | 7 +- src/subsquid/client.rs | 1 + src/subsquid/types.rs | 10 +- src/wormhole_lib.rs | 150 +--- tests/wormhole_integration.rs | 13 + 9 files changed, 410 insertions(+), 1027 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f08fe3d..38c8915 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3831,8 +3831,6 @@ dependencies = [ [[package]] name = "qp-wormhole-aggregator" version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c98df8c8a59dca57f896904abd1cd5cd5cb4387b4831e077cfc17aa8f448fffa" dependencies = [ "anyhow", "hex", @@ -3850,8 +3848,6 @@ dependencies = [ [[package]] name = "qp-wormhole-circuit" version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62c4c1671504baeefc3bd44ce62c69055b99595b2442eb5968b152ebccd33153" dependencies = [ "anyhow", "hex", @@ -3863,8 +3859,6 @@ dependencies = [ [[package]] name = "qp-wormhole-circuit-builder" version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2602cb861a190728337d28e660384ad3bb707768260c4e48db50fb60d8717908" dependencies = [ "anyhow", "clap", @@ -3877,8 +3871,6 @@ dependencies = [ [[package]] name = "qp-wormhole-inputs" version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aed8a9809079a89ea2a69cd5e3459d88e9a36b0a771d8821cacc50f7c66ef19d" dependencies = [ "anyhow", ] @@ -3886,8 +3878,6 @@ dependencies = [ [[package]] name = "qp-wormhole-prover" version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa4a78a88d3d851c6a828e2031c4422f0a44b17c53f8446656fd45dea146a5fd" dependencies = [ "anyhow", "qp-plonky2", @@ -3910,8 +3900,6 @@ dependencies = [ [[package]] name = "qp-zk-circuits-common" version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0756b4c23179e5e4a53cc9ac9ed2e0134124ec40e21f72ec12fe793bc6f6f278" dependencies = [ "anyhow", "hex", diff --git a/Cargo.toml b/Cargo.toml index 5468bdd..f6d20ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -105,3 +105,13 @@ opt-level = 3 [profile.release.build-override] opt-level = 3 + +# Patch crates.io dependencies with local development versions +# Remove this section before publishing or when using released versions +[patch.crates-io] +qp-wormhole-circuit = { path = "../qp-zk-circuits/wormhole/circuit" } +qp-wormhole-prover = { path = "../qp-zk-circuits/wormhole/prover" } +qp-wormhole-aggregator = { path = "../qp-zk-circuits/wormhole/aggregator" } +qp-wormhole-inputs = { path = "../qp-zk-circuits/wormhole/inputs" } +qp-zk-circuits-common = { path = "../qp-zk-circuits/common" } +qp-wormhole-circuit-builder = { path = "../qp-zk-circuits/wormhole/circuit-builder" } diff --git a/src/chain/quantus_subxt.rs b/src/chain/quantus_subxt.rs index dc41a34..bc15567 100644 --- a/src/chain/quantus_subxt.rs +++ b/src/chain/quantus_subxt.rs @@ -20109,6 +20109,8 @@ pub mod api { pub to: native_transferred::To, pub amount: native_transferred::Amount, pub transfer_count: native_transferred::TransferCount, + /// Index of this transfer in the ZK trie (for Merkle proof lookup) + pub leaf_index: native_transferred::LeafIndex, } pub mod native_transferred { use super::runtime_types; @@ -20116,6 +20118,7 @@ pub mod api { pub type To = ::subxt::ext::subxt_core::utils::AccountId32; pub type Amount = ::core::primitive::u128; pub type TransferCount = ::core::primitive::u64; + pub type LeafIndex = ::core::primitive::u64; } impl ::subxt::ext::subxt_core::events::StaticEvent for NativeTransferred { const PALLET: &'static str = "Wormhole"; @@ -20134,6 +20137,8 @@ pub mod api { pub to: asset_transferred::To, pub amount: asset_transferred::Amount, pub transfer_count: asset_transferred::TransferCount, + /// Index of this transfer in the ZK trie (for Merkle proof lookup) + pub leaf_index: asset_transferred::LeafIndex, } pub mod asset_transferred { use super::runtime_types; @@ -20142,6 +20147,7 @@ pub mod api { pub type To = ::subxt::ext::subxt_core::utils::AccountId32; pub type Amount = ::core::primitive::u128; pub type TransferCount = ::core::primitive::u64; + pub type LeafIndex = ::core::primitive::u64; } impl ::subxt::ext::subxt_core::events::StaticEvent for AssetTransferred { const PALLET: &'static str = "Wormhole"; diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index e1cb2b1..a1c41a6 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -22,28 +22,22 @@ use qp_wormhole_aggregator::{ aggregator::{AggregationBackend, CircuitType}, config::CircuitBinsConfig, }; -use qp_wormhole_circuit::{ - inputs::{CircuitInputs, ParseAggregatedPublicInputs, PrivateCircuitInputs}, - nullifier::Nullifier, -}; -use qp_wormhole_inputs::{AggregatedPublicCircuitInputs, PublicCircuitInputs}; -use qp_wormhole_prover::WormholeProver; +use qp_wormhole_circuit::inputs::ParseAggregatedPublicInputs; +use qp_wormhole_inputs::AggregatedPublicCircuitInputs; use qp_zk_circuits_common::{ circuit::{C, D, F}, - storage_proof::prepare_proof_for_circuit, - utils::{digest_to_bytes, BytesDigest}, + utils::BytesDigest, }; use rand::RngCore; use sp_core::crypto::{AccountId32, Ss58Codec}; use std::path::Path; use subxt::{ - backend::legacy::rpc_methods::ReadProof, blocks::Block, ext::{ codec::Encode, jsonrpsee::{core::client::ClientT, rpc_params}, }, - utils::{to_hex, AccountId32 as SubxtAccountId}, + utils::AccountId32 as SubxtAccountId, OnlineClient, }; @@ -53,6 +47,186 @@ pub use crate::wormhole_lib::{ compute_output_amount, NATIVE_ASSET_ID, SCALE_DOWN_FACTOR, VOLUME_FEE_BPS, }; +// ============================================================================ +// ZK Trie Types (for 4-ary Poseidon Merkle proofs) +// ============================================================================ + +/// A 32-byte hash output. +pub type Hash256 = [u8; 32]; + +/// Merkle proof from the ZK trie RPC. +/// +/// This is the client-side representation of the proof returned by `zkTrie_getMerkleProof`. +/// Siblings are unsorted - the client computes position hints by sorting siblings + current hash. +#[derive(Debug, Clone, serde::Deserialize)] +#[allow(dead_code)] // Fields used for deserialization and future use when ZK trie is deployed +pub struct ZkMerkleProofRpc { + /// Index of the leaf + pub leaf_index: u64, + /// The leaf data (SCALE-encoded ZkLeaf) + #[serde(with = "byte_array")] + pub leaf_data: Vec, + /// Leaf hash + #[serde(with = "hash_array")] + pub leaf_hash: Hash256, + /// Sibling hashes at each level (3 siblings per level for 4-ary tree). + /// These are unsorted - client sorts and computes positions. + #[serde(with = "siblings_format")] + pub siblings: Vec<[Hash256; 3]>, + /// Current tree root + #[serde(with = "hash_array")] + pub root: Hash256, + /// Current tree depth + pub depth: u8, +} + +/// Helper module for deserializing byte arrays (chain sends as array of numbers) +mod byte_array { + use serde::{Deserialize, Deserializer}; + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + Vec::::deserialize(deserializer) + } +} + +/// Helper module for deserializing 32-byte hashes (chain sends as array of numbers) +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 = Deserialize::deserialize(deserializer)?; + bytes.try_into().map_err(|v: Vec| { + serde::de::Error::custom(format!("expected 32 bytes, got {}", v.len())) + }) + } +} + +/// Helper module for deserializing siblings array (chain sends as array of arrays of numbers) +mod siblings_format { + use serde::{Deserialize, Deserializer}; + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + // Chain sends: Vec<[[u8; 32]; 3]> serialized as array of arrays of arrays of numbers + let levels: Vec>> = Deserialize::deserialize(deserializer)?; + 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| { + serde::de::Error::custom(format!("expected 32 bytes, got {}", v.len())) + })?; + } + Ok(siblings) + }) + .collect() + } +} + +/// Fetch a ZK Merkle proof from the chain via RPC. +/// +/// # Arguments +/// * `quantus_client` - The chain client +/// * `leaf_index` - The index of the leaf to get the proof for +/// +/// # Returns +/// The Merkle proof if the leaf exists, or an error +#[allow(dead_code)] // Will be used when ZK trie is deployed to production +pub async fn get_zk_merkle_proof( + quantus_client: &QuantusClient, + leaf_index: u64, +) -> crate::error::Result { + let proof_params = rpc_params![leaf_index]; + let proof: Option = quantus_client + .rpc_client() + .request("zkTrie_getMerkleProof", proof_params) + .await + .map_err(|e| crate::error::QuantusError::Generic(format!("RPC error: {}", e)))?; + + proof.ok_or_else(|| { + crate::error::QuantusError::Generic(format!( + "Leaf index {} not found in ZK trie", + leaf_index + )) + }) +} + +/// Compute sorted siblings and position hints from unsorted siblings. +/// +/// The chain returns unsorted siblings at each level. This function: +/// 1. Combines current hash with the 3 siblings +/// 2. Sorts all 4 hashes +/// 3. Finds the position (0-3) of the current hash in the sorted order +/// 4. Extracts the 3 sorted siblings (excluding current hash) +/// 5. Computes the parent hash for the next level +/// +/// # Arguments +/// * `unsorted_siblings` - Siblings at each level (3 per level), unsorted +/// * `leaf_hash` - The hash of the leaf +/// +/// # Returns +/// A tuple of (sorted_siblings, positions) ready for the circuit +pub fn compute_merkle_positions( + unsorted_siblings: &[[Hash256; 3]], + leaf_hash: Hash256, +) -> (Vec<[Hash256; 3]>, Vec) { + 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 { + // Combine current hash with the 3 siblings + let mut all_four: [Hash256; 4] = + [current_hash, level_siblings[0], level_siblings[1], level_siblings[2]]; + + // Sort to get the order used by hash_node + all_four.sort(); + + // Find position of current_hash in sorted order + let pos = all_four + .iter() + .position(|h| *h == current_hash) + .expect("current hash must be in the array") as u8; + positions.push(pos); + + // Extract the 3 siblings in sorted order (excluding current_hash) + 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); + + // Compute parent hash for next level using Poseidon + current_hash = hash_node_presorted(&all_four); + } + + (sorted_siblings, positions) +} + /// Parse a hex-encoded secret string into a 32-byte array pub fn parse_secret_hex(secret_hex: &str) -> Result<[u8; 32], String> { let secret_bytes = hex::decode(secret_hex.trim_start_matches("0x")) @@ -482,6 +656,10 @@ pub enum WormholeCommands { #[arg(long)] transfer_count: u64, + /// ZK trie leaf index from the transfer event (for Merkle proof lookup) + #[arg(long)] + leaf_index: u64, + /// Funding account (sender of transfer, hex or SS58) #[arg(long)] funding_account: String, @@ -625,6 +803,7 @@ pub async fn handle_wormhole_command( exit_account, block, transfer_count, + leaf_index, funding_account, output, } => { @@ -659,6 +838,7 @@ pub async fn handle_wormhole_command( &block, transfer_count, &funding_account, + leaf_index, &output, &quantus_client, ) @@ -725,22 +905,25 @@ pub async fn handle_wormhole_command( ) .await }, - WormholeCommands::Fuzz { wallet, password, password_file, amount } => { - let amount_planck = (amount * 1_000_000_000_000.0) as u128; - let amount_aligned = (amount_planck / SCALE_DOWN_FACTOR) * SCALE_DOWN_FACTOR; - run_fuzz_test(wallet, password, password_file, amount_aligned, node_url).await + WormholeCommands::Fuzz { wallet: _, password: _, password_file: _, amount: _ } => { + // TODO: Re-enable fuzz tests once ZK trie is deployed to a test chain. + // The fuzz tests need to be rewritten to use zkTrie_getMerkleProof RPC + // instead of the old state_getReadProof storage proofs. + // See run_fuzz_test() and try_generate_fuzz_proof() below for the old implementation. + Err(crate::error::QuantusError::Generic( + "Fuzz testing is temporarily disabled during the migration to ZK trie proofs. \ + The fuzz tests require a chain with pallet-zk-trie deployed and the \ + zkTrie_getMerkleProof RPC endpoint available." + .to_string(), + )) }, } } -/// Key for TransferProof storage - uniquely identifies a transfer. -/// Uses (to, transfer_count) since transfer_count is atomic per recipient. -/// This is hashed with Blake2_256 to form the storage key suffix. -pub type TransferProofKey = (AccountId32, u64); - -/// Full transfer data including amount - used to compute the leaf_inputs_hash via Poseidon2. -/// This is what the ZK circuit verifies. -pub type TransferProofData = (u32, u64, AccountId32, AccountId32, u128); +// NOTE: TransferProofKey and TransferProofData type aliases were removed during +// the migration to ZK trie. The new ZK leaf structure is: +// (to: AccountId32, transfer_count: u64, asset_id: u32, amount: u32) +// No longer includes `from` (funding_account). /// Derive and display the unspendable wormhole address from a secret. /// Users can then send funds to this address using `quantus send`. @@ -1124,6 +1307,8 @@ struct TransferInfo { wormhole_address: SubxtAccountId, /// The funding account (source of transfer) funding_account: SubxtAccountId, + /// Index of this transfer in the ZK trie (for Merkle proof lookup) + leaf_index: u64, } /// Derive a wormhole secret using HD derivation @@ -1186,6 +1371,7 @@ fn parse_transfer_events( amount: matching_event.amount, wormhole_address: expected_addr.clone(), funding_account: matching_event.from.clone(), + leaf_index: matching_event.leaf_index, }); } @@ -1393,23 +1579,53 @@ async fn execute_initial_transfers( .await .map_err(|e| crate::error::QuantusError::Generic(format!("Batch transfer failed: {}", e)))?; - // Get the block hash for the transfer info (any recent block works since we use storage) + // Get the block hash for the transfer info let block = at_best_block(quantus_client) .await .map_err(|e| crate::error::QuantusError::Generic(format!("Failed to get block: {}", e)))?; let block_hash = block.hash(); + // Fetch events from the block to get leaf_index values + let events_api = + quantus_client.client().events().at(block_hash).await.map_err(|e| { + crate::error::QuantusError::Generic(format!("Failed to get events: {}", e)) + })?; + // Build transfer info using the transfer counts we captured before the batch + // and leaf_index from events let funding_account: SubxtAccountId = SubxtAccountId(wallet.keypair.to_account_id_32().into()); let mut transfers = Vec::with_capacity(num_proofs); for (i, secret) in secrets.iter().enumerate() { + let wormhole_address = SubxtAccountId(secret.address); + + // Find the matching event to get leaf_index + let event = events_api + .find::() + .find(|e| { + if let Ok(evt) = e { + evt.to == wormhole_address && evt.transfer_count == transfer_counts_before[i] + } else { + false + } + }) + .ok_or_else(|| { + crate::error::QuantusError::Generic(format!( + "No transfer event found for address {}", + hex::encode(secret.address) + )) + })? + .map_err(|e| { + crate::error::QuantusError::Generic(format!("Event decode error: {}", e)) + })?; + transfers.push(TransferInfo { block_hash, transfer_count: transfer_counts_before[i], amount: partition_amounts[i], - wormhole_address: SubxtAccountId(secret.address), + wormhole_address, funding_account: funding_account.clone(), + leaf_index: event.leaf_index, }); } @@ -1507,6 +1723,7 @@ async fn generate_round_proofs( &format!("0x{}", hex::encode(proof_block_hash.0)), transfer.transfer_count, &funding_account_hex, + transfer.leaf_index, // ZK trie leaf index for Merkle proof lookup &proof_file, quantus_client, ) @@ -1870,18 +2087,17 @@ async fn run_multiround( /// `wormhole_lib::generate_proof` for the actual proof generation. async fn generate_proof( secret_hex: &str, - funding_amount: u128, + _funding_amount: u128, // No longer needed - input_amount comes from ZK leaf output_assignment: &ProofOutputAssignment, block_hash_str: &str, transfer_count: u64, - funding_account_str: &str, + _funding_account_str: &str, // No longer needed - no `from` field in ZK leaf + leaf_index: u64, // ZK trie leaf index for Merkle proof lookup output_file: &str, quantus_client: &QuantusClient, ) -> crate::error::Result<()> { // Parse inputs let secret = parse_secret_hex(secret_hex).map_err(crate::error::QuantusError::Generic)?; - let funding_account_bytes = - parse_exit_account(funding_account_str).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)))? @@ -1894,38 +2110,38 @@ async fn generate_proof( let wormhole_address = wormhole_lib::compute_wormhole_address(&secret) .map_err(|e| crate::error::QuantusError::Generic(e.message))?; - // Compute storage key using wormhole_lib - let storage_key = wormhole_lib::compute_storage_key(&wormhole_address, transfer_count); - // Fetch data from chain let block_hash = subxt::utils::H256::from(block_hash_bytes); let client = quantus_client.client(); - // Get block + // Get block header let blocks = client.blocks().at(block_hash).await.map_err(|e| { crate::error::QuantusError::Generic(format!("Failed to get block: {}", e)) })?; - // Verify storage key exists - let storage_api = client.storage().at(block_hash); - let val = storage_api - .fetch_raw(storage_key.clone()) - .await - .map_err(|e| crate::error::QuantusError::Generic(e.to_string()))?; - if val.is_none() { - return Err(crate::error::QuantusError::Generic( - "Storage key not found - transfer may not exist in this block".to_string(), - )); - } - - // Get storage proof from chain - let proof_params = rpc_params![vec![to_hex(&storage_key)], block_hash]; - let read_proof: ReadProof = quantus_client + // Fetch ZK Merkle proof from chain via RPC using the leaf_index + let proof_params = rpc_params![leaf_index]; + let zk_proof: Option = quantus_client .rpc_client() - .request("state_getReadProof", proof_params) + .request("zkTrie_getMerkleProof", proof_params) .await - .map_err(|e| crate::error::QuantusError::Generic(e.to_string()))?; + .map_err(|e| { + crate::error::QuantusError::Generic(format!("Failed to get ZK Merkle proof: {}", e)) + })?; + + let zk_proof = zk_proof.ok_or_else(|| { + crate::error::QuantusError::Generic(format!( + "No ZK Merkle proof found for leaf_index {}", + leaf_index + )) + })?; + + // Decode the input amount from the leaf data + // The leaf data is SCALE-encoded ZkLeaf: (to: AccountId, transfer_count: u64, asset_id: + // AssetId, amount: Balance) For now, we'll use the quantized amount that the circuit expects + // The chain stores the quantized amount directly in the ZK leaf + let input_amount = decode_input_amount_from_leaf(&zk_proof.leaf_data)?; // Extract header data let header = blocks.header(); @@ -1935,23 +2151,26 @@ async fn generate_proof( let digest = header.digest.encode(); let block_number = header.number; - // Convert proof nodes - let proof_nodes: Vec> = read_proof.proof.iter().map(|p| p.0.clone()).collect(); + // Compute sorted siblings and positions from unsorted siblings returned by chain + // The chain returns unsorted siblings; we sort them and compute position hints + let (sorted_siblings, positions) = + compute_merkle_positions(&zk_proof.siblings, zk_proof.leaf_hash); - // Build ProofGenerationInput using wormhole_lib types + // Build ProofGenerationInput using wormhole_lib types with ZK Merkle proof let input = wormhole_lib::ProofGenerationInput { secret, transfer_count, - funding_account: funding_account_bytes, wormhole_address, - funding_amount, + input_amount, block_hash: block_hash_bytes, block_number, parent_hash, state_root, extrinsics_root, digest, - proof_nodes, + zk_trie_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, @@ -1978,6 +2197,31 @@ async fn generate_proof( Ok(()) } +/// Decode the input amount from SCALE-encoded ZkLeaf data. +/// ZkLeaf structure: (to: AccountId32, transfer_count: u64, asset_id: u32, amount: u32) +fn decode_input_amount_from_leaf(leaf_data: &[u8]) -> crate::error::Result { + // ZkLeaf is: (AccountId32, u64, u32, u32) + // AccountId32 = 32 bytes + // u64 = 8 bytes + // u32 = 4 bytes (asset_id) + // u32 = 4 bytes (amount - quantized) + // Total = 48 bytes + + if leaf_data.len() < 48 { + return Err(crate::error::QuantusError::Generic(format!( + "Invalid leaf data length: expected at least 48 bytes, got {}", + leaf_data.len() + ))); + } + + // The amount is the last 4 bytes (u32, little-endian) + let amount_bytes: [u8; 4] = leaf_data[44..48].try_into().map_err(|_| { + crate::error::QuantusError::Generic("Failed to extract amount bytes".to_string()) + })?; + + Ok(u32::from_le_bytes(amount_bytes)) +} + /// Verify an aggregated proof and return the block hash, extrinsic hash, and transfer events async fn verify_aggregated_and_get_events( proof_file: &str, @@ -2302,6 +2546,8 @@ struct DissolveOutput { funding_account: SubxtAccountId, /// Block hash where the transfer was recorded (needed for storage proof) proof_block_hash: subxt::utils::H256, + /// ZK trie leaf index for Merkle proof lookup + leaf_index: u64, } /// Dissolve a large wormhole deposit into many small outputs for better privacy. @@ -2429,6 +2675,7 @@ async fn run_dissolve( transfer_count: event.transfer_count, funding_account: funding_account.clone(), proof_block_hash: block_hash, + leaf_index: event.leaf_index, }]; log_success!(" Funded 1 wormhole address with {}", format_balance(amount)); @@ -2507,6 +2754,7 @@ async fn run_dissolve( &format!("0x{}", hex::encode(batch_proof_block_hash.0)), input.transfer_count, &format!("0x{}", hex::encode(input.funding_account.0)), + input.leaf_index, // ZK trie leaf index for Merkle proof lookup &proof_file, &quantus_client, ) @@ -2561,6 +2809,7 @@ async fn run_dissolve( transfer_count: event.transfer_count, funding_account: event.from.clone(), proof_block_hash: verification_block, + leaf_index: event.leaf_index, }); } } @@ -2640,844 +2889,37 @@ fn aggregate_proofs_to_file(proof_files: &[String], output_file: &str) -> crate: Ok(()) } -/// Goldilocks field order (2^64 - 2^32 + 1) -const GOLDILOCKS_ORDER: u64 = 0xFFFFFFFF00000001; - -/// Fuzz inputs: (amount, from, to, transfer_count, secret) -type FuzzInputs = (u128, [u8; 32], [u8; 32], u64, [u8; 32]); - -/// Represents a fuzz test case -struct FuzzCase { - name: &'static str, - description: &'static str, - /// Whether this case should pass (true) or fail (false) - /// Cases within quantization threshold should pass - expect_pass: bool, -} - -/// Seeded random number generator for reproducible fuzz tests -struct SeededRng { - state: u64, -} - -impl SeededRng { - fn new(seed: u64) -> Self { - Self { state: seed } - } - - fn next_u64(&mut self) -> u64 { - // Simple xorshift64 PRNG - self.state ^= self.state << 13; - self.state ^= self.state >> 7; - self.state ^= self.state << 17; - self.state - } - - fn next_u128(&mut self) -> u128 { - let high = self.next_u64() as u128; - let low = self.next_u64() as u128; - (high << 64) | low - } -} - -/// Add a u64 value to the first 8-byte chunk of an address (little-endian) -fn add_to_address_chunk(addr: &mut [u8; 32], chunk_idx: usize, value: u64) { - let start = chunk_idx * 8; - let end = start + 8; - let current = u64::from_le_bytes(addr[start..end].try_into().unwrap()); - let new_value = current.wrapping_add(value); - addr[start..end].copy_from_slice(&new_value.to_le_bytes()); -} - -/// Generate all fuzz cases with their fuzzed inputs -#[allow(clippy::vec_init_then_push)] -fn generate_fuzz_cases( - amount: u128, - from: [u8; 32], - to: [u8; 32], - count: u64, - secret: [u8; 32], - rng: &mut SeededRng, -) -> Vec<(FuzzCase, FuzzInputs)> { - let random_u64 = rng.next_u64(); - let random_u128 = rng.next_u128(); - let mut random_addr = [0u8; 32]; - for chunk in random_addr.chunks_mut(8) { - chunk.copy_from_slice(&rng.next_u64().to_le_bytes()); - } - let mut random_secret = [0u8; 32]; - for chunk in random_secret.chunks_mut(8) { - chunk.copy_from_slice(&rng.next_u64().to_le_bytes()); - } - - let mut cases = Vec::new(); - - // ============================================================ - // AMOUNT FUZZING - // ============================================================ - - // Check if amount is at a quantization boundary - let amount_quantized = amount / SCALE_DOWN_FACTOR; - let amount_plus_one_quantized = (amount + 1) / SCALE_DOWN_FACTOR; - let amount_minus_one_quantized = amount.saturating_sub(1) / SCALE_DOWN_FACTOR; - - // Amount + 1 planck - passes only if it stays in the same quantization bucket - let plus_one_same_bucket = amount_quantized == amount_plus_one_quantized; - cases.push(( - FuzzCase { - name: "amount_plus_one_planck", - description: "Amount + 1 planck", - expect_pass: plus_one_same_bucket, - }, - (amount + 1, from, to, count, secret), - )); - - // Amount - 1 planck - passes only if it stays in the same quantization bucket - let minus_one_same_bucket = amount_quantized == amount_minus_one_quantized; - cases.push(( - FuzzCase { - name: "amount_minus_one_planck", - description: "Amount - 1 planck", - expect_pass: minus_one_same_bucket, - }, - (amount.saturating_sub(1), from, to, count, secret), - )); - - // Amount + SCALE_DOWN_FACTOR (one quantized unit, should FAIL) - cases.push(( - FuzzCase { - name: "amount_plus_one_quant_unit", - description: "Amount + 1 quantized unit", - expect_pass: false, - }, - (amount + SCALE_DOWN_FACTOR, from, to, count, secret), - )); - - // Amount - SCALE_DOWN_FACTOR (one quantized unit, should FAIL) - cases.push(( - FuzzCase { - name: "amount_minus_one_quant_unit", - description: "Amount - 1 quantized unit", - expect_pass: false, - }, - (amount.saturating_sub(SCALE_DOWN_FACTOR), from, to, count, secret), - )); - - // Amount * 2 (should FAIL) - cases.push(( - FuzzCase { name: "amount_doubled", description: "Amount * 2", expect_pass: false }, - (amount * 2, from, to, count, secret), - )); - - // Amount = 0 (should FAIL) - cases.push(( - FuzzCase { name: "amount_zero", description: "Amount = 0", expect_pass: false }, - (0, from, to, count, secret), - )); - - // Amount + random large value (should FAIL) - cases.push(( - FuzzCase { - name: "amount_plus_random", - description: "Amount + random u128", - expect_pass: false, - }, - (amount.wrapping_add(random_u128), from, to, count, secret), - )); - - // Amount + Goldilocks order (overflow attack, should FAIL) - cases.push(( - FuzzCase { - name: "amount_plus_goldilocks", - description: "Amount + Goldilocks field order", - expect_pass: false, - }, - (amount.wrapping_add(GOLDILOCKS_ORDER as u128), from, to, count, secret), - )); - - // ============================================================ - // TRANSFER COUNT FUZZING - // ============================================================ - - // Count + 1 (should FAIL) - cases.push(( - FuzzCase { name: "count_plus_one", description: "Transfer count + 1", expect_pass: false }, - (amount, from, to, count.wrapping_add(1), secret), - )); - - // Count - 1 with wrapping (0 -> u64::MAX, should FAIL) - cases.push(( - FuzzCase { - name: "count_minus_one_wrap", - description: "Transfer count - 1 (wrapping)", - expect_pass: false, - }, - (amount, from, to, count.wrapping_sub(1), secret), - )); - - // Count = u64::MAX (should FAIL) - cases.push(( - FuzzCase { - name: "count_max", - description: "Transfer count = u64::MAX", - expect_pass: false, - }, - (amount, from, to, u64::MAX, secret), - )); - - // Count + random (should FAIL) - cases.push(( - FuzzCase { - name: "count_plus_random", - description: "Transfer count + random u64", - expect_pass: false, - }, - (amount, from, to, count.wrapping_add(random_u64), secret), - )); - - // Count + Goldilocks order (overflow attack, should FAIL) - cases.push(( - FuzzCase { - name: "count_plus_goldilocks", - description: "Transfer count + Goldilocks field order", - expect_pass: false, - }, - (amount, from, to, count.wrapping_add(GOLDILOCKS_ORDER), secret), - )); - - // ============================================================ - // FROM ADDRESS FUZZING - // ============================================================ - - // From: single bit flip (should FAIL) - let mut from_bit_flip = from; - from_bit_flip[0] ^= 0x01; - cases.push(( - FuzzCase { - name: "from_single_bit_flip", - description: "From address single bit flip", - expect_pass: false, - }, - (amount, from_bit_flip, to, count, secret), - )); - - // From: zeroed (should FAIL) - cases.push(( - FuzzCase { name: "from_zeroed", description: "From address zeroed", expect_pass: false }, - (amount, [0u8; 32], to, count, secret), - )); - - // From: add 1 to first chunk (should FAIL) - let mut from_plus_one = from; - add_to_address_chunk(&mut from_plus_one, 0, 1); - cases.push(( - FuzzCase { - name: "from_plus_one", - description: "From address + 1 (first chunk)", - expect_pass: false, - }, - (amount, from_plus_one, to, count, secret), - )); - - // From: random address (should FAIL) - cases.push(( - FuzzCase { name: "from_random", description: "From address random", expect_pass: false }, - (amount, random_addr, to, count, secret), - )); - - // From: add Goldilocks order to each chunk (should FAIL) - for chunk_idx in 0..4 { - let mut from_goldilocks = from; - add_to_address_chunk(&mut from_goldilocks, chunk_idx, GOLDILOCKS_ORDER); - cases.push(( - FuzzCase { - name: match chunk_idx { - 0 => "from_goldilocks_chunk0", - 1 => "from_goldilocks_chunk1", - 2 => "from_goldilocks_chunk2", - _ => "from_goldilocks_chunk3", - }, - description: match chunk_idx { - 0 => "From + Goldilocks order (chunk 0)", - 1 => "From + Goldilocks order (chunk 1)", - 2 => "From + Goldilocks order (chunk 2)", - _ => "From + Goldilocks order (chunk 3)", - }, - expect_pass: false, - }, - (amount, from_goldilocks, to, count, secret), - )); - } - - // ============================================================ - // EXIT ACCOUNT FUZZING - // ============================================================ - // NOTE: The exit_account is a PUBLIC INPUT that specifies where funds should go - // after proof verification. It is intentionally NOT validated against the storage - // proof's leaf hash. The security comes from: - // 1. The nullifier prevents double-spending - // 2. Only someone with the `secret` can create a valid proof - // 3. The exit_account being public means verifiers see where funds go - // - // The `to_account` IN THE LEAF HASH is the unspendable_account (derived from secret), - // which IS validated via the circuit's connect_hashes constraint. - // - // These tests confirm that exit_account can be set to any value (as designed). - - // Exit account: single bit flip (SHOULD PASS - exit_account is not validated) - let mut exit_bit_flip = to; - exit_bit_flip[0] ^= 0x01; - cases.push(( - FuzzCase { - name: "exit_single_bit_flip", - description: "Exit account single bit flip (not validated)", - expect_pass: true, - }, - (amount, from, exit_bit_flip, count, secret), - )); - - // Exit account: zeroed (SHOULD PASS - exit_account is not validated) - cases.push(( - FuzzCase { - name: "exit_zeroed", - description: "Exit account zeroed (not validated)", - expect_pass: true, - }, - (amount, from, [0u8; 32], count, secret), - )); - - // Exit account: add 1 to first chunk (SHOULD PASS - exit_account is not validated) - let mut exit_plus_one = to; - add_to_address_chunk(&mut exit_plus_one, 0, 1); - cases.push(( - FuzzCase { - name: "exit_plus_one", - description: "Exit account + 1 (not validated)", - expect_pass: true, - }, - (amount, from, exit_plus_one, count, secret), - )); - - // Exit account: add Goldilocks order to each chunk (SHOULD PASS - not validated) - for chunk_idx in 0..4 { - let mut exit_goldilocks = to; - add_to_address_chunk(&mut exit_goldilocks, chunk_idx, GOLDILOCKS_ORDER); - cases.push(( - FuzzCase { - name: match chunk_idx { - 0 => "exit_goldilocks_chunk0", - 1 => "exit_goldilocks_chunk1", - 2 => "exit_goldilocks_chunk2", - _ => "exit_goldilocks_chunk3", - }, - description: match chunk_idx { - 0 => "Exit + Goldilocks (chunk 0, not validated)", - 1 => "Exit + Goldilocks (chunk 1, not validated)", - 2 => "Exit + Goldilocks (chunk 2, not validated)", - _ => "Exit + Goldilocks (chunk 3, not validated)", - }, - expect_pass: true, - }, - (amount, from, exit_goldilocks, count, secret), - )); - } - - // ============================================================ - // SWAPPED/COMBINED - // ============================================================ - - // Swapped from/to (should FAIL) - cases.push(( - FuzzCase { - name: "swapped_from_to", - description: "From and To addresses swapped", - expect_pass: false, - }, - (amount, to, from, count, secret), - )); - - // All inputs fuzzed with random offsets (should FAIL) - let mut all_fuzzed_from = from; - let mut all_fuzzed_to = to; - all_fuzzed_from[31] ^= 0xFF; - all_fuzzed_to[31] ^= 0xFF; - cases.push(( - FuzzCase { - name: "all_random_fuzzed", - description: "All inputs fuzzed with random values", - expect_pass: false, - }, - ( - amount.wrapping_add(random_u128), - all_fuzzed_from, - all_fuzzed_to, - count.wrapping_add(random_u64), - secret, - ), - )); - - // All inputs + Goldilocks order (should FAIL) - let mut all_gold_from = from; - let mut all_gold_to = to; - add_to_address_chunk(&mut all_gold_from, 0, GOLDILOCKS_ORDER); - add_to_address_chunk(&mut all_gold_to, 0, GOLDILOCKS_ORDER); - cases.push(( - FuzzCase { - name: "all_goldilocks_fuzzed", - description: "All inputs + Goldilocks order", - expect_pass: false, - }, - ( - amount.wrapping_add(GOLDILOCKS_ORDER as u128), - all_gold_from, - all_gold_to, - count.wrapping_add(GOLDILOCKS_ORDER), - secret, - ), - )); - - // ============================================================ - // SECRET FUZZING (tests unspendable_account validation) - // ============================================================ - // The secret is used to derive the unspendable_account, which IS validated - // against the storage proof's leaf hash via the circuit's connect_hashes constraint. - // A wrong secret will derive a wrong unspendable_account, causing proof failure. - - // Secret: single bit flip (should FAIL - wrong unspendable_account) - let mut secret_bit_flip = secret; - secret_bit_flip[0] ^= 0x01; - cases.push(( - FuzzCase { - name: "secret_single_bit_flip", - description: "Secret single bit flip (wrong unspendable_account)", - expect_pass: false, - }, - (amount, from, to, count, secret_bit_flip), - )); - - // Secret: zeroed (should FAIL) - cases.push(( - FuzzCase { name: "secret_zeroed", description: "Secret zeroed", expect_pass: false }, - (amount, from, to, count, [0u8; 32]), - )); - - // Secret: random (should FAIL) - cases.push(( - FuzzCase { name: "secret_random", description: "Secret random value", expect_pass: false }, - (amount, from, to, count, random_secret), - )); - - // Secret: add 1 to first byte (should FAIL) - let mut secret_plus_one = secret; - secret_plus_one[0] = secret_plus_one[0].wrapping_add(1); - cases.push(( - FuzzCase { - name: "secret_plus_one", - description: "Secret + 1 (first byte)", - expect_pass: false, - }, - (amount, from, to, count, secret_plus_one), - )); - - // Secret: add Goldilocks order to first chunk (overflow attack, should FAIL) - let mut secret_goldilocks = secret; - add_to_address_chunk(&mut secret_goldilocks, 0, GOLDILOCKS_ORDER); - cases.push(( - FuzzCase { - name: "secret_plus_goldilocks", - description: "Secret + Goldilocks field order (chunk 0)", - expect_pass: false, - }, - (amount, from, to, count, secret_goldilocks), - )); - - cases -} - -/// Run fuzz tests on the leaf verification circuit. -/// -/// This function: -/// 1. Executes a real transfer to a wormhole address -/// 2. Attempts to generate proofs with fuzzed inputs (wrong amount, wrong address, etc.) -/// 3. Verifies that proof preparation fails for each fuzzed case (except within-threshold cases) -/// 4. Reports which cases correctly passed/failed -async fn run_fuzz_test( - wallet_name: String, - password: Option, - password_file: Option, - amount: u128, - node_url: &str, -) -> crate::error::Result<()> { - use colored::Colorize; - - log_print!(""); - log_print!("=================================================="); - log_print!(" Wormhole Fuzz Test"); - log_print!("=================================================="); - log_print!(""); - - // Use block timestamp as seed for reproducibility within same block - let seed = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - log_print!(" Random seed: {}", seed); - - // Load wallet - let wallet = load_multiround_wallet(&wallet_name, password, password_file)?; - - // Connect to node - let quantus_client = QuantusClient::new(node_url) - .await - .map_err(|e| crate::error::QuantusError::Generic(format!("Failed to connect: {}", e)))?; - - // Step 1: Generate a secret and derive wormhole address - log_print!("{}", "Step 1: Creating test transfer...".bright_yellow()); - - let mut secret_bytes = [0u8; 32]; - rand::rng().fill_bytes(&mut secret_bytes); - let secret: BytesDigest = secret_bytes.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; - - let wormhole_address = SubxtAccountId(unspendable_account_bytes); - - // Execute transfer - 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(), - }; - - submit_transaction( - &quantus_client, - &quantum_keypair, - transfer_tx, - None, - ExecutionMode { finalized: false, wait_for_transaction: true }, - ) - .await - .map_err(|e| crate::error::QuantusError::Generic(format!("Transfer failed: {}", e)))?; - - // Get block and event - let block = at_best_block(&quantus_client) - .await - .map_err(|e| crate::error::QuantusError::Generic(format!("Failed to get block: {}", e)))?; - let block_hash = block.hash(); - 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 event = events_api - .find::() - .find(|e| if let Ok(evt) = e { evt.to.0 == unspendable_account_bytes } else { false }) - .ok_or_else(|| crate::error::QuantusError::Generic("No transfer event found".to_string()))? - .map_err(|e| crate::error::QuantusError::Generic(format!("Event decode error: {}", e)))?; - - let transfer_count = event.transfer_count; - let funding_account: [u8; 32] = wallet.keypair.to_account_id_32().into(); - - log_success!( - " Transfer complete: {} to wormhole, transfer_count={}", - format_balance(amount), - transfer_count - ); - log_print!(" Block: 0x{}", hex::encode(block_hash.0)); - - // Step 2: Test that correct inputs work (sanity check) - log_print!(""); - log_print!("{}", "Step 2: Verifying correct inputs work...".bright_yellow()); - - // Step 2: Get block header data needed for proof generation - log_print!("{}", "Step 2: Fetching block header data...".bright_yellow()); - - 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 header = blocks.header(); - - let state_root = BytesDigest::try_from(header.state_root.as_bytes()) - .map_err(|e| crate::error::QuantusError::Generic(e.to_string()))?; - let parent_hash = BytesDigest::try_from(header.parent_hash.as_bytes()) - .map_err(|e| crate::error::QuantusError::Generic(e.to_string()))?; - let extrinsics_root = BytesDigest::try_from(header.extrinsics_root.as_bytes()) - .map_err(|e| crate::error::QuantusError::Generic(e.to_string()))?; - let digest: [u8; 110] = - header.digest.encode().try_into().map_err(|_| { - crate::error::QuantusError::Generic("Failed to encode digest".to_string()) - })?; - let block_number = header.number; - let state_root_hex = hex::encode(header.state_root.0); - - // Build storage key for fetching proof - let pallet_hash = sp_core::twox_128(b"Wormhole"); - let storage_hash = sp_core::twox_128(b"TransferProof"); - let mut final_key = Vec::with_capacity(32 + 32); - final_key.extend_from_slice(&pallet_hash); - final_key.extend_from_slice(&storage_hash); - let key_tuple: TransferProofKey = (AccountId32::new(unspendable_account_bytes), transfer_count); - let encoded_key = key_tuple.encode(); - let key_hash = sp_core::blake2_256(&encoded_key); - final_key.extend_from_slice(&key_hash); - - // Fetch storage proof from RPC - let proof_params = rpc_params![vec![to_hex(&final_key)], block_hash]; - let read_proof: ReadProof = quantus_client - .rpc_client() - .request("state_getReadProof", proof_params) - .await - .map_err(|e| crate::error::QuantusError::Generic(e.to_string()))?; - - log_success!(" Block header and storage proof fetched"); - - // Prover bins directory - let bins_dir = Path::new("generated-bins"); - - // Step 3: Test that correct inputs work (sanity check with actual proof generation) - log_print!(""); - log_print!("{}", "Step 3: Verifying correct inputs generate valid proof...".bright_yellow()); - - let correct_result = try_generate_fuzz_proof( - bins_dir, - &read_proof, - &state_root_hex, - block_hash, - secret, - amount, - funding_account, - unspendable_account_bytes, - transfer_count, - state_root, - parent_hash, - extrinsics_root, - digest, - block_number, - ); - - match correct_result { - Ok(_) => log_success!(" Correct inputs: PASSED (ZK proof generated successfully)"), - Err(e) => { - return Err(crate::error::QuantusError::Generic(format!( - "FATAL: Correct inputs failed proof generation: {}", - e - ))); - }, - } - - // Step 4: Generate and run fuzz cases - log_print!(""); - log_print!("{}", "Step 4: Running fuzz cases (generating ZK proofs)...".bright_yellow()); - log_print!( - " NOTE: Each case attempts actual ZK proof generation - this tests circuit constraints" - ); - log_print!(""); - - let mut rng = SeededRng::new(seed); - let secret_bytes: [u8; 32] = secret.as_ref().try_into().expect("secret is 32 bytes"); - let fuzz_cases = generate_fuzz_cases( - amount, - funding_account, - unspendable_account_bytes, - transfer_count, - secret_bytes, - &mut rng, - ); - - log_print!(" Total fuzz cases: {}", fuzz_cases.len()); - log_print!(""); - - let mut passed = 0; - let mut failed = 0; - - for (case, (fuzzed_amount, fuzzed_from, fuzzed_to, fuzzed_count, fuzzed_secret)) in &fuzz_cases - { - // Convert fuzzed_secret bytes to BytesDigest - let fuzzed_secret_digest: BytesDigest = - (*fuzzed_secret).try_into().expect("fuzzed_secret is 32 bytes"); - - // Use catch_unwind to handle panics from field overflow validation - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - try_generate_fuzz_proof( - bins_dir, - &read_proof, - &state_root_hex, - block_hash, - fuzzed_secret_digest, - *fuzzed_amount, - *fuzzed_from, - *fuzzed_to, - *fuzzed_count, - state_root, - parent_hash, - extrinsics_root, - digest, - block_number, - ) - })); - - let succeeded = match &result { - Ok(Ok(_)) => true, - Ok(Err(_)) => false, - Err(_) => false, // Panic counts as failure - }; - - if succeeded == case.expect_pass { - // Correct behavior - log_print!(" {} {}: {}", "OK".bright_green(), case.name, case.description); - passed += 1; - } else { - // Wrong behavior - let problem = if case.expect_pass { - match result { - Ok(Err(e)) => format!("Expected to pass but failed: {}", e), - Err(_) => "Expected to pass but panicked".to_string(), - _ => "Unknown error".to_string(), - } - } else { - "Expected circuit to reject but proof succeeded!".to_string() - }; - log_print!( - " {} {}: {} - {}", - "FAIL".bright_red(), - case.name, - case.description, - problem.red() - ); - failed += 1; - } - } - - // Summary - log_print!(""); - log_print!("=================================================="); - log_print!(" Fuzz Test Results"); - log_print!("=================================================="); - log_print!(""); - log_print!(" Total cases: {}", fuzz_cases.len()); - log_print!(" Passed: {} (correct behavior)", format!("{}", passed).bright_green()); - - if failed > 0 { - log_print!(" Failed: {} (incorrect behavior)", format!("{}", failed).bright_red()); - log_print!(""); - return Err(crate::error::QuantusError::Generic(format!( - "Fuzz test failed: {} cases had incorrect behavior", - failed - ))); - } - - log_print!(""); - log_success!("All fuzz cases behaved correctly!"); - - Ok(()) -} - -/// Try to generate a ZK proof with the given (possibly fuzzed) inputs. -/// This actually runs the ZK prover to test that circuit constraints reject invalid inputs. -/// -/// Returns Ok(()) if proof generation succeeds, Err if it fails. -#[allow(clippy::too_many_arguments)] -fn try_generate_fuzz_proof( - bins_dir: &Path, - read_proof: &ReadProof, - state_root_hex: &str, - block_hash: subxt::utils::H256, - secret: BytesDigest, - fuzzed_amount: u128, - fuzzed_from: [u8; 32], - fuzzed_to: [u8; 32], - fuzzed_count: u64, - state_root: BytesDigest, - parent_hash: BytesDigest, - extrinsics_root: BytesDigest, - digest: [u8; 110], - block_number: u32, -) -> Result<(), String> { - use subxt::ext::codec::Encode; - - // Compute the fuzzed leaf_inputs_hash - let from_account = AccountId32::new(fuzzed_from); - let to_account = AccountId32::new(fuzzed_to); - let transfer_data_tuple = - (NATIVE_ASSET_ID, fuzzed_count, from_account.clone(), to_account.clone(), fuzzed_amount); - let encoded_data = transfer_data_tuple.encode(); - let fuzzed_leaf_hash = - qp_poseidon::PoseidonHasher::hash_storage::(&encoded_data); - - // Prepare storage proof (now just logs warning on mismatch, doesn't fail) - let processed_storage_proof = prepare_proof_for_circuit( - read_proof.proof.iter().map(|proof| proof.0.clone()).collect(), - state_root_hex.to_string(), - fuzzed_leaf_hash, - ) - .map_err(|e| e.to_string())?; - - // Quantize input amount (may panic for invalid amounts, caught by caller) - let input_amount_quantized: u32 = quantize_funding_amount(fuzzed_amount)?; - - // Compute output amount (single output for simplicity) - let output_amount = compute_output_amount(input_amount_quantized, VOLUME_FEE_BPS); - - // Generate unspendable account from secret - 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); - - // Build circuit inputs with fuzzed data - let inputs = CircuitInputs { - private: PrivateCircuitInputs { - secret, - transfer_count: fuzzed_count, - funding_account: BytesDigest::try_from(fuzzed_from.as_ref()) - .map_err(|e| e.to_string())?, - storage_proof: processed_storage_proof, - unspendable_account: unspendable_account_bytes_digest, - parent_hash, - state_root, - extrinsics_root, - digest, - input_amount: input_amount_quantized, - }, - public: PublicCircuitInputs { - output_amount_1: output_amount, - output_amount_2: 0, - volume_fee_bps: VOLUME_FEE_BPS, - nullifier: digest_to_bytes(Nullifier::from_preimage(secret, fuzzed_count).hash), - exit_account_1: BytesDigest::try_from(fuzzed_to.as_ref()).map_err(|e| e.to_string())?, - exit_account_2: BytesDigest::try_from([0u8; 32].as_ref()).map_err(|e| e.to_string())?, - block_hash: BytesDigest::try_from(block_hash.as_ref()).map_err(|e| e.to_string())?, - block_number, - asset_id: NATIVE_ASSET_ID, - }, - }; - - // Load prover (need fresh instance for each proof since commit() takes ownership) - let prover = - WormholeProver::new_from_files(&bins_dir.join("prover.bin"), &bins_dir.join("common.bin")) - .map_err(|e| format!("Failed to load prover: {}", e))?; - - // Try to generate the proof - this is where circuit constraints are checked - let prover_next = prover.commit(&inputs).map_err(|e| e.to_string())?; - let _proof: ProofWithPublicInputs<_, _, 2> = - prover_next.prove().map_err(|e| format!("Circuit rejected: {}", e))?; - - Ok(()) -} +// ============================================================================= +// FUZZ TEST FUNCTIONS - TEMPORARILY DISABLED +// ============================================================================= +// +// The fuzz tests below are temporarily disabled during the migration from MPT +// storage proofs to ZK trie Merkle proofs. To re-enable: +// +// 1. Deploy pallet-zk-trie to a test chain +// 2. Update run_fuzz_test() to use zkTrie_getMerkleProof RPC instead of state_getReadProof +// 3. Update try_generate_fuzz_proof() to use the new PrivateCircuitInputs fields: +// - zk_trie_root: [u8; 32] +// - zk_merkle_siblings: Vec<[[u8; 32]; 3]> +// - zk_merkle_positions: Vec +// 4. Note: The ZK leaf no longer contains `from` (funding_account) - it's now: (to: AccountId, +// transfer_count: u64, asset_id: u32, amount: u32) +// 5. Update generate_fuzz_cases() to remove from-address fuzzing since it's no longer in the leaf +// +// See qp-zk-circuits/wormhole/tests/src/prover/prover_tests.rs for examples of +// how to construct ZK Merkle proofs for testing. +// ============================================================================= + +// TODO: Re-enable fuzz tests once ZK trie is deployed +// The old implementation used: +// - state_getReadProof RPC to fetch MPT storage proofs +// - prepare_proof_for_circuit() to process proofs +// - PrivateCircuitInputs with funding_account and storage_proof fields +// +// The new implementation should: +// - Use zkTrie_getMerkleProof RPC +// - Directly use ZkMerkleProofRpc response (siblings, positions) +// - Use PrivateCircuitInputs with zk_trie_root, zk_merkle_siblings, zk_merkle_positions #[cfg(test)] mod tests { use super::*; diff --git a/src/lib.rs b/src/lib.rs index 0111942..81ff1d3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -41,10 +41,9 @@ pub use cli::multisig::{ // Re-export wormhole library functions for SDK usage pub use wormhole_lib::{ - compute_leaf_hash, compute_nullifier, compute_output_amount, compute_storage_key, - compute_wormhole_address, generate_proof as generate_wormhole_proof, quantize_amount, - ProofGenerationInput, ProofGenerationOutput, TransferProofData, TransferProofKey, - WormholeLibError, NATIVE_ASSET_ID, SCALE_DOWN_FACTOR, VOLUME_FEE_BPS, + compute_nullifier, compute_output_amount, compute_wormhole_address, + generate_proof as generate_wormhole_proof, quantize_amount, ProofGenerationInput, + ProofGenerationOutput, WormholeLibError, NATIVE_ASSET_ID, SCALE_DOWN_FACTOR, VOLUME_FEE_BPS, }; /// Library version diff --git a/src/subsquid/client.rs b/src/subsquid/client.rs index d0ef368..7785d99 100644 --- a/src/subsquid/client.rs +++ b/src/subsquid/client.rs @@ -74,6 +74,7 @@ impl SubsquidClient { fee fromHash toHash + leafIndex } totalCount } diff --git a/src/subsquid/types.rs b/src/subsquid/types.rs index 00aba1f..0105cea 100644 --- a/src/subsquid/types.rs +++ b/src/subsquid/types.rs @@ -38,6 +38,9 @@ pub struct Transfer { /// Blake3 hash of the recipient's raw address pub to_hash: String, + + /// Index in the ZK trie for Merkle proof generation + pub leaf_index: String, } /// Result from a prefix query. @@ -186,7 +189,8 @@ mod tests { "amount": "1000000000000", "fee": "1000000", "fromHash": "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", - "toHash": "5678efgh5678efgh5678efgh5678efgh5678efgh5678efgh5678efgh5678efgh" + "toHash": "5678efgh5678efgh5678efgh5678efgh5678efgh5678efgh5678efgh5678efgh", + "leafIndex": "42" }"#; let transfer: Transfer = serde_json::from_str(json).expect("should deserialize"); @@ -200,6 +204,7 @@ mod tests { assert_eq!(transfer.to_id, "qzBob456"); assert_eq!(transfer.amount, "1000000000000"); assert_eq!(transfer.fee, "1000000"); + assert_eq!(transfer.leaf_index, "42"); } #[test] @@ -215,7 +220,8 @@ mod tests { "amount": "1000000000000", "fee": "1000000", "fromHash": "abcd1234", - "toHash": "5678efgh" + "toHash": "5678efgh", + "leafIndex": "0" }"#; let transfer: Transfer = serde_json::from_str(json).expect("should deserialize"); diff --git a/src/wormhole_lib.rs b/src/wormhole_lib.rs index a292031..72bbda6 100644 --- a/src/wormhole_lib.rs +++ b/src/wormhole_lib.rs @@ -5,11 +5,10 @@ //! a chain client connection. //! //! These functions handle the core cryptographic operations: -//! - Computing leaf hashes for storage proof verification -//! - Computing storage keys +//! - Computing leaf hashes for ZK Merkle proof verification +//! - Computing wormhole addresses from secrets //! - Generating ZK proofs from raw inputs -use codec::Encode; use qp_wormhole_circuit::{ inputs::{CircuitInputs, PrivateCircuitInputs}, nullifier::Nullifier, @@ -17,10 +16,9 @@ use qp_wormhole_circuit::{ use qp_wormhole_inputs::PublicCircuitInputs; use qp_wormhole_prover::WormholeProver; use qp_zk_circuits_common::{ - storage_proof::prepare_proof_for_circuit, utils::{digest_to_bytes, BytesDigest}, + zk_merkle::SIBLINGS_PER_LEVEL, }; -use sp_core::crypto::AccountId32; use std::path::Path; /// Native asset id for QTU token @@ -32,14 +30,6 @@ pub const SCALE_DOWN_FACTOR: u128 = 10_000_000_000; /// Volume fee rate in basis points (10 bps = 0.1%) pub const VOLUME_FEE_BPS: u32 = 10; -/// Full transfer data type - used to compute the leaf_inputs_hash via Poseidon2. -/// Order: (asset_id, transfer_count, from, to, amount) -pub type TransferProofData = (u32, u64, AccountId32, AccountId32, u128); - -/// Storage key type - (wormhole_address, transfer_count) -/// This is hashed with Blake2_256 to form the storage key suffix. -pub type TransferProofKey = (AccountId32, u64); - /// Result type for wormhole library operations pub type Result = std::result::Result; @@ -71,26 +61,28 @@ pub struct ProofGenerationInput { pub secret: [u8; 32], /// Transfer count (atomic counter per recipient) pub transfer_count: u64, - /// Funding account (sender) as 32 bytes - pub funding_account: [u8; 32], /// Wormhole address (recipient/unspendable account) as 32 bytes pub wormhole_address: [u8; 32], - /// Funding amount in planck (12 decimals) - pub funding_amount: u128, + /// Input amount (quantized, 2 decimals) - from ZK leaf data + pub input_amount: u32, /// Block hash as 32 bytes pub block_hash: [u8; 32], /// Block number pub block_number: u32, /// Parent hash as 32 bytes pub parent_hash: [u8; 32], - /// State root as 32 bytes + /// State root as 32 bytes (still needed for block hash computation) pub state_root: [u8; 32], /// Extrinsics root as 32 bytes pub extrinsics_root: [u8; 32], /// SCALE-encoded digest (variable length, padded to 110 bytes internally) pub digest: Vec, - /// Storage proof nodes (each node is a Vec) - pub proof_nodes: Vec>, + /// ZK trie root (from block digest) + pub zk_trie_root: [u8; 32], + /// ZK Merkle proof siblings at each level (3 siblings per level, in sorted order) + pub zk_merkle_siblings: Vec<[[u8; 32]; SIBLINGS_PER_LEVEL]>, + /// Position hints (0-3) for each level + pub zk_merkle_positions: Vec, /// Exit account 1 as 32 bytes pub exit_account_1: [u8; 32], /// Exit account 2 as 32 bytes (use zeros for single output) @@ -115,67 +107,6 @@ pub struct ProofGenerationOutput { pub nullifier: [u8; 32], } -/// Compute the leaf hash (leaf_inputs_hash) for storage proof verification. -/// -/// This uses Poseidon2 hashing via `hash_storage` to match the chain's -/// PoseidonStorageHasher behavior. -/// -/// # Arguments -/// * `asset_id` - Asset ID (0 for native token) -/// * `transfer_count` - Atomic transfer counter -/// * `funding_account` - Sender account as 32 bytes -/// * `wormhole_address` - Recipient (unspendable) account as 32 bytes -/// * `amount` - Transfer amount in planck -/// -/// # Returns -/// 32-byte leaf hash -pub fn compute_leaf_hash( - asset_id: u32, - transfer_count: u64, - funding_account: &[u8; 32], - wormhole_address: &[u8; 32], - amount: u128, -) -> [u8; 32] { - // Use AccountId32 to match the chain's type exactly - let from_account = AccountId32::new(*funding_account); - let to_account = AccountId32::new(*wormhole_address); - - let transfer_data: TransferProofData = - (asset_id, transfer_count, from_account, to_account, amount); - let encoded_data = transfer_data.encode(); - - qp_poseidon::PoseidonHasher::hash_storage::(&encoded_data) -} - -/// Compute the storage key for a transfer proof. -/// -/// The storage key is: Twox128("Wormhole") || Twox128("TransferProof") || -/// Blake2_256(wormhole_address, transfer_count) -/// -/// # Arguments -/// * `wormhole_address` - The unspendable wormhole account as 32 bytes -/// * `transfer_count` - The atomic transfer counter -/// -/// # Returns -/// Full storage key as bytes -pub fn compute_storage_key(wormhole_address: &[u8; 32], transfer_count: u64) -> Vec { - let pallet_hash = sp_core::twox_128(b"Wormhole"); - let storage_hash = sp_core::twox_128(b"TransferProof"); - - let mut final_key = Vec::with_capacity(32 + 32); - final_key.extend_from_slice(&pallet_hash); - final_key.extend_from_slice(&storage_hash); - - // Hash the key tuple with Blake2_256 - let to_account = AccountId32::new(*wormhole_address); - let key_tuple: TransferProofKey = (to_account, transfer_count); - let encoded_key = key_tuple.encode(); - let key_hash = sp_core::blake2_256(&encoded_key); - final_key.extend_from_slice(&key_hash); - - final_key -} - /// Compute the unspendable wormhole account from a secret. /// /// # Arguments @@ -243,7 +174,7 @@ pub fn compute_output_amount(input_amount: u32, fee_bps: u32) -> u32 { /// It does not require a chain client - all data must be pre-fetched. /// /// # Arguments -/// * `input` - All input data for proof generation +/// * `input` - All input data for proof generation (including ZK Merkle proof) /// * `prover_bin_path` - Path to prover.bin /// * `common_bin_path` - Path to common.bin /// @@ -260,26 +191,6 @@ pub fn generate_proof( .try_into() .map_err(|e| WormholeLibError::from(format!("Invalid secret: {:?}", e)))?; - // Compute leaf hash for storage proof - let leaf_hash = compute_leaf_hash( - input.asset_id, - input.transfer_count, - &input.funding_account, - &input.wormhole_address, - input.funding_amount, - ); - - // Prepare storage proof - let processed_proof = prepare_proof_for_circuit( - input.proof_nodes.clone(), - hex::encode(input.state_root), - leaf_hash, - ) - .map_err(|e| WormholeLibError::from(format!("Storage proof preparation failed: {}", e)))?; - - // Quantize input amount - let input_amount_quantized = quantize_amount(input.funding_amount)?; - // Compute nullifier let nullifier = Nullifier::from_preimage(secret_digest, input.transfer_count); let nullifier_bytes = digest_to_bytes(nullifier.hash); @@ -289,22 +200,24 @@ pub fn generate_proof( qp_wormhole_circuit::unspendable_account::UnspendableAccount::from_secret(secret_digest); let unspendable_bytes = digest_to_bytes(unspendable.account_id); + // Verify the wormhole address matches what we computed from the secret + if *unspendable_bytes != input.wormhole_address { + return Err(WormholeLibError::from( + "Wormhole address doesn't match the computed unspendable account from secret" + .to_string(), + )); + } + // Prepare digest (padded to 110 bytes) const DIGEST_LOGS_SIZE: usize = 110; let mut digest_padded = [0u8; DIGEST_LOGS_SIZE]; let copy_len = input.digest.len().min(DIGEST_LOGS_SIZE); digest_padded[..copy_len].copy_from_slice(&input.digest[..copy_len]); - // Build circuit inputs + // Build circuit inputs with ZK Merkle proof let private = PrivateCircuitInputs { secret: secret_digest, transfer_count: input.transfer_count, - funding_account: input - .funding_account - .as_slice() - .try_into() - .map_err(|e| WormholeLibError::from(format!("Invalid funding account: {:?}", e)))?, - storage_proof: processed_proof, unspendable_account: unspendable_bytes, parent_hash: input .parent_hash @@ -322,7 +235,10 @@ pub fn generate_proof( .try_into() .map_err(|e| WormholeLibError::from(format!("Invalid extrinsics root: {:?}", e)))?, digest: digest_padded, - input_amount: input_amount_quantized, + input_amount: input.input_amount, + zk_trie_root: input.zk_trie_root, + zk_merkle_siblings: input.zk_merkle_siblings.clone(), + zk_merkle_positions: input.zk_merkle_positions.clone(), }; let public = PublicCircuitInputs { @@ -393,11 +309,13 @@ mod tests { } #[test] - fn test_storage_key_computation() { - // Just verify it doesn't panic and returns expected length - let wormhole_address = [0u8; 32]; - let key = compute_storage_key(&wormhole_address, 1); - // 16 (pallet) + 16 (storage) + 32 (blake2_256) = 64 bytes - assert_eq!(key.len(), 64); + fn test_compute_wormhole_address() { + // Just verify it doesn't panic and returns 32 bytes + let secret = [42u8; 32]; + let address = compute_wormhole_address(&secret).unwrap(); + assert_eq!(address.len(), 32); + // Should be deterministic + let address2 = compute_wormhole_address(&secret).unwrap(); + assert_eq!(address, address2); } } diff --git a/tests/wormhole_integration.rs b/tests/wormhole_integration.rs index ec0fef7..b7e89c1 100644 --- a/tests/wormhole_integration.rs +++ b/tests/wormhole_integration.rs @@ -1,5 +1,15 @@ //! Integration tests for wormhole proof verification on-chain //! +//! **TEMPORARILY DISABLED** - These tests use the old MPT storage proof workflow. +//! They need to be rewritten to use ZK trie Merkle proofs once pallet-zk-trie is deployed. +//! +//! To re-enable these tests: +//! 1. Deploy pallet-zk-trie to a test chain +//! 2. Update the tests to use zkTrie_getMerkleProof RPC +//! 3. Update PrivateCircuitInputs to use zk_trie_root, zk_merkle_siblings, zk_merkle_positions +//! 4. Remove the #![cfg(feature = "legacy_storage_proofs")] flag below +//! +//! Original description: //! These tests require a local Quantus node running at ws://127.0.0.1:9944 //! with funded developer accounts (crystal_alice, crystal_bob, crystal_charlie). //! @@ -14,6 +24,9 @@ //! Note: For aggregation, proofs must be from the same block or consecutive blocks //! with valid parent hash linkage. We use batch transfers to ensure same-block proofs. +// Disable this entire test file until ZK trie integration is complete +#![cfg(feature = "legacy_storage_proofs")] + use plonky2::plonk::{circuit_data::CircuitConfig, proof::ProofWithPublicInputs}; use qp_wormhole_aggregator::aggregator::{AggregationBackend, Layer0Aggregator}; use qp_wormhole_circuit::{ From 011adb88baf8f1fb5682f14bb68c7f53718c4a56 Mon Sep 17 00:00:00 2001 From: illuzen Date: Sun, 12 Apr 2026 22:06:30 +0800 Subject: [PATCH 02/11] fix wormhole multiround --- src/cli/wormhole.rs | 68 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 57 insertions(+), 11 deletions(-) diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index a1c41a6..4f756e6 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -191,7 +191,7 @@ pub fn compute_merkle_positions( 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 { + for level_siblings in unsorted_siblings.iter() { // Combine current hash with the 3 siblings let mut all_four: [Hash256; 4] = [current_hash, level_siblings[0], level_siblings[1], level_siblings[2]]; @@ -2198,28 +2198,74 @@ async fn generate_proof( } /// Decode the input amount from SCALE-encoded ZkLeaf data. -/// ZkLeaf structure: (to: AccountId32, transfer_count: u64, asset_id: u32, amount: u32) +/// ZkLeaf structure: (to: AccountId32, transfer_count: u64, asset_id: u32, amount: u128) +/// +/// The chain stores the RAW amount (in planck), but hash_leaf() quantizes it. +/// We need to return the QUANTIZED amount for the circuit. fn decode_input_amount_from_leaf(leaf_data: &[u8]) -> crate::error::Result { - // ZkLeaf is: (AccountId32, u64, u32, u32) + // ZkLeaf is: (AccountId32, u64, u32, u128) // AccountId32 = 32 bytes - // u64 = 8 bytes + // u64 = 8 bytes (transfer_count) // u32 = 4 bytes (asset_id) - // u32 = 4 bytes (amount - quantized) - // Total = 48 bytes + // u128 = 16 bytes (amount - RAW in planck) + // Total = 60 bytes - if leaf_data.len() < 48 { + if leaf_data.len() < 60 { return Err(crate::error::QuantusError::Generic(format!( - "Invalid leaf data length: expected at least 48 bytes, got {}", + "Invalid leaf data length: expected at least 60 bytes, got {}", leaf_data.len() ))); } - // The amount is the last 4 bytes (u32, little-endian) - let amount_bytes: [u8; 4] = leaf_data[44..48].try_into().map_err(|_| { + // The amount is bytes 44-60 (u128, little-endian) + let amount_bytes: [u8; 16] = leaf_data[44..60].try_into().map_err(|_| { crate::error::QuantusError::Generic("Failed to extract amount bytes".to_string()) })?; - Ok(u32::from_le_bytes(amount_bytes)) + let raw_amount = u128::from_le_bytes(amount_bytes); + + // Quantize: divide by 10^10 to get 2 decimal places (matches chain's hash_leaf) + const AMOUNT_SCALE_DOWN_FACTOR: u128 = 10_000_000_000; + let quantized = (raw_amount / AMOUNT_SCALE_DOWN_FACTOR) as u32; + + Ok(quantized) +} + +/// Decode all fields from SCALE-encoded ZkLeaf data. +/// Returns (to_account, transfer_count, asset_id, raw_amount_u128) +#[allow(dead_code)] +fn decode_full_leaf_data(leaf_data: &[u8]) -> crate::error::Result<([u8; 32], u64, u32, u128)> { + // ZkLeaf is: (AccountId32, u64, u32, u128) + // AccountId32 = 32 bytes + // u64 = 8 bytes (transfer_count) + // u32 = 4 bytes (asset_id) + // u128 = 16 bytes (amount - RAW in planck) + // Total = 60 bytes + + 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)) } /// Verify an aggregated proof and return the block hash, extrinsic hash, and transfer events From 1f9edda56e744b871f4416f434782abf7e864308 Mon Sep 17 00:00:00 2001 From: illuzen Date: Mon, 13 Apr 2026 11:48:06 +0800 Subject: [PATCH 03/11] its a tree not a trie --- src/cli/wormhole.rs | 54 ++++++++++++++++++----------------- src/wormhole_lib.rs | 6 ++-- tests/wormhole_integration.rs | 8 +++--- 3 files changed, 35 insertions(+), 33 deletions(-) diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index 4f756e6..4367057 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -48,15 +48,15 @@ pub use crate::wormhole_lib::{ }; // ============================================================================ -// ZK Trie Types (for 4-ary Poseidon Merkle proofs) +// ZK Tree Types (for 4-ary Poseidon Merkle proofs) // ============================================================================ /// A 32-byte hash output. pub type Hash256 = [u8; 32]; -/// Merkle proof from the ZK trie RPC. +/// Merkle proof from the ZK tree RPC. /// -/// This is the client-side representation of the proof returned by `zkTrie_getMerkleProof`. +/// This is the client-side representation of the proof returned by `zkTree_getMerkleProof`. /// Siblings are unsorted - the client computes position hints by sorting siblings + current hash. #[derive(Debug, Clone, serde::Deserialize)] #[allow(dead_code)] // Fields used for deserialization and future use when ZK trie is deployed @@ -154,13 +154,13 @@ pub async fn get_zk_merkle_proof( let proof_params = rpc_params![leaf_index]; let proof: Option = quantus_client .rpc_client() - .request("zkTrie_getMerkleProof", proof_params) + .request("zkTree_getMerkleProof", proof_params) .await .map_err(|e| crate::error::QuantusError::Generic(format!("RPC error: {}", e)))?; proof.ok_or_else(|| { crate::error::QuantusError::Generic(format!( - "Leaf index {} not found in ZK trie", + "Leaf index {} not found in ZK tree", leaf_index )) }) @@ -848,10 +848,12 @@ pub async fn handle_wormhole_command( Ok(()) }, WormholeCommands::Aggregate { proofs, output } => aggregate_proofs(proofs, output).await, - WormholeCommands::VerifyAggregated { proof } => - verify_aggregated_proof(proof, node_url).await, - WormholeCommands::ParseProof { proof, aggregated, verify } => - parse_proof_file(proof, aggregated, verify).await, + WormholeCommands::VerifyAggregated { proof } => { + verify_aggregated_proof(proof, node_url).await + }, + WormholeCommands::ParseProof { proof, aggregated, verify } => { + parse_proof_file(proof, aggregated, verify).await + }, WormholeCommands::Multiround { num_proofs, rounds, @@ -906,14 +908,14 @@ pub async fn handle_wormhole_command( .await }, WormholeCommands::Fuzz { wallet: _, password: _, password_file: _, amount: _ } => { - // TODO: Re-enable fuzz tests once ZK trie is deployed to a test chain. - // The fuzz tests need to be rewritten to use zkTrie_getMerkleProof RPC + // TODO: Re-enable fuzz tests once ZK tree is deployed to a test chain. + // The fuzz tests need to be rewritten to use zkTree_getMerkleProof RPC // instead of the old state_getReadProof storage proofs. // See run_fuzz_test() and try_generate_fuzz_proof() below for the old implementation. Err(crate::error::QuantusError::Generic( - "Fuzz testing is temporarily disabled during the migration to ZK trie proofs. \ - The fuzz tests require a chain with pallet-zk-trie deployed and the \ - zkTrie_getMerkleProof RPC endpoint available." + "Fuzz testing is temporarily disabled during the migration to ZK tree proofs. \ + The fuzz tests require a chain with pallet-zk-tree deployed and the \ + zkTree_getMerkleProof RPC endpoint available." .to_string(), )) }, @@ -921,7 +923,7 @@ pub async fn handle_wormhole_command( } // NOTE: TransferProofKey and TransferProofData type aliases were removed during -// the migration to ZK trie. The new ZK leaf structure is: +// the migration to ZK tree. The new ZK leaf structure is: // (to: AccountId32, transfer_count: u64, asset_id: u32, amount: u32) // No longer includes `from` (funding_account). @@ -2124,7 +2126,7 @@ async fn generate_proof( let proof_params = rpc_params![leaf_index]; let zk_proof: Option = quantus_client .rpc_client() - .request("zkTrie_getMerkleProof", proof_params) + .request("zkTree_getMerkleProof", proof_params) .await .map_err(|e| { crate::error::QuantusError::Generic(format!("Failed to get ZK Merkle proof: {}", e)) @@ -2168,7 +2170,7 @@ async fn generate_proof( state_root, extrinsics_root, digest, - zk_trie_root: zk_proof.root, + zk_tree_root: zk_proof.root, zk_merkle_siblings: sorted_siblings, zk_merkle_positions: positions, exit_account_1: output_assignment.exit_account_1, @@ -2940,12 +2942,12 @@ fn aggregate_proofs_to_file(proof_files: &[String], output_file: &str) -> crate: // ============================================================================= // // The fuzz tests below are temporarily disabled during the migration from MPT -// storage proofs to ZK trie Merkle proofs. To re-enable: +// storage proofs to ZK tree Merkle proofs. To re-enable: // -// 1. Deploy pallet-zk-trie to a test chain -// 2. Update run_fuzz_test() to use zkTrie_getMerkleProof RPC instead of state_getReadProof +// 1. Deploy pallet-zk-tree to a test chain +// 2. Update run_fuzz_test() to use zkTree_getMerkleProof RPC instead of state_getReadProof // 3. Update try_generate_fuzz_proof() to use the new PrivateCircuitInputs fields: -// - zk_trie_root: [u8; 32] +// - zk_tree_root: [u8; 32] // - zk_merkle_siblings: Vec<[[u8; 32]; 3]> // - zk_merkle_positions: Vec // 4. Note: The ZK leaf no longer contains `from` (funding_account) - it's now: (to: AccountId, @@ -2956,16 +2958,16 @@ fn aggregate_proofs_to_file(proof_files: &[String], output_file: &str) -> crate: // how to construct ZK Merkle proofs for testing. // ============================================================================= -// TODO: Re-enable fuzz tests once ZK trie is deployed +// TODO: Re-enable fuzz tests once ZK tree is deployed // The old implementation used: // - state_getReadProof RPC to fetch MPT storage proofs // - prepare_proof_for_circuit() to process proofs // - PrivateCircuitInputs with funding_account and storage_proof fields // // The new implementation should: -// - Use zkTrie_getMerkleProof RPC +// - Use zkTree_getMerkleProof RPC // - Directly use ZkMerkleProofRpc response (siblings, positions) -// - Use PrivateCircuitInputs with zk_trie_root, zk_merkle_siblings, zk_merkle_positions +// - Use PrivateCircuitInputs with zk_tree_root, zk_merkle_siblings, zk_merkle_positions #[cfg(test)] mod tests { use super::*; @@ -3081,8 +3083,8 @@ mod tests { let output_medium = compute_output_amount(input_medium, VOLUME_FEE_BPS); assert_eq!(output_medium, 9990); assert!( - (output_medium as u64) * 10000 <= - (input_medium as u64) * (10000 - VOLUME_FEE_BPS as u64) + (output_medium as u64) * 10000 + <= (input_medium as u64) * (10000 - VOLUME_FEE_BPS as u64) ); // Large amounts near u32::MAX diff --git a/src/wormhole_lib.rs b/src/wormhole_lib.rs index 72bbda6..27e4d33 100644 --- a/src/wormhole_lib.rs +++ b/src/wormhole_lib.rs @@ -77,8 +77,8 @@ pub struct ProofGenerationInput { pub extrinsics_root: [u8; 32], /// SCALE-encoded digest (variable length, padded to 110 bytes internally) pub digest: Vec, - /// ZK trie root (from block digest) - pub zk_trie_root: [u8; 32], + /// ZK tree root (from block header's zk_tree_root field) + pub zk_tree_root: [u8; 32], /// ZK Merkle proof siblings at each level (3 siblings per level, in sorted order) pub zk_merkle_siblings: Vec<[[u8; 32]; SIBLINGS_PER_LEVEL]>, /// Position hints (0-3) for each level @@ -236,7 +236,7 @@ pub fn generate_proof( .map_err(|e| WormholeLibError::from(format!("Invalid extrinsics root: {:?}", e)))?, digest: digest_padded, input_amount: input.input_amount, - zk_trie_root: input.zk_trie_root, + zk_tree_root: input.zk_tree_root, zk_merkle_siblings: input.zk_merkle_siblings.clone(), zk_merkle_positions: input.zk_merkle_positions.clone(), }; diff --git a/tests/wormhole_integration.rs b/tests/wormhole_integration.rs index b7e89c1..e3cd584 100644 --- a/tests/wormhole_integration.rs +++ b/tests/wormhole_integration.rs @@ -1,12 +1,12 @@ //! Integration tests for wormhole proof verification on-chain //! //! **TEMPORARILY DISABLED** - These tests use the old MPT storage proof workflow. -//! They need to be rewritten to use ZK trie Merkle proofs once pallet-zk-trie is deployed. +//! They need to be rewritten to use ZK tree Merkle proofs once pallet-zk-tree is deployed. //! //! To re-enable these tests: -//! 1. Deploy pallet-zk-trie to a test chain -//! 2. Update the tests to use zkTrie_getMerkleProof RPC -//! 3. Update PrivateCircuitInputs to use zk_trie_root, zk_merkle_siblings, zk_merkle_positions +//! 1. Deploy pallet-zk-tree to a test chain +//! 2. Update the tests to use zkTree_getMerkleProof RPC +//! 3. Update PrivateCircuitInputs to use zk_tree_root, zk_merkle_siblings, zk_merkle_positions //! 4. Remove the #![cfg(feature = "legacy_storage_proofs")] flag below //! //! Original description: From 147ef7f396dad0fd75b010db36c0f84ce6333d79 Mon Sep 17 00:00:00 2001 From: illuzen Date: Mon, 13 Apr 2026 12:23:01 +0800 Subject: [PATCH 04/11] same block for merkle proofs and block header --- src/cli/wormhole.rs | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index 4367057..1de6682 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -138,30 +138,42 @@ mod siblings_format { } } -/// Fetch a ZK Merkle proof from the chain via RPC. +/// Fetch a ZK Merkle proof from the chain via RPC at a specific block. /// /// # Arguments /// * `quantus_client` - The chain client /// * `leaf_index` - The index of the leaf to get the proof for +/// * `at_block` - The block hash to fetch the proof at (MUST match the block you're proving against) /// /// # Returns /// The Merkle proof if the leaf exists, or an error -#[allow(dead_code)] // Will be used when ZK trie is deployed to production +/// +/// # Important +/// The `at_block` parameter is critical for ZK proof generation. The tree root changes +/// with each block, so the Merkle proof MUST be fetched at the same block whose header +/// you're including in the ZK proof. +#[allow(dead_code)] // Will be used when ZK tree is deployed to production pub async fn get_zk_merkle_proof( quantus_client: &QuantusClient, leaf_index: u64, + at_block: subxt::utils::H256, ) -> crate::error::Result { - let proof_params = rpc_params![leaf_index]; + let proof_params = rpc_params![leaf_index, at_block]; let proof: Option = quantus_client .rpc_client() .request("zkTree_getMerkleProof", proof_params) .await - .map_err(|e| crate::error::QuantusError::Generic(format!("RPC error: {}", e)))?; + .map_err(|e| { + crate::error::QuantusError::Generic(format!( + "RPC error fetching proof at block {:?}: {}", + at_block, e + )) + })?; proof.ok_or_else(|| { crate::error::QuantusError::Generic(format!( - "Leaf index {} not found in ZK tree", - leaf_index + "Leaf index {} not found in ZK tree at block {:?}", + leaf_index, at_block )) }) } @@ -2123,13 +2135,18 @@ async fn generate_proof( })?; // Fetch ZK Merkle proof from chain via RPC using the leaf_index - let proof_params = rpc_params![leaf_index]; + // CRITICAL: We MUST fetch the proof at the same block we're proving against. + // The tree root changes with each block, so proof must match header.zk_tree_root. + let proof_params = rpc_params![leaf_index, block_hash]; let zk_proof: Option = quantus_client .rpc_client() .request("zkTree_getMerkleProof", proof_params) .await .map_err(|e| { - crate::error::QuantusError::Generic(format!("Failed to get ZK Merkle proof: {}", e)) + crate::error::QuantusError::Generic(format!( + "Failed to get ZK Merkle proof at block {:?}: {}", + block_hash, e + )) })?; let zk_proof = zk_proof.ok_or_else(|| { From 303fa5a413b20ed4dfe3c950a037e691e04e1d85 Mon Sep 17 00:00:00 2001 From: illuzen Date: Mon, 13 Apr 2026 12:27:36 +0800 Subject: [PATCH 05/11] fmt --- src/cli/wormhole.rs | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index 1de6682..adb6bf2 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -143,7 +143,8 @@ mod siblings_format { /// # Arguments /// * `quantus_client` - The chain client /// * `leaf_index` - The index of the leaf to get the proof for -/// * `at_block` - The block hash to fetch the proof at (MUST match the block you're proving against) +/// * `at_block` - The block hash to fetch the proof at (MUST match the block you're proving +/// against) /// /// # Returns /// The Merkle proof if the leaf exists, or an error @@ -860,12 +861,10 @@ pub async fn handle_wormhole_command( Ok(()) }, WormholeCommands::Aggregate { proofs, output } => aggregate_proofs(proofs, output).await, - WormholeCommands::VerifyAggregated { proof } => { - verify_aggregated_proof(proof, node_url).await - }, - WormholeCommands::ParseProof { proof, aggregated, verify } => { - parse_proof_file(proof, aggregated, verify).await - }, + WormholeCommands::VerifyAggregated { proof } => + verify_aggregated_proof(proof, node_url).await, + WormholeCommands::ParseProof { proof, aggregated, verify } => + parse_proof_file(proof, aggregated, verify).await, WormholeCommands::Multiround { num_proofs, rounds, @@ -3100,8 +3099,8 @@ mod tests { let output_medium = compute_output_amount(input_medium, VOLUME_FEE_BPS); assert_eq!(output_medium, 9990); assert!( - (output_medium as u64) * 10000 - <= (input_medium as u64) * (10000 - VOLUME_FEE_BPS as u64) + (output_medium as u64) * 10000 <= + (input_medium as u64) * (10000 - VOLUME_FEE_BPS as u64) ); // Large amounts near u32::MAX From bf2743f9eb864c8c091a4e32b6d4fe57eab90e7f Mon Sep 17 00:00:00 2001 From: illuzen Date: Mon, 13 Apr 2026 14:09:19 +0800 Subject: [PATCH 06/11] bump zk-circuits --- Cargo.toml | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index f6d20ca..dbf8c95 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,19 +78,19 @@ subxt-metadata = "0.44" anyhow = "1.0" qp-plonky2 = { version = "1.4.1", default-features = false, features = ["rand", "std"] } -qp-wormhole-circuit = { version = "1.4.2", default-features = false, features = ["std"] } -qp-wormhole-prover = { version = "1.4.2", default-features = false, features = ["std"] } -qp-wormhole-verifier = { version = "1.4.2", default-features = false, features = ["std"] } -qp-wormhole-aggregator = { version = "1.4.2", default-features = false, features = ["rayon", "std"] } -qp-wormhole-inputs = { version = "1.4.2", default-features = false, features = ["std"] } -qp-zk-circuits-common = { version = "1.4.2", default-features = false, features = ["std"] } -qp-wormhole-circuit-builder = { version = "1.4.2" } +qp-wormhole-circuit = { version = "2.0.0", default-features = false, features = ["std"] } +qp-wormhole-prover = { version = "2.0.0", default-features = false, features = ["std"] } +qp-wormhole-verifier = { version = "2.0.0", default-features = false, features = ["std"] } +qp-wormhole-aggregator = { version = "2.0.0", default-features = false, features = ["rayon", "std"] } +qp-wormhole-inputs = { version = "2.0.0", default-features = false, features = ["std"] } +qp-zk-circuits-common = { version = "2.0.0", default-features = false, features = ["std"] } +qp-wormhole-circuit-builder = { version = "2.0.0" } [build-dependencies] hex = "0.4" qp-poseidon-core = "1.4.0" -qp-wormhole-circuit-builder = { version = "1.4.2" } +qp-wormhole-circuit-builder = { version = "2.0.0" } [dev-dependencies] tempfile = "3.8.1" @@ -106,12 +106,3 @@ opt-level = 3 [profile.release.build-override] opt-level = 3 -# Patch crates.io dependencies with local development versions -# Remove this section before publishing or when using released versions -[patch.crates-io] -qp-wormhole-circuit = { path = "../qp-zk-circuits/wormhole/circuit" } -qp-wormhole-prover = { path = "../qp-zk-circuits/wormhole/prover" } -qp-wormhole-aggregator = { path = "../qp-zk-circuits/wormhole/aggregator" } -qp-wormhole-inputs = { path = "../qp-zk-circuits/wormhole/inputs" } -qp-zk-circuits-common = { path = "../qp-zk-circuits/common" } -qp-wormhole-circuit-builder = { path = "../qp-zk-circuits/wormhole/circuit-builder" } From e5289344dfdad277e82ef0f7fc78fd750a629d31 Mon Sep 17 00:00:00 2001 From: illuzen Date: Mon, 13 Apr 2026 14:37:16 +0800 Subject: [PATCH 07/11] taplo... --- Cargo.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index dbf8c95..c043f49 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -105,4 +105,3 @@ opt-level = 3 [profile.release.build-override] opt-level = 3 - From 22c0ce812a1f67dcde89843d3b66e0d83fbfd7f7 Mon Sep 17 00:00:00 2001 From: illuzen Date: Mon, 13 Apr 2026 14:47:51 +0800 Subject: [PATCH 08/11] fmt --- src/cli/multisig.rs | 5 ++++- src/cli/referenda.rs | 7 ++++--- src/cli/tech_collective.rs | 11 ++++++++--- src/cli/tech_referenda.rs | 10 ++-------- 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/cli/multisig.rs b/src/cli/multisig.rs index 9bb506d..167daaa 100644 --- a/src/cli/multisig.rs +++ b/src/cli/multisig.rs @@ -1458,7 +1458,10 @@ fn log_proposal_result(multisig_ss58: &str, proposal_id: Option) { ); } else { log_success!("✅ Proposal confirmed on-chain"); - log_print!(" Run `quantus multisig list-proposals --address {}` to find the proposal ID", multisig_ss58); + log_print!( + " Run `quantus multisig list-proposals --address {}` to find the proposal ID", + multisig_ss58 + ); } } diff --git a/src/cli/referenda.rs b/src/cli/referenda.rs index 9320a2f..5b67815 100644 --- a/src/cli/referenda.rs +++ b/src/cli/referenda.rs @@ -1,9 +1,10 @@ //! `quantus referenda` subcommand - manage standard Referenda proposals use crate::{ - chain::quantus_subxt, cli::common::submit_transaction, error::QuantusError, log_error, - log_print, log_success, log_verbose, + chain::quantus_subxt, + cli::{common::submit_transaction, tech_collective::VoteChoice}, + error::QuantusError, + log_error, log_print, log_success, log_verbose, }; -use crate::cli::tech_collective::VoteChoice; use clap::Subcommand; use colored::Colorize; use std::str::FromStr; diff --git a/src/cli/tech_collective.rs b/src/cli/tech_collective.rs index 6bf1e7f..5db10da 100644 --- a/src/cli/tech_collective.rs +++ b/src/cli/tech_collective.rs @@ -197,9 +197,14 @@ pub async fn vote_on_referendum( let wait_mode = crate::cli::common::ExecutionMode { wait_for_transaction: true, ..execution_mode }; - let tx_hash = - crate::cli::common::submit_transaction(quantus_client, from_keypair, vote_call, None, wait_mode) - .await?; + let tx_hash = crate::cli::common::submit_transaction( + quantus_client, + from_keypair, + vote_call, + None, + wait_mode, + ) + .await?; log_verbose!("📋 Vote transaction confirmed: {:?}", tx_hash); diff --git a/src/cli/tech_referenda.rs b/src/cli/tech_referenda.rs index 030a832..d3e172b 100644 --- a/src/cli/tech_referenda.rs +++ b/src/cli/tech_referenda.rs @@ -412,10 +412,7 @@ async fn submit_runtime_upgrade_with_preimage( let tx_hash = submit_transaction(quantus_client, &keypair, submit_call, None, execution_mode).await?; - log_success!( - "Runtime upgrade proposal submitted! Hash: {:?}", - tx_hash - ); + log_success!("Runtime upgrade proposal submitted! Hash: {:?}", tx_hash); log_print!("💡 Use 'quantus tech-referenda list' to see active proposals"); Ok(()) @@ -493,10 +490,7 @@ async fn submit_treasury_portion_with_preimage( let tx_hash = submit_transaction(quantus_client, &keypair, submit_call, None, execution_mode).await?; - log_success!( - "Treasury portion proposal submitted! Hash: {:?}", - tx_hash - ); + log_success!("Treasury portion proposal submitted! Hash: {:?}", tx_hash); log_print!("💡 Use 'quantus tech-referenda list' to see active proposals"); Ok(()) From e812d04794bee651ea876025b83bd6b20aa48f2d Mon Sep 17 00:00:00 2001 From: illuzen Date: Mon, 13 Apr 2026 15:24:28 +0800 Subject: [PATCH 09/11] bump zk again --- Cargo.toml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index c043f49..953bae8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,19 +78,19 @@ subxt-metadata = "0.44" anyhow = "1.0" qp-plonky2 = { version = "1.4.1", default-features = false, features = ["rand", "std"] } -qp-wormhole-circuit = { version = "2.0.0", default-features = false, features = ["std"] } -qp-wormhole-prover = { version = "2.0.0", default-features = false, features = ["std"] } -qp-wormhole-verifier = { version = "2.0.0", default-features = false, features = ["std"] } -qp-wormhole-aggregator = { version = "2.0.0", default-features = false, features = ["rayon", "std"] } -qp-wormhole-inputs = { version = "2.0.0", default-features = false, features = ["std"] } -qp-zk-circuits-common = { version = "2.0.0", default-features = false, features = ["std"] } -qp-wormhole-circuit-builder = { version = "2.0.0" } +qp-wormhole-circuit = { version = "2.0.1", default-features = false, features = ["std"] } +qp-wormhole-prover = { version = "2.0.1", default-features = false, features = ["std"] } +qp-wormhole-verifier = { version = "2.0.1", default-features = false, features = ["std"] } +qp-wormhole-aggregator = { version = "2.0.1", default-features = false, features = ["rayon", "std"] } +qp-wormhole-inputs = { version = "2.0.1", default-features = false, features = ["std"] } +qp-zk-circuits-common = { version = "2.0.1", default-features = false, features = ["std"] } +qp-wormhole-circuit-builder = { version = "2.0.1" } [build-dependencies] hex = "0.4" qp-poseidon-core = "1.4.0" -qp-wormhole-circuit-builder = { version = "2.0.0" } +qp-wormhole-circuit-builder = { version = "2.0.1" } [dev-dependencies] tempfile = "3.8.1" From 8f203c8d33c7566b21daa4adb0601003cf557824 Mon Sep 17 00:00:00 2001 From: illuzen Date: Mon, 13 Apr 2026 15:47:46 +0800 Subject: [PATCH 10/11] lock --- Cargo.lock | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 38c8915..6b64066 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3830,7 +3830,9 @@ dependencies = [ [[package]] name = "qp-wormhole-aggregator" -version = "1.4.2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a23eea8e17c97632f5056d1fecdb83d2e997f79d823fb97c514823fce8580" dependencies = [ "anyhow", "hex", @@ -3847,7 +3849,9 @@ dependencies = [ [[package]] name = "qp-wormhole-circuit" -version = "1.4.2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b72abd3357e1c486621431109d83a259076ed941fcc7f035353a629940c443d9" dependencies = [ "anyhow", "hex", @@ -3858,7 +3862,9 @@ dependencies = [ [[package]] name = "qp-wormhole-circuit-builder" -version = "1.4.2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d27c981d34a35cb10ee96e16c1fb44fbb4c52f3e58a787ce089c21bf5469514" dependencies = [ "anyhow", "clap", @@ -3870,14 +3876,18 @@ dependencies = [ [[package]] name = "qp-wormhole-inputs" -version = "1.4.2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f69e54a0f450d349cb1169bbb743a7a846475a1210760308e38f55841e5aa5c0" dependencies = [ "anyhow", ] [[package]] name = "qp-wormhole-prover" -version = "1.4.2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68cee4d6a0317f89d1ec7a6c40a9592e5525c75b9cb21bd7d5d3b408301068bd" dependencies = [ "anyhow", "qp-plonky2", @@ -3888,9 +3898,9 @@ dependencies = [ [[package]] name = "qp-wormhole-verifier" -version = "1.4.2" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf5d21d7c8c07cfa7bfea14240c63d09fad5c1396b111ab72063dbe07aeae121" +checksum = "83f7c0134a766c6624183d129abf12523247e38fc27860e3a9778f73321008ae" dependencies = [ "anyhow", "qp-plonky2-verifier", @@ -3899,7 +3909,9 @@ dependencies = [ [[package]] name = "qp-zk-circuits-common" -version = "1.4.2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6d5e1d764560fd797f71defbf75a1618596945c53e33033ac42cc7c2e4689f6" dependencies = [ "anyhow", "hex", From a96bf64c1d6c68dcd46f6afef835e22228a3efd8 Mon Sep 17 00:00:00 2001 From: illuzen Date: Mon, 13 Apr 2026 16:25:20 +0800 Subject: [PATCH 11/11] clippy cleanup --- src/cli/multisig.rs | 17 +- tests/wormhole_integration.rs | 1209 --------------------------------- 2 files changed, 8 insertions(+), 1218 deletions(-) delete mode 100644 tests/wormhole_integration.rs diff --git a/src/cli/multisig.rs b/src/cli/multisig.rs index 167daaa..057d20b 100644 --- a/src/cli/multisig.rs +++ b/src/cli/multisig.rs @@ -1431,15 +1431,14 @@ async fn fetch_proposal_id( ) -> Option { let latest_block_hash = quantus_client.get_latest_block().await.ok()?; let events = quantus_client.client().events().at(latest_block_hash).await.ok()?; - for ev in events.find::() { - if let Ok(created) = ev { - let addr_bytes: &[u8; 32] = created.multisig_address.as_ref(); - let addr = SpAccountId32::from(*addr_bytes); - let addr_ss58 = - addr.to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(189)); - if addr_ss58 == multisig_ss58 { - return Some(created.proposal_id); - } + for created in events.find::().flatten() + { + let addr_bytes: &[u8; 32] = created.multisig_address.as_ref(); + let addr = SpAccountId32::from(*addr_bytes); + let addr_ss58 = + addr.to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(189)); + if addr_ss58 == multisig_ss58 { + return Some(created.proposal_id); } } None diff --git a/tests/wormhole_integration.rs b/tests/wormhole_integration.rs deleted file mode 100644 index e3cd584..0000000 --- a/tests/wormhole_integration.rs +++ /dev/null @@ -1,1209 +0,0 @@ -//! Integration tests for wormhole proof verification on-chain -//! -//! **TEMPORARILY DISABLED** - These tests use the old MPT storage proof workflow. -//! They need to be rewritten to use ZK tree Merkle proofs once pallet-zk-tree is deployed. -//! -//! To re-enable these tests: -//! 1. Deploy pallet-zk-tree to a test chain -//! 2. Update the tests to use zkTree_getMerkleProof RPC -//! 3. Update PrivateCircuitInputs to use zk_tree_root, zk_merkle_siblings, zk_merkle_positions -//! 4. Remove the #![cfg(feature = "legacy_storage_proofs")] flag below -//! -//! Original description: -//! These tests require a local Quantus node running at ws://127.0.0.1:9944 -//! with funded developer accounts (crystal_alice, crystal_bob, crystal_charlie). -//! -//! Run with: `cargo test --release --test wormhole_integration -- --ignored --nocapture` -//! -//! The tests verify the full end-to-end flow: -//! 1. Fund an unspendable account via wormhole transfer -//! 2. Generate a ZK proof of the transfer -//! 3. Submit the proof for on-chain verification -//! 4. For aggregated proofs: generate multiple proofs and aggregate them -//! -//! Note: For aggregation, proofs must be from the same block or consecutive blocks -//! with valid parent hash linkage. We use batch transfers to ensure same-block proofs. - -// Disable this entire test file until ZK trie integration is complete -#![cfg(feature = "legacy_storage_proofs")] - -use plonky2::plonk::{circuit_data::CircuitConfig, proof::ProofWithPublicInputs}; -use qp_wormhole_aggregator::aggregator::{AggregationBackend, Layer0Aggregator}; -use qp_wormhole_circuit::{ - inputs::{CircuitInputs, PrivateCircuitInputs}, - nullifier::Nullifier, -}; -use qp_wormhole_inputs::{AggregatedPublicCircuitInputs, PublicCircuitInputs}; -use qp_wormhole_prover::WormholeProver; - -use qp_zk_circuits_common::{ - circuit::{C, D, F}, - storage_proof::prepare_proof_for_circuit, - utils::{digest_to_bytes, BytesDigest}, -}; -use quantus_cli::{ - chain::{ - client::QuantusClient, - quantus_subxt::{self as quantus_node, api::wormhole}, - }, - wallet::{QuantumKeyPair, WalletManager}, -}; -use rand::{rng, RngCore}; -use serial_test::serial; -use sp_core::{ - crypto::{AccountId32, Ss58Codec}, - Decode, Hasher, -}; -use sp_runtime::Permill; -use std::time::Duration; -use subxt::{ - backend::legacy::rpc_methods::ReadProof, - ext::{codec::Encode, jsonrpsee::core::client::ClientT}, - tx::TxStatus, - utils::{to_hex, AccountId32 as SubxtAccountId}, -}; - -/// Default local node URL for testing -const LOCAL_NODE_URL: &str = "ws://127.0.0.1:9944"; - -/// Native asset ID (matches chain configuration) -const NATIVE_ASSET_ID: u32 = 0; - -/// Scale down factor for quantizing amounts (10^10 to go from 12 to 2 decimal places) -const SCALE_DOWN_FACTOR: u128 = 10_000_000_000; - -/// Volume fee rate in basis points (10 bps = 0.1%) -const VOLUME_FEE_BPS: u32 = 10; - -/// Type alias for transfer proof storage key. -/// Uses (to, transfer_count) since transfer_count is atomic per recipient. -/// This is hashed with Blake2_256 to form the storage key suffix. -type TransferProofKey = (AccountId32, u64); - -/// Full transfer data including amount - used to compute the leaf_inputs_hash via Poseidon2. -/// This is what the ZK circuit verifies. -type TransferProofData = (u32, u64, AccountId32, AccountId32, u128); - -/// Compute output amount after fee deduction -fn compute_output_amount(input_amount: u32, fee_bps: u32) -> u32 { - ((input_amount as u64) * (10000 - fee_bps as u64) / 10000) as u32 -} - -/// Generate a random 32-byte secret -fn generate_random_secret() -> [u8; 32] { - let mut secret = [0u8; 32]; - rng().fill_bytes(&mut secret); - secret -} - -/// Helper struct to hold proof generation context -struct ProofContext { - proof: ProofWithPublicInputs, - proof_bytes: Vec, - public_inputs: PublicCircuitInputs, -} - -/// Helper struct to hold aggregated proof context -struct AggregatedProofContext { - proof_bytes: Vec, - public_inputs: AggregatedPublicCircuitInputs, -} - -/// Data collected from a transfer, needed to generate proof later -struct TransferData { - secret: [u8; 32], - exit_account_bytes: [u8; 32], - funding_amount: u128, - transfer_count: u64, - from_account: SubxtAccountId, - to_account: SubxtAccountId, - amount: u128, - funding_account: AccountId32, - unspendable_account: qp_zk_circuits_common::utils::Digest, -} - -/// Submit a transaction and return the block hash where it was included -async fn submit_and_get_block_hash( - quantus_client: &QuantusClient, - keypair: &QuantumKeyPair, - call: Call, -) -> Result -where - Call: subxt::tx::Payload, -{ - let client = quantus_client.client(); - - let signer = keypair - .to_subxt_signer() - .map_err(|e| format!("Failed to convert keypair: {}", e))?; - - // Get fresh nonce - let (from_account_id, _version) = - AccountId32::from_ss58check_with_version(&keypair.to_account_id_ss58check()) - .map_err(|e| format!("Invalid from address: {:?}", e))?; - let nonce = quantus_client - .get_account_nonce_from_best_block(&from_account_id) - .await - .map_err(|e| format!("Failed to get nonce: {}", e))?; - - // Build transaction params - use subxt::config::DefaultExtrinsicParamsBuilder; - let params = DefaultExtrinsicParamsBuilder::new().mortal(256).nonce(nonce).build(); - - // Submit and watch for the block hash where it's included - let mut tx_progress = client - .tx() - .sign_and_submit_then_watch(&call, &signer, params) - .await - .map_err(|e| format!("Failed to submit transaction: {}", e))?; - - // Wait for transaction to be included and get the block hash - loop { - match tx_progress.next().await { - Some(Ok(TxStatus::InBestBlock(tx_in_block))) => { - return Ok(tx_in_block.block_hash()); - }, - Some(Ok(TxStatus::InFinalizedBlock(tx_in_block))) => { - return Ok(tx_in_block.block_hash()); - }, - Some(Ok(TxStatus::Error { message })) | Some(Ok(TxStatus::Invalid { message })) => { - return Err(format!("Transaction failed: {}", message)); - }, - Some(Err(e)) => { - return Err(format!("Transaction progress error: {}", e)); - }, - None => { - return Err("Transaction stream ended unexpectedly".to_string()); - }, - _ => continue, - } - } -} - -/// Setup developer wallet for testing -/// Creates the wallet if it doesn't exist -async fn setup_developer_wallet(wallet_name: &str) -> QuantumKeyPair { - let wallet_manager = WalletManager::new().expect("Failed to create wallet manager"); - - // Try to load existing wallet, or create if doesn't exist - // get_wallet returns Ok(Some(...)) if wallet exists, Ok(None) if it doesn't - match wallet_manager.get_wallet(wallet_name, Some("")) { - Ok(Some(_wallet_info)) => { - // Wallet exists, load the keypair (developer wallets use empty password) - let wallet_data = - wallet_manager.load_wallet(wallet_name, "").expect("Failed to load wallet data"); - wallet_data.keypair - }, - Ok(None) | Err(_) => { - // Wallet doesn't exist, create developer wallet (pre-funded on dev chains) - println!(" Creating developer wallet '{}'...", wallet_name); - wallet_manager - .create_developer_wallet(wallet_name) - .await - .expect("Failed to create developer wallet"); - - let wallet_data = - wallet_manager.load_wallet(wallet_name, "").expect("Failed to load wallet data"); - wallet_data.keypair - }, - } -} - -/// Generate a wormhole proof by funding an unspendable account -async fn generate_wormhole_proof( - quantus_client: &QuantusClient, - keypair: &QuantumKeyPair, - funding_amount: u128, - exit_account_bytes: [u8; 32], - secret: [u8; 32], -) -> Result { - // First submit the transfer and collect transfer data - let transfer_data = submit_wormhole_transfer( - quantus_client, - keypair, - funding_amount, - exit_account_bytes, - secret, - ) - .await?; - - // Wait a moment then get the latest block to use for proof generation - tokio::time::sleep(Duration::from_millis(500)).await; - let block_hash = quantus_client - .get_latest_block() - .await - .map_err(|e| format!("Failed to get latest block: {}", e))?; - - // Generate proof from the transfer data using this block - generate_proof_from_transfer(quantus_client, &transfer_data, block_hash).await -} - -/// Submit a wormhole transfer and return the transfer data needed for proof generation -async fn submit_wormhole_transfer( - quantus_client: &QuantusClient, - keypair: &QuantumKeyPair, - funding_amount: u128, - exit_account_bytes: [u8; 32], - secret: [u8; 32], -) -> Result { - let secret_digest: BytesDigest = - secret.try_into().map_err(|e| format!("Failed to convert secret: {:?}", e))?; - - let client = quantus_client.client(); - let funding_account = - AccountId32::new(qp_poseidon::PoseidonHasher::hash(keypair.public_key.as_ref()).0); - - // Generate unspendable account from secret - let unspendable_account = - qp_wormhole_circuit::unspendable_account::UnspendableAccount::from_secret(secret_digest) - .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 unspendable_account_id = SubxtAccountId(unspendable_account_bytes); - - println!(" Unspendable account: 0x{}", hex::encode(unspendable_account_bytes)); - println!(" Exit account: 0x{}", hex::encode(exit_account_bytes)); - - // Fund via Balances (wormhole has no transfer_native; WormholeProofRecorderExtension - // records every Balances transfer and emits NativeTransferred) - let transfer_tx = quantus_node::api::tx().balances().transfer_allow_death( - subxt::ext::subxt_core::utils::MultiAddress::Id(unspendable_account_id.clone()), - funding_amount, - ); - - println!(" Submitting transfer to unspendable account..."); - - let quantum_keypair = QuantumKeyPair { - public_key: keypair.public_key.clone(), - private_key: keypair.private_key.clone(), - }; - - // Submit transaction and get the actual block hash where it was included - let block_hash: subxt::utils::H256 = - submit_and_get_block_hash(quantus_client, &quantum_keypair, transfer_tx) - .await - .map_err(|e| format!("Transfer failed: {}", e))?; - - println!(" Transfer included in block: {:?}", block_hash); - - // WormholeProofRecorderExtension emits NativeTransferred for every Balances transfer - let events_api = client - .events() - .at(block_hash) - .await - .map_err(|e| format!("Failed to get events: {}", e))?; - - // Find the event that matches our specific unspendable account - let mut matching_event = None; - for event_result in events_api.find::() { - let event = event_result.map_err(|e| format!("Failed to decode event: {}", e))?; - // Check if this event is for our unspendable account - if event.to.0 == unspendable_account_bytes { - matching_event = Some(event); - break; - } - } - - let event = matching_event.ok_or_else(|| { - "No NativeTransferred event found for our unspendable account".to_string() - })?; - - println!(" Transfer event: amount={}, transfer_count={}", event.amount, event.transfer_count); - - Ok(TransferData { - secret, - exit_account_bytes, - funding_amount, - transfer_count: event.transfer_count, - from_account: event.from, - to_account: event.to, - amount: event.amount, - funding_account, - unspendable_account, - }) -} - -/// Generate a proof from transfer data using a specific block for the storage proof -async fn generate_proof_from_transfer( - quantus_client: &QuantusClient, - transfer_data: &TransferData, - block_hash: subxt::utils::H256, -) -> Result { - let client = quantus_client.client(); - - let secret_digest: BytesDigest = transfer_data - .secret - .try_into() - .map_err(|e| format!("Failed to convert secret: {:?}", e))?; - - let blocks = client - .blocks() - .at(block_hash) - .await - .map_err(|e| format!("Failed to get block: {}", e))?; - - // Get storage proof - let from_account = AccountId32::new(transfer_data.from_account.0); - let to_account = AccountId32::new(transfer_data.to_account.0); - - // Compute leaf_inputs_hash (Poseidon2 hash of full transfer data including amount) - // This is what gets stored as the value and verified by the ZK circuit - let leaf_hash = qp_poseidon::PoseidonHasher::hash_storage::( - &( - NATIVE_ASSET_ID, - transfer_data.transfer_count, - from_account.clone(), - to_account.clone(), - transfer_data.amount, - ) - .encode(), - ); - - // Build the storage key manually: - // Key = Twox128("Wormhole") || Twox128("TransferProof") || Blake2_256(to, transfer_count) - // Compute the prefix using Twox128 hashes - let pallet_hash = sp_core::twox_128(b"Wormhole"); - let storage_hash = sp_core::twox_128(b"TransferProof"); - let mut final_key = Vec::with_capacity(32 + 32); // prefix + blake2_256 hash - final_key.extend_from_slice(&pallet_hash); - final_key.extend_from_slice(&storage_hash); - - // Hash the key tuple with Blake2_256 and append - let key_tuple: TransferProofKey = (to_account.clone(), transfer_data.transfer_count); - let key_hash = sp_core::blake2_256(&key_tuple.encode()); - final_key.extend_from_slice(&key_hash); - - let storage_api = client.storage().at(block_hash); - let val = storage_api - .fetch_raw(final_key.clone()) - .await - .map_err(|e| format!("Failed to fetch storage: {}", e))?; - - if val.is_none() { - return Err("Storage key not found".to_string()); - } - - // Get read proof via RPC - let proof_params = subxt::ext::jsonrpsee::rpc_params![vec![to_hex(&final_key)], block_hash]; - let read_proof: ReadProof = quantus_client - .rpc_client() - .request("state_getReadProof", proof_params) - .await - .map_err(|e| format!("Failed to get read proof: {}", e))?; - - let header = blocks.header(); - - let state_root = BytesDigest::try_from(header.state_root.as_bytes()) - .map_err(|e| format!("Failed to convert state root: {}", e))?; - let parent_hash = BytesDigest::try_from(header.parent_hash.as_bytes()) - .map_err(|e| format!("Failed to convert parent hash: {}", e))?; - let extrinsics_root = BytesDigest::try_from(header.extrinsics_root.as_bytes()) - .map_err(|e| format!("Failed to convert extrinsics root: {}", e))?; - let digest = header - .digest - .encode() - .try_into() - .map_err(|_| "Failed to encode digest".to_string())?; - - let block_number = header.number; - - // Prepare storage proof for circuit - let processed_storage_proof = prepare_proof_for_circuit( - read_proof.proof.iter().map(|proof| proof.0.clone()).collect(), - hex::encode(header.state_root.0), - leaf_hash, - ) - .map_err(|e| format!("Failed to prepare storage proof: {}", e))?; - - // Quantize the funding amount - let input_amount_quantized: u32 = (transfer_data.funding_amount / SCALE_DOWN_FACTOR) - .try_into() - .map_err(|_| "Funding amount too large after quantization".to_string())?; - - let output_amount_quantized = compute_output_amount(input_amount_quantized, VOLUME_FEE_BPS); - - let exit_account_digest = BytesDigest::try_from(&transfer_data.exit_account_bytes[..]) - .map_err(|e| format!("Failed to convert exit account: {}", e))?; - - let inputs = CircuitInputs { - private: PrivateCircuitInputs { - secret: secret_digest, - transfer_count: transfer_data.transfer_count, - funding_account: BytesDigest::try_from(transfer_data.funding_account.as_ref() as &[u8]) - .map_err(|e| format!("Failed to convert funding account: {}", e))?, - storage_proof: processed_storage_proof, - unspendable_account: digest_to_bytes(transfer_data.unspendable_account), - parent_hash, - state_root, - extrinsics_root, - digest, - input_amount: input_amount_quantized, - }, - public: PublicCircuitInputs { - output_amount_1: output_amount_quantized, - output_amount_2: 0, // No change output for single-output spend - volume_fee_bps: VOLUME_FEE_BPS, - nullifier: digest_to_bytes( - Nullifier::from_preimage(secret_digest, transfer_data.transfer_count).hash, - ), - exit_account_1: exit_account_digest, - exit_account_2: BytesDigest::try_from([0u8; 32].as_ref()) - .map_err(|e| format!("Failed to convert zero exit account: {}", e))?, - block_hash: BytesDigest::try_from(block_hash.as_ref()) - .map_err(|e| format!("Failed to convert block hash: {}", e))?, - block_number, - asset_id: NATIVE_ASSET_ID, - }, - }; - - println!(" Generating ZK proof (this may take ~30s)..."); - let config = CircuitConfig::standard_recursion_zk_config(); - let prover = WormholeProver::new(config); - let prover_next = prover.commit(&inputs).map_err(|e| format!("Failed to commit: {}", e))?; - let proof: ProofWithPublicInputs<_, _, 2> = - prover_next.prove().map_err(|e| format!("Proof generation failed: {}", e))?; - - use qp_wormhole_circuit::inputs::ParsePublicInputs; - let public_inputs = PublicCircuitInputs::try_from_proof(&proof) - .map_err(|e| format!("Failed to parse public inputs: {}", e))?; - - let proof_bytes = proof.to_bytes(); - println!(" Proof generated! Size: {} bytes", proof_bytes.len()); - - Ok(ProofContext { proof, proof_bytes, public_inputs }) -} - -/// Submit a single proof for on-chain verification -async fn submit_single_proof_for_verification( - quantus_client: &QuantusClient, - proof_bytes: Vec, -) -> Result { - println!(" Submitting single proof for on-chain verification..."); - - let verify_tx = quantus_node::api::tx().wormhole().verify_aggregated_proof(proof_bytes); - - let unsigned_tx = quantus_client - .client() - .tx() - .create_unsigned(&verify_tx) - .map_err(|e| format!("Failed to create unsigned tx: {}", e))?; - - let mut tx_progress = unsigned_tx - .submit_and_watch() - .await - .map_err(|e| format!("Failed to submit tx: {}", e))?; - - while let Some(Ok(status)) = tx_progress.next().await { - match status { - TxStatus::InBestBlock(tx_in_block) => { - let block_hash = tx_in_block.block_hash(); - println!(" ✅ Single proof verified on-chain! Block: {:?}", block_hash); - return Ok(block_hash); - }, - TxStatus::InFinalizedBlock(tx_in_block) => { - let block_hash = tx_in_block.block_hash(); - println!( - " ✅ Single proof verified on-chain (finalized)! Block: {:?}", - block_hash - ); - return Ok(block_hash); - }, - TxStatus::Error { message } | TxStatus::Invalid { message } => { - return Err(format!("Transaction failed: {}", message)); - }, - _ => continue, - } - } - - Err("Transaction stream ended unexpectedly".to_string()) -} - -/// Aggregate multiple proofs into one -fn aggregate_proofs( - proof_contexts: Vec, - num_leaf_proofs: usize, -) -> Result { - println!( - " Aggregating {} proofs (num_leaf_proofs={})...", - proof_contexts.len(), - num_leaf_proofs, - ); - - if proof_contexts.len() > num_leaf_proofs { - return Err(format!( - "Too many proofs: {} provided, max {}", - proof_contexts.len(), - num_leaf_proofs, - )); - } - - let bins_dir = std::path::Path::new("generated-bins"); - - let mut aggregator = Layer0Aggregator::new(bins_dir) - .map_err(|e| format!("Failed to create aggregator: {}", e))?; - - for (idx, ctx) in proof_contexts.into_iter().enumerate() { - println!(" Adding proof {} to aggregator...", idx + 1); - println!(" Public inputs:"); - println!(" asset_id: {}", ctx.public_inputs.asset_id); - println!(" output_amount_1: {}", ctx.public_inputs.output_amount_1); - println!(" output_amount_2: {}", ctx.public_inputs.output_amount_2); - println!(" volume_fee_bps: {}", ctx.public_inputs.volume_fee_bps); - println!(" nullifier: {:?}", ctx.public_inputs.nullifier); - println!(" exit_account_1: {:?}", ctx.public_inputs.exit_account_1); - println!(" exit_account_2: {:?}", ctx.public_inputs.exit_account_2); - println!(" block_hash: {:?}", ctx.public_inputs.block_hash); - println!(" block_number: {:?}", ctx.public_inputs.block_number); - println!(" block_number: {}", ctx.public_inputs.block_number); - aggregator - .push_proof(ctx.proof) - .map_err(|e| format!("Failed to push proof: {}", e))?; - } - - println!(" Running aggregation (this may take ~60s)..."); - let aggregated_proof = - aggregator.aggregate().map_err(|e| format!("Aggregation failed: {}", e))?; - - use qp_wormhole_circuit::inputs::ParseAggregatedPublicInputs; - let public_inputs = - AggregatedPublicCircuitInputs::try_from_felts(aggregated_proof.public_inputs.as_slice()) - .map_err(|e| format!("Failed to parse aggregated public inputs: {}", e))?; - - println!(" Verifying aggregated proof locally..."); - aggregator - .verify(aggregated_proof.clone()) - .map_err(|e| format!("Local verification failed: {}", e))?; - - let proof_bytes = aggregated_proof.to_bytes(); - println!( - " Aggregation complete! Size: {} bytes, {} nullifiers", - proof_bytes.len(), - public_inputs.nullifiers.len() - ); - - Ok(AggregatedProofContext { proof_bytes, public_inputs }) -} - -/// Submit an aggregated proof for on-chain verification -async fn submit_aggregated_proof_for_verification( - quantus_client: &QuantusClient, - proof_bytes: Vec, -) -> Result { - println!(" Submitting aggregated proof for on-chain verification..."); - - let verify_tx = quantus_node::api::tx().wormhole().verify_aggregated_proof(proof_bytes); - - let unsigned_tx = quantus_client - .client() - .tx() - .create_unsigned(&verify_tx) - .map_err(|e| format!("Failed to create unsigned tx: {}", e))?; - - let mut tx_progress = unsigned_tx - .submit_and_watch() - .await - .map_err(|e| format!("Failed to submit tx: {}", e))?; - - while let Some(Ok(status)) = tx_progress.next().await { - match status { - TxStatus::InBestBlock(tx_in_block) => { - let block_hash = tx_in_block.block_hash(); - println!(" ✅ Aggregated proof verified on-chain! Block: {:?}", block_hash); - return Ok(block_hash); - }, - TxStatus::InFinalizedBlock(tx_in_block) => { - let block_hash = tx_in_block.block_hash(); - println!( - " ✅ Aggregated proof verified on-chain (finalized)! Block: {:?}", - block_hash - ); - return Ok(block_hash); - }, - TxStatus::Error { message } | TxStatus::Invalid { message } => { - return Err(format!("Transaction failed: {}", message)); - }, - _ => continue, - } - } - - Err("Transaction stream ended unexpectedly".to_string()) -} - -const POW_ENGINE_ID: [u8; 4] = *b"pow_"; - -fn author_from_header_digest( - header_digest: &subxt::config::substrate::Digest, -) -> Option { - header_digest.logs.iter().find_map(|item| match item { - subxt::config::substrate::DigestItem::PreRuntime(engine_id, data) - if *engine_id == POW_ENGINE_ID && data.len() == 32 => - { - let preimage: [u8; 32] = data.as_slice().try_into().ok()?; - let author_bytes = qp_poseidon_core::rehash_to_bytes(&preimage); - SubxtAccountId::decode(&mut &author_bytes[..]).ok() - }, - _ => None, - }) -} - -/// fee = exit * fee_bps / (10000 - fee_bps) -/// miner_fee = fee - burn_rate*fee -fn expected_miner_fee_u128(total_exit_amount_u128: u128, fee_bps: u32) -> u128 { - let fee_bps_u128 = fee_bps as u128; - - let fee_u128 = total_exit_amount_u128 - .saturating_mul(fee_bps_u128) - .checked_div(10_000u128.saturating_sub(fee_bps_u128)) - .unwrap_or(0); - - let burn_rate: Permill = Permill::from_percent(50); - let burn_amount_u128 = burn_rate * fee_u128; - fee_u128.saturating_sub(burn_amount_u128) -} - -async fn free_balance_at( - client: &QuantusClient, - at: sp_core::H256, - who: &SubxtAccountId, -) -> anyhow::Result { - let api = client.client(); - let storage = api.storage().at(at); - let addr = quantus_node::api::storage().system().account(who.clone()); - let info = storage.fetch(&addr).await?; - let free: u128 = info.map(|i| i.data.free).unwrap_or(0u128); - Ok(free) -} - -/// Integration test: Generate and verify a single wormhole proof on-chain -/// -/// This test: -/// 1. Connects to a local Quantus node -/// 2. Uses a developer wallet (crystal_alice) to fund an unspendable account -/// 3. Generates a ZK proof of the transfer -/// 4. Submits the proof for on-chain verification -/// 5. Miner fee is paid. -#[tokio::test] -#[serial] -#[ignore] // Requires running local node - run with `cargo test -- --ignored` -async fn test_single_proof_on_chain_verification() { - println!("\n=== Single Proof On-Chain Verification Test ===\n"); - - // Connect to local node - println!("1. Connecting to local node at {}...", LOCAL_NODE_URL); - let quantus_client = QuantusClient::new(LOCAL_NODE_URL) - .await - .expect("Failed to connect to local node"); - println!(" Connected!"); - - // Setup developer wallet - println!("2. Setting up developer wallet (crystal_alice)..."); - let keypair = setup_developer_wallet("crystal_alice").await; - println!(" Wallet address: {}", keypair.to_account_id_ss58check()); - - // Generate random secret and exit account - let secret = generate_random_secret(); - let mut exit_account = [0u8; 32]; - rng().fill_bytes(&mut exit_account); - - // Use a small funding amount (1 token = 10^12 units) - let funding_amount: u128 = 1_000_000_000_000; // 1 token - - println!("3. Generating wormhole proof..."); - println!(" Funding amount: {} units (1 token)", funding_amount); - println!(" Secret: 0x{}", hex::encode(secret)); - - let proof_context = - generate_wormhole_proof(&quantus_client, &keypair, funding_amount, exit_account, secret) - .await - .expect("Failed to generate proof"); - - println!( - " Public inputs - output_amount_1: {}, nullifier: {:?}", - proof_context.public_inputs.output_amount_1, proof_context.public_inputs.nullifier - ); - - // Submit for on-chain verification - println!("4. Verifying proof on-chain..."); - - // Submit extrinsic and find the block that executed verification - let verify_block_hash = - submit_single_proof_for_verification(&quantus_client, proof_context.proof_bytes.clone()) - .await - .expect("On-chain verification failed"); - - // Extract author from block digest - let verify_block = quantus_client.client().blocks().at(verify_block_hash).await.unwrap(); - let header = verify_block.header(); - let parent_hash = header.parent_hash; - let author = author_from_header_digest(&header.digest) - .expect("could not decode author from pre-runtime digest"); - - // Compute expected miner fee from public inputs (single proof) - let fee_bps = proof_context.public_inputs.volume_fee_bps; - let exit_u128 = (proof_context.public_inputs.output_amount_1 as u128) * SCALE_DOWN_FACTOR; - let expected_miner_fee = expected_miner_fee_u128(exit_u128, fee_bps); - - assert!(expected_miner_fee > 0, "expected miner fee should be > 0"); - - // Balance delta check - let before = free_balance_at(&quantus_client, parent_hash, &author).await.unwrap(); - let after = free_balance_at(&quantus_client, verify_block_hash, &author).await.unwrap(); - let delta = after.saturating_sub(before); - - println!( - "Miner fee check: author={:?} before={} after={} delta={} expected_miner_fee={}", - author, before, after, delta, expected_miner_fee - ); - - assert!( - delta >= expected_miner_fee, - "author balance delta ({}) did not cover expected miner fee ({})", - delta, - expected_miner_fee - ); - - println!("\n=== Single Proof Test PASSED ===\n"); -} - -/// Integration test: Generate, aggregate, and verify multiple wormhole proofs on-chain -/// -/// This test: -/// 1. Connects to a local Quantus node -/// 2. Uses developer wallets to fund multiple unspendable accounts -/// 3. Generates ZK proofs for each transfer (all from the same block for aggregation) -/// 4. Aggregates the proofs into a single proof -/// 5. Submits the aggregated proof for on-chain verification -/// 6. Miner fee is paid. -#[tokio::test] -#[serial] -#[ignore] // Requires running local node - run with `cargo test -- --ignored` -async fn test_aggregated_proof_on_chain_verification() { - println!("\n=== Aggregated Proof On-Chain Verification Test ===\n"); - - // Connect to local node - println!("1. Connecting to local node at {}...", LOCAL_NODE_URL); - let quantus_client = QuantusClient::new(LOCAL_NODE_URL) - .await - .expect("Failed to connect to local node"); - println!(" Connected!"); - - // Setup developer wallets - use different wallets for different proofs to avoid nonce issues - println!("2. Setting up developer wallets..."); - let keypair_alice = setup_developer_wallet("crystal_alice").await; - let keypair_bob = setup_developer_wallet("crystal_bob").await; - println!(" Alice address: {}", keypair_alice.to_account_id_ss58check()); - println!(" Bob address: {}", keypair_bob.to_account_id_ss58check()); - - let keypairs = [&keypair_alice, &keypair_bob]; - let mut transfer_data_list = Vec::new(); - - // Phase 1: Submit all transfers first - println!("3. Submitting wormhole transfers..."); - for (i, keypair) in keypairs.iter().enumerate() { - println!("\n --- Transfer {} ---", i + 1); - - let secret = generate_random_secret(); - let mut exit_account = [0u8; 32]; - rng().fill_bytes(&mut exit_account); - - // Use a small funding amount (0.5 tokens each) - let funding_amount: u128 = 500_000_000_000; // 0.5 tokens - - println!(" Funding amount: {} units (0.5 tokens)", funding_amount); - println!(" Secret: 0x{}", hex::encode(secret)); - - let transfer_data = submit_wormhole_transfer( - &quantus_client, - keypair, - funding_amount, - exit_account, - secret, - ) - .await - .expect("Failed to submit transfer"); - - transfer_data_list.push(transfer_data); - } - - // Wait for all transfers to be available in storage - println!("\n Waiting for transfers to be confirmed..."); - tokio::time::sleep(Duration::from_secs(2)).await; - - // Phase 2: Get a common block hash and generate all proofs from it - let common_block_hash = - quantus_client.get_latest_block().await.expect("Failed to get latest block"); - println!(" Using common block for all proofs: {:?}", common_block_hash); - - let mut proof_contexts = Vec::new(); - println!("\n4. Generating proofs from common block..."); - for (i, transfer_data) in transfer_data_list.iter().enumerate() { - println!("\n --- Proof {} ---", i + 1); - - let proof_context = - generate_proof_from_transfer(&quantus_client, transfer_data, common_block_hash) - .await - .expect("Failed to generate proof"); - - println!( - " Public inputs - output_amount_1: {}, nullifier: {:?}", - proof_context.public_inputs.output_amount_1, proof_context.public_inputs.nullifier - ); - - proof_contexts.push(proof_context); - } - - // Aggregate proofs - println!("\n4. Aggregating {} proofs...", proof_contexts.len()); - let aggregated_context = aggregate_proofs( - proof_contexts, - 2, // num_leaf_proofs - ) - .expect("Failed to aggregate proofs"); - - println!( - " Aggregated {} nullifiers, {} account entries", - aggregated_context.public_inputs.nullifiers.len(), - aggregated_context.public_inputs.account_data.len() - ); - - // Verify aggregated proof on-chain - println!("5. Verifying aggregated proof on-chain..."); - - // Submit aggregated verification - let verify_block_hash = submit_aggregated_proof_for_verification( - &quantus_client, - aggregated_context.proof_bytes.clone(), - ) - .await - .expect("On-chain aggregated verification failed"); - - // Extract author - let verify_block = quantus_client.client().blocks().at(verify_block_hash).await.unwrap(); - let header = verify_block.header(); - let parent_hash = header.parent_hash; - let author = author_from_header_digest(&header.digest) - .expect("could not decode author from pre-runtime digest"); - - // Compute total exit amount (sum of quantized outputs) scaled up - let fee_bps = aggregated_context.public_inputs.volume_fee_bps; - let total_output_quantized: u128 = aggregated_context - .public_inputs - .account_data - .iter() - .map(|a| a.summed_output_amount as u128) - .sum(); - - let total_exit_u128 = total_output_quantized * SCALE_DOWN_FACTOR; - let expected_miner_fee = expected_miner_fee_u128(total_exit_u128, fee_bps); - - assert!(expected_miner_fee > 0, "expected miner fee should be > 0"); - - // Balance delta check - let before = free_balance_at(&quantus_client, parent_hash, &author).await.unwrap(); - let after = free_balance_at(&quantus_client, verify_block_hash, &author).await.unwrap(); - let delta = after.saturating_sub(before); - - println!( - "Miner fee check (agg): author={:?} before={} after={} delta={} expected_miner_fee={}", - author, before, after, delta, expected_miner_fee - ); - - assert!( - delta >= expected_miner_fee, - "author balance delta ({}) did not cover expected miner fee ({})", - delta, - expected_miner_fee - ); - - println!("\n=== Aggregated Proof Test PASSED ===\n"); -} - -/// Integration test: Test single proof with specific exit account -/// -/// This test verifies that the exit account in the proof matches what we specified. -#[tokio::test] -#[serial] -#[ignore] // Requires running local node - run with `cargo test -- --ignored` -async fn test_single_proof_exit_account_verification() { - println!("\n=== Exit Account Verification Test ===\n"); - - // Connect to local node - println!("1. Connecting to local node..."); - let quantus_client = QuantusClient::new(LOCAL_NODE_URL) - .await - .expect("Failed to connect to local node"); - - // Setup developer wallet - println!("2. Setting up developer wallet..."); - let keypair = setup_developer_wallet("crystal_alice").await; - - // Generate random secret - let secret = generate_random_secret(); - - // Use Bob's address as the exit account - let keypair_bob = setup_developer_wallet("crystal_bob").await; - let bob_account = keypair_bob.to_account_id_ss58check(); - println!(" Using Bob's address as exit account: {}", bob_account); - - // Convert Bob's address to bytes (use from_ss58check_with_version for Quantus SS58 format) - let (bob_account_id, _version) = - AccountId32::from_ss58check_with_version(&bob_account).expect("Invalid SS58"); - let exit_account_bytes: [u8; 32] = bob_account_id.into(); - - let funding_amount: u128 = 1_000_000_000_000; - - println!("3. Generating wormhole proof with specific exit account..."); - let proof_context = generate_wormhole_proof( - &quantus_client, - &keypair, - funding_amount, - exit_account_bytes, - secret, - ) - .await - .expect("Failed to generate proof"); - - // Verify the exit account in public inputs matches what we specified - let exit_account_from_proof: [u8; 32] = proof_context - .public_inputs - .exit_account_1 - .as_ref() - .try_into() - .expect("Exit account should be 32 bytes"); - - assert_eq!( - exit_account_bytes, exit_account_from_proof, - "Exit account in proof should match specified account" - ); - println!(" ✅ Exit account verification passed!"); - - // Submit for on-chain verification - println!("4. Verifying proof on-chain..."); - submit_single_proof_for_verification(&quantus_client, proof_context.proof_bytes) - .await - .expect("On-chain verification failed"); - - println!("\n=== Exit Account Verification Test PASSED ===\n"); -} - -/// Integration test: Verify nullifier uniqueness across proofs -/// -/// This test ensures that different secrets produce different nullifiers, -/// which is critical for preventing double-spending. -#[tokio::test] -#[serial] -#[ignore] // Requires running local node - run with `cargo test -- --ignored` -async fn test_nullifier_uniqueness() { - println!("\n=== Nullifier Uniqueness Test ===\n"); - - // Connect to local node - println!("1. Connecting to local node..."); - let quantus_client = QuantusClient::new(LOCAL_NODE_URL) - .await - .expect("Failed to connect to local node"); - - // Setup developer wallet - println!("2. Setting up developer wallet..."); - let keypair = setup_developer_wallet("crystal_alice").await; - - // Generate two proofs with different secrets - let secret1 = generate_random_secret(); - let secret2 = generate_random_secret(); - assert_ne!(secret1, secret2, "Secrets should be different"); - - let mut exit_account = [0u8; 32]; - rng().fill_bytes(&mut exit_account); - - let funding_amount: u128 = 500_000_000_000; - - println!("3. Generating first proof..."); - let proof1 = - generate_wormhole_proof(&quantus_client, &keypair, funding_amount, exit_account, secret1) - .await - .expect("Failed to generate first proof"); - - // Wait for nonce to update - tokio::time::sleep(Duration::from_secs(2)).await; - - println!("4. Generating second proof..."); - let proof2 = - generate_wormhole_proof(&quantus_client, &keypair, funding_amount, exit_account, secret2) - .await - .expect("Failed to generate second proof"); - - // Verify nullifiers are different - assert_ne!( - proof1.public_inputs.nullifier, proof2.public_inputs.nullifier, - "Nullifiers from different secrets should be different" - ); - println!(" ✅ Nullifiers are unique!"); - println!(" Nullifier 1: {:?}", proof1.public_inputs.nullifier); - println!(" Nullifier 2: {:?}", proof2.public_inputs.nullifier); - - // Verify both proofs on-chain - println!("5. Verifying first proof on-chain..."); - submit_single_proof_for_verification(&quantus_client, proof1.proof_bytes) - .await - .expect("First proof verification failed"); - - println!("6. Verifying second proof on-chain..."); - submit_single_proof_for_verification(&quantus_client, proof2.proof_bytes) - .await - .expect("Second proof verification failed"); - - println!("\n=== Nullifier Uniqueness Test PASSED ===\n"); -} - -/// Integration test: Full end-to-end workflow with multiple aggregated proofs -/// -/// This is a comprehensive test that exercises the full wormhole workflow: -/// 1. Multiple transfers from different accounts -/// 2. Multiple proof generations -/// 3. Proof aggregation -/// 4. On-chain verification of aggregated proof -#[tokio::test] -#[serial] -#[ignore] // Requires running local node - run with `cargo test -- --ignored` -async fn test_full_wormhole_workflow() { - println!("\n=== Full Wormhole Workflow Test ===\n"); - - // Connect to local node - println!("1. Connecting to local node at {}...", LOCAL_NODE_URL); - let quantus_client = QuantusClient::new(LOCAL_NODE_URL) - .await - .expect("Failed to connect to local node"); - println!(" Connected!"); - - // Setup developer wallet - println!("2. Setting up developer wallet..."); - let keypair = setup_developer_wallet("crystal_alice").await; - println!(" Address: {}", keypair.to_account_id_ss58check()); - - // Step 1: Generate and verify a single proof - println!("\n--- Step 1: Single Proof ---"); - let secret1 = generate_random_secret(); - let mut exit1 = [0u8; 32]; - rng().fill_bytes(&mut exit1); - - let proof1 = generate_wormhole_proof( - &quantus_client, - &keypair, - 1_000_000_000_000, // 1 token - exit1, - secret1, - ) - .await - .expect("Failed to generate proof 1"); - - println!(" Generated proof 1, verifying on-chain..."); - submit_single_proof_for_verification(&quantus_client, proof1.proof_bytes.clone()) - .await - .expect("Proof 1 verification failed"); - - // Wait for state to settle - tokio::time::sleep(Duration::from_secs(2)).await; - - // Step 2: Submit multiple transfers for aggregation - println!("\n--- Step 2: Submitting Transfers for Aggregation ---"); - let mut transfer_data_list = Vec::new(); - - for i in 0..2 { - println!(" Submitting transfer {}...", i + 1); - let secret = generate_random_secret(); - let mut exit = [0u8; 32]; - rng().fill_bytes(&mut exit); - - let transfer_data = submit_wormhole_transfer( - &quantus_client, - &keypair, - 500_000_000_000, // 0.5 tokens - exit, - secret, - ) - .await - .unwrap_or_else(|_| panic!("Failed to submit transfer {}", i + 1)); - - transfer_data_list.push(transfer_data); - } - - // Wait for transfers to be confirmed and get a common block - println!(" Waiting for transfers to be confirmed..."); - tokio::time::sleep(Duration::from_secs(2)).await; - - let common_block_hash = - quantus_client.get_latest_block().await.expect("Failed to get latest block"); - println!(" Using common block for all proofs: {:?}", common_block_hash); - - // Generate all proofs from the common block - let mut proofs_for_aggregation = Vec::new(); - for (i, transfer_data) in transfer_data_list.iter().enumerate() { - println!(" Generating proof {}...", i + 1); - let proof = generate_proof_from_transfer(&quantus_client, transfer_data, common_block_hash) - .await - .unwrap_or_else(|_| panic!("Failed to generate proof {}", i + 1)); - proofs_for_aggregation.push(proof); - } - - // Step 3: Aggregate and verify - println!("\n--- Step 3: Aggregation ---"); - let aggregated = - aggregate_proofs(proofs_for_aggregation, 2).expect("Failed to aggregate proofs"); - - println!(" Verifying aggregated proof on-chain..."); - submit_aggregated_proof_for_verification(&quantus_client, aggregated.proof_bytes) - .await - .expect("Aggregated proof verification failed"); - - println!("\n=== Full Wormhole Workflow Test PASSED ===\n"); -} - -/// Test that the fee calculation is correct -#[test] -fn test_fee_calculation_consistency() { - // Test various amounts and verify the fee calculation - let test_cases = [ - (1000u32, 10u32, 999u32), // 1000 * 0.999 = 999 - (10000, 10, 9990), // 10000 * 0.999 = 9990 - (100, 10, 99), // 100 * 0.999 = 99 - (1, 10, 0), // 1 * 0.999 = 0 (rounds down) - (0, 10, 0), // 0 * 0.999 = 0 - (1000, 100, 990), // 1% fee: 1000 * 0.99 = 990 - (1000, 0, 1000), // 0% fee: no deduction - ]; - - for (input, fee_bps, expected_output) in test_cases { - let output = compute_output_amount(input, fee_bps); - assert_eq!( - output, expected_output, - "Fee calculation failed for input={}, fee_bps={}: got {}, expected {}", - input, fee_bps, output, expected_output - ); - } - - println!("Fee calculation test passed!"); -} - -/// Test that secrets generate deterministic nullifiers -#[test] -fn test_nullifier_determinism() { - let secret: BytesDigest = [42u8; 32].try_into().expect("valid secret"); - let transfer_count = 123u64; - - let nullifier1 = Nullifier::from_preimage(secret, transfer_count); - let nullifier2 = Nullifier::from_preimage(secret, transfer_count); - - assert_eq!(nullifier1.hash, nullifier2.hash, "Same inputs should produce same nullifier"); - - // Different transfer count should produce different nullifier - let nullifier3 = Nullifier::from_preimage(secret, transfer_count + 1); - assert_ne!( - nullifier1.hash, nullifier3.hash, - "Different transfer counts should produce different nullifiers" - ); - - println!("Nullifier determinism test passed!"); -}