diff --git a/contracts/suilend/sources/lending_market.move b/contracts/suilend/sources/lending_market.move index 1dfacd0..b712eb3 100644 --- a/contracts/suilend/sources/lending_market.move +++ b/contracts/suilend/sources/lending_market.move @@ -401,7 +401,9 @@ module suilend::lending_market { &mut lending_market.obligations, obligation_owner_cap.obligation_id, ); - obligation::refresh

(obligation, &mut lending_market.reserves, clock); + + let exist_stale_oracles = obligation::refresh

(obligation, &mut lending_market.reserves, clock); + obligation::assert_no_stale_oracles(exist_stale_oracles); let reserve = vector::borrow_mut(&mut lending_market.reserves, reserve_array_index); assert!(reserve::coin_type(reserve) == type_name::get(), EWrongType); @@ -478,7 +480,8 @@ module suilend::lending_market { &mut lending_market.obligations, obligation_owner_cap.obligation_id, ); - obligation::refresh

(obligation, &mut lending_market.reserves, clock); + + let exist_stale_oracles = obligation::refresh

(obligation, &mut lending_market.reserves, clock); let reserve = vector::borrow_mut(&mut lending_market.reserves, reserve_array_index); assert!(reserve::coin_type(reserve) == type_name::get(), EWrongType); @@ -488,7 +491,7 @@ module suilend::lending_market { max_withdraw_amount

(lending_market.rate_limiter, obligation, reserve, clock); }; - obligation::withdraw

(obligation, reserve, clock, amount); + obligation::withdraw

