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 f71563c..57a2877 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -104,6 +104,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 { @@ -770,6 +776,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(), @@ -777,6 +786,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 @@ -940,6 +952,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())); @@ -1019,6 +1034,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())); @@ -1242,6 +1260,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 { @@ -1348,6 +1369,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; @@ -1454,6 +1478,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 { @@ -1738,6 +1765,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)?; @@ -1758,6 +1788,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); @@ -1934,9 +1967,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); @@ -2134,6 +2173,9 @@ impl Htlc for EscrowContract { // CEI pattern: update state before external calls 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); @@ -2256,6 +2298,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); // Only transfer if there's an unreleased amount to refund if refund_amount > 0 { 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