Skip to main content
Two contracts, one system - how Writz lives on Soroban. The Stellar side of Writz v1 consists of two Soroban smart contracts (bitcoin-spv and writz-lend), Circle’s native USDC, the Reflector BTC/USD oracle, and an off-chain relayer that submits Bitcoin headers and co-signs releases. This page walks through each component, how they interact, and the design decisions behind them. Positions are plaintext: amounts, collateral and addresses are public on chain.

The Contracts

1. bitcoin-spv

Verifies Bitcoin transactions on Soroban using Simplified Payment Verification. It keeps a compact header chain on Stellar, anchored at a checkpoint, and proves transactions against it (see SPV Verification). verify_transaction takes a block hash, a Merkle proof, the transaction index and the raw transaction, and returns a SpvVerificationResult (defined once in the shared spv-types crate) containing the txid, block hash, block height and confirmation count. It also holds the shared deposit registry: consume_deposit records a Bitcoin txid the first time a registered lending contract uses it, so the same output can never back two positions. Deployed: CBX23YKCCJMAMBMSCNP6YVKEXUIYK2CTKSR4TQ3RVBPAFYZFVCW7W4EO

2. writz-lend

The lending contract. It registers Bitcoin deposits as positions keyed by txid, runs the USDC pool, accrues interest, handles liquidation and stores the co-signed release PSBT. Positions are plaintext. Key functions (simplified - deposit also takes the full SPV arguments, see contracts/contracts/writz-lend/src/lib.rs):
Security properties:
  • The deposit output must be exactly the P2WSH of the Writz script for the protocol key in the contract’s config, so BTC the depositor could spend alone can never become collateral
  • key_sig is a BIP-137 signature by user_pubkey over a message naming this contract, the depositor and the txid, so nobody can register someone else’s deposit
  • The timelock must lie 1,008..=105,000 blocks above the confirming block
  • Liquidation pays the liquidator a USDC bonus from the protocol reserve and never moves BTC (GHSA-5rxp-7f9g-r66x)
  • A pause (set_paused) stops new deposits, borrows and supply, never repay, withdraw_supply, liquidate or publish_release_psbt
Events emitted (topic strings, matching contracts/contracts/writz-lend/src/events.rs exactly): deposit, borrow, repay, repay_full (emitted instead of repay when a repayment fully closes the debt), liquidate, supply, withdraw, paused_set, oracle_set. Deployed: CABF7VK7F25ZGDLV6EWSAU3NYQYKDQNDSJIISDMDZAUASMSXUADSHZGG

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: Supply APR = borrow APR x utilization x (1 - 15%), as in contracts/contracts/writz-lend/src/rates.rs. 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: Pyth Network (pull model, verifier contract - real cross-exchange BTC/USD) Secondary oracle: Reflector’s external-prices instance (SEP-40 interface, lastprice({"Other":"BTC"})) - confirmed live on testnet with a real, updating BTC/USD price (Corrected 2026-09-16, then re-verified by directly calling both contracts on testnet - see docs/research/oracle-design.md. RedStone was dropped: it doesn’t list Stellar among its supported chains. DIA was parked: its documented testnet contract address returns “contract not found” when queried.) Staleness check: Price data older than 60 minutes is rejected (corrected - previously stated 90 seconds; see docs/research/oracle-design.md for rationale). At full design maturity, borrowing and liquidation pause when both oracles are stale. Interim status (2026-09-17): only Reflector is wired into writz-lend today - see contracts/contracts/writz-lend/src/oracle.rs. There is no median yet, so “both oracles stale” does not apply; a stale or unavailable Reflector price alone currently blocks the calling operation (OraclePriceStale/OraclePriceUnavailable) rather than silently mispricing. Do not treat this as the finished manipulation-resistance story until Pyth is also wired - see docs/security/security-model.md for the same caveat. Manipulation resistance:
  • Median of two oracles: a single oracle manipulation requires moving the median
  • Liquidation smoothing (design): large liquidations could be executed in parts to blunt single-block oracle manipulation; not implemented in writz-lend today
SEP-40 interface: writz-lend calls a real Reflector oracle live over the Stellar SEP-40 standard interface (contracts/contracts/writz-lend/src/oracle.rs); (The privacy edition’s commitment-tree still uses a hardcoded stub; it is not part of v1.) For writz-lend, 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 persistent entry has an expiration point; an entry past it is archived, not deleted. An archived entry cannot be read or overwritten until it is restored, unchanged, which the RPC’s simulation does automatically for any entry a transaction touches (the transaction pays the restore). So an archived position still blocks a second deposit of the same txid, and comes back with its debt when it is next used. The transaction that creates an entry pays rent for its whole window, at the rate the network charges at that moment. The windows for entries created per use are therefore set at deployment: The testnet contracts were redeployed with these parameters on 2026-10-25, after testnet rent rose sharply: a deposit was charged about 428 XLM in fees on the previous contracts, which used the mainnet windows, and about 89 XLM with the testnet windows (measured 2026-10-25). Positions on the previous contracts were closed and their BTC released. The constructor arguments are in the Contract reference. Testnet runs short windows because its live state is above the network’s target size, which prices rent far higher than on mainnet: a 180-day position would cost more resource fee than one transaction can carry. Seven days is the network’s minimum persistent TTL, so a 7-day window adds no rent beyond what creating the entry already costs. An entry that is read or rewritten in the last third of its window (at most its last 30 days) is extended back to its full window. The admin can change these windows without a redeploy (set_entry_ttl on writz-lend, set_ttls on bitcoin-spv, same bounds as the constructors), so they can follow the rent rate. A change applies to entries written or touched after it; existing entries keep their TTL until a call tops them up or refreshes them. 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.
writz-lend’s refresh_release_psbt_ttl is a recent addition - before it, 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 writz-lend) and trigger the BTC co-signing release on the Bitcoin side - see relayer/src/repay-watcher/.

Contract Interactions: Full Deposit Flow


Privacy edition contracts (in progress, not part of v1)

zk-verifier and commitment-tree are deployed on testnet for the separate, opt-in privacy edition (in progress). They have their own pool, and writz-lend never calls them; the only thing they share with v1 is bitcoin-spv and its deposit registry. The summary below records their current design.

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: CB5XAP6MF2GVVFATFTVCKVEXUODWWRQP4B3QFDMRTM55REP7N2YMXEUD
All three verification keys are set on testnet (Deposit IC=6, BorrowRepay IC=9, Liquidation IC=6).

commitment-tree

The privacy edition’s 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 privacy/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 privacy/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: CAQBLCAPGXXCZTJDAJLY7E4PTJYGR6CY4YWH4JSJKFXYIHW3GANP6RFM
Next: Developer Quick Start →