The Stellar Side
Four contracts, one system - how Writz lives on Soroban. The Stellar side of Writz consists of four Soroban smart contracts, a USDC liquidity pool, an interest rate model, and an oracle layer. This page walks through each component, how they interact, and the design decisions behind them.The Four Contracts
1. bitcoin-spv
Verifies Bitcoin transactions on Soroban using Simplified Payment Verification. Completely stateless - no Bitcoin headers are stored on-chain. Takes a proof bundle (headers + Merkle proof + raw transaction) and returns aSpvVerificationResult (defined once in the shared spv-types crate, not duplicated per contract) containing the txid, block hash, and confirmation count.
This contract is called first in every deposit flow. Its output - specifically the txid - is passed to the commitment-tree contract and bound into the ZK proof, ensuring every position corresponds to a real Bitcoin transaction.
Deployed: CB2BD6QCSZVNZN5NLI7C5NF356WXVJDSXT6LVAQFWHHS4SZ4NCKKNIVA
2. zk-verifier
Verifies Groth16 BN254 proofs on Soroban using Protocol X-Ray’s BN254 host functions. Stores one verification key per circuit type (Deposit, BorrowRepay, Liquidation).
The core verification:
- Loads the verification key for the specified circuit
- Computes
vk_x = Σ (public_signals[i] × vk.ic[i])usingbn254_g1_msm - Runs a 4-pair
bn254_pairing_check:e(A,B) == e(alpha,beta) × e(vk_x,gamma) × e(C,delta)
true if the proof is valid, false otherwise. Malformed proofs (invalid curve points) cause the host to reject the transaction entirely - the correct security behavior.
Deployed: CBNZU23QGCZATJB2QMNF2K6IST2SVP7FSGCKASQNBULTWDWGANDBYLFYAll three verification keys are set on testnet (Deposit IC=6, BorrowRepay IC=9, Liquidation IC=6).
3. commitment-tree
The core privacy and lending contract. Manages the Poseidon Merkle commitment tree and all ZK-gated lending operations. This is where positions are created, loans are issued, and repayments are recorded. Key functions (simplified signatures - seecontracts/contracts/commitment-tree/src/lib.rs for the exact ABI, including the full SPV header/Merkle-proof arguments deposit also takes):
contracts/contracts/commitment-tree/src/events.rs exactly): deposit, insert_leaf, borrow, repay, liquidate. There is currently no event for supply_usdc/withdraw_supply - an indexer tracking pool liquidity needs to read get_pool_state/get_supply_balance directly rather than listen for an event that doesn’t exist.
Security properties:
- The
borrowamount is extracted from the ZK proof’s public signal - the caller cannot supply an arbitrary amount - The
repayamount is recovered from field-negation inversion of the proof’s delta signal - The
liquidateusdc_debtis extracted from the proof - the liquidator cannot inflate the debt they claim - Nullifier freshness is checked before any state change - double-spending is impossible
- Merkle root must match the current on-chain root - stale proofs are rejected
CDQCTFO3FK3M47QS47O2A4WLNPSQAQBSXBFPJ6RZEHFO5D7RY34FSBBP
4. private-lend
A non-ZK lending skeleton that provides the borrowing and repayment interface without the ZK layer. Used for:- Phase 1 testing (simpler than the full ZK flow)
- A reference implementation showing the core lending mechanics
- Future: may be used as a “fast lane” for users who opt out of ZK privacy
deposit also takes the full SPV header/Merkle-proof args plus the P2WSH script details, see contracts/contracts/private-lend/src/lib.rs):
contracts/contracts/private-lend/src/events.rs exactly): deposit, borrow, repay, repay_full (emitted in addition to repay when a repayment fully closes the debt), liquidate.
Deployed: CAAWVMDRUPEJNELSQ6RU2VMVX5EJLQ2E77T7IXDWGMW4DGSNAGECGSWR
Interest Rate Model
Writz uses a kinked utilization curve - the same model pioneered by Aave and Compound, adapted for Writz’s parameters.
Rate examples:
Interest accrues continuously. Every position-touching call (borrow, repay, liquidate) applies accrued interest before processing the action.
Oracle Design
Writz uses a multi-oracle approach for BTC/USD price feeds, with a median aggregation strategy to resist price manipulation. Primary oracle: RedStone (pull model, SEP-40 interface)Secondary oracle: Pyth Network (pull model, SEP-40 interface) Staleness check: Price data older than 60 minutes is rejected (corrected - previously stated 90 seconds; see
docs/research/oracle-design.md for rationale). If both oracles are stale, borrowing and liquidation are paused until fresh prices are available.
Manipulation resistance:
- Median of two oracles: a single oracle manipulation requires moving the median
- Liquidation smoothing: large liquidations can be executed in tranches to prevent single-block oracle manipulation attacks
Storage and TTL Management
Soroban’s storage has a time-to-live (TTL) system. Every storage entry has an expiration point; entries that are not accessed eventually expire and are deleted. Writz manages TTL per-entry (not one contract-wide setting) to ensure user positions never expire unexpectedly:
Permissionless refresh: every entry above has a public
refresh_* function (or is covered by a contract-wide refresh_ttl/refresh_instance_ttl) that extends its TTL. Any keeper - including Writz’s own keeper, a third-party keeper, or even the user themselves - can call these to prevent expiry. No permission required. This is a genuinely load-bearing safety net, not a nice-to-have: none of these entries auto-renews just from being read by an unrelated call, and an idle deployment (low traffic, or a position nobody touches for months) is exactly the case these functions exist for.
zk-verifier’s refresh_ttl and private-lend’s refresh_release_psbt_ttl are recent additions - before them, zk-verifier had no TTL management at all (a real gap: if a verification key’s TTL lapsed, verify_deposit/verify_borrow_repay/verify_liquidation would start failing with VerificationKeyNotSet with no prior on-chain warning), and a published release PSBT could only have its TTL bumped as a side effect of fetching or re-publishing it, with no standalone way to keep it alive.
Events
Contract state changes emit structured events using Soroban’s#[contractevent] annotation. Topic strings below match events.rs in each contract exactly - an indexer or the relayer’s event watcher must filter on these, not on invented names.
supply/withdraw carry supplier, usdc_amount, and the resulting total_supplied - enough to enumerate every lender an off-chain indexer needs, the same way deposit events let you enumerate borrowers (see docs/architecture/contract-migration-runbook.md).
Events are the primary mechanism for the Writz backend to detect loan repayments (repay_full on private-lend) and trigger the BTC co-signing release on the Bitcoin side - see relayer/src/repay-watcher/.
Contract Interactions: Full Deposit Flow
Next: Developer Quick Start →