From 0a598df1619c3831313876dd56807cad64e34661 Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Thu, 30 Apr 2026 08:01:17 +0800 Subject: [PATCH 1/3] feat: Expose wormhole helpers as SDK + add e2e/read-only examples --- examples/wormhole_sdk_e2e.rs | 462 +++++++++++++++++++++++++++++++++ examples/wormhole_sdk_usage.rs | 345 ++++++++++++++++++++++++ src/cli/wormhole.rs | 82 ++++-- src/lib.rs | 16 ++ 4 files changed, 883 insertions(+), 22 deletions(-) create mode 100644 examples/wormhole_sdk_e2e.rs create mode 100644 examples/wormhole_sdk_usage.rs diff --git a/examples/wormhole_sdk_e2e.rs b/examples/wormhole_sdk_e2e.rs new file mode 100644 index 0000000..76afc2f --- /dev/null +++ b/examples/wormhole_sdk_e2e.rs @@ -0,0 +1,462 @@ +//! End-to-end wormhole SDK example. +//! +//! This is the *functional* counterpart to [`wormhole_sdk_usage.rs`]: it +//! actually moves coins on a live chain. Given a funded Dilithium wallet and a +//! reachable node, it runs the full wormhole loop: +//! +//! ```text +//! 1. derive a fresh wormhole address from a random secret +//! 2. signed deposit -> balances.transfer_allow_death(wh_addr, amount) +//! 3. wait for inclusion + locate NativeTransferred event in the block +//! 4. parse_transfer_events -> TransferInfo +//! 5. fetch block header + ZK Merkle proof at a recent block +//! 6. compute_merkle_positions -> (siblings, positions) +//! 7. wormhole_lib::generate_proof -> leaf proof bytes +//! 8. aggregate_proofs([leaf]) -> agg.hex +//! 9. verify_aggregated_and_get_events -> minted NativeTransferred +//! ``` +//! +//! All helpers are the same ones consumed by `stress-test`. The example is +//! intentionally written against the public re-exports from `lib.rs` so that +//! it doubles as documentation of the SDK surface. +//! +//! Requirements: +//! - A reachable Quantus node (default `ws://127.0.0.1:9944`). On a fresh dev node, run +//! `quantus-node --dev --tmp` and create a developer wallet (e.g. `quantus-cli wallet +//! create-developer crystal_alice`). +//! - A funded wallet whose name + password you'll pass to this example. +//! - The bundled circuit binaries — generated lazily on first use into +//! `~/.quantus/generated-bins/` (or `$QUANTUS_BINS_DIR`). +//! +//! Run: +//! +//! ```bash +//! # default: alice on a local dev chain, deposit 5 DEV +//! cargo run --example wormhole_sdk_e2e -- \ +//! --node ws://127.0.0.1:9944 \ +//! --funder crystal_alice \ +//! --password '' \ +//! --amount 5 +//! +//! # also keep generated proof files for inspection +//! cargo run --example wormhole_sdk_e2e -- \ +//! --funder crystal_alice --password '' --amount 5 --keep-files +//! ``` +//! +//! The example prints each step's inputs / outputs so you can correlate them +//! with the chain state (subscan, `quantus block analyze ...`, etc.). + +use std::{env, time::Duration}; + +use quantus_cli::{ + aggregate_proofs, bins, + chain::{client::QuantusClient, quantus_subxt::api::wormhole}, + cli::{address_format::bytes_to_quantus_ss58, common::ExecutionMode, send::parse_amount}, + compute_merkle_positions, compute_wormhole_address, decode_full_leaf_data, + error::{QuantusError, Result}, + get_zk_merkle_proof, parse_transfer_events, transfer, verify_aggregated_and_get_events, + wallet::WalletManager, + wormhole_lib::{ + self, ProofGenerationInput, NATIVE_ASSET_ID, SCALE_DOWN_FACTOR, VOLUME_FEE_BPS, + }, + write_proof_file, NativeTransferred, +}; +use rand::RngCore; +use subxt::{ext::codec::Encode, utils::H256}; +use tempfile::TempDir; +use tokio::time::sleep; + +/// How many recent blocks to scan for the inclusion event before giving up. +const INCLUSION_SCAN_BLOCKS: u32 = 60; +/// How long to wait between rescans while looking for the inclusion event. +const INCLUSION_POLL_INTERVAL_MS: u64 = 1_000; +/// Depth (best - depth) used as the "stable" proof block for the ZK Merkle +/// proof. Small enough that we don't wait for finality, large enough to +/// avoid most reorgs on a quiet dev chain. +const PROOF_BLOCK_DEPTH: u32 = 2; + +#[derive(Debug)] +struct Args { + node_url: String, + funder: String, + password: String, + amount_str: String, + keep_files: bool, +} + +fn parse_args() -> Args { + let mut node_url = "ws://127.0.0.1:9944".to_string(); + let mut funder = "crystal_alice".to_string(); + let mut password: Option = None; + let mut amount_str = "5".to_string(); + let mut keep_files = false; + + let mut it = env::args().skip(1); + while let Some(a) = it.next() { + match a.as_str() { + "--node" => node_url = it.next().expect("--node requires a value"), + s if s.starts_with("--node=") => node_url = s["--node=".len()..].to_string(), + "--funder" => funder = it.next().expect("--funder requires a value"), + s if s.starts_with("--funder=") => funder = s["--funder=".len()..].to_string(), + "--password" => password = Some(it.next().expect("--password requires a value")), + s if s.starts_with("--password=") => + password = Some(s["--password=".len()..].to_string()), + "--amount" => amount_str = it.next().expect("--amount requires a value"), + s if s.starts_with("--amount=") => amount_str = s["--amount=".len()..].to_string(), + "--keep-files" => keep_files = true, + "--help" | "-h" => { + print_usage(); + std::process::exit(0); + }, + other => { + eprintln!("Unknown argument: {other}"); + print_usage(); + std::process::exit(2); + }, + } + } + + let password = password + .or_else(|| env::var("QUANTUS_WALLET_PASSWORD").ok()) + .unwrap_or_default(); + + Args { node_url, funder, password, amount_str, keep_files } +} + +fn print_usage() { + eprintln!( + "Usage: cargo run --example wormhole_sdk_e2e -- [--node URL] \ + [--funder NAME] [--password PASS] [--amount DEV] [--keep-files]\n\ + \n\ + Defaults: --node ws://127.0.0.1:9944 --funder crystal_alice \ + --password (empty) --amount 5\n\ + The password also falls back to $QUANTUS_WALLET_PASSWORD." + ); +} + +#[tokio::main] +async fn main() -> Result<()> { + let args = parse_args(); + println!("Quantus wormhole SDK e2e example"); + println!("================================"); + println!(" node : {}", args.node_url); + println!(" funder : {}", args.funder); + println!(" amount : {} DEV", args.amount_str); + + let client = QuantusClient::new(&args.node_url).await?; + let amount_planck = parse_amount(&client, &args.amount_str).await?; + println!(" planck : {amount_planck}"); + + // 1. wallet ---------------------------------------------------------------- + let wm = WalletManager::new()?; + let wallet = wm.load_wallet(&args.funder, &args.password)?; + let funder_kp = wallet.keypair; + let funder_ss58 = funder_kp.to_account_id_ss58check(); + println!(" wallet : {funder_ss58}"); + + // 2. derive wormhole address from a random secret + random exit account --- + let mut rng = rand::rng(); + let mut secret = [0u8; 32]; + rng.fill_bytes(&mut secret); + let mut exit_account = [0u8; 32]; + rng.fill_bytes(&mut exit_account); + + let wh_addr = + compute_wormhole_address(&secret).map_err(|e| QuantusError::Generic(e.message))?; + let wh_ss58 = bytes_to_quantus_ss58(&wh_addr); + println!(); + println!("[1/9] secret + addresses"); + println!(" secret : 0x{}", hex::encode(secret)); + println!(" wh_addr : 0x{} ({wh_ss58})", hex::encode(wh_addr)); + println!(" exit_account: 0x{}", hex::encode(exit_account)); + + // 3. signed deposit -------------------------------------------------------- + println!(); + println!("[2/9] depositing {} planck -> wormhole address...", amount_planck); + let tx_hash = transfer( + &client, + &funder_kp, + &wh_ss58, + amount_planck, + None, + ExecutionMode { finalized: false, wait_for_transaction: true }, + ) + .await?; + println!(" deposit tx_hash: {:?}", tx_hash); + + // 4. find inclusion block + NativeTransferred event ------------------------ + println!(); + println!("[3/9] scanning recent blocks for NativeTransferred to {wh_ss58}..."); + let (block_hash, block_number, event) = wait_for_native_transferred(&client, &wh_addr).await?; + println!(" found in block #{block_number} ({:?})", block_hash); + println!(" transfer_count : {}", event.transfer_count); + println!(" leaf_index : {}", event.leaf_index); + println!(" amount : {} planck", event.amount); + + // 5. parse_transfer_events ------------------------------------------------- + println!(); + println!("[4/9] parse_transfer_events"); + let infos = parse_transfer_events( + std::slice::from_ref(&event), + std::slice::from_ref(&event.to), + block_hash, + )?; + let info = infos.first().expect("one event in -> one info out"); + println!( + " TransferInfo: block={:?} count={} leaf={} amount={}", + info.block_hash, info.transfer_count, info.leaf_index, info.amount + ); + + // 6. proof block + header + zk merkle proof -------------------------------- + // We want a stable block whose ZK trie already contains our leaf; it must + // therefore be on or after the deposit's block. We also wait a few blocks + // past the deposit so a tiny reorg won't invalidate the proof on us. + println!(); + let target_best = block_number + PROOF_BLOCK_DEPTH; + let best_number = wait_for_best_at_least(&client, target_best).await?; + let proof_number = best_number.saturating_sub(PROOF_BLOCK_DEPTH).max(block_number); + let proof_hash = fetch_block_hash(&client, proof_number).await?; + let header = fetch_header(&client, proof_hash).await?; + println!( + "[5/9] proof block: #{proof_number} (best #{best_number}, depth {PROOF_BLOCK_DEPTH}) {:?}", + proof_hash + ); + + let proof = get_zk_merkle_proof(&client, info.leaf_index, proof_hash).await?; + let (siblings, positions) = compute_merkle_positions(&proof.siblings, proof.leaf_hash); + println!(" zk root : 0x{}", hex::encode(proof.root)); + println!(" leaf_hash : 0x{}", hex::encode(proof.leaf_hash)); + println!(" siblings/levels: {}", siblings.len()); + + // Decode the leaf to extract the quantized input amount the circuit + // expects (raw_amount / SCALE_DOWN_FACTOR, capped to u32). + let (_to_dec, _tc_dec, asset_id, raw_amount) = decode_full_leaf_data(&proof.leaf_data)?; + assert_eq!(asset_id, NATIVE_ASSET_ID, "this example only handles native asset"); + let leaf_input_amount_quantized = (raw_amount / SCALE_DOWN_FACTOR) as u32; + let output_amount = + ((leaf_input_amount_quantized as u64) * (10_000 - VOLUME_FEE_BPS as u64) / 10_000) as u32; + println!( + " input(qz)={} output(qz)={} fee_bps={}", + leaf_input_amount_quantized, output_amount, VOLUME_FEE_BPS + ); + + // 7. generate the leaf ZK proof ------------------------------------------- + println!(); + println!("[6/9] generating leaf ZK proof (CPU-heavy, can take 10-60s)..."); + let bins_dir = bins::ensure_bins_dir()?; + let prover_bin = bins_dir.join("prover.bin"); + let common_bin = bins_dir.join("common.bin"); + + let pgi = ProofGenerationInput { + secret, + transfer_count: event.transfer_count, + wormhole_address: wh_addr, + input_amount: leaf_input_amount_quantized, + block_hash: header.block_hash, + block_number: header.block_number, + parent_hash: header.parent_hash, + state_root: header.state_root, + extrinsics_root: header.extrinsics_root, + digest: header.digest.clone(), + zk_tree_root: proof.root, + zk_merkle_siblings: siblings, + zk_merkle_positions: positions, + exit_account_1: exit_account, + exit_account_2: [0u8; 32], + output_amount_1: output_amount, + output_amount_2: 0, + volume_fee_bps: VOLUME_FEE_BPS, + asset_id: NATIVE_ASSET_ID, + }; + + let leaf_start = std::time::Instant::now(); + let leaf_result = wormhole_lib::generate_proof(&pgi, &prover_bin, &common_bin) + .map_err(|e| QuantusError::Generic(format!("generate_proof: {}", e.message)))?; + println!( + " leaf proof generated in {:.2}s ({} bytes)", + leaf_start.elapsed().as_secs_f64(), + leaf_result.proof_bytes.len() + ); + + // 8. write + aggregate ----------------------------------------------------- + println!(); + println!("[7/9] writing leaf proof + aggregating"); + let tmp = TempDir::new() + .map_err(|e| QuantusError::Generic(format!("Failed to create temp dir: {e}")))?; + let work_dir = if args.keep_files { + let p = std::env::temp_dir().join(format!("quantus-sdk-e2e-{}", std::process::id())); + std::fs::create_dir_all(&p) + .map_err(|e| QuantusError::Generic(format!("Failed to create work dir {p:?}: {e}")))?; + println!(" --keep-files: writing to {p:?}"); + p + } else { + tmp.path().to_path_buf() + }; + + let leaf_path = work_dir.join("leaf_0.hex"); + write_proof_file(leaf_path.to_str().unwrap(), &leaf_result.proof_bytes) + .map_err(QuantusError::Generic)?; + let agg_path = work_dir.join("agg.hex"); + let agg_start = std::time::Instant::now(); + aggregate_proofs( + vec![leaf_path.to_string_lossy().into_owned()], + agg_path.to_string_lossy().into_owned(), + ) + .await?; + println!(" aggregated in {:.2}s -> {:?}", agg_start.elapsed().as_secs_f64(), agg_path); + + // 9. verify + submit ------------------------------------------------------- + println!(); + println!("[8/9] verify_aggregated_and_get_events (off-chain verify + on-chain submit)"); + let verify_start = std::time::Instant::now(); + let (mint_block, mint_tx, transfers) = + verify_aggregated_and_get_events(agg_path.to_str().unwrap(), &client).await?; + println!(" verified+included in {:.2}s", verify_start.elapsed().as_secs_f64()); + println!(" mint block : {:?}", mint_block); + println!(" mint tx : {:?}", mint_tx); + + println!(); + println!("[9/9] minted NativeTransferred events:"); + if transfers.is_empty() { + println!(" (none — verify_aggregated_proof did not emit any events?)"); + } + for (i, ev) in transfers.iter().enumerate() { + let to_ss58 = bytes_to_quantus_ss58(&ev.to.0); + let to_match = if ev.to.0 == exit_account { " (== exit_account)" } else { "" }; + println!( + " [{i}] -> {} ({}){} amount={} planck transfer_count={} leaf_index={}", + to_ss58, + hex::encode(ev.to.0), + to_match, + ev.amount, + ev.transfer_count, + ev.leaf_index + ); + } + + println!(); + println!("Done."); + Ok(()) +} + +/// Small helper for the deposit's block header data needed by the prover. +struct HeaderBits { + parent_hash: [u8; 32], + state_root: [u8; 32], + extrinsics_root: [u8; 32], + block_number: u32, + block_hash: [u8; 32], + digest: Vec, +} + +async fn current_best_number(client: &QuantusClient) -> Result { + let best = client.get_latest_block().await?; + let block = client + .client() + .blocks() + .at(best) + .await + .map_err(|e| QuantusError::NetworkError(format!("blocks().at(best): {e:?}")))?; + Ok(block.header().number) +} + +/// Block until the chain's best block reaches `target` (with a small timeout). +async fn wait_for_best_at_least(client: &QuantusClient, target: u32) -> Result { + let start = std::time::Instant::now(); + loop { + let best = current_best_number(client).await?; + if best >= target { + return Ok(best); + } + if start.elapsed() > Duration::from_secs(60) { + return Err(QuantusError::Generic(format!( + "Timed out waiting for best block #{target}, still at #{best} after 60s" + ))); + } + sleep(Duration::from_millis(500)).await; + } +} + +async fn fetch_block_hash(client: &QuantusClient, n: u32) -> Result { + use jsonrpsee::core::client::ClientT; + let h: Option = client + .rpc_client() + .request("chain_getBlockHash", [n]) + .await + .map_err(|e| QuantusError::NetworkError(format!("chain_getBlockHash({n}): {e:?}")))?; + h.ok_or_else(|| QuantusError::Generic(format!("Block #{n} has no hash"))) +} + +async fn fetch_header(client: &QuantusClient, hash: H256) -> Result { + let block = client + .client() + .blocks() + .at(hash) + .await + .map_err(|e| QuantusError::NetworkError(format!("blocks().at({hash:?}): {e:?}")))?; + let header = block.header(); + Ok(HeaderBits { + parent_hash: header.parent_hash.0, + state_root: header.state_root.0, + extrinsics_root: header.extrinsics_root.0, + block_number: header.number, + block_hash: hash.0, + digest: header.digest.encode(), + }) +} + +/// Polls recent blocks until it finds a `NativeTransferred` whose recipient +/// matches `wh_addr`, or runs out of blocks/time. +async fn wait_for_native_transferred( + client: &QuantusClient, + wh_addr: &[u8; 32], +) -> Result<(H256, u32, NativeTransferred)> { + use jsonrpsee::core::client::ClientT; + + let start = std::time::Instant::now(); + let timeout = Duration::from_millis( + (INCLUSION_POLL_INTERVAL_MS * INCLUSION_SCAN_BLOCKS as u64).max(60_000), + ); + let mut last_seen_best: u32 = 0; + + loop { + let best = client.get_latest_block().await?; + let best_number = client + .client() + .blocks() + .at(best) + .await + .map_err(|e| QuantusError::NetworkError(format!("blocks().at(best): {e:?}")))? + .header() + .number; + let lower = best_number.saturating_sub(INCLUSION_SCAN_BLOCKS); + let scan_from = last_seen_best.max(lower); + + for n in scan_from..=best_number { + let hash: Option = + client.rpc_client().request("chain_getBlockHash", [n]).await.map_err(|e| { + QuantusError::NetworkError(format!("chain_getBlockHash({n}): {e:?}")) + })?; + let Some(block_hash) = hash else { continue }; + let events = match client.client().events().at(block_hash).await { + Ok(e) => e, + Err(_) => continue, + }; + for ev in events.find::().flatten() { + if &ev.to.0 == wh_addr { + return Ok((block_hash, n, ev)); + } + } + } + last_seen_best = best_number; + + if start.elapsed() > timeout { + return Err(QuantusError::Generic(format!( + "Timed out waiting for NativeTransferred to 0x{} after {:.1}s", + hex::encode(wh_addr), + start.elapsed().as_secs_f64() + ))); + } + sleep(Duration::from_millis(INCLUSION_POLL_INTERVAL_MS)).await; + } +} diff --git a/examples/wormhole_sdk_usage.rs b/examples/wormhole_sdk_usage.rs new file mode 100644 index 0000000..d96eef2 --- /dev/null +++ b/examples/wormhole_sdk_usage.rs @@ -0,0 +1,345 @@ +//! End-to-end wormhole SDK example. +//! +//! Shows how a downstream crate (e.g. `stress-test`) consumes the wormhole +//! helpers re-exported from `quantus_cli`. The example is split into two +//! parts: +//! +//! 1. **Offline** — deterministic, no node required. Computes a wormhole address from a secret, +//! decodes a SCALE-encoded `ZkLeaf`, exercises the proof-file round trip and demonstrates +//! `IncludedAt`/`TransferInfo` formatting. Always runs. +//! 2. **Online (read-only)** — connects to a node and exercises *real* on-chain primitives without +//! submitting anything: `at_best_block`, scans recent blocks for a real `NativeTransferred` +//! event, runs `parse_transfer_events`, fetches the ZK Merkle proof via `get_zk_merkle_proof`, +//! computes positions with `compute_merkle_positions` and decodes the leaf bytes with +//! `decode_full_leaf_data`. The submission path (`submit_unsigned_verify_aggregated_proof`, +//! `verify_aggregated_and_get_events`) is shown only as pseudocode — it requires a funded +//! deposit + ZK proof generation, which [`wormhole_sdk_e2e.rs`](./wormhole_sdk_e2e.rs) +//! demonstrates end-to-end. If the node is unreachable the example exits cleanly with hints; CI +//! without a node still builds and runs it green. +//! +//! Typical full flow (for context, not executed here): +//! +//! ```text +//! DEPOSIT (signed `balances.transfer_allow_death` to wormhole address) +//! │ +//! ▼ +//! collect events ──► parse_transfer_events ──► Vec +//! │ +//! ▼ +//! for each TransferInfo: +//! get_zk_merkle_proof(client, block_hash, leaf_index) → siblings + positions +//! wormhole_lib::generate_proof(...) → leaf proof bytes +//! │ +//! ▼ +//! aggregate_proofs(leaf_files, "agg.hex") → aggregated proof file +//! │ +//! ▼ +//! verify_aggregated_and_get_events("agg.hex", &client) +//! │ (locally verifies + submits unsigned `verify_aggregated_proof` +//! │ + waits for inclusion + collects NativeTransferred events) +//! ▼ +//! Vec with the minted amounts at the exit accounts +//! ``` +//! +//! Run: +//! ```bash +//! cargo run --example wormhole_sdk_usage +//! cargo run --example wormhole_sdk_usage -- ws://127.0.0.1:9944 +//! ``` + +use std::env; + +use codec::Encode; +use quantus_cli::{ + // All wormhole helpers re-exported by `quantus_cli` for SDK use: + at_best_block, + chain::{client::QuantusClient, quantus_subxt::api::wormhole}, + compute_merkle_positions, + compute_wormhole_address, + decode_full_leaf_data, + error::Result, + get_zk_merkle_proof, + parse_transfer_events, + read_proof_file, + wormhole_lib::{self, NATIVE_ASSET_ID}, + write_proof_file, + IncludedAt, + NativeTransferred, + TransferInfo, +}; +use subxt::utils::AccountId32 as SubxtAccountId; + +/// How many recent blocks to scan when looking for an existing +/// `NativeTransferred` event. Keep this small to stay snappy on a fresh node. +const RECENT_BLOCKS_TO_SCAN: u32 = 200; + +/// Tiny `--node ` parser so we don't pull in clap for a single argument. +/// Also accepts a bare URL as the first positional argument for backwards +/// compatibility with `cargo run --example wormhole_sdk_usage -- ws://...`. +fn parse_node_arg() -> Option { + let mut args = env::args().skip(1); + while let Some(a) = args.next() { + match a.as_str() { + "--node" => return args.next(), + s if s.starts_with("--node=") => return Some(s["--node=".len()..].to_string()), + s if s.starts_with("ws://") || s.starts_with("wss://") => return Some(s.to_string()), + _ => continue, + } + } + None +} + +#[tokio::main] +async fn main() -> Result<()> { + println!("Quantus wormhole SDK example"); + println!("============================"); + + offline_demo()?; + online_demo().await?; + + println!(); + println!("Done. See `examples/wormhole_sdk_usage.rs` for the full SDK API surface."); + Ok(()) +} + +/// Deterministic, no-network demonstration of the wormhole helpers. +fn offline_demo() -> Result<()> { + println!(); + println!("--- Offline ---"); + + // 1) Address derivation. + // + // `compute_wormhole_address` is `Poseidon2(Poseidon2(secret))`. The first + // hash is the "inner_hash" used by `quantus-node --rewards-inner-hash`; + // the second hash is the unspendable account id where rewards land. + let secret: [u8; 32] = [42u8; 32]; + let address = compute_wormhole_address(&secret).expect("compute_wormhole_address"); + println!(" secret : 0x{}", hex::encode(secret)); + println!(" address : 0x{}", hex::encode(address)); + + // 2) ZkLeaf decoding. + // + // The on-chain ZK trie stores SCALE-encoded `(AccountId32, u64, u32, u128)` + // tuples. A real leaf comes from the `zkTree_getMerkleProof` RPC; here we + // synthesise one to show the decode round trip. + let leaf = ( + SubxtAccountId::from(address), + 7u64, // transfer_count + NATIVE_ASSET_ID, // asset_id + 1_234_567_890_000u128, // raw planck (12 decimals) + ); + let leaf_bytes = leaf.encode(); + let (to, transfer_count, asset_id, raw_amount) = decode_full_leaf_data(&leaf_bytes)?; + println!( + " leaf : to=0x{} transfer_count={} asset_id={} raw_amount={} planck", + hex::encode(to), + transfer_count, + asset_id, + raw_amount, + ); + let quantized = wormhole_lib::quantize_amount(raw_amount) + .map_err(|e| quantus_cli::error::QuantusError::Generic(e.message))?; + println!(" quantized (2 decimals): {quantized}"); + + // 3) Proof file round trip. SDKs that already have proof bytes in memory can persist them with + // `write_proof_file` and pick them up later with `read_proof_file` (hex-encoded format + // compatible with the CLI). + let tmp = std::env::temp_dir().join("wormhole-sdk-demo.hex"); + let dummy_proof: Vec = (0u8..32).collect(); + write_proof_file(tmp.to_str().unwrap(), &dummy_proof) + .map_err(quantus_cli::error::QuantusError::Generic)?; + let read_back = read_proof_file(tmp.to_str().unwrap()) + .map_err(quantus_cli::error::QuantusError::Generic)?; + assert_eq!(read_back, dummy_proof); + let _ = std::fs::remove_file(&tmp); + println!(" proof : write+read round trip OK ({} bytes)", read_back.len()); + + // 4) IncludedAt Display impl. + for v in [IncludedAt::Best, IncludedAt::Finalized] { + println!(" IncludedAt::{:?} -> {}", v, v); + } + + // 5) Building a TransferInfo by hand. In production this is produced by + // `parse_transfer_events(&[NativeTransferred], &[expected_addrs], block_hash)`. + let info = TransferInfo { + block_hash: subxt::utils::H256::zero(), + transfer_count, + amount: raw_amount, + wormhole_address: SubxtAccountId::from(address), + funding_account: SubxtAccountId::from([0u8; 32]), + leaf_index: 0, + }; + println!( + " TransferInfo: block={:?} transfer_count={} leaf_index={} amount={} planck", + info.block_hash, info.transfer_count, info.leaf_index, info.amount, + ); + + // 6) Show that `NativeTransferred` is reachable from the SDK without drilling into + // `chain::quantus_subxt::api::wormhole::events::*`. + let _zero_event_type: Option = None; + + Ok(()) +} + +/// Online demonstration. Best-effort: if there's no node, log and return Ok(()). +async fn online_demo() -> Result<()> { + println!(); + println!("--- Online ---"); + + let node_url = parse_node_arg().unwrap_or_else(|| "ws://127.0.0.1:9944".to_string()); + println!(" Trying node: {node_url}"); + + let client = match QuantusClient::new(&node_url).await { + Ok(c) => c, + Err(e) => { + println!(" No node reachable ({e}); skipping online section."); + print_online_recipe(); + return Ok(()); + }, + }; + + let block = at_best_block(&client).await?; + let header = block.header(); + let best_number = header.number; + let best_hash = block.hash(); + println!(" Best block: #{} {:?}", best_number, best_hash); + println!(" Parent : {:?}", header.parent_hash); + + // Read-only on-chain demo: scan recent blocks for a real NativeTransferred, + // then exercise parse_transfer_events + get_zk_merkle_proof + decode. + match find_recent_native_transferred(&client, best_number).await? { + Some((event_block_hash, event)) => { + scan_real_event(&client, event_block_hash, event).await?; + }, + None => { + println!(); + println!( + " No NativeTransferred event found in the last {RECENT_BLOCKS_TO_SCAN} blocks." + ); + println!( + " This is normal on a fresh dev chain. Run examples/wormhole_sdk_e2e.rs first" + ); + println!(" (it submits a deposit + verify_aggregated_proof) to populate the chain."); + }, + } + + print_online_recipe(); + Ok(()) +} + +/// Walk back from `best_number` and return the first `NativeTransferred` event +/// we find together with its block hash. `None` if none in the window. +async fn find_recent_native_transferred( + client: &QuantusClient, + best_number: u32, +) -> Result> { + use jsonrpsee::core::client::ClientT; + + let lower = best_number.saturating_sub(RECENT_BLOCKS_TO_SCAN); + println!(); + println!(" Scanning blocks #{lower}..=#{best_number} for NativeTransferred..."); + + for n in (lower..=best_number).rev() { + // chain_getBlockHash(n) -> H256 + let hash: Option = + client.rpc_client().request("chain_getBlockHash", [n]).await.map_err(|e| { + quantus_cli::error::QuantusError::NetworkError(format!( + "chain_getBlockHash({n}): {e:?}" + )) + })?; + let Some(block_hash) = hash else { continue }; + + let events = match client.client().events().at(block_hash).await { + Ok(e) => e, + Err(_) => continue, + }; + let first = events.find::().flatten().next(); + if let Some(ev) = first { + println!(" Found NativeTransferred in block #{n} ({:?})", block_hash); + return Ok(Some((block_hash, ev))); + } + } + Ok(None) +} + +/// Run the real-data section of the demo against a known on-chain event. +async fn scan_real_event( + client: &QuantusClient, + event_block_hash: subxt::utils::H256, + event: NativeTransferred, +) -> Result<()> { + let to_addr = event.to.0; + let leaf_index = event.leaf_index; + + // 1) parse_transfer_events on the real event --------------------------- + let infos: Vec = + parse_transfer_events(&[event], &[SubxtAccountId::from(to_addr)], event_block_hash)?; + let info = infos.first().expect("parse_transfer_events returned the input event"); + println!(); + println!(" parse_transfer_events ->"); + println!(" block_hash : {:?}", info.block_hash); + println!(" transfer_count : {}", info.transfer_count); + println!(" leaf_index : {}", info.leaf_index); + println!(" amount (planck) : {}", info.amount); + println!(" funding_account : {:?}", info.funding_account); + println!(" wormhole_addr : 0x{}", hex::encode(to_addr)); + + // 2) ZK Merkle proof -- the proof is taken at the *current* best block because the trie's root + // advances as new leaves are added; using the deposit's own block would give us a stale + // proof. + let proof_block = client.get_latest_block().await?; + let proof = match get_zk_merkle_proof(client, leaf_index, proof_block).await { + Ok(p) => p, + Err(e) => { + println!(" get_zk_merkle_proof failed (zkTree RPC may be disabled): {e}"); + return Ok(()); + }, + }; + println!(); + println!(" get_zk_merkle_proof ->"); + println!(" leaf_index : {}", proof.leaf_index); + println!(" leaf_hash : 0x{}", hex::encode(proof.leaf_hash)); + println!(" root : 0x{}", hex::encode(proof.root)); + println!(" depth : {}", proof.depth); + println!(" siblings : {} levels", proof.siblings.len()); + + // 3) Sort siblings + compute position hints (what the circuit expects). + let (sorted, positions) = compute_merkle_positions(&proof.siblings, proof.leaf_hash); + println!(" compute_merkle_positions ->"); + println!(" sorted siblings: {} levels", sorted.len()); + println!(" positions : {:?}", positions.iter().take(8).copied().collect::>()); + + // 4) Decode the leaf bytes with the public helper. + let (to, transfer_count, asset_id, raw_amount) = decode_full_leaf_data(&proof.leaf_data)?; + let quantized = wormhole_lib::quantize_amount(raw_amount) + .map_err(|e| quantus_cli::error::QuantusError::Generic(e.message))?; + println!(); + println!(" decode_full_leaf_data ->"); + println!(" to : 0x{}", hex::encode(to)); + println!(" transfer_count : {transfer_count}"); + println!(" asset_id : {asset_id} (NATIVE_ASSET_ID = {NATIVE_ASSET_ID})"); + println!(" raw amount : {raw_amount} planck"); + println!(" quantized : {quantized} (2 decimals)"); + assert_eq!(to, to_addr, "leaf 'to' must match the event recipient"); + Ok(()) +} + +/// Show the canonical SDK recipe for the on-chain side without actually +/// broadcasting anything. Helpful both with and without a node. +fn print_online_recipe() { + println!(); + println!("Typical SDK recipe (pseudo-code):"); + println!(" let bytes = std::fs::read(\"agg.hex\")?;"); + println!(" let bytes = hex::decode(bytes.trim_ascii())?;"); + println!(" let (included_at, block_hash, tx_hash) ="); + println!(" submit_unsigned_verify_aggregated_proof(&client, bytes).await?;"); + println!(" println!(\"included @ {{}} block={{:?}} tx={{:?}}\","); + println!(" included_at, block_hash, tx_hash);"); + println!(); + println!(" // Or, with local verify + event collection:"); + println!(" let (block_hash, tx_hash, transfers) ="); + println!(" verify_aggregated_and_get_events(\"agg.hex\", &client).await?;"); + println!(" for ev in transfers {{"); + println!(" println!(\" -> {{}} planck to {{}}\", ev.amount, ev.to.to_ss58check());"); + println!(" }}"); +} diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index 8e882c1..01231e9 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -1107,15 +1107,24 @@ fn show_wormhole_address(secret_hex: String) -> crate::error::Result<()> { Ok(()) } -async fn at_best_block( +/// Fetch the latest (best) block as a fully materialised subxt `Block`. +/// +/// Uses [`crate::error::Result`] (not `anyhow`) so it composes with the rest +/// of the SDK surface. Network/decoding failures are wrapped in +/// [`crate::error::QuantusError::NetworkError`]. +pub async fn at_best_block( quantus_client: &QuantusClient, -) -> anyhow::Result>> { +) -> crate::error::Result>> { let best_block = quantus_client.get_latest_block().await?; - let block = quantus_client.client().blocks().at(best_block).await?; + let block = quantus_client.client().blocks().at(best_block).await.map_err(|e| { + crate::error::QuantusError::NetworkError(format!( + "Failed to fetch best block {best_block:?}: {e:?}" + )) + })?; Ok(block) } -async fn aggregate_proofs( +pub async fn aggregate_proofs( proof_files: Vec, output_file: String, ) -> crate::error::Result<()> { @@ -1242,14 +1251,20 @@ async fn aggregate_proofs( Ok(()) } -#[derive(Debug, Clone, Copy)] -enum IncludedAt { +/// Where in the chain a submitted extrinsic has been observed. +/// +/// `Best` means it landed in the current best block (not yet finalised); +/// `Finalized` means it's in a block past the finality gadget. Returned by +/// [`submit_unsigned_verify_aggregated_proof`] alongside the block + tx hash. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IncludedAt { Best, Finalized, } impl IncludedAt { - fn label(self) -> &'static str { + /// Short human-readable label. Equivalent to [`std::fmt::Display`] output. + pub fn label(self) -> &'static str { match self { IncludedAt::Best => "best block", IncludedAt::Finalized => "finalized block", @@ -1257,6 +1272,12 @@ impl IncludedAt { } } +impl std::fmt::Display for IncludedAt { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.label()) + } +} + fn read_hex_proof_file_to_bytes(proof_file: &str) -> crate::error::Result> { let proof_hex = std::fs::read_to_string(proof_file).map_err(|e| { crate::error::QuantusError::Generic(format!("Failed to read proof file: {}", e)) @@ -1270,7 +1291,7 @@ fn read_hex_proof_file_to_bytes(proof_file: &str) -> crate::error::Result, ) -> crate::error::Result<(IncludedAt, subxt::utils::H256, subxt::utils::H256)> { @@ -1437,22 +1458,31 @@ async fn verify_aggregated_proof(proof_file: String, node_url: &str) -> crate::e // Multi-round wormhole flow implementation // ============================================================================ -/// Information about a transfer needed for proof generation +/// Information about a transfer needed for proof generation. +/// +/// Returned by [`parse_transfer_events`] for every confirmed deposit; carries +/// the on-chain identifiers (`block_hash`, `transfer_count`, `leaf_index`) +/// plus the amount and the source/destination accounts. SDK consumers feed +/// these into [`crate::wormhole_lib::generate_proof`] to build the ZK proof. +// +// `block_hash` and `wormhole_address` are read by SDK consumers (e.g. +// `stress-test`, downstream wormhole clients) but not by the CLI binary +// itself; rustc only sees the bin target's usage and would otherwise warn. #[derive(Debug, Clone)] #[allow(dead_code)] -struct TransferInfo { +pub struct TransferInfo { /// Block hash where the transfer was included - block_hash: subxt::utils::H256, + pub block_hash: subxt::utils::H256, /// Transfer count for this specific transfer - transfer_count: u64, + pub transfer_count: u64, /// Amount transferred - amount: u128, + pub amount: u128, /// The wormhole address (destination of transfer) - wormhole_address: SubxtAccountId, + pub wormhole_address: SubxtAccountId, /// The funding account (source of transfer) - funding_account: SubxtAccountId, + pub funding_account: SubxtAccountId, /// Index of this transfer in the ZK trie (for Merkle proof lookup) - leaf_index: u64, + pub leaf_index: u64, } /// Derive a wormhole secret using HD derivation @@ -1493,7 +1523,7 @@ async fn get_minting_account( /// Parse transfer info from NativeTransferred events in a block and updates block hash for all /// transfers -fn parse_transfer_events( +pub fn parse_transfer_events( events: &[wormhole::events::NativeTransferred], expected_addresses: &[SubxtAccountId], block_hash: subxt::utils::H256, @@ -2380,9 +2410,17 @@ fn decode_input_amount_from_leaf(leaf_data: &[u8]) -> crate::error::Result } /// Decode all fields from SCALE-encoded ZkLeaf data. -/// Returns (to_account, transfer_count, asset_id, raw_amount_u128) +/// +/// `ZkLeaf` is the on-chain leaf payload sitting in the ZK trie (`AccountId32`, +/// `u64`, `u32`, `u128`). Returned tuple is `(to_account, transfer_count, +/// asset_id, raw_amount_u128)` — the raw amount is in planck (12 decimals); +/// use [`crate::wormhole_lib::quantize_amount`] before feeding into a circuit. +// +// SDK helper: the CLI binary uses `decode_input_amount_from_leaf` for its own +// flow; this fuller decoder is exported for downstream consumers (the bin +// target itself doesn't reference it, hence `allow(dead_code)`). #[allow(dead_code)] -fn decode_full_leaf_data(leaf_data: &[u8]) -> crate::error::Result<([u8; 32], u64, u32, u128)> { +pub 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) @@ -2417,7 +2455,7 @@ fn decode_full_leaf_data(leaf_data: &[u8]) -> crate::error::Result<([u8; 32], u6 } /// Verify an aggregated proof and return the block hash, extrinsic hash, and transfer events -async fn verify_aggregated_and_get_events( +pub async fn verify_aggregated_and_get_events( proof_file: &str, quantus_client: &QuantusClient, ) -> crate::error::Result<( @@ -2441,8 +2479,8 @@ async fn verify_aggregated_and_get_events( e )) })?; - println!( - "[quantus-cli] Circuit binaries: common_bytes.len={}, verifier_bytes.len={}, common_hash={}, verifier_hash={}", + log_verbose!( + "Circuit binaries: common_bytes.len={}, verifier_bytes.len={}, common_hash={}, verifier_hash={}", common_bytes.len(), verifier_bytes.len(), hex::encode(blake3::hash(&common_bytes).as_bytes()), diff --git a/src/lib.rs b/src/lib.rs index a07f455..998e595 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -48,6 +48,22 @@ pub use wormhole_lib::{ ProofGenerationOutput, WormholeLibError, NATIVE_ASSET_ID, SCALE_DOWN_FACTOR, VOLUME_FEE_BPS, }; +// Re-export wormhole on-chain helpers for SDK usage. +// These are the on-chain side of the wormhole flow (proof aggregation, unsigned +// `verify_aggregated_proof` submission, transfer-event parsing, leaf decoding) +// that complement the off-chain proof-generation functions in `wormhole_lib`. +// +// `NativeTransferred` is the subxt-generated event type required by +// `parse_transfer_events`; we re-export it so SDK callers don't have to reach +// into `chain::quantus_subxt::api::wormhole::events::*`. +pub use chain::quantus_subxt::api::wormhole::events::NativeTransferred; +pub use cli::wormhole::{ + aggregate_proofs, at_best_block, compute_merkle_positions, decode_full_leaf_data, + get_zk_merkle_proof, parse_transfer_events, read_proof_file, + submit_unsigned_verify_aggregated_proof, verify_aggregated_and_get_events, write_proof_file, + IncludedAt, TransferInfo, +}; + // Re-export collect rewards library for SDK usage pub use collect_rewards_lib::{ collect_rewards, query_pending_transfers, query_pending_transfers_for_address, From cd13c7d6dbd5ab391b25e49c40b54df3efb3b3ba Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Thu, 30 Apr 2026 08:16:04 +0800 Subject: [PATCH 2/3] fix: Security update: rustls-webpki v0.103.12 -> v0.103.13 --- Cargo.lock | 4 ++-- Cargo.toml | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index add5cc9..733b618 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4401,9 +4401,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.12" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", diff --git a/Cargo.toml b/Cargo.toml index a7b8668..6602315 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,8 +64,9 @@ bytes = "1.11.1" # Force patched version of quinn-proto (RUSTSEC-2026-0037) quinn-proto = "0.11.14" -# Force patched version of rustls-webpki (RUSTSEC-2026-0098, RUSTSEC-2026-0099) -rustls-webpki = "0.103.12" +# Force patched version of rustls-webpki +# (RUSTSEC-2026-0098, RUSTSEC-2026-0099, RUSTSEC-2026-0104) +rustls-webpki = "0.103.13" # Blockchain deps: align with chain workspace; use chain primitives for qp-dilithium-crypto so sp-* versions match codec = { package = "parity-scale-codec", version = "3.7", features = ["derive"] } From a3224a673e00b917eea218ce802e5bb612ceadb4 Mon Sep 17 00:00:00 2001 From: Cezary Olborski Date: Thu, 30 Apr 2026 12:59:33 +0800 Subject: [PATCH 3/3] fix: Review - duplicate removed --- src/collect_rewards_lib.rs | 55 +++----------------------------------- 1 file changed, 3 insertions(+), 52 deletions(-) diff --git a/src/collect_rewards_lib.rs b/src/collect_rewards_lib.rs index eb601ed..cb0b38c 100644 --- a/src/collect_rewards_lib.rs +++ b/src/collect_rewards_lib.rs @@ -16,7 +16,9 @@ use crate::{ client::QuantusClient, quantus_subxt::{self as quantus_node, api::wormhole}, }, - cli::wormhole::{parse_secret_hex as parse_secret_hex_str, ZkMerkleProofRpc}, + cli::wormhole::{ + compute_merkle_positions, parse_secret_hex as parse_secret_hex_str, ZkMerkleProofRpc, + }, subsquid::{ compute_address_hash, get_hash_prefix, SubsquidClient, Transfer, TransferQueryParams, }, @@ -24,57 +26,6 @@ use crate::{ wormhole_lib::{compute_output_amount, NATIVE_ASSET_ID, VOLUME_FEE_BPS}, }; -type Hash256 = [u8; 32]; - -/// Compute sorted siblings and position hints from unsorted siblings. -/// -/// The chain returns unsorted siblings. This function sorts them and computes -/// position hints that indicate where the current hash fits in the sorted order. -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.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]]; - - // 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 - current_hash = hash_node_presorted(&all_four); - } - - (sorted_siblings, positions) -} use plonky2::plonk::proof::ProofWithPublicInputs; use qp_rusty_crystals_hdwallet::{derive_wormhole_from_mnemonic, QUANTUS_WORMHOLE_CHAIN_ID}; use qp_wormhole_aggregator::{