Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ qp-poseidon = { version = "1.4.0", default-features = false }
qp-poseidon-core = { version = "1.4.0", default-features = false }
qp-rusty-crystals-dilithium = { version = "2.4.0", default-features = false }
qp-rusty-crystals-hdwallet = { version = "2.3.1" }
qp-wormhole-aggregator = { version = "2.0.1", default-features = false }
qp-wormhole-circuit = { version = "2.0.1", default-features = false }
qp-wormhole-circuit-builder = { version = "2.0.1", default-features = false }
qp-wormhole-prover = { version = "2.0.1", default-features = false }
Expand Down
44 changes: 30 additions & 14 deletions docs/wormhole-zk.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
# Wormhole ZK: Leaf, Layer‑0, Layer‑1

> Wormhole ZK: each leaf proof spends 1 nullifier, pays up to 2 exits; layer‑0 aggregates 16 leaves (pads with dummies); layer‑1 batches L0 proofs server‑side. Chain currently verifies only L0.
> Wormhole ZK: each leaf proof spends 1 nullifier, pays up to 2 exits; layer‑0 aggregates `N` leaves (pads with dummies); the chain accepts multiple aggregation sizes simultaneously (default: `N ∈ {2, 16}`); layer‑1 batches L0 proofs server‑side.

The wormhole flow has three proof levels. Today the runtime only verifies L0;
L1 is built in the external `qp-wormhole-aggregator` crate but is **not** wired
into `pallet-wormhole` on the current `main`.
The wormhole flow has three proof levels. The runtime verifies L0 against one
of several baked‑in verifiers, picked at call time via a `num_leaf_proofs`
argument on the extrinsic. L1 is built in the external `qp-wormhole-aggregator`
crate but is **not** wired into `pallet-wormhole` on the current `main`.

| Level | Produced by | Inputs | Outputs | Verified by chain? |
|------:|-------------|--------|---------|--------------------|
| Leaf | Client (per transfer) | 1 nullifier (1 spend) | Up to 2 exit accounts (spend + change) | No |
| L0 | Client (aggregator) | Up to `N = 16` leaves (rest = dummies) | `2·N = 32` exit slots, `N = 16` nullifiers | **Yes** |
| L0 | Client (aggregator) | Up to `N` leaves (rest = dummies). `N` is one of `pallet_wormhole::SUPPORTED_NUM_LEAF_PROOFS`. | `2·N` exit slots, `N` nullifiers | **Yes** |
| L1 | Server / delegated aggregator | `n_inner` full L0 proofs (no padding) | `n_inner · 2N` exit slots, `n_inner · N` nullifiers | **No** (not enabled) |

---
Expand Down Expand Up @@ -44,8 +45,17 @@ Source: `qp-wormhole-circuit/src/{circuit.rs,zk_merkle_proof.rs}` and

`Layer0Aggregator` in `qp-wormhole-aggregator/src/aggregator.rs` and the
monolithic circuit in `src/layer0/circuit/circuit_logic.rs`. Built into the
pallet by `pallets/wormhole/build.rs`; `N = num_leaf_proofs = 16` by default
(override with the `QP_NUM_LEAF_PROOFS` env var at build time).
pallet by `pallets/wormhole/build.rs`. The pallet bakes in **one verifier per
supported `N`** simultaneously. The set of supported sizes is read from the
build environment:

- `QP_NUM_LEAF_PROOFS_LIST=2,16` — comma‑separated list (recommended)
- `QP_NUM_LEAF_PROOFS=N` — legacy single‑value fallback

Default if neither is set: `2,16`. `N = 2` keeps the prover under ~1 GB of
peak memory (mobile‑friendly); `N = 16` provides the strongest privacy
(largest anonymity set per submitted batch). Clients pick which one to
produce based on their device capabilities; the chain accepts both.

What the L0 circuit does:

Expand Down Expand Up @@ -76,12 +86,17 @@ be zero).

### On‑chain verification

`pallet_wormhole::verify_aggregated_proof` (`pallets/wormhole/src/lib.rs`):

