Status: hackathon research project. Not audited. Do not use with real funds. The Groth16 trusted setup is a local (single-party) ceremony, not an MPC ceremony, so the proving key must be treated as compromised for any real deployment.
- Withdrawals require a valid Groth16 proof. The proof attests, in zero
knowledge, knowledge of
(nullifier, secret)whosecommitment = Poseidon(nullifier, secret)is a leaf in the on-chain Merkle tree, without revealing which leaf. Verified on-chain with Stellar's native BN254 pairing host functions against a baked-in verifying key. - On-chain tree == circuit tree. The contract hashes the Merkle tree with
Stellar's native Poseidon host function using circomlib's exact
t=3parameters. This is proven byte-identical towithdraw.circom(poseidon(1,2)test vector, and the on-chain root after a real deposit matches the root the proof was generated against, verified on devnet). - Recipient / relayer / fee are bound into the proof (squaring
constraint). A relayer that submits someone else's proof cannot redirect funds
or change amounts; the contract recomputes the bound field elements from its
own arguments and the proof fails otherwise (test:
wrong_recipient_rejected). - Double-spend prevention. Each withdrawal reveals a one-time
nullifierHash; the contract records it and rejects reuse (test:double_spend_rejected).
| Threat | Mitigation | Test |
|---|---|---|
| Reuse of a spent note | global nullifier-hash set | double_spend_rejected |
| Proof against a stale/forged root | recent-root ring buffer (30); unknown roots rejected | unknown_root_rejected |
| Relayer redirecting funds | recipient/relayer/fee bound in proof | wrong_recipient_rejected |
| Relayer over-charging | fee <= denomination enforced |
fee_above_denomination_rejected |
| Duplicate commitment | per-commitment dedup before insert | duplicate_commitment_rejected |
| Forged / tampered proof | on-chain Groth16 pairing check | groth16_rejects_tampered_public_input |
| Unauthorized deposit | from.require_auth() |
deposit_requires_real_auth |
| Unauthorized freeze/admin | admin.require_auth(); admin handover is two-step (transfer_admin proposes, accept_admin confirms) so a typo cannot brick admin |
two_step_admin_handover, accept_admin_without_pending_rejected |
| Front-run of init | init runs as __constructor, atomic with deploy (no separate initialize to race) |
constructor_rejects_zero_denomination |
| Self-grief via note bloat | per-depositor note-backup vector capped at MAX_NOTES |
notes_cap_enforced |
| Reentrancy | effects-before-interactions: nullifier marked spent before payout; SAC token has no attacker callback | n/a |
| Integer overflow | overflow-checks = true; payout = denomination - fee with fee <= denomination |
n/a |
| State expiry breaking the tree / enabling double-spend | Merkle tree state (zeros, filled-subtrees, root ring) kept in instance storage (bumped together); nullifier/commitment/notes/frozen persistent entries TTL-bumped on write | n/a |
| Public input ≥ field modulus | address fields are sha256 with the top byte zeroed (< 2^248 < r); amounts are small |
n/a |
Nullifier malleability (nh + r replay) |
the host reduces scalars mod r, so the contract requires nullifier_hash to be the canonical representative (< r) before the spent/frozen lookups, making the field element and the storage key 1:1 |
non_canonical_nullifier_rejected |
Compliance is a contract-layer freeze list keyed by nullifierHash, the only
deposit identifier revealed at withdrawal time. The admin can freeze a flagged
note (set_frozen); a frozen note cannot withdraw (frozen_note_rejected).
A commitment blocklist is deliberately not used: it would be unenforceable, since withdrawals never reveal the commitment. A circuit-level Association Set (Privacy Pools / Buterin et al.) is the stronger, decentralizable design and is tracked as future work, not in v1.
- Local trusted setup. Single-party powers-of-tau + phase2. A real deployment needs a multi-party ceremony; otherwise forged proofs are possible.
- Centralized admin. A single admin key controls the freeze list. Handover is now two-step (propose/accept) to prevent bricking, but there is no timelock or multisig yet.
- Root history depth = 30. If more than 30 deposits land between proof generation and withdrawal, the proof's root ages out and the user must re-prove against a current root. Mitigated by client retry; a larger buffer trades storage for resilience.
- Nullifier persistence. Spent nullifiers live in persistent storage with a generous TTL bump. For a long-lived pool they must never be evicted (eviction could allow replay); production needs a TTL-maintenance/restore job.
- Fixed denomination, single asset. By design for a strong anonymity set.
- Wallet-derived backup key depends on stable message signing. The on-chain note backup is encrypted with a key derived from the wallet's signature over a fixed message. If the wallet changes how it signs/encodes that message (a version-to-version risk), the derived key changes and old backups become undecryptable. The user's shown/exported note string is the primary backup; the on-chain copy is a convenience. A KDF version tag + a non-wallet fallback are future hardening.
Built against soroban-sdk 27.0.0-rc.1 (wasm targets protocol 27) and deployed + fully exercised on Stellar testnet (protocol 27, stellar-core 27.0.0):
- Contract
CBRICYFV2KXEN2Q224NUULGNKRUWVXBP2SEVHIEXXPHQVH7327TBUCTS, fixed denomination 1 USDC (10_000_000stroops at 7 decimals). __constructorbuilds the empty tree (20 on-chain Poseidon hashes) atomically at deploy.deposit(on-chain Poseidon Merkle insert; root matches the circuit exactly:09e718aa…) andwithdrawsettled live: the Groth16 proof verified on-chain via BN254 pairing and paid9999000(0.9999 USDC) to the recipient and1000(0.0001 USDC) fee to the relayer, 1 USDC total; the nullifier is marked spent and a replay is rejected (AlreadySpent).
An earlier local quickstart:future image ran mismatched env-host versions
(soroban-rpc preflight on env-host 26.0.1, stellar-core apply on 26.1.0). The
26.1.0 apply host spuriously rejected valid BN254 points that both the 26.0.1
preflight host and the 27.0.0 test/testnet host accept, a regression in the
preview BN254 path (CAP-0074), not a contract defect. Running on a consistent
protocol-27 host (testnet / quickstart:testing) eliminates the skew:
the proof both verifies and settles.