diff --git a/common/src/borrow.rs b/common/src/borrow.rs index 933cbc6b..fd359b58 100644 --- a/common/src/borrow.rs +++ b/common/src/borrow.rs @@ -378,6 +378,8 @@ impl<'a> BorrowPositionGuard<'a> { .increase_collateral_asset_deposit(amount) .unwrap_or_else(|| env::panic_str("Borrow position collateral asset overflow")); + asset_op!(self.market.collateral_asset_deposited += amount); + MarketEvent::CollateralDeposited { account_id: self.account_id.clone(), collateral_asset_amount: amount, @@ -394,6 +396,8 @@ impl<'a> BorrowPositionGuard<'a> { .decrease_collateral_asset_deposit(amount) .unwrap_or_else(|| env::panic_str("Borrow position collateral asset underflow")); + asset_op!(self.market.collateral_asset_deposited -= amount); + MarketEvent::CollateralWithdrawn { account_id: self.account_id.clone(), collateral_asset_amount: amount, diff --git a/common/src/market/impl.rs b/common/src/market/impl.rs index cbd4d275..b2134740 100644 --- a/common/src/market/impl.rs +++ b/common/src/market/impl.rs @@ -6,12 +6,12 @@ use near_sdk::{ }; use crate::{ - asset::BorrowAssetAmount, + asset::{BorrowAssetAmount, CollateralAssetAmount}, asset_op, borrow::{BorrowPosition, BorrowPositionGuard, BorrowPositionRef}, chunked_append_only_list::ChunkedAppendOnlyList, event::MarketEvent, - market::MarketConfiguration, + market::{MarketConfiguration, WithdrawalResolution}, number::Decimal, snapshot::Snapshot, static_yield::StaticYieldRecord, @@ -19,8 +19,6 @@ use crate::{ withdrawal_queue::{error::WithdrawalQueueLockError, WithdrawalQueue}, }; -use super::WithdrawalResolution; - #[derive(BorshStorageKey)] #[near] enum StorageKey { @@ -35,10 +33,20 @@ enum StorageKey { pub struct Market { prefix: Vec, pub configuration: MarketConfiguration, + /// Total amount of borrow asset earning interest in the market. pub borrow_asset_deposited_active: BorrowAssetAmount, + /// Mapping of upcoming snapshot indices to amounts of borrow asset that will be activated. pub borrow_asset_deposited_incoming: HashMap, + /// Sending borrow asset out, because if somebody sends the contract borrow asset, it's ok for the + /// contract to attempt to fulfill withdrawal request, even if the market thinks it doesn't have + /// enough to fulfill. pub borrow_asset_in_flight: BorrowAssetAmount, + /// Amount of borrow asset that has been withdrawn (is in use by) by borrowers. + /// + /// `borrow_asset_deposited_active - borrow_asset_borrowed >= 0` should always be true. pub borrow_asset_borrowed: BorrowAssetAmount, + /// Market-wide collateral asset deposit tracking. + pub collateral_asset_deposited: CollateralAssetAmount, pub(crate) supply_positions: UnorderedMap, pub(crate) borrow_positions: UnorderedMap, pub current_snapshot: Snapshot, @@ -66,7 +74,7 @@ impl Market { let first_snapshot = Snapshot::new(configuration.time_chunk_configuration.previous()); let mut current_snapshot = first_snapshot.clone(); - current_snapshot.time_chunk = configuration.time_chunk_configuration.now(); + current_snapshot.set_time_chunk(configuration.time_chunk_configuration.now()); let mut self_ = Self { prefix: prefix.clone(), @@ -75,6 +83,7 @@ impl Market { borrow_asset_deposited_incoming: HashMap::new(), borrow_asset_in_flight: 0.into(), borrow_asset_borrowed: 0.into(), + collateral_asset_deposited: 0.into(), supply_positions: UnorderedMap::new(key!(SupplyPositions)), borrow_positions: UnorderedMap::new(key!(BorrowPositions)), current_snapshot, @@ -117,13 +126,16 @@ impl Market { self.current_snapshot.update_active( self.borrow_asset_deposited_active, self.borrow_asset_borrowed, + self.collateral_asset_deposited, &self.configuration.borrow_interest_rate_strategy, ); self.current_snapshot.add_yield(yield_distribution); - self.current_snapshot.deposited_incoming = *self - .borrow_asset_deposited_incoming - .get(&self.finalized_snapshots.len()) - .unwrap_or(&0.into()); + self.current_snapshot.set_borrow_asset_deposited_incoming( + *self + .borrow_asset_deposited_incoming + .get(&self.finalized_snapshots.len()) + .unwrap_or(&0.into()), + ); } else { // Otherwise, finalize the current snapshot and create a new one. let deposited_incoming = self @@ -132,11 +144,12 @@ impl Market { .unwrap_or(0.into()); asset_op!(self.borrow_asset_deposited_active += deposited_incoming); let mut snapshot = Snapshot::new(time_chunk); - snapshot.yield_distribution = yield_distribution; - snapshot.deposited_incoming = deposited_incoming; + snapshot.set_yield_distribution(yield_distribution); + snapshot.set_borrow_asset_deposited_incoming(deposited_incoming); snapshot.update_active( self.borrow_asset_deposited_active, self.borrow_asset_borrowed, + self.collateral_asset_deposited, &self.configuration.borrow_interest_rate_strategy, ); std::mem::swap(&mut snapshot, &mut self.current_snapshot); diff --git a/common/src/snapshot.rs b/common/src/snapshot.rs index 2b9fc044..37aa6fa7 100644 --- a/common/src/snapshot.rs +++ b/common/src/snapshot.rs @@ -1,19 +1,23 @@ use near_sdk::{env, json_types::U64, near}; use crate::{ - asset::BorrowAssetAmount, asset_op, interest_rate_strategy::InterestRateStrategy, - number::Decimal, time_chunk::TimeChunk, + asset::{BorrowAssetAmount, CollateralAssetAmount}, + asset_op, + interest_rate_strategy::InterestRateStrategy, + number::Decimal, + time_chunk::TimeChunk, }; #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] #[near(serializers = [borsh, json])] pub struct Snapshot { - pub time_chunk: TimeChunk, - pub end_timestamp_ms: U64, - deposited_active: BorrowAssetAmount, - pub deposited_incoming: BorrowAssetAmount, - borrowed: BorrowAssetAmount, - pub yield_distribution: BorrowAssetAmount, + pub(crate) time_chunk: TimeChunk, + pub(crate) end_timestamp_ms: U64, + pub(crate) borrow_asset_deposited_active: BorrowAssetAmount, + borrow_asset_deposited_incoming: BorrowAssetAmount, + borrow_asset_borrowed: BorrowAssetAmount, + collateral_asset_deposited: CollateralAssetAmount, + yield_distribution: BorrowAssetAmount, interest_rate: Decimal, } @@ -21,10 +25,11 @@ impl Snapshot { pub fn new(time_chunk: TimeChunk) -> Self { Self { time_chunk, - end_timestamp_ms: near_sdk::env::block_timestamp_ms().into(), - deposited_active: 0.into(), - deposited_incoming: 0.into(), - borrowed: 0.into(), + end_timestamp_ms: env::block_timestamp_ms().into(), + borrow_asset_deposited_active: 0.into(), + borrow_asset_deposited_incoming: 0.into(), + borrow_asset_borrowed: 0.into(), + collateral_asset_deposited: 0.into(), yield_distribution: BorrowAssetAmount::zero(), interest_rate: Decimal::ZERO, } @@ -36,35 +41,70 @@ impl Snapshot { pub fn update_active( &mut self, - deposited_active: BorrowAssetAmount, - borrowed: BorrowAssetAmount, + borrow_asset_deposited_active: BorrowAssetAmount, + borrow_asset_borrowed: BorrowAssetAmount, + collateral_asset_deposited: CollateralAssetAmount, interest_rate_strategy: &InterestRateStrategy, ) { self.end_timestamp_ms = env::block_timestamp_ms().into(); - self.deposited_active = deposited_active; - self.borrowed = borrowed; + self.borrow_asset_deposited_active = borrow_asset_deposited_active; + self.borrow_asset_borrowed = borrow_asset_borrowed; + self.collateral_asset_deposited = collateral_asset_deposited; self.interest_rate = interest_rate_strategy.at(self.usage_ratio()); } pub fn usage_ratio(&self) -> Decimal { - if self.deposited_active.is_zero() || self.borrowed.is_zero() { + if self.borrow_asset_deposited_active.is_zero() || self.borrow_asset_borrowed.is_zero() { Decimal::ZERO - } else if self.borrowed >= self.deposited_active { + } else if self.borrow_asset_borrowed >= self.borrow_asset_deposited_active { Decimal::ONE } else { - Decimal::from(self.borrowed) / Decimal::from(self.deposited_active) + Decimal::from(self.borrow_asset_borrowed) + / Decimal::from(self.borrow_asset_deposited_active) } } + pub fn set_time_chunk(&mut self, time_chunk: TimeChunk) { + self.time_chunk = time_chunk; + } + + pub fn set_borrow_asset_deposited_incoming(&mut self, amount: BorrowAssetAmount) { + self.borrow_asset_deposited_incoming = amount; + } + + pub fn set_yield_distribution(&mut self, amount: BorrowAssetAmount) { + self.yield_distribution = amount; + } + + pub fn time_chunk(&self) -> &TimeChunk { + &self.time_chunk + } + + pub fn end_timestamp_ms(&self) -> U64 { + self.end_timestamp_ms + } + + pub fn borrow_asset_deposited_incoming(&self) -> BorrowAssetAmount { + self.borrow_asset_deposited_incoming + } + + pub fn collateral_asset_deposited(&self) -> CollateralAssetAmount { + self.collateral_asset_deposited + } + + pub fn yield_distribution(&self) -> BorrowAssetAmount { + self.yield_distribution + } + pub fn interest_rate(&self) -> Decimal { self.interest_rate } - pub fn deposited_active(&self) -> BorrowAssetAmount { - self.deposited_active + pub fn borrow_asset_deposited_active(&self) -> BorrowAssetAmount { + self.borrow_asset_deposited_active } - pub fn borrowed(&self) -> BorrowAssetAmount { - self.borrowed + pub fn borrow_asset_borrowed(&self) -> BorrowAssetAmount { + self.borrow_asset_borrowed } } diff --git a/common/src/supply.rs b/common/src/supply.rs index 1321d0fb..380d2307 100644 --- a/common/src/supply.rs +++ b/common/src/supply.rs @@ -162,9 +162,9 @@ impl> SupplyPositionRef { amount += u128::from(incoming.amount); } - if !snapshot.deposited_active().is_zero() { - accumulated += amount * Decimal::from(snapshot.yield_distribution) - / Decimal::from(snapshot.deposited_active()); + if !snapshot.borrow_asset_deposited_active.is_zero() { + accumulated += amount * Decimal::from(snapshot.yield_distribution()) + / Decimal::from(snapshot.borrow_asset_deposited_active); } next_snapshot_index = i as u32 + 1; diff --git a/contract/market/src/impl_market_external.rs b/contract/market/src/impl_market_external.rs index d1222d93..9a364ed0 100644 --- a/contract/market/src/impl_market_external.rs +++ b/contract/market/src/impl_market_external.rs @@ -292,11 +292,11 @@ impl MarketExternalInterface for Contract { } fn get_last_yield_rate(&self) -> Decimal { - let deposited: Decimal = self.current_snapshot.deposited_active().into(); + let deposited: Decimal = self.current_snapshot.borrow_asset_deposited_active().into(); if deposited.is_zero() { return Decimal::ZERO; } - let borrowed: Decimal = self.current_snapshot.borrowed().into(); + let borrowed: Decimal = self.current_snapshot.borrow_asset_borrowed().into(); let supply_weight: Decimal = self.configuration.yield_weights.supply.get().into(); let total_weight: Decimal = self.configuration.yield_weights.total_weight().get().into(); diff --git a/contract/market/tests/happy_path.rs b/contract/market/tests/happy_path.rs index e4957ee1..9b1ed588 100644 --- a/contract/market/tests/happy_path.rs +++ b/contract/market/tests/happy_path.rs @@ -81,9 +81,9 @@ async fn test_happy(#[case] borrow_mt: bool, #[case] collateral_mt: bool) { let snapshots = c.list_finalized_snapshots(None, None).await; assert_eq!(snapshots.len(), 1); - assert!(snapshots[0].yield_distribution.is_zero()); - assert!(snapshots[0].deposited_active().is_zero()); - assert!(snapshots[0].borrowed().is_zero()); + assert!(snapshots[0].yield_distribution().is_zero()); + assert!(snapshots[0].borrow_asset_deposited_active().is_zero()); + assert!(snapshots[0].borrow_asset_borrowed().is_zero()); // Step 1: Supply user sends tokens to contract to use for borrows. c.supply(&supply_user, 1100).await; diff --git a/contract/market/tests/snapshot.rs b/contract/market/tests/snapshot.rs new file mode 100644 index 00000000..b8b0e204 --- /dev/null +++ b/contract/market/tests/snapshot.rs @@ -0,0 +1,582 @@ +use std::time::Duration; +use templar_common::{ + dec, fee::Fee, interest_rate_strategy::InterestRateStrategy, time_chunk::TimeChunkConfiguration, +}; +use test_utils::*; + +#[tokio::test] +async fn snapshot_captures_borrow_and_collateral_state() { + setup_test!( + extract(c) + accounts(borrow_user, supply_user) + config(|c| { + c.borrow_origination_fee = Fee::zero(); + c.time_chunk_configuration = TimeChunkConfiguration::BlockTimestampMs { + divisor: 500.into(), // 0.5 seconds + }; + }) + ); + + // Setup liquidity + c.supply_and_harvest_until_activation(&supply_user, 2_000_000) + .await; + + let initial_snapshots_len = c.get_finalized_snapshots_len().await; + + // Perform operations within the same time chunk + c.collateralize(&borrow_user, 1_000_000).await; + c.borrow(&borrow_user, 500_000).await; + + // Wait for snapshot to finalize + tokio::time::sleep(Duration::from_secs(1)).await; + + // Trigger something to ensure snapshot finalization + c.collateralize(&borrow_user, 1).await; + // Snapshot updating occurs before collateral deposit is recorded, so do + // it 2x so we can see 1 (from the preceding call) in the current snapshot. + c.collateralize(&borrow_user, 1).await; + + let final_snapshots_len = c.get_finalized_snapshots_len().await; + + assert!( + final_snapshots_len > initial_snapshots_len, + "Should have created a new finalized snapshot" + ); + + // Get the latest snapshot + let snapshots = c + .list_finalized_snapshots(Some(final_snapshots_len - 1), Some(1)) + .await; + let latest_snapshot = &snapshots[0]; + + eprintln!("Latest snapshot: {latest_snapshot:#?}"); + + // Verify snapshot captured the state correctly + assert_eq!( + u128::from(latest_snapshot.collateral_asset_deposited()), + 1_000_000, // Additional 1 in the next (current) snapshot + "Snapshot should show collateral was deposited", + ); + + assert_eq!( + u128::from(latest_snapshot.borrow_asset_borrowed()), + 500_000, + "Snapshot should show assets were borrowed", + ); + + let current_snapshot = c.market.get_current_snapshot().await; + + assert_eq!( + u128::from(current_snapshot.collateral_asset_deposited()), + 1_000_001, + ); + assert_eq!( + u128::from(current_snapshot.borrow_asset_borrowed()), + 500_000, + ); +} + +#[tokio::test] +async fn multiple_snapshots_show_progression() { + setup_test!( + extract(c) + accounts(user, supply_user) + config(|c| { + c.borrow_origination_fee = Fee::zero(); + c.time_chunk_configuration = TimeChunkConfiguration::BlockTimestampMs { + divisor: 1000.into(), + }; + }) + ); + + c.supply_and_harvest_until_activation(&supply_user, 3_000_000) + .await; + + let initial_snapshots_len = c.get_finalized_snapshots_len().await; + + // First period: collateralize + c.collateralize(&user, 1_000_000).await; + tokio::time::sleep(Duration::from_secs(2)).await; + + // Second period: borrow + c.borrow(&user, 400_000).await; + tokio::time::sleep(Duration::from_secs(2)).await; + + // Third period: more borrowing + c.borrow(&user, 200_000).await; + tokio::time::sleep(Duration::from_secs(2)).await; + + // Create snapshot + c.apply_interest(&user, None, None).await; + + let final_snapshots_len = c.get_finalized_snapshots_len().await; + let new_snapshots_count = final_snapshots_len - initial_snapshots_len; + + assert!( + new_snapshots_count >= 3, + "Should have created at least 3 new snapshots, got {new_snapshots_count}", + ); + + // Get the snapshots + let snapshots = c + .list_finalized_snapshots(Some(initial_snapshots_len), None) + .await; + + eprintln!("Snapshots progression:"); + for (i, snapshot) in snapshots.iter().enumerate() { + eprintln!( + "Snapshot {}: collateral={:?}, borrowed={:?}", + i, + u128::from(snapshot.collateral_asset_deposited()), + u128::from(snapshot.borrow_asset_borrowed()) + ); + } + + // Expected progression states - but allow for different ordering due to timing + let expected_states = [ + (0.into(), 0.into()), + (1_000_000.into(), 0.into()), + (1_000_000.into(), 400_000.into()), + (1_000_000.into(), 600_000.into()), + ]; + + // Verify that we see the expected progression somewhere in the snapshots + let mut found_states = vec![false; expected_states.len()]; + + for snapshot in &snapshots { + let current_state = ( + snapshot.collateral_asset_deposited(), + snapshot.borrow_asset_borrowed(), + ); + + for (i, expected_state) in expected_states.iter().enumerate() { + if current_state == *expected_state { + found_states[i] = true; + eprintln!("Found expected state {i}: {expected_state:?}"); + } + } + } + + // Should find at least the final state and some intermediate states + assert!( + found_states[found_states.len() - 1], // Final state + "Should find final state (1M collateral, 600k borrowed)" + ); + + let found_count = found_states.iter().filter(|&&x| x).count(); + assert!( + found_count >= 2, + "Should find at least 2 expected states in progression, found {found_count}", + ); +} + +#[tokio::test] +async fn snapshot_reflects_repayment_changes() { + setup_test!( + extract(c) + accounts(borrow_user, supply_user) + config(|c| { + c.borrow_interest_rate_strategy = InterestRateStrategy::zero(); + c.borrow_origination_fee = Fee::zero(); + c.time_chunk_configuration = TimeChunkConfiguration::BlockTimestampMs { + divisor: 500.into(), + }; + }) + ); + + c.supply_and_harvest_until_activation(&supply_user, 2_000_000) + .await; + c.collateralize(&borrow_user, 1_000_000).await; + c.borrow(&borrow_user, 500_000).await; + + // Wait and trigger first snapshot (with borrowed amount) + tokio::time::sleep(Duration::from_secs(1)).await; + c.collateralize(&borrow_user, 1).await; + + let snapshots_after_borrow = c.get_finalized_snapshots_len().await; + + // Repay half + c.repay(&borrow_user, 250_000).await; + + // Wait and trigger second snapshot (after partial repayment) + tokio::time::sleep(Duration::from_secs(1)).await; + c.collateralize(&borrow_user, 1).await; + + let snapshots_after_repay = c.get_finalized_snapshots_len().await; + + assert!( + snapshots_after_repay > snapshots_after_borrow, + "Should have created snapshot after repayment" + ); + + // Compare the two snapshots + let all_snapshots = c.list_finalized_snapshots(None, None).await; + let borrow_snapshot = &all_snapshots[snapshots_after_borrow as usize - 1]; + let repay_snapshot = &all_snapshots[snapshots_after_repay as usize - 1]; + + let amount_after_borrow = u128::from(borrow_snapshot.borrow_asset_borrowed()); + let amount_after_repay = u128::from(repay_snapshot.borrow_asset_borrowed()); + + eprintln!("After borrow: borrowed={amount_after_borrow}"); + eprintln!("After repay: borrowed={amount_after_repay}"); + + assert_eq!( + amount_after_borrow, + amount_after_repay * 2, + "Snapshots should reflect different borrowed states", + ); +} + +#[tokio::test] +async fn snapshot_handles_zero_operations() { + setup_test!( + extract(c) + accounts(supply_user) + config(|c| { + c.time_chunk_configuration = TimeChunkConfiguration::BlockTimestampMs { + divisor: 500.into(), // 0.5 seconds + }; + }) + ); + + // Setup initial state + c.supply_and_harvest_until_activation(&supply_user, 1_000_000) + .await; + + let initial_snapshots_len = c.get_finalized_snapshots_len().await; + + // Wait for time chunk to expire with no operations + tokio::time::sleep(Duration::from_secs(1)).await; + + // Trigger snapshot with minimal operation + c.supply_and_harvest_until_activation(&supply_user, 1).await; + + let final_snapshots_len = c.get_finalized_snapshots_len().await; + + eprintln!("Snapshots before: {initial_snapshots_len}, after: {final_snapshots_len}"); + + // Verify behavior when no meaningful operations occur + assert!(final_snapshots_len > initial_snapshots_len); + + let snapshots = c + .list_finalized_snapshots(Some(final_snapshots_len - 1), Some(1)) + .await; + let latest_snapshot = &snapshots[0]; + eprintln!("Empty period snapshot: {latest_snapshot:#?}"); + + // Should still have a valid snapshot even with minimal activity + assert_eq!( + latest_snapshot.borrow_asset_deposited_active(), + 1_000_000.into(), + "Should maintain previous active deposits", + ); +} + +#[tokio::test] +async fn snapshot_with_full_repayment() { + setup_test!( + extract(c) + accounts(borrow_user, supply_user) + config(|c| { + c.borrow_interest_rate_strategy = + InterestRateStrategy::linear(dec!("1000"), dec!("1000")).unwrap(); + c.borrow_origination_fee = Fee::zero(); + c.time_chunk_configuration = TimeChunkConfiguration::BlockTimestampMs { + divisor: 500.into(), + }; + }) + ); + + tokio::join!( + c.supply_and_harvest_until_activation(&supply_user, 2_000_000), + async { + c.collateralize(&borrow_user, 1_000_000).await; + c.borrow(&borrow_user, 500_000).await; + }, + ); + + // Create snapshot with borrowed amount + tokio::time::sleep(Duration::from_secs(1)).await; + c.collateralize(&borrow_user, 1).await; + + let borrow_position = c.get_borrow_position(borrow_user.id()).await.unwrap(); + let total_liability = u128::from(borrow_position.get_total_borrow_asset_liability()); + + eprintln!("Total liability before repayment: {total_liability:?}"); + + // Repay everything (including any accrued interest) + c.repay(&borrow_user, total_liability).await; + + // Create snapshot after full repayment + tokio::time::sleep(Duration::from_secs(1)).await; + c.collateralize(&borrow_user, 1).await; + + let final_snapshots_len = c.get_finalized_snapshots_len().await; + let snapshots = c + .list_finalized_snapshots(Some(final_snapshots_len - 1), Some(1)) + .await; + let final_snapshot = &snapshots[0]; + + eprintln!( + "After full repayment: borrowed={:?}", + final_snapshot.borrow_asset_borrowed() + ); + + let final_position = c.get_borrow_position(borrow_user.id()).await.unwrap(); + eprintln!( + "Final position liability: {:?}", + final_position.get_total_borrow_asset_liability() + ); + + // Verify snapshot reflects full repayment + assert!( + final_snapshot.borrow_asset_borrowed() <= 1000.into(), // Allow for small rounding + "Snapshot should show minimal borrowed amount after full repayment" + ); +} + +#[tokio::test] +async fn snapshot_field_validation() { + setup_test!( + extract(c) + accounts(borrow_user, supply_user) + config(|c| { + c.borrow_interest_rate_strategy = + InterestRateStrategy::linear(dec!("2000"), dec!("3000")).unwrap(); // Higher rates for testing + c.borrow_origination_fee = Fee::zero(); + c.time_chunk_configuration = TimeChunkConfiguration::BlockTimestampMs { + divisor: 500.into(), + }; + }) + ); + + let initial_snapshots_len = c.get_finalized_snapshots_len().await; + + // Step 1: Supply (affects borrow_asset_deposited fields) + c.supply_and_harvest_until_activation(&supply_user, 1_500_000) + .await; + tokio::time::sleep(Duration::from_secs(1)).await; + c.collateralize(&borrow_user, 1).await; + + // Step 2: Collateralize (affects collateral_asset_deposited) + c.collateralize(&borrow_user, 800_000).await; + tokio::time::sleep(Duration::from_secs(1)).await; + c.collateralize(&borrow_user, 1).await; + + // Step 3: Borrow (affects borrow_asset_borrowed, interest_rate) + c.borrow(&borrow_user, 400_000).await; + tokio::time::sleep(Duration::from_secs(1)).await; + c.collateralize(&borrow_user, 1).await; + + // Step 4: Let interest accrue + tokio::time::sleep(Duration::from_secs(2)).await; + c.collateralize(&borrow_user, 1).await; + + let final_snapshots_len = c.get_finalized_snapshots_len().await; + let snapshots_count = final_snapshots_len - initial_snapshots_len; + + eprintln!("Created {snapshots_count} snapshots"); + + assert!(snapshots_count >= 3); + + let recent_snapshots = c + .list_finalized_snapshots(Some(final_snapshots_len - 3), Some(3)) + .await; + + for (i, snapshot) in recent_snapshots.iter().enumerate() { + eprintln!("Snapshot {i}: "); + eprintln!("{snapshot:?}"); + eprintln!(); + } + + let first = &recent_snapshots[0]; + let last = &recent_snapshots[recent_snapshots.len() - 1]; + + // Validate field progressions + assert!( + last.collateral_asset_deposited() >= first.collateral_asset_deposited(), + "Collateral should not decrease", + ); + + assert!( + last.borrow_asset_borrowed() >= first.borrow_asset_borrowed(), + "Borrowed amount should increase with interest", + ); + + // Timestamps should be increasing + assert!( + last.end_timestamp_ms() > first.end_timestamp_ms(), + "Timestamps should increase", + ); + + // Interest rate should reflect utilization + assert!( + !last.interest_rate().is_zero(), + "Interest rate should be positive with borrowing activity", + ); +} + +#[tokio::test] +async fn snapshot_at_time_boundaries() { + setup_test!( + extract(c) + accounts(user1, user2, supply_user) + config(|c| { + c.borrow_interest_rate_strategy = + InterestRateStrategy::linear(dec!("0"), dec!("0")).unwrap(); + c.borrow_origination_fee = Fee::zero(); + c.time_chunk_configuration = TimeChunkConfiguration::BlockTimestampMs { + divisor: 5000.into(), // 5 second chunks + }; + }) + ); + + c.supply_and_harvest_until_activation(&supply_user, 3_000_000) + .await; + + let initial_snapshots_len = c.get_finalized_snapshots_len().await; + + // Operations right at boundary + c.collateralize(&user1, 500_000).await; + c.collateralize(&user2, 300_000).await; + + // Wait almost to boundary + tokio::time::sleep(Duration::from_secs(6)).await; + + // Trigger snapshot for first chunk + c.borrow(&user1, 1).await; // Small operation to trigger snapshot + + let after_first_boundary_len = c.get_finalized_snapshots_len().await; + + // Multiple operations in quick succession near boundary + c.borrow(&user1, 200_000).await; + c.borrow(&user2, 100_000).await; + + // Wait to cross another boundary and trigger snapshot + tokio::time::sleep(Duration::from_secs(6)).await; + c.collateralize(&user1, 1).await; // Trigger snapshot finalization + + let final_snapshots_len = c.get_finalized_snapshots_len().await; + + eprintln!("Snapshot indices: {initial_snapshots_len} -> {after_first_boundary_len} -> {final_snapshots_len}"); + + assert!( + final_snapshots_len > initial_snapshots_len, + "Should create snapshot at time boundary" + ); + + // Get the last two snapshots to compare across boundaries + if final_snapshots_len >= 2 { + let snapshots = c + .list_finalized_snapshots(Some(final_snapshots_len - 2), Some(2)) + .await; + + if snapshots.len() >= 2 { + let first_boundary_snapshot = &snapshots[0]; + let second_boundary_snapshot = &snapshots[1]; + + eprintln!("First boundary snapshot: {first_boundary_snapshot:#?}"); + eprintln!("Second boundary snapshot: {second_boundary_snapshot:#?}"); + + // First snapshot should have collateral but no borrowing + assert_eq!( + first_boundary_snapshot.collateral_asset_deposited(), + 500_000.into(), // 500k + 300k + "First snapshot should capture collateral operations" + ); + + assert_eq!( + first_boundary_snapshot.borrow_asset_borrowed(), + 0.into(), + "First snapshot should have no borrowing yet" + ); + + // Second snapshot should have both collateral and borrowing + assert_eq!( + second_boundary_snapshot.collateral_asset_deposited(), + 800_000.into(), // Previous + 1 from trigger + "Second snapshot should maintain collateral" + ); + + assert_eq!( + second_boundary_snapshot.borrow_asset_borrowed(), + 300_001.into(), // 200k + 100k + "Second snapshot should capture borrow operations" + ); + } + } +} + +#[tokio::test] +async fn many_users_same_snapshot() { + setup_test!( + extract(c) + accounts(user1, user2, user3, user4, user5, supply_user1, supply_user2) + config(|c| { + c.borrow_interest_rate_strategy = + InterestRateStrategy::linear(dec!("1000"), dec!("1000")).unwrap(); + c.borrow_origination_fee = Fee::zero(); + c.time_chunk_configuration = TimeChunkConfiguration::BlockTimestampMs { + divisor: 500.into(), + }; + }) + ); + + // Multiple suppliers + c.supply_and_harvest_until_activation(&supply_user1, 2_000_000) + .await; + c.supply_and_harvest_until_activation(&supply_user2, 1_500_000) + .await; + + // Many users doing operations in same time chunk + let collateral_amounts = [400_000, 350_000, 300_000, 250_000, 200_000]; + let borrow_amounts = [150_000, 120_000, 100_000, 80_000, 60_000]; + + // All collateral operations + c.collateralize(&user1, collateral_amounts[0]).await; + c.collateralize(&user2, collateral_amounts[1]).await; + c.collateralize(&user3, collateral_amounts[2]).await; + c.collateralize(&user4, collateral_amounts[3]).await; + c.collateralize(&user5, collateral_amounts[4]).await; + + // All borrow operations + c.borrow(&user1, borrow_amounts[0]).await; + c.borrow(&user2, borrow_amounts[1]).await; + c.borrow(&user3, borrow_amounts[2]).await; + c.borrow(&user4, borrow_amounts[3]).await; + c.borrow(&user5, borrow_amounts[4]).await; + + // Wait and trigger snapshot + tokio::time::sleep(Duration::from_secs(1)).await; + c.harvest_yield(&supply_user1, None, None).await; + + let final_snapshots_len = c.get_finalized_snapshots_len().await; + let snapshots = c + .list_finalized_snapshots(Some(final_snapshots_len - 1), Some(1)) + .await; + let multi_user_snapshot = &snapshots[0]; + + let total_expected_collateral: u128 = collateral_amounts.iter().sum(); + let total_expected_borrow: u128 = borrow_amounts.iter().sum(); + + eprintln!("Multi-user snapshot: {multi_user_snapshot:#?}"); + eprintln!("Expected collateral total: {total_expected_collateral}"); + eprintln!("Expected borrow total: {total_expected_borrow}"); + + // Verify aggregate amounts are correct + assert!( + multi_user_snapshot.collateral_asset_deposited() >= total_expected_collateral.into(), + "Should aggregate all collateral from multiple users" + ); + + assert!( + multi_user_snapshot.borrow_asset_borrowed() >= total_expected_borrow.into(), + "Should aggregate all borrows from multiple users" + ); + + // Verify we have reasonable supply amounts from multiple suppliers + assert!( + multi_user_snapshot.borrow_asset_deposited_active() >= 3_500_000.into(), + "Should show combined supply from multiple suppliers" + ); +} diff --git a/test-utils/src/controller/market.rs b/test-utils/src/controller/market.rs index 478eac8f..b523831a 100644 --- a/test-utils/src/controller/market.rs +++ b/test-utils/src/controller/market.rs @@ -69,6 +69,7 @@ impl MarketController { #[view] pub fn get_configuration() -> MarketConfiguration; #[view] pub fn get_finalized_snapshots_len() -> u32; #[view] pub fn list_finalized_snapshots(offset: Option, count: Option) -> Vec; + #[view] pub fn get_current_snapshot() -> Snapshot; #[view] pub fn list_supply_positions(offset: Option, count: Option) -> HashMap; #[view] pub fn get_supply_position(account_id: &AccountId) -> Option; #[view] pub fn list_borrow_positions(offset: Option, count: Option) -> HashMap; @@ -122,11 +123,14 @@ impl MarketController { eprintln!("Market snapshots:"); for (i, snapshot) in snapshots.iter().enumerate() { - eprintln!("\t{i}: {}", snapshot.time_chunk.0 .0); - eprintln!("\t\tTimestamp:\t{}", snapshot.end_timestamp_ms.0); - eprintln!("\t\tDeposited (active):\t{}", snapshot.deposited_active(),); - eprintln!("\t\tBorrowed:\t{}", snapshot.borrowed()); - eprintln!("\t\tDistribution:\t{}", snapshot.yield_distribution); + eprintln!("\t{i}: {}", snapshot.time_chunk().0 .0); + eprintln!("\t\tTimestamp:\t{}", snapshot.end_timestamp_ms().0); + eprintln!( + "\t\tDeposited (active):\t{}", + snapshot.borrow_asset_deposited_active(), + ); + eprintln!("\t\tBorrowed:\t{}", snapshot.borrow_asset_borrowed()); + eprintln!("\t\tDistribution:\t{}", snapshot.yield_distribution()); } } }