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
19 changes: 19 additions & 0 deletions Cargo.lock

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

10 changes: 10 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
24 changes: 24 additions & 0 deletions examples/confidential/verifier/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"] }
66 changes: 66 additions & 0 deletions examples/confidential/verifier/src/contract.rs
Original file line number Diff line number Diff line change
@@ -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 {}
5 changes: 5 additions & 0 deletions examples/confidential/verifier/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#![no_std]

pub mod contract;
#[cfg(test)]
mod test;
93 changes: 93 additions & 0 deletions examples/confidential/verifier/src/test.rs
Original file line number Diff line number Diff line change
@@ -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,
);
}
5 changes: 4 additions & 1 deletion packages/tokens/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
94 changes: 94 additions & 0 deletions packages/tokens/src/confidential/circuits/scripts/build_vk_bins.sh
Original file line number Diff line number Diff line change
@@ -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/<circuit>.vk.bin`, one file per per-operation circuit, alongside the
# human-readable `vks/<circuit>.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."
Loading
Loading