Wormhole sends in the browser: explore multithreaded WASM proving, or a slow prover with an engaging UX #13

Open
opened 2026-09-16 12:51:21 +00:00 by grenade · 0 comments
Owner

Deferred. For now wormhole accounts are read-only in the extension: derive the addresses and show the balance, no send. This issue records what was measured, what can't be done, and the two routes worth exploring before wormhole sends come to the extension.

Part of #1.

What a wormhole send has to prove

A wormhole address is not a key. The path m/44'/189189189'/n'/{0,1}'/k' yields a 32-byte secret; first_hash = poseidon(salt ‖ secret) and the address is poseidon(first_hash). Funds leave through an unsigned wormhole.verify_private_batch (ensure_none) carrying a ZK proof, in two layers (qp-wormhole-* 4.3.0, the version the chain pins):

  1. Leaf proof, one per transfer. Its private inputs are the secret, the ZK-tree Merkle path and the block header.
  2. Private-batch proof, aggregating up to N leaf proofs (padded with dummies).

Constraint: proving cannot be offloaded

qp-zk-circuits-common 4.3.0, circuit.rs:

  • wormhole_leaf_circuit_config() is standard_recursion_config() // zero_knowledge: false.
  • The private-batch config is documented as the one layer that requires zero-knowledge, because its witnesses are the leaf proofs, "whose own witnesses (spend secrets, Merkle paths) must never leak."

So whoever builds the private batch sees non-ZK leaf proofs and must be assumed able to spend. A hosted prover would be custody. Both layers run where the secret is, and the only layer that could safely leave the machine is the public batch above them, which isn't needed to spend.

Measurements (2026-09-16, one 20-core desktop)

