Skip to content

Latest commit

 

History

36 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Setu

Private remittances on Stellar with compliance-oriented selective disclosure.

Setu is a production-oriented build for private remittance infrastructure on Stellar. It turns the soroban-privacy-pools base implementation into a remittance-focused flow:

  1. Deposit a fixed-value note into a Soroban privacy pool.
  2. Withdraw with a Groth16 proof of Merkle membership, nullifier correctness, and association-set membership.
  3. Generate a selective-disclosure receipt proving that a spent nullifier links to a real deposited commitment and that the disclosed amount equals the committed value.
  4. Let an auditor verify the receipt without learning the note secret.

The product story is cross-border stablecoin remittance: payment privacy for families and small businesses, with an explicit compliance edge instead of a black-box mixer.

Prototype: this build runs on Stellar testnet, is not audited, and the proof does not establish everything the story implies. Read Privacy & Compliance Limitations for what the proofs prove, what they do not prove, the anchor/off-ramp mock status, and the legal caveats.

Live Testnet Deployment

Fresh withdrawal public signals:

[
  "33832171054643436472546998686772011210227251098487950275135154568712175384598",
  "1000000000",
  "30162851960749159054107963444341137279716337900493764726816877893946218126682",
  "30671046209969431012473152916297518771579159592633900587133061089753651787613"
]

Fresh disclosure public signals:

[
  "33832171054643436472546998686772011210227251098487950275135154568712175384598",
  "792451850146572312119437015092516461585820411321474856545290648487812800938",
  "13601723215849916214344531109121559986847487952922840288412049033564696840611",
  "49445820628079692271178516198415444927595013704302380125940463497865958044604"
]

On the fresh patched deployment:

  • snarkjs groth16 verify for the disclosure proof returned OK!.
  • verify_disclosure(validProof, validPublicSignals) returned true.
  • verify_disclosure(validProof, tamperedPublicSignals) returned false.
  • The withdrawal nullifier is stored on-chain: 4acc5489ab80200caae1eca0b44dd2335e92931ff51e358e4fc7381378367816.

What Is New In Setu

This repository is built on top of ymcrcat/soroban-privacy-pools (MIT). The fork already had the base privacy pool, BLS12-381 Groth16 verifier, Poseidon parity, LeanIMT tree, nullifiers, association-set proof path, and Circom-to-Soroban artifact conversion.

Setu adds:

  • circuits/disclosure.circom: selective-disclosure receipt circuit.
  • circuits/disclosure_witness.circom: helper circuit for deriving receipt public signals without a separate Poseidon implementation.
  • circuits/auditor_recompute.circom: auditor-side recomputation helper.
  • contract/src/disclosure.rs: set_disclosure_vk, has_disclosure_vk, and verify_disclosure.
  • Safe proof/public-signal parsing in libs/zk/src/lib.rs, including canonical BLS12-381 scalar validation.
  • Regression tests for malformed input and non-canonical public signals.
  • scripts/live_testnet_e2e.ps1: reproducible patched testnet flow.

ZK Statements

Withdrawal Circuit

circuits/main.circom proves:

  • The prover knows a deposited note opening.
  • The note commitment is included in the pool Merkle tree.
  • The nullifier hash is correctly derived.
  • The withdrawn value is consistent with the fixed-denomination note.
  • The note label is included in the current association set root.

The contract additionally checks:

  • The association root in the proof matches the admin-set root.
  • The Merkle root in the proof matches the current pool root.
  • The nullifier has not already been used.
  • The Groth16 proof verifies under the stored withdrawal VK.

Withdrawal Event

Each successful withdrawal emits a Withdrawal event with one field, the nullifier hash. The nullifier hash is already public in the withdrawal proof's public signals and is stored on-chain to prevent replay, so the event metadata adds no new exposure. The event is emitted only after Groth16 verification succeeds, the nullifier is unused, and the transfer completes. A duplicate-nullifier withdrawal or any other failed verification does not emit the success event.

Privacy tradeoff: nullifier hashes are permanent public metadata. They are the protocol's double-spend protection, not note secrets; the event simply makes the already-public settlement marker available to indexers and auditors without exposing the note secret, recipient, or amount beyond what the transfer already reveals.

