Skip to content

Commit 005cad5

Browse files
authored
Merge pull request #924 from kaynaomi-oss/feature/870-nullifier-registry
ZK: Extract nullifier registry into its own module
2 parents 4f13d26 + 5bd4613 commit 005cad5

4 files changed

Lines changed: 66 additions & 41 deletions

File tree

contracts/contracts/zk-payment-verifier/src/commitment.rs

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,3 @@ pub fn compute_commitment(
1919

2020
env.crypto().sha256(&payload).into()
2121
}
22-
23-
/// Compute nullifier: SHA-256("syncro:payment:v1" || blinding_factor || service_id)
24-
/// Deterministic per (blinding_factor, service_id) pair — prevents double-proving.
25-
pub fn compute_nullifier(
26-
env: &Env,
27-
blinding_factor: &BytesN<32>,
28-
service_id: &Bytes,
29-
) -> BytesN<32> {
30-
let mut payload = Bytes::from_slice(env, b"syncro:payment:v1");
31-
payload.append(&Bytes::from_slice(env, &blinding_factor.to_array()));
32-
payload.append(&service_id.clone());
33-
34-
env.crypto().sha256(&payload).into()
35-
}

contracts/contracts/zk-payment-verifier/src/lib.rs

Lines changed: 5 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
#![no_std]
22

3-
use soroban_sdk::{contract, contractimpl, contracttype, Bytes, BytesN, Env, Map};
3+
use soroban_sdk::{contract, contractimpl, contracttype, Bytes, BytesN, Env};
44

55
pub mod commitment;
6+
pub mod nullifier;
67

78
#[contracttype]
89
#[derive(Clone)]
@@ -39,35 +40,13 @@ impl ZkPaymentVerifier {
3940
return false;
4041
}
4142

42-
let nullifier = commitment::compute_nullifier(&env, &blinding_factor, &service_id);
43-
44-
let mut nullifiers: Map<BytesN<32>, bool> = env
45-
.storage()
46-
.persistent()
47-
.get(&DataKey::Nullifiers)
48-
.unwrap_or(Map::new(&env));
49-
50-
if nullifiers.contains_key(nullifier.clone()) {
51-
return false;
52-
}
53-
54-
nullifiers.set(nullifier, true);
55-
env.storage()
56-
.persistent()
57-
.set(&DataKey::Nullifiers, &nullifiers);
58-
59-
true
43+
let computed_nullifier = nullifier::compute_nullifier(&env, &blinding_factor, &service_id);
44+
nullifier::record(&env, computed_nullifier)
6045
}
6146

6247
/// Check if a nullifier has already been used.
6348
pub fn is_nullifier_used(env: Env, nullifier: BytesN<32>) -> bool {
64-
let nullifiers: Map<BytesN<32>, bool> = env
65-
.storage()
66-
.persistent()
67-
.get(&DataKey::Nullifiers)
68-
.unwrap_or(Map::new(&env));
69-
70-
nullifiers.contains_key(nullifier)
49+
nullifier::is_used(&env, &nullifier)
7150
}
7251
}
7352

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
//! On-chain nullifier registry, preventing the same ZK proof secret
2+
//! (a `blinding_factor` + `service_id` pair) from being used to prove the
3+
//! same payment twice.
4+
//!
5+
//! All submitted nullifiers live in a single persistent `Map`, so every
6+
//! lookup/insert reads and rewrites the whole set -- storage and per-call
7+
//! cost grow linearly with the number of nullifiers ever submitted. That's
8+
//! an accepted MVP tradeoff (see the issue this module was built for);
9+
//! revisit if proof volume grows large enough to make it a bottleneck.
10+
11+
use soroban_sdk::{Bytes, BytesN, Env, Map};
12+
13+
use crate::DataKey;
14+
15+
/// Compute a nullifier: `SHA-256("syncro:payment:v1" || blinding_factor || service_id)`.
16+
///
17+
/// Deterministic per `(blinding_factor, service_id)` pair, so the same
18+
/// secret can never produce two different nullifiers. It reveals nothing
19+
/// about the underlying payment (amount, user, timestamp) -- it's a
20+
/// one-way hash of values only the prover knows, so an observer learns
21+
/// only "some proof already used this nullifier," never which payment.
22+
pub fn compute_nullifier(
23+
env: &Env,
24+
blinding_factor: &BytesN<32>,
25+
service_id: &Bytes,
26+
) -> BytesN<32> {
27+
let mut payload = Bytes::from_slice(env, b"syncro:payment:v1");
28+
payload.append(&Bytes::from_slice(env, &blinding_factor.to_array()));
29+
payload.append(&service_id.clone());
30+
31+
env.crypto().sha256(&payload).into()
32+
}
33+
34+
fn load(env: &Env) -> Map<BytesN<32>, bool> {
35+
env.storage()
36+
.persistent()
37+
.get(&DataKey::Nullifiers)
38+
.unwrap_or(Map::new(env))
39+
}
40+
41+
/// Whether `nullifier` has already been recorded.
42+
pub fn is_used(env: &Env, nullifier: &BytesN<32>) -> bool {
43+
load(env).contains_key(nullifier.clone())
44+
}
45+
46+
/// Record `nullifier` as used.
47+
///
48+
/// Returns `true` if it was freshly recorded, `false` if it was already
49+
/// present (a duplicate -- the existing entry is left untouched).
50+
pub fn record(env: &Env, nullifier: BytesN<32>) -> bool {
51+
let mut nullifiers = load(env);
52+
if nullifiers.contains_key(nullifier.clone()) {
53+
return false;
54+
}
55+
nullifiers.set(nullifier, true);
56+
env.storage()
57+
.persistent()
58+
.set(&DataKey::Nullifiers, &nullifiers);
59+
true
60+
}

contracts/contracts/zk-payment-verifier/src/test.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ fn test_is_nullifier_used() {
137137
let service_id = Bytes::from_slice(&env, b"service_netflix");
138138
let blinding_factor = BytesN::from_array(&env, &[42u8; 32]);
139139

140-
let nullifier = commitment::compute_nullifier(&env, &blinding_factor, &service_id);
140+
let nullifier = nullifier::compute_nullifier(&env, &blinding_factor, &service_id);
141141

142142
assert!(!client.is_nullifier_used(&nullifier));
143143

0 commit comments

Comments
 (0)