(obligation, reserve, clock, amount, exist_stale_oracles); event::emit(WithdrawEvent { lending_market_id, @@ -522,7 +525,9 @@ module suilend::lending_market { &mut lending_market.obligations, obligation_id, ); - obligation::refresh

(obligation, &mut lending_market.reserves, clock); + + let exist_stale_oracles = obligation::refresh

(obligation, &mut lending_market.reserves, clock); + obligation::assert_no_stale_oracles(exist_stale_oracles); let (withdraw_ctoken_amount, required_repay_amount) = obligation::liquidate

( obligation, @@ -644,7 +649,9 @@ module suilend::lending_market { &mut lending_market.obligations, obligation_id, ); - obligation::refresh

(obligation, &mut lending_market.reserves, clock); + + let exist_stale_oracles = obligation::refresh

(obligation, &mut lending_market.reserves, clock); + obligation::assert_no_stale_oracles(exist_stale_oracles); let reserve = vector::borrow_mut(&mut lending_market.reserves, reserve_array_index); assert!(reserve::coin_type(reserve) == type_name::get(), EWrongType); diff --git a/contracts/suilend/sources/obligation.move b/contracts/suilend/sources/obligation.move index a3aa24b..a3f748b 100644 --- a/contracts/suilend/sources/obligation.move +++ b/contracts/suilend/sources/obligation.move @@ -39,6 +39,7 @@ module suilend::obligation { const ETooManyBorrows: u64 = 6; const EObligationIsNotForgivable: u64 = 7; const ECannotDepositAndBorrowSameAsset: u64 = 8; + const EOraclesAreStale: u64 = 9; // === Constants === const CLOSE_FACTOR_PCT: u8 = 20; @@ -100,6 +101,9 @@ module suilend::obligation { user_reward_manager_index: u64, } + // hot potato. used by obligation::refresh to indicate that prices are stale. + public struct ExistStaleOracles {} + // === Events === public struct ObligationDataEvent has copy, drop { lending_market_id: address, @@ -167,7 +171,9 @@ module suilend::obligation { obligation: &mut Obligation

, reserves: &mut vector>, clock: &Clock, - ) { + ): Option { + let mut exist_stale_oracles = false; + let mut i = 0; let mut deposited_value_usd = decimal::from(0); let mut allowed_borrow_value_usd = decimal::from(0); @@ -179,7 +185,10 @@ module suilend::obligation { let deposit_reserve = vector::borrow_mut(reserves, deposit.reserve_array_index); reserve::compound_interest(deposit_reserve, clock); - reserve::assert_price_is_fresh(deposit_reserve, clock); + + if (!reserve::is_price_fresh(deposit_reserve, clock)) { + exist_stale_oracles = true; + }; let market_value = reserve::ctoken_market_value( deposit_reserve, @@ -227,7 +236,9 @@ module suilend::obligation { let borrow_reserve = vector::borrow_mut(reserves, borrow.reserve_array_index); reserve::compound_interest(borrow_reserve, clock); - reserve::assert_price_is_fresh(borrow_reserve, clock); + if (!reserve::is_price_fresh(borrow_reserve, clock)) { + exist_stale_oracles = true; + }; compound_debt(borrow, borrow_reserve); @@ -269,6 +280,12 @@ module suilend::obligation { weighted_borrowed_value_upper_bound_usd; obligation.borrowing_isolated_asset = borrowing_isolated_asset; + + if (exist_stale_oracles) { + return option::some(ExistStaleOracles {}) + }; + + option::none() } /// Process a deposit action @@ -492,7 +509,14 @@ module suilend::obligation { reserve: &mut Reserve

, clock: &Clock, ctoken_amount: u64, + stale_oracles: Option, ) { + if (stale_oracles.is_some() && vector::is_empty(&obligation.borrows)) { + let ExistStaleOracles {} = option::destroy_some(stale_oracles); + } else { + assert_no_stale_oracles(stale_oracles); + }; + withdraw_unchecked(obligation, reserve, clock, ctoken_amount); assert!(is_healthy(obligation), EObligationIsNotHealthy); @@ -834,6 +858,11 @@ module suilend::obligation { ) } + public(package) fun assert_no_stale_oracles(exist_stale_oracles: Option) { + assert!(option::is_none(&exist_stale_oracles), EOraclesAreStale); + option::destroy_none(exist_stale_oracles); + } + public(package) fun zero_out_rewards_if_looped

( obligation: &mut Obligation

, reserves: &mut vector>, diff --git a/contracts/suilend/sources/reserve.move b/contracts/suilend/sources/reserve.move index 0147c91..e45688b 100644 --- a/contracts/suilend/sources/reserve.move +++ b/contracts/suilend/sources/reserve.move @@ -236,11 +236,13 @@ module suilend::reserve { // make sure we are using the latest published price on sui public fun assert_price_is_fresh

(reserve: &Reserve

, clock: &Clock) { + assert!(is_price_fresh(reserve, clock), EPriceStale); + } + + public(package) fun is_price_fresh

(reserve: &Reserve

, clock: &Clock): bool { let cur_time_s = clock::timestamp_ms(clock) / 1000; - assert!( - cur_time_s - reserve.price_last_update_timestamp_s <= PRICE_STALENESS_THRESHOLD_S, - EPriceStale - ); + + cur_time_s - reserve.price_last_update_timestamp_s <= PRICE_STALENESS_THRESHOLD_S } // if SUI = $1, this returns decimal::from(1). diff --git a/contracts/suilend/tests/lending_market_tests.move b/contracts/suilend/tests/lending_market_tests.move index aed6fc5..2cc448f 100644 --- a/contracts/suilend/tests/lending_market_tests.move +++ b/contracts/suilend/tests/lending_market_tests.move @@ -782,6 +782,264 @@ module suilend::lending_market_tests { test_scenario::end(scenario); } + #[test] + #[expected_failure(abort_code = suilend::obligation::EOraclesAreStale)] + public fun test_withdraw_price_stale_with_borrows() { + use sui::test_utils::{Self}; + use suilend::test_usdc::{TEST_USDC}; + use suilend::test_sui::{TEST_SUI}; + use suilend::mock_pyth::{Self}; + use suilend::reserve_config::{Self, default_reserve_config}; + + let owner = @0x26; + let mut scenario = test_scenario::begin(owner); + let State { mut clock, owner_cap, mut lending_market, mut prices, type_to_index } = setup({ + let mut bag = bag::new(test_scenario::ctx(&mut scenario)); + bag::add( + &mut bag, + type_name::get(), + ReserveArgs { + config: { + let config = default_reserve_config(); + let mut builder = reserve_config::from( + &config, + test_scenario::ctx(&mut scenario), + ); + reserve_config::set_open_ltv_pct(&mut builder, 50); + reserve_config::set_close_ltv_pct(&mut builder, 50); + reserve_config::set_max_close_ltv_pct(&mut builder, 50); + sui::test_utils::destroy(config); + + reserve_config::build(builder, test_scenario::ctx(&mut scenario)) + }, + initial_deposit: 100 * 1_000_000, + }, + ); + bag::add( + &mut bag, + type_name::get(), + ReserveArgs { + config: reserve_config::default_reserve_config(), + initial_deposit: 100 * 1_000_000_000, + }, + ); + + bag + }, &mut scenario); + + clock::set_for_testing(&mut clock, 1 * 1000); + + // set reserve parameters and prices + mock_pyth::update_price(&mut prices, 1, 0, &clock); // $1 + mock_pyth::update_price(&mut prices, 1, 1, &clock); // $10 + + // create obligation + let obligation_owner_cap = lending_market::create_obligation( + &mut lending_market, + test_scenario::ctx(&mut scenario), + ); + + let coins = coin::mint_for_testing( + 100 * 1_000_000, + test_scenario::ctx(&mut scenario), + ); + let ctokens = lending_market::deposit_liquidity_and_mint_ctokens( + &mut lending_market, + *bag::borrow(&type_to_index, type_name::get()), + &clock, + coins, + test_scenario::ctx(&mut scenario), + ); + lending_market::deposit_ctokens_into_obligation( + &mut lending_market, + *bag::borrow(&type_to_index, type_name::get()), + &obligation_owner_cap, + &clock, + ctokens, + test_scenario::ctx(&mut scenario), + ); + + lending_market::refresh_reserve_price( + &mut lending_market, + *bag::borrow(&type_to_index, type_name::get()), + &clock, + mock_pyth::get_price_obj(&prices), + ); + lending_market::refresh_reserve_price( + &mut lending_market, + *bag::borrow(&type_to_index, type_name::get()), + &clock, + mock_pyth::get_price_obj(&prices), + ); + + let sui = lending_market::borrow( + &mut lending_market, + *bag::borrow(&type_to_index, type_name::get()), + &obligation_owner_cap, + &clock, + 2_500_000_000, + test_scenario::ctx(&mut scenario), + ); + + let obligation = lending_market::obligation( + &lending_market, + lending_market::obligation_id(&obligation_owner_cap), + ); + let old_deposited_amount = obligation::deposited_ctoken_amount( + obligation, + ); + + clock.increment_for_testing(100_000); + + // this should fail because the price is stale and we have borrows + let usdc = lending_market::withdraw_ctokens( + &mut lending_market, + *bag::borrow(&type_to_index, type_name::get()), + &obligation_owner_cap, + &clock, + 50 * 1_000_000, + test_scenario::ctx(&mut scenario), + ); + + let obligation = lending_market::obligation( + &lending_market, + lending_market::obligation_id(&obligation_owner_cap), + ); + let deposited_amount = obligation::deposited_ctoken_amount( + obligation, + ); + + assert!(coin::value(&usdc) == 50_000_000, 0); + assert!(deposited_amount == old_deposited_amount - 50 * 1_000_000, 0); + + test_utils::destroy(sui); + test_utils::destroy(usdc); + test_utils::destroy(obligation_owner_cap); + test_utils::destroy(owner_cap); + test_utils::destroy(lending_market); + test_utils::destroy(clock); + test_utils::destroy(prices); + test_utils::destroy(type_to_index); + test_scenario::end(scenario); + } + + #[test] + public fun test_withdraw_price_stale_no_borrows() { + use sui::test_utils::{Self}; + use suilend::test_usdc::{TEST_USDC}; + use suilend::test_sui::{TEST_SUI}; + use suilend::mock_pyth::{Self}; + use suilend::reserve_config::{Self, default_reserve_config}; + + let owner = @0x26; + let mut scenario = test_scenario::begin(owner); + let State { mut clock, owner_cap, mut lending_market, mut prices, type_to_index } = setup({ + let mut bag = bag::new(test_scenario::ctx(&mut scenario)); + bag::add( + &mut bag, + type_name::get(), + ReserveArgs { + config: { + let config = default_reserve_config(); + let mut builder = reserve_config::from( + &config, + test_scenario::ctx(&mut scenario), + ); + reserve_config::set_open_ltv_pct(&mut builder, 50); + reserve_config::set_close_ltv_pct(&mut builder, 50); + reserve_config::set_max_close_ltv_pct(&mut builder, 50); + sui::test_utils::destroy(config); + + reserve_config::build(builder, test_scenario::ctx(&mut scenario)) + }, + initial_deposit: 100 * 1_000_000, + }, + ); + bag::add( + &mut bag, + type_name::get(), + ReserveArgs { + config: reserve_config::default_reserve_config(), + initial_deposit: 100 * 1_000_000_000, + }, + ); + + bag + }, &mut scenario); + + clock::set_for_testing(&mut clock, 1 * 1000); + + // set reserve parameters and prices + mock_pyth::update_price(&mut prices, 1, 0, &clock); // $1 + mock_pyth::update_price(&mut prices, 1, 1, &clock); // $10 + + // create obligation + let obligation_owner_cap = lending_market::create_obligation( + &mut lending_market, + test_scenario::ctx(&mut scenario), + ); + + let coins = coin::mint_for_testing( + 100 * 1_000_000, + test_scenario::ctx(&mut scenario), + ); + let ctokens = lending_market::deposit_liquidity_and_mint_ctokens( + &mut lending_market, + *bag::borrow(&type_to_index, type_name::get()), + &clock, + coins, + test_scenario::ctx(&mut scenario), + ); + lending_market::deposit_ctokens_into_obligation( + &mut lending_market, + *bag::borrow(&type_to_index, type_name::get()), + &obligation_owner_cap, + &clock, + ctokens, + test_scenario::ctx(&mut scenario), + ); + + let obligation = lending_market::obligation( + &lending_market, + lending_market::obligation_id(&obligation_owner_cap), + ); + let old_deposited_amount = obligation::deposited_ctoken_amount( + obligation, + ); + + clock.increment_for_testing(100_000); + + // this should succeed even though price is stale + let usdc = lending_market::withdraw_ctokens( + &mut lending_market, + *bag::borrow(&type_to_index, type_name::get()), + &obligation_owner_cap, + &clock, + 50 * 1_000_000, + test_scenario::ctx(&mut scenario), + ); + + let obligation = lending_market::obligation( + &lending_market, + lending_market::obligation_id(&obligation_owner_cap), + ); + let deposited_amount = obligation::deposited_ctoken_amount( + obligation, + ); + + assert!(coin::value(&usdc) == 50_000_000, 0); + assert!(deposited_amount == old_deposited_amount - 50 * 1_000_000, 0); + + test_utils::destroy(usdc); + test_utils::destroy(obligation_owner_cap); + test_utils::destroy(owner_cap); + test_utils::destroy(lending_market); + test_utils::destroy(clock); + test_utils::destroy(prices); + test_utils::destroy(type_to_index); + test_scenario::end(scenario); + } + #[test] public fun test_liquidate() { use sui::test_utils::{Self}; diff --git a/contracts/suilend/tests/obligation_tests.move b/contracts/suilend/tests/obligation_tests.move index 3b6da5d..526a83a 100644 --- a/contracts/suilend/tests/obligation_tests.move +++ b/contracts/suilend/tests/obligation_tests.move @@ -6,6 +6,7 @@ module suilend::obligation_tests { use suilend::decimal::{Self, add}; use suilend::liquidity_mining; use suilend::obligation::{ + Self, create_obligation, deposit, borrow, @@ -448,7 +449,8 @@ module suilend::obligation_tests { 1, ); - refresh(&mut obligation, &mut reserves, &clock); + let exist_stale_oracles = refresh(&mut obligation, &mut reserves, &clock); + obligation::assert_no_stale_oracles(exist_stale_oracles); // this fails borrow( @@ -509,7 +511,8 @@ module suilend::obligation_tests { 1, ); - refresh(&mut obligation, &mut reserves, &clock); + let exist_stale_oracles = refresh(&mut obligation, &mut reserves, &clock); + obligation::assert_no_stale_oracles(exist_stale_oracles); // this fails borrow( @@ -570,7 +573,8 @@ module suilend::obligation_tests { 1, ); - refresh(&mut obligation, &mut reserves, &clock); + let exist_stale_oracles = refresh(&mut obligation, &mut reserves, &clock); + obligation::assert_no_stale_oracles(exist_stale_oracles); // this fails borrow( @@ -715,7 +719,13 @@ module suilend::obligation_tests { deposit(&mut obligation, &mut sui_reserve, &clock, 100 * 1_000_000_000); borrow(&mut obligation, &mut usdc_reserve, &clock, 50 * 1_000_000); - withdraw(&mut obligation, &mut sui_reserve, &clock, 50 * 1_000_000_000 + 1); + withdraw( + &mut obligation, + &mut sui_reserve, + &clock, + 50 * 1_000_000_000 + 1, + option::none(), + ); sui::test_utils::destroy(lending_market_id); sui::test_utils::destroy(usdc_reserve); @@ -744,7 +754,7 @@ module suilend::obligation_tests { deposit(&mut obligation, &mut sui_reserve, &clock, 100 * 1_000_000_000); borrow(&mut obligation, &mut usdc_reserve, &clock, 50 * 1_000_000); - withdraw(&mut obligation, &mut usdc_reserve, &clock, 1); + withdraw(&mut obligation, &mut usdc_reserve, &clock, 1, option::none()); sui::test_utils::destroy(lending_market_id); sui::test_utils::destroy(usdc_reserve); @@ -847,7 +857,13 @@ module suilend::obligation_tests { deposit(&mut obligation, &mut sui_reserve, &clock, 100 * 1_000_000_000); borrow(&mut obligation, &mut usdc_reserve, &clock, 20 * 1_000_000); - withdraw(&mut obligation, &mut sui_reserve, &clock, 20 * 1_000_000_000); + withdraw( + &mut obligation, + &mut sui_reserve, + &clock, + 20 * 1_000_000_000, + option::none(), + ); assert!(obligation.deposits().length() == 1, 0); @@ -1132,7 +1148,7 @@ module suilend::obligation_tests { } #[test] - #[expected_failure(abort_code = 0, location = reserve)] // price stale + #[expected_failure(abort_code = suilend::obligation::EOraclesAreStale)] public fun test_refresh_fail_deposit_price_stale() { let owner = @0x26; let mut scenario = test_scenario::begin(owner); @@ -1155,11 +1171,12 @@ module suilend::obligation_tests { clock::set_for_testing(&mut clock, 1000); - refresh( + let exist_stale_oracles = refresh( &mut obligation, &mut reserves, &clock, ); + obligation::assert_no_stale_oracles(exist_stale_oracles); sui::test_utils::destroy(reserves); sui::test_utils::destroy(lending_market_id); @@ -1169,7 +1186,7 @@ module suilend::obligation_tests { } #[test] - #[expected_failure(abort_code = 0, location = reserve)] // price stale + #[expected_failure(abort_code = suilend::obligation::EOraclesAreStale)] public fun test_refresh_fail_borrow_price_stale() { use sui::test_utils::{Self}; @@ -1206,11 +1223,12 @@ module suilend::obligation_tests { decimal::from(10), ); - refresh( + let exist_stale_oracles = refresh( &mut obligation, &mut reserves, &clock, ); + obligation::assert_no_stale_oracles(exist_stale_oracles); test_utils::destroy(reserves); sui::test_utils::destroy(lending_market_id); @@ -1274,11 +1292,12 @@ module suilend::obligation_tests { decimal::from(2), ); - refresh( + let exist_stale_oracles = refresh( &mut obligation, &mut reserves, &clock, ); + obligation::assert_no_stale_oracles(exist_stale_oracles); assert!(obligation.deposits().length() == 2, 0); @@ -1341,11 +1360,13 @@ module suilend::obligation_tests { 100 * 1_000_000, ); - refresh( + let exist_stale_oracles = refresh( &mut obligation, &mut reserves, &clock, ); + obligation::assert_no_stale_oracles(exist_stale_oracles); + liquidate( &mut obligation, &mut reserves, @@ -1413,11 +1434,13 @@ module suilend::obligation_tests { config, ); - refresh( + let exist_stale_oracles = refresh( &mut obligation, &mut reserves, &clock, ); + obligation::assert_no_stale_oracles(exist_stale_oracles); + let (withdraw_amount, repay_amount) = liquidate( &mut obligation, &mut reserves, @@ -1537,11 +1560,12 @@ module suilend::obligation_tests { }; reserve::update_reserve_config(sui_reserve, config); - refresh( + let exist_stale_oracles = refresh( &mut obligation, &mut reserves, &clock, ); + obligation::assert_no_stale_oracles(exist_stale_oracles); let (withdraw_amount, repay_amount) = liquidate( &mut obligation, @@ -1640,11 +1664,13 @@ module suilend::obligation_tests { config, ); - refresh( + let exist_stale_oracles = refresh( &mut obligation, &mut reserves, &clock, ); + obligation::assert_no_stale_oracles(exist_stale_oracles); + let (withdraw_amount, repay_amount) = liquidate( &mut obligation, &mut reserves, @@ -1765,11 +1791,12 @@ module suilend::obligation_tests { }; reserve::update_reserve_config(sui_reserve, config); - refresh( + let exist_stale_oracles = refresh( &mut obligation, &mut reserves, &clock, ); + obligation::assert_no_stale_oracles(exist_stale_oracles); let (withdraw_amount, repay_amount) = liquidate( &mut obligation,