diff --git a/Cargo.lock b/Cargo.lock index ef81aabd..92f7b0b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1920,7 +1920,7 @@ checksum = "bedc768765dd8229a1d960c94f517317f40771a003e78916124784c7d6ea9d74" dependencies = [ "anyhow", "json_comments", - "thiserror 2.0.9", + "thiserror 2.0.11", "tracing", ] @@ -1956,7 +1956,7 @@ dependencies = [ "serde", "serde_json", "subtle", - "thiserror 2.0.9", + "thiserror 2.0.11", ] [[package]] @@ -1995,7 +1995,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "thiserror 2.0.9", + "thiserror 2.0.11", ] [[package]] @@ -2011,7 +2011,7 @@ dependencies = [ "near-schema-checker-lib", "serde", "serde_json", - "thiserror 2.0.9", + "thiserror 2.0.11", "time", ] @@ -2031,7 +2031,7 @@ dependencies = [ "serde_repr", "serde_yaml", "strum 0.24.1", - "thiserror 2.0.9", + "thiserror 2.0.11", ] [[package]] @@ -2071,7 +2071,7 @@ dependencies = [ "sha3", "smart-default", "strum 0.24.1", - "thiserror 2.0.9", + "thiserror 2.0.11", "tracing", "zstd 0.13.2", ] @@ -2094,7 +2094,7 @@ dependencies = [ "serde", "serde_repr", "sha2", - "thiserror 2.0.9", + "thiserror 2.0.11", ] [[package]] @@ -2259,7 +2259,7 @@ dependencies = [ "sha3", "strum 0.24.1", "tempfile", - "thiserror 2.0.9", + "thiserror 2.0.11", "tracing", "zeropool-bn", ] @@ -3519,6 +3519,7 @@ version = "0.1.0" dependencies = [ "near-contract-standards", "near-sdk", + "thiserror 2.0.11", ] [[package]] @@ -3551,11 +3552,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.9" +version = "2.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f072643fd0190df67a8bab670c20ef5d8737177d6ac6b2e9a236cb096206b2cc" +checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" dependencies = [ - "thiserror-impl 2.0.9", + "thiserror-impl 2.0.11", ] [[package]] @@ -3571,9 +3572,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.9" +version = "2.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b50fa271071aae2e6ee85f842e2e28ba8cd2c5fb67f11fcb1fd70b276f9e7d4" +checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 7e866b7b..c48d79e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ rstest = { version = "0.24" } serde = { version = "1.0", features = ["derive"] } templar-common = { path = "./common" } test-utils = { path = "./test-utils" } +thiserror = "2.0.11" tokio = { version = "1.30.0", features = ["full"] } [package] diff --git a/common/Cargo.toml b/common/Cargo.toml index d0e38cfc..d210783e 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -6,3 +6,4 @@ edition = "2021" [dependencies] near-contract-standards.workspace = true near-sdk.workspace = true +thiserror.workspace = true diff --git a/common/src/borrow.rs b/common/src/borrow.rs index 652cfcf3..8c0d15f2 100644 --- a/common/src/borrow.rs +++ b/common/src/borrow.rs @@ -63,6 +63,7 @@ pub struct BorrowPosition { borrow_asset_principal: BorrowAssetAmount, pub borrow_asset_fees: FeeRecord, pub temporary_lock: BorrowAssetAmount, + pub liquidation_lock: bool, } impl BorrowPosition { @@ -73,9 +74,19 @@ impl BorrowPosition { borrow_asset_principal: 0.into(), borrow_asset_fees: FeeRecord::new(block_height), temporary_lock: 0.into(), + liquidation_lock: false, } } + pub fn full_liquidation(&mut self, block_timestamp_ms: u64) { + self.liquidation_lock = false; + self.started_at_block_timestamp_ms = None; + self.collateral_asset_deposit = 0.into(); + self.borrow_asset_principal = 0.into(); + self.borrow_asset_fees.total = 0.into(); + self.borrow_asset_fees.last_updated_block_height = block_timestamp_ms.into(); + } + pub fn get_borrow_asset_principal(&self) -> BorrowAssetAmount { self.borrow_asset_principal } @@ -123,7 +134,11 @@ impl BorrowPosition { pub(crate) fn reduce_borrow_asset_liability( &mut self, mut amount: BorrowAssetAmount, - ) -> LiabilityReduction { + ) -> Result { + if self.liquidation_lock { + return Err(error::LiquidationLockError); + } + // No bounds checks necessary here: the min() call prevents underflow. let amount_to_fees = self.borrow_asset_fees.total.min(amount); @@ -139,11 +154,11 @@ impl BorrowPosition { self.started_at_block_timestamp_ms = None; } - LiabilityReduction { + Ok(LiabilityReduction { amount_to_fees, amount_to_principal, amount_remaining: amount, - } + }) } } @@ -152,3 +167,11 @@ pub struct LiabilityReduction { pub amount_to_principal: BorrowAssetAmount, pub amount_remaining: BorrowAssetAmount, } + +pub mod error { + use thiserror::Error; + + #[derive(Error, Debug)] + #[error("This position is currently being liquidated.")] + pub struct LiquidationLockError; +} diff --git a/common/src/market/configuration.rs b/common/src/market/configuration.rs index 2625518a..b6fc4bfa 100644 --- a/common/src/market/configuration.rs +++ b/common/src/market/configuration.rs @@ -15,7 +15,6 @@ pub struct MarketConfiguration { pub borrow_asset: FungibleAsset, pub collateral_asset: FungibleAsset, pub balance_oracle_account_id: AccountId, - pub liquidator_account_id: AccountId, pub minimum_collateral_ratio_per_borrow: Rational, /// How much of the deposited principal may be lent out (up to 100%)? /// This is a matter of protection for supply providers. @@ -38,7 +37,7 @@ pub struct MarketConfiguration { /// NEAR, a "maximum liquidator spread" of 10% would mean that a liquidator /// could liquidate this borrow by sending 109USDC, netting the liquidator /// ($110 - $100) * 10% = $1 of NEAR. - pub maximum_liquidator_spread: Rational, + pub maximum_liquidator_spread: Fraction, } impl MarketConfiguration { @@ -108,7 +107,6 @@ mod tests { borrow_asset: FungibleAsset::nep141("usdt.fakes.testnet".parse().unwrap()), collateral_asset: FungibleAsset::nep141("wrap.testnet".parse().unwrap()), balance_oracle_account_id: "root.testnet".parse().unwrap(), - liquidator_account_id: "templar-in-training.testnet".parse().unwrap(), minimum_collateral_ratio_per_borrow: Rational::new(120, 100), maximum_borrow_asset_usage_ratio: Fraction::new(99, 100).unwrap(), borrow_origination_fee: Fee::Proportional(Rational::new(1, 100)), @@ -120,7 +118,7 @@ mod tests { yield_weights: YieldWeights::new_with_supply_weight(8) .with_static("protocol".parse().unwrap(), 1) .with_static("insurance".parse().unwrap(), 1), - maximum_liquidator_spread: Rational::new(5, 100), + maximum_liquidator_spread: Fraction::new(5, 100).unwrap(), }) .unwrap() ); diff --git a/common/src/market/impl.rs b/common/src/market/impl.rs index 4fe23ae9..c5e02b87 100644 --- a/common/src/market/impl.rs +++ b/common/src/market/impl.rs @@ -304,7 +304,9 @@ impl Market { borrow_position: &mut BorrowPosition, amount: BorrowAssetAmount, ) { - let liability_reduction = borrow_position.reduce_borrow_asset_liability(amount); + let liability_reduction = borrow_position + .reduce_borrow_asset_liability(amount) + .unwrap_or_else(|e| env::panic_str(&e.to_string())); require!( liability_reduction.amount_remaining.is_zero(), @@ -416,15 +418,24 @@ impl Market { .is_liquidation() } + pub fn record_liquidation_lock(&mut self, borrow_position: &mut BorrowPosition) { + borrow_position.liquidation_lock = true; + } + + pub fn record_liquidation_unlock(&mut self, borrow_position: &mut BorrowPosition) { + borrow_position.liquidation_lock = false; + } + pub fn record_full_liquidation( &mut self, borrow_position: &mut BorrowPosition, mut recovered_amount: BorrowAssetAmount, ) { - if recovered_amount - .split(borrow_position.get_borrow_asset_principal()) - .is_some() - { + let principal = borrow_position.get_borrow_asset_principal(); + borrow_position.full_liquidation(env::block_timestamp_ms()); + + // TODO: Is it correct to only care about the original principal here? + if recovered_amount.split(principal).is_some() { // distribute yield self.record_borrow_asset_yield_distribution(recovered_amount); } else { diff --git a/common/src/rational.rs b/common/src/rational.rs index 6e086f60..556b614e 100644 --- a/common/src/rational.rs +++ b/common/src/rational.rs @@ -30,6 +30,10 @@ impl + BitXor + Sub + Copy + Eq + Ord Self(a, b).simplify() } + pub const fn new_const(a: T, b: T) -> Self { + Self(a, b) + } + pub fn simplify(self) -> Self { let Self(mut n, mut d) = self; diff --git a/src/lib.rs b/src/lib.rs index 74ccd9c3..7029c343 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -150,11 +150,6 @@ impl FungibleTokenReceiver for Contract { }) => { let amount = use_borrow_asset(); - require!( - sender_id == self.configuration.liquidator_account_id, - "Account not authorized to perform liquidations", - ); - let mut borrow_position = self .borrow_positions .get(&account_id) @@ -171,16 +166,39 @@ impl FungibleTokenReceiver for Contract { "Borrow position cannot be liquidated", ); - // TODO: Implement `maximum_liquidator_spread` here, since - // we have the price data available in `oracle_price_proof`. - self.record_full_liquidation(&mut borrow_position, amount); + // minimum_acceptable_amount = collateral_amount * (1 - maximum_liquidator_spread) * collateral_price / borrow_price + let minimum_acceptable_amount: BorrowAssetAmount = self + .configuration + .maximum_liquidator_spread + .complement() + .upcast::() + .checked_mul(oracle_price_proof.collateral_asset_price) + .and_then(|x| x.checked_div(oracle_price_proof.borrow_asset_price)) + .and_then(|x| { + x.checked_scalar_mul(borrow_position.collateral_asset_deposit.as_u128()) + }) + .and_then(|x| x.ceil()) + .unwrap() // TODO: Eliminate .unwrap() + .into(); + + require!( + amount >= minimum_acceptable_amount, + "Too little attached to liquidate", + ); + + self.record_liquidation_lock(&mut borrow_position); self.borrow_positions.insert(&account_id, &borrow_position); - // TODO: (cont'd from above) This would allow us to calculate - // the amount that "should" be recovered and refund the - // liquidator any excess. - PromiseOrValue::Value(U128(0)) + PromiseOrValue::Promise( + self.configuration + .collateral_asset + .transfer(sender_id, borrow_position.collateral_asset_deposit) + .then( + Self::ext(env::current_account_id()) + .after_liquidate(account_id, amount), + ), + ) } } } @@ -658,4 +676,40 @@ impl Contract { } } } + + /// Called during liquidation process; checks whether the transfer of + /// collateral to the liquidator was successful. + #[private] + pub fn after_liquidate( + &mut self, + account_id: AccountId, + borrow_asset_amount: BorrowAssetAmount, + ) -> U128 { + require!(env::promise_results_count() == 1); + + let mut borrow_position = self.borrow_positions.get(&account_id).unwrap_or_else(|| { + env::panic_str("Invariant violation: Liquidation of nonexistent position.") + }); + + match env::promise_result(0) { + PromiseResult::Successful(_) => { + self.record_full_liquidation(&mut borrow_position, borrow_asset_amount); + U128(0) + } + PromiseResult::Failed => { + // Somehow transfer of collateral failed. This could mean: + // + // 1. Somehow the contract does not have enough collateral + // available. This would be indicative of a *fundamental flaw* + // in the contract (i.e. this should never happen). + // + // 2. More likely, in a multichain context, communication + // broke down somewhere between the signer and the remote RPC. + // Could be as simple as a nonce sync issue. Should just wait + // and try again later. + self.record_liquidation_unlock(&mut borrow_position); + U128(borrow_asset_amount.as_u128()) + } + } + } } diff --git a/test-utils/src/lib.rs b/test-utils/src/lib.rs index f1fae81e..0e88c1bc 100644 --- a/test-utils/src/lib.rs +++ b/test-utils/src/lib.rs @@ -3,12 +3,17 @@ use near_sdk::{ serde_json::{self, json}, AccountId, AccountIdRef, NearToken, }; -use near_workspaces::{network::Sandbox, prelude::*, Account, Contract, DevNetwork, Worker}; +use near_workspaces::{ + network::Sandbox, prelude::*, result::ExecutionSuccess, Account, Contract, DevNetwork, Worker, +}; use templar_common::{ asset::{BorrowAssetAmount, CollateralAssetAmount, FungibleAsset}, borrow::{BorrowPosition, BorrowStatus}, fee::{Fee, TimeBasedFee}, - market::{MarketConfiguration, Nep141MarketDepositMessage, OraclePriceProof, YieldWeights}, + market::{ + LiquidateMsg, MarketConfiguration, Nep141MarketDepositMessage, OraclePriceProof, + YieldWeights, + }, rational::{Fraction, Rational}, static_yield::StaticYieldRecord, supply::SupplyPosition, @@ -20,6 +25,11 @@ pub const EQUAL_PRICE: OraclePriceProof = OraclePriceProof { borrow_asset_price: Rational::::one(), }; +pub const COLLATERAL_HALF_PRICE: OraclePriceProof = OraclePriceProof { + collateral_asset_price: Rational::::new_const(1, 2), + borrow_asset_price: Rational::::one(), +}; + pub struct TestController { pub worker: Worker, pub contract: Contract, @@ -224,6 +234,33 @@ impl TestController { .unwrap(); } + pub async fn asset_transfer_call( + &self, + asset_id: &AccountId, + sender: &Account, + receiver_id: &AccountId, + amount: u128, + msg: &str, + ) -> ExecutionSuccess { + println!( + "{} sending {amount} tokens of {asset_id} to {receiver_id} with msg {msg}...", + sender.id(), + ); + sender + .call(asset_id, "ft_transfer_call") + .args_json(json!({ + "receiver_id": receiver_id, + "amount": U128(amount), + "msg": msg, + })) + .deposit(NearToken::from_yoctonear(1)) + .max_gas() + .transact() + .await + .unwrap() + .unwrap() + } + pub async fn borrow_asset_transfer( &self, sender: &Account, @@ -234,6 +271,17 @@ impl TestController { .await; } + pub async fn borrow_asset_transfer_call( + &self, + sender: &Account, + receiver_id: &AccountId, + amount: u128, + msg: &str, + ) -> ExecutionSuccess { + self.asset_transfer_call(self.borrow_asset.id(), sender, receiver_id, amount, msg) + .await + } + pub async fn repay(&self, borrow_user: &Account, amount: u128) { println!("{} repaying {amount} tokens...", borrow_user.id()); borrow_user @@ -380,6 +428,32 @@ impl TestController { .unwrap(); } + pub async fn liquidate( + &self, + liquidator_user: &Account, + account_id: &AccountId, + borrow_asset_amount: u128, + oracle_price_proof: OraclePriceProof, + ) { + println!( + "{} executing liquidation against {} for {}...", + liquidator_user.id(), + account_id, + borrow_asset_amount, + ); + self.borrow_asset_transfer_call( + liquidator_user, + self.contract.id(), + borrow_asset_amount, + &serde_json::to_string(&Nep141MarketDepositMessage::Liquidate(LiquidateMsg { + account_id: account_id.clone(), + oracle_price_proof, + })) + .unwrap(), + ) + .await; + } + #[allow(unused)] // This is useful for debugging tests pub async fn print_logs(&self) { let total_borrow_asset_deposited_log = self @@ -436,14 +510,12 @@ macro_rules! accounts { pub fn market_configuration( borrow_asset_id: AccountId, collateral_asset_id: AccountId, - liquidator_account_id: AccountId, yield_weights: YieldWeights, ) -> MarketConfiguration { MarketConfiguration { borrow_asset: FungibleAsset::nep141(borrow_asset_id), collateral_asset: FungibleAsset::nep141(collateral_asset_id), balance_oracle_account_id: "balance_oracle".parse().unwrap(), - liquidator_account_id, minimum_collateral_ratio_per_borrow: Rational::new(120, 100), maximum_borrow_asset_usage_ratio: Fraction::new(99, 100).unwrap(), borrow_origination_fee: Fee::Proportional(Rational::new(10, 100)), @@ -451,7 +523,7 @@ pub fn market_configuration( maximum_borrow_duration_ms: None, minimum_borrow_amount: 1.into(), maximum_borrow_amount: u128::MAX.into(), - maximum_liquidator_spread: Rational::new(5, 100), + maximum_liquidator_spread: Fraction::new(5, 100).unwrap(), supply_withdrawal_fee: TimeBasedFee::zero(), yield_weights, } @@ -507,7 +579,7 @@ pub async fn deploy_ft( pub struct SetupEverything { pub c: TestController, - pub owner_user: Account, + pub liquidator_user: Account, pub supply_user: Account, pub borrow_user: Account, pub protocol_yield_user: Account, @@ -520,7 +592,7 @@ pub async fn setup_everything( let worker = near_workspaces::sandbox().await.unwrap(); accounts!( worker, - owner_user, + liquidator_user, supply_user, borrow_user, protocol_yield_user, @@ -531,29 +603,28 @@ pub async fn setup_everything( let mut config = market_configuration( borrow_asset.id().clone(), collateral_asset.id().clone(), - owner_user.id().clone(), YieldWeights::new_with_supply_weight(8) .with_static(protocol_yield_user.id().clone(), 1) .with_static(insurance_yield_user.id().clone(), 1), ); customize_market_configuration(&mut config); - let contract = setup_market(&worker, config).await; - let borrow_asset = deploy_ft( - borrow_asset, - "Borrow Asset", - "BORROW", - supply_user.id(), - 100000, - ) - .await; - let collateral_asset = deploy_ft( - collateral_asset, - "Collateral Asset", - "COLLATERAL", - borrow_user.id(), - 100000, - ) - .await; + let (contract, borrow_asset, collateral_asset) = tokio::join!( + setup_market(&worker, config), + deploy_ft( + borrow_asset, + "Borrow Asset", + "BORROW", + supply_user.id(), + 200000, + ), + deploy_ft( + collateral_asset, + "Collateral Asset", + "COLLATERAL", + borrow_user.id(), + 100000, + ), + ); let c = TestController { worker, @@ -565,6 +636,11 @@ pub async fn setup_everything( // Asset opt-ins. tokio::join!( c.storage_deposits(c.contract.as_account()), + async { + c.storage_deposits(&liquidator_user).await; + c.borrow_asset_transfer(&supply_user, liquidator_user.id(), 100000) + .await; + }, c.storage_deposits(&borrow_user), c.storage_deposits(&supply_user), c.storage_deposits(&protocol_yield_user), @@ -573,7 +649,7 @@ pub async fn setup_everything( SetupEverything { c, - owner_user, + liquidator_user, supply_user, borrow_user, protocol_yield_user, diff --git a/tests/integration.rs b/tests/happy_path.rs similarity index 98% rename from tests/integration.rs rename to tests/happy_path.rs index e2eb9d25..477eefff 100644 --- a/tests/integration.rs +++ b/tests/happy_path.rs @@ -3,14 +3,13 @@ use test_utils::*; use tokio::join; #[test] -#[ignore = "generates a dummy config"] -fn gen_config() { +#[ignore = "generates the arguments to a new() call"] +fn gen_constructor_arguments() { println!( - "{}", + "{{\"configuration\":{}}}", near_sdk::serde_json::to_string(&market_configuration( "usdt.fakes.testnet".parse().unwrap(), "wrap.testnet".parse().unwrap(), - "liquidator".parse().unwrap(), YieldWeights::new_with_supply_weight(1) )) .unwrap() @@ -21,11 +20,11 @@ fn gen_config() { async fn test_happy() { let SetupEverything { c, - owner_user: _, supply_user, borrow_user, protocol_yield_user, insurance_yield_user, + .. } = setup_everything(|_| {}).await; let configuration = c.get_configuration().await; diff --git a/tests/liquidation.rs b/tests/liquidation.rs new file mode 100644 index 00000000..ad421b79 --- /dev/null +++ b/tests/liquidation.rs @@ -0,0 +1,293 @@ +use rstest::rstest; +use templar_common::{ + fee::Fee, + market::OraclePriceProof, + rational::{Fraction, Rational}, +}; +use test_utils::*; + +#[tokio::test] +async fn successful_liquidation_totally_underwater() { + let SetupEverything { + c, + liquidator_user, + supply_user, + borrow_user, + .. + } = setup_everything(|_| {}).await; + + c.supply(&supply_user, 1000).await; + c.collateralize(&borrow_user, 500).await; + c.borrow(&borrow_user, 300, EQUAL_PRICE).await; + + // value of collateral will go 500->250 + // collateralization: 250/300 ~= 83% + // which is bad debt (<100%). + + let collateral_balance_before = c.collateral_asset_balance_of(liquidator_user.id()).await; + let borrow_balance_before = c.borrow_asset_balance_of(liquidator_user.id()).await; + + c.liquidate( + &liquidator_user, + borrow_user.id(), + 300, // this is fmv (i.e. NOT what a real liquidator would do to purchase bad debt) + COLLATERAL_HALF_PRICE, + ) + .await; + + let collateral_balance_after = c.collateral_asset_balance_of(liquidator_user.id()).await; + let borrow_balance_after = c.borrow_asset_balance_of(liquidator_user.id()).await; + + assert_eq!( + collateral_balance_after - collateral_balance_before, + 500, + "Liquidator should obtain all collateral after a successful liquidation", + ); + assert_eq!( + borrow_balance_before - borrow_balance_after, + 300, + "Liquidation should transfer correct amount of tokens", + ); +} + +// Caveat to this test: Make sure that the yield distribution value is +// divisible by 10 for easy maths. +#[rstest] +#[case(110, 5000, 2450, 50, 2500)] +#[case(120, 1250, 1000, 88, 1100)] // fmv +#[case(120, 1250, 1000, 88, 1070)] // liquidator spread of ~2.7% +#[tokio::test] +async fn successful_liquidation_good_debt_under_mcr( + #[case] mcr: u16, + #[case] collateral_amount: u128, + #[case] borrow_amount: u128, + #[case] collateral_asset_price_pct: u128, + #[case] liquidation_amount: u128, +) { + let SetupEverything { + c, + liquidator_user, + supply_user, + borrow_user, + protocol_yield_user, + insurance_yield_user, + .. + } = setup_everything(|config| { + config.borrow_origination_fee = Fee::zero(); + config.minimum_collateral_ratio_per_borrow = Rational::new(mcr, 100); + }) + .await; + + c.supply(&supply_user, 10000).await; + c.collateralize(&borrow_user, collateral_amount).await; + c.borrow(&borrow_user, borrow_amount, EQUAL_PRICE).await; + + let collateral_balance_before = c.collateral_asset_balance_of(liquidator_user.id()).await; + let borrow_balance_before = c.borrow_asset_balance_of(liquidator_user.id()).await; + + c.liquidate( + &liquidator_user, + borrow_user.id(), + liquidation_amount, + OraclePriceProof { + collateral_asset_price: Rational::new(collateral_asset_price_pct, 100), + borrow_asset_price: Rational::::one(), + }, + ) + .await; + + let collateral_balance_after = c.collateral_asset_balance_of(liquidator_user.id()).await; + let borrow_balance_after = c.borrow_asset_balance_of(liquidator_user.id()).await; + + assert_eq!( + collateral_balance_after - collateral_balance_before, + collateral_amount, + "Liquidator should obtain all collateral after a successful liquidation", + ); + assert_eq!( + borrow_balance_before - borrow_balance_after, + liquidation_amount, + "Liquidation should transfer correct amount of tokens", + ); + + let yield_amount = liquidation_amount - borrow_amount; + + tokio::join!( + async { + c.harvest_yield(&supply_user).await; + let supply_position = c.get_supply_position(supply_user.id()).await.unwrap(); + assert_eq!( + supply_position.borrow_asset_yield.amount.as_u128(), + yield_amount * 8 / 10, + ); + }, + async { + let protocol_yield = c.get_static_yield(protocol_yield_user.id()).await.unwrap(); + assert_eq!(protocol_yield.borrow_asset.as_u128(), yield_amount * 1 / 10); + }, + async { + let insurance_yield = c.get_static_yield(insurance_yield_user.id()).await.unwrap(); + assert_eq!( + insurance_yield.borrow_asset.as_u128(), + yield_amount * 1 / 10, + ); + }, + ); +} + +#[rstest] +#[case(120, 5, 0)] +#[case(120, 5, 2)] +#[case(120, 5, 5)] +#[case(110, 2, 1)] +#[case(150, 33, 32)] +#[tokio::test] +async fn successful_liquidation_with_spread( + #[case] mcr: u16, + #[case] maximum_spread_pct: u16, + #[case] spread_pct: u16, +) { + assert!(spread_pct <= maximum_spread_pct); + + let maximum_liquidator_spread = Fraction::new(maximum_spread_pct, 100).unwrap(); + let target_spread = Fraction::new(spread_pct, 100).unwrap(); + let mcr = Rational::new(mcr, 100); + + let SetupEverything { + c, + liquidator_user, + supply_user, + borrow_user, + .. + } = setup_everything(|config| { + config.minimum_collateral_ratio_per_borrow = mcr; + config.maximum_liquidator_spread = maximum_liquidator_spread; + }) + .await; + + c.supply(&supply_user, 10000).await; + c.collateralize(&borrow_user, 2000).await; // 2:1 collateralization + c.borrow(&borrow_user, 1000, EQUAL_PRICE).await; + + let collateral_balance_before = c.collateral_asset_balance_of(liquidator_user.id()).await; + let borrow_balance_before = c.borrow_asset_balance_of(liquidator_user.id()).await; + + let collateral_asset_price = mcr + .upcast::() + .checked_div( + Rational::new(201, 100), // 2:1 collateralization + a bit to ensure we're under MCR + ) + .unwrap(); + + let liquidation_amount = collateral_asset_price + .checked_mul(*target_spread.complement().upcast()) + .and_then(|x| x.checked_scalar_mul(2000)) + .and_then(|x| x.ceil()) + .unwrap(); + + c.liquidate( + &liquidator_user, + borrow_user.id(), + liquidation_amount, + OraclePriceProof { + collateral_asset_price, + borrow_asset_price: Rational::::one(), + }, + ) + .await; + + let collateral_balance_after = c.collateral_asset_balance_of(liquidator_user.id()).await; + let borrow_balance_after = c.borrow_asset_balance_of(liquidator_user.id()).await; + + assert_eq!( + collateral_balance_after - collateral_balance_before, + 2000, + "Liquidator should obtain all collateral after a successful liquidation", + ); + assert_eq!( + borrow_balance_before - borrow_balance_after, + liquidation_amount, + "Liquidation should transfer correct amount of tokens", + ); +} + +#[tokio::test] +async fn fail_liquidation_too_little_attached() { + let SetupEverything { + c, + liquidator_user, + supply_user, + borrow_user, + .. + } = setup_everything(|_| {}).await; + + c.supply(&supply_user, 1000).await; + c.collateralize(&borrow_user, 500).await; + c.borrow(&borrow_user, 300, EQUAL_PRICE).await; + + let collateral_balance_before = c.collateral_asset_balance_of(liquidator_user.id()).await; + let borrow_balance_before = c.borrow_asset_balance_of(liquidator_user.id()).await; + + c.liquidate( + &liquidator_user, + borrow_user.id(), + 150, + COLLATERAL_HALF_PRICE, + ) + .await; + + let collateral_balance_after = c.collateral_asset_balance_of(liquidator_user.id()).await; + let borrow_balance_after = c.borrow_asset_balance_of(liquidator_user.id()).await; + + assert_eq!( + collateral_balance_before, collateral_balance_after, + "Liquidator should not obtain any additional collateral from a rejected liquidation attempt", + ); + assert_eq!( + borrow_balance_before, borrow_balance_after, + "Liquidator should be refunded for a rejected liquidation attempt", + ); + + // ensure borrow position remains unchanged + let borrow_position = c.get_borrow_position(borrow_user.id()).await.unwrap(); + assert_eq!(borrow_position.get_borrow_asset_principal().as_u128(), 300); + assert_eq!(borrow_position.collateral_asset_deposit.as_u128(), 500); +} + +#[tokio::test] +async fn fail_liquidation_healthy_borrow() { + let SetupEverything { + c, + liquidator_user, + supply_user, + borrow_user, + .. + } = setup_everything(|_| {}).await; + + c.supply(&supply_user, 1000).await; + c.collateralize(&borrow_user, 500).await; + c.borrow(&borrow_user, 300, EQUAL_PRICE).await; + + let collateral_balance_before = c.collateral_asset_balance_of(liquidator_user.id()).await; + let borrow_balance_before = c.borrow_asset_balance_of(liquidator_user.id()).await; + + c.liquidate(&liquidator_user, borrow_user.id(), 300, EQUAL_PRICE) + .await; + + let collateral_balance_after = c.collateral_asset_balance_of(liquidator_user.id()).await; + let borrow_balance_after = c.borrow_asset_balance_of(liquidator_user.id()).await; + + assert_eq!( + collateral_balance_before, collateral_balance_after, + "Liquidator should not obtain any additional collateral from a rejected liquidation attempt", + ); + assert_eq!( + borrow_balance_before, borrow_balance_after, + "Liquidator should be refunded for a rejected liquidation attempt", + ); + + // ensure borrow position remains unchanged + let borrow_position = c.get_borrow_position(borrow_user.id()).await.unwrap(); + assert_eq!(borrow_position.get_borrow_asset_principal().as_u128(), 300); + assert_eq!(borrow_position.collateral_asset_deposit.as_u128(), 500); +} diff --git a/tests/maximum_borrow_asset_usage_ratio.rs b/tests/maximum_borrow_asset_usage_ratio.rs index 31596ce9..872db80f 100644 --- a/tests/maximum_borrow_asset_usage_ratio.rs +++ b/tests/maximum_borrow_asset_usage_ratio.rs @@ -8,7 +8,7 @@ use test_utils::*; #[case(99)] #[case(100)] #[tokio::test] -async fn within(#[case] percent: u16) { +async fn borrow_within_maximum_usage_ratio(#[case] percent: u16) { let SetupEverything { c, supply_user, @@ -32,7 +32,7 @@ async fn within(#[case] percent: u16) { #[case(100)] #[tokio::test] #[should_panic = "Smart contract panicked: Insufficient borrow asset available"] -async fn exceed(#[case] percent: u16) { +async fn borrow_exceeds_maximum_usage_ratio(#[case] percent: u16) { let SetupEverything { c, supply_user,