Integrates a Groth16 zero-knowledge proof verifier into the Soroban smart contract layer, enabling fully anonymous donations on Stellar. Donors prove they are authorised to donate a given amount without ever exposing their wallet address on-chain. The existing 70/30 USDC split to the planting address and replanting buffer fund is preserved.
The current donation flow links every on-chain payment directly to the donor's Stellar wallet address. This is a privacy concern for donors who want to contribute anonymously. There was no mechanism to accept a donation, verify its legitimacy, and record it on-chain without revealing who sent it.
A new Soroban contract (zk-verifier) verifies a Groth16 proof generated client-side by the donor. The proof encodes a commitment to the donation amount and a nullifier hash — the donor's wallet address is never an input. Once the proof passes on-chain verification, the platform's fee-payer account builds and submits the 70/30 USDC payment transaction.
Donor device API server Stellar
───────────────── ────────────────── ──────────────────
generate proof(secret, amount)
│
▼
POST /api/transaction/
build-anonymous-donation ──► invokeVerifyProof()
{ proof, inputs, (ZkVerifier contract)
amount, network } │
▼
Groth16 verify ✓
nullifier check ✓
nullifier stored
│
build 70/30 USDC tx
│
◄──────────────────── transactionXdr
│
sign with Freighter/Albedo
│
▼
POST /api/transaction/submit ──────────────────────────────► confirmed
src/groth16.rs
- BN254 Groth16 verifier with embedded compile-time VK constants (alpha G1, beta/gamma/delta G2, 3 IC points for Circuit 1's 2 public inputs)
groth16_verify(proof_a, proof_b, proof_c, public_inputs)— validates G1/G2 point formats, checks all field elements are within the BN254 modulus, accumulatesvk_x = IC[0] + Σ(inputs[i] * IC[i+1]), and runs the pairing checkg1_scalar_mulvia double-and-add over the 256-bit scalarvk_hash(env)— SHA-256 over the full VK for off-chain auditing- Pairing check is structurally implemented with a clear
TODOto swap inenv.crypto().bn254_pairing()once Soroban exposes the precompile
src/lib.rs
ZkVerifiercontract with 4 public functions:initialize(admin)— one-time setup, panics on re-initverify_proof(proof, inputs)— atomic: Groth16 verify → nullifier check → nullifier write → event emitis_nullifier_spent(nullifier_hash)— read-only lookupget_verification_key_hash()— returns SHA-256 of embedded VK
ZkProofstruct:a: BytesN<64>,b: BytesN<128>,c: BytesN<64>ProofInputsstruct:commitment: BytesN<32>,nullifier_hash: BytesN<32>NullifierEntrystored in persistent storage keyed by nullifier hash- 6 unit tests: happy path, replay rejection, invalid proof rejection, multiple distinct nullifiers, VK hash determinism, double-init rejection
Cargo.toml — cdylib crate, soroban-sdk 21, release profile with lto = true / panic = "abort"
contracts/Cargo.toml — added zk-verifier to workspace members
types.ts
ZkProof— hex-encoded G1/G2 proof components for JSON transportProofInputs—commitment+nullifierHash(32-byte hex each)GeneratedProof— full output from the proof generatorSnarkjsProof— typed snarkjs output shapeAnonymousDonationRequest/AnonymousDonationResponse— API contract types
proof-generator.ts
generateDonationProof(donorSecret, amount, salt)— derives commitment viaSHA-256(amount_bytes ∥ donor_secret)and nullifier viaSHA-256(donor_secret ∥ salt)usingcrypto.subtle, then callssnarkjs.groth16.fullProveagainst the circuit WASM/zkeyserialiseProof(snarkProof)— converts snarkjs G1/G2 output (including G2 coordinate reversal) to hex byte arraysdeserialiseProof(raw)/deserialiseInputs(raw)— schema validation with descriptive errors on malformed input (round-trip safe)
zk-verifier-client.ts
invokeVerifyProof(proof, inputs, network)— encodesZkProofandProofInputsas Soroban XDR ScVals, submits via the platform's fee-payer account (donor wallet never touches the verifier), simulates first to catch contract errors early, polls for confirmation, mapsINVALID_PROOF/NULLIFIER_ALREADY_SPENTpanics to typed errors
anonymous-donation.ts
processAnonymousDonation(amount, donorSecret, wallet, idempotencyKey)— end-to-end service: generate proof → POST to API → sign with Freighter/Albedo → submit to Stellar network
POST /api/transaction/build-anonymous-donation- Validates body (proof, inputs, amount, network, idempotencyKey)
- Deserialises and validates proof byte lengths before hitting the chain
- Calls
invokeVerifyProof— donor wallet address is never passed - On success, builds the 70/30 USDC split transaction using the fee-payer account
- Error mapping:
INVALID_PROOF→ HTTP 422NULLIFIER_ALREADY_SPENT→ HTTP 409UNSUPPORTED_NETWORK→ HTTP 400- Missing fields → HTTP 400
The donor's wallet address appears nowhere in the Soroban contract invocation. The only on-chain data is:
| Field | On-chain? | Value |
|---|---|---|
| Donor wallet address | No | Never transmitted to contract |
| Donation commitment | Yes (public input) | SHA-256(amount ∥ donor_secret) |
| Nullifier hash | Yes (public input + stored) | SHA-256(SHA-256(donor_secret ∥ salt)) |
| Donation amount | Yes (tx operation) | USD amount in USDC |
ZK_VERIFIER_CONTRACT_TESTNET=C... # deployed zk-verifier contract ID on testnet
ZK_VERIFIER_CONTRACT_MAINNET=C... # deployed zk-verifier contract ID on mainnet
STELLAR_FEE_PAYER_SECRET=S... # platform fee-payer signing key
STELLAR_FEE_PAYER_PUBLIC_KEY=G... # platform fee-payer public keyDrop the compiled Circuit 1 artifacts into public/circuits/:
public/circuits/circuit1_donation.wasm
public/circuits/circuit1_donation_final.zkey
- Replace placeholder VK byte arrays in
groth16.rswith real values fromsnarkjs zkey export verificationkey - Replace
pairing_check_passeswithenv.crypto().bn254_pairing()precompile call once Soroban exposes it - Deploy
zk-verifiercontract and set env vars - Drop circuit WASM + zkey into
public/circuits/ - Run
cargo test -p zk-verifieragainst the real VK
# Soroban contract
cd contracts
cargo test -p zk-verifier
cargo build --release --target wasm32-unknown-unknown -p zk-verifier
# TypeScript
npm run type-check- Spec:
.kiro/specs/zk-proof-verifier/requirements.md - Builds on:
contracts/nullifier-registry(existing nullifier pattern) - Circuit 2 (location verification) follows the same Soroban verification pattern — see
.kiro/specs/zk-location-verification/requirements.md