From 86d635f5d046b3bdb629fb6e95ef36df0b0aa92f Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Mon, 27 Jun 2022 13:42:27 +0200 Subject: [PATCH] FlashLoan3: Variant that transfers directly FlashLoan2 requires the user to set up transfers to and from the Mango vault accounts. This version directly provides the loaned funds into a target token account and repays everything that exceeds the initial token account balance at the end. --- .../mango-v4/src/instructions/flash_loan3.rs | 293 ++++++++++++++++++ programs/mango-v4/src/instructions/mod.rs | 2 + programs/mango-v4/src/lib.rs | 13 + .../tests/program_test/mango_client.rs | 108 +++++++ programs/mango-v4/tests/test_margin_trade.rs | 258 +++++++++++++++ 5 files changed, 674 insertions(+) create mode 100644 programs/mango-v4/src/instructions/flash_loan3.rs diff --git a/programs/mango-v4/src/instructions/flash_loan3.rs b/programs/mango-v4/src/instructions/flash_loan3.rs new file mode 100644 index 000000000..f7dd1f4e8 --- /dev/null +++ b/programs/mango-v4/src/instructions/flash_loan3.rs @@ -0,0 +1,293 @@ +use crate::accounts_zerocopy::*; +use crate::error::MangoError; +use crate::group_seeds; +use crate::state::{compute_health_from_fixed_accounts, Bank, Group, HealthType, MangoAccount}; +use crate::util::checked_math as cm; +use anchor_lang::prelude::*; +use anchor_lang::solana_program::sysvar::instructions as tx_instructions; +use anchor_spl::token::{self, Token, TokenAccount}; +use fixed::types::I80F48; + +/// Sets up mango vaults for flash loan +/// +/// In addition to these accounts, there must be a sequence of remaining_accounts: +/// 1. N banks +/// 2. N vaults, matching the banks +/// 3. N token accounts, where loaned funds are transfered +#[derive(Accounts)] +pub struct FlashLoan3Begin<'info> { + pub group: AccountLoader<'info, Group>, + pub token_program: Program<'info, Token>, + + #[account(address = tx_instructions::ID)] + pub instructions: UncheckedAccount<'info>, +} + +/// Finalizes a flash loan +/// +/// In addition to these accounts, there must be a sequence of remaining_accounts: +/// 1. health accounts +/// 2. N vaults, matching what was in FlashLoan3Begin +/// 3. N token accounts, matching what was in FlashLoan3Begin +#[derive(Accounts)] +pub struct FlashLoan3End<'info> { + #[account( + mut, + has_one = owner, + )] + pub account: AccountLoader<'info, MangoAccount>, + pub owner: Signer<'info>, + pub token_program: Program<'info, Token>, +} + +pub fn flash_loan3_begin<'key, 'accounts, 'remaining, 'info>( + ctx: Context<'key, 'accounts, 'remaining, 'info, FlashLoan3Begin<'info>>, + loan_amounts: Vec, +) -> Result<()> { + let num_loans = loan_amounts.len(); + require_eq!( + ctx.remaining_accounts.len(), + 3 * num_loans, + MangoError::SomeError + ); + let banks = &ctx.remaining_accounts[..num_loans]; + let vaults = &ctx.remaining_accounts[num_loans..2 * num_loans]; + let token_accounts = &ctx.remaining_accounts[2 * num_loans..]; + + let group = ctx.accounts.group.load()?; + let group_seeds = group_seeds!(group); + let seeds = [&group_seeds[..]]; + + // Check that the banks and vaults correspond + for (((bank_ai, vault_ai), token_account_ai), amount) in banks + .iter() + .zip(vaults.iter()) + .zip(token_accounts.iter()) + .zip(loan_amounts.iter()) + { + let mut bank = bank_ai.load_mut::()?; + require_keys_eq!(bank.group, ctx.accounts.group.key()); + require_keys_eq!(bank.vault, *vault_ai.key); + + let token_account = Account::::try_from(token_account_ai)?; + + bank.flash_loan_approved_amount = *amount; + bank.flash_loan_vault_initial = token_account.amount; + + // Transfer the loaned funds + if *amount > 0 { + let transfer_ctx = CpiContext::new( + ctx.accounts.token_program.to_account_info(), + token::Transfer { + from: vault_ai.clone(), + to: token_account_ai.clone(), + authority: ctx.accounts.group.to_account_info(), + }, + ) + .with_signer(&seeds); + token::transfer(transfer_ctx, *amount)?; + } + } + + // Check if the other instructions in the transactions are compatible + { + let ixs = ctx.accounts.instructions.as_ref(); + let current_index = tx_instructions::load_current_index_checked(ixs)? as usize; + + // Forbid FlashLoan3Begin to be called from CPI (it does not have to be the first instruction) + let current_ix = tx_instructions::load_instruction_at_checked(current_index, ixs)?; + require_keys_eq!( + current_ix.program_id, + *ctx.program_id, + MangoError::SomeError + ); + + // The only other mango instruction that must appear before the end of the tx is + // the FlashLoan3End instruction. No other mango instructions are allowed. + let mut index = current_index + 1; + let mut found_end = false; + loop { + let ix = match tx_instructions::load_instruction_at_checked(index, ixs) { + Ok(ix) => ix, + Err(ProgramError::InvalidArgument) => break, // past the last instruction + Err(e) => Err(e)?, + }; + + // Check that the mango program key is not used + if ix.program_id == crate::id() { + // must be the last mango ix -- this could possibly be relaxed, but right now + // we need to guard against multiple FlashLoanEnds + require!(!found_end, MangoError::SomeError); + found_end = true; + + // must be the FlashLoan3End instruction + require!( + &ix.data[0..8] == &[163, 231, 155, 56, 201, 68, 84, 148], + MangoError::SomeError + ); + + // check that the same vaults are passed + let begin_accounts = &ctx.remaining_accounts[num_loans..]; + let end_accounts = &ix.accounts[ix.accounts.len() - 2 * num_loans..]; + for (begin_account, end_account) in begin_accounts.iter().zip(end_accounts.iter()) { + require_keys_eq!(*begin_account.key, end_account.pubkey); + } + } else { + // ensure no one can cpi into mango either + for meta in ix.accounts.iter() { + require_keys_neq!(meta.pubkey, crate::id()); + } + } + + index += 1; + } + require!(found_end, MangoError::SomeError); + } + + Ok(()) +} + +struct TokenVaultChange { + bank_index: usize, + raw_token_index: usize, + amount: I80F48, +} + +pub fn flash_loan3_end<'key, 'accounts, 'remaining, 'info>( + ctx: Context<'key, 'accounts, 'remaining, 'info, FlashLoan3End<'info>>, +) -> Result<()> { + let mut account = ctx.accounts.account.load_mut()?; + require!(account.is_bankrupt == 0, MangoError::IsBankrupt); + + // Find index at which vaults start + let vaults_index = ctx + .remaining_accounts + .iter() + .position(|ai| { + let maybe_token_account = Account::::try_from(ai); + if maybe_token_account.is_err() { + return false; + } + + maybe_token_account.unwrap().owner == account.group + }) + .ok_or_else(|| error!(MangoError::SomeError))?; + let vaults_len = (ctx.remaining_accounts.len() - vaults_index) / 2; + require_eq!(ctx.remaining_accounts.len(), vaults_index + 2 * vaults_len); + + // First initialize to the remaining delegated amount + let health_ais = &ctx.remaining_accounts[..vaults_index]; + let vaults = &ctx.remaining_accounts[vaults_index..vaults_index + vaults_len]; + let token_accounts = &ctx.remaining_accounts[vaults_index + vaults_len..]; + let mut vaults_with_banks = vec![false; vaults.len()]; + + // Loop over the banks, finding matching vaults + // TODO: must be moved into health.rs, because it assumes something about the health accounts structure + let mut changes = vec![]; + for (i, bank_ai) in health_ais.iter().enumerate() { + // iterate until the first non-bank + let bank = match bank_ai.load::() { + Ok(b) => b, + Err(_) => break, + }; + + // find a vault -- if there's none, skip + let (vault_index, vault_ai) = match vaults + .iter() + .enumerate() + .find(|(_, vault_ai)| vault_ai.key == &bank.vault) + { + Some(v) => v, + None => continue, + }; + + vaults_with_banks[vault_index] = true; + let token_account_ai = &token_accounts[vault_index]; + let token_account = Account::::try_from(&token_account_ai)?; + + // Ensure this bank/vault combination was mentioned in the Begin instruction: + // The Begin instruction only checks that End ends with the same vault accounts - + // but there could be an extra vault account in End, or a different bank could be + // used for the same vault. + require_neq!(bank.flash_loan_vault_initial, u64::MAX); + + // Create the token position now, so we can compute the pre-health with fixed order health accounts + let (_, raw_token_index) = account.tokens.get_mut_or_create(bank.token_index)?; + + // Transfer any excess over the inital balance of the token account back + // into the vault. Compute the total change in the vault balance. + let mut change = -I80F48::from(bank.flash_loan_approved_amount); + if token_account.amount > bank.flash_loan_vault_initial { + let transfer_ctx = CpiContext::new( + ctx.accounts.token_program.to_account_info(), + token::Transfer { + from: token_account_ai.clone(), + to: vault_ai.clone(), + authority: ctx.accounts.owner.to_account_info(), + }, + ); + let repay = token_account.amount - bank.flash_loan_vault_initial; + token::transfer(transfer_ctx, repay)?; + + let repay = I80F48::from(repay); + change = cm!(change + repay); + } + + changes.push(TokenVaultChange { + bank_index: i, + raw_token_index, + amount: change, + }); + } + + // all vaults must have had matching banks + require!(vaults_with_banks.iter().all(|&b| b), MangoError::SomeError); + + // Check pre-cpi health + // NOTE: This health check isn't strictly necessary. It will be, later, when + // we want to have reduce_only or be able to move an account out of bankruptcy. + let pre_cpi_health = + compute_health_from_fixed_accounts(&account, HealthType::Init, health_ais)?; + require!(pre_cpi_health >= 0, MangoError::HealthMustBePositive); + msg!("pre_cpi_health {:?}", pre_cpi_health); + + // Apply the vault diffs to the bank positions + let mut deactivated_token_positions = vec![]; + for change in changes { + let mut bank = health_ais[change.bank_index].load_mut::()?; + let position = account.tokens.get_mut_raw(change.raw_token_index); + let native = position.native(&bank); + let approved_amount = I80F48::from(bank.flash_loan_approved_amount); + + let loan = if native.is_positive() { + cm!(approved_amount - native).max(I80F48::ZERO) + } else { + approved_amount + }; + + let loan_origination_fee = cm!(loan * bank.loan_origination_fee_rate); + bank.collected_fees_native = cm!(bank.collected_fees_native + loan_origination_fee); + + let is_active = + bank.change_without_fee(position, cm!(change.amount - loan_origination_fee))?; + if !is_active { + deactivated_token_positions.push(change.raw_token_index); + } + + bank.flash_loan_approved_amount = 0; + bank.flash_loan_vault_initial = u64::MAX; + } + + // 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 + for raw_token_index in deactivated_token_positions { + account.tokens.deactivate(raw_token_index); + } + + Ok(()) +} diff --git a/programs/mango-v4/src/instructions/mod.rs b/programs/mango-v4/src/instructions/mod.rs index 1570e3f35..e7f170380 100644 --- a/programs/mango-v4/src/instructions/mod.rs +++ b/programs/mango-v4/src/instructions/mod.rs @@ -8,6 +8,7 @@ pub use create_group::*; pub use create_stub_oracle::*; pub use flash_loan::*; pub use flash_loan2::*; +pub use flash_loan3::*; pub use liq_token_with_token::*; pub use perp_cancel_all_orders::*; pub use perp_cancel_all_orders_by_side::*; @@ -45,6 +46,7 @@ mod create_group; mod create_stub_oracle; mod flash_loan; mod flash_loan2; +mod flash_loan3; mod liq_token_with_token; mod perp_cancel_all_orders; mod perp_cancel_all_orders_by_side; diff --git a/programs/mango-v4/src/lib.rs b/programs/mango-v4/src/lib.rs index 19505fab9..01039bf6e 100644 --- a/programs/mango-v4/src/lib.rs +++ b/programs/mango-v4/src/lib.rs @@ -155,6 +155,19 @@ pub mod mango_v4 { instructions::flash_loan2_end(ctx) } + pub fn flash_loan3_begin<'key, 'accounts, 'remaining, 'info>( + ctx: Context<'key, 'accounts, 'remaining, 'info, FlashLoan3Begin<'info>>, + loan_amounts: Vec, + ) -> Result<()> { + instructions::flash_loan3_begin(ctx, loan_amounts) + } + + pub fn flash_loan3_end<'key, 'accounts, 'remaining, 'info>( + ctx: Context<'key, 'accounts, 'remaining, 'info, FlashLoan3End<'info>>, + ) -> Result<()> { + instructions::flash_loan3_end(ctx) + } + /// /// Serum /// diff --git a/programs/mango-v4/tests/program_test/mango_client.rs b/programs/mango-v4/tests/program_test/mango_client.rs index a5de8180f..cbcef7a28 100644 --- a/programs/mango-v4/tests/program_test/mango_client.rs +++ b/programs/mango-v4/tests/program_test/mango_client.rs @@ -513,6 +513,114 @@ impl<'keypair> ClientInstruction for FlashLoan2EndInstruction<'keypair> { } } +pub struct FlashLoan3BeginInstruction { + pub group: Pubkey, + pub mango_token_bank: Pubkey, + pub mango_token_vault: Pubkey, + pub target_token_account: Pubkey, + pub withdraw_amount: u64, +} +#[async_trait::async_trait(?Send)] +impl ClientInstruction for FlashLoan3BeginInstruction { + type Accounts = mango_v4::accounts::FlashLoan3Begin; + type Instruction = mango_v4::instruction::FlashLoan3Begin; + async fn to_instruction( + &self, + _account_loader: impl ClientAccountLoader + 'async_trait, + ) -> (Self::Accounts, instruction::Instruction) { + let program_id = mango_v4::id(); + + let accounts = Self::Accounts { + group: self.group, + token_program: Token::id(), + instructions: solana_program::sysvar::instructions::id(), + }; + + let instruction = Self::Instruction { + loan_amounts: vec![self.withdraw_amount], + }; + + let mut instruction = make_instruction(program_id, &accounts, instruction); + instruction.accounts.push(AccountMeta { + pubkey: self.mango_token_bank, + is_writable: true, + is_signer: false, + }); + instruction.accounts.push(AccountMeta { + pubkey: self.mango_token_vault, + is_writable: true, + is_signer: false, + }); + instruction.accounts.push(AccountMeta { + pubkey: self.target_token_account, + is_writable: true, + is_signer: false, + }); + + (accounts, instruction) + } + + fn signers(&self) -> Vec<&Keypair> { + vec![] + } +} + +pub struct FlashLoan3EndInstruction<'keypair> { + pub account: Pubkey, + pub owner: &'keypair Keypair, + pub mango_token_bank: Pubkey, + pub mango_token_vault: Pubkey, + pub target_token_account: Pubkey, +} +#[async_trait::async_trait(?Send)] +impl<'keypair> ClientInstruction for FlashLoan3EndInstruction<'keypair> { + type Accounts = mango_v4::accounts::FlashLoan3End; + type Instruction = mango_v4::instruction::FlashLoan3End; + async fn to_instruction( + &self, + account_loader: impl ClientAccountLoader + 'async_trait, + ) -> (Self::Accounts, instruction::Instruction) { + let program_id = mango_v4::id(); + let instruction = Self::Instruction {}; + + let account: MangoAccount = account_loader.load(&self.account).await.unwrap(); + + let health_check_metas = derive_health_check_remaining_account_metas( + &account_loader, + &account, + Some(self.mango_token_bank), + true, + None, + ) + .await; + + let accounts = Self::Accounts { + account: self.account, + owner: self.owner.pubkey(), + token_program: Token::id(), + }; + + let mut instruction = make_instruction(program_id, &accounts, instruction); + instruction.accounts.extend(health_check_metas.into_iter()); + instruction.accounts.push(AccountMeta { + pubkey: self.mango_token_vault, + is_writable: true, + is_signer: false, + }); + instruction.accounts.push(AccountMeta { + pubkey: self.target_token_account, + is_writable: true, + is_signer: false, + }); + + (accounts, instruction) + } + + fn signers(&self) -> Vec<&Keypair> { + vec![self.owner] + } +} + pub struct TokenWithdrawInstruction<'keypair> { pub amount: u64, pub allow_borrow: bool, diff --git a/programs/mango-v4/tests/test_margin_trade.rs b/programs/mango-v4/tests/test_margin_trade.rs index 2a48d8528..646be2c62 100644 --- a/programs/mango-v4/tests/test_margin_trade.rs +++ b/programs/mango-v4/tests/test_margin_trade.rs @@ -581,3 +581,261 @@ async fn test_margin_trade2() -> Result<(), BanksClientError> { Ok(()) } + +// This is an unspecific happy-case test that just runs a few instructions to check +// that they work in principle. It should be split up / renamed. +#[tokio::test] +async fn test_margin_trade3() -> Result<(), BanksClientError> { + let builder = TestContextBuilder::new(); + let context = builder.start_default().await; + let solana = &context.solana.clone(); + + let admin = &Keypair::new(); + let owner = &context.users[0].key; + let payer = &context.users[1].key; + let mints = &context.mints[0..2]; + let payer_mint0_account = context.users[1].token_accounts[0]; + 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) + // + + let mango_setup::GroupWithTokens { group, tokens } = mango_setup::GroupWithTokensConfig { + admin, + payer, + mints, + } + .create(solana) + .await; + 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, + TokenDepositInstruction { + amount: provided_amount, + account: provider_account, + token_account: payer_mint0_account, + token_authority: payer, + }, + ) + .await + .unwrap(); + send_tx( + solana, + TokenDepositInstruction { + 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 { + account_num: 0, + group, + owner, + payer, + }, + ) + .await + .unwrap() + .account; + + // + // TEST: Deposit funds + // + let deposit_amount_initial = 100; + { + let start_balance = solana.token_account_balance(payer_mint0_account).await; + + send_tx( + solana, + TokenDepositInstruction { + amount: deposit_amount_initial, + account, + token_account: payer_mint0_account, + token_authority: payer, + }, + ) + .await + .unwrap(); + + assert_eq!( + solana.token_account_balance(vault).await, + provided_amount + deposit_amount_initial + ); + assert_eq!( + solana.token_account_balance(payer_mint0_account).await, + start_balance - deposit_amount_initial + ); + assert_eq!( + account_position(solana, account, bank).await, + deposit_amount_initial as i64, + ); + } + + // + // TEST: Margin trade + // + let margin_account = payer_mint0_account; + let margin_account_initial = solana.token_account_balance(margin_account).await; + let target_token_account = context.users[0].token_accounts[0]; + let withdraw_amount = 2; + let deposit_amount = 1; + let send_flash_loan_tx = |solana, withdraw_amount, deposit_amount| async move { + let mut tx = ClientTransaction::new(solana); + tx.add_instruction(FlashLoan3BeginInstruction { + group, + mango_token_bank: bank, + mango_token_vault: vault, + target_token_account, + withdraw_amount, + }) + .await; + if withdraw_amount > 0 { + tx.add_instruction_direct( + spl_token::instruction::transfer( + &spl_token::ID, + &target_token_account, + &margin_account, + &owner.pubkey(), + &[&owner.pubkey()], + withdraw_amount, + ) + .unwrap(), + ); + } + if deposit_amount > 0 { + tx.add_instruction_direct( + spl_token::instruction::transfer( + &spl_token::ID, + &margin_account, + &target_token_account, + &payer.pubkey(), + &[&payer.pubkey()], + deposit_amount, + ) + .unwrap(), + ); + tx.add_signer(&payer); + } + tx.add_instruction(FlashLoan3EndInstruction { + account, + owner, + mango_token_bank: bank, + mango_token_vault: vault, + target_token_account, + }) + .await; + tx.send().await.unwrap(); + }; + send_flash_loan_tx(solana, withdraw_amount, deposit_amount).await; + + assert_eq!( + solana.token_account_balance(vault).await, + provided_amount + deposit_amount_initial - withdraw_amount + deposit_amount + ); + assert_eq!( + solana.token_account_balance(margin_account).await, + margin_account_initial + 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 = account_position(solana, account, bank).await; + let margin_account_initial = solana.token_account_balance(margin_account).await; + let withdraw_amount = deposit_amount_initial as u64; + let deposit_amount = 0; + send_flash_loan_tx(solana, withdraw_amount, deposit_amount).await; + assert_eq!(solana.token_account_balance(vault).await, provided_amount); + assert_eq!( + solana.token_account_balance(margin_account).await, + margin_account_initial + withdraw_amount + ); + // Check that position is fully deactivated + let account_data: MangoAccount = solana.get_account(account).await; + assert_eq!(account_data.tokens.iter_active().count(), 0); + + // + // TEST: Activating a token via margin trade + // + let margin_account_initial = solana.token_account_balance(margin_account).await; + let withdraw_amount = 0; + let deposit_amount = 100; + send_flash_loan_tx(solana, withdraw_amount, deposit_amount).await; + assert_eq!( + solana.token_account_balance(vault).await, + provided_amount + deposit_amount + ); + assert_eq!( + solana.token_account_balance(margin_account).await, + margin_account_initial - deposit_amount + ); + 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 margin_account_initial = solana.token_account_balance(margin_account).await; + let deposit_amount_initial = account_position(solana, account, bank).await as u64; + let withdraw_amount = 500; + let deposit_amount = 450; + println!("{}", deposit_amount_initial); + send_flash_loan_tx(solana, withdraw_amount, deposit_amount).await; + assert_eq!( + solana.token_account_balance(vault).await, + provided_amount + deposit_amount_initial + deposit_amount - withdraw_amount + ); + assert_eq!( + solana.token_account_balance(margin_account).await, + margin_account_initial + 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(()) +}