From 2b1f898a7c2fa7a28bfe7a5364891d38bd065f8e Mon Sep 17 00:00:00 2001 From: Tobi Olusanya Date: Wed, 29 Jul 2026 22:11:28 +0100 Subject: [PATCH] Add missing extend_ttl calls for storage rent management Add TTL_EXTEND constant and extend_ttl calls on persistent storage entries across all contracts. Fixes cases where active trade state transitions (release, refund, dispute, resolve, etc.) and other persistent writes were missing TTL extensions, risking archival before off-chain observers can read terminal states. Affected contracts: - escrow: lock (TradeCounter, TradeId), release, refund, raise_dispute, resolve_dispute, fallback_after_timeout, batch_release, release_batch, release_escrow, chain_release_to_lock - atomic-swap: release, refund, extend_timelock_for_reorg - reputation: initialize, register_identity_root, verify_provider_reputation, compute_score - zk-credential: initialize, buy_credential, spend_credential Also adds docs/soroban-storage-rent.md with storage rent strategy and audit summary. --- contracts/atomic-swap/src/lib.rs | 14 +++++ contracts/escrow/src/lib.rs | 45 ++++++++++++++ contracts/reputation/src/lib.rs | 32 ++++++++-- contracts/zk-credential/src/lib.rs | 16 +++++ docs/soroban-storage-rent.md | 96 ++++++++++++++++++++++++++++++ 5 files changed, 198 insertions(+), 5 deletions(-) create mode 100644 docs/soroban-storage-rent.md diff --git a/contracts/atomic-swap/src/lib.rs b/contracts/atomic-swap/src/lib.rs index 9493839..aaf1bd7 100644 --- a/contracts/atomic-swap/src/lib.rs +++ b/contracts/atomic-swap/src/lib.rs @@ -76,6 +76,11 @@ pub struct CrossChainTxInfo { const DEFAULT_TIMEOUT_LEDGERS_MAX: u32 = 6 * 60 * 24 * 7; // ~7 days at 10s/ledger, sanity cap +/// TTL extension (in ledgers) for persistent storage entries — ~5.8 days at +/// ~5s/ledger. Applied on every active interaction that writes a persistent key +/// so the entry remains observable until settlement completes. +const TTL_EXTEND: u32 = 100_000; + /// Cross-chain reorg protection: finality depth per EVM chain /// These are chain_id -> k_confirmations mappings const ETHEREUM_MAINNET_FINALITY: u32 = 64; // ~15 minutes @@ -233,6 +238,9 @@ impl AtomicSwapContract { state.timeout_ledger = old_timeout.saturating_add(MAX_REORG_WINDOW_LEDGERS); env.storage().persistent().set(&key, &state); + env.storage() + .persistent() + .extend_ttl(&key, TTL_EXTEND, TTL_EXTEND); env.events().publish( (Symbol::new(&env, "timelock_extended"), trade_id), (old_timeout, state.timeout_ledger), @@ -362,6 +370,9 @@ impl Htlc for AtomicSwapContract { // CEI pattern: update state before external calls state.status = TradeStatus::Released; env.storage().persistent().set(&key, &state); + env.storage() + .persistent() + .extend_ttl(&key, TTL_EXTEND, TTL_EXTEND); let token_addr: Address = env.storage().instance().get(&DataKey::Token).unwrap(); let client = token::Client::new(&env, &token_addr); @@ -396,6 +407,9 @@ impl Htlc for AtomicSwapContract { // CEI pattern: update state before external calls state.status = TradeStatus::Refunded; env.storage().persistent().set(&key, &state); + env.storage() + .persistent() + .extend_ttl(&key, TTL_EXTEND, TTL_EXTEND); let token_addr: Address = env.storage().instance().get(&DataKey::Token).unwrap(); let client = token::Client::new(&env, &token_addr); diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 837137d..a1c59d2 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -98,6 +98,12 @@ enum DataKey { /// (~50s at Stellar's ~5s ledger close time). pub const PAUSE_DELAY_LEDGERS: u32 = 10; +/// Default TTL extension (in ledgers) for persistent storage entries during +/// active interactions. ~5.8 days at ~5s/ledger. Long enough for clients, +/// relayers, and reputation-scanners to observe terminal trade states, but +/// short enough that archived entries are eventually reclaimed. +const TTL_EXTEND: u32 = 100_000; + #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] pub enum Error { @@ -709,6 +715,9 @@ impl EscrowContract { state.status = TradeStatus::Disputed; env.storage().persistent().set(&key, &state); + env.storage() + .persistent() + .extend_ttl(&key, TTL_EXTEND, TTL_EXTEND); let info = DisputeInfo { start_ledger: env.ledger().sequence(), @@ -716,6 +725,9 @@ impl EscrowContract { env.storage() .persistent() .set(&DataKey::Dispute(id.clone()), &info); + env.storage() + .persistent() + .extend_ttl(&DataKey::Dispute(id.clone()), TTL_EXTEND, TTL_EXTEND); // Snapshot arbitrator-pool eligibility now, before anyone can react // to this dispute existing. `resolve_dispute()` and @@ -879,6 +891,9 @@ impl EscrowContract { state.status = TradeStatus::Resolved; env.storage().persistent().set(&key, &state); + env.storage() + .persistent() + .extend_ttl(&key, TTL_EXTEND, TTL_EXTEND); env.storage() .persistent() .remove(&DataKey::Dispute(id.clone())); @@ -958,6 +973,9 @@ impl EscrowContract { state.status = TradeStatus::Refunded; env.storage().persistent().set(&key, &state); + env.storage() + .persistent() + .extend_ttl(&key, TTL_EXTEND, TTL_EXTEND); env.storage() .persistent() .remove(&DataKey::Dispute(id.clone())); @@ -1136,6 +1154,9 @@ impl EscrowContract { // CEI pattern, same as release(): update state before external calls. state.status = TradeStatus::Released; env.storage().persistent().set(&key, &state); + env.storage() + .persistent() + .extend_ttl(&key, TTL_EXTEND, TTL_EXTEND); client.transfer(&env.current_contract_address(), &state.seller, &payout); if fee > 0 { @@ -1242,6 +1263,9 @@ impl EscrowContract { // property this function exists to provide. release_state.status = TradeStatus::Released; env.storage().persistent().set(&release_key, &release_state); + env.storage() + .persistent() + .extend_ttl(&release_key, TTL_EXTEND, TTL_EXTEND); let new_timeout_ledger = env.ledger().sequence() + new_timeout_ledgers; let new_state = TradeState { @@ -1336,6 +1360,9 @@ impl EscrowContract { // CEI pattern: update state before external calls. state.status = TradeStatus::Released; env.storage().persistent().set(&key, &state); + env.storage() + .persistent() + .extend_ttl(&key, TTL_EXTEND, TTL_EXTEND); client.transfer(&env.current_contract_address(), &state.seller, &payout); if fee > 0 { @@ -1607,6 +1634,9 @@ impl EscrowContract { } env.storage().persistent().set(&nonce_key, &true); + env.storage() + .persistent() + .extend_ttl(&nonce_key, TTL_EXTEND, TTL_EXTEND); let key = DataKey::Trade(escrow_id.clone()); let mut state: TradeState = env.storage().persistent().get(&key).ok_or(Error::TradeNotFound)?; @@ -1627,6 +1657,9 @@ impl EscrowContract { state.status = TradeStatus::Released; env.storage().persistent().set(&key, &state); + env.storage() + .persistent() + .extend_ttl(&key, TTL_EXTEND, TTL_EXTEND); let client = token::Client::new(&env, &token_addr); client.transfer(&env.current_contract_address(), &recipient_address, &payout); @@ -1791,9 +1824,15 @@ impl Htlc for EscrowContract { env.storage() .persistent() .set(&DataKey::TradeCounter, &next_idx); + env.storage() + .persistent() + .extend_ttl(&DataKey::TradeCounter, TTL_EXTEND, TTL_EXTEND); env.storage() .persistent() .set(&DataKey::TradeId(next_idx), &id); + env.storage() + .persistent() + .extend_ttl(&DataKey::TradeId(next_idx), TTL_EXTEND, TTL_EXTEND); let client = token::Client::new(&env, &token_addr); client.transfer(&buyer, &env.current_contract_address(), &amount); @@ -1846,6 +1885,9 @@ impl Htlc for EscrowContract { // CEI pattern: update state before external calls state.status = TradeStatus::Released; env.storage().persistent().set(&key, &state); + env.storage() + .persistent() + .extend_ttl(&key, TTL_EXTEND, TTL_EXTEND); let client = token::Client::new(&env, &token_addr); client.transfer(&env.current_contract_address(), &state.seller, &payout); @@ -1878,6 +1920,9 @@ impl Htlc for EscrowContract { // CEI pattern: update state before external calls state.status = TradeStatus::Refunded; env.storage().persistent().set(&key, &state); + env.storage() + .persistent() + .extend_ttl(&key, TTL_EXTEND, TTL_EXTEND); let token_addr: Address = env.storage().instance().get(&DataKey::Token).unwrap(); let client = token::Client::new(&env, &token_addr); diff --git a/contracts/reputation/src/lib.rs b/contracts/reputation/src/lib.rs index 3115be6..0045147 100644 --- a/contracts/reputation/src/lib.rs +++ b/contracts/reputation/src/lib.rs @@ -18,6 +18,10 @@ use soroban_sdk::{ const LEDGERS_PER_DAY: u32 = 17_280; const MAX_TRADES: u32 = 200; +/// TTL extension (in ledgers) for persistent storage entries. ~5.8 days at +/// ~5s/ledger. Applied on every active interaction that writes a persistent key. +const TTL_EXTEND: u32 = 100_000; + // Pre-computed exp(-0.01 * n) * 1_000_000 for n = 0..365 const DECAY_TABLE: [u32; 356] = [ 1_000_000, 990_049, 980_198, 970_445, 960_789, 951_229, 941_764, 932_393, 923_116, 913_931, @@ -110,9 +114,15 @@ impl ReputationContract { panic!("already initialized"); } env.storage().persistent().set(&RepDataKey::Admin, &admin); + env.storage() + .persistent() + .extend_ttl(&RepDataKey::Admin, TTL_EXTEND, TTL_EXTEND); env.storage() .persistent() .set(&RepDataKey::EscrowContract, &escrow_contract); + env.storage() + .persistent() + .extend_ttl(&RepDataKey::EscrowContract, TTL_EXTEND, TTL_EXTEND); } /// Register a verified identity Merkle root (admin only). @@ -129,7 +139,10 @@ impl ReputationContract { env.storage() .persistent() - .set(&RepDataKey::VerifiedRoot(root), &true); + .set(&RepDataKey::VerifiedRoot(root.clone()), &true); + env.storage() + .persistent() + .extend_ttl(&RepDataKey::VerifiedRoot(root), TTL_EXTEND, TTL_EXTEND); Ok(()) } @@ -182,6 +195,9 @@ impl ReputationContract { env.storage() .persistent() .set(&RepDataKey::SpentNullifier(nullifier_hash.clone()), &true); + env.storage() + .persistent() + .extend_ttl(&RepDataKey::SpentNullifier(nullifier_hash.clone()), TTL_EXTEND, TTL_EXTEND); // Emit verification event env.events().publish( @@ -243,9 +259,12 @@ impl ReputationContract { let count = call_escrow_u32(&env, &escrow, "get_trade_count"); let scan_max = core::cmp::min(count, MAX_TRADES); if scan_max == 0 { - env.storage() - .persistent() - .set(&RepDataKey::CachedScore(address), &0u32); + env.storage() + .persistent() + .set(&RepDataKey::CachedScore(address.clone()), &0u32); + env.storage() + .persistent() + .extend_ttl(&RepDataKey::CachedScore(address), TTL_EXTEND, TTL_EXTEND); return 0; } @@ -303,7 +322,10 @@ impl ReputationContract { env.storage() .persistent() - .set(&RepDataKey::CachedScore(address), &score); + .set(&RepDataKey::CachedScore(address.clone()), &score); + env.storage() + .persistent() + .extend_ttl(&RepDataKey::CachedScore(address), TTL_EXTEND, TTL_EXTEND); score } diff --git a/contracts/zk-credential/src/lib.rs b/contracts/zk-credential/src/lib.rs index 8c3adb9..21d3c2c 100644 --- a/contracts/zk-credential/src/lib.rs +++ b/contracts/zk-credential/src/lib.rs @@ -17,6 +17,10 @@ use soroban_sdk::{ const TREE_DEPTH: u32 = 8; const MAX_LEAVES: u32 = 256; // 2^TREE_DEPTH +/// TTL extension (in ledgers) for persistent storage entries. ~5.8 days at +/// ~5s/ledger. Applied on every active interaction that writes a persistent key. +const TTL_EXTEND: u32 = 100_000; + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum DataKey { @@ -72,6 +76,9 @@ impl ZkCredentialContract { env.storage() .persistent() .set(&DataKey::KnownRoots(initial_root.clone()), &true); + env.storage() + .persistent() + .extend_ttl(&DataKey::KnownRoots(initial_root), TTL_EXTEND, TTL_EXTEND); Ok(()) } @@ -108,6 +115,9 @@ impl ZkCredentialContract { env.storage() .persistent() .set(&DataKey::Leaf(count), &commitment); + env.storage() + .persistent() + .extend_ttl(&DataKey::Leaf(count), TTL_EXTEND, TTL_EXTEND); let new_count = count + 1; env.storage() .instance() @@ -121,6 +131,9 @@ impl ZkCredentialContract { env.storage() .persistent() .set(&DataKey::KnownRoots(new_root.clone()), &true); + env.storage() + .persistent() + .extend_ttl(&DataKey::KnownRoots(new_root), TTL_EXTEND, TTL_EXTEND); // Emit buy event env.events().publish( @@ -172,6 +185,9 @@ impl ZkCredentialContract { env.storage() .persistent() .set(&DataKey::Nullifier(nullifier_hash.clone()), &true); + env.storage() + .persistent() + .extend_ttl(&DataKey::Nullifier(nullifier_hash.clone()), TTL_EXTEND, TTL_EXTEND); // Emit spend event env.events() diff --git a/docs/soroban-storage-rent.md b/docs/soroban-storage-rent.md new file mode 100644 index 0000000..4d84c87 --- /dev/null +++ b/docs/soroban-storage-rent.md @@ -0,0 +1,96 @@ +# Soroban Storage Rent & TTL Management + +## Overview + +Soroban uses a rent-based storage model: entries in `Persistent` and `Temporary` storage have a Time-To-Live (TTL) measured in ledgers. Once the TTL expires, the entry becomes **archived** and can no longer be read by contract code. Archived entries may be reclaimed (deleted) by validators. + +Instance storage (`env.storage().instance()`) TTL is managed at the contract instance level and is automatically refreshed when the contract is invoked. It does not require per-key `extend_ttl` calls. + +## Storage Types + +| Type | Max TTL | Use Case | extend_ttl Required? | +|---|---|---|---| +| `Instance` | Indefinite | Config (Admin, Token, Fee, Signers) | No — bundled with instance TTL | +| `Persistent` | ~1 year | Trade state, arbitrator membership, dispute records, reputation | Yes — on every active write | +| `Temporary` | ~30 days | Ephemeral caches, proof verification results | Yes — on every active write | + +## TTL Extension Strategy in Velo + +All contracts use `TTL_EXTEND = 100_000` ledgers (~5.8 days at ~5s/ledger) for persistent entries. This value is chosen to: + +1. Cover the full lifecycle of a typical trade (lock → release/refund → observation) +2. Allow dispute resolution windows to play out without the trade or dispute record being archived +3. Keep reputation-scanner access to `TradeCounter` and `TradeId` entries +4. Provide enough runway for off-chain relayers and indexers to observe terminal events + +## Audit Findings: Missing `extend_ttl` Calls + +The following locations were found missing `extend_ttl` calls on persistent keys during active interactions and have been patched: + +### Escrow (`contracts/escrow/src/lib.rs`) + +| Function | Key(s) Patched | +|---|---| +| `lock` | `TradeCounter`, `TradeId` | +| `release` | `Trade` | +| `refund` | `Trade` | +| `raise_dispute` | `Trade`, `Dispute` | +| `resolve_dispute` | `Trade` | +| `fallback_after_timeout` | `Trade` | +| `batch_release` | `Trade` (per item) | +| `release_batch` | `Trade` (per item) | +| `release_escrow` | `Trade`, `Nonce` | +| `chain_release_to_lock` | `Trade` (released trade) | + +### Atomic Swap (`contracts/atomic-swap/src/lib.rs`) + +| Function | Key(s) Patched | +|---|---| +| `release` | `Trade` | +| `refund` | `Trade` | +| `extend_timelock_for_reorg` | `Trade` | + +### Reputation (`contracts/reputation/src/lib.rs`) + +| Function | Key(s) Patched | +|---|---| +| `initialize` | `Admin`, `EscrowContract` | +| `register_identity_root` | `VerifiedRoot` | +| `verify_provider_reputation` | `SpentNullifier` | +| `compute_score` | `CachedScore` | + +### ZK Credential (`contracts/zk-credential/src/lib.rs`) + +| Function | Key(s) Patched | +|---|---| +| `initialize` | `KnownRoots` | +| `buy_credential` | `Leaf`, `KnownRoots` | +| `spend_credential` | `Nullifier` | + +## Best Practices + +### When to call `extend_ttl` + +- Call **after** every `env.storage().persistent().set()` during an active interaction. +- Call **after** reading and modifying a persistent entry (CEI pattern: set + extend before external calls). +- Do NOT call for read-only getter/view functions — they don't modify state. + +### TTL values + +- **Trade data** (`Trade`, `Dispute`, `DisputeSelection`, `Commitment`): `100_000` ledgers +- **Sequential index** (`TradeCounter`, `TradeId`): `100_000` ledgers +- **Reputation / identity** (`CachedScore`, `VerifiedRoot`, `SpentNullifier`): `100_000` ledgers +- **Proof cache** (`ProofCache`): `50_000`–`100_000` ledgers (shorter threshold, longer max) +- **Nonce tracking** (`Nonce`): `100_000` ledgers + +### Monitoring + +Monitor contract storage usage via Soroban RPC endpoints. A rapid increase in archived entries with active trades suggests TTL values may need adjustment. + +## Cost Implications + +Each `extend_ttl` call consumes Soroban resources (CPU instructions + storage fees). The `TTL_EXTEND = 100_000` value balances archival protection with cost: + +- Extending an entry to 100_000 ledgers costs a fixed amount of gas per call +- The alternative — a client-facing transaction failing because a key was archived — is far more expensive in user trust and support burden +- Trade entries that reach a terminal state (`Released`, `Refunded`, `Resolved`) still get one final extension so off-chain observers can read the terminal state before archival