Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions docs/POOL_STATE_LAZY_LOADING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# Lazy Pool State: On-Demand Loading, Caching, and Monitoring

This document describes the lazy pool-state architecture introduced for the
StellarLend protocol on Soroban. It is the reference for how pool snapshots are
built, cached, invalidated, and monitored, and it documents the performance
targets and benchmark story for the feature.

Related: [storage.md](storage.md), [gas-benchmarks.md](gas-benchmarks.md).

## Problem

Pool-level state (aggregate supply, borrow, utilization, health, and
cross-asset metrics) is needed by most views and by the liquidator. Eagerly
recomputing it on every mutation is expensive: every deposit, borrow, repay, or
health check would pay for rebuilding the full aggregate even when nothing else
reads it. The previous design recomputed the snapshot unconditionally and
stored it under a single key, so reads were cheap but writes were asymptotically
costly and the value went stale across unrelated mutations.

## Design

Pool state is **lazy**:

1. **On-demand construction** — the snapshot is only built when it is first
requested (`pool_state::load`), not on every mutation.
2. **Epoch-keyed caching** — snapshots are cached under a *temporary* key that
includes the current epoch:
`PoolStateTempKey::Snapshot(pool, epoch)`. Temporary entries survive only for
the current ledger run, so a snapshot is valid for the epoch in which it was
produced and is never stale across epoch boundaries.
3. **Explicit invalidation** — mutations that change aggregate values call
`pool_state::bump_epoch`, which advances `PoolStateKey::Epoch`. Reads fall
back to rebuilding when the cached entry's epoch does not match. Targeted
invalidation of a single pool is available via `pool_state::invalidate`.
4. **Initialization marker** — `PoolStateKey::Initialized(pool)` records that a
pool has been initialized; `default_pool_state_loader` presets safe defaults
for uninitialized pools so that `get_pool_state` never panics on a fresh or
partially-initialized pool.

### Contract surface

All state lives in `contracts/hello-world/src/pool_state.rs` with storage keys
in `contracts/hello-world/src/storage.rs`:

| Key (Type) | Storage | Value |
|------------|---------|-------|
| `PoolStateKey::Epoch` | persistent | `u64` |
| `PoolStateKey::Initialized(pool)` | persistent | `bool` |
| `PoolStateKey::Metrics` | persistent | `PoolStateMetrics` |
| `PoolStateTempKey::Snapshot(pool, epoch)` | temporary | `PoolStateSnapshot` |

Entrypoints (`contracts/hello-world/src/lib.rs`):

- `get_pool_state(env, asset) -> PoolStateSnapshot` — build or return the
cached snapshot.
- `get_multiple_pool_states(env, assets: Vec<Option<Address>>) -> Vec<PoolStateSnapshot>`.
- `is_pool_state_initialized(env, asset) -> bool`.
- `get_pool_state_epoch(env) -> u64`.
- `get_pool_state_metrics(env) -> PoolStateMetrics`.
- `invalidate_pool_state(env)` — advance the epoch (admin-gated).

Epoch bumping is wired into the mutation paths that change aggregates (the
`bump_epoch`/`invalidate` hooks in `lib.rs`, e.g. the borrow/repay/withdrawal
and pool-management flows), keeping cache coherence without a central scheduler.

## API service

The API layer mirrors the contract with its own bounded cache in
`api/src/services/stellar.service.ts`, keyed constants `NATIVE_POOL_KEY` and
`POOL_STATE_EPOCH_KEY`:

- `getPoolState` / `getMultiplePoolStates` — reads with response coalescing.
- `getPoolStateEpoch` — cached epoch used for request fencing.
- `invalidatePoolStateCache` — clears the in-memory and Redis caches.
- `getPoolStateCacheMetrics` — hit rate and cost observability.

The contract epoch is the source of truth; the service cache is best-effort and
always fallible, so the UI remains correct even when caching is bypassed.

## Consistency

- Snapshot identity is `(pool, epoch)`. Any write that should invalidate a
snapshot advances the epoch before subsequent reads see the new value.
- Temporary storage guarantees cross-ledger freshness: a snapshot cached in a
previous run is not used, satisfying the "never stale across ledgers" rule
without extra bookkeeping.
- Deeper consistency and reentrancy guarantees are documented in
[REENTRANCY_GUARANTEES.md](../stellar-lend/docs/REENTRANCY_GUARANTEES.md).

