Skip to content

Confidential compliance (ZK) ​

A business can't publish who it pays and how much on a public ledger — but its owner, auditor, or counterparty still needs to know the spending stayed inside the rules. Eunomia's answer: prove compliance, reveal nothing.

Not in the mainnet beta

The verifier is not deployed on mainnet yet. Its trusted setup is a single-party development setup, and a mainnet verifier needs a multi-party ceremony first. Everything below was built and verified end to end before mainnet.

What is proven ​

A Groth16 (BN254) proof, verified on-chain by the Compliance Verifier contract, attests that a closed period's agent payments (a batch of up to 16) obeyed policy:

  • every payment ≤ the per-payment cap,
  • the batch total ≤ the daily cap,
  • every payee ∈ the treasury's published payee list (a Poseidon Merkle root),
  • the batch total equals what the treasury actually moved in that period —

without revealing a single amount or payee. Payments are committed as Poseidon(amount, payee, salt); only the commitments and the proof go on-chain. Public signals: [dailyLimit, perTaskLimit, whitelistRoot, periodId, periodSpent, commitments[16]].

Why the verifier is "hardened", not just a math check ​

  • Bound to chain state, not to a copy. The verifier holds no policy of its own. For the treasury named in the call it re-reads the limits, the published payee root and the period's on-chain total, and every public signal must match them (#4 PolicyMismatch, #9 SpendMismatch). A proof about a self-chosen policy, or about a batch that never happened, cannot attest. Only that treasury's admin can submit one.
  • Periods close and only move forward. A period must be over before it can be attested (#6), and each treasury's periods strictly advance (#8) — a compliant proof can't be replayed to mask a later period. The replay (#8) and the fabricated batch (#9) were both rejected on chain before mainnet.
  • Fail-closed input validation. Malformed proof or signal lengths and non-canonical field encodings trap with typed errors instead of undefined behavior.

Check an attestation yourself ​

One command, no keys and no ZK tooling — it asks the on-chain verifier to re-run the Groth16 check on the proof inside an attestation transaction and compares every public input with the treasury's own limits, payee root and recorded total:

bash
git clone https://github.com/eunomia-finance/eunomia && cd eunomia/packages/prover && npm install
npx tsx src/check-attestation.ts --tx <attestation tx hash>

The payee root is rebuilt from real Stellar addresses: a payee's field element is the top 248 bits of SHA-256 over its address, the tree is built over the sorted set, and every member must be confirmed by the treasury's is_payee before the root is published.

Honest scope ​

  • The ZK layer attests after the fact today: pay() does not yet require a proof. Wiring confidential compliance into the payment flow (confidential-by-default) is roadmap work — see Roadmap.
  • If settlement moves real tokens to revealed payees, those transfers stay visible at the token-contract layer; transfer-level privacy is the shielded-pool track.
  • The Groth16 setup is a single-party dev setup; a multi-party ceremony is the prerequisite for deploying the verifier on mainnet.

Composability: Eunomia as a policy for confidential tokens ​

Eunomia's payee gate is also packaged as an OpenZeppelin Confidential Token Policy (is_authorized(account, token) → bool). Wired as a confidential token's compliance policy, every private-amount transfer is still bounded to Eunomia-approved payees: the confidential token hides the amount; Eunomia bounds the payee.

Toolchain ​

Circom + snarkjs (Groth16/BN254) · Poseidon commitments · public Hermez powers-of-tau · on-chain verifier generated with soroban-verifier-gen, verified via Soroban's native bn254_multi_pairing_check host functions.