Disclosure Circuit

circuits/disclosure.circom uses public signals:

[nullifierHash, commitment, discloseHash, auditorTag]

and private witness:

value, label, nullifier, secret, recipientId, purpose, viewingKey

It proves:

  • nullifierHash = Poseidon255(nullifier).
  • commitment = Poseidon255(value, label, Poseidon255(nullifier, secret)).
  • discloseHash = Poseidon255(recipientId, purpose, value).
  • auditorTag = Poseidon255(viewingKey, nullifierHash).

verify_disclosure additionally checks:

  • nullifierHash is already spent in this pool.
  • commitment is a real deposited leaf in this pool.
  • The receipt proof verifies under the disclosure VK.

Important v1 limit: value is cryptographically tied to the deposited commitment. recipientId and purpose are prover-asserted context hashed into the receipt; they are not deposit-time committed facts. A production-grade version should bind those fields into the deposit commitment or verify an off-ramp signature over them.

Security Fix From Audit

The audit found a serious cross-layer issue: public signals were parsed into field elements while set-membership checks used raw bytes. A proof could be replayed with a non-canonical encoding such as n + r, where the pairing reduces mod r but the nullifier set sees a different byte string.

Setu fixes this by:

  • Making Proof::from_bytes and PublicSignals::from_bytes return Result.
  • Rejecting malformed/truncated proof and public-signal byte arrays.
  • Rejecting any BLS12-381 scalar public signal greater than or equal to the scalar modulus.
  • Returning normal proof-failure responses instead of trapping.

Regression tests cover:

  • Truncated proof bytes.
  • Truncated public-signal payloads.
  • Non-canonical public signals.
  • Disclosure verifier malformed inputs.
  • Withdraw rejecting non-canonical public signals without storing a nullifier.

circom2soroban Conversion Audit

cli/circom2soroban is the bridge that turns snarkjs Groth16 artifacts into the byte layout libs/zk deserializes on-chain. It has been audited and hardened:

  • snarkjs assumptions: artifacts must come from snarkjs 0.7.x on the BLS12-381 curve (the setup scripts run snarkjs powersoftau new bls12-381). Default BN254 artifacts are not compatible and are rejected as out-of-field values. Each G2 coordinate is read as snarkjs [c0, c1], and the projective z component of each point is intentionally ignored.
  • Malformed inputs fail clearly: the CLI now prints a specific error and exits non-zero (instead of panicking) for unreadable files, invalid JSON, non-decimal or out-of-field coordinates, points that are off-curve or outside the prime-order subgroup, an IC array whose length does not match nPublic + 1, non-decimal or oversized public signals, non-canonical public signals (>= r, which the on-chain verifier would reject), and unknown filetypes.
  • Regression tests catch conversion drift (cargo test -p circom2soroban): VK, proof, and public-signal outputs are round-tripped back through the real zk deserializers, plus a hand-derived golden vector pins the public-signal byte encoding.

Prototype limit: the point-encoding glue (g1_from_coords / g2_from_coords) is duplicated between the converter and libs/zk/src/test.rs; the two must stay in sync, and the round-trip tests exist to catch it if they drift.

Run Locally

Prerequisites:

  • Rust stable.
  • Stellar CLI 26.x.
  • Node.js.
  • circom 2.2.x.
  • snarkjs 0.7.x.
  • circomlib.

Run node scripts/check-prereqs.cjs to check which of these are actually installed. It prints required vs. found versions for each and exits non-zero if anything is missing or off the required track. It's a prototype convenience check, not a hermetic environment validator: it only reads version strings from whatever is already on PATH (or already npm installed for snarkjs/circomlib), it doesn't install or sandbox anything.

Install JS dependencies from the parent workspace if needed:

cd ..
npm install

From this repository:

cargo test
cargo build --target wasm32v1-none --release -p privacy-pools

Verify the regenerated disclosure proof:

