diff --git a/Cargo.lock b/Cargo.lock index f08026a31..6add78fd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -525,6 +525,16 @@ dependencies = [ "stellar-tokens", ] +[[package]] +name = "confidential-verifier-example" +version = "0.7.1" +dependencies = [ + "soroban-sdk", + "stellar-access", + "stellar-macros", + "stellar-tokens", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -2481,6 +2491,7 @@ dependencies = [ "soroban-test-helpers", "stellar-contract-utils", "stellar-governance", + "ultrahonk_soroban_verifier", ] [[package]] @@ -2627,6 +2638,14 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "ultrahonk_soroban_verifier" +version = "0.1.0" +source = "git+https://github.com/brozorec/rs-soroban-ultrahonk?rev=5e9b4d995ec43ed1953cf89cfd738df6471e4b93#5e9b4d995ec43ed1953cf89cfd738df6471e4b93" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "unarray" version = "0.1.4" diff --git a/Cargo.toml b/Cargo.toml index d88c336ee..d093cb5f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,16 @@ p256 = "0.13.2" serde = { version = "1", default-features = false } serde-json-core = { version = "0.6.0", default-features = false } +# UltraHonk proof-verification backend for the confidential token. Pinned to a +# specific commit because the upstream crate is pre-release and unaudited. +# +# Points at a fork rather than NethermindEth/rs-soroban-ultrahonk because +# upstream is still on soroban-sdk 26, which cannot coexist with this +# workspace's 27 (two SDK majors in one graph make `Env`/`Bytes` distinct +# types). The fork's `v27` branch is upstream `main` (661db072) with the SDK +# bump and nothing else. Repoint at upstream once it ships an SDK 27 release. +ultrahonk-soroban-verifier = { git = "https://github.com/brozorec/rs-soroban-ultrahonk", rev = "5e9b4d995ec43ed1953cf89cfd738df6471e4b93", package = "ultrahonk_soroban_verifier", default-features = false } + # members stellar-access = { path = "packages/access", version = "0.7.1" } stellar-accounts = { path = "packages/accounts", version = "0.7.1" } diff --git a/examples/confidential/verifier/Cargo.toml b/examples/confidential/verifier/Cargo.toml new file mode 100644 index 000000000..f583b88c7 --- /dev/null +++ b/examples/confidential/verifier/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "confidential-verifier-example" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false +version.workspace = true +authors.workspace = true + +[package.metadata.stellar] +cargo_inherit = true + +[lib] +crate-type = ["cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +stellar-access = { workspace = true } +stellar-macros = { workspace = true } +stellar-tokens = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/examples/confidential/verifier/src/contract.rs b/examples/confidential/verifier/src/contract.rs new file mode 100644 index 000000000..2bd7260ff --- /dev/null +++ b/examples/confidential/verifier/src/contract.rs @@ -0,0 +1,66 @@ +//! Confidential Verifier Example Contract. +//! +//! A deployable [`ConfidentialVerifier`] registry: it stores one UltraHonk +//! verification key per [`CircuitType`] and exposes `verify_proof` to the +//! confidential token (called cross-contract on every state-changing +//! operation). VK management is gated behind a `manager` role; `verify_proof` +//! and `get_verification_key` use the trait's default implementations, which +//! run the UltraHonk backend from `NethermindEth/rs-soroban-ultrahonk`. +//! +//! # ⚠️ Not Production Ready +//! +//! The UltraHonk backend and the circuits the verification keys are derived +//! from are **not audited**. Do not deploy this anywhere handling real value. +//! +//! # Security +//! +//! `update_verification_key` is a soundness-critical break-glass operation: a +//! wrong key makes the circuit accept forged proofs. This example gates it +//! behind the same `manager` role as registration purely for illustration. A +//! real deployment should follow the trait's guidance — ship VKs immutably +//! where possible, and put any update path behind multisig + timelock. +use soroban_sdk::{contract, contractimpl, symbol_short, Address, Bytes, Env, Symbol, Vec}; +use stellar_access::access_control::{self as access_control, AccessControl}; +use stellar_macros::only_role; +use stellar_tokens::confidential::verifier::{ + storage as verifier, CircuitType, ConfidentialVerifier, +}; + +const MANAGER_ROLE: Symbol = symbol_short!("manager"); + +#[contract] +pub struct ConfidentialVerifierContract; + +#[contractimpl] +impl ConfidentialVerifierContract { + pub fn __constructor(e: &Env, admin: Address, manager: Address) { + access_control::set_admin(e, &admin); + access_control::grant_role_no_auth(e, &manager, &MANAGER_ROLE, &admin); + } +} + +#[contractimpl(contracttrait)] +impl ConfidentialVerifier for ConfidentialVerifierContract { + #[only_role(operator, "manager")] + fn register_verification_key( + e: &Env, + circuit_type: CircuitType, + verification_key: Bytes, + operator: Address, + ) { + verifier::register_verification_key(e, circuit_type, &verification_key); + } + + #[only_role(operator, "manager")] + fn update_verification_key( + e: &Env, + circuit_type: CircuitType, + new_verification_key: Bytes, + operator: Address, + ) { + verifier::update_verification_key(e, circuit_type, &new_verification_key); + } +} + +#[contractimpl(contracttrait)] +impl AccessControl for ConfidentialVerifierContract {} diff --git a/examples/confidential/verifier/src/lib.rs b/examples/confidential/verifier/src/lib.rs new file mode 100644 index 000000000..a879b6f80 --- /dev/null +++ b/examples/confidential/verifier/src/lib.rs @@ -0,0 +1,5 @@ +#![no_std] + +pub mod contract; +#[cfg(test)] +mod test; diff --git a/examples/confidential/verifier/src/test.rs b/examples/confidential/verifier/src/test.rs new file mode 100644 index 000000000..cc844d676 --- /dev/null +++ b/examples/confidential/verifier/src/test.rs @@ -0,0 +1,93 @@ +extern crate std; + +use soroban_sdk::{testutils::Address as _, Address, Bytes, Env}; +use stellar_tokens::confidential::verifier::CircuitType; + +use crate::contract::{ConfidentialVerifierContract, ConfidentialVerifierContractClient}; + +// Real UltraHonk verification keys in the packed on-chain format, generated +// from the committed circuits by `circuits/scripts/build_vk_bins.sh`. Using +// real keys here exercises the wired UltraHonk backend end to end: a malformed +// key would be rejected by `UltraHonkVerifier::new` with `#3403`. +const REGISTER_VK: &[u8; 1760] = + include_bytes!("../../../../packages/tokens/src/confidential/circuits/vks/register.vk.bin"); +const WITHDRAW_VK: &[u8; 1760] = + include_bytes!("../../../../packages/tokens/src/confidential/circuits/vks/withdraw.vk.bin"); + +fn create_client<'a>( + e: &Env, + admin: &Address, + manager: &Address, +) -> ConfidentialVerifierContractClient<'a> { + let address = e.register(ConfidentialVerifierContract, (admin, manager)); + ConfidentialVerifierContractClient::new(e, &address) +} + +#[test] +fn register_and_get_verification_key_works() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let manager = Address::generate(&e); + let client = create_client(&e, &admin, &manager); + + let vk = Bytes::from_array(&e, REGISTER_VK); + client.register_verification_key(&CircuitType::Register, &vk, &manager); + + assert_eq!(client.get_verification_key(&CircuitType::Register), vk); +} + +#[test] +fn verify_proof_runs_backend_on_real_vk() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let manager = Address::generate(&e); + let client = create_client(&e, &admin, &manager); + + client.register_verification_key( + &CircuitType::Register, + &Bytes::from_array(&e, REGISTER_VK), + &manager, + ); + + // The key parses (no `#3403`), so the UltraHonk backend actually runs and + // rejects a junk proof rather than panicking. A real positive case needs a + // matching proof + public inputs produced by the prover toolchain. + let junk = Bytes::from_array(&e, &[0u8; 32]); + assert!(!client.verify_proof(&CircuitType::Register, &junk, &junk)); +} + +#[test] +fn update_verification_key_replaces_in_place() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let manager = Address::generate(&e); + let client = create_client(&e, &admin, &manager); + + let old = Bytes::from_array(&e, REGISTER_VK); + let new = Bytes::from_array(&e, WITHDRAW_VK); + + client.register_verification_key(&CircuitType::Register, &old, &manager); + client.update_verification_key(&CircuitType::Register, &new, &manager); + + assert_eq!(client.get_verification_key(&CircuitType::Register), new); +} + +#[test] +#[should_panic(expected = "Error(Contract, #2000)")] +fn register_by_non_manager_panics() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let manager = Address::generate(&e); + let stranger = Address::generate(&e); + let client = create_client(&e, &admin, &manager); + + client.register_verification_key( + &CircuitType::Register, + &Bytes::from_array(&e, REGISTER_VK), + &stranger, + ); +} diff --git a/packages/tokens/Cargo.toml b/packages/tokens/Cargo.toml index 7168b3ddb..3b55ed1c7 100644 --- a/packages/tokens/Cargo.toml +++ b/packages/tokens/Cargo.toml @@ -14,11 +14,14 @@ cargo_inherit = true crate-type = ["lib", "cdylib"] doctest = false +# The `alloc` feature provides the global allocator that the UltraHonk verifier +# backend (`ultrahonk-soroban-verifier`) requires in `no_std` wasm builds. [dependencies] -soroban-sdk = { workspace = true } +soroban-sdk = { workspace = true, features = ["alloc"] } soroban-poseidon = { workspace = true } stellar-contract-utils = { workspace = true } stellar-governance = { workspace = true } +ultrahonk-soroban-verifier = { workspace = true } [dev-dependencies] ed25519-dalek = { workspace = true } diff --git a/packages/tokens/src/confidential/circuits/scripts/build_vk_bins.sh b/packages/tokens/src/confidential/circuits/scripts/build_vk_bins.sh new file mode 100755 index 000000000..98ddb2888 --- /dev/null +++ b/packages/tokens/src/confidential/circuits/scripts/build_vk_bins.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Builds the packed binary verification keys consumed on-chain by the +# UltraHonk verifier (`ultrahonk-soroban-verifier`) and writes them under +# `vks/.vk.bin`, one file per per-operation circuit, alongside the +# human-readable `vks/.vk.json` produced by `extract_vks.sh`. +# +# Why a separate binary artifact: +# - `vks/*.vk.json` is bb's `fields` output (a JSON array of hex `Fr` +# elements). It is committed for cross-platform-stable code review and is +# the format diffed by CI, but it is NOT the byte layout the verifier +# parses. +# - `ultrahonk-soroban-verifier::load_vk_from_bytes` expects exactly 1760 +# bytes: a 32-byte header of four big-endian u64s +# (circuit_size, log_circuit_size, public_inputs_size, pub_inputs_offset) +# followed by 27 G1 commitments at 64 bytes each (x || y, big-endian). +# +# `bb write_vk` (default `bytes` output) emits this exact layout with ONE +# extra 4-byte field appended to the header: a big-endian u32 holding the +# number of user public inputs (= public_inputs_size - PAIRING_POINTS_SIZE), +# which the verifier recomputes and does not store. The bb file is therefore +# 1764 bytes laid out as: +# +# [0..32) four big-endian u64 header words (kept) +# [32..36) big-endian u32 user-PI count (dropped) +# [36..1764) 27 * 64-byte G1 commitments (kept) +# +# This script strips bytes [32..36) to obtain the 1760-byte file. The VK bytes +# themselves come straight from bb -- nothing here recomputes or rederives the +# key material. +# +# Requires the pinned `nargo` and `bb` versions declared in +# `.github/workflows/noir.yml`. Run from anywhere; the script anchors to the +# circuits/ root. Keep the circuit list in sync with `extract_vks.sh`. +set -euo pipefail + +cd "$(dirname "$0")/.." + +CIRCUITS=( + "register" + "withdraw" + "transfer" + "set_spender" + "spender_transfer" + "revoke_spender" +) + +OUT_DIR="vks" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +# bb's binary VK layout (see header): 32-byte header, then a 4-byte u32, then +# the commitments. The verifier omits the u32, so the on-chain file is 4 bytes +# shorter. +BB_VK_LEN=1764 +HEADER_LEN=32 +DROP_LEN=4 +PACKED_VK_LEN=1760 + +mkdir -p "$OUT_DIR" + +for name in "${CIRCUITS[@]}"; do + pkg="circuit_${name}" + bytecode="target/${pkg}.json" + + echo "==> Compiling ${pkg}" + nargo compile --package "$pkg" + + echo "==> Writing VK for ${pkg}" + stage="${TMP_DIR}/${name}" + mkdir -p "$stage" + bb write_vk -s ultra_honk -b "$bytecode" -o "$stage" + + src="${stage}/vk" + src_len="$(wc -c < "$src")" + if [ "$src_len" -ne "$BB_VK_LEN" ]; then + echo " ERROR: expected bb VK of ${BB_VK_LEN} bytes, got ${src_len}." >&2 + echo " The bb VK layout may have changed; re-validate the strip offsets." >&2 + exit 1 + fi + + out="${OUT_DIR}/${name}.vk.bin" + # Keep the four u64 header words, drop the u32 user-PI count, keep the rest. + head -c "$HEADER_LEN" "$src" > "$out" + tail -c "+$((HEADER_LEN + DROP_LEN + 1))" "$src" >> "$out" + + out_len="$(wc -c < "$out")" + if [ "$out_len" -ne "$PACKED_VK_LEN" ]; then + echo " ERROR: packed VK is ${out_len} bytes, expected ${PACKED_VK_LEN}." >&2 + exit 1 + fi + echo " wrote ${out} (${out_len} bytes)" +done + +echo "Done." diff --git a/packages/tokens/src/confidential/circuits/vks/README.md b/packages/tokens/src/confidential/circuits/vks/README.md index 76db7eda5..8c2130c84 100644 --- a/packages/tokens/src/confidential/circuits/vks/README.md +++ b/packages/tokens/src/confidential/circuits/vks/README.md @@ -1,13 +1,25 @@ # Verification keys -UltraHonk verification keys for the per-operation circuits, one JSON file -per circuit (`.vk.json`). These are committed artifacts -- the -integration contract with the verifier (#701). +UltraHonk verification keys for the per-operation circuits, two files per +circuit. These are committed artifacts -- the integration contract with the +verifier (#701). -**Format:** each file is a JSON array of hex-encoded `Fr` elements, produced -by `bb write_vk --output_format fields`. Used instead of bb's raw `bytes` -format because the latter includes platform-dependent header bytes that -spuriously break cross-platform reproducibility (macOS vs Linux CI). +- **`.vk.json`** -- a JSON array of hex-encoded `Fr` elements, produced + by `bb write_vk --output_format fields`. This is the human-readable, + review-friendly form and the one CI diffs. It is *not* the byte layout the + verifier parses. Used instead of bb's raw `bytes` format because the latter + includes platform-dependent header bytes that spuriously break + cross-platform reproducibility (macOS vs Linux CI). +- **`.vk.bin`** -- the packed binary key the on-chain verifier actually + consumes (`ultrahonk-soroban-verifier::load_vk_from_bytes`): a 1760-byte + blob made of a 32-byte header (four big-endian `u64`s -- `circuit_size`, + `log_circuit_size`, `public_inputs_size`, `pub_inputs_offset`) followed by + 27 G1 commitments at 64 bytes each (`x || y`, big-endian). It is bb's + default `bytes` output with the redundant 4-byte trailing header field (the + user-public-input count, which the verifier recomputes) stripped. The key + material is bb's verbatim output -- nothing is recomputed off-chain. The + point section is byte-identical to the field elements in the matching + `.vk.json`. Reproducible from the circuit sources with the pinned toolchain: @@ -37,17 +49,24 @@ bb prove -s ultra_honk --oracle_hash keccak \ - Do **not** pass `--zk`: the verifier currently implements only the non-zk `ultra_flavor`. -The verifier backend is unfinished (see the module-level warning in -`../../verifier/mod.rs`). This recipe is provisional and will be finalized -together with the verifier, including the zero-knowledge setting. +The verifier backend is pre-release and unaudited (see the module-level +warning in `../../verifier/mod.rs`). This recipe is provisional and will be +finalized together with the verifier, including the zero-knowledge setting. ## Regenerating ```bash cd packages/tokens/src/confidential/circuits -./scripts/extract_vks.sh +./scripts/extract_vks.sh # regenerates the *.vk.json (CI-diffed) +./scripts/build_vk_bins.sh # regenerates the *.vk.bin (the on-chain form) ``` +Both scripts compile the circuits with the same pinned toolchain, so the +two formats stay in lockstep -- always run both when a circuit changes and +commit the result in the same PR. CI diffs the `.vk.json`; since each +`.vk.bin` point section is byte-identical to its `.vk.json`, that diff +transitively guards the key material in the binary too. + If the diff is intentional (the circuit changed), regenerate and commit in the same PR. If unintentional (e.g. toolchain bumped without an explicit decision), do **not** regenerate -- track down the source first. diff --git a/packages/tokens/src/confidential/circuits/vks/register.vk.bin b/packages/tokens/src/confidential/circuits/vks/register.vk.bin new file mode 100644 index 000000000..ef5f70c26 Binary files /dev/null and b/packages/tokens/src/confidential/circuits/vks/register.vk.bin differ diff --git a/packages/tokens/src/confidential/circuits/vks/revoke_spender.vk.bin b/packages/tokens/src/confidential/circuits/vks/revoke_spender.vk.bin new file mode 100644 index 000000000..30a0a5d9f Binary files /dev/null and b/packages/tokens/src/confidential/circuits/vks/revoke_spender.vk.bin differ diff --git a/packages/tokens/src/confidential/circuits/vks/set_spender.vk.bin b/packages/tokens/src/confidential/circuits/vks/set_spender.vk.bin new file mode 100644 index 000000000..39fbceafc Binary files /dev/null and b/packages/tokens/src/confidential/circuits/vks/set_spender.vk.bin differ diff --git a/packages/tokens/src/confidential/circuits/vks/spender_transfer.vk.bin b/packages/tokens/src/confidential/circuits/vks/spender_transfer.vk.bin new file mode 100644 index 000000000..6b0df5456 Binary files /dev/null and b/packages/tokens/src/confidential/circuits/vks/spender_transfer.vk.bin differ diff --git a/packages/tokens/src/confidential/circuits/vks/transfer.vk.bin b/packages/tokens/src/confidential/circuits/vks/transfer.vk.bin new file mode 100644 index 000000000..3d63b0c2e Binary files /dev/null and b/packages/tokens/src/confidential/circuits/vks/transfer.vk.bin differ diff --git a/packages/tokens/src/confidential/circuits/vks/withdraw.vk.bin b/packages/tokens/src/confidential/circuits/vks/withdraw.vk.bin new file mode 100644 index 000000000..e5028d905 Binary files /dev/null and b/packages/tokens/src/confidential/circuits/vks/withdraw.vk.bin differ diff --git a/packages/tokens/src/confidential/verifier/mod.rs b/packages/tokens/src/confidential/verifier/mod.rs index d23486fb5..335f6b2e7 100644 --- a/packages/tokens/src/confidential/verifier/mod.rs +++ b/packages/tokens/src/confidential/verifier/mod.rs @@ -10,15 +10,16 @@ //! //! # ⚠️ Not Production Ready //! -//! This module is **unfinished**. [`ConfidentialVerifier::verify_proof`] has no -//! working default implementation because its UltraHonk backend -//! ([`NethermindEth/rs-soroban-ultrahonk`](https://github.com/NethermindEth/rs-soroban-ultrahonk)) -//! is still under development and **has not been audited**. Do **not** deploy a -//! contract built on this trait to mainnet or any environment that handles -//! real value. The trait surface, the [`VerifierStorageKey`] layout, and the -//! VK-management helpers in [`storage`] are stable enough for the confidential -//! token to scaffold against, and they are the only part of this -//! module that is intended to be relied upon today. +//! [`ConfidentialVerifier::verify_proof`] is backed by the UltraHonk verifier +//! from +//! [`NethermindEth/rs-soroban-ultrahonk`](https://github.com/NethermindEth/rs-soroban-ultrahonk), +//! pinned in the workspace `Cargo.toml` to a fork carrying that upstream commit +//! plus a `soroban-sdk` 27 bump (see the comment on the pin). That backend is +//! **pre-release and has not been audited**, and neither have the circuits the +//! verification keys are derived from. Do **not** deploy a contract built on +//! this trait to mainnet or any environment that handles real value until both +//! the backend and the circuits have been audited and the dependency is pinned +//! to a released, reviewed version. //! //! ## Why a Separate Contract //! @@ -34,11 +35,18 @@ //! //! ## VK Encoding //! -//! Verification keys are opaque [`Bytes`] blobs from this module's point of -//! view; structural validation lives in the future UltraHonk backend, not in -//! the storage layer. The on-disk reference format committed under -//! `circuits/vks/` is a JSON array of hex-encoded `Fr` field elements (one -//! file per circuit, produced by `bb write_vk --output_format fields`). +//! Verification keys are opaque [`Bytes`] blobs from the storage layer's point +//! of view: [`register_verification_key`] and [`update_verification_key`] store +//! them verbatim without inspection. Structural validation happens lazily, in +//! the UltraHonk backend, the first time [`verify_proof`] parses the stored +//! bytes. +//! +//! The bytes registered on-chain must be the backend's packed binary VK +//! encoding: a fixed-size header followed by the curve-point commitments, +//! committed per circuit as `circuits/vks/.vk.bin`. The sibling +//! `.vk.json` is the review-friendly form — a JSON array of hex-encoded +//! `Fr` field elements produced by `bb write_vk --output_format fields` — and +//! is **not** accepted by the backend. //! //! ## Storage //! @@ -87,7 +95,8 @@ mod test; use soroban_sdk::{contracterror, contractevent, contracttrait, contracttype, Address, Bytes, Env}; pub use storage::{ - get_verification_key, register_verification_key, update_verification_key, VerifierStorageKey, + get_verification_key, register_verification_key, update_verification_key, verify_proof, + VerifierStorageKey, }; /// Identifier of a zero-knowledge circuit whose verification key is stored in @@ -222,16 +231,26 @@ pub trait ConfidentialVerifier { /// /// * [`VerifierError::VerificationKeyNotRegistered`] - When `circuit_type` /// has no registered key. + /// * [`VerifierError::InvalidVerificationKey`] - When the registered key + /// cannot be parsed as a valid UltraHonk verification key. /// /// # Notes /// - /// No default implementation is provided. The UltraHonk verification - /// backend lives in `NethermindEth/rs-soroban-ultrahonk`, which is still - /// under development and has not been audited (see the module-level - /// warning). Implementors MUST NOT ship a stub that returns `true` - /// unconditionally to any environment that handles real value. - fn verify_proof(e: &Env, circuit_type: CircuitType, public_inputs: Bytes, proof: Bytes) - -> bool; + /// The default implementation delegates to [`storage::verify_proof`], which + /// runs the UltraHonk verifier from + /// [`NethermindEth/rs-soroban-ultrahonk`](https://github.com/NethermindEth/rs-soroban-ultrahonk). + /// That backend and the circuits the verification keys are derived from are + /// **not yet audited** (see the module-level warning); the default + /// implementation MUST NOT be relied upon in any environment that handles + /// real value until they are. + fn verify_proof( + e: &Env, + circuit_type: CircuitType, + public_inputs: Bytes, + proof: Bytes, + ) -> bool { + storage::verify_proof(e, circuit_type, &public_inputs, &proof) + } /// Returns the UltraHonk verification key registered under `circuit_type`. /// @@ -261,6 +280,9 @@ pub enum VerifierError { VerificationKeyNotRegistered = 3401, /// Indicates the proof failed UltraHonk verification. InvalidProof = 3402, + /// Indicates the registered verification key could not be parsed as a valid + /// UltraHonk verification key. + InvalidVerificationKey = 3403, } // ################## EVENTS ################## diff --git a/packages/tokens/src/confidential/verifier/storage.rs b/packages/tokens/src/confidential/verifier/storage.rs index e579ab419..d129549ea 100644 --- a/packages/tokens/src/confidential/verifier/storage.rs +++ b/packages/tokens/src/confidential/verifier/storage.rs @@ -1,4 +1,5 @@ use soroban_sdk::{contracttype, panic_with_error, Bytes, Env}; +use ultrahonk_soroban_verifier::UltraHonkVerifier; use crate::confidential::verifier::{ emit_verification_key_registered, emit_verification_key_updated, CircuitType, VerifierError, @@ -31,6 +32,42 @@ pub fn get_verification_key(e: &Env, circuit_type: CircuitType) -> Bytes { .unwrap_or_else(|| panic_with_error!(e, VerifierError::VerificationKeyNotRegistered)) } +/// Verifies an UltraHonk `proof` for `public_inputs` against the verification +/// key registered under `circuit_type`, returning `true` iff the proof is +/// valid. +/// +/// The UltraHonk backend lives in the external +/// [`ultrahonk_soroban_verifier`](https://github.com/NethermindEth/rs-soroban-ultrahonk) +/// crate. A malformed proof or mismatched public inputs is not an error here: +/// the function simply returns `false` and lets the caller decide how to react +/// (the confidential token reverts with its own `InvalidProof` error). +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `circuit_type` - The circuit the proof was produced against. +/// * `public_inputs` - The serialized public inputs the prover committed to. +/// * `proof` - The serialized UltraHonk proof. +/// +/// # Errors +/// +/// * [`VerifierError::VerificationKeyNotRegistered`] - When `circuit_type` has +/// no registered key. +/// * [`VerifierError::InvalidVerificationKey`] - When the registered key cannot +/// be parsed as a valid UltraHonk verification key. +pub fn verify_proof( + e: &Env, + circuit_type: CircuitType, + public_inputs: &Bytes, + proof: &Bytes, +) -> bool { + let vk = get_verification_key(e, circuit_type); + let verifier = UltraHonkVerifier::new(e, &vk) + .unwrap_or_else(|_| panic_with_error!(e, VerifierError::InvalidVerificationKey)); + + verifier.verify(e, proof, public_inputs).is_ok() +} + // ################## CHANGE STATE ################## /// Registers an UltraHonk verification key under a fresh [`CircuitType`]. diff --git a/packages/tokens/src/confidential/verifier/test.rs b/packages/tokens/src/confidential/verifier/test.rs index aca870426..bb3bac300 100644 --- a/packages/tokens/src/confidential/verifier/test.rs +++ b/packages/tokens/src/confidential/verifier/test.rs @@ -4,7 +4,7 @@ use soroban_sdk::{contract, testutils::Events, Bytes, Env}; use crate::confidential::verifier::{ storage::{ - get_verification_key, register_verification_key, update_verification_key, + get_verification_key, register_verification_key, update_verification_key, verify_proof, VerifierStorageKey, }, CircuitType, @@ -140,3 +140,30 @@ fn storage_key_round_trip() { assert_eq!(stored, verification_key); }); } + +#[test] +#[should_panic(expected = "Error(Contract, #3401)")] +fn verify_proof_unregistered_panics_with_not_registered() { + let e = Env::default(); + let address = e.register(MockContract, ()); + + e.as_contract(&address, || { + let empty = Bytes::new(&e); + let _ = verify_proof(&e, CircuitType::Register, &empty, &empty); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #3403)")] +fn verify_proof_malformed_vk_panics_with_invalid_vk() { + let e = Env::default(); + let address = e.register(MockContract, ()); + + e.as_contract(&address, || { + // A 32-byte blob is not a valid UltraHonk verification key, so the + // backend rejects it before any proof is examined. + register_verification_key(&e, CircuitType::Transfer, &verification_key_bytes(&e, 0xab)); + let empty = Bytes::new(&e); + let _ = verify_proof(&e, CircuitType::Transfer, &empty, &empty); + }); +}