Skip to content
Open
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
58 changes: 58 additions & 0 deletions contracts/lending-contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -185,6 +195,7 @@ pub enum DataKey {
WhitelistedCollateral(Address),
NFTToken,
ReentrancyGuard,
Performance,
}

// ─────────────────────────────────────────────────
Expand Down Expand Up @@ -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(())
}

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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()));
Expand Down Expand Up @@ -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<PerformanceData, LendingError> {
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)
Expand Down Expand Up @@ -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")),
Expand Down
144 changes: 144 additions & 0 deletions contracts/lending-contract/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading