# Build a Privacy-Preserving Badge System on Starknet
**TL;DR:** Clone the repo, install Noir/Barretenberg/Garaga, run `./zk-badges/generate-proof.sh --amount 15000 --threshold 1000 --donor-secret "mysecret123" --tier 1`, then call `sncast` with the generated calldata to mint a badge on Starknet Sepolia. That's the full loop: local proof → on-chain claim → badge minted.

You donated $150 to a sensitive cause. You want to tell the world "yes, I donated enough" without exposing the exact number - because blockchains never forget. This guide shows how to keep that promise: build a Noir circuit, wire up a Garaga verifier, and use Starknet to mint a badge that certifies the tier while hiding the amount.
## Why this matters (and why you might not care)
Here's the tension: **Legacy blockchains are transparent by default.** Donate on-chain and everyone sees your exact amount, wallet balance, and - even worse - the record is permanent. Traditional charities hide that info, but they become the centralized gatekeeper. Zero-knowledge lets you have it both ways: prove you met the bar, keep the number private, and rely on math instead of trust.
**But does this actually matter?** Probably not for most people. If you're donating to mainstream causes, privacy isn't urgent. But if you're in a place where certain causes are politically sensitive - LGBTQ+ organizations, opposition groups, whistleblower funds - or if you just hate the idea of your giving history being permanent public record, yes. It matters.
The argument: blockchains claim to be trustless. But transparency without privacy is just surveillance in a ledger. ZK proofs let you have one without the other.
## What you'll build
By the end, you'll have all of the following:
• A **Noir circuit** that generates ZK proofs (the math)
• Two **smart contracts** live on Starknet Sepolia (the blockchain)
• A **local proof pipeline** you control entirely (no external API required)
• The ability to **mint privacy badges** (the payoff)
**See it live right now in Sepolia:**
• [UltraKeccakHonkVerifier](https://sepolia.voyager.online/contract/0x022b20fef3764d09293c5b377bc399ae7490e60665797ec6654d478d74212669)
• [DonationBadge](https://sepolia.voyager.online/contract/0x077ca6f2ee4624e51ed6ea6d5ca292889ca7437a0c887bf0d63f055f42ad7010)
These contracts are *already deployed*. They're verifying proofs right now. You'll understand how by the end.
## How it works (the zk magic)
### Step 1: Make a commitment
You hash two things together:
• Your donation amount ($150)
• A secret string (like "mysecretkey123")
This creates one number, the **commitment**. Think of it as a locked box. Anyone can see the box exists, but nobody can see what's inside.
```
Commitment = Hash(DonationAmount, DonorSecret)
```
### Step 2: Generate a proof
You run a prover. It takes your inputs and generates a blob of numbers, about 8 KB, that proves two things:
1. "I know what's inside that locked box"
2. "The donation amount inside is at least $100"
**Crucially:** The proof doesn't reveal the amount. It just proves the claim.
### Step 3: Verify on-chain
A smart contract runs a verifier. The verifier checks the proof and asks: "Is this proof valid?" If yes, the contract mints you a badge. It never learns your actual donation amount. It only knows: "This proof is valid, which means someone donated at least $100".
```
Verifier: "Proof is valid?" → Contract: "Yes" → Badge: 🏅 Minted!
```
That's it. The magic is that verification is *fast* and *public*, but proof generation is *private*.
## The proof pipeline
**Data flows through three tools in sequence - and each one has a single job.**
```mermaid
%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#375a7f', 'primaryTextColor': '#ffffff', 'primaryBorderColor': '#375a7f', 'lineColor': '#5bc0de', 'secondaryColor': '#f8f9fa', 'secondaryTextColor': '#000000', 'tertiaryColor': '#ffffff', 'tertiaryTextColor': '#000000', 'tertiaryBorderColor': '#5bc0de'}}}%%
flowchart LR
subgraph Inputs["📥 Inputs"]
TOML["Your Secrets<br/>amount, secret<br/>threshold"]
end
subgraph Pipeline["🔧 Pipeline"]
NOIR["Noir<br/>Write constraints"]
BB["Barretenberg<br/>Generate proof"]
GARAGA["Garaga<br/>Make it Starknet-ready"]
end
subgraph Output["📤 Output"]
FELT["Calldata<br/>→ Starknet"]
end
TOML --> NOIR --> BB --> GARAGA --> FELT
```
**Left to right:** Your secrets go in. Each tool does exactly one job. Swap any piece (Noir version, prover backend, Garaga flavor) and the pipeline still works.
## The circuit logic
This tiny circuit is only doing two things, but they're the reasons your badge means anything at all:
### Check 1: commitment verification
**The question:** Does Poseidon(amount, secret) equal the public commitment?
**Why it matters:** This binds you to a specific donation amount without revealing it. If you later claim you donated $500 instead of $150, the hash won't match. The proof fails.
**The mechanism:** You computed the commitment long ago. You published it. Now you're trying to prove something about that commitment. The circuit verifies that your current computation matches what you promised earlier, with no room for adjustment afterward.
### Check 2: threshold verification
**The question:** Is the donation amount ≥ $100?
**Why it matters:** This is the actual qualification. You want to prove you gave enough. The circuit checks the number.
**The mechanism:** The private value `donation_amount` is compared against the public value `threshold`. If amount is too low, the constraint fails. The proof stops.
## The complete verification process
```mermaid
%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#375a7f', 'primaryTextColor': '#ffffff', 'primaryBorderColor': '#375a7f', 'lineColor': '#5bc0de', 'secondaryColor': '#f8f9fa', 'secondaryTextColor': '#000000', 'tertiaryColor': '#ffffff', 'tertiaryTextColor': '#000000', 'tertiaryBorderColor': '#5bc0de'}}}%%
flowchart TD
subgraph Public["🌐 Public (Everyone sees)"]
T[threshold: $100]
C[commitment: 0x2a5f...]
end
subgraph Private["🔒 Private (Only you know)"]
DA[amount: $150]
DS[secret: 'xyz']
end
DA --> HASH["Poseidon Hash"]
DS --> HASH
HASH --> CHECK1{hash ==<br/>commitment?}
C --> CHECK1
CHECK1 -->|Yes| CHECK2{amount >=<br/>threshold?}
T --> CHECK2
DA --> CHECK2
CHECK2 -->|Yes| VALID["✅ Valid"]
CHECK1 -->|No| INVALID["❌ Invalid"]
CHECK2 -->|No| INVALID
style Public fill:#f8f9fa,stroke:#375a7f,stroke-width:2px,color:#000000
style Private fill:#375a7f,stroke:#375a7f,stroke-width:2px,color:#ffffff
style VALID fill:#4ade80,stroke:#22c55e,stroke-width:2px,color:#ffffff
style INVALID fill:#f87171,stroke:#ef4444,stroke-width:2px,color:#ffffff
```
The two checks execute in order:
1. **Private Input Collection:** Prover loads the donation amount and secret phrase. Nobody else knows these values.
2. **Hashing:** Poseidon combines amount and secret into a single hash. This is the commitment. Think of it as a cryptographic fingerprint. Change even one bit of input, and the output changes completely.
3. **Check 1 - commitment match:** The circuit compares the computed hash against the public commitment. They must match exactly. If they don't, the proof fails immediately. No second check. No partial credit.
4. **Check 2 - threshold:** The circuit compares the donation amount against the threshold. If amount ≥ threshold, the check passes. If amount < threshold, the check fails.
5. **Result:** If both checks pass, the proof is valid. The verifier learns only that the constraint is satisfied. The verifier doesn't learn the amount. The verifier doesn't learn the secret.
## Quick start (GitHub Codespaces)
Launch Codespaces → run the install snippet → you're ready. Prefer local? Jump to "Toolchain Installation."
### Option 1: GitHub Codespaces (recommended)
1. Go to your repository on GitHub
2. Click **Code** → **Codespaces** → **Create codespace on main**
3. Wait 2-3 minutes for the environment to build
4. In the terminal, run:
```bash
# Install Noir
curl -L https://noirup.dev | bash
source ~/.bashrc
noirup --version 1.0.0-beta.1
# Install Barretenberg
curl -L https://bbup.dev | bash
source ~/.bashrc
bbup --version 0.67.0
# Install Garaga (Python)
pip install garaga==0.15.5
# Install Bun (JS runtime)
curl -fsSL https://bun.sh/install | bash
source ~/.bashrc
# Install project dependencies
bun install
# Start the API server
bun run api
```
Done. Your proof API is running on `http://localhost:3001`.
### Option 2: Local setup
Skip to "Toolchain Installation" below, then come back here.
## Getting testnet STRK
Badge claims cost ~0.001 STRK each. You need testnet funds.
### Pick a faucet
| Faucet | URL | Request |
|--------|-----|---------|
| **Official** | https://faucet.starknet.io/ | 0.1 STRK |
| **Alchemy** | https://www.alchemy.com/faucets/starknet-sepolia | 0.1 STRK (requires account) |
## Toolchain installation (if not using Codespaces)
### Why these specific versions?
Versions matter in cryptography. Mixing versions produces incompatible proofs. These versions work together:
| Tool | Version | Why |
|------|---------|-----|
| **Noir** | 1.0.0-beta.1 | Circuit language (compiles your logic) |
| **Barretenberg** | 0.67.0 | Proof prover (generates the proof) |
| **Garaga** | 0.15.5 | Verifier generator (makes Cairo code) |
| **Scarb** | 2.9.2 | Cairo build tool (compiles contracts) |
### macOS
```bash
# Noir
curl -L https://noirup.dev | bash
source ~/.zshrc
noirup --version 1.0.0-beta.1
# Barretenberg (may fail on ARM64 - use Codespaces if it does)
curl -L https://bbup.dev | bash
source ~/.zshrc
bbup --version 0.67.0
# Garaga (requires Python 3.10)
brew install python@3.10
python3.10 -m pip install garaga==0.15.5
# Scarb
curl --proto '=https' --tlsv1.2 -sSf https://docs.swmansion.com/scarb/install.sh | sh -s -- -v 2.9.2
# Starknet Foundry
curl -L https://raw.githubusercontent.com/foundry-rs/starknet-foundry/master/scripts/install.sh | sh
snfoundryup
# Bun
curl -fsSL https://bun.sh/install | bash
source ~/.zshrc
```
### Linux (Ubuntu 22.04+)
```bash
# Same as macOS - just use `source ~/.bashrc` instead of `~/.zshrc`
```
### Verify everything installed
```bash
#!/bin/bash
echo "=== Verifying ZK Toolchain ==="
nargo --version | grep -q "1.0.0-beta.1" && echo "✅ Noir" || echo "❌ Noir"
bb --version | grep -q "0.67.0" && echo "✅ Barretenberg" || echo "❌ Barretenberg"
garaga --version | grep -q "0.15.5" && echo "✅ Garaga" || echo "❌ Garaga"
scarb --version | grep -q "2.9.2" && echo "✅ Scarb" || echo "❌ Scarb"
bun --version && echo "✅ Bun" || echo "❌ Bun"
```
If all green, you're done. If any red, reinstall that tool.
> **Docker?** Not yet. Noir + bb are still too brittle inside containers, so stick with Codespaces or a native setup.
## Environment setup
Create `.env.local` (add to `.gitignore`):
```bash
# Starknet Account (NEVER COMMIT!)
STARKNET_ACCOUNT_ADDRESS=0x...
STARKNET_PRIVATE_KEY=0x...
# RPC Endpoints
SEPOLIA_RPC_URL=...
# Proof API (for local testing)
PROOF_API_URL=http://localhost:3001/api/generate-proof
```
## The Noir circuit
Time to open the editor. This tiny function is the whole promise - read it slowly before you run anything.
### File: `zk-badges/donation_badge/src/main.nr`
```noir
use std::hash::poseidon::bn254::hash_2;
fn main(
// Public inputs (everyone sees these)
threshold: pub u64,
commitment: pub Field,
// Private inputs (only the prover knows)
donation_amount: u64,
donor_secret: Field
) {
// Constraint 1: Verify the commitment matches
let computed_commitment = hash_2([
donation_amount as Field,
donor_secret
]);
assert(computed_commitment == commitment);
// Constraint 2: Verify donation meets threshold
assert(donation_amount >= threshold);
}
```
### What this does
The `pub` keyword means "public." Everyone sees `threshold` and `commitment`. But `donation_amount` and `donor_secret`? Those are private. They're used to *generate* the proof but never appear in it.
The two assertions are the constraints. If either fails, the proof fails.
- **First:** "Does my commitment hash match the public commitment?" (Binds you to your claimed amount)
- **Second:** "Is my amount at least the threshold?" (Proves you qualify)
Both must pass. That's the whole circuit.
### Config: `zk-badges/donation_badge/Nargo.toml`
```toml
[package]
name = "donation_badge"
type = "bin"
authors = ["Your Name"]
compiler_version = ">=1.0.0-beta.1"
```
The `>=1.0.0-beta.1` allows newer versions, but this tutorial was tested with exactly `1.0.0-beta.1`. Other versions may produce incompatible proofs.
### Test the circuit
Create `zk-badges/donation_badge/src/main.test.nr`:
```noir
use crate::main;
use std::hash::poseidon::bn254::hash_2;
#[test]
fn test_valid_donation() {
let donor_secret = 0x123abc as Field;
let donation_amount = 15000 as u64;
let threshold = 1000 as u64;
let commitment = hash_2([donation_amount as Field, donor_secret]);
// Should pass - amount meets threshold
main(threshold, commitment, donation_amount, donor_secret);
}
#[test(should_fail)]
fn test_below_threshold() {
let donor_secret = 0x123abc as Field;
let donation_amount = 500 as u64;
let threshold = 1000 as u64;
let commitment = hash_2([donation_amount as Field, donor_secret]);
// Should fail - amount is below threshold
main(threshold, commitment, donation_amount, donor_secret);
}
```
Run tests:
```bash
cd zk-badges/donation_badge
nargo test
```
### Compile
```bash
cd zk-badges/donation_badge
nargo compile
```
Output: `target/donation_badge.json` (your circuit in ACIR format)
## Generating proofs
Now you have a circuit. Let's generate a proof.
### Step 1: Compute the commitment
The circuit will check that your commitment matches `Hash(amount, secret)`. You need to compute this locally first.
Use `zk-badges/computecommitment.js` (ES modules):
```javascript
import { buildPoseidon } from 'circomlibjs';
async function computeCommitment(donationAmount, donorSecret) {
const poseidon = await buildPoseidon();
let secretBigInt;
if (donorSecret.startsWith('0x')) {
secretBigInt = BigInt(donorSecret);
} else {
// Convert string secret to hex first
const encoder = new TextEncoder();
const bytes = encoder.encode(donorSecret);
secretBigInt = BigInt('0x' + Buffer.from(bytes).toString('hex'));
}
const hash = poseidon([BigInt(donationAmount), secretBigInt]);
const commitment = poseidon.F.toObject(hash);
const commitmentHex = '0x' + commitment.toString(16);
return { commitment, commitmentHex };
}
// Usage
const [, , amount, secret] = process.argv;
computeCommitment(BigInt(amount), secret).then(({ commitmentHex }) => {
console.log(commitmentHex);
});
```
Run it:
```bash
cd zk-badges
node computecommitment.js 15000 "mysecret123"
# Output: 0x1abc...def
```
Save that output.
### Step 2: Create `Prover.toml`
Create `zk-badges/donation_badge/Prover.toml`:
```toml
threshold = "1000" # $10 in cents
commitment = "0x1abc...def" # From step 1 above (hex format)
donation_amount = "15000" # $150 in cents (private!)
donor_secret = "0xabc123def456" # Hex-encoded secret (private!)
```
> **⚠️ Important:** If you're using a string secret like `"mysecretkey123"`, convert it to hex using the `computecommitment.js` script first. The script handles the conversion. Then use the hex output in Prover.toml.
### Step 3: Generate witness
```bash
cd zk-badges/donation_badge
nargo execute witness
```
Output: `target/witness.gz` (your witness, compressed)
### Step 4: Generate proof
This is where the crypto happens. Takes 30-60 seconds:
```bash
bb prove --scheme ultrakeccakhonk \
--bytecode target/donation_badge.json \
--witness target/witness.gz \
--output target/proof
bb write_vk --scheme ultrakeccakhonk \
--bytecode target/donation_badge.json \
--output target/vk
```
Output:
- `target/proof` - Your proof (binary)
- `target/vk` - Verification key (tells verifiers how to check)
### Step 5: Generate Starknet calldata
```bash
garaga calldata \
--system ultrakeccakhonk \
--vk target/vk \
--proof target/proof \
--format starkli \
> ../calldata.txt
```
Output: `zk-badges/calldata.txt` (array of numbers for Starknet)
### Automated script
Doing all this manually is tedious. Use `zk-badges/generate-proof.sh`:
```bash
#!/bin/bash
set -e
AMOUNT=1000
THRESHOLD=1000
SECRET="hunter2"
TIER=1
while [[ $# -gt 0 ]]; do
case $1 in
--amount) AMOUNT="$2"; shift 2 ;;
--threshold) THRESHOLD="$2"; shift 2 ;;
--donor-secret) SECRET="$2"; shift 2 ;;
--tier) TIER="$2"; shift 2 ;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
cd "$(dirname "$0")/donation_badge"
echo "🔐 Computing commitment..."
COMMITMENT=$(node ../computecommitment.js $AMOUNT "$SECRET")
echo "📝 Commitment: $COMMITMENT"
echo "📝 Creating Prover.toml..."
cat > Prover.toml << EOF
threshold = "$THRESHOLD"
commitment = "$COMMITMENT"
donation_amount = "$AMOUNT"
donor_secret = "$SECRET"
EOF
echo "⚙️ Compiling circuit..."
nargo compile
echo "🔍 Executing witness..."
nargo execute witness
echo "🔒 Generating proof (30-60 seconds)..."
bb prove --scheme ultrakeccakhonk \
--bytecode target/donation_badge.json \
--witness target/witness.gz \
--output target/proof
echo "🔑 Generating verification key..."
bb write_vk --scheme ultrakeccakhonk \
--bytecode target/donation_badge.json \
--output target/vk
echo "📦 Generating calldata..."
garaga calldata \
--system ultrakeccakhonk \
--vk target/vk \
--proof target/proof \
--format starkli \
> ../calldata.txt
echo "✅ Done! Calldata: zk-badges/calldata.txt"
```
Use it:
```bash
cd zk-badges
chmod +x generate-proof.sh
./generate-proof.sh --amount 15000 --threshold 1000 --donor-secret "mysecret123" --tier 1
```
## The Cairo verifier contract
Garaga handles the math translation; you focus on policy (no replays, only tier upgrades, maintaining accurate counts).
### Generate the verifier
```bash
cd donation_badge_verifier
garaga gen --system ultrakeccakhonk \
--vk ../zk-badges/donation_badge/target/vk \
--project-name donation_badge_verifier
```
This creates:
- `src/honk_verifier.cairo` - Auto-generated verifier (don't edit)
- `src/honk_verifier_circuits.cairo` - Circuit-specific code
- `src/honk_verifier_constants.cairo` - Verification key constants
### The badge contract
You write this part. It's where the business logic lives.
Create `src/badge_contract.cairo`:
```cairo
#[starknet::contract]
mod DonationBadge {
use starknet::ContractAddress;
use starknet::get_caller_address;
use super::honk_verifier::IUltraKeccakHonkVerifierDispatcher;
use super::honk_verifier::IUltraKeccakHonkVerifierDispatcherTrait;
#[storage]
struct Storage {
verifier_address: ContractAddress,
user_badges: LegacyMap<(ContractAddress, u8), bool>,
user_max_tier: LegacyMap<ContractAddress, u8>,
used_commitments: LegacyMap<u256, bool>,
badge_counts: (u64, u64, u64),
}
#[constructor]
fn constructor(ref self: ContractState, verifier: ContractAddress) {
self.verifier_address.write(verifier);
}
#[external(v0)]
fn claim_badge(
ref self: ContractState,
full_proof_with_hints: Span<felt252>,
threshold: u256,
donation_commitment: u256,
badge_tier: u8
) -> bool {
let caller = get_caller_address();
// Prevent replay attacks
assert(!self.used_commitments.read(donation_commitment), 'Commitment used');
// Can only upgrade tiers
let current_tier = self.user_max_tier.read(caller);
assert(badge_tier > current_tier, 'Must upgrade tier');
// Verify the proof
let verifier = IUltraKeccakHonkVerifierDispatcher {
contract_address: self.verifier_address.read()
};
let is_valid = verifier.verify_ultra_keccak_honk_proof(full_proof_with_hints);
assert(is_valid, 'Invalid proof');
// Mark commitment used
self.used_commitments.write(donation_commitment, true);
// Update badge
self.user_badges.write((caller, badge_tier), true);
self.user_max_tier.write(caller, badge_tier);
// Update counts
let (bronze, silver, gold) = self.badge_counts.read();
if badge_tier == 1 {
self.badge_counts.write((bronze + 1, silver, gold));
} else if badge_tier == 2 {
self.badge_counts.write((bronze, silver + 1, gold));
} else if badge_tier == 3 {
self.badge_counts.write((bronze, silver, gold + 1));
}
true
}
#[external(v0)]
fn get_badge_tier(self: @ContractState, address: ContractAddress) -> u8 {
self.user_max_tier.read(address)
}
#[external(v0)]
fn has_badge(self: @ContractState, address: ContractAddress, tier: u8) -> bool {
self.user_badges.read((address, tier))
}
#[external(v0)]
fn is_commitment_used(self: @ContractState, commitment: u256) -> bool {
self.used_commitments.read(commitment)
}
#[external(v0)]
fn get_badge_counts(self: @ContractState) -> (u64, u64, u64) {
self.badge_counts.read()
}
}
```
### Build
```bash
cd donation_badge_verifier
scarb build
```
Output: Two compiled contracts, ready to deploy.
## Deployed contracts (Sepolia)
The system is already live. These are real contracts on Starknet Sepolia:
| Contract | Address |
|----------|---------|
| **UltraKeccakHonkVerifier** | [`0x022b20...2669`](https://sepolia.voyager.online/contract/0x022b20fef3764d09293c5b377bc399ae7490e60665797ec6654d478d74212669) |
| **DonationBadge** | [`0x077ca6...7010`](https://sepolia.voyager.online/contract/0x077ca6f2ee4624e51ed6ea6d5ca292889ca7437a0c887bf0d63f055f42ad7010) |
### If you want to deploy your own
```bash
cd donation_badge_verifier
# Declare verifier
sncast --profile sepolia declare \
--contract target/release/donation_badge_verifier_UltraKeccakHonkVerifier.contract_class.json
# Deploy verifier
sncast --profile sepolia deploy --class-hash 0x0452...
# Declare badge contract
sncast --profile sepolia declare \
--contract target/release/donation_badge_verifier_DonationBadge.contract_class.json
# Deploy badge contract (pass verifier address)
sncast --profile sepolia deploy --class-hash 0x04be... \
--constructor-calldata 0x022b20fef3764d09293c5b377bc399ae7490e60665797ec6654d478d74212669
```
## What could go wrong
- **Version mismatch:** You run Noir 1.0.0 but accidentally installed 1.0.1. Proof fails. Proofs are version-dependent.
- **Prover timeout:** Takes 90 seconds instead of 60 on slower hardware. This isn't a bug, it's crypto.
- **Lost secret:** You generate a proof, then lose the donor_secret. You can't prove it again with the same commitment. Backup your `.env`.
- **You reveal the secret later:** Someone doxes you and finds out you donated $5K, not $100. The commitment was public, so now everyone knows. Choose secrets accordingly.
- **Starknet RPC goes down:** Your proof is safe (it's local), but you can't claim the badge that moment. RPC providers are (usually) resilient, but it's not guaranteed.
## End-to-end flow (manual)
Once `zk-badges/generate-proof.sh` finishes, the proof artifacts are already on disk. All that's left:
1. **Claim the badge**
```bash
sncast --profile sepolia invoke \
--contract-address 0x077ca6f2ee4624e51ed6ea6d5ca292889ca7437a0c887bf0d63f055f42ad7010 \
--function claim_badge \
--calldata [calldata_from_script] 1000 0xCOMMITMENT 1
```
Replace `[calldata_from_script]` with the array in `zk-badges/calldata.txt` and `0xCOMMITMENT` with the Poseidon hash printed by the script.
2. **Verify the tier**
```bash
sncast --profile sepolia call \
--contract-address 0x077ca6f2ee4624e51ed6ea6d5ca292889ca7437a0c887bf0d63f055f42ad7010 \
--function get_badge_tier \
--calldata 0xYOUR_WALLET_ADDRESS
```
Expect `1`, `2`, or `3` depending on the tier you just claimed.
That's the loop: secrets stay local, only the proof hits the blockchain.
### The full flow visualized
```mermaid
%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#375a7f', 'primaryTextColor': '#ffffff', 'actorTextColor': '#000000', 'actorBkg': '#f8f9fa', 'actorBorder': '#375a7f', 'signalColor': '#5bc0de', 'signalTextColor': '#000000'}}}%%
sequenceDiagram
autonumber
participant User as 👤 User
participant API as ⚙️ Proof API
participant ZK as 🔐 Noir/BB/Garaga
participant Badge as 📜 Badge Contract
participant Verifier as ✅ Verifier
User->>API: {amount, secret, threshold, tier}
rect rgba(55,90,127,0.1)
Note over API,ZK: Proof generation typically takes 30-60 seconds
API->>ZK: nargo → bb → garaga
ZK-->>API: calldata[]
end
API-->>User: {calldata, commitment}
User->>Badge: claim_badge(calldata, threshold, commitment, tier)
Badge->>Verifier: verify_proof(calldata)
Verifier-->>Badge: true ✓
Badge-->>User: 🏅 Badge Minted!
```
User sends secrets → API generates proof → user claims badge on-chain → verifier checks proof → badge minted.
## Common errors
| Error | Fix |
|-------|-----|
| `error: could not satisfy constraint` | `donation_amount < threshold` - increase amount |
| `WASM module not found` | macOS ARM64 issue - use Codespaces |
| `Invalid proof` on-chain | Version mismatch - verify `bb --version = 0.67.0` |
| `Commitment already used` | Proof replay - use new `donor_secret` |
| `Must upgrade tier` | Already have this tier - claim higher tier instead |
| `garaga: command not found` | `pip install garaga==0.15.5` |
| `nargo: command not found` | `curl -L https://noirup.dev \| bash` |
| Transaction fails | Check you're on Sepolia, not mainnet |
| Amounts below threshold rejected | Remember: amounts in cents ($10 = 1000, $100 = 10000) |
## Resources
- [Noir Docs](https://noir-lang.org/docs)
- [Barretenberg](https://github.com/AztecProtocol/aztec-packages/tree/master/barretenberg)
- [Garaga](https://github.com/keep-starknet-strange/garaga)
- [Starknet Foundry](https://foundry-rs.github.io/starknet-foundry/)
## Next steps: plug into Tongo
This badge system is half the Starknet privacy story. The repo also contains Tongo's private donation flow (encrypted STRK/USDC balances, rollover, withdraw). Stitching them together on mainnet is the logical next experiment: fund privately via Tongo, then mint a public badge proving you crossed the threshold. It's the same ethos - math beats trust - but now it spans both the money movement and the public proof.