diff --git a/contracts/lending-contract/src/lib.rs b/contracts/lending-contract/src/lib.rs index a965077..994aa61 100644 --- a/contracts/lending-contract/src/lib.rs +++ b/contracts/lending-contract/src/lib.rs @@ -29,6 +29,16 @@ pub struct PoolState { pub bad_debt_reserve: u64, // Reserve bucket for bad debt coverage } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PerformanceData { + pub total_loans_issued: u64, + pub total_principal_borrowed: u64, + pub total_interest_earned: u64, + pub total_liquidations_count: u64, + pub total_collateral_seized: u64, +} + const SECONDS_IN_YEAR: u64 = 31_536_000; #[contracttype] @@ -185,6 +195,7 @@ pub enum DataKey { WhitelistedCollateral(Address), NFTToken, ReentrancyGuard, + Performance, } // ───────────────────────────────────────────────── @@ -231,6 +242,16 @@ impl LendingContract { bad_debt_reserve: 0, }, ); + env.storage().instance().set( + &DataKey::Performance, + &PerformanceData { + total_loans_issued: 0, + total_principal_borrowed: 0, + total_interest_earned: 0, + total_liquidations_count: 0, + total_collateral_seized: 0, + }, + ); Ok(()) } @@ -277,6 +298,23 @@ impl LendingContract { env.storage().instance().set(&DataKey::Pool, pool); } + fn get_performance(env: &Env) -> PerformanceData { + env.storage() + .instance() + .get(&DataKey::Performance) + .unwrap_or(PerformanceData { + total_loans_issued: 0, + total_principal_borrowed: 0, + total_interest_earned: 0, + total_liquidations_count: 0, + total_collateral_seized: 0, + }) + } + + fn set_performance(env: &Env, performance: &PerformanceData) { + env.storage().instance().set(&DataKey::Performance, performance); + } + fn get_shares(env: &Env, owner: &Address) -> u64 { env.storage() .persistent() @@ -604,6 +642,11 @@ impl LendingContract { Self::set_pool(&env, &pool); + let mut performance = Self::get_performance(&env); + performance.total_loans_issued += 1; + performance.total_principal_borrowed += amount; + Self::set_performance(&env, &performance); + let loan_id = Self::increment_loan_id(&env); let borrow_time = env.ledger().timestamp(); let due_date = borrow_time + duration_seconds; @@ -726,6 +769,10 @@ impl LendingContract { pool.bad_debt_reserve += reserve_share; Self::set_pool(&env, &pool); + let mut performance = Self::get_performance(&env); + performance.total_interest_earned += interest; + Self::set_performance(&env, &performance); + env.storage() .persistent() .remove(&DataKey::Loan(borrower.clone())); @@ -867,6 +914,12 @@ impl LendingContract { Ok(Self::get_pool(&env)) } + /// Returns the current cumulative performance data. + pub fn get_performance_data(env: Env) -> Result { + Self::require_initialized(&env)?; + Ok(Self::get_performance(&env)) + } + /// Returns the share balance of the given address. pub fn get_shares_of(env: Env, owner: Address) -> u64 { Self::get_shares(&env, &owner) @@ -999,6 +1052,11 @@ impl LendingContract { pool.total_deposits += amount; Self::set_pool(&env, &pool); + let mut performance = Self::get_performance(&env); + performance.total_liquidations_count += 1; + performance.total_collateral_seized += collateral_to_seize; + Self::set_performance(&env, &performance); + // Emit liquidation event env.events().publish( (symbol_short!("POOL"), symbol_short!("LIQUIDATE")), diff --git a/contracts/lending-contract/src/test.rs b/contracts/lending-contract/src/test.rs index cb71479..b7871f0 100644 --- a/contracts/lending-contract/src/test.rs +++ b/contracts/lending-contract/src/test.rs @@ -993,3 +993,147 @@ fn test_reentrancy_attack_fails() { let pool = client.get_pool_state(); assert_eq!(pool.total_borrowed, 1000); // Only the first borrow succeeded } + +#[test] +fn test_performance_tracking() { + let env = Env::default(); + env.mock_all_auths(); + let (client, token_addr, collateral_addr, _admin) = setup(&env); + + let depositor = Address::generate(&env); + let borrower = Address::generate(&env); + mint_to(&env, &token_addr, &depositor, 100_000); + mint_to(&env, &token_addr, &borrower, 100_000); + mint_to(&env, &collateral_addr, &borrower, 100_000); + + client.deposit(&depositor, &50_000u64); + + // 1. Initial performance should be zero + let perf = client.get_performance_data(); + assert_eq!(perf.total_loans_issued, 0); + assert_eq!(perf.total_principal_borrowed, 0); + assert_eq!(perf.total_interest_earned, 0); + + // 2. Borrow 10,000 + client.borrow( + &borrower, + &10_000u64, + &collateral_addr, + &15_000u64, + &31_536_000u64, + ); + + let perf = client.get_performance_data(); + assert_eq!(perf.total_loans_issued, 1); + assert_eq!(perf.total_principal_borrowed, 10_000); + + // 3. Advance time and repay + // Utilization = 10000 / 50000 = 20%. + // Rate = 5% + (20% * 20%) = 9% (900 bps) + // Interest = 10000 * 0.09 * 1 year = 900 + env.ledger() + .set_timestamp(env.ledger().timestamp() + 31_536_000); + + client.repay(&borrower); + + let perf = client.get_performance_data(); + assert_eq!(perf.total_interest_earned, 900); + assert_eq!(perf.total_loans_issued, 1); + assert_eq!(perf.total_principal_borrowed, 10_000); +} + +#[test] +fn test_liquidation_performance() { + let env = Env::default(); + env.mock_all_auths(); + let (client, token_addr, collateral_addr, _admin) = setup(&env); + + let depositor = Address::generate(&env); + let borrower = Address::generate(&env); + let liquidator = Address::generate(&env); + + mint_to(&env, &token_addr, &depositor, 100_000); + mint_to(&env, &token_addr, &liquidator, 100_000); + mint_to(&env, &collateral_addr, &borrower, 100_000); + + client.deposit(&depositor, &50_000u64); + + // Borrow 10,000 with 14,000 collateral (140% < 150% threshold) + // Wait, borrow requires 150% initially. Let's borrow 10,000 with 15,000. + client.borrow( + &borrower, + &10_000u64, + &collateral_addr, + &15_000u64, + &31_536_000u64, + ); + + // In this simplified contract, "health factor" is calculated as collateral / principal. + // If we repay some principal elsewhere or if the collateral value drops (not modeled here), + // we can trigger liquidation. + // However, our `liquidate` function has a hardcoded threshold: + // let health_factor = (loan.collateral_amount as u128).checked_mul(10000).and_then(|v| v.checked_div(loan.principal as u128)).unwrap_or(0) as u32; + // if health_factor >= 15000 { return Err(...); } + + // So we need to get the health factor below 150%. + // Since interest is NOT added to principal in this contract's `liquidate` check (it only checks `loan.principal`), + // the only way to lower health factor is if the principal increases or collateral decreases. + // Actually, `loan.principal` is static. + // Wait, the contract has a bug or a simplification: it doesn't accrue interest into the health factor check. + // But let's look at `liquidate`: + // if health_factor >= 15000 { return Err(LendingError::InvalidAmount); } + // 15000 / 10000 = 1.5 (150%). + // If I borrow 10,000 with 14,999 collateral, it should be liquidatable. + // But `borrow` requires 150%. + // I can bypass `borrow` check if I decrease collateral ratio or use a different borrow amount. + + // Let's check `liquidate` again. + // It says: `if health_factor >= liquidation_threshold_bps { return Err(LendingError::InvalidAmount); }` + // where `liquidation_threshold_bps = 15000`. + // My `setup` uses `15000` for `collateral_ratio_bps` in `initialize`. + // `borrow` checks: `if collateral_amount < required_collateral { return Err(LendingError::InsufficientCollateral); }` + // where `required_collateral = amount * 15000 / 10000`. + // So if I borrow 10,000, I MUST provide 15,000. + // If I provide EXACTLY 15,000, health factor is 15,000. `if 15000 >= 15000` is true, so I CAN'T liquidate. + + // I need to find a way to make it liquidatable. + // Maybe by increasing the interest? No, health factor only uses `loan.principal`. + // Is there a `set_collateral_ratio`? No. + // Wait, I can just borrow with a lower collateral if I'm admin? No, `borrow` doesn't check admin. + + // Ah! I can use `initialize` with a different threshold if I want, or just accept that I can't easily trigger it with the current `borrow` guard unless I change the contract. + // Wait, I am the developer! I can add a way to test this or just assume 150% is the LIMIT. + // If I borrow 10,000 with 15,000, and then I repay 1 token of debt... wait, that's not possible, only full repayment. + + // Let's look at `liquidate` again. + // If `health_factor` is EXACTLY 15000, it's NOT liquidatable. + // If I can get it to 14999, it IS. + // I'll borrow 1000 with 1500. Required: 1000 * 15000 / 10000 = 1500. + // Health factor: 1500 * 10000 / 1000 = 15000. Still not liquidatable. + + // Wait! The `RequiredCollateral` calculation is: + // `let required_collateral = (amount as u128).checked_mul(Self::get_collateral_ratio(&env) as u128).and_then(|v| v.checked_div(10000)).unwrap_or(0) as u64;` + // If I use an amount that rounds DOWN, I might get 14999. + // E.g. amount = 7, ratio = 15000. Required = 7 * 1.5 = 10.5 -> 10. + // Borrow 7 with 10 collateral. + // Health factor = 10 * 10000 / 7 = 14285. This IS liquidatable! + + let borrow_amount = 7u64; + let collat_amount = 11u64; // 7 * 1.5 = 10.5 -> 10 required. 11 is safe. + // Wait, if I use 10, health factor is 10000/7 * 10 = 14285. + client.borrow( + &borrower, + &7u64, + &collateral_addr, + &11u64, + &31_536_000u64, + ); + + // Liquidate 5 principal + // collateral_to_seize = 5 * 1.5 = 7.5 -> 7. + client.liquidate(&liquidator, &borrower, &5u64); + + let perf = client.get_performance_data(); + assert_eq!(perf.total_liquidations_count, 1); + assert_eq!(perf.total_collateral_seized, 7); +}