Skip to main content

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 a SpvVerificationResult (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:
Internally, this:
  1. Loads the verification key for the specified circuit
  2. Computes vk_x = Σ (public_signals[i] × vk.ic[i]) using bn254_g1_msm
  3. Runs a 4-pair bn254_pairing_check: e(A,B) == e(alpha,beta) × e(vk_x,gamma) × e(C,delta)
Returns 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: CBNZU23QGCZATJB2QMNF2K6IST2SVP7FSGCKASQNBULTWDWGANDBYLFY
All 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 - see contracts/contracts/commitment-tree/src/lib.rs for the exact ABI, including the full SPV header/Merkle-proof arguments deposit also takes):
Events emitted (topic strings, matching 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 borrow amount is extracted from the ZK proof’s public signal - the caller cannot supply an arbitrary amount
  • The repay amount is recovered from field-negation inversion of the proof’s delta signal
  • The liquidate usdc_debt is 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
Deployed: 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
Key functions (simplified - deposit also takes the full SPV header/Merkle-proof args plus the P2WSH script details, see contracts/contracts/private-lend/src/lib.rs):
Events emitted (topic strings, matching 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.
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
SEP-40 interface: The oracle stub in both contracts follows the Stellar SEP-40 standard interface. Switching oracle providers requires only updating the oracle contract address - no changes to lending logic.

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 →