The ZK Privacy Layer
How your position stays hidden - even from the protocol. Zero-knowledge proofs are the electric fence around the Writz house. They allow the protocol to verify facts about your position - that your loan is adequately collateralized, that you actually made the deposit you claim, that your repayment amount is correct - without ever learning the underlying numbers. This page explains how the ZK layer works, what it hides, and what the cryptographic guarantees are.Zero-Knowledge Proofs in Plain English
A zero-knowledge proof lets you prove something is true without revealing anything else. The classic analogy: imagine you want to prove to someone that you know the solution to a Sudoku puzzle, without revealing the solution. A ZK proof is a mathematical protocol that makes this possible - you can demonstrate you know the answer, and the verifier becomes convinced, without ever seeing the answer. In Writz, the “fact” being proved is: “I have a deposit commitment in this Merkle tree, my loan amount is X% or less of my collateral value, and I have the secret that unlocks this commitment.” The ZK proof convinces the Soroban contract that all of this is true - without the contract ever learning the deposit amount, the loan amount, or anything else about the position.What Is Hidden, What Is Visible
The protocol sees only commitments and nullifiers - opaque hashes that prove actions occurred without revealing the substance of those actions.
The Commitment Scheme
At the heart of the ZK layer is a commitment. A commitment is a hash of private data:amountis the BTC deposit amount in satoshis (private)secretis a random number only the user knows (private)nonceis a unique value preventing commitment reuse (private)
The Nullifier
When a commitment is “consumed” (used in a borrow, repay, or withdraw), a nullifier is published:secret.
The Three ZK Circuits
Writz uses three Circom circuits, each compiled to a Groth16 proof system over the BN254 curve.1. Deposit Circuit
What it proves: “I know (amount, secret, nonce) such thatcommitment = Poseidon(amount, secret, nonce) AND the transaction ID from SPV verification matches my claimed deposit.”
Public inputs:
- The commitment hash (stored on-chain)
- The
txidreturned by the SPV contract (binding the ZK proof to a specific Bitcoin transaction)
- Amount (satoshis)
- Secret
- Nonce
2. Borrow/Repay Circuit
What it proves for borrow: “I know the preimage of a commitment in the Merkle tree, the Oracle price P, and a loan amount L such that:L / (amount × P) ≤ 1/min_ratio (the loan is within the allowed LTV).”
What it proves for repay: “I know the preimage of a commitment in the Merkle tree, and the repay amount correctly reduces the outstanding debt.”
Public inputs:
- The Merkle root (current state of the commitment tree)
- The oracle price
- The minimum collateral ratio (150%)
- A nullifier for the old state
- A new commitment for the updated state (after borrow or repay)
- Amount, secret, nonce (commitment preimage)
- Current debt
- Delta (borrow or repay amount)
- Merkle path (proof of membership in the tree)
circom --r1cs; grew slightly after the is_borrow soundness fix noted below added a boolean constraint plus two 120-bit debt-direction range checks)
Key design decision - no division in ZK circuits: Division is expensive in ZK (requires a range proof for the denominator). The collateral ratio check is implemented as a multiplication: loan_amount × min_ratio_bp ≤ collateral_amount × price × 10000. This avoids division entirely.
Field negation for repay: The repay amount is encoded as a field element negation - p − delta_stroops where p is the BN254 field prime. The circuit recovers the repay amount correctly. This was verified on-chain.
is_borrow soundness fix: earlier versions of this circuit left is_borrow unconstrained - nothing forced it to be 0 or 1, and nothing tied it to the actual sign of delta_stroops. A prover could submit is_borrow=0 (skipping the collateral-ratio check entirely, which is only gated on is_borrow=1) while still passing a positive delta_stroops that increases debt with zero ratio enforcement. The only thing that prevented this from being exploitable end-to-end was that commitment-tree::repay’s Rust-side decode of delta_stroops (field-negation, described above) happens to overflow i128 for a non-negative delta - a property of that one caller, not a guarantee the circuit itself made. The circuit now directly constrains is_borrow to be boolean and requires new_debt >= old_debt when is_borrow=1, new_debt <= old_debt when is_borrow=0 - see circuits/src/borrow_repay.circom, Step 3b, and the negative tests in circuits/test/borrow_repay.test.js.
3. Liquidation Circuit
What it proves: “I know the preimage of a commitment in the Merkle tree, and the position’s current health ratio is below 120% at the current oracle price.” Public inputs:- The Merkle root
- The oracle price
- The usdc_debt (the debt amount - extracted from the proof so the liquidator cannot inflate it)
- The liquidation threshold (120%)
- Amount, secret, nonce (commitment preimage)
- Current debt
- Merkle path
usdc_debt binding: The liquidation circuit’s usdc_debt public output is computed inside the circuit from the private debt_stroops field. The liquidator cannot supply an arbitrary debt amount - it is derived from the private commitment and constrained by the circuit. This prevents a malicious keeper from claiming a larger debt than actually exists.
Groth16 and Protocol X-Ray
All three circuits use Groth16 - the most widely deployed ZK proof system, used by Zcash, Tornado Cash, and Stellar’s own Private Payments reference implementation. A Groth16 proof is a small constant-size object (3 elliptic curve points, ~128 bytes on BN254) that can be verified in constant time - regardless of the size of the computation being proven. Verification uses BN254 pairing checks: elliptic curve operations that confirm the relationship between the proof and the verification key. These are computationally expensive but deterministic. Stellar’s Protocol X-Ray (Protocol 26) added native host functions for BN254 operations:bn254_g1_msm- multi-scalar multiplication on the G1 curvebn254_pairing_check- pairing check across multiple pairs
zk-verifier contract uses these host functions to run the Groth16 verification:
The Merkle Commitment Tree
All commitments are stored in a Poseidon Merkle tree with depth 20 - supporting up to 1,048,576 (2²⁰) positions. The tree root is stored on-chain. When a new commitment is inserted, the root is updated. Every ZK proof that references the tree must match the current root - ensuring ZK proofs are valid only against the current state of the protocol. Sparse tree: The tree uses a sparse representation. Only occupied leaves require storage. Empty subtrees are represented by a precomputed empty-subtree hash. On-chain initial root:0x2134e76ac74b4b8765b6e37992aa15f06ff... (Poseidon-2 empty tree root - verified on-chain on Soroban testnet)
The Trusted Setup Ceremony
Groth16 requires a one-time trusted setup ceremony - a multi-party computation event that generates the proving and verification keys for each circuit. The ceremony has two phases:- Powers of Tau (Phase 1): A universal setup shared across all circuits. Writz will use the Hermez ceremony artifact (the same trusted setup used by Stellar’s own Private Payments reference implementation).
- Phase 2 (circuit-specific): A separate ceremony for each of Writz’s three circuits, incorporating the circuit-specific parameters.
pot15 from snarkjs) are used for testing only - they are NOT suitable for production.
Rotating the on-chain key once the real ceremony completes: zk-verifier.set_verification_key (admin-only) replaces a circuit’s key immediately - there is no grace period during which the previous key is also accepted. That’s a deliberate choice: accepting proofs against a stale key is a strictly weaker security posture than requiring the current one. What the contract does provide is an audit trail - every call emits a vk_rotated event carrying a fingerprint of the old and new key, so there’s an on-chain record of exactly when the dev key was replaced by the real ceremony’s output, even though storage itself only ever holds the current key.
What Gets Stored On-Chain
The ZK layer is designed to minimize on-chain storage while preventing double-spending and enabling verifiable state.
TTL management: Soroban’s storage has a time-to-live (TTL) system. Entries that are not accessed expire. Writz provides permissionless
refresh_* functions that any keeper can call to extend the TTL of critical entries - ensuring user positions never expire unexpectedly.
Next: The Stellar Side →