Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .github/workflows/noir.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ jobs:
- name: nargo test
run: nargo test

- name: Check that no witness reaches a lookup table
# Proofs are non-zk, so lookup read counts are committed in the
# clear; the script header explains the leak this rules out.
run: ./scripts/check_witness_lookups.sh

- name: Diff extracted VKs against committed artifacts
# Re-extracts both VK forms for each per-operation circuit using the
# pinned bb version and compares against the committed `vks/*.vk.json`
Expand Down
14 changes: 14 additions & 0 deletions packages/confidential/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,20 @@ Noir sources, build scripts, and the pinned UltraHonk VKs that the verifier regi

Start with [`docs/overview.md`](./docs/overview.md) for the design without mathematics, then read the protocol specification from [`docs/protocol/README.md`](./docs/protocol/README.md) onward; each file links to the next. [`docs/README.md`](./docs/README.md) maps the whole set: reading paths per role, the compliance, selective-disclosure, indexer, and SDK companions, and the citation rules that CI enforces with lychee.

## Verifier backend

[`ConfidentialVerifier::verify_proof`] delegates to Nethermind's UltraHonk verifier, `ultrahonk_soroban_verifier` from [rs-soroban-ultrahonk](https://github.com/NethermindEth/rs-soroban-ultrahonk). It verifies Barretenberg 0.87.0 UltraHonk proofs over a Keccak transcript; [`circuits/vks/README.md`](./circuits/vks/README.md) gives the exact proving recipe.

### Zero-knowledge setting

The verifier implements only the non-zk UltraHonk flavour, so proofs are generated without `bb prove --zk`. This does not affect soundness. The zk flavour masks every committed witness polynomial with fresh randomness, so a proof reveals nothing about the witness by construction; the non-zk flavour commits to the witness as it is, so a proof is a deterministic function of the witness and carries no such guarantee.

The circuits close one known leak. When a base point is a constant equal to one of Barretenberg's precomputed generators, as the Pedersen generators `G` and `H` are, Barretenberg performs the scalar multiplication with fixed-base plookup tables, and the read counts of those tables would expose windows of the secret scalars. `commit` and `scalar_mul` therefore pass the generators as witnesses, so Barretenberg uses its variable-base path, which reads ROM tables instead. `circuits/scripts/check_witness_lookups.sh` checks in CI that no `multi_scalar_mul` takes a constant base point and that every blackbox is on an allowlist of those Barretenberg 0.87.0 lowers without plookup.

This removes the plookup channel only and does not make proofs private. Whether other parts of a non-zk proof, such as the wire commitments or the opened evaluations, leak private values has not been established, beyond one known case: the commitment to the lookup inverses and its opened evaluation determine two BN254 points whose discrete logarithm is the prover's long-term key. Recovering the key from them is a discrete-logarithm problem in BN254, estimated at about 100 bits of security, the level the proof system's soundness already relies on.

