diff --git a/common/src/event.rs b/common/src/event.rs index 0941fa06..cca25df7 100644 --- a/common/src/event.rs +++ b/common/src/event.rs @@ -35,7 +35,8 @@ pub enum MarketEvent { #[event_version("1.0.0")] SupplyWithdrawn { account_id: AccountId, - borrow_asset_amount: BorrowAssetAmount, + borrow_asset_amount_to_account: BorrowAssetAmount, + borrow_asset_amount_to_fees: BorrowAssetAmount, }, #[event_version("1.0.0")] CollateralDeposited { diff --git a/common/src/fee.rs b/common/src/fee.rs index 6e4409fe..71cc0cd2 100644 --- a/common/src/fee.rs +++ b/common/src/fee.rs @@ -50,11 +50,14 @@ impl TimeBasedFee { pub enum TimeBasedFeeFunction { Fixed, Linear, - Logarithmic, } impl TimeBasedFee { - pub fn of(&self, amount: FungibleAssetAmount, time: u64) -> Option> { + pub fn of( + &self, + amount: FungibleAssetAmount, + duration: u64, + ) -> Option> { let base_fee = self.fee.of(amount)?; if self.duration.0 == 0 { @@ -62,23 +65,20 @@ impl TimeBasedFee { } match self.behavior { - TimeBasedFeeFunction::Fixed => Some(base_fee), - TimeBasedFeeFunction::Linear => (Decimal::from(time) / self.duration.0 - * base_fee.as_u128()) - .to_u128_ceil() - .map(FungibleAssetAmount::new), - TimeBasedFeeFunction::Logarithmic => Some( - // TODO: Seems jank. - #[allow( - clippy::cast_sign_loss, - clippy::cast_possible_truncation, - clippy::cast_precision_loss - )] - (((base_fee.as_u128() as f64 * f64::log2((1 + time - self.duration.0) as f64)) - / f64::log2((1 + time) as f64)) - .ceil() as u128) - .into(), - ), + TimeBasedFeeFunction::Fixed => { + if duration >= self.duration.0 { + Some(0.into()) + } else { + Some(base_fee) + } + } + TimeBasedFeeFunction::Linear => { + (Decimal::from(self.duration.0.saturating_sub(duration)) + / Decimal::from(self.duration.0) + * base_fee.as_u128()) + .to_u128_ceil() + .map(FungibleAssetAmount::new) + } } } } diff --git a/common/src/market/configuration.rs b/common/src/market/configuration.rs index 35542780..cfc77d10 100644 --- a/common/src/market/configuration.rs +++ b/common/src/market/configuration.rs @@ -1,4 +1,4 @@ -use near_sdk::{json_types::U64, near}; +use near_sdk::{json_types::U64, near, AccountId}; use crate::{ asset::{ @@ -33,8 +33,9 @@ pub struct MarketConfiguration { pub maximum_borrow_duration_ms: Option, pub minimum_borrow_amount: BorrowAssetAmount, pub maximum_borrow_amount: BorrowAssetAmount, - pub supply_withdrawal_fee: TimeBasedFee, + pub supply_withdrawal_fee: TimeBasedFee, pub yield_weights: YieldWeights, + pub protocol_account_id: AccountId, /// How far below market rate to accept liquidation? This is effectively the liquidator's spread. /// /// For example, if a 100USDC borrow is (under)collateralized with $110 of diff --git a/common/src/market/impl.rs b/common/src/market/impl.rs index 6e454c21..73e7fa79 100644 --- a/common/src/market/impl.rs +++ b/common/src/market/impl.rs @@ -18,6 +18,8 @@ use crate::{ withdrawal_queue::{error::WithdrawalQueueLockError, WithdrawalQueue}, }; +use super::WithdrawalResolution; + #[derive(BorshStorageKey)] #[near] enum StorageKey { @@ -240,7 +242,7 @@ impl Market { /// - If the withdrawal queue is empty. pub fn try_lock_next_withdrawal_request( &mut self, - ) -> Result, WithdrawalQueueLockError> { + ) -> Result, WithdrawalQueueLockError> { let (account_id, requested_amount) = self.withdrawal_queue.try_lock()?; let Some((amount, mut supply_position)) = self @@ -266,12 +268,24 @@ impl Market { return Ok(None); }; - supply_position.record_withdrawal(amount); + let resolution = supply_position.record_withdrawal(amount, env::block_timestamp_ms()); + + Ok(Some(resolution)) + } + + pub fn record_borrow_asset_protocol_yield(&mut self, amount: BorrowAssetAmount) { + let mut yield_record = self + .static_yield + .get(&self.configuration.protocol_account_id) + .unwrap_or_default(); + + yield_record.borrow_asset.join(amount); - Ok(Some((account_id, amount))) + self.static_yield + .insert(&self.configuration.protocol_account_id, &yield_record); } - pub(crate) fn record_borrow_asset_yield_distribution(&mut self, mut amount: BorrowAssetAmount) { + pub fn record_borrow_asset_yield_distribution(&mut self, mut amount: BorrowAssetAmount) { // Sanity. if amount.is_zero() { return; diff --git a/common/src/market/mod.rs b/common/src/market/mod.rs index 44f59d0d..bfba58e3 100644 --- a/common/src/market/mod.rs +++ b/common/src/market/mod.rs @@ -74,3 +74,11 @@ pub enum Nep141MarketDepositMessage { pub struct LiquidateMsg { pub account_id: AccountId, } + +#[derive(Clone, Debug)] +#[near(serializers = [json, borsh])] +pub struct WithdrawalResolution { + pub account_id: AccountId, + pub amount_to_account: BorrowAssetAmount, + pub amount_to_fees: BorrowAssetAmount, +} diff --git a/common/src/supply.rs b/common/src/supply.rs index 68e2ec36..ddb21e03 100644 --- a/common/src/supply.rs +++ b/common/src/supply.rs @@ -3,19 +3,20 @@ use std::{ ops::{Deref, DerefMut}, }; -use near_sdk::{env, near, AccountId}; +use near_sdk::{env, json_types::U64, near, AccountId}; use crate::{ accumulator::{AccumulationRecord, Accumulator}, asset::{BorrowAsset, BorrowAssetAmount}, event::MarketEvent, - market::Market, + market::{Market, WithdrawalResolution}, number::Decimal, }; #[derive(Debug, Clone, PartialEq, Eq)] #[near(serializers = [json, borsh])] pub struct SupplyPosition { + pub started_at_block_timestamp_ms: Option, borrow_asset_deposit: BorrowAssetAmount, pub borrow_asset_yield: Accumulator, } @@ -23,6 +24,7 @@ pub struct SupplyPosition { impl SupplyPosition { pub fn new(current_snapshot_index: u32) -> Self { Self { + started_at_block_timestamp_ms: None, borrow_asset_deposit: 0.into(), // We start at next log index so that the supply starts // accumulating yield from the _next_ log (since they were not @@ -35,6 +37,10 @@ impl SupplyPosition { self.borrow_asset_deposit } + pub fn get_started_at_block_timestamp_ms(&self) -> Option { + self.started_at_block_timestamp_ms.map(u64::from) + } + pub fn exists(&self) -> bool { !self.borrow_asset_deposit.is_zero() || !self.borrow_asset_yield.get_total().is_zero() } @@ -43,7 +49,11 @@ impl SupplyPosition { pub(crate) fn increase_borrow_asset_deposit( &mut self, amount: BorrowAssetAmount, + block_timestamp_ms: u64, ) -> Option<()> { + if self.started_at_block_timestamp_ms.is_none() || self.borrow_asset_deposit.is_zero() { + self.started_at_block_timestamp_ms = Some(block_timestamp_ms.into()); + } self.borrow_asset_deposit.join(amount) } @@ -52,6 +62,8 @@ impl SupplyPosition { &mut self, amount: BorrowAssetAmount, ) -> Option { + // No need to reset the timer; it is a permanent indication of the + // initial supply event. self.borrow_asset_deposit.split(amount) } } @@ -247,11 +259,14 @@ impl> LinkedSupplyPositionMut { } } - pub fn record_withdrawal(&mut self, amount: BorrowAssetAmount) -> BorrowAssetAmount { + pub fn record_withdrawal( + &mut self, + mut amount: BorrowAssetAmount, + block_timestamp_ms: u64, + ) -> WithdrawalResolution { self.accumulate_yield(); - let withdrawn = self - .position + self.position .decrease_borrow_asset_deposit(amount) .unwrap_or_else(|| env::panic_str("Supply position borrow asset underflow")); @@ -263,20 +278,39 @@ impl> LinkedSupplyPositionMut { self.market.borrow_mut().snapshot(); + let started_at_block_timestamp_ms = + self.0.position.started_at_block_timestamp_ms.unwrap().0; + let supply_duration = block_timestamp_ms.saturating_sub(started_at_block_timestamp_ms); + + let amount_to_fees = self + .market + .borrow() + .configuration + .supply_withdrawal_fee + .of(amount, supply_duration) + .unwrap(); + + amount.split(amount_to_fees).unwrap(); + MarketEvent::SupplyWithdrawn { account_id: self.account_id.clone(), - borrow_asset_amount: amount, + borrow_asset_amount_to_account: amount, + borrow_asset_amount_to_fees: amount_to_fees, } .emit(); - withdrawn + WithdrawalResolution { + account_id: self.account_id.clone(), + amount_to_account: amount, + amount_to_fees, + } } - pub fn record_deposit(&mut self, amount: BorrowAssetAmount) { + pub fn record_deposit(&mut self, amount: BorrowAssetAmount, block_timestamp_ms: u64) { self.accumulate_yield(); self.position - .increase_borrow_asset_deposit(amount) + .increase_borrow_asset_deposit(amount, block_timestamp_ms) .unwrap_or_else(|| env::panic_str("Supply position borrow asset overflow")); self.market diff --git a/contract/market/src/impl_helper.rs b/contract/market/src/impl_helper.rs index 34d038d8..cc635bab 100644 --- a/contract/market/src/impl_helper.rs +++ b/contract/market/src/impl_helper.rs @@ -5,6 +5,7 @@ use near_sdk::{ use templar_common::{ asset::{BorrowAssetAmount, CollateralAssetAmount}, market::PricePair, + market::WithdrawalResolution, oracle::pyth::OracleResponse, snapshot::Snapshot, }; @@ -15,7 +16,7 @@ use crate::{Contract, ContractExt}; impl Contract { pub fn execute_supply(&mut self, account_id: AccountId, amount: BorrowAssetAmount) { let mut supply_position = self.get_or_create_linked_supply_position_mut(account_id); - supply_position.record_deposit(amount); + supply_position.record_deposit(amount, env::block_timestamp_ms()); } pub fn execute_collateralize(&mut self, account_id: AccountId, amount: CollateralAssetAmount) { @@ -237,7 +238,7 @@ impl Contract { } #[private] - pub fn after_execute_next_withdrawal(&mut self, account: AccountId, amount: BorrowAssetAmount) { + pub fn after_execute_next_withdrawal(&mut self, withdrawal_resolution: WithdrawalResolution) { // TODO: Is this check even necessary in a #[private] function? require!(env::promise_results_count() == 1); @@ -256,9 +257,11 @@ impl Contract { // head of the queue cannot change while transfers are // in-flight. This should be maintained by the queue itself. require!( - popped_account == account, + popped_account == withdrawal_resolution.account_id, "Invariant violation: Queue shifted while locked/in-flight.", ); + + self.record_borrow_asset_protocol_yield(withdrawal_resolution.amount_to_fees); } PromiseResult::Failed => { // Withdrawal failed: unlock the queue so they can try again. @@ -270,8 +273,14 @@ impl Contract { env::log_str("The withdrawal request cannot be fulfilled at this time. Please try again later."); self.withdrawal_queue.unlock(); - if let Some(mut supply_position) = self.get_linked_supply_position_mut(account) { - supply_position.record_deposit(amount); + + if let Some(mut supply_position) = + self.get_linked_supply_position_mut(withdrawal_resolution.account_id.clone()) + { + let timestamp = env::block_timestamp_ms(); + let mut amount = withdrawal_resolution.amount_to_account; + amount.join(withdrawal_resolution.amount_to_fees); + supply_position.record_deposit(amount, timestamp); } } } diff --git a/contract/market/src/impl_market_external.rs b/contract/market/src/impl_market_external.rs index 3f09e4f2..a96d5143 100644 --- a/contract/market/src/impl_market_external.rs +++ b/contract/market/src/impl_market_external.rs @@ -179,7 +179,7 @@ impl MarketExternalInterface for Contract { } fn execute_next_supply_withdrawal_request(&mut self) -> PromiseOrValue<()> { - let Some((account_id, amount)) = self + let Some(withdrawal_resolution) = self .try_lock_next_withdrawal_request() .unwrap_or_else(|e| env::panic_str(&e.to_string())) else { @@ -190,10 +190,13 @@ impl MarketExternalInterface for Contract { PromiseOrValue::Promise( self.configuration .borrow_asset - .transfer(account_id.clone(), amount) + .transfer( + withdrawal_resolution.account_id.clone(), + withdrawal_resolution.amount_to_account, + ) .then( Self::ext(env::current_account_id()) - .after_execute_next_withdrawal(account_id.clone(), amount), + .after_execute_next_withdrawal(withdrawal_resolution), ), ) } @@ -217,7 +220,7 @@ impl MarketExternalInterface for Contract { // Compound yield by withdrawing it and recording it as an immediate deposit. let total_yield = supply_position.inner().borrow_asset_yield.get_total(); supply_position.record_yield_withdrawal(total_yield); - supply_position.record_deposit(total_yield); + supply_position.record_deposit(total_yield, env::block_timestamp_ms()); } } } diff --git a/contract/market/tests/supply_withdrawal_fee.rs b/contract/market/tests/supply_withdrawal_fee.rs new file mode 100644 index 00000000..b9bd57d8 --- /dev/null +++ b/contract/market/tests/supply_withdrawal_fee.rs @@ -0,0 +1,110 @@ +use std::time::Duration; + +use near_sdk::json_types::U64; +use templar_common::fee::{Fee, TimeBasedFee, TimeBasedFeeFunction}; +use test_utils::*; + +#[tokio::test] +async fn supply_withdrawal_fee_flat() { + let fee = TimeBasedFee { + fee: Fee::Flat(100.into()), + duration: U64(1000 * 60 * 60 * 24 * 30), + behavior: TimeBasedFeeFunction::Fixed, + }; + + let SetupEverything { + c, + supply_user, + protocol_yield_user, + .. + } = setup_everything(|c| { + c.supply_withdrawal_fee = fee; + }) + .await; + + c.supply(&supply_user, 1000).await; + + println!("Sleeping 10s..."); + tokio::time::sleep(Duration::from_secs(10)).await; + + let supply_user_balance_before = c.borrow_asset_balance_of(supply_user.id()).await; + let yield_before = c + .get_static_yield(protocol_yield_user.id()) + .await + .map_or(0, |r| r.borrow_asset.as_u128()); + + c.create_supply_withdrawal_request(&supply_user, 1000).await; + c.execute_next_supply_withdrawal_request(&supply_user).await; + + let supply_user_balance_after = c.borrow_asset_balance_of(supply_user.id()).await; + let yield_after = c + .get_static_yield(protocol_yield_user.id()) + .await + .unwrap() + .borrow_asset + .as_u128(); + + assert_eq!( + supply_user_balance_after, + supply_user_balance_before + 900, + "Fee should be applied to early withdrawal", + ); + + assert_eq!( + yield_after, + yield_before + 100, + "Fee should be credited to the protocol account", + ); +} + +#[tokio::test] +async fn supply_withdrawal_fee_expired() { + let fee = TimeBasedFee { + fee: Fee::Flat(100.into()), + duration: U64(1000), // 1 second + behavior: TimeBasedFeeFunction::Fixed, + }; + + let SetupEverything { + c, + supply_user, + protocol_yield_user, + .. + } = setup_everything(|c| { + c.supply_withdrawal_fee = fee; + }) + .await; + + c.supply(&supply_user, 1000).await; + + println!("Sleeping 10s..."); + tokio::time::sleep(Duration::from_secs(10)).await; + + let supply_user_balance_before = c.borrow_asset_balance_of(supply_user.id()).await; + let yield_before = c + .get_static_yield(protocol_yield_user.id()) + .await + .map_or(0, |r| r.borrow_asset.as_u128()); + + c.create_supply_withdrawal_request(&supply_user, 1000).await; + c.execute_next_supply_withdrawal_request(&supply_user).await; + + let supply_user_balance_after = c.borrow_asset_balance_of(supply_user.id()).await; + let yield_after = c + .get_static_yield(protocol_yield_user.id()) + .await + .unwrap() + .borrow_asset + .as_u128(); + + assert_eq!( + supply_user_balance_after, + supply_user_balance_before + 1000, + "Fee should not be applied after period expires", + ); + + assert_eq!( + yield_after, yield_before, + "Fee should not be credited after period expires", + ); +} diff --git a/test-utils/examples/generate_testnet_configuration.rs b/test-utils/examples/generate_testnet_configuration.rs index 1f8f60ec..555528f3 100644 --- a/test-utils/examples/generate_testnet_configuration.rs +++ b/test-utils/examples/generate_testnet_configuration.rs @@ -49,6 +49,7 @@ pub fn main() { supply_withdrawal_fee: TimeBasedFee::zero(), yield_weights: YieldWeights::new_with_supply_weight(1), maximum_liquidator_spread: Decimal::from_str("0.05").unwrap(), + protocol_account_id: "templar-in-training.testnet".parse().unwrap(), }) .unwrap(), ); diff --git a/test-utils/src/lib.rs b/test-utils/src/lib.rs index c714e72e..6ab61138 100644 --- a/test-utils/src/lib.rs +++ b/test-utils/src/lib.rs @@ -742,6 +742,7 @@ pub fn market_configuration( balance_oracle_id: AccountId, borrow_asset_id: AccountId, collateral_asset_id: AccountId, + protocol_account_id: AccountId, yield_weights: YieldWeights, ) -> MarketConfiguration { MarketConfiguration { @@ -776,6 +777,7 @@ pub fn market_configuration( maximum_liquidator_spread: Decimal::from_str("0.05").unwrap(), supply_withdrawal_fee: TimeBasedFee::zero(), yield_weights, + protocol_account_id, } } @@ -898,6 +900,7 @@ pub async fn setup_everything( balance_oracle.id().clone(), borrow_asset.id().clone(), collateral_asset.id().clone(), + protocol_yield_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),