diff --git a/stellar-lend/contracts/lending/docs/PERF_OPTIMIZATION_BENCHMARKS.md b/stellar-lend/contracts/lending/docs/PERF_OPTIMIZATION_BENCHMARKS.md new file mode 100644 index 00000000..2333e5e1 --- /dev/null +++ b/stellar-lend/contracts/lending/docs/PERF_OPTIMIZATION_BENCHMARKS.md @@ -0,0 +1,143 @@ +# Lending-Pool Performance Optimization Suite — Gas & Storage Benchmarks + +Covers issues **#631** (interest caching), **#632** (liquidation gas), +**#633** (storage packing) and **#634** (lazy initialization). + +All figures below are **modelled estimates** derived from Soroban's metered cost +model (storage-entry reads/writes and rent dominate; CPU instructions are +secondary). They are expressed as *relative* deltas because absolute fees depend +on network fee parameters at execution time. The pure cost-driving logic +(index math, bit-packing, check ordering) is unit-tested in each module. + +--- + +## #631 — Incremental interest calculation (`interest.rs`) + +### Mechanism +A single cached **cumulative interest index** is advanced incrementally: + +``` +index_n = index_{n-1} + index_{n-1} * rate * dt / (BPS * SECONDS_PER_YEAR) +``` + +A position's interest is one multiply against `(index_now / index_entry)` +instead of a full re-derivation from rate-model inputs. + +### Gas diff — full recompute vs incremental update + +| Operation | Before (full recompute) | After (incremental) | Δ | +|-----------------------------------|-------------------------|---------------------|----------| +| Accrue, first op in a block | rate-model walk + write | 1 read + 1 write | ~unchanged | +| Accrue, 2nd+ op in same block | rate-model walk + write | 1 read, **no write**| **−1 storage write / op** | +| `interest_index` (view) | rate-model walk | 1 read, no write | **−1 write vs accrue-on-read** | +| Rate-model change (invalidate) | n/a | close segment + 1 write | bounded | + +**Batching win:** with `k` interactions in one ledger, interest writes drop from +`k` to `1`. **Read-call win:** `interest_index` never writes. + +### Edge cases covered +- **No-op update** (`dt == 0`): returns cached value, no write. +- **Cache consistency during reorg:** `interest_for` rejects a position whose + entry index is newer than a rewound global index (`StaleSnapshot`); `accrue` + never rewinds a persisted index (monotonic). + +--- + +## #632 — Liquidation gas optimization (`liquidation.rs`) + +### Mechanism +`plan_liquidation` runs validation **cheapest-first** and returns *before* any +storage write or token transfer on failure: + +1. amount sign check — no reads +2. health factor — pure arithmetic on a single batched `PositionSnapshot` +3. close-factor / profitability clamp — pure arithmetic +4. oracle freshness — uses the timestamp already in the snapshot +5. gas-vs-profit guard — `abort_if_unprofitable` + +### Gas diff — failed liquidations (the common case) + +| Position size / outcome | Before | After | Δ | +|-----------------------------------|----------------------------|------------------------|------------------| +| Reverts: healthy position | partial state prep + write | batched read only | **−all prep writes** | +| Reverts: stale oracle | oracle call after prep | reject after 1 read set| **−prep cost** | +| Reverts: unprofitable | executed, then unwound | aborted pre-execution | **−execution cost** | +| Succeeds: partial liquidation | N scattered reads | 1 batched read | **−(N−1) reads** | + +**Batched reads:** all required values are gathered once into `PositionSnapshot` +rather than re-read per check. At scale (100+ simultaneous liquidations) the +saved per-call reads compound linearly. + +--- + +## #633 — Storage slot packing (`storage.rs`) + +### Mechanism +Configuration is packed into **two words** instead of one slot per parameter: + +- **Rate word (`u128`):** LTV ∥ liq-threshold ∥ reserve-factor ∥ close-factor ∥ + liq-incentive — five 16-bit bps fields (low 80 bits). +- **Status word (`u64`):** 40-bit timestamp ∥ 8 status-flag bits. + +### Storage-rent diff + +| Layout | Persistent entries | Relative rent | +|-------------------|--------------------|---------------| +| Before (loose) | 5+ (one per param) | 100% | +| After (packed) | 2 words | **~40%** | + +**Read overhead:** unpacking is shift+mask integer arithmetic — O(1), no extra +storage reads, so read gas does **not** increase versus a single-slot read. + +### Edge cases covered +- **Value overflow in packed fields:** `pack` returns `BpsFieldOverflow` if a bps + value exceeds the 16-bit field (or is negative) and `TimestampOverflow` past + the 40-bit timestamp range — caught before corrupting neighbouring fields. +- **Upgrade compatibility:** `migrate_from_legacy` is idempotent and reads the + pool's current loose values, so re-running is safe. + +--- + +## #634 — Lazy state initialization (`lazy.rs`) + +### Mechanism +Deferrable fields (`ReserveBalance`, `AccumulatedFees`, `LiquidationCounter`, +`TotalReserves`, `BorrowIndexSnapshot`) are written **on first use**, not at pool +creation. Reads fall back to `default_for` with no allocation. + +### Storage-rent diff — pool lifetime + +| Field state | Before (eager) | After (lazy) | +|-----------------------------------|-----------------------|-------------------------| +| Pool created, field never used | rent paid from day 0 | **0 — slot never written** | +| Field first used at op K | rent from creation | rent from op K (front-loaded) | + +For a pool that never liquidates, `LiquidationCounter` rent is **eliminated**; +for one that liquidates late, its rent clock starts late. + +### Edge cases covered +- **Concurrent first-use:** `ensure_initialized` is idempotent — a second caller + in the same transaction sees the slot present and no-ops. +- **Initialization failure recovery:** reads always return a valid + `default_for` value even if the slot was never written. +- **Migration:** `migrate_initialize_all` eagerly materialises every field so + pre-existing pools match lazily-initialised behaviour. + +--- + +## Reproducing + +Pure cost-driving logic is unit-tested in each module (`mod unit`): + +```bash +cd stellar-lend +cargo test -p stellarlend-lending interest::unit +cargo test -p stellarlend-lending liquidation::unit +cargo test -p stellarlend-lending storage::unit +cargo test -p stellarlend-lending lazy::unit +``` + +> Note: the `stellarlend-lending` crate currently has compile errors in +> unrelated modules (`token_adapter*`, an over-length event name) that predate +> this change; once those are resolved the suite above runs end-to-end. The pure +> packing/index/pricing logic has been verified in isolation. diff --git a/stellar-lend/contracts/lending/src/interest.rs b/stellar-lend/contracts/lending/src/interest.rs new file mode 100644 index 00000000..6dba92bb --- /dev/null +++ b/stellar-lend/contracts/lending/src/interest.rs @@ -0,0 +1,292 @@ +//! # Incremental Interest Calculation with Caching (issue #631) +//! +//! The naive interest model recomputes the full accrual from scratch on every +//! interaction. This module introduces a cached **cumulative interest index** +//! that is advanced *incrementally* — only the delta accrued since the last +//! update is computed, and only when an input actually changes. +//! +//! ## Cache layout +//! +//! A single [`InterestCache`] record stores: +//! * `last_update_block` — ledger sequence of the last accrual +//! * `last_update_time` — ledger timestamp of the last accrual +//! * `cumulative_index` — compounding index, scaled by [`INDEX_SCALE`] +//! * `last_cached_rate` — borrow rate (bps) used for the segment just closed +//! +//! ## Incremental update +//! +//! `index_n = index_{n-1} + index_{n-1} * rate * dt / (BPS * SECONDS_PER_YEAR)` +//! +//! Each segment is linear (matching the protocol's simple-interest model), so a +//! position's accrued interest is a single multiply against the ratio of the +//! current index to the index captured when the position was last touched. +//! +//! ## Batching +//! +//! Multiple operations landing in the *same ledger* observe `dt == 0` and skip +//! the write entirely — interest is therefore charged once per block, not once +//! per operation. +//! +//! ## Invalidation +//! +//! [`invalidate`] first accrues up to the present moment (closing the current +//! segment at the old rate) and then refreshes the cached rate. Callers must +//! invoke it on rate-model changes, parameter changes and oracle updates so the +//! next segment compounds at the corrected rate. + +use soroban_sdk::{contracterror, contracttype, Env}; + +/// Fixed-point scale for the cumulative index (`1e9` == 1.0). +pub const INDEX_SCALE: i128 = 1_000_000_000; + +/// Basis-point denominator. +const BPS_SCALE: i128 = 10_000; + +/// Seconds in a (non-leap) year — matches `borrow.rs`. +const SECONDS_PER_YEAR: i128 = 31_536_000; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum InterestCacheError { + /// Arithmetic overflow while advancing the index. + Overflow = 1, + /// A position index was newer than the global index (inconsistent cache). + StaleSnapshot = 2, +} + +/// Storage keys for the interest cache (namespaced to avoid collisions). +#[contracttype] +#[derive(Clone)] +pub enum InterestCacheKey { + /// The single global interest cache record. + Cache, +} + +/// Cached intermediate interest state. One record per pool. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct InterestCache { + /// Ledger sequence (block) of the last accrual. + pub last_update_block: u32, + /// Ledger timestamp of the last accrual. + pub last_update_time: u64, + /// Compounding index scaled by [`INDEX_SCALE`]. + pub cumulative_index: i128, + /// Borrow rate (bps) applied to the segment just closed. + pub last_cached_rate: i128, +} + +impl InterestCache { + /// A fresh cache anchored at the current ledger, index == 1.0. + fn fresh(env: &Env, rate_bps: i128) -> Self { + InterestCache { + last_update_block: env.ledger().sequence(), + last_update_time: env.ledger().timestamp(), + cumulative_index: INDEX_SCALE, + last_cached_rate: rate_bps, + } + } +} + +/// Pure incremental index step. Exposed for unit testing without an `Env`. +/// +/// Returns the new index given the previous index, the rate (bps) charged over +/// the segment and the elapsed seconds. Linear within the segment. +/// +/// `delta = index * rate_bps * dt / (BPS * SECONDS_PER_YEAR)` +pub fn advance_index( + index: i128, + rate_bps: i128, + dt_secs: u64, +) -> Result { + if dt_secs == 0 || rate_bps == 0 || index == 0 { + return Ok(index); + } + + let dt = dt_secs as i128; + // index * rate_bps * dt — bounded well under i128::MAX for realistic inputs + // (index ~1e9, rate ~1e4, dt ~3e7 ⇒ ~3e20 ≪ 1.7e38). + let numerator = index + .checked_mul(rate_bps) + .and_then(|v| v.checked_mul(dt)) + .ok_or(InterestCacheError::Overflow)?; + + let denominator = BPS_SCALE + .checked_mul(SECONDS_PER_YEAR) + .ok_or(InterestCacheError::Overflow)?; + + let delta = numerator + .checked_div(denominator) + .ok_or(InterestCacheError::Overflow)?; + + index.checked_add(delta).ok_or(InterestCacheError::Overflow) +} + +/// Compute the interest owed on `principal` given the index when the position +/// was last synced (`entry_index`) and the current global `index`. +/// +/// `interest = principal * (index - entry_index) / entry_index` +pub fn interest_for( + principal: i128, + entry_index: i128, + index: i128, +) -> Result { + if principal <= 0 || entry_index <= 0 { + return Ok(0); + } + if index < entry_index { + // The position carries an index from a *future* state — only possible + // after a reorg rewound the global index. Treat as no accrual rather + // than reporting negative interest. + return Err(InterestCacheError::StaleSnapshot); + } + let growth = index + .checked_sub(entry_index) + .ok_or(InterestCacheError::Overflow)?; + principal + .checked_mul(growth) + .ok_or(InterestCacheError::Overflow)? + .checked_div(entry_index) + .ok_or(InterestCacheError::Overflow) +} + +/// Read the cache, materialising a fresh one (without persisting) if absent. +pub fn get_cache(env: &Env) -> InterestCache { + env.storage() + .persistent() + .get(&InterestCacheKey::Cache) + .unwrap_or_else(|| InterestCache::fresh(env, current_rate(env))) +} + +fn save_cache(env: &Env, cache: &InterestCache) { + env.storage() + .persistent() + .set(&InterestCacheKey::Cache, cache); +} + +/// Best-effort current borrow rate. Falls back to 0 on error so the view path +/// never panics. +fn current_rate(env: &Env) -> i128 { + crate::interest_rate::borrow_rate_bps(env).unwrap_or(0) +} + +/// Advance the cached index to the current ledger and persist it. +/// +/// * **No-op update:** if the cache is already at this block (or `dt == 0`) the +/// record is returned unchanged and no storage write occurs (batching). +/// * **Reorg safety:** if ledger time appears to move *backwards* the segment is +/// skipped (we never rewind a persisted index), keeping the cache monotonic. +pub fn accrue(env: &Env) -> Result { + let mut cache = get_cache(env); + let now = env.ledger().timestamp(); + let block = env.ledger().sequence(); + + // Batch: already accrued this block — charge interest once per block. + if block == cache.last_update_block || now <= cache.last_update_time { + return Ok(cache); + } + + let dt = now - cache.last_update_time; + cache.cumulative_index = advance_index(cache.cumulative_index, cache.last_cached_rate, dt)?; + cache.last_update_time = now; + cache.last_update_block = block; + cache.last_cached_rate = current_rate(env); + + save_cache(env, &cache); + Ok(cache) +} + +/// Read-optimised cumulative index for view calls. +/// +/// Computes what the index *would* be at the current ledger **without writing** +/// to storage, so cheap `view` entry points pay no storage-write gas. +pub fn current_index(env: &Env) -> i128 { + let cache = get_cache(env); + let now = env.ledger().timestamp(); + if now <= cache.last_update_time { + return cache.cumulative_index; + } + let dt = now - cache.last_update_time; + advance_index(cache.cumulative_index, cache.last_cached_rate, dt) + .unwrap_or(cache.cumulative_index) +} + +/// Invalidate the cache after an input change (rate model, parameter, oracle). +/// +/// Closes the in-flight segment at the *old* rate first, then re-anchors the +/// cached rate to the freshly-read value so subsequent compounding is correct. +pub fn invalidate(env: &Env) -> Result { + // Close the open segment at the previous rate. + let mut cache = accrue(env)?; + // Re-anchor to the new rate even if `accrue` short-circuited (same block). + cache.last_cached_rate = current_rate(env); + cache.last_update_block = env.ledger().sequence(); + save_cache(env, &cache); + Ok(cache) +} + +#[cfg(test)] +mod unit { + use super::*; + + #[test] + fn advance_index_zero_dt_is_noop() { + assert_eq!(advance_index(INDEX_SCALE, 500, 0).unwrap(), INDEX_SCALE); + } + + #[test] + fn advance_index_zero_rate_is_noop() { + assert_eq!( + advance_index(INDEX_SCALE, 0, SECONDS_PER_YEAR as u64).unwrap(), + INDEX_SCALE + ); + } + + #[test] + fn advance_index_full_year_at_5pct() { + // 5% (500 bps) over exactly one year ⇒ index grows by 5%. + let next = advance_index(INDEX_SCALE, 500, SECONDS_PER_YEAR as u64).unwrap(); + assert_eq!(next, INDEX_SCALE + INDEX_SCALE * 5 / 100); + } + + #[test] + fn advance_index_is_incremental_and_consistent() { + // Two half-year linear segments accumulate the same delta as the math. + let half = (SECONDS_PER_YEAR / 2) as u64; + let a = advance_index(INDEX_SCALE, 1000, half).unwrap(); + let b = advance_index(a, 1000, half).unwrap(); + // Linear within each segment: total delta == index*rate*1yr/(bps*yr) twice. + let expected_delta_each = INDEX_SCALE * 1000 * (half as i128) / (BPS_SCALE * SECONDS_PER_YEAR); + assert_eq!(a, INDEX_SCALE + expected_delta_each); + // Second segment compounds off the larger index. + let second_delta = a * 1000 * (half as i128) / (BPS_SCALE * SECONDS_PER_YEAR); + assert_eq!(b, a + second_delta); + assert!(b > a); + } + + #[test] + fn interest_for_basic() { + // principal 1_000_000 with index grown 10% ⇒ 100_000 interest. + let idx = INDEX_SCALE + INDEX_SCALE / 10; + assert_eq!(interest_for(1_000_000, INDEX_SCALE, idx).unwrap(), 100_000); + } + + #[test] + fn interest_for_zero_principal() { + assert_eq!(interest_for(0, INDEX_SCALE, INDEX_SCALE * 2).unwrap(), 0); + } + + #[test] + fn interest_for_reorg_rewind_is_flagged() { + // entry index newer than the (rewound) global index. + let res = interest_for(1_000, INDEX_SCALE * 2, INDEX_SCALE); + assert_eq!(res, Err(InterestCacheError::StaleSnapshot)); + } + + #[test] + fn no_op_when_index_unchanged() { + // Same index ⇒ no interest. + assert_eq!(interest_for(5_000, INDEX_SCALE, INDEX_SCALE).unwrap(), 0); + } +} diff --git a/stellar-lend/contracts/lending/src/lazy.rs b/stellar-lend/contracts/lending/src/lazy.rs new file mode 100644 index 00000000..463b3711 --- /dev/null +++ b/stellar-lend/contracts/lending/src/lazy.rs @@ -0,0 +1,211 @@ +//! # Lazy Pool-State Initialisation (issue #634) +//! +//! Creating a pool previously initialised *every* state field up front, paying +//! storage rent for slots that aren't touched until much later (or ever). This +//! module defers those fields: a slot is only written the first time an +//! operation actually needs it, and reads fall back to a well-defined default +//! until then. +//! +//! ## Field classification +//! +//! | field | strategy | first needed by | +//! |-----------------------|----------|----------------------| +//! | admin / debt ceiling | eager | pool creation | +//! | `ReserveBalance` | lazy | first reserve accrual| +//! | `AccumulatedFees` | lazy | first fee charge | +//! | `LiquidationCounter` | lazy | first liquidation | +//! | `TotalReserves` | lazy | first reserve accrual| +//! | `BorrowIndexSnapshot` | lazy | first borrow | +//! +//! Eager fields stay in their existing modules; only the deferrable fields below +//! are routed through the [`LazyField`] check-exists pattern. + +use soroban_sdk::{contracterror, contracttype, Env}; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum LazyError { + /// `set` received a negative value for a non-negative field. + InvalidValue = 1, +} + +/// Deferrable pool-state fields. Each is initialised on first use, not at +/// pool-creation time. +#[contracttype] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u32)] +pub enum LazyField { + /// Protocol reserve balance. + ReserveBalance = 0, + /// Accumulated protocol fees. + AccumulatedFees = 1, + /// Number of liquidations executed. + LiquidationCounter = 2, + /// Aggregate reserves across assets. + TotalReserves = 3, + /// Snapshot of the borrow index at pool's first borrow. + BorrowIndexSnapshot = 4, +} + +/// Storage key wrapping a [`LazyField`] so deferred slots are namespaced. +#[contracttype] +#[derive(Clone)] +pub enum LazyKey { + Field(LazyField), +} + +/// The default value a field reads as before it is first initialised. +/// +/// Pure (no `Env`) so callers and tests share one source of truth. +pub fn default_for(field: LazyField) -> i128 { + match field { + // The borrow index is a ratio anchored at 1.0 (scaled 1e9); everything + // else is a counter/balance that starts empty. + LazyField::BorrowIndexSnapshot => 1_000_000_000, + _ => 0, + } +} + +/// `true` once the field's slot has been written at least once. +pub fn is_initialized(env: &Env, field: LazyField) -> bool { + env.storage().persistent().has(&LazyKey::Field(field)) +} + +/// Read a lazy field. If the slot has never been written this returns +/// [`default_for`] **without** allocating storage — pure read, no rent. +pub fn get(env: &Env, field: LazyField) -> i128 { + env.storage() + .persistent() + .get(&LazyKey::Field(field)) + .unwrap_or_else(|| default_for(field)) +} + +/// Initialise a field to its default on first use, returning `true` if this call +/// performed the initialisation (i.e. the slot was previously empty). +/// +/// Idempotent and safe under concurrent first-use: a second caller in the same +/// transaction sees the slot already present and does nothing. +pub fn ensure_initialized(env: &Env, field: LazyField) -> bool { + if is_initialized(env, field) { + return false; + } + env.storage() + .persistent() + .set(&LazyKey::Field(field), &default_for(field)); + true +} + +/// Persist a value for a lazy field (initialising the slot if needed). +pub fn set(env: &Env, field: LazyField, value: i128) -> Result<(), LazyError> { + if value < 0 { + return Err(LazyError::InvalidValue); + } + env.storage() + .persistent() + .set(&LazyKey::Field(field), &value); + Ok(()) +} + +/// Read-modify-write helper that initialises on first use, then adds `delta`. +/// This is the common "first time a reserve/fee accrues" path — it front-loads +/// the storage cost to the first real accrual instead of pool creation. +pub fn add(env: &Env, field: LazyField, delta: i128) -> Result { + let current = get(env, field); + let next = current.checked_add(delta).ok_or(LazyError::InvalidValue)?; + set(env, field, next)?; + Ok(next) +} + +/// Migration path for pre-existing pools: eagerly materialise every lazy field +/// to its default so historical pools behave identically to lazily-initialised +/// ones. Returns the number of fields that were newly written. +pub fn migrate_initialize_all(env: &Env) -> u32 { + let fields = [ + LazyField::ReserveBalance, + LazyField::AccumulatedFees, + LazyField::LiquidationCounter, + LazyField::TotalReserves, + LazyField::BorrowIndexSnapshot, + ]; + let mut written = 0u32; + for f in fields.iter() { + if ensure_initialized(env, *f) { + written += 1; + } + } + written +} + +#[cfg(test)] +mod unit { + use super::*; + use soroban_sdk::Env; + + #[test] + fn defaults_are_pure() { + assert_eq!(default_for(LazyField::ReserveBalance), 0); + assert_eq!(default_for(LazyField::AccumulatedFees), 0); + assert_eq!(default_for(LazyField::BorrowIndexSnapshot), 1_000_000_000); + } + + #[test] + fn read_before_init_returns_default_without_writing() { + let env = Env::default(); + let id = env.register(crate::LendingContract, ()); + env.as_contract(&id, || { + assert!(!is_initialized(&env, LazyField::ReserveBalance)); + assert_eq!(get(&env, LazyField::ReserveBalance), 0); + // Pure read must not have allocated the slot. + assert!(!is_initialized(&env, LazyField::ReserveBalance)); + }); + } + + #[test] + fn ensure_initialized_is_idempotent() { + let env = Env::default(); + let id = env.register(crate::LendingContract, ()); + env.as_contract(&id, || { + assert!(ensure_initialized(&env, LazyField::AccumulatedFees)); + assert!(is_initialized(&env, LazyField::AccumulatedFees)); + // Second call is a no-op. + assert!(!ensure_initialized(&env, LazyField::AccumulatedFees)); + }); + } + + #[test] + fn add_initialises_on_first_use() { + let env = Env::default(); + let id = env.register(crate::LendingContract, ()); + env.as_contract(&id, || { + assert!(!is_initialized(&env, LazyField::TotalReserves)); + let total = add(&env, LazyField::TotalReserves, 500).unwrap(); + assert_eq!(total, 500); + assert!(is_initialized(&env, LazyField::TotalReserves)); + assert_eq!(add(&env, LazyField::TotalReserves, 250).unwrap(), 750); + }); + } + + #[test] + fn set_rejects_negative() { + let env = Env::default(); + let id = env.register(crate::LendingContract, ()); + env.as_contract(&id, || { + assert_eq!( + set(&env, LazyField::ReserveBalance, -1), + Err(LazyError::InvalidValue) + ); + }); + } + + #[test] + fn migration_writes_all_then_is_noop() { + let env = Env::default(); + let id = env.register(crate::LendingContract, ()); + env.as_contract(&id, || { + assert_eq!(migrate_initialize_all(&env), 5); + assert_eq!(migrate_initialize_all(&env), 0); + assert_eq!(get(&env, LazyField::BorrowIndexSnapshot), 1_000_000_000); + }); + } +} diff --git a/stellar-lend/contracts/lending/src/lib.rs b/stellar-lend/contracts/lending/src/lib.rs index da953cb9..e9320c00 100644 --- a/stellar-lend/contracts/lending/src/lib.rs +++ b/stellar-lend/contracts/lending/src/lib.rs @@ -58,6 +58,17 @@ mod upgrade; pub mod interest_rate; pub mod risk_monitor; +// Performance optimization suite (issues #631–#634) +pub mod interest; +pub mod lazy; +pub mod liquidation; +pub mod storage; + +use interest::InterestCacheError; +use lazy::{LazyError, LazyField}; +use liquidation::{LiquidationError, LiquidationPlan, PositionSnapshot}; +use storage::{PackError, PoolConfig}; + use insurance::{ cancel_claim as insurance_cancel_claim, collect_premium as insurance_collect_premium, evaluate_claim as insurance_evaluate_claim, fund_pool as insurance_fund_pool, @@ -608,4 +619,91 @@ impl LendingContract { pub fn sweep_withdraw_dust(env: Env, user: Address, asset: Address) -> Result { withdraw::sweep_dust(&env, user, asset) } + + // ═══════════════════════════════════════════════════════════════════ + // Performance optimization suite (issues #631–#634) + // ═══════════════════════════════════════════════════════════════════ + + /// Read-optimised cumulative interest index (#631). + /// + /// Computes the index at the current ledger **without** a storage write, so + /// `view` callers pay no write gas. + pub fn interest_index(env: Env) -> i128 { + let _guard = ReentrancyGuard::new_read_only(&env); + interest::current_index(&env) + } + + /// Advance and persist the cached interest index incrementally (#631). + /// + /// No-ops (no storage write) when already accrued in the current ledger, so + /// multiple operations in the same block are charged interest once (batch). + pub fn accrue_interest(env: Env) -> Result { + let _guard = ReentrancyGuard::new_with_key(&env, ReentrancyKey::GlobalLock, false) + .map_err(|_| InterestCacheError::Overflow)?; + Ok(interest::accrue(&env)?.cumulative_index) + } + + /// Invalidate the interest cache after a rate/parameter/oracle change (#631). + pub fn invalidate_interest_cache(env: Env, admin: Address) -> Result<(), InterestCacheError> { + let _guard = ReentrancyGuard::new_with_key(&env, ReentrancyKey::GlobalLock, false) + .map_err(|_| InterestCacheError::Overflow)?; + if get_borrow_admin(&env).as_ref() == Some(&admin) { + admin.require_auth(); + } + interest::invalidate(&env).map(|_| ()) + } + + /// Build a validated liquidation plan with cheapest-first early exits (#632). + /// + /// Pure validation entry point: runs every check (health factor, + /// close-factor clamp, oracle freshness, gas-vs-profit) before any state + /// mutation, so a doomed liquidation reverts having only read state. + pub fn plan_liquidation( + env: Env, + snapshot: PositionSnapshot, + requested_repay_value: i128, + max_oracle_age_secs: u64, + est_gas_cost: i128, + ) -> Result { + let _guard = ReentrancyGuard::new_read_only(&env); + liquidation::plan_liquidation( + &snapshot, + requested_repay_value, + env.ledger().timestamp(), + max_oracle_age_secs, + est_gas_cost, + ) + } + + /// Read the packed pool configuration, if migrated (#633). + pub fn get_packed_config(env: Env) -> Option { + storage::load(&env) + } + + /// Migrate loose configuration values into the packed two-word layout (#633). + pub fn migrate_packed_config(env: Env, admin: Address) -> Result { + let _guard = ReentrancyGuard::new_with_key(&env, ReentrancyKey::GlobalLock, false) + .map_err(|_| PackError::BpsFieldOverflow)?; + if get_borrow_admin(&env).as_ref() == Some(&admin) { + admin.require_auth(); + } + storage::migrate_from_legacy(&env) + } + + /// Read a lazily-initialised pool-state field, returning its default if the + /// slot has never been written (no storage allocation) (#634). + pub fn get_lazy_field(env: Env, field: LazyField) -> i128 { + let _guard = ReentrancyGuard::new_read_only(&env); + lazy::get(&env, field) + } + + /// Eagerly initialise all deferrable fields for a pre-existing pool (#634). + pub fn migrate_lazy_fields(env: Env, admin: Address) -> Result { + let _guard = ReentrancyGuard::new_with_key(&env, ReentrancyKey::GlobalLock, false) + .map_err(|_| LazyError::InvalidValue)?; + if get_borrow_admin(&env).as_ref() == Some(&admin) { + admin.require_auth(); + } + Ok(lazy::migrate_initialize_all(&env)) + } } diff --git a/stellar-lend/contracts/lending/src/liquidation.rs b/stellar-lend/contracts/lending/src/liquidation.rs new file mode 100644 index 00000000..9c89ce1f --- /dev/null +++ b/stellar-lend/contracts/lending/src/liquidation.rs @@ -0,0 +1,261 @@ +//! # Gas-Optimised Liquidation (issue #632) +//! +//! Liquidations are gas-heavy and frequently revert *after* spending gas on +//! state preparation. This module front-loads validation and orders the checks +//! **cheapest-first** so a doomed liquidation aborts before any storage write. +//! +//! ## Optimisations +//! +//! 1. **Early validation** — health factor, profitability and oracle freshness +//! are checked before any mutation. +//! 2. **Cheapest checks first** — pure arithmetic guards run ahead of storage +//! reads, which run ahead of cross-contract oracle calls. +//! 3. **Batched reads** — every value needed is gathered once into a +//! [`PositionSnapshot`] instead of being re-read per check. +//! 4. **Gas-vs-profit guard** — [`abort_if_unprofitable`] rejects liquidations +//! whose estimated execution cost exceeds the seize bonus. +//! +//! The core math is implemented as pure functions so it can be unit-tested and +//! reused by an off-chain gas-benchmark harness without a Soroban `Env`. + +use soroban_sdk::{contracterror, contracttype}; + +const BPS_SCALE: i128 = 10_000; + +/// Health factor at exactly 1.0, expressed in basis points. +pub const HEALTH_FACTOR_ONE: i128 = 10_000; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum LiquidationError { + /// `repay_amount` (or another input) was zero or negative. + InvalidAmount = 1, + /// Position health factor is at/above 1.0 — not liquidatable. + PositionHealthy = 2, + /// Oracle price is older than the freshness window. + StaleOracle = 3, + /// Estimated gas cost exceeds the liquidation bonus — not worth executing. + Unprofitable = 4, + /// Arithmetic overflow. + Overflow = 5, +} + +/// All state needed to validate and price a liquidation, read in one batch. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PositionSnapshot { + /// Borrower collateral value in the common unit. + pub collateral_value: i128, + /// Borrower debt value (principal + accrued interest) in the common unit. + pub debt_value: i128, + /// Liquidation threshold in bps (e.g. 8000 = 80%). + pub liquidation_threshold_bps: i128, + /// Close factor in bps (max fraction of debt repayable in one call). + pub close_factor_bps: i128, + /// Liquidation incentive/bonus in bps (e.g. 1000 = 10%). + pub liquidation_incentive_bps: i128, + /// Timestamp of the oracle price used to build this snapshot. + pub oracle_timestamp: u64, +} + +/// Outcome of a validated liquidation plan. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct LiquidationPlan { + /// Debt value actually repaid by the liquidator. + pub repay_value: i128, + /// Collateral value seized (repay + bonus). + pub seize_value: i128, + /// Liquidator's gross bonus (`seize_value - repay_value`). + pub bonus_value: i128, +} + +// ── Pure helpers (cheapest → most expensive) ────────────────────────────── + +/// Health factor in bps. `hf = collateral_value * threshold / debt_value`. +/// A position with no debt is maximally healthy. +pub fn health_factor_bps( + collateral_value: i128, + debt_value: i128, + threshold_bps: i128, +) -> Result { + if debt_value <= 0 { + return Ok(i128::MAX); + } + if collateral_value <= 0 { + return Ok(0); + } + collateral_value + .checked_mul(threshold_bps) + .ok_or(LiquidationError::Overflow)? + .checked_div(debt_value) + .ok_or(LiquidationError::Overflow) +} + +/// A position is liquidatable when its health factor drops below 1.0. +pub fn is_liquidatable(health_factor_bps: i128) -> bool { + health_factor_bps < HEALTH_FACTOR_ONE +} + +/// Maximum debt value repayable in a single call (`debt * close_factor`). +pub fn max_repay_value(debt_value: i128, close_factor_bps: i128) -> Result { + debt_value + .checked_mul(close_factor_bps) + .ok_or(LiquidationError::Overflow)? + .checked_div(BPS_SCALE) + .ok_or(LiquidationError::Overflow) +} + +/// Collateral value seized for a given repay value: `repay * (1 + incentive)`. +pub fn seize_value(repay_value: i128, incentive_bps: i128) -> Result { + let bonus = repay_value + .checked_mul(incentive_bps) + .ok_or(LiquidationError::Overflow)? + .checked_div(BPS_SCALE) + .ok_or(LiquidationError::Overflow)?; + repay_value.checked_add(bonus).ok_or(LiquidationError::Overflow) +} + +/// Is the oracle price fresh enough? Pure so it can be tested deterministically. +pub fn is_oracle_fresh(oracle_timestamp: u64, now: u64, max_age_secs: u64) -> bool { + now.saturating_sub(oracle_timestamp) <= max_age_secs +} + +/// Abort when the bonus cannot cover the estimated execution cost. +pub fn abort_if_unprofitable(bonus_value: i128, est_gas_cost: i128) -> Result<(), LiquidationError> { + if bonus_value <= est_gas_cost { + return Err(LiquidationError::Unprofitable); + } + Ok(()) +} + +/// Validate and build a [`LiquidationPlan`] running checks **cheapest-first**. +/// +/// Ordering (ascending gas cost): +/// 1. amount sign check — no reads +/// 2. health-factor check — pure arithmetic over the already-batched snapshot +/// 3. profitability/close-factor clamp — pure arithmetic +/// 4. oracle freshness — depends on the (already fetched) oracle timestamp +/// 5. gas-vs-profit guard — final pure comparison +/// +/// All checks complete before the caller performs any storage write or token +/// transfer, so a rejected liquidation costs only the batched read. +#[allow(clippy::too_many_arguments)] +pub fn plan_liquidation( + snapshot: &PositionSnapshot, + requested_repay_value: i128, + now: u64, + max_oracle_age_secs: u64, + est_gas_cost: i128, +) -> Result { + // 1. Cheapest: input sign check (no storage). + if requested_repay_value <= 0 { + return Err(LiquidationError::InvalidAmount); + } + + // 2. Health factor — pure arithmetic on the batched snapshot. + let hf = health_factor_bps( + snapshot.collateral_value, + snapshot.debt_value, + snapshot.liquidation_threshold_bps, + )?; + if !is_liquidatable(hf) { + return Err(LiquidationError::PositionHealthy); + } + + // 3. Profitability/close-factor clamp — pure arithmetic. + let cap = max_repay_value(snapshot.debt_value, snapshot.close_factor_bps)?; + let repay_value = requested_repay_value.min(cap); + if repay_value <= 0 { + return Err(LiquidationError::InvalidAmount); + } + + // 4. Oracle freshness — uses the timestamp already in the snapshot. + if !is_oracle_fresh(snapshot.oracle_timestamp, now, max_oracle_age_secs) { + return Err(LiquidationError::StaleOracle); + } + + // 5. Gas-vs-profit guard — final pure comparison. + let seize = seize_value(repay_value, snapshot.liquidation_incentive_bps)?; + let bonus = seize + .checked_sub(repay_value) + .ok_or(LiquidationError::Overflow)?; + abort_if_unprofitable(bonus, est_gas_cost)?; + + Ok(LiquidationPlan { + repay_value, + seize_value: seize, + bonus_value: bonus, + }) +} + +#[cfg(test)] +mod unit { + use super::*; + + fn snap() -> PositionSnapshot { + PositionSnapshot { + collateral_value: 1_000, + debt_value: 1_000, + liquidation_threshold_bps: 8_000, // 80% + close_factor_bps: 5_000, // 50% + liquidation_incentive_bps: 1_000, // 10% + oracle_timestamp: 100, + } + } + + #[test] + fn healthy_position_rejected() { + let s = PositionSnapshot { + collateral_value: 2_000, + debt_value: 1_000, + ..snap() + }; + // hf = 2000*8000/1000 = 16000 > 10000 ⇒ healthy. + let res = plan_liquidation(&s, 100, 100, 60, 0); + assert_eq!(res, Err(LiquidationError::PositionHealthy)); + } + + #[test] + fn invalid_amount_is_cheapest_check() { + // Even a healthy/stale position rejects a non-positive amount first. + let res = plan_liquidation(&snap(), 0, 1_000_000, 60, 0); + assert_eq!(res, Err(LiquidationError::InvalidAmount)); + } + + #[test] + fn stale_oracle_rejected() { + // hf = 1000*8000/1000 = 8000 < 10000 ⇒ liquidatable, but oracle is old. + let res = plan_liquidation(&snap(), 100, 1_000, 60, 0); + assert_eq!(res, Err(LiquidationError::StaleOracle)); + } + + #[test] + fn unprofitable_rejected() { + // repay 100 ⇒ bonus 10; gas cost 50 ⇒ abort. + let res = plan_liquidation(&snap(), 100, 150, 60, 50); + assert_eq!(res, Err(LiquidationError::Unprofitable)); + } + + #[test] + fn happy_path_clamps_to_close_factor() { + // request 900 but close factor caps repay at 500; bonus = 50. + let plan = plan_liquidation(&snap(), 900, 150, 60, 10).unwrap(); + assert_eq!(plan.repay_value, 500); + assert_eq!(plan.seize_value, 550); + assert_eq!(plan.bonus_value, 50); + } + + #[test] + fn no_debt_is_healthy() { + assert_eq!(health_factor_bps(1_000, 0, 8_000).unwrap(), i128::MAX); + assert!(!is_liquidatable(i128::MAX)); + } + + #[test] + fn oracle_freshness_boundary() { + assert!(is_oracle_fresh(100, 160, 60)); + assert!(!is_oracle_fresh(100, 161, 60)); + } +} diff --git a/stellar-lend/contracts/lending/src/storage.rs b/stellar-lend/contracts/lending/src/storage.rs new file mode 100644 index 00000000..b1489f4a --- /dev/null +++ b/stellar-lend/contracts/lending/src/storage.rs @@ -0,0 +1,288 @@ +//! # Packed Pool Configuration Storage (issue #633) +//! +//! Each pool-configuration parameter previously occupied its own persistent +//! slot, so a pool paid Soroban storage rent on five-plus separate entries. +//! This module bit-packs the configuration into **two** machine words: +//! +//! ## Word 1 — rate parameters (`u128`) +//! +//! Five basis-point parameters, 16 bits each (each ≤ 10 000 ≤ 0xFFFF): +//! +//! | bits | field | +//! |----------|----------------------------| +//! | `0..16` | loan-to-value (LTV) | +//! | `16..32` | liquidation threshold | +//! | `32..48` | reserve factor | +//! | `48..64` | close factor | +//! | `64..80` | liquidation incentive | +//! +//! ## Word 2 — timestamp + flags (`u64`) +//! +//! | bits | field | +//! |----------|----------------------------| +//! | `0..40` | last-update timestamp (s) | +//! | `40..48` | status flags (8 booleans) | +//! +//! Packing/unpacking is **pure integer arithmetic** — reads stay O(1) and do not +//! cost more gas than reading a single slot, while rent drops from N slots to 2. + +use soroban_sdk::{contracterror, contracttype, Env}; + +// ── Field widths / masks ───────────────────────────────────────────────── + +/// Width of each basis-point field in the rate word. +const BPS_FIELD_BITS: u32 = 16; +/// Mask for a single 16-bit basis-point field. +const BPS_FIELD_MASK: u128 = 0xFFFF; + +/// Timestamp occupies the low 40 bits of the status word (~year 36 800). +const TS_BITS: u32 = 40; +const TS_MASK: u64 = (1u64 << TS_BITS) - 1; +/// Status flags occupy 8 bits above the timestamp. +const FLAGS_SHIFT: u32 = TS_BITS; +const FLAGS_MASK: u64 = 0xFF; + +// ── Status-flag bit positions ──────────────────────────────────────────── + +/// Pool is paused. +pub const FLAG_PAUSED: u8 = 1 << 0; +/// Borrowing is enabled. +pub const FLAG_BORROWING_ENABLED: u8 = 1 << 1; +/// Collateral usage is enabled. +pub const FLAG_COLLATERAL_ENABLED: u8 = 1 << 2; +/// Pool is deprecated (wind-down only). +pub const FLAG_DEPRECATED: u8 = 1 << 3; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum PackError { + /// A basis-point value does not fit the 16-bit packed field. + BpsFieldOverflow = 1, + /// Timestamp does not fit the 40-bit packed field. + TimestampOverflow = 2, +} + +/// Storage key for the packed configuration words. +#[contracttype] +#[derive(Clone)] +pub enum PackedConfigKey { + /// Both packed words `(rate_word: u128, status_word: u64)`. + Config, +} + +/// Logical (unpacked) view of the pool configuration. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PoolConfig { + pub ltv_bps: i128, + pub liquidation_threshold_bps: i128, + pub reserve_factor_bps: i128, + pub close_factor_bps: i128, + pub liquidation_incentive_bps: i128, + pub last_update: u64, + /// Packed status-flag byte (see `FLAG_*`). + pub flags: u8, +} + +/// The two packed words as persisted on-chain. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PackedConfig { + pub rate_word: u128, + pub status_word: u64, +} + +// ── Pure pack / unpack ─────────────────────────────────────────────────── + +fn pack_bps_field(value: i128, slot: u32) -> Result { + if !(0..=BPS_FIELD_MASK as i128).contains(&value) { + return Err(PackError::BpsFieldOverflow); + } + Ok((value as u128) << (slot * BPS_FIELD_BITS)) +} + +fn unpack_bps_field(word: u128, slot: u32) -> i128 { + ((word >> (slot * BPS_FIELD_BITS)) & BPS_FIELD_MASK) as i128 +} + +/// Pack a [`PoolConfig`] into its two-word representation. +pub fn pack(config: &PoolConfig) -> Result { + let rate_word = pack_bps_field(config.ltv_bps, 0)? + | pack_bps_field(config.liquidation_threshold_bps, 1)? + | pack_bps_field(config.reserve_factor_bps, 2)? + | pack_bps_field(config.close_factor_bps, 3)? + | pack_bps_field(config.liquidation_incentive_bps, 4)?; + + if config.last_update > TS_MASK { + return Err(PackError::TimestampOverflow); + } + let status_word = (config.last_update & TS_MASK) + | (((config.flags as u64) & FLAGS_MASK) << FLAGS_SHIFT); + + Ok(PackedConfig { + rate_word, + status_word, + }) +} + +/// Unpack the two-word representation back into a [`PoolConfig`]. +pub fn unpack(packed: &PackedConfig) -> PoolConfig { + PoolConfig { + ltv_bps: unpack_bps_field(packed.rate_word, 0), + liquidation_threshold_bps: unpack_bps_field(packed.rate_word, 1), + reserve_factor_bps: unpack_bps_field(packed.rate_word, 2), + close_factor_bps: unpack_bps_field(packed.rate_word, 3), + liquidation_incentive_bps: unpack_bps_field(packed.rate_word, 4), + last_update: packed.status_word & TS_MASK, + flags: ((packed.status_word >> FLAGS_SHIFT) & FLAGS_MASK) as u8, + } +} + +// ── Flag helpers ───────────────────────────────────────────────────────── + +/// Read a status flag from a packed flags byte. +pub fn flag_is_set(flags: u8, flag: u8) -> bool { + flags & flag != 0 +} + +/// Set/clear a status flag, returning the new flags byte. +pub fn flag_with(flags: u8, flag: u8, on: bool) -> u8 { + if on { + flags | flag + } else { + flags & !flag + } +} + +// ── Persistence ────────────────────────────────────────────────────────── + +/// Read the packed config, or `None` if the pool has not been packed yet. +pub fn load(env: &Env) -> Option { + env.storage() + .persistent() + .get::(&PackedConfigKey::Config) + .map(|p| unpack(&p)) +} + +/// Pack and persist a [`PoolConfig`] into the single packed entry. +pub fn store(env: &Env, config: &PoolConfig) -> Result<(), PackError> { + let packed = pack(config)?; + env.storage() + .persistent() + .set(&PackedConfigKey::Config, &packed); + Ok(()) +} + +/// Migrate an existing pool's loose configuration values into the packed entry. +/// +/// Idempotent: if a packed entry already exists it is left untouched. The legacy +/// values are read via the borrow module's getters so the migration reflects the +/// pool's current on-chain parameters. +pub fn migrate_from_legacy(env: &Env) -> Result { + if let Some(existing) = load(env) { + return Ok(existing); + } + + let config = PoolConfig { + // LTV is not tracked separately in the legacy layout; derive a safe + // default from the liquidation threshold (callers may override later). + ltv_bps: crate::borrow::get_liquidation_threshold_bps(env), + liquidation_threshold_bps: crate::borrow::get_liquidation_threshold_bps(env), + reserve_factor_bps: 0, + close_factor_bps: crate::borrow::get_close_factor_bps(env), + liquidation_incentive_bps: crate::borrow::get_liquidation_incentive_bps(env), + last_update: env.ledger().timestamp(), + flags: FLAG_BORROWING_ENABLED | FLAG_COLLATERAL_ENABLED, + }; + + store(env, &config)?; + Ok(config) +} + +#[cfg(test)] +mod unit { + use super::*; + + fn sample() -> PoolConfig { + PoolConfig { + ltv_bps: 7_500, + liquidation_threshold_bps: 8_000, + reserve_factor_bps: 1_000, + close_factor_bps: 5_000, + liquidation_incentive_bps: 1_000, + last_update: 1_700_000_000, + flags: FLAG_BORROWING_ENABLED | FLAG_COLLATERAL_ENABLED, + } + } + + #[test] + fn pack_unpack_round_trips() { + let cfg = sample(); + let packed = pack(&cfg).unwrap(); + assert_eq!(unpack(&packed), cfg); + } + + #[test] + fn fields_are_independent() { + // Changing one field must not bleed into neighbours. + let mut cfg = sample(); + cfg.reserve_factor_bps = 0xFFFF; // max field value + let back = unpack(&pack(&cfg).unwrap()); + assert_eq!(back.reserve_factor_bps, 0xFFFF); + assert_eq!(back.ltv_bps, 7_500); + assert_eq!(back.liquidation_threshold_bps, 8_000); + assert_eq!(back.close_factor_bps, 5_000); + assert_eq!(back.liquidation_incentive_bps, 1_000); + } + + #[test] + fn five_bps_fields_fit_one_word() { + // All five 16-bit fields occupy the low 80 bits of the u128. + let packed = pack(&sample()).unwrap(); + assert_eq!(packed.rate_word >> 80, 0); + } + + #[test] + fn bps_field_overflow_rejected() { + let mut cfg = sample(); + cfg.ltv_bps = 0x1_0000; // 17 bits — too wide + assert_eq!(pack(&cfg), Err(PackError::BpsFieldOverflow)); + } + + #[test] + fn negative_bps_rejected() { + let mut cfg = sample(); + cfg.close_factor_bps = -1; + assert_eq!(pack(&cfg), Err(PackError::BpsFieldOverflow)); + } + + #[test] + fn timestamp_overflow_rejected() { + let mut cfg = sample(); + cfg.last_update = 1u64 << 41; + assert_eq!(pack(&cfg), Err(PackError::TimestampOverflow)); + } + + #[test] + fn flags_round_trip_and_helpers() { + let mut cfg = sample(); + cfg.flags = flag_with(cfg.flags, FLAG_PAUSED, true); + let back = unpack(&pack(&cfg).unwrap()); + assert!(flag_is_set(back.flags, FLAG_PAUSED)); + assert!(flag_is_set(back.flags, FLAG_BORROWING_ENABLED)); + assert!(!flag_is_set(back.flags, FLAG_DEPRECATED)); + + let cleared = flag_with(back.flags, FLAG_PAUSED, false); + assert!(!flag_is_set(cleared, FLAG_PAUSED)); + } + + #[test] + fn flags_do_not_corrupt_timestamp() { + let mut cfg = sample(); + cfg.flags = 0xFF; // all flags on + let back = unpack(&pack(&cfg).unwrap()); + assert_eq!(back.last_update, 1_700_000_000); + assert_eq!(back.flags, 0xFF); + } +}