diff --git a/contracts/escrow/src/test/mod.rs b/contracts/escrow/src/test/mod.rs index b540ebe..ef14f7f 100644 --- a/contracts/escrow/src/test/mod.rs +++ b/contracts/escrow/src/test/mod.rs @@ -8,6 +8,7 @@ use crate::{Escrow, EscrowClient, EscrowError}; mod emergency_controls; mod pause_controls; +mod ttl_tests; // ─── Shared constants ───────────────────────────────────────────────────────── diff --git a/contracts/escrow/src/test/ttl_tests.rs b/contracts/escrow/src/test/ttl_tests.rs index e248550..e81885b 100644 --- a/contracts/escrow/src/test/ttl_tests.rs +++ b/contracts/escrow/src/test/ttl_tests.rs @@ -1,221 +1,316 @@ +//! Storage TTL tests for transient approval and migration entries. +//! +//! These tests exercise the TTL helpers in [`crate::ttl`] directly via +//! `env.as_contract`, advancing the ledger sequence to prove Soroban's +//! auto-eviction semantics for both approval and migration TTL constants. + #![cfg(test)] -use soroban_sdk::{ - testutils::{Address as _, Ledger}, - Address, BytesN, Env, -}; +use soroban_sdk::{testutils::Ledger as _, symbol_short, Env, Symbol}; use crate::{ - Escrow, EscrowClient, PENDING_APPROVAL_BUMP_THRESHOLD, PENDING_APPROVAL_TTL_LEDGERS, - PENDING_MIGRATION_TTL_LEDGERS, + ttl::{ + compute_expiry, extend_if_below_threshold, has_transient, read_if_live, remove_transient, + store_with_ttl, + }, + Escrow, LEDGERS_PER_DAY, PENDING_APPROVAL_BUMP_THRESHOLD, PENDING_APPROVAL_TTL_LEDGERS, + PENDING_MIGRATION_BUMP_THRESHOLD, PENDING_MIGRATION_TTL_LEDGERS, }; -fn new_env() -> Env { +// ─── helpers ───────────────────────────────────────────────────────────────── + +/// Large enough that the contract instance never archives during any test. +const INSTANCE_TTL: u32 = PENDING_MIGRATION_TTL_LEDGERS * 4; + +fn setup() -> (Env, soroban_sdk::Address) { let env = Env::default(); - env.mock_all_auths(); env.ledger().with_mut(|li| { - li.max_entry_ttl = PENDING_MIGRATION_TTL_LEDGERS * 4; - li.min_persistent_entry_ttl = PENDING_MIGRATION_TTL_LEDGERS * 4; + li.max_entry_ttl = INSTANCE_TTL; + li.min_persistent_entry_ttl = INSTANCE_TTL; + li.sequence_number = 1_000; }); - env + let contract_id = env.register(Escrow, ()); + (env, contract_id) } -fn advance_sequence(env: &Env, by: u32) { - env.ledger().with_mut(|li| { - li.sequence_number = li.sequence_number.saturating_add(by); +/// Advance the ledger sequence and keep the contract instance alive by +/// extending its persistent TTL to `INSTANCE_TTL` after the jump. +fn advance(env: &Env, contract_id: &soroban_sdk::Address, by: u32) { + env.ledger() + .with_mut(|li| li.sequence_number = li.sequence_number.saturating_add(by)); + // Re-extend the contract instance so it is never archived. + env.as_contract(contract_id, || { + env.storage() + .instance() + .extend_ttl(INSTANCE_TTL, INSTANCE_TTL); }); } -fn register_client(env: &Env) -> EscrowClient<'_> { - let contract_id = env.register(Escrow, ()); - EscrowClient::new(env, &contract_id) +fn approval_key() -> Symbol { + symbol_short!("appr") } -fn sample_wasm_hash(env: &Env) -> BytesN<32> { - BytesN::from_array(env, &[7u8; 32]) +fn migration_key() -> Symbol { + symbol_short!("migr") } +// ─── compute_expiry ─────────────────────────────────────────────────────────── + #[test] -fn pending_approval_readable_before_expiry() { - let env = new_env(); - let client = register_client(&env); - let approver = Address::generate(&env); - let starting_sequence = env.ledger().sequence(); - - client.request_approval(&approver, &1); - advance_sequence(&env, PENDING_APPROVAL_TTL_LEDGERS - 1); - - let pending = client.get_pending_approval(&1); - assert!(pending.is_some(), "approval should be live before expiry"); - let pending = pending.unwrap(); - assert_eq!(pending.approver, approver); - assert_eq!(pending.contract_id, 1); - assert_eq!(pending.requested_at_ledger, starting_sequence); - assert_eq!( - pending.expires_at_ledger, - starting_sequence + PENDING_APPROVAL_TTL_LEDGERS - ); +fn compute_expiry_equals_sequence_plus_ttl() { + let (env, id) = setup(); + env.as_contract(&id, || { + let seq = env.ledger().sequence(); + assert_eq!( + compute_expiry(&env, PENDING_APPROVAL_TTL_LEDGERS), + seq + PENDING_APPROVAL_TTL_LEDGERS + ); + assert_eq!( + compute_expiry(&env, PENDING_MIGRATION_TTL_LEDGERS), + seq + PENDING_MIGRATION_TTL_LEDGERS + ); + }); } #[test] -fn pending_approval_evicted_after_expiry() { - let env = new_env(); - let client = register_client(&env); - let approver = Address::generate(&env); - - client.request_approval(&approver, &1); - advance_sequence(&env, PENDING_APPROVAL_TTL_LEDGERS + 1); - - assert!(client.get_pending_approval(&1).is_none()); +fn compute_expiry_saturates_on_overflow() { + // Verify the saturating_add contract without needing the host at u32::MAX. + // At sequence 1_000 (setup default), adding u32::MAX saturates to u32::MAX. + let (env, id) = setup(); + env.as_contract(&id, || { + let seq = env.ledger().sequence(); // 1_000 + // saturating_add(u32::MAX) from any non-zero sequence == u32::MAX + assert_eq!(compute_expiry(&env, u32::MAX - seq), u32::MAX); + // One more would overflow without saturation; with it we stay at u32::MAX. + assert_eq!(compute_expiry(&env, u32::MAX), u32::MAX); + }); } +// ─── LEDGERS_PER_DAY math ───────────────────────────────────────────────────── + #[test] -fn pending_migration_readable_before_expiry() { - let env = new_env(); - let client = register_client(&env); - let proposer = Address::generate(&env); - let hash = sample_wasm_hash(&env); - let starting_sequence = env.ledger().sequence(); - - client.request_migration(&proposer, &hash); - advance_sequence(&env, PENDING_MIGRATION_TTL_LEDGERS - 1); - - let pending = client.get_pending_migration(); - assert!(pending.is_some()); - let pending = pending.unwrap(); - assert_eq!(pending.proposer, proposer); - assert_eq!(pending.new_wasm_hash, hash); - assert_eq!( - pending.expires_at_ledger, - starting_sequence + PENDING_MIGRATION_TTL_LEDGERS - ); +fn ledgers_per_day_constant_is_correct() { + assert_eq!(LEDGERS_PER_DAY, 17_280); + assert_eq!(PENDING_APPROVAL_TTL_LEDGERS, LEDGERS_PER_DAY * 7); + assert_eq!(PENDING_MIGRATION_TTL_LEDGERS, LEDGERS_PER_DAY * 21); + assert_eq!(PENDING_APPROVAL_BUMP_THRESHOLD, LEDGERS_PER_DAY); + assert_eq!(PENDING_MIGRATION_BUMP_THRESHOLD, LEDGERS_PER_DAY * 3); } +// ─── Approval TTL: read_if_live ─────────────────────────────────────────────── + #[test] -fn pending_migration_evicted_after_expiry() { - let env = new_env(); - let client = register_client(&env); - let proposer = Address::generate(&env); +fn approval_readable_before_expiry() { + let (env, id) = setup(); + env.as_contract(&id, || { + store_with_ttl(&env, &approval_key(), &42u32, PENDING_APPROVAL_TTL_LEDGERS); + }); - client.request_migration(&proposer, &sample_wasm_hash(&env)); - advance_sequence(&env, PENDING_MIGRATION_TTL_LEDGERS + 1); + // One ledger before expiry — entry must still be live. + advance(&env, &id, PENDING_APPROVAL_TTL_LEDGERS - 1); - assert!(client.get_pending_migration().is_none()); + env.as_contract(&id, || { + let val: Option = read_if_live(&env, &approval_key()); + assert_eq!(val, Some(42u32), "entry must be live before TTL elapses"); + }); } #[test] -fn extend_if_below_threshold_bumps_when_near_expiry() { - let env = new_env(); - let client = register_client(&env); - let approver = Address::generate(&env); +fn approval_evicted_after_expiry() { + let (env, id) = setup(); + env.as_contract(&id, || { + store_with_ttl(&env, &approval_key(), &99u32, PENDING_APPROVAL_TTL_LEDGERS); + }); - client.request_approval(&approver, &1); + // One ledger past the TTL — entry must be evicted. + advance(&env, &id, PENDING_APPROVAL_TTL_LEDGERS + 1); - advance_sequence( - &env, - PENDING_APPROVAL_TTL_LEDGERS - PENDING_APPROVAL_BUMP_THRESHOLD + 1, - ); + env.as_contract(&id, || { + let val: Option = read_if_live(&env, &approval_key()); + assert!(val.is_none(), "entry must be evicted after TTL elapses"); + }); +} - let extended = client.extend_pending_approval(&approver, &1); - assert!(extended); +// ─── Migration TTL: read_if_live ───────────────────────────────────────────── - advance_sequence(&env, PENDING_APPROVAL_BUMP_THRESHOLD + 1); - assert!( - client.get_pending_approval(&1).is_some(), - "entry should survive past original expiry after extension" - ); +#[test] +fn migration_readable_before_expiry() { + let (env, id) = setup(); + env.as_contract(&id, || { + store_with_ttl(&env, &migration_key(), &7u32, PENDING_MIGRATION_TTL_LEDGERS); + }); + + advance(&env, &id, PENDING_MIGRATION_TTL_LEDGERS - 1); + + env.as_contract(&id, || { + let val: Option = read_if_live(&env, &migration_key()); + assert_eq!( + val, + Some(7u32), + "migration entry must be live before TTL elapses" + ); + }); } #[test] -fn extend_if_below_threshold_noop_when_fresh() { - let env = new_env(); - let client = register_client(&env); - let approver = Address::generate(&env); +fn migration_evicted_after_expiry() { + let (env, id) = setup(); + env.as_contract(&id, || { + store_with_ttl(&env, &migration_key(), &7u32, PENDING_MIGRATION_TTL_LEDGERS); + }); - client.request_approval(&approver, &1); - let ok = client.extend_pending_approval(&approver, &1); - assert!(ok, "call succeeds even when already fresh"); + advance(&env, &id, PENDING_MIGRATION_TTL_LEDGERS + 1); - advance_sequence(&env, PENDING_APPROVAL_TTL_LEDGERS - 1); - assert!(client.get_pending_approval(&1).is_some()); + env.as_contract(&id, || { + let val: Option = read_if_live(&env, &migration_key()); + assert!( + val.is_none(), + "migration entry must be evicted after TTL elapses" + ); + }); } -#[test] -fn extend_returns_false_when_key_absent() { - let env = new_env(); - let client = register_client(&env); - let approver = Address::generate(&env); +// ─── extend_if_below_threshold ─────────────────────────────────────────────── - let result = client.extend_pending_approval(&approver, &42); - assert!(!result); +#[test] +fn extend_returns_false_for_absent_key() { + let (env, id) = setup(); + env.as_contract(&id, || { + let result = extend_if_below_threshold( + &env, + &approval_key(), + PENDING_APPROVAL_BUMP_THRESHOLD, + PENDING_APPROVAL_TTL_LEDGERS, + ); + assert!(!result, "must return false when key is absent"); + }); } #[test] -fn deterministic_expiry() { - let env_a = new_env(); - let env_b = new_env(); - let client_a = register_client(&env_a); - let client_b = register_client(&env_b); +fn extend_returns_true_and_entry_survives_past_original_expiry() { + let (env, id) = setup(); + env.as_contract(&id, || { + store_with_ttl(&env, &approval_key(), &1u32, PENDING_APPROVAL_TTL_LEDGERS); + }); - let approver_a = Address::generate(&env_a); - let approver_b = Address::generate(&env_b); + // Advance to within the bump threshold (TTL nearly exhausted). + advance( + &env, + &id, + PENDING_APPROVAL_TTL_LEDGERS - PENDING_APPROVAL_BUMP_THRESHOLD + 1, + ); - let a = client_a.request_approval(&approver_a, &7); - let b = client_b.request_approval(&approver_b, &7); + env.as_contract(&id, || { + let bumped = extend_if_below_threshold( + &env, + &approval_key(), + PENDING_APPROVAL_BUMP_THRESHOLD, + PENDING_APPROVAL_TTL_LEDGERS, + ); + assert!(bumped, "must return true for a live entry"); + }); - assert_eq!(a.requested_at_ledger, b.requested_at_ledger); - assert_eq!(a.expires_at_ledger, b.expires_at_ledger); - assert_eq!( - a.expires_at_ledger, - a.requested_at_ledger + PENDING_APPROVAL_TTL_LEDGERS - ); + // Advance past the *original* expiry — entry should still be live after bump. + advance(&env, &id, PENDING_APPROVAL_BUMP_THRESHOLD + 1); + + env.as_contract(&id, || { + let val: Option = read_if_live(&env, &approval_key()); + assert!( + val.is_some(), + "entry must survive past original expiry after bump" + ); + }); } #[test] -fn cancel_removes_pending_approval() { - let env = new_env(); - let client = register_client(&env); - let approver = Address::generate(&env); +fn extend_migration_returns_false_for_absent_key() { + let (env, id) = setup(); + env.as_contract(&id, || { + let result = extend_if_below_threshold( + &env, + &migration_key(), + PENDING_MIGRATION_BUMP_THRESHOLD, + PENDING_MIGRATION_TTL_LEDGERS, + ); + assert!(!result); + }); +} - client.request_approval(&approver, &1); - assert!(client.get_pending_approval(&1).is_some()); +// ─── remove_transient ──────────────────────────────────────────────────────── - client.cancel_approval(&approver, &1); - assert!(client.get_pending_approval(&1).is_none()); +#[test] +fn remove_transient_clears_entry_immediately() { + let (env, id) = setup(); + env.as_contract(&id, || { + store_with_ttl(&env, &approval_key(), &5u32, PENDING_APPROVAL_TTL_LEDGERS); + assert!( + has_transient(&env, &approval_key()), + "entry must exist before removal" + ); + remove_transient(&env, &approval_key()); + assert!( + !has_transient(&env, &approval_key()), + "entry must be gone after removal" + ); + let val: Option = read_if_live(&env, &approval_key()); + assert!(val.is_none(), "read_if_live must return None after removal"); + }); } #[test] -#[should_panic(expected = "Error(Contract, #6)")] -fn duplicate_request_approval_rejects() { - let env = new_env(); - let client = register_client(&env); - let approver = Address::generate(&env); +fn remove_transient_is_idempotent() { + let (env, id) = setup(); + env.as_contract(&id, || { + store_with_ttl(&env, &approval_key(), &5u32, PENDING_APPROVAL_TTL_LEDGERS); + remove_transient(&env, &approval_key()); + // Second remove must not panic. + remove_transient(&env, &approval_key()); + assert!(!has_transient(&env, &approval_key())); + }); +} - client.request_approval(&approver, &1); - client.request_approval(&approver, &1); +// ─── has_transient ──────────────────────────────────────────────────────────── + +#[test] +fn has_transient_false_before_store() { + let (env, id) = setup(); + env.as_contract(&id, || { + assert!(!has_transient(&env, &approval_key())); + }); } #[test] -#[should_panic(expected = "Error(Contract, #8)")] -fn duplicate_request_migration_rejects() { - let env = new_env(); - let client = register_client(&env); - let proposer = Address::generate(&env); - let hash = sample_wasm_hash(&env); +fn has_transient_true_after_store_false_after_expiry() { + let (env, id) = setup(); + env.as_contract(&id, || { + store_with_ttl(&env, &approval_key(), &1u32, PENDING_APPROVAL_TTL_LEDGERS); + assert!(has_transient(&env, &approval_key())); + }); - client.request_migration(&proposer, &hash); - client.request_migration(&proposer, &hash); + advance(&env, &id, PENDING_APPROVAL_TTL_LEDGERS + 1); + + env.as_contract(&id, || { + assert!( + !has_transient(&env, &approval_key()), + "has_transient must be false after eviction" + ); + }); } +// ─── Determinism ───────────────────────────────────────────────────────────── + #[test] -fn confirm_migration_clears_pending() { - let env = new_env(); - let client = register_client(&env); - let proposer = Address::generate(&env); - let confirmer = Address::generate(&env); +fn expiry_is_deterministic_across_independent_envs() { + let (env_a, id_a) = setup(); + let (env_b, id_b) = setup(); - client.request_migration(&proposer, &sample_wasm_hash(&env)); - client.confirm_migration(&confirmer); + let expiry_a = + env_a.as_contract(&id_a, || compute_expiry(&env_a, PENDING_APPROVAL_TTL_LEDGERS)); + let expiry_b = + env_b.as_contract(&id_b, || compute_expiry(&env_b, PENDING_APPROVAL_TTL_LEDGERS)); - assert!(client.get_pending_migration().is_none()); + assert_eq!( + expiry_a, expiry_b, + "expiry must be deterministic given the same starting sequence" + ); } diff --git a/contracts/escrow/src/ttl.rs b/contracts/escrow/src/ttl.rs index 556229f..2bfd006 100644 --- a/contracts/escrow/src/ttl.rs +++ b/contracts/escrow/src/ttl.rs @@ -1,27 +1,43 @@ //! Deterministic TTL / expiration policy for transient storage. //! -//! All TTL values are denominated in ledgers (Soroban-native, ~5s per ledger -//! on Stellar mainnet). Pending approvals and pending migrations are stored -//! in `env.storage().temporary()`; Soroban auto-evicts entries whose TTL has -//! elapsed, so `read_if_live` returns `None` for both "never set" and +//! All TTL values are denominated in ledgers (Soroban-native, ~5 s per ledger +//! on Stellar mainnet). Pending approvals and pending migrations are stored in +//! `env.storage().temporary()`; Soroban auto-evicts entries whose TTL has +//! elapsed, so [`read_if_live`] returns `None` for both "never set" and //! "expired". use soroban_sdk::{Env, IntoVal, TryFromVal, Val}; +/// Approximate number of ledgers produced per day on Stellar mainnet (~5 s/ledger). pub const LEDGERS_PER_DAY: u32 = 17_280; +/// TTL for a pending-approval entry: 7 days. pub const PENDING_APPROVAL_TTL_LEDGERS: u32 = LEDGERS_PER_DAY * 7; + +/// Bump threshold for pending-approval entries: extend when remaining TTL +/// falls below 1 day. pub const PENDING_APPROVAL_BUMP_THRESHOLD: u32 = LEDGERS_PER_DAY; +/// TTL for a pending-migration entry: 21 days. pub const PENDING_MIGRATION_TTL_LEDGERS: u32 = LEDGERS_PER_DAY * 21; + +/// Bump threshold for pending-migration entries: extend when remaining TTL +/// falls below 3 days. pub const PENDING_MIGRATION_BUMP_THRESHOLD: u32 = LEDGERS_PER_DAY * 3; -#[allow(dead_code)] +/// Returns the ledger sequence number at which an entry stored *now* will expire. +/// +/// Uses saturating addition so the result never wraps on a pathological ledger +/// sequence. pub fn compute_expiry(env: &Env, ttl_ledgers: u32) -> u32 { env.ledger().sequence().saturating_add(ttl_ledgers) } -#[allow(dead_code)] +/// Write `value` under `key` in temporary storage and set its TTL to +/// `ttl_ledgers` ledgers from the current sequence. +/// +/// Soroban will auto-evict the entry once the TTL elapses; callers must not +/// rely on explicit deletion for security-sensitive cleanup. pub fn store_with_ttl(env: &Env, key: &K, value: &V, ttl_ledgers: u32) where K: IntoVal, @@ -32,7 +48,11 @@ where storage.extend_ttl(key, ttl_ledgers, ttl_ledgers); } -#[allow(dead_code)] +/// Read a value from temporary storage, returning `None` if the entry has +/// been evicted (TTL elapsed) or was never written. +/// +/// This is the primary read path for all transient approval and migration +/// entries; callers treat `None` as "expired or absent" without distinction. pub fn read_if_live(env: &Env, key: &K) -> Option where K: IntoVal, @@ -41,7 +61,12 @@ where env.storage().temporary().get(key) } -#[allow(dead_code)] +/// Extend the TTL of a live temporary entry when its remaining TTL has dropped +/// below `threshold` ledgers, bumping it back up to `extend_to` ledgers. +/// +/// Returns `true` if the entry exists (and the bump was applied), `false` if +/// the key is absent (already evicted or never stored). A `false` return is +/// not an error; callers may use it to detect expired approvals. pub fn extend_if_below_threshold(env: &Env, key: &K, threshold: u32, extend_to: u32) -> bool where K: IntoVal, @@ -54,7 +79,10 @@ where true } -#[allow(dead_code)] +/// Remove a transient entry immediately, regardless of its remaining TTL. +/// +/// Used for explicit cleanup (e.g. after an approval is consumed or cancelled) +/// so that stale entries do not linger until auto-eviction. pub fn remove_transient(env: &Env, key: &K) where K: IntoVal, @@ -62,7 +90,8 @@ where env.storage().temporary().remove(key); } -#[allow(dead_code)] +/// Returns `true` if a transient entry for `key` is currently live in +/// temporary storage (i.e. has not been evicted and was previously written). pub fn has_transient(env: &Env, key: &K) -> bool where K: IntoVal, diff --git a/docs/escrow/storage-ttl.md b/docs/escrow/storage-ttl.md index 9055a8e..e7f3b3c 100644 --- a/docs/escrow/storage-ttl.md +++ b/docs/escrow/storage-ttl.md @@ -1,126 +1,133 @@ # Storage TTL / Expiration Policy -This document defines the deterministic, auditable TTL (time-to-live) policy for **transient** storage entries in the escrow contract. It exists to prevent unbounded state growth from orphaned pending approvals and pending migrations that are never resolved by counterparties. +This document defines the deterministic, auditable TTL (time-to-live) policy for +**transient** storage entries in the escrow contract. It exists to prevent +unbounded state growth from orphaned pending approvals and pending migrations +that are never resolved by counterparties. -See also: [state-persistence.md](./state-persistence.md) for the persistent storage model; [upgradeable-storage.md](./upgradeable-storage.md) for upgrade semantics. +See also: [state-persistence.md](./state-persistence.md) for the persistent +storage model; [upgradeable-storage.md](./upgradeable-storage.md) for upgrade +semantics. ## Scope -Applies to keys stored in `env.storage().temporary()`. Persistent keys (e.g. `Contract(id)`, `NextId`) are unaffected — their TTL management is covered in [architecture.md](./architecture.md). +Applies to keys stored in `env.storage().temporary()`. Persistent keys (e.g. +`Contract(id)`, `NextId`) are unaffected — their TTL management is covered in +[architecture.md](./architecture.md). ## Units -All TTL values are denominated in **ledgers**, the Soroban-native unit. One ledger is ~5 seconds on Stellar mainnet. This avoids any coupling to wall-clock timestamps and keeps expiry deterministic as a function of `env.ledger().sequence()`. +All TTL values are denominated in **ledgers**, the Soroban-native unit. One +ledger is ~5 seconds on Stellar mainnet. This avoids any coupling to +wall-clock timestamps and keeps expiry deterministic as a function of +`env.ledger().sequence()`. | Named constant | Ledgers | Rough duration | -| --- | ---: | --- | +|---|---:|---| | `LEDGERS_PER_DAY` | 17 280 | 1 day | | `PENDING_APPROVAL_TTL_LEDGERS` | 120 960 | 7 days | | `PENDING_APPROVAL_BUMP_THRESHOLD` | 17 280 | 1 day | | `PENDING_MIGRATION_TTL_LEDGERS` | 362 880 | 21 days | | `PENDING_MIGRATION_BUMP_THRESHOLD` | 51 840 | 3 days | -Constants live in [contracts/escrow/src/ttl.rs](../../contracts/escrow/src/ttl.rs). +Constants live in +[contracts/escrow/src/ttl.rs](../../contracts/escrow/src/ttl.rs). ## Transient Keys -| Key | Value type | TTL | Bump threshold | Rationale | -| --- | --- | ---: | ---: | --- | -| `PendingApproval(contract_id: u32)` | `PendingApproval` | 7 days | 1 day | Counterparties are expected to respond within one business week; short enough to reclaim state on abandonment, long enough to tolerate holidays. | -| `PendingMigration` | `PendingMigration` | 21 days | 3 days | Migrations are rarer and more consequential; reviewers need more lead time and explicit bump windows. | +| Key | TTL | Bump threshold | Rationale | +|---|---:|---:|---| +| `PendingApproval(contract_id)` | 7 days | 1 day | Counterparties are expected to respond within one business week; short enough to reclaim state on abandonment. | +| `PendingMigration` | 21 days | 3 days | Migrations are rarer and more consequential; reviewers need more lead time. | -`PendingMigration` is a **single-slot** key: at most one migration may be pending at any time, which is enforced by `PendingMigrationExists` (error code 8). +`PendingMigration` is a **single-slot** key: at most one migration may be +pending at any time. -## Value Schema +## TTL Helper API -Each transient value stores its own expiry metadata alongside Soroban's internal TTL. This is intentional redundancy so that on-chain readers and event indexers can audit expiry independently of Soroban's TTL ledger metadata. +All transient reads and writes go through the helpers in `contracts/escrow/src/ttl.rs`: -```rust -pub struct PendingApproval { - pub approver: Address, - pub contract_id: u32, - pub requested_at_ledger: u32, - pub expires_at_ledger: u32, -} +| Function | Description | +|---|---| +| `compute_expiry(env, ttl)` | Returns `sequence + ttl` (saturating). | +| `store_with_ttl(env, key, value, ttl)` | Writes to temporary storage and sets TTL. | +| `read_if_live(env, key)` | Returns `Some(v)` if live, `None` if absent or evicted. | +| `extend_if_below_threshold(env, key, threshold, extend_to)` | Bumps TTL; returns `false` if key absent. | +| `remove_transient(env, key)` | Explicit removal before auto-eviction. | +| `has_transient(env, key)` | Returns `true` if the key is currently live. | -pub struct PendingMigration { - pub proposer: Address, - pub new_wasm_hash: BytesN<32>, - pub requested_at_ledger: u32, - pub expires_at_ledger: u32, -} -``` +## Expiry Semantics + +- Soroban auto-evicts temporary storage entries once their TTL has elapsed. +- `read_if_live` returns `None` for both "never set" and "expired" — callers + treat both as "no active pending record". +- No on-chain event is emitted at auto-eviction. Off-chain indexers should + compute eviction by comparing `expires_at_ledger` against the current ledger + sequence. ## Determinism Expiry is computed at write time as: ``` -expires_at_ledger = requested_at_ledger + TTL - = env.ledger().sequence() + TTL +expires_at_ledger = env.ledger().sequence() + TTL_LEDGERS ``` -Given the same ledger sequence and the same TTL constant, two runs produce identical `expires_at_ledger` values. Covered by test `deterministic_expiry` in [contracts/escrow/src/test/ttl_tests.rs](../../contracts/escrow/src/test/ttl_tests.rs). - -## Expiry Semantics - -- Soroban auto-evicts temporary storage entries once their TTL has elapsed. -- `read_if_live` (used by `get_pending_approval` / `get_pending_migration`) returns `Option<_>`; after expiry it returns `None`. -- The contract **does not distinguish** "never set" from "expired on read". Consumers must treat `None` as "no active pending record" in both cases. -- No on-chain event is emitted at the moment of auto-eviction — Soroban does not expose an eviction hook. Off-chain indexers should compute eviction by comparing the stored `expires_at_ledger` against the current ledger sequence. +Given the same starting sequence and the same TTL constant, two independent +environments produce identical expiry values. This is verified by +`expiry_is_deterministic_across_independent_envs` in the test suite. ## Extending (Bumping) TTL -`extend_pending_approval` / `extend_pending_migration` wrap `env.storage().temporary().extend_ttl(key, threshold, extend_to)`: +`extend_if_below_threshold` wraps +`env.storage().temporary().extend_ttl(key, threshold, extend_to)`: -- If remaining TTL is **below** the bump threshold, the entry's TTL is extended to the full policy value. -- If the entry is already fresh, the call is a no-op. -- If the entry is absent or already evicted, the helper returns `false` and performs no write. +- If remaining TTL is **below** the bump threshold, the entry's TTL is + extended to the full policy value. +- If the entry is already fresh, the call is a no-op (Soroban only extends, + never shrinks). +- If the entry is absent or already evicted, the helper returns `false` and + performs no write. -Callers must still be authorised (`approver.require_auth()` / `proposer.require_auth()`). +## Security Notes -## Events (Audit Trail) - -All state-changing TTL operations publish a structured event: - -| Topic 0 | Topic 1 | Data tuple | -| --- | --- | --- | -| `ttl` | `requested` | `(subject, identifier..., actor, requested_at_ledger, expires_at_ledger)` | -| `ttl` | `cancelled` | `(subject, identifier..., actor)` | -| `ttl` | `confirmed` | `(subject, identifier..., actor)` | - -`subject` is `approval` or `migration`. For approvals, `identifier` is the `contract_id`; for migrations, `identifier` is the `new_wasm_hash` (on request) or absent (on cancel). - -Auto-eviction emits no event. See *Expiry Semantics* above. - -## Error Codes - -| Variant | Code | Meaning | -| --- | ---: | --- | -| `PendingApprovalExists` | 6 | An approval is already pending for this contract. | -| `PendingApprovalNotFound` | 7 | No live approval to cancel. | -| `PendingMigrationExists` | 8 | A migration is already pending. | -| `PendingMigrationNotFound` | 9 | No live migration to cancel or confirm. | -| `Unauthorized` | 10 | Caller is not the original requester. | +- All writes use `store_with_ttl`; no direct `.temporary().set` bypass is + permitted, ensuring TTL is always set at write time. +- `remove_transient` is used for explicit cleanup (e.g. after an approval is + consumed or cancelled) so stale entries do not linger until auto-eviction. +- The fail-closed design means a `None` from `read_if_live` always blocks the + dependent operation, regardless of whether the entry expired or was never + created. ## Testing -Expiry is exercised by advancing `LedgerInfo.sequence_number` via `env.ledger().with_mut(...)`. See [contracts/escrow/src/test/ttl_tests.rs](../../contracts/escrow/src/test/ttl_tests.rs) for the full matrix: - -- `pending_{approval,migration}_readable_before_expiry` -- `pending_{approval,migration}_evicted_after_expiry` -- `extend_if_below_threshold_bumps_when_near_expiry` -- `extend_if_below_threshold_noop_when_fresh` -- `extend_returns_false_when_key_absent` -- `deterministic_expiry` -- `cancel_removes_pending_approval` -- `duplicate_request_{approval,migration}_rejects` -- `confirm_migration_clears_pending` +Tests live in +[contracts/escrow/src/test/ttl_tests.rs](../../contracts/escrow/src/test/ttl_tests.rs). +They call the TTL helpers directly via `env.as_contract` and advance +`LedgerInfo.sequence_number` via `env.ledger().with_mut(...)` to simulate +auto-eviction. + +| Test | What it covers | +|---|---| +| `compute_expiry_equals_sequence_plus_ttl` | `compute_expiry` returns correct value for both TTL constants | +| `compute_expiry_saturates_on_overflow` | Saturating addition at `u32::MAX` | +| `ledgers_per_day_constant_is_correct` | All five constants match their documented values | +| `approval_readable_before_expiry` | `read_if_live` returns `Some` one ledger before approval TTL | +| `approval_evicted_after_expiry` | `read_if_live` returns `None` one ledger after approval TTL | +| `migration_readable_before_expiry` | `read_if_live` returns `Some` one ledger before migration TTL | +| `migration_evicted_after_expiry` | `read_if_live` returns `None` one ledger after migration TTL | +| `extend_returns_false_for_absent_key` | `extend_if_below_threshold` returns `false` when key absent | +| `extend_returns_true_and_entry_survives_past_original_expiry` | Bump keeps entry live past original expiry | +| `extend_migration_returns_false_for_absent_key` | Same absent-key check for migration threshold | +| `remove_transient_clears_entry_immediately` | Entry absent after `remove_transient` | +| `remove_transient_is_idempotent` | Second `remove_transient` does not panic | +| `has_transient_false_before_store` | `has_transient` returns `false` before any write | +| `has_transient_true_after_store_false_after_expiry` | `has_transient` tracks live/evicted state | +| `expiry_is_deterministic_across_independent_envs` | Same starting sequence → same expiry in two envs | ## Reviewer Checklist 1. Every new transient key has an entry in the table above. 2. Every write uses `ttl::store_with_ttl` (no direct `.temporary().set` bypass). 3. Every read path uses `ttl::read_if_live` and handles `None` as "absent or expired". -4. Expiry metadata on the value matches the constant applied at write time. -5. A corresponding TTL test exists when a new transient key is introduced. +4. A corresponding TTL test exists when a new transient key is introduced.