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
16 changes: 16 additions & 0 deletions contracts/loan_manager/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,19 @@ pub fn collateral_liquidated(env: &Env, loan_id: u32, amount: i128) {
let topics = (Symbol::new(env, "CollateralLiquidated"), loan_id);
env.events().publish(topics, amount);
}

pub fn loan_liquidated(
env: &Env,
loan_id: u32,
borrower: Address,
liquidator: Address,
total_collateral: i128,
liquidator_bonus: i128,
borrower_remainder: i128,
) {
let topics = (Symbol::new(env, "LoanLiquidated"), loan_id, borrower);
env.events().publish(
topics,
(liquidator, total_collateral, liquidator_bonus, borrower_remainder),
);
}
168 changes: 168 additions & 0 deletions contracts/loan_manager/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ pub enum LoanError {
PoolPaused = 18,
NftPaused = 19,
InvalidConfiguration = 20,
LoanNotUndercollateralized = 21,
LoanAlreadyLiquidated = 22,
}

#[contracttype]
Expand All @@ -64,6 +66,7 @@ pub enum LoanStatus {
Defaulted,
Cancelled,
Rejected,
Liquidated,
}

#[contracttype]
Expand Down Expand Up @@ -109,6 +112,8 @@ pub enum DataKey {
GracePeriodLedgers,
DefaultWindowLedgers,
RateOracle,
LiquidationThresholdBps,
LiquidationBonusBps,
}

#[contract]
Expand All @@ -132,6 +137,8 @@ impl LoanManager {
const LATE_REPAYMENT_SCORE_PENALTY: i32 = 10;
const DEFAULT_SCORE_PENALTY_POINTS: u32 = 50;
const DEFAULT_MIN_REPAYMENT_AMOUNT: i128 = 100;
const DEFAULT_LIQUIDATION_THRESHOLD_BPS: u32 = 15_000;
const DEFAULT_LIQUIDATION_BONUS_BPS: u32 = 500;

fn bump_instance_ttl(env: &Env) {
env.storage()
Expand Down Expand Up @@ -1654,6 +1661,167 @@ impl LoanManager {

Ok(())
}

fn liquidation_threshold_bps(env: &Env) -> u32 {
Self::bump_instance_ttl(env);
env.storage()
.instance()
.get(&DataKey::LiquidationThresholdBps)
.unwrap_or(Self::DEFAULT_LIQUIDATION_THRESHOLD_BPS)
}

fn liquidation_bonus_bps(env: &Env) -> u32 {
Self::bump_instance_ttl(env);
env.storage()
.instance()
.get(&DataKey::LiquidationBonusBps)
.unwrap_or(Self::DEFAULT_LIQUIDATION_BONUS_BPS)
}

/// Admin function to set the liquidation threshold in basis points.
/// For example, 15000 = 150%. When a loan's collateral ratio falls
/// below this threshold, it becomes eligible for liquidation.
pub fn set_liquidation_threshold(env: Env, ratio_bps: u32) -> Result<(), LoanError> {
if ratio_bps == 0 || ratio_bps > 50_000 {
return Err(LoanError::InvalidConfiguration);
}
let admin = Self::admin(&env);
admin.require_auth();

env.storage()
.instance()
.set(&DataKey::LiquidationThresholdBps, &ratio_bps);
Self::bump_instance_ttl(&env);

Ok(())
}

/// Admin function to set the liquidation bonus in basis points.
/// For example, 500 = 5% of collateral awarded to the liquidator.
pub fn set_liquidation_bonus(env: Env, bonus_bps: u32) -> Result<(), LoanError> {
if bonus_bps > 5_000 {
return Err(LoanError::InvalidConfiguration);
}
let admin = Self::admin(&env);
admin.require_auth();

env.storage()
.instance()
.set(&DataKey::LiquidationBonusBps, &bonus_bps);
Self::bump_instance_ttl(&env);

Ok(())
}

pub fn get_liquidation_threshold(env: Env) -> u32 {
Self::liquidation_threshold_bps(&env)
}

pub fn get_liquidation_bonus(env: Env) -> u32 {
Self::liquidation_bonus_bps(&env)
}

/// Liquidate an under-collateralized loan. Callable by anyone.
///
/// When the collateral ratio (collateral * 10_000 / total_debt) drops
/// below the configured threshold, the loan can be liquidated:
/// - The liquidator receives a bonus percentage of the collateral
/// - Remaining collateral is returned to the borrower
/// - The loan is marked as Liquidated
/// - A LoanLiquidated event is emitted
pub fn liquidate(env: Env, loan_id: u32, liquidator: Address) -> Result<(), LoanError> {
use soroban_sdk::token::TokenClient;

liquidator.require_auth();
Self::assert_not_paused(&env)?;

let loan_key = DataKey::Loan(loan_id);
let mut loan: Loan = env
.storage()
.persistent()
.get(&loan_key)
.ok_or(LoanError::LoanNotFound)?;
Self::bump_persistent_ttl(&env, &loan_key);

if loan.status == LoanStatus::Liquidated {
return Err(LoanError::LoanAlreadyLiquidated);
}
if loan.status != LoanStatus::Approved {
return Err(LoanError::LoanNotActive);
}

// Accrue interest so total_debt is up to date.
let (total_debt, _late_fee_delta) = Self::current_total_debt(&env, &mut loan);

if total_debt <= 0 {
return Err(LoanError::LoanNotActive);
}

let collateral = loan.collateral_amount;
// Collateral ratio = collateral * 10_000 / total_debt (in bps)
let collateral_ratio_bps = collateral
.checked_mul(10_000)
.and_then(|v| v.checked_div(total_debt))
.unwrap_or(0) as u32;

let threshold = Self::liquidation_threshold_bps(&env);
if collateral_ratio_bps >= threshold {
return Err(LoanError::LoanNotUndercollateralized);
}

// Calculate liquidation bonus for the liquidator.
let bonus_bps = Self::liquidation_bonus_bps(&env);
let liquidator_bonus = collateral
.checked_mul(bonus_bps as i128)
.and_then(|v| v.checked_div(10_000))
.unwrap_or(0);
let borrower_remainder = collateral
.checked_sub(liquidator_bonus)
.unwrap_or(0);

// Transfer collateral.
let token: Address = env
.storage()
.instance()
.get(&DataKey::Token)
.expect("token not set");
let token_client = TokenClient::new(&env, &token);

if liquidator_bonus > 0 {
token_client.transfer(
&env.current_contract_address(),
&liquidator,
&liquidator_bonus,
);
}
if borrower_remainder > 0 {
token_client.transfer(
&env.current_contract_address(),
&loan.borrower,
&borrower_remainder,
);
}

// Mark loan as liquidated.
loan.collateral_amount = 0;
loan.status = LoanStatus::Liquidated;
env.storage().persistent().set(&loan_key, &loan);
Self::bump_persistent_ttl(&env, &loan_key);
Self::decrement_borrower_loan_count(&env, &loan.borrower);

// Emit event.
events::loan_liquidated(
&env,
loan_id,
loan.borrower.clone(),
liquidator.clone(),
collateral,
liquidator_bonus,
borrower_remainder,
);

Ok(())
}
}

#[cfg(test)]
Expand Down
Loading
Loading