Zero knowledge requires the zk flavour of the verifier. Nethermind has [zk verification in progress](https://github.com/NethermindEth/rs-soroban-ultrahonk/pull/38); adopting it changes the proving recipe and the verifier dependency, not the circuits or the verification keys.

## Packaging

The crate's packaging follows from its UltraHonk verifier backend
Expand Down
5 changes: 5 additions & 0 deletions packages/confidential/circuits/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Run from this directory. CI is `.github/workflows/noir.yml`.
| Per-primitive constraint counts | `nargo info` |
| Compile one circuit | `nargo compile --package circuit_<name>` |
| Regenerate verification keys | `./scripts/extract_vks.sh` |
| Check no witness reaches a lookup table | `./scripts/check_witness_lookups.sh` |

There is no `nargo fmt` step in CI and no formatter config — Noir formatting is unenforced. The workflow triggers only on `circuits/**`, so a change to the Rust-side parity test in `../test.rs` does **not** run this job.

Expand All @@ -28,6 +29,10 @@ It appears in all fourteen package `Nargo.toml` files — the workspace manifest

`_acct_f` in `register/src/main.nr` is referenced by no gate and looks like dead code. It is the replay binding: UltraHonk absorbs every public input into the transcript, so a proof produced for one account fails when the contract assembles the blob for another. Removing it lets anyone replay a legitimate registration's published proof and payload to mint duplicate-key accounts. `clawback/src/main.nr` carries four such bindings — `addr_f`, `_acct_f`, `_dest_f`, `_nonce` — removing `_dest_f` lets a compromised clawback signer settle a witness to a destination of its own choosing, and removing `_nonce` lets an approved proof execute again once the commitments are restored. Each operation circuit declares its exact public-input count in a header comment — clawback 11, withdraw 16, transfer / spender_transfer 25, set_spender 26 — and the count is part of the contract with the on-chain assembler.

### `as_witness` in `commit` and `scalar_mul` is not dead code

`as_witness(G, value)` looks like an identity wrapper around a constant. It keeps bb off its fixed-base lookup tables: proofs are non-zk, so the read-count commitments would publish which table rows each secret scalar's windows hit. The doc comment on `as_witness` lists what keeps the generator a witness. `scripts/check_witness_lookups.sh` fails CI on any `multi_scalar_mul` with a constant base point and on any blackbox outside its plookup-free allowlist; extend the allowlist only after confirming bb lowers the new blackbox without lookups.

### Package names are load-bearing

Directory `transfer/` is package `circuit_transfer`; `gadgets/commit/` is `gadget_commit`; the library is `stellar_confidential_lib`. `scripts/extract_vks.sh` derives `circuit_${name}` and `target/${pkg}.json` from a bare name, and every row of `constraints.baseline` is keyed on the package name. Renaming a directory without matching the package name breaks both. Gadgets depend on the lib via `../../lib`, operations via `../lib`.
Expand Down
24 changes: 16 additions & 8 deletions packages/confidential/circuits/constraints.baseline
Original file line number Diff line number Diff line change
Expand Up @@ -20,34 +20,42 @@
| Package | Function | Expression Width | ACIR Opcodes | Brillig Opcodes |
| circuit_clawback | decompose_hint | N/A | N/A | 30 |
| circuit_clawback | lte_hint | N/A | N/A | 33 |
| circuit_clawback | main | Bounded { width: 4 } | 64 | 63 |
| circuit_clawback | main | Bounded { width: 4 } | 84 | 91 |
| circuit_clawback | point_hint | N/A | N/A | 28 |
| circuit_register | decompose_hint | N/A | N/A | 30 |
| circuit_register | directive_invert | N/A | N/A | 9 |
| circuit_register | lte_hint | N/A | N/A | 33 |
| circuit_register | main | Bounded { width: 4 } | 33 | 72 |
| circuit_register | main | Bounded { width: 4 } | 41 | 100 |
| circuit_register | point_hint | N/A | N/A | 28 |
| circuit_set_spender | decompose_hint | N/A | N/A | 30 |
| circuit_set_spender | directive_invert | N/A | N/A | 9 |
| circuit_set_spender | lte_hint | N/A | N/A | 33 |
| circuit_set_spender | main | Bounded { width: 4 } | 135 | 72 |
| circuit_set_spender | main | Bounded { width: 4 } | 175 | 100 |
| circuit_set_spender | point_hint | N/A | N/A | 28 |
| circuit_spender_transfer | decompose_hint | N/A | N/A | 30 |
| circuit_spender_transfer | directive_invert | N/A | N/A | 9 |
| circuit_spender_transfer | lte_hint | N/A | N/A | 33 |
| circuit_spender_transfer | main | Bounded { width: 4 } | 136 | 72 |
| circuit_spender_transfer | main | Bounded { width: 4 } | 180 | 100 |
| circuit_spender_transfer | point_hint | N/A | N/A | 28 |
| circuit_transfer | decompose_hint | N/A | N/A | 30 |
| circuit_transfer | directive_invert | N/A | N/A | 9 |
| circuit_transfer | lte_hint | N/A | N/A | 33 |
| circuit_transfer | main | Bounded { width: 4 } | 134 | 72 |
| circuit_transfer | main | Bounded { width: 4 } | 178 | 100 |
| circuit_transfer | point_hint | N/A | N/A | 28 |
| circuit_withdraw | decompose_hint | N/A | N/A | 30 |
| circuit_withdraw | directive_invert | N/A | N/A | 9 |
| circuit_withdraw | lte_hint | N/A | N/A | 33 |
| circuit_withdraw | main | Bounded { width: 4 } | 95 | 72 |
| circuit_withdraw | main | Bounded { width: 4 } | 123 | 100 |
| circuit_withdraw | point_hint | N/A | N/A | 28 |
| gadget_assert_on_curve | main | Bounded { width: 4 } | 2 | 0 |
| gadget_commit | decompose_hint | N/A | N/A | 30 |
| gadget_commit | lte_hint | N/A | N/A | 33 |
| gadget_commit | main | Bounded { width: 4 } | 23 | 63 |
| gadget_commit | main | Bounded { width: 4 } | 31 | 91 |
| gadget_commit | point_hint | N/A | N/A | 28 |
| gadget_ecdh | decompose_hint | N/A | N/A | 30 |
| gadget_ecdh | lte_hint | N/A | N/A | 33 |
| gadget_ecdh | main | Bounded { width: 4 } | 17 | 63 |
| gadget_ecdh | main | Bounded { width: 4 } | 21 | 91 |
| gadget_ecdh | point_hint | N/A | N/A | 28 |
| gadget_encrypt_amount | main | Bounded { width: 4 } | 4 | 0 |
| gadget_poseidon_with_domain | main | Bounded { width: 4 } | 3 | 0 |
| gadget_sponge_squeeze_2 | main | Bounded { width: 4 } | 4 | 0 |
Expand Down
40 changes: 36 additions & 4 deletions packages/confidential/circuits/lib/src/lib.nr
Original file line number Diff line number Diff line change
Expand Up @@ -155,18 +155,50 @@ pub mod domain {

// ################## CORE PRIMITIVES ##################

unconstrained fn point_hint(p: EmbeddedCurvePoint, _anchor: Field) -> EmbeddedCurvePoint {
p
}

/// Returns `p` as a witness. Barretenberg routes a multi_scalar_mul through its
/// fixed-base lookup tables when a base point is an ACIR constant equal to one of
/// its precomputed generators (G and H are) and the scalar is a witness; the
/// non-ZK proof's read-count and read-tag commitments then depend on which table
/// rows the scalars' 9-bit windows hit, and how often. The hint takes a witness
/// `anchor` so the compiler does not fold it back to a constant, and `assert_is`
/// must run after the multiplication: an equality asserted before it lets the
/// compiler substitute the constant back. `scripts/check_witness_lookups.sh`
/// guards both.
fn as_witness(p: EmbeddedCurvePoint, anchor: Field) -> EmbeddedCurvePoint {
// Safety: every caller constrains the result equal to `p` via `assert_is`.
unsafe { point_hint(p, anchor) }
}

fn assert_is(w: EmbeddedCurvePoint, p: EmbeddedCurvePoint) {
assert(w.x == p.x);
assert(w.y == p.y);
assert(w.is_infinite == p.is_infinite);
}

/// Pedersen commitment `v*G + r*H` on Grumpkin. docs/protocol/primitives.md#pedersen-commitments.
pub fn commit(value: Field, randomness: Field) -> EmbeddedCurvePoint {
multi_scalar_mul(
[G, H],
let g = as_witness(G, value);
let h = as_witness(H, randomness);
let out = multi_scalar_mul(
[g, h],
[EmbeddedCurveScalar::from_field(value), EmbeddedCurveScalar::from_field(randomness)],
)
);
assert_is(g, G);
assert_is(h, H);
out
}

/// Scalar multiplication `s*P` on Grumpkin. Used for `Y = sk*H`, `PVK = vk*H`,
/// `R_e = r_e*H`, and as the building block under `ecdh`.
pub fn scalar_mul(scalar: Field, point: EmbeddedCurvePoint) -> EmbeddedCurvePoint {
multi_scalar_mul([point], [EmbeddedCurveScalar::from_field(scalar)])
let p = as_witness(point, scalar);
let out = multi_scalar_mul([p], [EmbeddedCurveScalar::from_field(scalar)]);
assert_is(p, point);
out
}

/// ECDH shared-secret scalar extraction per docs/protocol/primitives.md#elliptic-curve-diffie-hellman: computes
Expand Down
74 changes: 74 additions & 0 deletions packages/confidential/circuits/scripts/check_witness_lookups.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# Fails if any compiled package could feed witness data into Barretenberg's
# plookup argument.
#
# Proofs are generated without `--zk`, so `lookup_read_counts` and
# `lookup_read_tags` are committed in the clear and every lookup whose table
# row depends on a witness exposes that row. bb lowers MULTI_SCALAR_MUL to its
# fixed-base lookup tables whenever a base point is an ACIR constant equal to
# one of its precomputed generators, which `G` and `H` are; `commit` and
# `scalar_mul` in `lib/src/lib.nr` pass them as witnesses to prevent that.
# This script inspects the compiled ACIR for the two ways that can regress: a
# blackbox outside the plookup-free set below, or a MULTI_SCALAR_MUL base
# point with a constant coordinate.
#
# Requires the pinned `nargo` (which ships `noir-inspector`) declared in
# `.github/workflows/noir.yml`. Run from anywhere; the script anchors to the
# circuits/ root.
set -euo pipefail

cd "$(dirname "$0")/.."

# Blackboxes that bb 0.87.0 lowers without plookup: POSEIDON2_PERMUTATION to
# Poseidon2 gates, RANGE to delta-range gates, MULTI_SCALAR_MUL with witness
# bases to ROM-based Straus tables.
ALLOWED="MULTI_SCALAR_MUL POSEIDON2_PERMUTATION RANGE"

nargo compile --workspace >/dev/null

status=0
for artifact in target/*.json; do
if ! noir-inspector print-acir "$artifact" | awk -v file="$artifact" -v allowed="$ALLOWED" '
BEGIN { n = split(allowed, a, " "); for (i = 1; i <= n; i++) ok[a[i]] = 1 }
match($0, /BLACKBOX::[A-Z0-9_]+/) {
name = substr($0, RSTART + 10, RLENGTH - 10)
if (!(name in ok)) {
printf "%s: BLACKBOX::%s is not in the plookup-free set\n", file, name
bad = 1
next
}
if (name != "MULTI_SCALAR_MUL") next
# Operands are 3 per point (x, y, is_infinite), then 2 per scalar.
ops = $0
sub(/^[^[]*\[/, "", ops)
sub(/\].*$/, "", ops)
k = 0
while (match(ops, /\([^)]*\)/)) {
op = substr(ops, RSTART + 1, RLENGTH - 2)
sub(/,.*/, "", op)
vals[k++] = op
ops = substr(ops, RSTART + RLENGTH)
}
if (k == 0 || k % 5 != 0) {
printf "%s: cannot parse MULTI_SCALAR_MUL operands: %s\n", file, $0
bad = 1
next
}
for (p = 0; p < k / 5; p++) {
if (vals[3 * p] !~ /^_/ || vals[3 * p + 1] !~ /^_/) {
printf "%s: MULTI_SCALAR_MUL takes a constant base point\n", file
bad = 1
break
}
}
Comment thread
brozorec marked this conversation as resolved.
Resolved
}
END { exit bad }
'; then
status=1
fi
done

if [ "$status" -ne 0 ]; then
echo "Witness data can reach a lookup table; see the header of $0." >&2
fi
exit "$status"
Binary file modified packages/confidential/circuits/vks/clawback.vk.bin
Binary file not shown.
Loading
Loading