From 5bc4e037aa35dde4b2057ce81bc7c692f8589176 Mon Sep 17 00:00:00 2001 From: Hein Dauven Date: Sat, 23 May 2026 03:02:00 +0200 Subject: [PATCH] Add Phoenix transfer WASM benchmark --- .gitignore | 1 + Cargo.toml | 3 +- Makefile | 14 +- transfer-wasm/Cargo.toml | 45 +++++ transfer-wasm/README.md | 50 ++++++ transfer-wasm/example/transfer_wasm.rs | 229 +++++++++++++++++++++++++ transfer-wasm/src/lib.rs | 1 + 7 files changed, 341 insertions(+), 2 deletions(-) create mode 100644 transfer-wasm/Cargo.toml create mode 100644 transfer-wasm/README.md create mode 100644 transfer-wasm/example/transfer_wasm.rs create mode 100644 transfer-wasm/src/lib.rs diff --git a/.gitignore b/.gitignore index b2e73ab..5b6dc05 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ target/ Cargo.lock plonkjs/dist/ +transfer-wasm/dist/ node_modules/ .DS_Store diff --git a/Cargo.toml b/Cargo.toml index a4e0b2c..53630c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,9 @@ [workspace] members = ["plonkwasm", "integration-tests"] +exclude = ["transfer-wasm"] resolver = "2" [profile.release] -opt-level = "s" +opt-level = 3 lto = true panic = "abort" diff --git a/Makefile b/Makefile index b543290..5c88258 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,14 @@ -.PHONY: cq build-wasm build-wasm-raw build-wasm-rayon serve-example test test-rust test-js +.PHONY: cq build-wasm build-wasm-raw build-wasm-rayon build-transfer-wasm-rayon serve-example test test-rust test-js WASM_ARTIFACT := target/wasm32-unknown-unknown/release/examples/test_circuit_wasm.wasm +TRANSFER_WASM_ARTIFACT := target/wasm32-unknown-unknown/release/examples/transfer_wasm.wasm WASM_RAYON_MAX_MEMORY ?= 2147483648 +TRANSFER_WASM_RAYON_MAX_MEMORY ?= 4294967296 WASM_RAYON_RUSTFLAGS := -C target-feature=+atomics,+bulk-memory -C link-arg=--shared-memory -C link-arg=--import-memory -C link-arg=--max-memory=$(WASM_RAYON_MAX_MEMORY) -C link-arg=--export=__heap_base -C link-arg=--export=__wasm_init_tls -C link-arg=--export=__tls_size -C link-arg=--export=__tls_align -C link-arg=--export=__tls_base +TRANSFER_WASM_RAYON_RUSTFLAGS := -C target-feature=+atomics,+bulk-memory -C link-arg=--shared-memory -C link-arg=--import-memory -C link-arg=--max-memory=$(TRANSFER_WASM_RAYON_MAX_MEMORY) -C link-arg=--export=__heap_base -C link-arg=--export=__wasm_init_tls -C link-arg=--export=__tls_size -C link-arg=--export=__tls_align -C link-arg=--export=__tls_base +TRANSFER_WASM_FEATURES ?= wasm-rayon +TRANSFER_WASM_CARGO_ARGS ?= +TRANSFER_WASM_OPT ?= 1 EXAMPLE_PORT ?= 8000 cq: @@ -24,6 +30,12 @@ build-wasm-rayon: RUSTFLAGS="$(WASM_RAYON_RUSTFLAGS)" rustup run nightly cargo build -p plonk-integration-tests --release --target wasm32-unknown-unknown --example test_circuit_wasm --features wasm-rayon -Z build-std=panic_abort,std wasm-bindgen --target web --out-dir plonkjs/dist --out-name plonkwasm --no-typescript --keep-lld-exports $(WASM_ARTIFACT) +build-transfer-wasm-rayon: + mkdir -p transfer-wasm/dist + RUSTFLAGS="$(TRANSFER_WASM_RAYON_RUSTFLAGS)" rustup run nightly cargo $(TRANSFER_WASM_CARGO_ARGS) build --manifest-path transfer-wasm/Cargo.toml --target-dir target --release --target wasm32-unknown-unknown --example transfer_wasm --features "$(TRANSFER_WASM_FEATURES)" -Z build-std=panic_abort,std + wasm-bindgen --target web --out-dir transfer-wasm/dist --out-name transfer_wasm --no-typescript --keep-lld-exports $(TRANSFER_WASM_ARTIFACT) + if [ "$(TRANSFER_WASM_OPT)" = "1" ] && command -v wasm-opt >/dev/null 2>&1; then wasm-opt -O4 --enable-threads --enable-bulk-memory transfer-wasm/dist/transfer_wasm_bg.wasm -o transfer-wasm/dist/transfer_wasm_bg.wasm; fi + serve-example: build-keys build-wasm node integration-tests/example/server.mjs $(EXAMPLE_PORT) diff --git a/transfer-wasm/Cargo.toml b/transfer-wasm/Cargo.toml new file mode 100644 index 0000000..a9ecbed --- /dev/null +++ b/transfer-wasm/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "plonk-transfer-wasm" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +dusk-bytes = "0.1.7" +dusk-core = { path = "/home/hein_/projects/rusk-private/core", features = ["zk"] } +dusk-plonk = { version = "0.22.0-rc.0", default-features = false, features = ["legacy-proving", "rkyv-impl", "alloc"] } +plonkwasm = { path = "../plonkwasm" } +rand_chacha = "0.3" +rand_core = "0.6" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { version = "0.2", features = ["js"], optional = true } +wasm-bindgen = "=0.2.121" +wasm-bindgen-rayon = { version = "1.3", features = ["no-bundler"], optional = true } + +[features] +default = [] +wasm-rayon = ["dusk-core/std", "dusk-plonk/std", "dep:getrandom", "dep:wasm-bindgen-rayon"] + +[[example]] +name = "transfer_wasm" +path = "example/transfer_wasm.rs" +crate-type = ["cdylib"] +test = false + +[profile.release] +opt-level = 3 +lto = true +panic = "abort" + +[patch.crates-io] +dusk-plonk = { git = "ssh://git@github.com/dusk-network/plonk-private.git", branch = "browser-wasm-transfer-bench" } +bls12_381-bls = { git = "https://github.com/dusk-network/bls12_381-bls-private.git", rev = "2bbf457d31d83df92f3afeca8e9fc72c3d33545b" } +dusk-jubjub = { git = "https://github.com/dusk-network/jubjub-private.git", rev = "6e2b63f59edac8afa4f81a28b12d88529af37cdb" } +jubjub-elgamal = { git = "https://github.com/dusk-network/jubjub-elgamal-private.git", rev = "1f0d481fb29282c69fdc0063a0404cb89a53bd10" } +phoenix-core = { git = "https://github.com/dusk-network/phoenix-private.git", rev = "3d5b9838d7e824b441c93de9aaa38737263b97bd" } +phoenix-circuits = { git = "https://github.com/dusk-network/phoenix-private.git", rev = "3d5b9838d7e824b441c93de9aaa38737263b97bd" } +piecrust = { git = "https://github.com/dusk-network/piecrust-private.git", rev = "66ddb31693ffb0fd5a4faac3c80930e6228e0836" } +piecrust-uplink = { git = "https://github.com/dusk-network/piecrust-private.git", rev = "66ddb31693ffb0fd5a4faac3c80930e6228e0836" } diff --git a/transfer-wasm/README.md b/transfer-wasm/README.md new file mode 100644 index 0000000..450601f --- /dev/null +++ b/transfer-wasm/README.md @@ -0,0 +1,50 @@ +# Dusk transfer WASM benchmark + +This crate builds a real Phoenix transfer prover for browser WASM. It exposes +the standard `plonkweb` circuit ABI (`plonkweb_init`, `plonkweb_prove`, and +`plonkweb_verify`) so it can be loaded by `plonkjs` like the integration-test +artifact. + +Current fixture defaults: + +- circuit: `TxCircuitFourTwo` +- input notes: 4 +- circuit bytes: `/home/hein_/projects/rusk-private-boreas-full-blst/rusk-prover/tests/tx_circuit_vec.hex` +- prover key: `/home/hein_/.dusk/rusk/keys/58929845e2fa0ba344dc378af9ef2c9edbe1346439a4c786b80b79ab3e07d6cf.pk` +- verifier key: same path with `.vd` + +Build the Dusk BLS backend currently patched in this crate. The benchmark crate +is excluded from the root workspace so normal `plonkweb` CI does not need local +Rusk paths or private benchmark dependencies. Its manifest patch points at +`dusk-network/plonk-private` branch `browser-wasm-transfer-bench` for the +prover hot-path and trusted unchecked key loading changes. + +```sh +make build-transfer-wasm-rayon +``` + +`plonkweb_init` uses `Prover::try_from_trusted_unchecked_bytes`, which validates +the serialized container lengths but skips commit-key point validation. It is +only appropriate when the `.pk` is a local/generated trusted artifact, not user +supplied bytes. For `TxCircuitFourTwo` this reduced browser prover decode from +about 32 seconds to about 3.5 seconds without changing artifact size or +steady-state memory. + +The `plonkweb_prove` JSON request accepts the normal `seed_hex` field plus a +Phoenix transfer fixture in `tx_circuit_vec_hex`. `plonkweb_verify` accepts the +standard `proof_hex` and `public_inputs_hex` fields. + +To test another `dusk-plonk` worktree without editing the root manifest, pass +Cargo config through the Makefile: + +```sh +make build-transfer-wasm-rayon \ + TRANSFER_WASM_CARGO_ARGS='--config patch.crates-io.dusk-plonk.path="/home/hein_/projects/plonk-private-boreas-full-blst"' +``` + +For the BLST branch, also add `blst-backend` to the `dusk-plonk` dependency +features in `transfer-wasm/Cargo.toml`; the current Dusk-BLS branch does not +declare that feature, so it cannot be made a shared workspace feature. + +The browser harness used during investigation is intentionally not included in +this branch. diff --git a/transfer-wasm/example/transfer_wasm.rs b/transfer-wasm/example/transfer_wasm.rs new file mode 100644 index 0000000..268fe9a --- /dev/null +++ b/transfer-wasm/example/transfer_wasm.rs @@ -0,0 +1,229 @@ +use std::sync::{Mutex, OnceLock}; + +use dusk_bytes::{DeserializableSlice, Serializable}; +use dusk_core::transfer::phoenix::{TxCircuit, TxCircuitVec, NOTES_TREE_DEPTH}; +use dusk_plonk::prelude::{PlonkVersion, Proof, Prover, Verifier}; +use rand_chacha::ChaCha20Rng; +use rand_core::SeedableRng; +use serde::{Deserialize, Serialize}; + +#[cfg(all(target_arch = "wasm32", feature = "wasm-rayon"))] +pub use wasm_bindgen_rayon::init_thread_pool; + +static PROVER: OnceLock>> = OnceLock::new(); +static VERIFIER: OnceLock>> = OnceLock::new(); + +#[derive(Debug, Serialize)] +struct InitResponse { + ready: bool, +} + +#[derive(Debug, Deserialize)] +struct ProveRequest { + seed_hex: String, + tx_circuit_vec_hex: String, +} + +#[derive(Debug, Serialize)] +struct ProveResponse { + proof_hex: String, + public_inputs_hex: String, + input_notes: usize, +} + +#[derive(Debug, Deserialize)] +struct VerifyRequest { + proof_hex: String, + public_inputs_hex: String, +} + +#[derive(Debug, Serialize)] +struct VerifyResponse { + verified: bool, +} + +#[no_mangle] +pub extern "C" fn plonkweb_alloc(len: usize) -> *mut u8 { + plonkwasm::wasm::alloc(len) +} + +#[no_mangle] +/// # Safety +/// +/// `ptr` and `len` must describe a buffer previously returned by +/// `plonkweb_alloc` and not already freed. +pub unsafe extern "C" fn plonkweb_free(ptr: *mut u8, len: usize) { + unsafe { plonkwasm::wasm::free(ptr, len) }; +} + +#[no_mangle] +/// Loads the transfer prover and verifier keys into wasm memory. +/// +/// The prover key uses `Prover::try_from_trusted_unchecked_bytes`, which skips +/// commit-key point validation. This artifact is for trusted local benchmark +/// keys generated by Rusk, not user-supplied prover-key bytes. +/// +/// # Safety +/// +/// Pointer and length pairs must describe valid readable key buffers for the +/// duration of this call. +pub unsafe extern "C" fn plonkweb_init( + prover_key_ptr: *const u8, + prover_key_len: usize, + verifier_key_ptr: *const u8, + verifier_key_len: usize, +) -> u64 { + let prover_key = unsafe { core::slice::from_raw_parts(prover_key_ptr, prover_key_len) }; + let verifier_key = unsafe { core::slice::from_raw_parts(verifier_key_ptr, verifier_key_len) }; + + plonkwasm::wasm::respond(init(prover_key, verifier_key)) +} + +#[no_mangle] +/// Computes a Phoenix transfer proof from a JSON request. +/// +/// The request must contain `seed_hex` and `tx_circuit_vec_hex`; the circuit +/// shape is selected from the serialized `TxCircuitVec` input-note count. +/// +/// # Safety +/// +/// `request_ptr` and `request_len` must describe a valid readable request +/// buffer for the duration of this call. +pub unsafe extern "C" fn plonkweb_prove(request_ptr: *const u8, request_len: usize) -> u64 { + unsafe { plonkwasm::wasm::respond_from_request(request_ptr, request_len, prove_transfer) } +} + +#[no_mangle] +/// Verifies a Phoenix transfer proof from a JSON request. +/// +/// # Safety +/// +/// `request_ptr` and `request_len` must describe a valid readable request +/// buffer for the duration of this call. +pub unsafe extern "C" fn plonkweb_verify(request_ptr: *const u8, request_len: usize) -> u64 { + unsafe { plonkwasm::wasm::respond_from_request(request_ptr, request_len, verify_transfer) } +} + +fn init(prover_key: &[u8], verifier_key: &[u8]) -> Result { + let prover = + Prover::try_from_trusted_unchecked_bytes(prover_key).map_err(|err| format!("{err:?}"))?; + let verifier = Verifier::try_from_bytes(verifier_key).map_err(|err| format!("{err:?}"))?; + + let prover_storage = PROVER.get_or_init(|| Mutex::new(None)); + *prover_storage + .lock() + .map_err(|_| "cached prover mutex is poisoned".to_string())? = Some(prover); + + let verifier_storage = VERIFIER.get_or_init(|| Mutex::new(None)); + *verifier_storage + .lock() + .map_err(|_| "cached verifier mutex is poisoned".to_string())? = Some(verifier); + + Ok(InitResponse { ready: true }) +} + +fn prove_transfer(request: &[u8]) -> Result { + let request: ProveRequest = + serde_json::from_slice(request).map_err(|err| format!("invalid prove request: {err}"))?; + let seed = plonkwasm::wasm::decode_seed(&request.seed_hex)?; + let tx_circuit_vec = plonkwasm::wasm::decode_hex(&request.tx_circuit_vec_hex)?; + + prove_transfer_bytes(&tx_circuit_vec, seed) +} + +fn prove_transfer_bytes( + tx_circuit_vec_bytes: &[u8], + seed: [u8; 32], +) -> Result { + let tx_circuit_vec = + TxCircuitVec::from_slice(tx_circuit_vec_bytes).map_err(|err| format!("{err:?}"))?; + let input_notes = tx_circuit_vec.input_notes_info.len(); + let mut rng = ChaCha20Rng::from_seed(seed); + + let prover_storage = PROVER + .get() + .ok_or_else(|| "plonkweb init must be called before prove".to_string())?; + let prover_guard = prover_storage + .lock() + .map_err(|_| "cached prover mutex is poisoned".to_string())?; + let prover = prover_guard + .as_ref() + .ok_or_else(|| "plonkweb init must be called before prove".to_string())?; + + let (proof, public_inputs) = match input_notes { + 1 => { + let circuit = create_circuit::<1>(tx_circuit_vec)?; + prover.prove_with_version(&mut rng, &circuit, PlonkVersion::V3) + } + 2 => { + let circuit = create_circuit::<2>(tx_circuit_vec)?; + prover.prove_with_version(&mut rng, &circuit, PlonkVersion::V3) + } + 3 => { + let circuit = create_circuit::<3>(tx_circuit_vec)?; + prover.prove_with_version(&mut rng, &circuit, PlonkVersion::V3) + } + 4 => { + let circuit = create_circuit::<4>(tx_circuit_vec)?; + prover.prove_with_version(&mut rng, &circuit, PlonkVersion::V3) + } + _ => return Err(format!("unsupported input note count: {input_notes}")), + } + .map_err(|err| format!("{err:?}"))?; + + Ok(ProveResponse { + proof_hex: plonkwasm::wasm::encode_hex(&proof.to_bytes()), + public_inputs_hex: plonkwasm::wasm::encode_hex(&plonkwasm::serialize_public_inputs( + &public_inputs, + )), + input_notes, + }) +} + +fn verify_transfer(request: &[u8]) -> Result { + let request: VerifyRequest = + serde_json::from_slice(request).map_err(|err| format!("invalid verify request: {err}"))?; + let proof = plonkwasm::wasm::decode_hex(&request.proof_hex)?; + let public_inputs = plonkwasm::wasm::decode_hex(&request.public_inputs_hex)?; + + verify_transfer_bytes(&proof, &public_inputs) +} + +fn verify_transfer_bytes(proof: &[u8], public_inputs: &[u8]) -> Result { + let proof = Proof::from_slice(proof).map_err(|err| format!("invalid proof bytes: {err:?}"))?; + let public_inputs = plonkwasm::deserialize_public_inputs(public_inputs)?; + + let verifier_storage = VERIFIER + .get() + .ok_or_else(|| "plonkweb init must be called before verify".to_string())?; + let verifier_guard = verifier_storage + .lock() + .map_err(|_| "cached verifier mutex is poisoned".to_string())?; + let verifier = verifier_guard + .as_ref() + .ok_or_else(|| "plonkweb init must be called before verify".to_string())?; + + verifier + .verify_with_version(&proof, &public_inputs, PlonkVersion::V3) + .map_err(|err| format!("{err:?}"))?; + + Ok(VerifyResponse { verified: true }) +} + +fn create_circuit( + tx_circuit_vec: TxCircuitVec, +) -> Result, String> { + Ok(TxCircuit { + input_notes_info: tx_circuit_vec + .input_notes_info + .try_into() + .map_err(|err| format!("{err:?}"))?, + output_notes_info: tx_circuit_vec.output_notes_info, + payload_hash: tx_circuit_vec.payload_hash, + root: tx_circuit_vec.root, + deposit: tx_circuit_vec.deposit, + max_fee: tx_circuit_vec.max_fee, + sender_pk: tx_circuit_vec.sender_pk, + signatures: tx_circuit_vec.signatures, + }) +} diff --git a/transfer-wasm/src/lib.rs b/transfer-wasm/src/lib.rs new file mode 100644 index 0000000..a34e7f8 --- /dev/null +++ b/transfer-wasm/src/lib.rs @@ -0,0 +1 @@ +// This crate only hosts wasm examples.