1. `validate_proof`: deserialize, parse PIs, check `asset_id == 0`,
`volume_fee_bps` matches `T::VolumeFeeRateBps::get()`, `block_hash` matches
the on‑chain header at `block_number`, no nullifier already in
`UsedNullifiers`, then run full plonky2 verification.
`pallet_wormhole::verify_aggregated_proof(origin, proof_bytes, num_leaf_proofs)`
(`pallets/wormhole/src/lib.rs`):

1. `validate_proof`: look up the verifier for `num_leaf_proofs` (rejects with
`AggregatedVerifierNotAvailable` if no verifier was baked in for that `N`),
deserialize, parse PIs, check the parsed PI layout matches the claimed
`N` (defense‑in‑depth — the matching `common_data` already enforces this
via deserialization), check `asset_id == 0`, `volume_fee_bps` matches
`T::VolumeFeeRateBps::get()`, `block_hash` matches the on‑chain header
at `block_number`, no nullifier already in `UsedNullifiers`, then run
full plonky2 verification.
2. Mark each L0 nullifier used.
3. Walk the `2·N` exit slots, skipping any with `exit == [0;32]` or `sum == 0`
(covers dummies + dedup'd slots).
Expand Down Expand Up @@ -140,7 +155,8 @@ embeds the L0 wrapper verifier. Enabling L1 would require:
| Leaf PI length (21) | `qp-wormhole-inputs/src/lib.rs` (`PUBLIC_INPUTS_FELTS_LEN`) |
| L0 wrapper PI layout | `qp-wormhole-aggregator/src/layer0/circuit/constants.rs` |
| L1 wrapper PI layout | `qp-wormhole-aggregator/src/layer1/circuit/constants.rs` |
| `N = num_leaf_proofs` (default 16) | `pallets/wormhole/build.rs` (`QP_NUM_LEAF_PROOFS`) |
| Supported `N` set (default `{2, 16}`) | `pallets/wormhole/build.rs` (`QP_NUM_LEAF_PROOFS_LIST`) |
| Runtime accessor for the supported set | `pallet_wormhole::SUPPORTED_NUM_LEAF_PROOFS` |
| Embedded verifier bytes | `pallets/wormhole/src/lib.rs` (`AGGREGATED_VERIFIER`) |
| On‑chain verify entrypoint | `pallet_wormhole::verify_aggregated_proof` |
| Amount scale (10^10) | `pallets/wormhole/src/lib.rs` (`SCALE_DOWN_FACTOR`) |
Expand Down
1 change: 1 addition & 0 deletions pallets/wormhole/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ sp-runtime.workspace = true
[build-dependencies]
hex.workspace = true
qp-poseidon-core.workspace = true
qp-wormhole-aggregator = { workspace = true, features = ["std"] }
qp-wormhole-circuit-builder.workspace = true

[dev-dependencies]
Expand Down
150 changes: 116 additions & 34 deletions pallets/wormhole/build.rs
Original file line number Diff line number Diff line change
@@ -1,24 +1,33 @@
//! Build script for pallet-wormhole.
//!
//! Generates circuit binaries (aggregated_verifier.bin, aggregated_common.bin) at build time.
//! This ensures the binaries are always consistent with the circuit crate version and
//! eliminates the need to commit large binary files to the repository.
//! Generates circuit binaries (leaf + one aggregated verifier per supported
//! `num_leaf_proofs`) at build time. Binaries are embedded into the runtime
//! via `include_bytes!` in `src/lib.rs` (through the generated
//! `verifiers_manifest.rs` file in `OUT_DIR`).
//!
//! Note: Circuit generation cannot be skipped for this pallet because the binaries are
//! embedded at compile time via `include_bytes!`.
//! Supported aggregation sizes are read from the environment:
//!
//! - `QP_NUM_LEAF_PROOFS_LIST=2,16` (preferred): comma-separated list.
//! - `QP_NUM_LEAF_PROOFS=N` (legacy): single value, treated as `[N]`.
//!
//! Default if neither is set: `2,16` (low-memory mobile + max-privacy desktop).

use std::{
env, fs,
path::{Path, PathBuf},
time::Instant,
};

use std::{env, path::Path, time::Instant};
const DEFAULT_SUPPORTED: &str = "2,16";

/// Compute Poseidon2 hash of bytes and return hex string
fn poseidon_hex(data: &[u8]) -> String {
let hash = qp_poseidon_core::hash_bytes(data);
hex::encode(&hash[..16]) // first 16 bytes for shorter display
hex::encode(&hash[..16])
}

/// Print hash of a generated binary file
fn print_bin_hash(dir: &Path, filename: &str) {
let path = dir.join(filename);
if let Ok(data) = std::fs::read(&path) {
if let Ok(data) = fs::read(&path) {
println!(
"cargo:warning= {}: {} bytes, hash: {}",
filename,
Expand All @@ -28,41 +37,114 @@ fn print_bin_hash(dir: &Path, filename: &str) {
}
}

fn parse_supported_list() -> Vec<usize> {
if let Ok(list) = env::var("QP_NUM_LEAF_PROOFS_LIST") {
return list
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| {
s.parse::<usize>()
.expect("QP_NUM_LEAF_PROOFS_LIST entries must be positive integers")
})
.collect();
}
if let Ok(single) = env::var("QP_NUM_LEAF_PROOFS") {
return vec![single
.parse()
.expect("QP_NUM_LEAF_PROOFS must be a positive integer")];
}
DEFAULT_SUPPORTED
.split(',')
.map(|s| s.parse::<usize>().expect("default supported list parse"))
.collect()
}

fn rename_agg_outputs(out_path: &Path, n: usize) {
for stem in ["aggregated_common", "aggregated_verifier"] {
let src = out_path.join(format!("{}.bin", stem));
let dst = out_path.join(format!("{}_{}.bin", stem, n));
fs::rename(&src, &dst).unwrap_or_else(|e| {
panic!("failed to rename {:?} -> {:?}: {}", src, dst, e);
});
print_bin_hash(out_path, &format!("{}_{}.bin", stem, n));
}
}

fn write_manifest(out_path: &Path, supported: &[usize]) {
let mut buf = String::new();
buf.push_str("// Auto-generated by build.rs. Do not edit.\n\n");
buf.push_str("pub const SUPPORTED_NUM_LEAF_PROOFS: &[u32] = &[");
for (i, n) in supported.iter().enumerate() {
if i > 0 {
buf.push_str(", ");
}
buf.push_str(&n.to_string());
}
buf.push_str("];\n\n");

buf.push_str(
"pub fn load_supported_verifiers(\n\
) -> alloc::vec::Vec<(u32, qp_wormhole_verifier::WormholeVerifier)> {\n\
\x20 let mut out: alloc::vec::Vec<(u32, qp_wormhole_verifier::WormholeVerifier)> =\n\
\x20 alloc::vec::Vec::new();\n",
);
for n in supported {
buf.push_str(&format!(
" if let Ok(v) = qp_wormhole_verifier::WormholeVerifier::new_from_bytes(\n\
\x20 include_bytes!(\"aggregated_verifier_{n}.bin\"),\n\
\x20 include_bytes!(\"aggregated_common_{n}.bin\"),\n\
\x20 ) {{\n\
\x20 out.push(({n}u32, v));\n\
\x20 }}\n",
n = n
));
}
buf.push_str(" out\n}\n");

fs::write(out_path.join("verifiers_manifest.rs"), buf)
.expect("failed to write verifiers_manifest.rs");
}

fn main() {
let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set");
let num_leaf_proofs: usize = env::var("QP_NUM_LEAF_PROOFS")
.unwrap_or_else(|_| "16".to_string())
.parse()
.expect("QP_NUM_LEAF_PROOFS must be a valid usize");
let out_path = PathBuf::from(&out_dir);

let supported = parse_supported_list();
assert!(
!supported.is_empty(),
"at least one supported num_leaf_proofs is required"
);

// cargo:warning= messages are shown during build when the script runs
println!(
"cargo:warning=[pallet-wormhole] Generating ZK circuit binaries (num_leaf_proofs={})...",
num_leaf_proofs
"cargo:warning=[pallet-wormhole] generating ZK circuit binaries for num_leaf_proofs={:?}...",
supported
);

let start = Instant::now();

// Generate all circuit binaries (leaf + layer-0 aggregated, no prover, no layer-1)
qp_wormhole_circuit_builder::generate_all_circuit_binaries(
Path::new(&out_dir),
false, // include_prover = false
num_leaf_proofs,
None, // num_layer0_proofs - no layer-1 aggregation
)
.expect("Failed to generate circuit binaries");
qp_wormhole_circuit_builder::generate_circuit_binaries(&out_path, false)
.expect("failed to generate leaf circuit binaries");

for n in &supported {
qp_wormhole_aggregator::layer0::circuit::build::generate_layer0_circuit_binaries(
&out_path, *n, false,
)
.unwrap_or_else(|e| panic!("failed to build aggregation circuit for N={}: {}", n, e));
rename_agg_outputs(&out_path, *n);
}

let elapsed = start.elapsed();
println!(
"cargo:warning=[pallet-wormhole] ZK circuit binaries generated in {:.2}s",
elapsed.as_secs_f64()
start.elapsed().as_secs_f64()
);

// Print hashes of generated binaries
let out_path = Path::new(&out_dir);
print_bin_hash(out_path, "common.bin");
print_bin_hash(out_path, "verifier.bin");
print_bin_hash(out_path, "dummy_proof.bin");
print_bin_hash(out_path, "aggregated_common.bin");
print_bin_hash(out_path, "aggregated_verifier.bin");
print_bin_hash(&out_path, "common.bin");
print_bin_hash(&out_path, "verifier.bin");
print_bin_hash(&out_path, "dummy_proof.bin");

write_manifest(&out_path, &supported);

println!("cargo:rerun-if-env-changed=QP_NUM_LEAF_PROOFS");
println!("cargo:rerun-if-env-changed=QP_NUM_LEAF_PROOFS_LIST");
}
10 changes: 8 additions & 2 deletions pallets/wormhole/src/benchmarking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ use qp_wormhole_verifier::{ProofWithPublicInputs, C, F};
/// This proof is used to benchmark the actual deserialization and verification cost.
const AGGREGATED_PROOF_HEX: &str = include_str!("../test-data/aggregated.hex");

/// `num_leaf_proofs` the bundled benchmark proof was generated for. Worst case
/// for the verifier (largest aggregation circuit currently shipped).
const BENCH_NUM_LEAF_PROOFS: u32 = 16;

/// Maximum number of nullifiers in an aggregated proof (default aggregation size)
const MAX_NULLIFIERS: u32 = 32;

Expand All @@ -36,7 +40,8 @@ mod benchmarks {
hex::decode(AGGREGATED_PROOF_HEX.trim()).expect("Invalid hex in test proof");

// Get verifier for deserialization
let verifier = crate::get_aggregated_verifier().expect("Aggregated verifier not available");
let verifier = crate::get_aggregated_verifier(BENCH_NUM_LEAF_PROOFS)
.expect("Aggregated verifier not available");

// Setup: Create nullifiers in storage to simulate worst-case reads
let nullifiers: Vec<[u8; 32]> = (0..MAX_NULLIFIERS)
Expand Down Expand Up @@ -88,7 +93,8 @@ mod benchmarks {
hex::decode(AGGREGATED_PROOF_HEX.trim()).expect("Invalid hex in test proof");

// Get verifier
let verifier = crate::get_aggregated_verifier().expect("Aggregated verifier not available");
let verifier = crate::get_aggregated_verifier(BENCH_NUM_LEAF_PROOFS)
.expect("Aggregated verifier not available");

// Deserialize proof (outside the measured block since pre_validate_proof covers this)
let proof = ProofWithPublicInputs::<F, C, D>::from_bytes(
Expand Down
Loading
Loading