## Performance target: <50 ms

The acceptance target for a single `get_pool_state` call is **under 50
milliseconds** of CPU time, including the cold-miss rebuild path.

Evidence strategy (benchmarks/):

- `stellar-lend/benchmarks/src/pool_state_benchmarks.rs` exercises cold
(uncached) load, warm cached load, invalidation-then-reload, and metric reads,
reporting instruction counts via the host cost estimator
(`framework.rs`).
- The elapsed-time runner measures wall-clock latency so the <50 ms constraint
is checked directly, not only via instruction counts.

### Current status / blocker

The benchmark runner currently cannot build: hello-world fails to compile when
the workspace enables the soroban-sdk `testutils` feature (which any contract-
driving harness requires). `#[contracttype]` under testutils generates
struct→`ScVal` conversions that need a plain `From<T> for ScVal` for every
field, which SDK 27 does not provide for custom types or raw `Val` fields. The
affected contracttypes are:

- `contracts/hello-world/src/storage.rs:3` — `SnapshotValue { value: Val, .. }`
- `contracts/hello-world/src/rate_limiter.rs:134` — `CongestionState` with
`Option<CongestionReport>` / `Option<LedgerIntervalSample>`
- `contracts/hello-world/src/mev_protection.rs:70` — `PendingCommit` with
`Option<Address>`

This is a pre-existing upstream issue, unrelated to pool-state itself, and is
tracked as a follow-up. Once resolved, running
`cargo run --bin run_benchmarks -- pool_state` produces the load numbers and
the elapsed-time assertion (`<50 ms`) is evaluated by the runner. Until then,
the margin argument rests on the instruction-count scale of the load path: a
single-pool snapshot touches a bounded set of keys and aggregates, orders of
magnitude below Soroban's per-invocation instruction ceiling, and cached reads
are a single temporary-storage lookup.

## Monitoring

`PoolStateMetrics` (persisted under `PoolStateKey::Metrics`) aggregates hit/miss
and rebuild counters across calls. `get_pool_state_metrics` exposes them
on-chain, and the API service exposes cache hit rate via
`getPoolStateCacheMetrics`. Existing dashboards may consume either endpoint; see
[RISK_MONITORING_DASHBOARD.md](RISK_MONITORING_DASHBOARD.md).

## Deployment notes

