Status: design in progress, testnet only, not part of Writz v1. Writz v1 (Writz Lend onPrivacy is not part of the Writz protocol. It is being designed as a separate, opt-in edition: its own contracts (todaybitcoin-spvandwritz-lend) has no privacy layer: positions, amounts and collateral are public on chain. Nothing on this page protects a Writz Lend position today.
commitment-tree, zk-verifier and the Circom circuits, all under privacy/ in the repository), its own lending pool, its own audit and its own trusted-setup ceremony. It is planned for after v1 is live on mainnet, on its own subdomain, and has no date; it is not on the v1 mainnet plan. Users who do not want privacy never interact with it, and writz-lend never calls these contracts.
The rest of this page records the current design of that edition, as built and deployed on testnet. Zero-knowledge proofs would let the edition’s contracts verify facts about a position (that the loan is adequately collateralized, that the deposit is real, that a repayment is correct) without learning the underlying numbers.
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 the privacy edition, 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.
What the ZK layer does not hide: the commitment-tree contract publishes the depositor’s address with its Bitcoin transaction id, and the borrow and repay events and USDC transfers carry the Stellar address and exact amount. Anyone reading the ledger can link a Stellar address to a Bitcoin deposit and see loan sizes. Hiding these would take different transfer rails, not event changes, so this is a known limit of the current design.
The Commitment Scheme
At the heart of the ZK layer is a commitment. A commitment is a hash of private data:collateralis the BTC deposit amount in satoshis (private)debtis the USDC owed, in stroops (private)secretis derived from the user’s wallet signature and never leaves the device (private)liq_keyis enough to liquidate the position, not to borrow, repay or release it (private; also encrypted to the protocol keeper, see below)noncerotates on every change, so the new commitment cannot be linked to the old one (private)timelock_heightis the CLTV height of the deposit’s Bitcoin script (private after deposit)
liq_key, nonce and amounts to the protocol keeper’s key, inside the circuit, and the contract publishes the ciphertext in the event. Only the keeper can open it, and what it learns lets it liquidate an undercollateralized position without the owner, never move the owner’s funds. Observers learn nothing from it.
The commitment is stored on-chain in a Merkle tree. The preimage stays with the user.
The Nullifier
When a commitment is “consumed” (used in a borrow, repay, or withdraw), a nullifier is published:The ZK Circuits
The privacy edition uses five Circom circuits (deposit, borrow/repay, liquidation, zero-debt release and Merkle insertion), each compiled to a Groth16 proof system over the BN254 curve. The constraint counts below are measured withcircom --O2.
1. Deposit Circuit
What it proves: “I know (collateral, secret, nonce) such thatcommitment = Poseidon(collateral, 0, Poseidon(secret), nonce, timelock_height), the collateral equals what the SPV-verified transaction paid, and the transaction ID matches my claimed deposit.”
Public inputs:
- The commitment hash and nullifier (stored on-chain)
- The
txidreturned by the SPV contract (binding the ZK proof to a specific Bitcoin transaction) - The minimum deposit and the real amount the contract parsed from the transaction
timelock_height, which the contract checks against the deposit’s Bitcoin script (#212)
- Collateral (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)
- The recipient binding
min_timelock_height: a borrow proves the position’s timelock is at least this, and the contract requires it to be at least the Bitcoin tip plus a 30-day margin (#212)- The keeper’s public key and the keeper note: the new position’s liquidation witness, encrypted to that key inside the proof (#212)
- Collateral, secret, nonces and timelock (commitment preimage)
- Current debt
- Delta (borrow or repay amount)
- Merkle path (proof of membership in the tree)
- A fresh ephemeral key for the keeper note
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 privacy/circuits/src/borrow_repay.circom, Step 3b, and the negative tests in privacy/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.” The witness is the position’sliq_key, not its secret, so the keeper can prove it from the keeper note without the owner (#212).
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%)
- Collateral,
liq_key, nonce and timelock (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.
4. Zero-Debt Release Circuit
Proves a leaf in the tree encodes zero debt, without revealing its collateral or keys. It publishes the leaf’scommitment and nullifier: commitment-tree::mark_released spends the nullifier so the leaf can never borrow again, and /api/cosign only co-signs the BTC release once that is recorded.
5. Insertion Circuit
Proves that a new Merkle root is the current root with one commitment written into the next empty leaf. The relayer generates it from public data when a deposit is inserted, andcommitment-tree::insert_commitment accepts the proof instead of a root, so neither the admin key nor a relayer bug can install a tree with invented or missing leaves (#211).
Groth16 and Protocol X-Ray
All five 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 Stellar 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. The edition is planned to 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 the edition’s five 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. The edition’s contracts provide permissionless
refresh_* functions that any keeper can call to extend the TTL of critical entries - ensuring user positions never expire unexpectedly.
Back to v1: The Stellar Side →