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:
- Deposit a fixed-value note into a Soroban privacy pool.
- Withdraw with a Groth16 proof of Merkle membership, nullifier correctness, and association-set membership.
- 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.
- 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.
- Contract:
CDXLQFYQJVDXBZDI5QVYRAM5TGPMZQWS424FCQWYVNGSKSSHPU6XXAXT - Lab link: https://lab.stellar.org/r/testnet/contract/CDXLQFYQJVDXBZDI5QVYRAM5TGPMZQWS424FCQWYVNGSKSSHPU6XXAXT
- WASM upload tx: https://stellar.expert/explorer/testnet/tx/c9c8ed3e1f6b988b76b29526967195aec4b135dc4bc29ad6fabde0073f701b51
- Contract deploy tx: https://stellar.expert/explorer/testnet/tx/3b9a86af7273750b54324c96fb3129a7677f59b7eb288b3c5de6dfe60abb019a
- Fresh deposit tx: https://stellar.expert/explorer/testnet/tx/adf901702fea3fcd09e0b8a94d19388d23d7579d05cbe8f7210b30489f9eb458
- Fresh association-root tx: https://stellar.expert/explorer/testnet/tx/709cf0d7530259e33b0af6ba0aa46d811947b1aa71a33f66042e275dfaafa938
- Fresh withdrawal tx: https://stellar.expert/explorer/testnet/tx/6d19b21f5f96ac4237ce85882c81c9d991cd88cfb4859fd59c359b28463cfb9a
- Disclosure VK install tx: https://stellar.expert/explorer/testnet/tx/d35c95b0c65ddaedee2b85cd52a5d248571abb36d56c42c3599235ef8d64c6e0
Fresh withdrawal public signals:
[
"33832171054643436472546998686772011210227251098487950275135154568712175384598",
"1000000000",
"30162851960749159054107963444341137279716337900493764726816877893946218126682",
"30671046209969431012473152916297518771579159592633900587133061089753651787613"
]Fresh disclosure public signals:
[
"33832171054643436472546998686772011210227251098487950275135154568712175384598",
"792451850146572312119437015092516461585820411321474856545290648487812800938",
"13601723215849916214344531109121559986847487952922840288412049033564696840611",
"49445820628079692271178516198415444927595013704302380125940463497865958044604"
]On the fresh patched deployment:
snarkjs groth16 verifyfor the disclosure proof returnedOK!.verify_disclosure(validProof, validPublicSignals)returnedtrue.verify_disclosure(validProof, tamperedPublicSignals)returnedfalse.- The withdrawal nullifier is stored on-chain:
4acc5489ab80200caae1eca0b44dd2335e92931ff51e358e4fc7381378367816.
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, andverify_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.
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.
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.
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:
nullifierHashis already spent in this pool.commitmentis 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.
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_bytesandPublicSignals::from_bytesreturnResult. - 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.
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 projectivezcomponent 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
ICarray whose length does not matchnPublic + 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 realzkdeserializers, 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.
Prerequisites:
- Rust stable.
- Stellar CLI 26.x.
- Node.js.
circom2.2.x.snarkjs0.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 installFrom this repository:
cargo test
cargo build --target wasm32v1-none --release -p privacy-poolsVerify the regenerated disclosure proof:
npx snarkjs groth16 verify `
circuits\build_disc\disc_vk.json `
circuits\build_disc\public.json `
circuits\build_disc\proof.jsonRun a fresh patched testnet flow:
.\scripts\live_testnet_e2e.ps1The script deploys if no -ContractId is supplied. To reuse the current live
contract:
.\scripts\live_testnet_e2e.ps1 `
-ContractId CDXLQFYQJVDXBZDI5QVYRAM5TGPMZQWS424FCQWYVNGSKSSHPU6XXAXTThe 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 pushConfigure 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 devIf the variables are missing, the auth forms stay disabled and the page shows a configuration warning instead of opening an unauthenticated workspace.
.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 smokecontract/ 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
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.
- 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.
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.