The feature ships behind the existing upgrade mechanism (`docs/upgrade-
mechanism.md`): a contract upgrade carries the new `pool_state.rs` module; no
data migration is needed because epoch and initialized markers are new keys and
the old snapshot key(s) are not read after upgrade.
6 changes: 3 additions & 3 deletions stellar-lend/contracts/bridge/src/lending_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ pub struct LendingBridgeStats {

// ─── Events ───────────────────────────────────────────────────────────────────

#[contractevent]
#[contractevent(topics = ["cross_chain_position_opened"])]
#[derive(Clone, Debug)]
pub struct CrossChainPositionOpenedEvent {
pub user: Address,
Expand All @@ -230,7 +230,7 @@ pub struct CrossChainPositionOpenedEvent {
pub timestamp: u64,
}

#[contractevent]
#[contractevent(topics = ["cross_chain_position_repaid"])]
#[derive(Clone, Debug)]
pub struct CrossChainPositionRepaidEvent {
pub user: Address,
Expand Down Expand Up @@ -269,7 +269,7 @@ pub struct LiquidityRouteRegisteredEvent {
pub timestamp: u64,
}

#[contractevent]
#[contractevent(topics = ["remote_health_report_submitted"])]
#[derive(Clone, Debug)]
pub struct RemoteHealthReportSubmittedEvent {
pub user: Address,
Expand Down
20 changes: 15 additions & 5 deletions stellar-lend/contracts/hello-world/src/circuit_breaker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ pub enum CircuitBreakerTier {
#[contracttype]
pub enum CircuitBreakerStatus {
Active,
Paused,
Emergency,
Tier1Paused,
Tier2Paused,
Tier3Halted,
Expand Down Expand Up @@ -68,7 +70,7 @@ pub struct CircuitBreakerConfig {
pub auto_deactivate_enabled: bool,
pub whitelist_enabled: bool,
pub price_deviation_threshold_bps: u64,
pub abnormal_utilization_threshold_bps: u64,
pub abnormal_utilization_bps: u64,
pub guardian_multisig: Option<Address>,
pub tier1_auto_trigger_enabled: bool,
pub tier2_auto_trigger_enabled: bool,
Expand All @@ -81,7 +83,7 @@ impl Default for CircuitBreakerConfig {
auto_deactivate_enabled: true,
whitelist_enabled: true,
price_deviation_threshold_bps: PRICE_DEVIATION_THRESHOLD_BPS,
abnormal_utilization_threshold_bps: ABNORMAL_UTILIZATION_THRESHOLD_BPS,
abnormal_utilization_bps: ABNORMAL_UTILIZATION_THRESHOLD_BPS,
guardian_multisig: None,
tier1_auto_trigger_enabled: true,
tier2_auto_trigger_enabled: true,
Expand Down Expand Up @@ -184,11 +186,15 @@ pub fn activate_circuit_breaker(

let state = CircuitBreakerState {
status,
tier: CircuitBreakerTier::Tier1,
activated_at: now,
activated_by: caller.clone(),
cooldown_period: config.cooldown_period,
auto_deactivate_at,
reason: reason.clone(),
affected_pool: None,
guardian_multisig: config.guardian_multisig.clone(),
governance_vote_required: false,
};

let state_key = storage::DataKey::CircuitBreakerState;
Expand Down Expand Up @@ -260,8 +266,10 @@ pub fn is_liquidation_allowed(env: &Env, liquidator: &Address) -> Result<bool, L

match state.status {
CircuitBreakerStatus::Active => Ok(true),
CircuitBreakerStatus::Paused => Ok(false),
CircuitBreakerStatus::Emergency => {
CircuitBreakerStatus::Paused | CircuitBreakerStatus::Tier1Paused | CircuitBreakerStatus::Tier2Paused => {
Ok(false)
}
CircuitBreakerStatus::Tier3Halted | CircuitBreakerStatus::Emergency => {
// Check whitelist
is_whitelisted(env, liquidator)
}
Expand Down Expand Up @@ -583,7 +591,7 @@ pub fn check_automatic_triggers(
return Ok(Some(CircuitBreakerTier::Tier1));
}

if config.tier2_auto_trigger_enabled && utilization_bps >= config.abnormal_utilization_threshold_bps {
if config.tier2_auto_trigger_enabled && utilization_bps >= config.abnormal_utilization_bps {
return Ok(Some(CircuitBreakerTier::Tier2));
}

Expand Down Expand Up @@ -658,6 +666,8 @@ pub fn is_operation_allowed(
CircuitBreakerStatus::Tier3Halted => {
is_whitelisted(env, caller)
}
CircuitBreakerStatus::Paused => Ok(false),
CircuitBreakerStatus::Emergency => is_whitelisted(env, caller),
}
}

Expand Down
27 changes: 21 additions & 6 deletions stellar-lend/contracts/hello-world/src/debt_token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
//! - Audit trail through events

#![allow(unused)]
use soroban_sdk::{contracterror, contractevent, contracttype, Address, Env, Map, Symbol, Vec};
use soroban_sdk::{contracterror, contractevent, contracttype, Address, Env, Map, Symbol, TryFromVal, Vec};

use crate::deposit::DepositDataKey;
use crate::errors::LendingError;
Expand Down Expand Up @@ -327,14 +327,25 @@ pub fn transfer_debt_token(
/// require the seller's auth) plus the buyer's auth on the purchase itself, not
/// by the seller re-signing at sale time. Callers are responsible for ensuring
/// whatever authorization model applies to their call site before invoking this.
fn is_zero_address(env: &Env, address: &Address) -> bool {
*address
== Address::try_from_val(
env,
&soroban_sdk::xdr::ScAddress::Contract(soroban_sdk::xdr::ContractId(
soroban_sdk::xdr::Hash([0u8; 32]),
)),
)
.unwrap()
}

fn move_debt_token_ownership(
env: &Env,
from: Address,
to: Address,
token_id: u64,
) -> Result<(), DebtTokenError> {
// Validate inputs
if to == Address::zero() {
if is_zero_address(env, &to) {
return Err(DebtTokenError::ZeroAddress);
}

Expand Down Expand Up @@ -365,9 +376,11 @@ fn move_debt_token_ownership(

// Remove from current owner
let mut from_tokens = owner_tokens;
let index = from_tokens.iter().position(|&id| id == token_id)
let index = from_tokens
.iter()
.position(|id| id == token_id)
.ok_or(DebtTokenError::TokenNotFound)?;
from_tokens.remove(index);
from_tokens.remove(index as u32);

let from_key = DebtTokenDataKey::OwnerTokens(from.clone());
env.storage().persistent().set(&from_key, &from_tokens);
Expand Down Expand Up @@ -576,9 +589,11 @@ pub fn burn_debt_token(

// Remove from owner's token list
let mut user_tokens = owner_tokens;
let index = user_tokens.iter().position(|&id| id == token_id)
let index = user_tokens
.iter()
.position(|id| id == token_id)
.ok_or(DebtTokenError::TokenNotFound)?;
user_tokens.remove(index);
user_tokens.remove(index as u32);

let owner_key = DebtTokenDataKey::OwnerTokens(user.clone());
env.storage().persistent().set(&owner_key, &user_tokens);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const DEFAULT_WITHDRAWAL_CAP_BPS: i128 = 3000; // 30%
pub fn initialize_emergency_withdrawal(env: &Env) {
let default_state = EmergencyState {
is_active: false,
trigger: EmergencyTrigger::AdminEmergency,
trigger: EmergencyTrigger::Admin,
started_at: 0,
window_opens_at: 0,
window_closes_at: 0,
Expand Down Expand Up @@ -101,7 +101,7 @@ pub fn get_emergency_state(env: &Env) -> EmergencyState {
.get(&DepositDataKey::EmergencyState)
.unwrap_or(EmergencyState {
is_active: false,
trigger: EmergencyTrigger::AdminEmergency,
trigger: EmergencyTrigger::Admin,
started_at: 0,
window_opens_at: 0,
window_closes_at: 0,
Expand Down
24 changes: 24 additions & 0 deletions stellar-lend/contracts/hello-world/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use crate::borrow::BorrowError;
use crate::cross_asset::CrossAssetError;
use crate::debt_token::DebtTokenError;
use crate::deposit::DepositError;
use crate::emergency_withdrawal::EmergencyWithdrawalError;
use crate::flash_loan::FlashLoanError;
use crate::interest_rate::InterestRateError;
use crate::liquidate::LiquidationError;
Expand Down Expand Up @@ -70,6 +71,10 @@ pub enum GovernanceError {
InvalidTimelockStatus = 144,
InvalidTimelockConfig = 145,
InvalidTimelockDelay = 146,
RecoveryNotReady = 147,
InvalidActionTypeDelay = 148,
EmergencyOverrideAlreadyApproved = 149,
InsufficientEmergencyApprovals = 150,
}

/// Unified public contract error type for the lending interface.
Expand Down Expand Up @@ -380,6 +385,10 @@ impl_from_error!(DebtTokenError, {
DebtTokenError::ZeroAddress => LendingError::InvalidParameter,
DebtTokenError::AlreadyTokenized => LendingError::AlreadyExists,
DebtTokenError::PositionNotFound => LendingError::DataNotFound,
DebtTokenError::NotListed => LendingError::DataNotFound,
DebtTokenError::AlreadyListed => LendingError::AlreadyExists,
DebtTokenError::NotSeller => LendingError::Unauthorized,
DebtTokenError::InvalidPrice => LendingError::InvalidParameter,
});

impl From<CrossAssetError> for LendingError {
Expand All @@ -401,3 +410,18 @@ impl From<CrossAssetError> for LendingError {
}
}
}

impl From<EmergencyWithdrawalError> for LendingError {
fn from(error: EmergencyWithdrawalError) -> Self {
match error {
EmergencyWithdrawalError::NotActive => LendingError::InvalidState,
EmergencyWithdrawalError::AlreadyActive => LendingError::AlreadyExists,
EmergencyWithdrawalError::WindowNotOpen => LendingError::InvalidState,
EmergencyWithdrawalError::NotAuthorized => LendingError::Unauthorized,
EmergencyWithdrawalError::InsufficientBalance => LendingError::InsufficientBalance,
EmergencyWithdrawalError::ExceedsWithdrawalCap => LendingError::LimitExceeded,
EmergencyWithdrawalError::InvalidParameter => LendingError::InvalidParameter,
EmergencyWithdrawalError::AlreadyWithdrawn => LendingError::AlreadyExists,
}
}
}
Loading