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
73 changes: 40 additions & 33 deletions contracts/lending/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,8 @@ pub enum DataKey {
PriceOracle,
/// Aggregated oracle price samples for a collateral asset.
OraclePriceSamples(Address),
/// Ledger timestamp at which the asset's current oracle price was posted.
OraclePriceUpdatedAt(Address),
/// TWAP fallback price for a collateral asset.
OracleTwapPrice(Address),
/// Borrower Loyalty Rewards contract address
Expand Down Expand Up @@ -297,6 +299,8 @@ const RATE_SWITCH_FEE_BPS: u32 = 50;

/// Cooldown between rate switches: 24 hours in seconds.
const RATE_SWITCH_COOLDOWN_SECS: u64 = 86_400;
/// Oracle collateral prices older than this cannot be used for borrowing.
const MAX_COLLATERAL_PRICE_AGE_SECS: u64 = 3_600;

/// Default collateral factor for assets without explicit config: 75% LTV.
const DEFAULT_COLLATERAL_FACTOR_BPS: u32 = 7500;
Expand Down Expand Up @@ -905,6 +909,10 @@ impl LendingContract {
env.storage()
.instance()
.set(&DataKey::OraclePriceSamples(asset.clone()), &prices);
env.storage().instance().set(
&DataKey::OraclePriceUpdatedAt(asset),
&env.ledger().timestamp(),
);
}

/// Store a TWAP fallback price for an asset.
Expand All @@ -927,6 +935,10 @@ impl LendingContract {
env.storage()
.instance()
.set(&DataKey::OracleTwapPrice(asset.clone()), &price);
env.storage().instance().set(
&DataKey::OraclePriceUpdatedAt(asset),
&env.ledger().timestamp(),
);
}

// ── Asset collateral configuration (multisig-gated) ───────────────────────
Expand All @@ -939,9 +951,8 @@ impl LendingContract {
/// can be borrowed, in basis-points (e.g. 8000 = 80% LTV).
/// Clamped to [MIN_COLLATERAL_FACTOR_BPS, MAX_COLLATERAL_FACTOR_BPS].
///
/// `has_price_oracle` — if true, the contract expects a price feed from
/// the authorized oracle; if false, the asset is valued at face value
/// (i.e. 1 unit = 1 USD) for borrowing power calculations.
/// `has_price_oracle` — the asset must have an active oracle feed before
/// it can be used in collateral valuation.
///
/// `volatility_bps` — estimated annualized volatility in bps, used in
/// dynamic liquidation threshold calculations.
Expand Down Expand Up @@ -1952,8 +1963,7 @@ impl LendingContract {
/// Borrowing power = sum over entries of:
/// (amount * price * collateral_factor_bps) / (PRICE_PRECISION * 10_000)
///
/// Where `price` is the oracle price if configured, or PRICE_PRECISION (1.0)
/// if no oracle is configured.
/// Where `price` is a fresh, registered oracle quote.
fn compute_borrowing_power_from_entries(env: &Env, entries: &Vec<CollateralEntry>) -> i128 {
let mut total_power: i128 = 0;
for entry in entries.iter() {
Expand Down Expand Up @@ -1985,36 +1995,33 @@ impl LendingContract {
/// For example, if 1 XLM = $0.10 USD, the price would be:
/// (1 XLM in stroops = 10_000_000) * PRICE_PRECISION / 1 = 10_000_000 * 10_000_000 / 1
///
/// But for collateral valuation, we use a simpler scheme:
/// - If the asset has an oracle, price is posted by the oracle
/// - If not, price = PRICE_PRECISION (i.e. face value = 1.0 base unit per asset unit)
///
/// For now, without a live oracle, all assets are valued at face value (1:1).
/// This still allows testing the multi-collateral framework with different
/// collateral factors.
/// Only a recent quote for an asset with a configured oracle can be used.
/// Missing or stale prices fail closed instead of assuming face value.
fn get_asset_price(env: &Env, asset: &Address, config: &AssetCollateralConfig) -> i128 {
if config.has_price_oracle {
let samples: Vec<i128> = env
.storage()
.instance()
.get(&DataKey::OraclePriceSamples(asset.clone()))
.unwrap_or(Vec::new(env));

if !samples.is_empty() {
return Self::median_price(env, &samples);
}

if let Some(twap_price) = env
.storage()
.instance()
.get::<DataKey, i128>(&DataKey::OracleTwapPrice(asset.clone()))
{
return twap_price;
}
if !config.has_price_oracle || !env.storage().instance().has(&DataKey::PriceOracle) {
panic!("Collateral asset has no active price oracle");
}

// No oracle or no live price data — fall back to face value.
PRICE_PRECISION
let updated_at: u64 = env
.storage()
.instance()
.get(&DataKey::OraclePriceUpdatedAt(asset.clone()))
.expect("No oracle price registered for collateral asset");
if env.ledger().timestamp().saturating_sub(updated_at) > MAX_COLLATERAL_PRICE_AGE_SECS {
panic!("Collateral oracle price is stale");
}
let samples: Vec<i128> = env
.storage()
.instance()
.get(&DataKey::OraclePriceSamples(asset.clone()))
.unwrap_or(Vec::new(env));
if !samples.is_empty() {
return Self::median_price(env, &samples);
}
env.storage()
.instance()
.get::<DataKey, i128>(&DataKey::OracleTwapPrice(asset.clone()))
.filter(|price| *price > 0)
.expect("No oracle price registered for collateral asset")
}

fn median_price(env: &Env, prices: &Vec<i128>) -> i128 {
Expand Down
62 changes: 53 additions & 9 deletions contracts/lending/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ fn setup() -> (Env, Address, Address, Address, Address) {

let collateral_asset = Address::generate(&env);
client.whitelist_asset(&admin, &collateral_asset);
let price_oracle = admin.clone();
client.set_price_oracle(&admin, &price_oracle);
client.set_asset_collateral_config(
&admin,
&collateral_asset,
&AssetCollateralConfig { collateral_factor_bps: 7500, has_price_oracle: true, volatility_bps: 0 },
);
client.set_asset_oracle_prices(&price_oracle, &collateral_asset, &vec![&env, PRICE_PRECISION]);

link_reputation(&env, &client, &admin, &borrower);

Expand Down Expand Up @@ -235,7 +243,7 @@ fn test_no_overflow_at_maximum_valid_inputs() {
&collateral_asset,
&AssetCollateralConfig {
collateral_factor_bps: 9500,
has_price_oracle: false,
has_price_oracle: true,
volatility_bps: 0,
},
);
Expand Down Expand Up @@ -1672,6 +1680,16 @@ fn setup_multi_collateral() -> (Env, Address, Address, Address, Address, Address
let asset_b = env.register_stellar_asset_contract_v2(asset_b_admin).address();
client.whitelist_asset(&admin, &asset_a);
client.whitelist_asset(&admin, &asset_b);
let price_oracle = admin.clone();
client.set_price_oracle(&admin, &price_oracle);
for asset in [&asset_a, &asset_b] {
client.set_asset_collateral_config(
&admin,
asset,
&AssetCollateralConfig { collateral_factor_bps: 7500, has_price_oracle: true, volatility_bps: 0 },
);
client.set_asset_oracle_prices(&price_oracle, asset, &vec![&env, PRICE_PRECISION]);
}

// Give each test borrower ample funds so deposits exercise real transfers.
let funding = 1_000_000_000_000_000i128;
Expand Down Expand Up @@ -1914,7 +1932,7 @@ fn test_get_borrowing_power_single_asset() {
&asset_a,
&AssetCollateralConfig {
collateral_factor_bps: 8000, // 80% LTV
has_price_oracle: false,
has_price_oracle: true,
volatility_bps: 500, // 5% vol
},
);
Expand All @@ -1941,7 +1959,7 @@ fn test_get_borrowing_power_multiple_assets() {
&asset_b,
&AssetCollateralConfig {
collateral_factor_bps: 5000, // 50% LTV
has_price_oracle: false,
has_price_oracle: true,
volatility_bps: 2000, // 20% vol
},
);
Expand Down Expand Up @@ -1977,7 +1995,7 @@ fn test_set_asset_collateral_config_non_whitelisted_rejected() {
&random_asset,
&AssetCollateralConfig {
collateral_factor_bps: 8000,
has_price_oracle: false,
has_price_oracle: true,
volatility_bps: 500,
},
);
Expand All @@ -1995,7 +2013,7 @@ fn test_set_asset_collateral_config_clamps_to_bounds() {
&asset_a,
&AssetCollateralConfig {
collateral_factor_bps: 9600, // Above MAX
has_price_oracle: false,
has_price_oracle: true,
volatility_bps: 500,
},
);
Expand All @@ -2009,7 +2027,7 @@ fn test_set_asset_collateral_config_clamps_to_bounds() {
&asset_a,
&AssetCollateralConfig {
collateral_factor_bps: 500, // Below MIN
has_price_oracle: false,
has_price_oracle: true,
volatility_bps: 500,
},
);
Expand Down Expand Up @@ -2095,6 +2113,32 @@ fn test_price_oracle_twap_fallback_is_used() {
assert_eq!(value, 2_000_0000000);
}

/// Collateral without an enabled oracle quote must never be assigned face value.
#[test]
#[should_panic(expected = "Collateral asset has no active price oracle")]
fn test_collateral_without_oracle_price_is_rejected() {
let (env, contract_id, admin, borrower, asset_a, _asset_b) = setup_multi_collateral();
let client = LendingContractClient::new(&env, &contract_id);
client.set_asset_collateral_config(
&admin,
&asset_a,
&AssetCollateralConfig { collateral_factor_bps: 7500, has_price_oracle: false, volatility_bps: 0 },
);
client.deposit_collateral(&borrower, &asset_a, &1_000_0000000);
client.get_total_collateral_value(&borrower);
}

/// A quote stops being usable after the configured freshness window.
#[test]
#[should_panic(expected = "Collateral oracle price is stale")]
fn test_stale_collateral_oracle_price_is_rejected() {
let (env, contract_id, _admin, borrower, asset_a, _asset_b) = setup_multi_collateral();
let client = LendingContractClient::new(&env, &contract_id);
client.deposit_collateral(&borrower, &asset_a, &1_000_0000000);
env.ledger().set_timestamp(MAX_COLLATERAL_PRICE_AGE_SECS + 1);
client.get_total_collateral_value(&borrower);
}

/// Only multisig admin can set the price oracle.
#[test]
#[should_panic(expected = "Unauthorised")]
Expand All @@ -2120,7 +2164,7 @@ fn test_set_asset_collateral_config_non_admin_rejected() {
&asset_a,
&AssetCollateralConfig {
collateral_factor_bps: 8000,
has_price_oracle: false,
has_price_oracle: true,
volatility_bps: 500,
},
);
Expand All @@ -2138,7 +2182,7 @@ fn test_create_loan_request_checks_borrowing_power() {
&asset_a,
&AssetCollateralConfig {
collateral_factor_bps: 1000, // 10% LTV
has_price_oracle: false,
has_price_oracle: true,
volatility_bps: 500,
},
);
Expand Down Expand Up @@ -2183,7 +2227,7 @@ fn test_create_loan_request_with_multi_asset_collateral() {
&asset_b,
&AssetCollateralConfig {
collateral_factor_bps: 5000, // 50% LTV
has_price_oracle: false,
has_price_oracle: true,
volatility_bps: 1000,
},
);
Expand Down