margin_trade: loan origination fees, limited withdraws

This commit is contained in:
Christian Kamm 2022-05-20 10:55:48 +02:00
parent 21af012d1f
commit 53a5e208fd
7 changed files with 299 additions and 106 deletions

View File

@ -1,14 +1,25 @@
use crate::error::MangoError;
use crate::state::{compute_health_from_fixed_accounts, Bank, Group, HealthType, MangoAccount};
use crate::util::LoadZeroCopy;
use crate::Mango;
use crate::{group_seeds, Mango};
use anchor_lang::prelude::*;
use anchor_spl::token::TokenAccount;
use anchor_spl::token::{self, Token, TokenAccount};
use fixed::types::I80F48;
use solana_program::instruction::Instruction;
use std::cell::Ref;
use std::collections::HashMap;
/// The margin trade instruction
///
/// In addition to these accounts, there must be a sequence of remaining_accounts:
/// 1. health_accounts: accounts needed for health checking
/// 2. target_program_id: the target program account
/// 3. target_accounts: the accounts to pass to the target program
///
/// Every vault address listed in 3. must also have the matching bank and oracle appear in 1.
///
/// Every vault that is to be withdrawn from must appear in the `withdraws` instruction argument.
/// The corresponding bank may be used as an authority for vault withdrawals.
#[derive(Accounts)]
pub struct MarginTrade<'info> {
pub group: AccountLoader<'info, Group>,
@ -21,18 +32,25 @@ pub struct MarginTrade<'info> {
pub account: AccountLoader<'info, MangoAccount>,
pub owner: Signer<'info>,
pub token_program: Program<'info, Token>,
}
struct AllowedVault {
vault_cpi_ai_index: usize,
bank_health_ai_index: usize,
pre_amount: u64,
withdraw_amount: u64,
loan_amount: I80F48,
}
// TODO: add loan fees
/// - `num_health_accounts` is the number of health accounts that remaining_accounts starts with.
/// - `withdraws` is a list of tuples containing the index to a vault in target_accounts and the
/// amount that the target program shall be allowed to withdraw
/// - `cpi_data` is the bytes to call the target_program_id with
pub fn margin_trade<'key, 'accounts, 'remaining, 'info>(
ctx: Context<'key, 'accounts, 'remaining, 'info, MarginTrade<'info>>,
num_health_accounts: usize,
withdraws: Vec<(u8, u64)>,
cpi_data: Vec<u8>,
) -> Result<()> {
let group = ctx.accounts.group.load()?;
@ -44,17 +62,18 @@ pub fn margin_trade<'key, 'accounts, 'remaining, 'info>(
// This is necessary because maybe the user wants to margin trade on a token
// that the account hasn't used before.
// - Collect the addresses of all banks to potentially sign for in cpi_ais.
// - Collect the addresses of all bank vaults.
// Note: This depends on the particular health account ordering.
let mut allowed_banks = HashMap::<Pubkey, Ref<Bank>>::new();
let mut allowed_vaults = HashMap::<Pubkey, usize>::new();
let health_ais = &ctx.remaining_accounts[0..num_health_accounts];
let mut allowed_banks = HashMap::<&Pubkey, Ref<Bank>>::new();
let mut allowed_vaults = HashMap::<Pubkey, usize>::new();
for (i, ai) in health_ais.iter().enumerate() {
match ai.load::<Bank>() {
Ok(bank) => {
require!(bank.group == account.group, MangoError::SomeError);
account.tokens.get_mut_or_create(bank.token_index)?;
allowed_vaults.insert(bank.vault, i);
allowed_banks.insert(*ai.key, bank);
allowed_banks.insert(ai.key, bank);
}
Err(Error::AnchorError(error))
if error.error_code_number == ErrorCode::AccountDiscriminatorMismatch as u32 =>
@ -65,79 +84,118 @@ pub fn margin_trade<'key, 'accounts, 'remaining, 'info>(
};
}
let cpi_program_id = *ctx.remaining_accounts[num_health_accounts].key;
// No self-calls via this method
require!(
cpi_program_id != Mango::id(),
MangoError::InvalidMarginTradeTargetCpiProgram
);
// Check pre-cpi health
let pre_cpi_health =
compute_health_from_fixed_accounts(&account, HealthType::Maint, health_ais)?;
require!(pre_cpi_health >= 0, MangoError::HealthMustBePositive);
msg!("pre_cpi_health {:?}", pre_cpi_health);
// Validate the cpi accounts.
// - Collect the signers for each used mango bank, thereby allowing
// withdraws from the associated vaults.
// - Check that each group-owned token account is the vault of one of the allowed banks,
// and track its balance.
let cpi_program_id = *ctx.remaining_accounts[num_health_accounts].key;
let cpi_ais = &ctx.remaining_accounts[num_health_accounts + 1..];
let mut cpi_ams = cpi_ais
.iter()
.flat_map(|item| item.to_account_metas(None))
.collect::<Vec<_>>();
require!(cpi_ais.len() == cpi_ams.len(), MangoError::SomeError);
let mut bank_signer_data = Vec::with_capacity(allowed_banks.len());
let mut used_vaults = Vec::with_capacity(allowed_vaults.len());
for (i, (ai, am)) in cpi_ais.iter().zip(cpi_ams.iter_mut()).enumerate() {
// The cpi is forbidden from calling back into mango indirectly
require!(
ai.key() != Mango::id(),
MangoError::InvalidMarginTradeTargetCpiProgram
);
// Each allowed bank used in the cpi becomes a signer
if ai.owner == &Mango::id() {
if let Some(bank) = allowed_banks.get(ai.key) {
am.is_signer = true;
// this is the data we'll need later to build the PDA account signer seeds
bank_signer_data.push((bank.token_index.to_le_bytes(), [bank.bump]));
// Check that each group-owned token account is the vault of one of the allowed banks,
// and track its balance.
let mut used_vaults = cpi_ais
.iter()
.enumerate()
.filter_map(|(i, ai)| {
if ai.owner != &TokenAccount::owner() {
return None;
}
}
// Every group-owned token account must be a vault of one of the banks.
if ai.owner == &TokenAccount::owner() {
let token_account = Account::<TokenAccount>::try_from(ai).unwrap();
if token_account.owner == ctx.accounts.group.key() {
if let Some(&bank_index) = allowed_vaults.get(&ai.key) {
used_vaults.push(AllowedVault {
if token_account.owner != ctx.accounts.group.key() {
return None;
}
// Every group-owned token account must be a vault of one of the banks.
if let Some(&bank_index) = allowed_vaults.get(&ai.key) {
return Some(Ok((
ai.key,
AllowedVault {
vault_cpi_ai_index: i,
bank_health_ai_index: bank_index,
pre_amount: token_account.amount,
});
} else {
// This is to protect users, because if their cpi deposits to a vault and they forgot
// to pass in the bank for the vault, their account would not be credited.
require!(false, MangoError::SomeError);
// these two are updated later
withdraw_amount: 0,
loan_amount: I80F48::ZERO,
},
)));
}
// This is to protect users, because if their cpi program sends deposits to a vault
// and they forgot to pass in the bank for the vault, their account would not be credited.
Some(Err(error!(MangoError::SomeError)))
})
.collect::<Result<HashMap<_, _>>>()?;
// Find banks for used vaults in cpi_ais and collect signer seeds for them.
// Also update withdraw_amount and loan_amount.
let mut bank_signer_data = Vec::with_capacity(used_vaults.len());
for (ai, am) in cpi_ais.iter().zip(cpi_ams.iter_mut()) {
if ai.owner != &Mango::id() {
continue;
}
if let Some(bank) = allowed_banks.get(ai.key) {
if let Some(vault_info) = used_vaults.get_mut(&bank.vault) {
let withdraw_amount = withdraws
.iter()
.find_map(|&(index, amount)| {
(index as usize == vault_info.vault_cpi_ai_index).then(|| amount)
})
.unwrap_or(0);
require!(
withdraw_amount <= vault_info.pre_amount,
MangoError::SomeError
);
vault_info.withdraw_amount = withdraw_amount;
// if there are withdraws: figure out loan amount, mark as signer
if withdraw_amount > 0 {
let token_account = account.tokens.get_mut(bank.token_index)?;
let native_position = token_account.native(&bank);
vault_info.loan_amount = if native_position > 0 {
(I80F48::from(withdraw_amount) - native_position).max(I80F48::ZERO)
} else {
I80F48::from(withdraw_amount)
};
am.is_signer = true;
// this is the data we'll need later to build the PDA account signer seeds
bank_signer_data.push((bank.token_index.to_le_bytes(), [bank.bump]));
}
}
}
}
// compute pre cpi health
let pre_cpi_health =
compute_health_from_fixed_accounts(&account, HealthType::Maint, health_ais)?;
require!(pre_cpi_health >= 0, MangoError::HealthMustBePositive);
msg!("pre_cpi_health {:?}", pre_cpi_health);
// Approve bank delegates for withdrawals
let group_seeds = group_seeds!(group);
let seeds = [&group_seeds[..]];
for (_, vault_info) in used_vaults.iter() {
if vault_info.withdraw_amount > 0 {
let approve_ctx = CpiContext::new(
ctx.accounts.token_program.to_account_info(),
token::Approve {
to: cpi_ais[vault_info.vault_cpi_ai_index].clone(),
delegate: health_ais[vault_info.bank_health_ai_index].clone(),
authority: ctx.accounts.group.to_account_info(),
},
)
.with_signer(&seeds);
token::approve(approve_ctx, vault_info.withdraw_amount)?;
}
}
// get rid of Ref<> to avoid limiting the cpi call
drop(allowed_banks);
drop(group);
drop(account);
// prepare and invoke cpi
let cpi_ix = Instruction {
program_id: cpi_program_id,
data: cpi_data,
accounts: cpi_ams,
};
// prepare signer seeds and invoke cpi
let group_key = ctx.accounts.group.key();
let signers = bank_signer_data
.iter()
@ -151,43 +209,72 @@ pub fn margin_trade<'key, 'accounts, 'remaining, 'info>(
})
.collect::<Vec<_>>();
let signers_ref = signers.iter().map(|v| &v[..]).collect::<Vec<_>>();
let cpi_ix = Instruction {
program_id: cpi_program_id,
data: cpi_data,
accounts: cpi_ams,
};
solana_program::program::invoke_signed(&cpi_ix, &cpi_ais, &signers_ref)?;
// Track vault changes and apply them to the user's token positions
let mut account = ctx.accounts.account.load_mut()?;
let inactive_tokens =
adjust_for_post_cpi_vault_amounts(health_ais, cpi_ais, used_vaults, &mut account)?;
adjust_for_post_cpi_vault_amounts(health_ais, cpi_ais, &used_vaults, &mut account)?;
// compute post cpi health
// todo: this is not working, the health is computed on old bank state and not taking into account
// withdraws done in adjust_for_post_cpi_token_amounts
// Check post-cpi health
let post_cpi_health =
compute_health_from_fixed_accounts(&account, HealthType::Init, health_ais)?;
require!(post_cpi_health >= 0, MangoError::HealthMustBePositive);
msg!("post_cpi_health {:?}", post_cpi_health);
// deactivate inactive token accounts after health check
// Deactivate inactive token accounts after health check
for raw_token_index in inactive_tokens {
account.tokens.deactivate(raw_token_index);
}
// Revoke delegates for vaults
let group = ctx.accounts.group.load()?;
let group_seeds = group_seeds!(group);
for (_, vault_info) in used_vaults.iter() {
if vault_info.withdraw_amount > 0 {
let ix = token::spl_token::instruction::revoke(
&token::spl_token::ID,
&cpi_ais[vault_info.vault_cpi_ai_index].key,
&ctx.accounts.group.key(),
&[],
)?;
solana_program::program::invoke_signed(
&ix,
&[
cpi_ais[vault_info.vault_cpi_ai_index].clone(),
ctx.accounts.group.to_account_info(),
],
&[group_seeds],
)?;
}
}
Ok(())
}
fn adjust_for_post_cpi_vault_amounts(
health_ais: &[AccountInfo],
cpi_ais: &[AccountInfo],
used_vaults: Vec<AllowedVault>,
used_vaults: &HashMap<&Pubkey, AllowedVault>,
account: &mut MangoAccount,
) -> Result<Vec<usize>> {
let mut inactive_token_raw_indexes = Vec::with_capacity(used_vaults.len());
for info in used_vaults {
for (_, info) in used_vaults.iter() {
let vault = Account::<TokenAccount>::try_from(&cpi_ais[info.vault_cpi_ai_index]).unwrap();
let mut bank = health_ais[info.bank_health_ai_index].load_mut::<Bank>()?;
let (position, raw_index) = account.tokens.get_mut_or_create(bank.token_index)?;
let is_active = bank.change_with_fee(
let loan_origination_fee = info.loan_amount * bank.loan_origination_fee_rate;
bank.collected_fees_native += loan_origination_fee;
let is_active = bank.change_without_fee(
position,
I80F48::from(vault.amount) - I80F48::from(info.pre_amount),
I80F48::from(vault.amount) - I80F48::from(info.pre_amount) - loan_origination_fee,
)?;
if !is_active {
inactive_token_raw_indexes.push(raw_index);

View File

@ -113,17 +113,6 @@ pub fn register_token(
) -> Result<()> {
// TODO: Error if mint is already configured (technically, init of vault will fail)
// Approve the bank account for withdraws from the vault. This allows us to later sign with a
// bank for foreign cpi calls in margin_trade and thereby give the foreign program the ability
// to withdraw - without the ability to set new delegates or close the token account.
// TODO: we need to refresh this approve occasionally?!
let group = ctx.accounts.group.load()?;
let group_seeds = group_seeds!(group);
token::approve(
ctx.accounts.approve_ctx().with_signer(&[group_seeds]),
u64::MAX,
)?;
let mut bank = ctx.accounts.bank.load_init()?;
*bank = Bank {
name: fill16_from_str(name)?,

View File

@ -100,9 +100,10 @@ pub mod mango_v4 {
pub fn margin_trade<'key, 'accounts, 'remaining, 'info>(
ctx: Context<'key, 'accounts, 'remaining, 'info, MarginTrade<'info>>,
num_health_accounts: usize,
withdraws: Vec<(u8, u64)>,
cpi_data: Vec<u8>,
) -> Result<()> {
instructions::margin_trade(ctx, num_health_accounts, cpi_data)
instructions::margin_trade(ctx, num_health_accounts, withdraws, cpi_data)
}
///

Binary file not shown.

View File

@ -239,6 +239,17 @@ pub async fn account_position(solana: &SolanaCookie, account: Pubkey, bank: Pubk
native.round().to_num::<i64>()
}
pub async fn account_position_f64(solana: &SolanaCookie, account: Pubkey, bank: Pubkey) -> f64 {
let account_data: MangoAccount = solana.get_account(account).await;
let bank_data: Bank = solana.get_account(bank).await;
let native = account_data
.tokens
.find(bank_data.token_index)
.unwrap()
.native(&bank_data);
native.to_num::<f64>()
}
//
// a struct for each instruction along with its
// ClientInstruction impl
@ -249,6 +260,7 @@ pub struct MarginTradeInstruction<'keypair> {
pub owner: &'keypair Keypair,
pub mango_token_bank: Pubkey,
pub mango_token_vault: Pubkey,
pub withdraw_amount: u64,
pub margin_trade_program_id: Pubkey,
pub deposit_account: Pubkey,
pub deposit_account_owner: Pubkey,
@ -270,6 +282,7 @@ impl<'keypair> ClientInstruction for MarginTradeInstruction<'keypair> {
group: account.group,
account: self.account,
owner: self.owner.pubkey(),
token_program: Token::id(),
};
let health_check_metas = derive_health_check_remaining_account_metas(
@ -282,6 +295,7 @@ impl<'keypair> ClientInstruction for MarginTradeInstruction<'keypair> {
let instruction = Self::Instruction {
num_health_accounts: health_check_metas.len(),
withdraws: vec![(1, self.withdraw_amount)],
cpi_data: self.margin_trade_program_ix_cpi_data.clone(),
};

View File

@ -1,7 +1,6 @@
#![cfg(feature = "test-bpf")]
use anchor_lang::InstructionData;
use fixed::types::I80F48;
use solana_program_test::*;
use solana_sdk::signature::Keypair;
use solana_sdk::signature::Signer;
@ -23,9 +22,13 @@ async fn test_margin_trade() -> Result<(), BanksClientError> {
let admin = &Keypair::new();
let owner = &context.users[0].key;
let payer = &context.users[1].key;
let mints = &context.mints[0..1];
let mints = &context.mints[0..2];
let payer_mint0_account = context.users[1].token_accounts[0];
let dust_threshold = 0.01;
let payer_mint1_account = context.users[1].token_accounts[1];
let loan_origination_fee = 0.0005;
// higher resolution that the loan_origination_fee for one token
let balance_f64eq = |a: f64, b: f64| (a - b).abs() < 0.0001;
//
// SETUP: Create a group, account, register a token (mint0)
@ -41,6 +44,51 @@ async fn test_margin_trade() -> Result<(), BanksClientError> {
let bank = tokens[0].bank;
let vault = tokens[0].vault;
//
// provide some funds for tokens, so the test user can borrow
//
let provided_amount = 1000;
let provider_account = send_tx(
solana,
CreateAccountInstruction {
account_num: 1,
group,
owner,
payer,
},
)
.await
.unwrap()
.account;
send_tx(
solana,
DepositInstruction {
amount: provided_amount,
account: provider_account,
token_account: payer_mint0_account,
token_authority: payer,
},
)
.await
.unwrap();
send_tx(
solana,
DepositInstruction {
amount: provided_amount,
account: provider_account,
token_account: payer_mint1_account,
token_authority: payer,
},
)
.await
.unwrap();
//
// create thes test user account
//
let account = send_tx(
solana,
CreateAccountInstruction {
@ -75,22 +123,15 @@ async fn test_margin_trade() -> Result<(), BanksClientError> {
assert_eq!(
solana.token_account_balance(vault).await,
deposit_amount_initial
provided_amount + deposit_amount_initial
);
assert_eq!(
solana.token_account_balance(payer_mint0_account).await,
start_balance - deposit_amount_initial
);
let account_data: MangoAccount = solana.get_account(account).await;
let bank_data: Bank = solana.get_account(bank).await;
assert!(
account_data.tokens.values[0].native(&bank_data)
- I80F48::from_num(deposit_amount_initial)
< dust_threshold
);
assert!(
bank_data.native_total_deposits() - I80F48::from_num(deposit_amount_initial)
< dust_threshold
assert_eq!(
account_position(solana, account, bank).await,
deposit_amount_initial as i64,
);
}
@ -107,6 +148,7 @@ async fn test_margin_trade() -> Result<(), BanksClientError> {
owner,
mango_token_bank: bank,
mango_token_vault: vault,
withdraw_amount,
margin_trade_program_id: margin_trade.program,
deposit_account: margin_trade.token_account.pubkey(),
deposit_account_owner: margin_trade.token_account_owner,
@ -125,7 +167,7 @@ async fn test_margin_trade() -> Result<(), BanksClientError> {
}
assert_eq!(
solana.token_account_balance(vault).await,
deposit_amount_initial - withdraw_amount + deposit_amount
provided_amount + deposit_amount_initial - withdraw_amount + deposit_amount
);
assert_eq!(
solana
@ -133,15 +175,20 @@ async fn test_margin_trade() -> Result<(), BanksClientError> {
.await,
withdraw_amount - deposit_amount
);
// no fee because user had positive balance
assert!(balance_f64eq(
account_position_f64(solana, account, bank).await,
(deposit_amount_initial - withdraw_amount + deposit_amount) as f64
));
//
// TEST: Bringing the balance to 0 deactivates the token
//
let deposit_amount_initial = solana.token_account_balance(vault).await;
let deposit_amount_initial = account_position(solana, account, bank).await;
let margin_account_initial = solana
.token_account_balance(margin_trade.token_account.pubkey())
.await;
let withdraw_amount = deposit_amount_initial;
let withdraw_amount = deposit_amount_initial as u64;
let deposit_amount = 0;
{
send_tx(
@ -151,6 +198,7 @@ async fn test_margin_trade() -> Result<(), BanksClientError> {
owner,
mango_token_bank: bank,
mango_token_vault: vault,
withdraw_amount,
margin_trade_program_id: margin_trade.program,
deposit_account: margin_trade.token_account.pubkey(),
deposit_account_owner: margin_trade.token_account_owner,
@ -167,7 +215,7 @@ async fn test_margin_trade() -> Result<(), BanksClientError> {
.await
.unwrap();
}
assert_eq!(solana.token_account_balance(vault).await, 0);
assert_eq!(solana.token_account_balance(vault).await, provided_amount);
assert_eq!(
solana
.token_account_balance(margin_trade.token_account.pubkey())
@ -194,6 +242,7 @@ async fn test_margin_trade() -> Result<(), BanksClientError> {
owner,
mango_token_bank: bank,
mango_token_vault: vault,
withdraw_amount,
margin_trade_program_id: margin_trade.program,
deposit_account: margin_trade.token_account.pubkey(),
deposit_account_owner: margin_trade.token_account_owner,
@ -210,16 +259,67 @@ async fn test_margin_trade() -> Result<(), BanksClientError> {
.await
.unwrap();
}
assert_eq!(solana.token_account_balance(vault).await, deposit_amount);
assert_eq!(
solana.token_account_balance(vault).await,
provided_amount + deposit_amount
);
assert_eq!(
solana
.token_account_balance(margin_trade.token_account.pubkey())
.await,
0
);
// Check that position is active
let account_data: MangoAccount = solana.get_account(account).await;
assert_eq!(account_data.tokens.iter_active().count(), 1);
assert!(balance_f64eq(
account_position_f64(solana, account, bank).await,
deposit_amount as f64
));
//
// TEST: Try loan fees by withdrawing more than the user balance
//
let deposit_amount_initial = account_position(solana, account, bank).await as u64;
let withdraw_amount = 500;
let deposit_amount = 450;
{
send_tx(
solana,
MarginTradeInstruction {
account,
owner,
mango_token_bank: bank,
mango_token_vault: vault,
withdraw_amount,
margin_trade_program_id: margin_trade.program,
deposit_account: margin_trade.token_account.pubkey(),
deposit_account_owner: margin_trade.token_account_owner,
margin_trade_program_ix_cpi_data: {
let ix = margin_trade::instruction::MarginTrade {
amount_from: withdraw_amount,
amount_to: deposit_amount,
deposit_account_owner_bump_seeds: margin_trade.token_account_bump,
};
ix.data()
},
},
)
.await
.unwrap();
}
assert_eq!(
solana.token_account_balance(vault).await,
provided_amount + deposit_amount_initial + deposit_amount - withdraw_amount
);
assert_eq!(
solana
.token_account_balance(margin_trade.token_account.pubkey())
.await,
withdraw_amount - deposit_amount
);
assert!(balance_f64eq(
account_position_f64(solana, account, bank).await,
(deposit_amount_initial + deposit_amount - withdraw_amount) as f64
- (withdraw_amount - deposit_amount_initial) as f64 * loan_origination_fee
));
Ok(())
}

View File

@ -14,12 +14,14 @@ pub mod margin_trade {
deposit_account_owner_bump_seeds: u8,
amount_to: u64,
) -> Result<()> {
msg!(
"withdrawing({}) for mint {:?}",
amount_from,
ctx.accounts.withdraw_account.mint
);
token::transfer(ctx.accounts.transfer_from_mango_vault_ctx(), amount_from)?;
if amount_from > 0 {
msg!(
"withdrawing({}) for mint {:?}",
amount_from,
ctx.accounts.withdraw_account.mint
);
token::transfer(ctx.accounts.transfer_from_mango_vault_ctx(), amount_from)?;
}
msg!("TODO: do something with the loan");
@ -52,7 +54,7 @@ impl anchor_lang::Id for MarginTrade {
#[derive(Accounts)]
pub struct MarginTradeCtx<'info> {
pub withdraw_account_owner: Signer<'info>,
pub withdraw_account_owner: UncheckedAccount<'info>,
#[account(mut)]
pub withdraw_account: Account<'info, TokenAccount>,