This document describes the persistent storage structure of the StellarLend protocol on Soroban.
For cross‑asset module storage details, see Cross‑Asset Storage Layout.
StellarLend uses Soroban's persistent() storage for position and accounting
state that must survive normal contract use, and instance() storage for
small administrative configuration, guards, and pause state. The canonical
lending-contract storage namespace is the DataKey enum in
stellar-lend/contracts/lending/src/lib.rs.
All keys are defined using contracttype enums.
Important
Namespace Isolation: To prevent collisions between modules, all storage key enum variants MUST be unique across the entire contract. Even different enum types will collide if their variants share the same name (as they serialize to the same Symbol).
The lending contract defines PERSISTENT_TTL_LEDGERS = 1_000_000 and bumps
position storage to min(env.storage().max_ttl(), PERSISTENT_TTL_LEDGERS).
The bump threshold is extend_to / 2 + 1, so an entry is extended only after it
falls below roughly half of the target lifetime.
Explicit TTL extension is implemented for the two per-user position entries:
DataKey::Collateral(user)inextend_collateral_ttlDataKey::Debt(user)inextend_debt_ttl
The current bump triggers are:
depositandwithdrawextend the collateral entry after a balance write.repayextends the debt entry after a debt write.get_positionandget_health_factorextend existing collateral and debt entries during reads.get_debt_positionextends an existing debt entry during a debt-only read.
borrow writes DataKey::Debt(user) through save_debt, but does not
currently call extend_debt_ttl; add that call if borrow-side TTL bumping is
required by a future storage policy.
Other persistent keys rely on normal Soroban storage lifetime and rent renewal outside these helper functions. Instance keys are tied to the contract instance and do not use per-key persistent TTL helpers.
- Write-side TTL bumps are limited to position-changing calls that already touch the affected key.
- Read-side TTL bumps are applied only on explicit position queries, preserving liveness for read-heavy users without imposing extra work on unrelated calls.
- The TTL target is long-lived, up to 1,000,000 ledgers or the network maximum, so routine use keeps positions live while inactive positions are not constantly bumped.
The lending contract centralizes its storage namespace in a single
#[contracttype] enum DataKey. This is the canonical key list for that module.
Every current DataKey variant appears exactly once in the table below.
Key (DataKey) |
Storage tier | Value type | Writers / owners | TTL policy | Source |
|---|---|---|---|---|---|
Collateral(Address) |
persistent() |
i128 |
deposit, withdraw, liquidate |
Explicitly bumped by deposit, withdraw, get_position, and get_health_factor when the key exists. |
DataKey, deposit, withdraw, extend_collateral_ttl |
Debt(Address) |
persistent() |
DebtPosition |
borrow, repay, liquidate through save_debt |
Explicitly bumped by repay, get_debt_position, get_position, and get_health_factor when the key exists. |
DataKey, save_debt, repay, extend_debt_ttl |
Balance(Address, Address) |
persistent() |
i128 |
flash_loan, repay_flash_loan |
No explicit TTL helper; persistent entry lifetime is managed through normal Soroban rent/renewal. | DataKey, repay_flash_loan, flash_loan |
Treasury(Address) |
persistent() |
i128 |
flash_loan, repay_flash_loan |
No explicit TTL helper; persistent entry lifetime is managed through normal Soroban rent/renewal. | DataKey, repay_flash_loan, flash_loan |
TotalDebt |
persistent() |
i128 |
borrow, repay; read by metrics and rate calculation |
No explicit TTL helper; protocol aggregate is a persistent accounting key. | DataKey, borrow, repay, current_borrow_rate |
TotalDeposits |
persistent() |
i128 |
deposit, withdraw; read by metrics and rate calculation |
No explicit TTL helper; protocol aggregate is a persistent accounting key. | DataKey, deposit, withdraw, get_protocol_metrics |
DebtCeiling |
instance() |
i128 |
set_debt_ceiling; intended admin-controlled protocol limit |
Instance storage; no per-key persistent TTL. | DataKey, set_debt_ceiling |
DepositCap |
persistent() |
i128 |
Read by deposit; currently falls back to DEFAULT_DEPOSIT_CAP when absent |
No explicit TTL helper; protocol safety limit is persistent when written. | DataKey, deposit |
FlashActive |
instance() |
bool |
flash_loan sets and clears it; deposit, withdraw, and repay read it |
Instance storage; no per-key persistent TTL. | DataKey, flash_loan, deposit |
FlashFeeBps |
instance() |
i128 |
set_flash_fee; read by flash_loan |
Instance storage; no per-key persistent TTL. | DataKey, get_flash_fee_bps, set_flash_fee |
BorrowMinAmount |
instance() |
i128 |
set_min_borrow; read by borrow |
Instance storage; no per-key persistent TTL. | DataKey, set_min_borrow, get_min_borrow |
Admin |
instance() |
Address |
initialize, accept_admin; read by admin-gated functions |
Instance storage; no per-key persistent TTL. | DataKey, initialize, get_admin, accept_admin |
PendingAdmin |
instance() |
Address |
propose_admin, accept_admin |
Instance storage; removed after successful admin acceptance. | DataKey, propose_admin, accept_admin |
OraclePubKey |
instance() |
BytesN<32> |
set_oracle_pubkey; read by set_price |
Instance storage; no per-key persistent TTL. | DataKey, set_oracle_pubkey, set_price |
OraclePrice(Address) |
persistent() |
PriceRecord |
set_price; read by get_price_record |
No explicit TTL helper; price records are persistent but can become stale by timestamp validation policy. | DataKey, set_price, get_price_record |
EmergencyState |
instance() |
EmergencyState |
initialize, set_emergency_state through set_emergency_state_internal |
Instance storage; defaults to Normal if absent. |
DataKey, initialize, get_emergency_state, set_emergency_state_internal |
Guardian |
instance() |
Address |
set_guardian; read by shutdown authorization |
Instance storage; no per-key persistent TTL. | DataKey, set_guardian, get_guardian |
PauseState(PauseType) |
instance() |
PauseState |
Pause state per operation; read by pause_is_active |
Instance storage; expires logically through expires_at_ledger, not a persistent TTL helper. |
DataKey, pause_is_active |
RateParams |
instance() |
rate_model::RateParams |
Borrow-rate configuration; read by current_borrow_rate |
Instance storage; no per-key persistent TTL. | DataKey, current_borrow_rate |
Notes:
Addresspayload order forBalance(asset, user)is asset first, user second.PauseState(PauseType)stores one instance entry per pause operation.DebtCeilingis currently written toinstance()storage; if the protocol later requires persistent ceiling history across instance expiration, update this table and the setter together.- New lending keys must be appended to
DataKey; never reuse an existing variant for a different value type.
Soroban supports contract upgrades via env.deployer().update_current_contract_wasm(new_wasm_hash). This replaces the contract code while preserving existing storage.
- Append Only: Always add new variants to the end of
contracttypeenums to preserve discriminant mapping. - Structural Stability: Avoid deleting or reordering fields in structs. If a field is deprecated, keep it but ignore its value.
- Key Consistency: Ensure that
contracttypedefinitions used for storage keys are identical across versions.
If a storage layout change is unavoidable (e.g., merging two maps into one), follow this process:
- Deployment: Deploy the new contract code.
- Migration Transaction: Execute a one-time admin function that reads old data, transforms it, and writes it to new keys.
- Cleanup: Remove the old keys to reclaim rent/storage costs.
- Verification: Execute a test suite against the migrated state.
- No Overwrites: Storage keys are designed to be unique. Using
contracttypeenums for keys ensures that different data types even with the same payload (likeCollateral(Address)vsDebt(Address)) serialize to distinct storage slots. - Multi-Address Isolation: By including the user
Addressin theDataKeyvariant payload (e.g.,DataKey::Collateral(Address)), we guarantee that one user's operations can never affect another's balance. This is verified by multi-user suite tests inlib.rs. - Tier by lifetime: User positions and accounting aggregates use
persistent()storage; bounded admin/configuration and guard state usesinstance()storage. - Admin Isolation: Admin addresses are stored in module-specific keys, allowing for granular permission management or a unified global admin.
- All
contracttypeenums have unique variants. - Critical per-user position and accounting state uses
persistent()storage. - No current
DataKeyvariant usestemporary()storage. - Lending storage keys stay isolated through the single canonical
DataKeyenum.
When introducing a new storage field or key (a "layout addition"), follow this
checklist to guarantee user positions (collateral, debt, rates, timestamps)
survive the upgrade unchanged. The safety tests in
stellar-lend/contracts/lending/src/upgrade_migration_safety_test.rs enforce
the same invariants programmatically.
- Snapshot rich fixture: confirm seed data covers multiple users and multiple assets, with collateral, debt, rate, and timestamp fields populated.
- Backup: call
data_backupand store the snapshot name. Thetest_view_consistency_after_upgradetest models this flow. - Schema version recorded: capture
data_schema_version()for use as the strict-greater-than check in the new bump.
- Append-only: new storage keys MUST live under fresh, non-overlapping
namespaces. Never reuse a legacy key for a different value type. The
test_new_storage_fields_coexist_with_preserved_positionstest asserts the new keys never alias the old ones. - No in-place rewrites of legacy entries: the migration may read legacy entries to derive new ones, but must never overwrite them with a different encoding during the same migration.
- Bump schema version: call
data_migrate_bump_versionwith the new version and a memo describing the layout addition.
- Per-entry round-trip: every legacy
(key, value)pair must read back identically.test_positions_preserved_across_upgrade_layout_additionandtest_position_decoding_after_upgrade_round_trippin this at both the byte-level and the decoded-field level. - Aggregate count:
data_entry_count()for legacy keys must remain unchanged; the count for new keys must equal exactly what the migration wrote. - Sequential safety: if multiple migrations are chained, each step
must independently preserve all preceding entries. See
test_positions_preserved_across_sequential_layout_additions. - Rollback semantics documented: storage writes are not transactional
with upgrade execution. Document any keys the migration wrote so operators
understand they will persist even if the upgrade is rolled back. See
test_migration_preserves_positions_under_rollback.
- A migration that silently mutates or drops user positions can socialise
losses across the borrower set. Treat any test failure in
upgrade_migration_safety_test.rsas a release-blocker. - New storage namespaces must not collide with legacy namespaces by symbol or by enum discriminant. Add a regression test alongside any new storage key.