npx snarkjs groth16 verify `
  circuits\build_disc\disc_vk.json `
  circuits\build_disc\public.json `
  circuits\build_disc\proof.json

Run a fresh patched testnet flow:

.\scripts\live_testnet_e2e.ps1

The script deploys if no -ContractId is supplied. To reuse the current live contract:

.\scripts\live_testnet_e2e.ps1 `
  -ContractId CDXLQFYQJVDXBZDI5QVYRAM5TGPMZQWS424FCQWYVNGSKSSHPU6XXAXT

Authentication

The web app uses Supabase Auth for production sign up, sign in, OAuth redirect, password reset, session persistence, and sign out. Browser code only receives the Supabase URL and anon/publishable key. Do not use or expose a service-role key in site/.

Create a Supabase project, enable Email auth, and enable Google/GitHub providers only after their OAuth client IDs and redirect URLs are configured in Supabase. Apply the profile table and RLS migration:

# Option A: Supabase SQL editor
# Paste supabase/migrations/20260620150000_create_profiles.sql and run it.

# Option B: Supabase CLI, after installing and logging in
supabase link --project-ref <project-ref>
supabase db push

Configure deployment variables:

SETU_SUPABASE_URL=https://<project-ref>.supabase.co
SETU_SUPABASE_ANON_KEY=<anon-or-publishable-key>

For local static testing, either set those variables before running the site or copy site/auth-config.example.js to site/auth-config.js and replace the placeholder values. site/auth-config.js is ignored so real deployment config does not get committed.

cd site
$env:SETU_SUPABASE_URL="https://<project-ref>.supabase.co"
$env:SETU_SUPABASE_ANON_KEY="<anon-or-publishable-key>"
npm run build
npm run dev

If the variables are missing, the auth forms stay disabled and the page shows a configuration warning instead of opening an unauthenticated workspace.

Site CI

.github/workflows/site-ci.yml runs npm run smoke in site/ on pushes to main and on pull requests. The smoke check verifies that required site files exist, syntax-checks the site scripts, generates the auth config when auth-config.js is absent (disabled mode when Supabase variables are unset; an existing local file is preserved, and a file the run generated is removed afterwards), resolves local references in index.html, and boots server.cjs for a request round trip. It is a static and smoke-level check for this prototype, not a browser test suite. Run it locally with:

cd site
npm run smoke

Project Structure

contract/                  Soroban pool + disclosure verifier
libs/zk/                   BLS12-381 Groth16 verifier and serializers
libs/lean-imt/             Lean incremental Merkle tree
cli/circom2soroban/        snarkjs artifact conversion
cli/coinutils/             note generation and witness input generation
circuits/main.circom       withdrawal proof circuit
circuits/disclosure.circom selective-disclosure receipt circuit
scripts/live_testnet_e2e.ps1
site/                      Supabase-authenticated web app
supabase/migrations/       Profiles table, trigger, and RLS policies

Integration Boundaries

For the full accounting of what the proofs do and do not guarantee — including why the receipt does not prove recipient/purpose were committed at deposit time, the zero-root association bypass, and the trusted-setup caveat — see Privacy & Compliance Limitations.

  • Fiat on-ramp and INR off-ramp are product-story stubs, not live anchor integrations.
  • Supabase Auth is wired for real accounts, but a live Supabase project and deployment environment variables must be configured before public sign in is enabled.
  • Testnet native asset is used as the testnet asset.
  • The trusted setup is local/staging-only and not production-secure.
  • There is no relayer, so gas metadata privacy is future work.

Future Work

  • Bind recipient and purpose into the deposit commitment.
  • Add authenticated auditor registry or verifier-key commitments.
  • Use per-receipt nonce/key derivation for auditor tags.
  • Add relayers for withdrawal gas privacy.
  • Support multiple denominations or variable amounts.
  • Replace local trusted setup with a real ceremony.
  • Integrate real Stellar anchors for SEP-24/SEP-31 production corridors.

License And Attribution

Base privacy-pool implementation: ymcrcat/soroban-privacy-pools, MIT.

Setu additions are active-development code. This is not audited production software yet and is not legal, compliance, or financial advice.

About

Setu: private remittances on Stellar with selective-disclosure ZK receipts

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages