Skip to content

Commit

Permalink
Origination fees on borrow (#239)
Browse files Browse the repository at this point in the history
Adds per-bank configurable origination fee that is recorded on the bank upon any borrow. The fee is configurable by the group admin, and is split between the program (i.e. fee state wallet) and the group.
  • Loading branch information
jgur-psyops authored Oct 23, 2024
1 parent 8a01852 commit f51c25a
Show file tree
Hide file tree
Showing 9 changed files with 181 additions and 37 deletions.
10 changes: 4 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,12 @@ use an x86 toolchain when compiling and running the tests.

## Rust Tests

Run the full test suite with `.scripts/test-program.sh <program_to_test>`

- e.g. `.scripts/test-program.sh all --sane`
Run the full test suite with `./scripts/test-program.sh <program_to_test>`
* e.g. `./scripts/test-program.sh all --sane`

Run a single test:
`.scripts/test-program.sh <program_to_test> <name_of_test>`

- e.g. `.scripts/test-program.sh marginfi configure_bank_success --verbose`
`./scripts/test-program.sh <program_to_test> <name_of_test>`
* e.g. `./scripts/test-program.sh marginfi configure_bank_success --verbose`

## Localnet Anchor Tests

Expand Down
4 changes: 4 additions & 0 deletions clients/rust/marginfi-cli/src/entrypoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,8 @@ pub enum BankCommand {
pf_fa: Option<f64>,
#[clap(long, help = "Protocol IR fee")]
pf_ir: Option<f64>,
#[clap(long, help = "Protocol origination fee")]
pf_or: Option<f64>,
#[clap(long, arg_enum, help = "Bank risk tier")]
risk_tier: Option<RiskTierArg>,
#[clap(long, arg_enum, help = "Bank oracle type")]
Expand Down Expand Up @@ -669,6 +671,7 @@ fn bank(subcmd: BankCommand, global_options: &GlobalOptions) -> Result<()> {
if_ir,
pf_fa,
pf_ir,
pf_or,
risk_tier,
oracle_type,
oracle_key,
Expand Down Expand Up @@ -718,6 +721,7 @@ fn bank(subcmd: BankCommand, global_options: &GlobalOptions) -> Result<()> {
insurance_ir_fee: if_ir.map(|x| I80F48::from_num(x).into()),
protocol_fixed_fee_apr: pf_fa.map(|x| I80F48::from_num(x).into()),
protocol_ir_fee: pf_ir.map(|x| I80F48::from_num(x).into()),
protocol_origination_fee: pf_or.map(|x| I80F48::from_num(x).into()),
}),
risk_tier: risk_tier.map(|x| x.into()),
total_asset_value_init_limit: usd_init_limit,
Expand Down
60 changes: 57 additions & 3 deletions programs/marginfi/src/instructions/marginfi_account/borrow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use crate::{
bank_signer, check,
constants::{LIQUIDITY_VAULT_AUTHORITY_SEED, LIQUIDITY_VAULT_SEED},
events::{AccountEventHeader, LendingAccountBorrowEvent},
math_error,
prelude::{MarginfiError, MarginfiGroup, MarginfiResult},
state::{
marginfi_account::{BankAccountWrapper, MarginfiAccount, RiskEngine, DISABLED_FLAG},
Expand Down Expand Up @@ -43,6 +44,8 @@ pub fn lending_account_borrow<'info>(
)?;

let mut marginfi_account = marginfi_account_loader.load_mut()?;
let group = &marginfi_group_loader.load()?;
let program_fee_rate: I80F48 = group.fee_state_cache.program_fee_rate.into();

check!(
!marginfi_account.get_flag(DISABLED_FLAG),
Expand All @@ -51,15 +54,21 @@ pub fn lending_account_borrow<'info>(

bank_loader.load_mut()?.accrue_interest(
clock.unix_timestamp,
&*marginfi_group_loader.load()?,
group,
#[cfg(not(feature = "client"))]
bank_loader.key(),
)?;

let mut origination_fee: I80F48 = I80F48::ZERO;
{
let mut bank = bank_loader.load_mut()?;

let liquidity_vault_authority_bump = bank.liquidity_vault_authority_bump;
let origination_fee_rate: I80F48 = bank
.config
.interest_rate_config
.protocol_origination_fee
.into();

let mut bank_account = BankAccountWrapper::find_or_create(
&bank_loader.key(),
Expand All @@ -80,7 +89,21 @@ pub fn lending_account_borrow<'info>(
.transpose()?
.unwrap_or(amount);

bank_account.borrow(I80F48::from_num(amount_pre_fee))?;
let origination_fee_u64: u64;
if !origination_fee_rate.is_zero() {
origination_fee = I80F48::from_num(amount_pre_fee)
.checked_mul(origination_fee_rate)
.ok_or_else(math_error!())?;
origination_fee_u64 = origination_fee.checked_to_num().ok_or_else(math_error!())?;

// Incurs a borrow that includes the origination fee (but withdraws just the amt)
bank_account.borrow(I80F48::from_num(amount_pre_fee) + origination_fee)?;
} else {
// Incurs a borrow for the amount without any fee
origination_fee_u64 = 0;
bank_account.borrow(I80F48::from_num(amount_pre_fee))?;
}

bank_account.withdraw_spl_transfer(
amount_pre_fee,
bank_liquidity_vault.to_account_info(),
Expand All @@ -105,8 +128,39 @@ pub fn lending_account_borrow<'info>(
},
bank: bank_loader.key(),
mint: bank.mint,
amount: amount_pre_fee,
amount: amount_pre_fee + origination_fee_u64,
});
} // release mutable borrow of bank

// The program and/or group fee account gains the origination fee
{
let mut bank = bank_loader.load_mut()?;

if !origination_fee.is_zero() {
let mut bank_fees_after: I80F48 = bank.collected_group_fees_outstanding.into();

if !program_fee_rate.is_zero() {
// Some portion of the origination fee to goes to program fees
let program_fee_amount: I80F48 = origination_fee
.checked_mul(program_fee_rate)
.ok_or_else(math_error!())?;
// The remainder of the origination fee goes to group fees
bank_fees_after = bank_fees_after
.saturating_add(origination_fee.saturating_sub(program_fee_amount));

// Update the bank's program fees
let program_fees_before: I80F48 = bank.collected_program_fees_outstanding.into();
bank.collected_program_fees_outstanding = program_fees_before
.saturating_add(program_fee_amount)
.into();
} else {
// If program fee rate is zero, add the full origination fee to group fees
bank_fees_after = bank_fees_after.saturating_add(origination_fee);
}

// Update the bank's group fees
bank.collected_group_fees_outstanding = bank_fees_after.into();
}
}

// Check account health, if below threshold fail transaction
Expand Down
39 changes: 18 additions & 21 deletions programs/marginfi/src/state/marginfi_group.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,29 +151,22 @@ pub struct InterestRateConfigCompact {
pub insurance_ir_fee: WrappedI80F48,
pub protocol_fixed_fee_apr: WrappedI80F48,
pub protocol_ir_fee: WrappedI80F48,
pub protocol_origination_fee: WrappedI80F48,
}

impl From<InterestRateConfigCompact> for InterestRateConfig {
fn from(
InterestRateConfigCompact {
optimal_utilization_rate,
plateau_interest_rate,
max_interest_rate,
insurance_fee_fixed_apr,
insurance_ir_fee,
protocol_fixed_fee_apr,
protocol_ir_fee,
}: InterestRateConfigCompact,
) -> Self {
Self {
optimal_utilization_rate,
plateau_interest_rate,
max_interest_rate,
insurance_fee_fixed_apr,
insurance_ir_fee,
protocol_fixed_fee_apr,
protocol_ir_fee,
_padding: [0; 32],
fn from(ir_config: InterestRateConfigCompact) -> Self {
InterestRateConfig {
optimal_utilization_rate: ir_config.optimal_utilization_rate,
plateau_interest_rate: ir_config.plateau_interest_rate,
max_interest_rate: ir_config.max_interest_rate,
insurance_fee_fixed_apr: ir_config.insurance_fee_fixed_apr,
insurance_ir_fee: ir_config.insurance_ir_fee,
protocol_fixed_fee_apr: ir_config.protocol_fixed_fee_apr,
protocol_ir_fee: ir_config.protocol_ir_fee,
protocol_origination_fee: ir_config.protocol_origination_fee,
_padding0: [0; 16],
_padding1: [[0; 32]; 3],
}
}
}
Expand All @@ -188,6 +181,7 @@ impl From<InterestRateConfig> for InterestRateConfigCompact {
insurance_ir_fee: ir_config.insurance_ir_fee,
protocol_fixed_fee_apr: ir_config.protocol_fixed_fee_apr,
protocol_ir_fee: ir_config.protocol_ir_fee,
protocol_origination_fee: ir_config.protocol_origination_fee,
}
}
}
Expand Down Expand Up @@ -215,8 +209,10 @@ pub struct InterestRateConfig {
pub protocol_fixed_fee_apr: WrappedI80F48,
/// Earned by the group, goes to `collected_group_fees_outstanding`
pub protocol_ir_fee: WrappedI80F48,
pub protocol_origination_fee: WrappedI80F48,

pub _padding: [u32; 32],
pub _padding0: [u8; 16],
pub _padding1: [[u8; 32]; 3],
}

impl InterestRateConfig {
Expand Down Expand Up @@ -418,6 +414,7 @@ pub struct InterestRateConfigOpt {
pub insurance_ir_fee: Option<WrappedI80F48>,
pub protocol_fixed_fee_apr: Option<WrappedI80F48>,
pub protocol_ir_fee: Option<WrappedI80F48>,
pub protocol_origination_fee: Option<WrappedI80F48>,
}

/// Group level configuration to be used in bank accounts.
Expand Down
13 changes: 13 additions & 0 deletions programs/marginfi/tests/admin_actions/bankruptcy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ async fn marginfi_group_handle_bankruptcy_success(
#[test_case(10_000., BankMint::PyUSD, BankMint::SolSwb)]
#[test_case(10_000., BankMint::PyUSD, BankMint::T22WithFee)]
#[test_case(10_000., BankMint::T22WithFee, BankMint::Sol)]
#[test_case(10_000., BankMint::Usdc, BankMint::SolSwbOrigFee)] // Sol @ ~ $153
#[tokio::test]
async fn marginfi_group_handle_bankruptcy_success_fully_insured(
borrow_amount: f64,
Expand Down Expand Up @@ -447,7 +448,18 @@ async fn marginfi_group_handle_bankruptcy_success_fully_insured(
test_f.get_bank(&debt_mint).mint.mint.decimals,
f64
);
let origination_fee_rate: I80F48 = debt_bank
.config
.interest_rate_config
.protocol_origination_fee
.into();
let origination_fee: I80F48 = I80F48::from_num(borrow_amount_native)
.checked_mul(origination_fee_rate)
.unwrap()
.ceil(); // Round up when repaying
let origination_fee_u64: u64 = origination_fee.checked_to_num().expect("out of bounds");
let actual_borrow_position = borrow_amount_native
+ origination_fee_u64
+ debt_bank_mint_state
.get_extension::<TransferFeeConfig>()
.map(|tf| {
Expand All @@ -466,6 +478,7 @@ async fn marginfi_group_handle_bankruptcy_success_fully_insured(
})
.unwrap_or(0),
);

let expected_liquidity_vault_delta = I80F48::from(actual_borrow_position);

let actual_liquidity_vault_delta = post_liquidity_vault_balance - pre_liquidity_vault_balance;
Expand Down
1 change: 1 addition & 0 deletions programs/marginfi/tests/admin_actions/setup_bank.rs
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ async fn configure_bank_success(bank_mint: BankMint) -> anyhow::Result<()> {
insurance_ir_fee: Some(I80F48::from_num(0.11).into()),
protocol_fixed_fee_apr: Some(I80F48::from_num(0.51).into()),
protocol_ir_fee: Some(I80F48::from_num(0.011).into()),
protocol_origination_fee: Some(I80F48::ZERO.into()),
}),
..BankConfigOpt::default()
};
Expand Down
46 changes: 41 additions & 5 deletions programs/marginfi/tests/user_actions/borrow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ use test_case::test_case;
#[test_case(128932.0, 9834.0, BankMint::PyUSD, BankMint::SolSwb)]
#[test_case(240., 0.092, BankMint::PyUSD, BankMint::T22WithFee)]
#[test_case(36., 1.7, BankMint::T22WithFee, BankMint::Sol)]
#[test_case(200., 1.1, BankMint::Usdc, BankMint::SolSwbOrigFee)] // Sol @ ~ $153
#[tokio::test]
async fn marginfi_account_borrow_success(
deposit_amount: f64,
Expand Down Expand Up @@ -78,13 +79,16 @@ async fn marginfi_account_borrow_success(
// -------------------------------------------------------------------------

let debt_bank_f = test_f.get_bank(&debt_mint);
let bank_before = debt_bank_f.load().await;

let pre_vault_balance = debt_bank_f
.get_vault_token_account(BankVaultType::Liquidity)
.await
.balance()
.await;
let pre_user_debt_accounted = I80F48::ZERO;
let pre_fee_group_fees: I80F48 = bank_before.collected_group_fees_outstanding.into();
let pre_fee_program_fees: I80F48 = bank_before.collected_program_fees_outstanding.into();

let res = user_mfi_account_f
.try_bank_borrow(user_debt_token_account_f.key, debt_bank_f, borrow_amount)
Expand All @@ -101,9 +105,7 @@ async fn marginfi_account_borrow_success(
.lending_account
.get_balance(&debt_bank_f.key)
.unwrap();
let post_user_debt_accounted = debt_bank_f
.load()
.await
let post_user_debt_accounted = bank_before
.get_asset_amount(balance.liability_shares.into())
.unwrap();

Expand All @@ -119,6 +121,23 @@ async fn marginfi_account_borrow_success(
})
.unwrap_or(0);
let borrow_amount_pre_fee = borrow_amount_native + borrow_fee;
let origination_fee_rate: I80F48 = bank_before
.config
.interest_rate_config
.protocol_origination_fee
.into();
let program_fee_rate: I80F48 = test_f
.marginfi_group
.load()
.await
.fee_state_cache
.program_fee_rate
.into();
let origination_fee: I80F48 = I80F48::from_num(borrow_amount_native)
.checked_mul(origination_fee_rate)
.unwrap();
let program_origination_fee: I80F48 = origination_fee.checked_mul(program_fee_rate).unwrap();
let group_origination_fee: I80F48 = origination_fee.saturating_sub(program_origination_fee);

let active_balance_count = marginfi_account
.lending_account
Expand All @@ -130,13 +149,30 @@ async fn marginfi_account_borrow_success(
let actual_liquidity_vault_delta = post_vault_balance as i64 - pre_vault_balance as i64;
let accounted_user_balance_delta = post_user_debt_accounted - pre_user_debt_accounted;

// The liquidity vault paid out just the pre-origination fee amount (e.g. what the user borrowed
// before accounting for the fee)
assert_eq!(expected_liquidity_vault_delta, actual_liquidity_vault_delta);
assert_eq_with_tolerance!(
I80F48::from(expected_liquidity_vault_delta),
// Note: the user still gains debt which includes the origination fee
I80F48::from(expected_liquidity_vault_delta) - origination_fee,
-accounted_user_balance_delta,
1
);

// The outstanding origination fee is recorded
let bank_after = debt_bank_f.load().await;
let post_fee_program_fees: I80F48 = bank_after.collected_program_fees_outstanding.into();
assert_eq!(
pre_fee_program_fees + program_origination_fee,
post_fee_program_fees
);

let post_fee_group_fees: I80F48 = bank_after.collected_group_fees_outstanding.into();
assert_eq!(
pre_fee_group_fees + group_origination_fee,
post_fee_group_fees
);

Ok(())
}

Expand All @@ -147,7 +183,7 @@ async fn marginfi_account_borrow_success(
#[test_case(128_932., 10_000., 15_000.0, BankMint::PyUSD, BankMint::SolSwb)]
#[test_case(240., 0.092, 500., BankMint::PyUSD, BankMint::T22WithFee)]
#[test_case(36., 1.7, 1.9, BankMint::T22WithFee, BankMint::Sol)]
#[test_case(1., 100., 155.1, BankMint::SolSwbPull, BankMint::Usdc)] // Sol @ $155
#[test_case(1., 100., 155.1, BankMint::SolSwbPull, BankMint::Usdc)] // Sol @ ~ $153
#[tokio::test]
async fn marginfi_account_borrow_failure_not_enough_collateral(
deposit_amount: f64,
Expand Down
Loading

0 comments on commit f51c25a

Please sign in to comment.