One real leaf (genuine header hash over test-helpers' test_inputs_0) padded to a batch of 8. Browser runs are single-threaded WASM in a Web Worker, built with wasm-bindgen 0.2.128 (the @quantus/codec toolchain), getrandom wasm_js backends, and default-features = false (no rayon).

Step Chrome Firefox Native, 1 thread Native, 4 threads Native, 20 threads
Leaf proof 0.8s 0.9s 0.20s 0.09s 0.07s
PrivateBatchProver::new_from_bytes (rebuilds the circuit) 34s 34s 11.3s 5.8s 5.3s
aggregate 100s 108s 29.9s 11.4s 8.2s
Total 135s 142s 41.6s 17.3s 13.7s
Peak memory 1.76 GB WASM 1.76 GB WASM 2.2 GB RSS 2.1 GB RSS 2.2 GB RSS
  • WASM overhead: about 3.2× slower than native on one thread.
  • Proof sizes: leaf 100,732 bytes; private batch 157,732 bytes.
  • Circuit files: the leaf common.bin, verifier.bin and dummy_proof.bin total about 100 KB and can be embedded. Nothing large needs downloading.
  • Against the bar: that bar was a send in under 2 minutes while the UI shows what is happening. Single-threaded, it misses on fast hardware and would be worse on a typical laptop.

Note: the upstream aggregator bench at v4.3.0 doesn't run. It feeds the same leaf proof twice and trips the pairwise-distinct nullifier check, so its wall times are circuit generation only. The numbers above come from a separate harness.

Route A: multithreaded WASM

Natively, 4 threads gave 2.4× over one thread. If that carries over, this machine lands around 55–70s, and a 4-core laptop perhaps near 2 minutes. To find out:

  • Build with wasm-bindgen-rayon (or equivalent) on nightly with -Z build-std and +atomics,+bulk-memory, enabling the aggregator's multithread feature and plonky2's parallel.
  • SharedArrayBuffer needs cross-origin isolation. Chrome MV3 supports the cross_origin_embedder_policy / cross_origin_opener_policy manifest keys. Unknown whether Firefox extensions do; this is the first thing to establish, since without it Firefox stays single-threaded.
  • Run proving in an extension page's worker, not the MV3 background. The background has an idle lifetime, and the connection keep-alive (ebd3b4b2) only covers message traffic, not a 2-minute CPU job.
  • Memory stays about 1.8 GB whatever the thread count. Check behaviour on an 8 GB machine.

Route B: accept a slow prover, make the wait engaging

Even single-threaded, a send finishes; the question is whether a 2–5 minute wait is acceptable when the UI never looks stalled.

  • The work already splits into observable steps: fetch transfers → Merkle proofs → leaf proofs (under 1s each) → build batch prover (~34s) → aggregate (~100s) → submit → inclusion. Each can report progress and what it is doing.
  • new_from_bytes rebuilds the batch circuit (~34s). Keep it warm in the worker for the session, so later sends cost only aggregation.
  • The two long steps are single Rust calls with no progress callback. Honest progress within them needs either a hook in the aggregator or elapsed time against a calibrated estimate.
  • The tab has to stay open, and closing it has to be safe: no nullifier is spent until submission.

Where sends go meanwhile

Proving natively takes about 14s on this machine, so blackbeard/wallet (desktop, Tauri, in progress in a separate effort) is the proposed home for wormhole sends until this lands. Whether and when it supports them is tracked there, not here.

Reproducing

A harness crate added as a member of Quantus-Network/qp-zk-circuits at tag v4.3.0:

# wormhole/wasm-spike/Cargo.toml
[lib]
crate-type = ["cdylib"]

[dependencies]
anyhow = { workspace = true }
qp-plonky2 = { workspace = true }
wasm-bindgen = "=0.2.128"
getrandom02 = { package = "getrandom", version = "0.2", features = ["js"] }
getrandom04 = { package = "getrandom", version = "0.4", features = ["wasm_js"] }
test-helpers = { path = "../tests/test-helpers", default-features = false }
wormhole-aggregator = { package = "qp-wormhole-aggregator", path = "../aggregator", default-features = false, features = ["std"] }
wormhole-circuit = { package = "qp-wormhole-circuit", path = "../circuit", default-features = false, features = ["std"] }
wormhole-prover = { package = "qp-wormhole-prover", path = "../prover", default-features = false, features = ["std"] }
zk-circuits-common = { package = "qp-zk-circuits-common", path = "../../common" }
// wormhole/wasm-spike/src/lib.rs: one export per step, so the page times each
const LEAF_COMMON: &[u8] = include_bytes!("../../generated-bins/common.bin");
const LEAF_VERIFIER: &[u8] = include_bytes!("../../generated-bins/verifier.bin");
const DUMMY_LEAF: &[u8] = include_bytes!("../../generated-bins/dummy_proof.bin");

#[wasm_bindgen] pub fn prove_leaf() -> Result<Vec<u8>, JsValue>;            // test_inputs_0 + real block_hash
#[wasm_bindgen] pub fn load_batch_prover(n: usize) -> Result<(), JsValue>; // PrivateBatchProver::new_from_bytes
#[wasm_bindgen] pub fn aggregate(leaf: Vec<u8>) -> Result<Vec<u8>, JsValue>; // one real leaf, padded
  • Circuit files: generate generated-bins with qp_wormhole_circuit_builder::generate_all_circuit_binaries(dir, true, 8, None).
  • Build: RUSTFLAGS='--cfg getrandom_backend="wasm_js"' cargo build -p wasm-spike --release --target wasm32-unknown-unknown, then wasm-bindgen --target web.
  • Run: a module Web Worker calls the three steps and posts performance.now() deltas and memory.buffer.byteLength to the page.
Deferred. For now wormhole accounts are **read-only** in the extension: derive the addresses and show the balance, no send. This issue records what was measured, what can't be done, and the two routes worth exploring before wormhole sends come to the extension. Part of #1. ## What a wormhole send has to prove A wormhole address is not a key. The path `m/44'/189189189'/n'/{0,1}'/k'` yields a 32-byte **secret**; `first_hash = poseidon(salt ‖ secret)` and the address is `poseidon(first_hash)`. Funds leave through an unsigned `wormhole.verify_private_batch` (`ensure_none`) carrying a ZK proof, in two layers (`qp-wormhole-*` 4.3.0, the version the chain pins): 1. **Leaf proof**, one per transfer. Its private inputs are the secret, the ZK-tree Merkle path and the block header. 2. **Private-batch proof**, aggregating up to N leaf proofs (padded with dummies). ## Constraint: proving cannot be offloaded `qp-zk-circuits-common` 4.3.0, `circuit.rs`: - `wormhole_leaf_circuit_config()` is `standard_recursion_config() // zero_knowledge: false`. - The private-batch config is documented as the one layer that requires zero-knowledge, because its witnesses are the leaf proofs, "whose own witnesses (spend secrets, Merkle paths) must never leak." So whoever builds the private batch sees non-ZK leaf proofs and must be assumed able to spend. A hosted prover would be custody. Both layers run where the secret is, and the only layer that could safely leave the machine is the public batch above them, which isn't needed to spend. ## Measurements (2026-09-16, one 20-core desktop) One **real** leaf (genuine header hash over `test-helpers`' `test_inputs_0`) padded to a batch of 8. Browser runs are single-threaded WASM in a Web Worker, built with `wasm-bindgen` 0.2.128 (the `@quantus/codec` toolchain), `getrandom` wasm_js backends, and `default-features = false` (no rayon). | Step | Chrome | Firefox | Native, 1 thread | Native, 4 threads | Native, 20 threads | |---|---|---|---|---|---| | Leaf proof | 0.8s | 0.9s | 0.20s | 0.09s | 0.07s | | `PrivateBatchProver::new_from_bytes` (rebuilds the circuit) | 34s | 34s | 11.3s | 5.8s | 5.3s | | `aggregate` | 100s | 108s | 29.9s | 11.4s | 8.2s | | **Total** | **135s** | **142s** | **41.6s** | **17.3s** | **13.7s** | | Peak memory | 1.76 GB WASM | 1.76 GB WASM | 2.2 GB RSS | 2.1 GB RSS | 2.2 GB RSS | - **WASM overhead:** about 3.2× slower than native on one thread. - **Proof sizes:** leaf 100,732 bytes; private batch 157,732 bytes. - **Circuit files:** the leaf `common.bin`, `verifier.bin` and `dummy_proof.bin` total about 100 KB and can be embedded. Nothing large needs downloading. - **Against the bar:** that bar was a send in under 2 minutes while the UI shows what is happening. Single-threaded, it misses on fast hardware and would be worse on a typical laptop. Note: the upstream `aggregator` bench at v4.3.0 doesn't run. It feeds the same leaf proof twice and trips the pairwise-distinct nullifier check, so its wall times are circuit generation only. The numbers above come from a separate harness. ## Route A: multithreaded WASM Natively, 4 threads gave 2.4× over one thread. If that carries over, this machine lands around 55–70s, and a 4-core laptop perhaps near 2 minutes. To find out: - Build with `wasm-bindgen-rayon` (or equivalent) on nightly with `-Z build-std` and `+atomics,+bulk-memory`, enabling the aggregator's `multithread` feature and plonky2's `parallel`. - **SharedArrayBuffer needs cross-origin isolation.** Chrome MV3 supports the `cross_origin_embedder_policy` / `cross_origin_opener_policy` manifest keys. **Unknown whether Firefox extensions do**; this is the first thing to establish, since without it Firefox stays single-threaded. - Run proving in an extension page's worker, not the MV3 background. The background has an idle lifetime, and the connection keep-alive (`ebd3b4b2`) only covers message traffic, not a 2-minute CPU job. - Memory stays about 1.8 GB whatever the thread count. Check behaviour on an 8 GB machine. ## Route B: accept a slow prover, make the wait engaging Even single-threaded, a send finishes; the question is whether a 2–5 minute wait is acceptable when the UI never looks stalled. - The work already splits into observable steps: fetch transfers → Merkle proofs → leaf proofs (under 1s each) → build batch prover (~34s) → aggregate (~100s) → submit → inclusion. Each can report progress and what it is doing. - `new_from_bytes` rebuilds the batch circuit (~34s). Keep it warm in the worker for the session, so later sends cost only aggregation. - The two long steps are single Rust calls with no progress callback. Honest progress within them needs either a hook in the aggregator or elapsed time against a calibrated estimate. - The tab has to stay open, and closing it has to be safe: no nullifier is spent until submission. ## Where sends go meanwhile Proving natively takes about 14s on this machine, so blackbeard/wallet (desktop, Tauri, in progress in a separate effort) is the proposed home for wormhole sends until this lands. Whether and when it supports them is tracked there, not here. ## Reproducing A harness crate added as a member of `Quantus-Network/qp-zk-circuits` at tag `v4.3.0`: ```toml # wormhole/wasm-spike/Cargo.toml [lib] crate-type = ["cdylib"] [dependencies] anyhow = { workspace = true } qp-plonky2 = { workspace = true } wasm-bindgen = "=0.2.128" getrandom02 = { package = "getrandom", version = "0.2", features = ["js"] } getrandom04 = { package = "getrandom", version = "0.4", features = ["wasm_js"] } test-helpers = { path = "../tests/test-helpers", default-features = false } wormhole-aggregator = { package = "qp-wormhole-aggregator", path = "../aggregator", default-features = false, features = ["std"] } wormhole-circuit = { package = "qp-wormhole-circuit", path = "../circuit", default-features = false, features = ["std"] } wormhole-prover = { package = "qp-wormhole-prover", path = "../prover", default-features = false, features = ["std"] } zk-circuits-common = { package = "qp-zk-circuits-common", path = "../../common" } ``` ```rust // wormhole/wasm-spike/src/lib.rs: one export per step, so the page times each const LEAF_COMMON: &[u8] = include_bytes!("../../generated-bins/common.bin"); const LEAF_VERIFIER: &[u8] = include_bytes!("../../generated-bins/verifier.bin"); const DUMMY_LEAF: &[u8] = include_bytes!("../../generated-bins/dummy_proof.bin"); #[wasm_bindgen] pub fn prove_leaf() -> Result<Vec<u8>, JsValue>; // test_inputs_0 + real block_hash #[wasm_bindgen] pub fn load_batch_prover(n: usize) -> Result<(), JsValue>; // PrivateBatchProver::new_from_bytes #[wasm_bindgen] pub fn aggregate(leaf: Vec<u8>) -> Result<Vec<u8>, JsValue>; // one real leaf, padded ``` - **Circuit files:** generate `generated-bins` with `qp_wormhole_circuit_builder::generate_all_circuit_binaries(dir, true, 8, None)`. - **Build:** `RUSTFLAGS='--cfg getrandom_backend="wasm_js"' cargo build -p wasm-spike --release --target wasm32-unknown-unknown`, then `wasm-bindgen --target web`. - **Run:** a module Web Worker calls the three steps and posts `performance.now()` deltas and `memory.buffer.byteLength` to the page.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: quantus/extension#13