diff --git a/.gitignore b/.gitignore index 24332c8f2..8b337f111 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ node_modules .idea +programs/margin-trade/src/lib-expanded.rs programs/mango-v4/src/lib-expanded.rs diff --git a/Cargo.lock b/Cargo.lock index 77126dd97..ddde4da3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1494,6 +1494,7 @@ dependencies = [ "fixed", "fixed-macro", "log", + "margin-trade", "pyth-client", "serde", "solana-logger", @@ -1505,6 +1506,15 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "margin-trade" +version = "0.1.0" +dependencies = [ + "anchor-lang", + "anchor-spl", + "solana-program", +] + [[package]] name = "matches" version = "0.1.9" diff --git a/programs/mango-v4/Cargo.toml b/programs/mango-v4/Cargo.toml index f573be86e..8ee8f8ecd 100644 --- a/programs/mango-v4/Cargo.toml +++ b/programs/mango-v4/Cargo.toml @@ -27,9 +27,9 @@ bytemuck = "^1.7.2" fixed = { version = "=1.11.0", features = ["serde", "borsh"] } fixed-macro = "^1.1.1" pyth-client = {version = "0.5.0", features = ["no-entrypoint"]} -static_assertions = "1.1" -solana-program = "1.9.5" serde = "^1.0" +solana-program = "1.9.5" +static_assertions = "1.1" [dev-dependencies] @@ -43,3 +43,4 @@ log = "0.4.14" env_logger = "0.9.0" base64 = "0.13.0" async-trait = "0.1.52" +margin-trade = { path = "../margin-trade", features = ["cpi"] } diff --git a/programs/mango-v4/src/instructions/margin_trade.rs b/programs/mango-v4/src/instructions/margin_trade.rs index 06eef9591..a89cd4058 100644 --- a/programs/mango-v4/src/instructions/margin_trade.rs +++ b/programs/mango-v4/src/instructions/margin_trade.rs @@ -1,9 +1,10 @@ use crate::error::MangoError; -use crate::state::{compute_health, MangoAccount, MangoGroup}; -use crate::util::to_account_meta; -use crate::{group_seeds, Mango}; +use crate::state::{compute_health, MangoAccount, MangoGroup, TokenBank}; +use crate::{group_seeds, util, Mango}; use anchor_lang::prelude::*; +use anchor_spl::token::TokenAccount; use solana_program::instruction::Instruction; +use std::cell::{Ref, RefMut}; #[derive(Accounts)] pub struct MarginTrade<'info> { @@ -20,18 +21,44 @@ pub struct MarginTrade<'info> { } /// reference https://github.com/blockworks-foundation/mango-v3/blob/mc/flash_loan/program/src/processor.rs#L5323 -pub fn margin_trade(ctx: Context, cpi_data: Vec) -> Result<()> { +pub fn margin_trade<'key, 'accounts, 'remaining, 'info>( + ctx: Context<'key, 'accounts, 'remaining, 'info, MarginTrade<'info>>, + cpi_data: Vec, +) -> Result<()> { let group = ctx.accounts.group.load()?; let mut account = ctx.accounts.account.load_mut()?; let active_len = account.indexed_positions.iter_active().count(); + + // remaining_accounts layout is expected as follows + // * active_len number of banks + // * active_len number of oracles + // * cpi_program + // * cpi_accounts + let banks = &ctx.remaining_accounts[0..active_len]; let oracles = &ctx.remaining_accounts[active_len..active_len * 2]; - let cpi_ais = &ctx.remaining_accounts[active_len * 2..]; + + let cpi_program_id = *ctx.remaining_accounts[active_len * 2].key; + + // prepare for cpi + let (cpi_ais, cpi_ams) = { + // we also need the group + let mut cpi_ais = [ctx.accounts.group.to_account_info()].to_vec(); + // skip banks, oracles and cpi program from the remaining_accounts + let mut remaining_cpi_ais = ctx.remaining_accounts[active_len * 2 + 1..].to_vec(); + cpi_ais.append(&mut remaining_cpi_ais); + + let mut cpi_ams = cpi_ais.to_account_metas(Option::None); + // we want group to be the signer, so that loans can be taken from the token vaults + cpi_ams[0].is_signer = true; + + (cpi_ais, cpi_ams) + }; // since we are using group signer seeds to invoke cpi, // assert that none of the cpi accounts is the mango program to prevent that invoker doesn't // abuse this ix to do unwanted changes - for cpi_ai in cpi_ais { + for cpi_ai in &cpi_ais { require!( cpi_ai.key() != Mango::id(), MangoError::InvalidMarginTradeTargetCpiProgram @@ -39,25 +66,85 @@ pub fn margin_trade(ctx: Context, cpi_data: Vec) -> Result<()> } // compute pre cpi health - let pre_cpi_health = compute_health(&mut account, &banks, &oracles).unwrap(); + let pre_cpi_health = compute_health(&mut account, &banks, &oracles)?; require!(pre_cpi_health > 0, MangoError::HealthMustBePositive); + msg!("pre_cpi_health {:?}", pre_cpi_health); // prepare and invoke cpi let cpi_ix = Instruction { - program_id: *ctx.remaining_accounts[active_len].key, + program_id: cpi_program_id, data: cpi_data, - accounts: cpi_ais - .iter() - .skip(1) - .map(|cpi_ai| to_account_meta(cpi_ai)) - .collect(), + accounts: cpi_ams, }; let group_seeds = group_seeds!(group); + let pre_cpi_token_vault_amounts = get_pre_cpi_token_amounts(&ctx, &cpi_ais); solana_program::program::invoke_signed(&cpi_ix, &cpi_ais, &[group_seeds])?; + adjust_for_post_cpi_token_amounts( + &ctx, + &cpi_ais, + pre_cpi_token_vault_amounts, + group, + &mut banks.to_vec(), + &mut account, + )?; // compute post cpi health - let post_cpi_health = compute_health(&mut account, &banks, &oracles).unwrap(); + let post_cpi_health = compute_health(&account, &banks, &oracles)?; require!(post_cpi_health > 0, MangoError::HealthMustBePositive); + msg!("post_cpi_health {:?}", post_cpi_health); Ok(()) } + +fn get_pre_cpi_token_amounts(ctx: &Context, cpi_ais: &Vec) -> Vec { + let mut mango_vault_token_account_amounts = vec![]; + for maybe_token_account in cpi_ais + .iter() + .filter(|ai| ai.owner == &TokenAccount::owner()) + { + let maybe_mango_vault_token_account = + Account::::try_from(maybe_token_account).unwrap(); + if maybe_mango_vault_token_account.owner == ctx.accounts.group.key() { + mango_vault_token_account_amounts.push(maybe_mango_vault_token_account.amount) + } + } + mango_vault_token_account_amounts +} + +/// withdraws from bank, on users behalf, if he hasn't returned back entire loan amount +fn adjust_for_post_cpi_token_amounts( + ctx: &Context, + cpi_ais: &Vec, + pre_cpi_token_vault_amounts: Vec, + group: Ref, + banks: &mut Vec, + account: &mut RefMut, +) -> Result<()> { + let x = cpi_ais + .iter() + .filter(|ai| ai.owner == &TokenAccount::owner()); + + for (maybe_token_account, (pre_cpi_token_vault_amount, bank_ai)) in + util::zip!(x, pre_cpi_token_vault_amounts.iter(), banks.iter()) + { + let maybe_mango_vault_token_account = + Account::::try_from(maybe_token_account).unwrap(); + if maybe_mango_vault_token_account.owner == ctx.accounts.group.key() { + let still_loaned_amount = + pre_cpi_token_vault_amount - maybe_mango_vault_token_account.amount; + if still_loaned_amount <= 0 { + continue; + } + + let token_index = group + .tokens + .index_for_mint(&maybe_mango_vault_token_account.mint)?; + let mut position = *account.indexed_positions.get_mut_or_create(token_index)?; + let bank_loader = AccountLoader::<'_, TokenBank>::try_from(bank_ai)?; + let mut bank = bank_loader.load_mut()?; + // todo: this doesnt work since bank is not mut in the tests atm + bank.withdraw(&mut position, still_loaned_amount); + } + } + Ok(()) +} diff --git a/programs/mango-v4/src/instructions/mod.rs b/programs/mango-v4/src/instructions/mod.rs index b11a4c435..039c6ec8e 100644 --- a/programs/mango-v4/src/instructions/mod.rs +++ b/programs/mango-v4/src/instructions/mod.rs @@ -1,8 +1,8 @@ +pub use self::margin_trade::*; pub use create_account::*; pub use create_group::*; pub use create_stub_oracle::*; pub use deposit::*; -pub use margin_trade::*; pub use register_token::*; pub use set_stub_oracle::*; pub use withdraw::*; diff --git a/programs/mango-v4/src/lib.rs b/programs/mango-v4/src/lib.rs index 6e14c398c..eb8daac64 100644 --- a/programs/mango-v4/src/lib.rs +++ b/programs/mango-v4/src/lib.rs @@ -1,5 +1,8 @@ use fixed::types::I80F48; +#[macro_use] +pub mod util; + extern crate static_assertions; use anchor_lang::prelude::*; @@ -10,7 +13,6 @@ pub mod address_lookup_table; pub mod error; pub mod instructions; pub mod state; -pub mod util; declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS"); @@ -65,7 +67,10 @@ pub mod mango_v4 { instructions::withdraw(ctx, amount, allow_borrow) } - pub fn margin_trade(ctx: Context, cpi_data: Vec) -> Result<()> { + pub fn margin_trade<'key, 'accounts, 'remaining, 'info>( + ctx: Context<'key, 'accounts, 'remaining, 'info, MarginTrade<'info>>, + cpi_data: Vec, + ) -> Result<()> { instructions::margin_trade(ctx, cpi_data) } } diff --git a/programs/mango-v4/src/state/health.rs b/programs/mango-v4/src/state/health.rs index 9d90db046..5edb9efd2 100644 --- a/programs/mango-v4/src/state/health.rs +++ b/programs/mango-v4/src/state/health.rs @@ -6,23 +6,16 @@ use pyth_client::load_price; use crate::error::MangoError; use crate::state::{determine_oracle_type, MangoAccount, OracleType, StubOracle, TokenBank}; - -macro_rules! zip { - ($x: expr) => ($x); - ($x: expr, $($y: expr), +) => ( - $x.zip( - zip!($($y), +)) - ) -} +use crate::util; pub fn compute_health( - account: &mut RefMut, + account: &RefMut, banks: &[AccountInfo], oracles: &[AccountInfo], ) -> Result { let mut assets = I80F48::ZERO; let mut liabilities = I80F48::ZERO; // absolute value - for (position, (bank_ai, oracle_ai)) in zip!( + for (position, (bank_ai, oracle_ai)) in util::zip!( account.indexed_positions.iter_active(), banks.iter(), oracles.iter() diff --git a/programs/mango-v4/src/util.rs b/programs/mango-v4/src/util.rs index 9220b736d..b272f5436 100644 --- a/programs/mango-v4/src/util.rs +++ b/programs/mango-v4/src/util.rs @@ -1,10 +1,12 @@ -use solana_program::account_info::AccountInfo; -use solana_program::instruction::AccountMeta; +#![macro_use] -pub fn to_account_meta(account_info: &AccountInfo) -> AccountMeta { - if account_info.is_writable { - AccountMeta::new(*account_info.key, account_info.is_signer) - } else { - AccountMeta::new_readonly(*account_info.key, account_info.is_signer) - } +#[macro_export] +macro_rules! zip { + ($x: expr) => ($x); + ($x: expr, $($y: expr), +) => ( + $x.zip( + zip!($($y), +)) + ) } + +pub(crate) use zip; diff --git a/programs/mango-v4/tests/program_test/mango_client.rs b/programs/mango-v4/tests/program_test/mango_client.rs index fad0c96ff..428b57989 100644 --- a/programs/mango-v4/tests/program_test/mango_client.rs +++ b/programs/mango-v4/tests/program_test/mango_client.rs @@ -130,6 +130,89 @@ async fn derive_health_check_remaining_account_metas( // ClientInstruction impl // +pub struct MarginTradeInstruction<'keypair> { + pub account: Pubkey, + pub owner: &'keypair Keypair, + pub mango_token_vault: Pubkey, + pub mango_group: Pubkey, + pub margin_trade_program_id: Pubkey, + pub loan_token_account: Pubkey, + pub loan_token_account_owner: Pubkey, + pub margin_trade_program_ix_cpi_data: Vec, +} +#[async_trait::async_trait(?Send)] +impl<'keypair> ClientInstruction for MarginTradeInstruction<'keypair> { + type Accounts = mango_v4::accounts::MarginTrade; + type Instruction = mango_v4::instruction::MarginTrade; + 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 { + cpi_data: self.margin_trade_program_ix_cpi_data.clone(), + }; + + let account: MangoAccount = account_loader.load(&self.account).await.unwrap(); + let lookup_table = account_loader + .load_bytes(&account.address_lookup_table) + .await + .unwrap(); + + let accounts = Self::Accounts { + group: account.group, + account: self.account, + owner: self.owner.pubkey(), + }; + + let banks_and_oracles = mango_v4::address_lookup_table::addresses(&lookup_table); + + let mut instruction = make_instruction(program_id, &accounts, instruction); + instruction.accounts.push(AccountMeta { + pubkey: banks_and_oracles[0], + // since user doesnt return all loan after margin trade, we want to mark withdrawal from the bank + is_writable: true, + is_signer: false, + }); + instruction.accounts.push(AccountMeta { + pubkey: banks_and_oracles[1], + is_writable: false, + is_signer: false, + }); + instruction.accounts.push(AccountMeta { + pubkey: self.margin_trade_program_id, + is_writable: false, + is_signer: false, + }); + instruction.accounts.push(AccountMeta { + pubkey: self.mango_token_vault, + is_writable: true, + is_signer: false, + }); + instruction.accounts.push(AccountMeta { + pubkey: self.loan_token_account, + is_writable: true, + is_signer: false, + }); + instruction.accounts.push(AccountMeta { + pubkey: self.loan_token_account_owner, + is_writable: false, + is_signer: false, + }); + instruction.accounts.push(AccountMeta { + pubkey: spl_token::ID, + is_writable: false, + is_signer: false, + }); + + (accounts, instruction) + } + + fn signers(&self) -> Vec<&Keypair> { + vec![self.owner] + } +} + pub struct WithdrawInstruction<'keypair> { pub amount: u64, pub allow_borrow: bool, diff --git a/programs/mango-v4/tests/program_test/mod.rs b/programs/mango-v4/tests/program_test/mod.rs index d459e4f5e..a6c89f814 100644 --- a/programs/mango-v4/tests/program_test/mod.rs +++ b/programs/mango-v4/tests/program_test/mod.rs @@ -78,7 +78,18 @@ pub struct TestContext { } impl TestContext { - pub async fn new() -> Self { + pub async fn new( + test_opt: Option, + margin_trade_program_id: Option<&Pubkey>, + margin_trade_token_account: Option<&Keypair>, + mtta_owner: Option<&Pubkey>, + ) -> Self { + let mut test = if test_opt.is_some() { + test_opt.unwrap() + } else { + ProgramTest::new("mango_v4", mango_v4::id(), processor!(mango_v4::entry)) + }; + // We need to intercept logs to capture program log output let log_filter = "solana_rbpf=trace,\ solana_runtime::message_processor=debug,\ @@ -94,11 +105,8 @@ impl TestContext { program_log: program_log_capture.clone(), })); - let program_id = mango_v4::id(); - - let mut test = ProgramTest::new("mango_v4", program_id, processor!(mango_v4::entry)); // intentionally set to half the limit, to catch potential problems early - test.set_compute_max_units(100000); + test.set_compute_max_units(200000); // Setup the environment @@ -156,6 +164,28 @@ impl TestContext { } let quote_index = mints.len() - 1; + // margin trade + if margin_trade_program_id.is_some() { + test.add_program( + "margin_trade", + *margin_trade_program_id.unwrap(), + std::option::Option::None, + ); + test.add_packable_account( + margin_trade_token_account.unwrap().pubkey(), + u32::MAX as u64, + &Account { + mint: mints[0].pubkey, + owner: *mtta_owner.unwrap(), + amount: 10, + state: AccountState::Initialized, + is_native: COption::None, + ..Account::default() + }, + &spl_token::id(), + ); + } + // Users let num_users = 4; let mut users = Vec::new(); diff --git a/programs/mango-v4/tests/test_basic.rs b/programs/mango-v4/tests/test_basic.rs index 6ced947bc..86c1e3904 100644 --- a/programs/mango-v4/tests/test_basic.rs +++ b/programs/mango-v4/tests/test_basic.rs @@ -1,8 +1,12 @@ #![cfg(feature = "test-bpf")] +use anchor_lang::InstructionData; use fixed::types::I80F48; +use solana_program::pubkey::Pubkey; use solana_program_test::*; +use solana_sdk::signature::Signer; use solana_sdk::{signature::Keypair, transport::TransportError}; +use std::str::FromStr; use mango_v4::state::*; use program_test::*; @@ -13,7 +17,18 @@ mod program_test; // that they work in principle. It should be split up / renamed. #[tokio::test] async fn test_basic() -> Result<(), TransportError> { - let context = TestContext::new().await; + let margin_trade_program_id = + Pubkey::from_str("J83w4HKfqxwcq3BEMMkPFSppX3gqekLyLJBexebFVkix").unwrap(); + let margin_trade_token_account = Keypair::new(); + let (mtta_owner, mtta_bump_seeds) = + Pubkey::find_program_address(&[b"margintrade"], &margin_trade_program_id); + let context = TestContext::new( + Option::None, + Some(&margin_trade_program_id), + Some(&margin_trade_token_account), + Some(&mtta_owner), + ) + .await; let solana = &context.solana.clone(); let admin = &Keypair::new(); @@ -125,6 +140,35 @@ async fn test_basic() -> Result<(), TransportError> { ); } + // + // TEST: Margin trade + // + { + send_tx( + solana, + MarginTradeInstruction { + account, + owner, + mango_token_vault: vault, + mango_group: group, + margin_trade_program_id, + loan_token_account: margin_trade_token_account.pubkey(), + loan_token_account_owner: mtta_owner, + margin_trade_program_ix_cpi_data: { + let ix = margin_trade::instruction::MarginTrade { + amount_from: 2, + amount_to: 1, + loan_token_account_owner_bump_seeds: mtta_bump_seeds, + }; + ix.data() + }, + }, + ) + .await + .unwrap(); + } + let margin_trade_loan = 1; + // // TEST: Withdraw funds // @@ -145,7 +189,10 @@ async fn test_basic() -> Result<(), TransportError> { .await .unwrap(); - assert_eq!(solana.token_account_balance(vault).await, withdraw_amount); + assert_eq!( + solana.token_account_balance(vault).await, + withdraw_amount - margin_trade_loan + ); assert_eq!( solana.token_account_balance(payer_mint0_account).await, start_balance + withdraw_amount diff --git a/programs/margin-trade/Cargo.toml b/programs/margin-trade/Cargo.toml new file mode 100644 index 000000000..f9b38707a --- /dev/null +++ b/programs/margin-trade/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "margin-trade" +version = "0.1.0" +description = "Created with Anchor" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "margin_trade" +doctest = false + +[features] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +cpi = ["no-entrypoint"] +default = [] +test-bpf = [] + +[dependencies] +anchor-lang = { version = "0.22.0", features = [] } +anchor-spl = "0.22.0" +solana-program = "1.9.5" diff --git a/programs/margin-trade/Xargo.toml b/programs/margin-trade/Xargo.toml new file mode 100644 index 000000000..475fb71ed --- /dev/null +++ b/programs/margin-trade/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] diff --git a/programs/margin-trade/src/lib.rs b/programs/margin-trade/src/lib.rs new file mode 100644 index 000000000..b3d3d4c07 --- /dev/null +++ b/programs/margin-trade/src/lib.rs @@ -0,0 +1,94 @@ +use anchor_lang::prelude::*; +use anchor_spl::token; +use anchor_spl::token::{Token, TokenAccount, Transfer}; + +declare_id!("J83w4HKfqxwcq3BEMMkPFSppX3gqekLyLJBexebFVkix"); + +#[program] +pub mod margin_trade { + use super::*; + + pub fn margin_trade( + ctx: Context, + amount_from: u64, + loan_token_account_owner_bump_seeds: u8, + amount_to: u64, + ) -> Result<()> { + msg!( + "taking amount({}) loan from mango for mint {:?}", + amount_from, + ctx.accounts.mango_token_vault.mint + ); + token::transfer(ctx.accounts.transfer_from_mango_vault_ctx(), amount_from)?; + + msg!("TODO: do something with the loan"); + + msg!( + "transferring amount({}) loan back to mango for mint {:?}", + amount_to, + ctx.accounts.loan_token_account.mint + ); + let seeds = &[ + b"margintrade".as_ref(), + &[loan_token_account_owner_bump_seeds], + ]; + token::transfer( + ctx.accounts + .transfer_back_to_mango_vault_ctx() + .with_signer(&[seeds]), + amount_to, + )?; + + Ok(()) + } +} + +#[derive(Clone)] +pub struct MarginTrade; + +impl anchor_lang::Id for MarginTrade { + fn id() -> Pubkey { + ID + } +} + +#[derive(Accounts)] + +pub struct MarginTradeCtx<'info> { + pub mango_group: Signer<'info>, + + #[account(mut)] + pub mango_token_vault: Account<'info, TokenAccount>, + + #[account(mut)] + pub loan_token_account: Account<'info, TokenAccount>, + + // todo: can we do better than UncheckedAccount? + pub loan_token_account_owner: UncheckedAccount<'info>, + + pub token_program: Program<'info, Token>, +} + +impl<'info> MarginTradeCtx<'info> { + pub fn transfer_from_mango_vault_ctx(&self) -> CpiContext<'_, '_, '_, 'info, Transfer<'info>> { + let program = self.token_program.to_account_info(); + let accounts = Transfer { + from: self.mango_token_vault.to_account_info(), + to: self.loan_token_account.to_account_info(), + authority: self.mango_group.to_account_info(), + }; + CpiContext::new(program, accounts) + } + + pub fn transfer_back_to_mango_vault_ctx( + &self, + ) -> CpiContext<'_, '_, '_, 'info, Transfer<'info>> { + let program = self.token_program.to_account_info(); + let accounts = Transfer { + from: self.loan_token_account.to_account_info(), + to: self.mango_token_vault.to_account_info(), + authority: self.loan_token_account_owner.to_account_info(), + }; + CpiContext::new(program, accounts) + } +}