diff --git a/README.md b/README.md index e5c69525d..7f13e3761 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,9 @@ available under GPL. | tagĀ  | network | program ID | | ---- | ------- | ------------------------------------------- | -| v1.1 | mainnet | opnb2LAfJYbRMAHHvqjCwQxanZn7ReEHp1k81EohpZb | -| v1.1 | devnet | opnb2LAfJYbRMAHHvqjCwQxanZn7ReEHp1k81EohpZb | -| v1.1 | testnet | opnb2LAfJYbRMAHHvqjCwQxanZn7ReEHp1k81EohpZb | +| v1.7 | mainnet | opnb2LAfJYbRMAHHvqjCwQxanZn7ReEHp1k81EohpZb | +| v1.7 | devnet | opnb2LAfJYbRMAHHvqjCwQxanZn7ReEHp1k81EohpZb | +| v1.7 | testnet | opnb2LAfJYbRMAHHvqjCwQxanZn7ReEHp1k81EohpZb | ## Building & testing diff --git a/lib/client/src/book.rs b/lib/client/src/book.rs index e097b0b75..c5cdf446b 100644 --- a/lib/client/src/book.rs +++ b/lib/client/src/book.rs @@ -7,9 +7,8 @@ use openbook_v2::state::{ Market, Orderbook, Side, DROP_EXPIRED_ORDER_LIMIT, FILL_EVENT_REMAINING_LIMIT, }; -// TODO Adjust this number after doing some calculations -pub const MAXIMUM_TAKEN_ORDERS: u8 = 8; -const MAXIMUM_REMAINING_ACCOUNTS: usize = 4; +pub const MAXIMUM_TAKEN_ORDERS: u8 = 45; +const MAXIMUM_REMAINING_ACCOUNTS: usize = 0; pub struct Amounts { pub total_base_taken_native: u64, diff --git a/lib/client/src/jup.rs b/lib/client/src/jup.rs index b95c76dd7..d989fe39f 100644 --- a/lib/client/src/jup.rs +++ b/lib/client/src/jup.rs @@ -1,4 +1,3 @@ -use anchor_lang::AccountDeserialize; use anchor_lang::__private::bytemuck::Zeroable; use anchor_lang::prelude::*; use anchor_spl::token::Token; @@ -14,6 +13,7 @@ use openbook_v2::{ use crate::{ book::{amounts_from_book, Amounts}, remaining_accounts_to_crank, + util::ZeroCopyDeserialize, }; use jupiter_amm_interface::{ AccountMap, Amm, KeyedAccount, Quote, QuoteParams, Side as JupiterSide, Swap, @@ -35,6 +35,7 @@ pub struct OpenBookMarket { related_accounts: Vec, reserve_mints: [Pubkey; 2], oracle_price: Option, + is_permissioned: bool, } impl Amm for OpenBookMarket { @@ -59,21 +60,29 @@ impl Amm for OpenBookMarket { } fn from_keyed_account(keyed_account: &KeyedAccount) -> Result { - let market = Market::try_deserialize(&mut keyed_account.account.data.as_slice())?; - let mut related_accounts = vec![ - market.bids, - market.asks, - market.event_heap, - market.market_base_vault, - market.market_quote_vault, - clock::ID, - ]; - - related_accounts.extend( - [market.oracle_a, market.oracle_b] - .into_iter() - .filter_map(Option::::from), - ); + let market = + Market::try_deserialize_from_slice(&mut keyed_account.account.data.as_slice())?; + + let is_permissioned = market.open_orders_admin.is_some(); + let related_accounts = if is_permissioned { + vec![] + } else { + let mut accs = vec![ + market.bids, + market.asks, + market.event_heap, + market.market_base_vault, + market.market_quote_vault, + clock::ID, + ]; + + accs.extend( + [market.oracle_a, market.oracle_b] + .into_iter() + .filter_map(Option::::from), + ); + accs + }; Ok(OpenBookMarket { market, @@ -86,18 +95,24 @@ impl Amm for OpenBookMarket { asks: BookSide::zeroed(), oracle_price: None, timestamp: 0, + is_permissioned, }) } fn update(&mut self, account_map: &AccountMap) -> Result<()> { + if self.is_permissioned { + return Ok(()); + } + let bids_data = account_map.get(&self.market.bids).unwrap(); - self.bids = BookSide::try_deserialize(&mut bids_data.data.as_slice()).unwrap(); + self.bids = BookSide::try_deserialize_from_slice(&mut bids_data.data.as_slice()).unwrap(); let asks_data = account_map.get(&self.market.asks).unwrap(); - self.asks = BookSide::try_deserialize(&mut asks_data.data.as_slice()).unwrap(); + self.asks = BookSide::try_deserialize_from_slice(&mut asks_data.data.as_slice()).unwrap(); let event_heap_data = account_map.get(&self.market.event_heap).unwrap(); - self.event_heap = EventHeap::try_deserialize(&mut event_heap_data.data.as_slice()).unwrap(); + self.event_heap = + EventHeap::try_deserialize_from_slice(&mut event_heap_data.data.as_slice()).unwrap(); let clock_data = account_map.get(&clock::ID).unwrap(); let clock: Clock = bincode::deserialize(clock_data.data.as_slice())?; @@ -121,6 +136,13 @@ impl Amm for OpenBookMarket { } fn quote(&self, quote_params: &QuoteParams) -> Result { + if self.is_permissioned { + return Ok(Quote { + not_enough_liquidity: true, + ..Quote::default() + }); + } + let side = if quote_params.input_mint == self.market.quote_mint { Side::Bid } else { @@ -192,86 +214,87 @@ impl Amm for OpenBookMarket { let source_is_quote = source_mint == &self.market.quote_mint; - let side = if source_is_quote { - Side::Bid + let (side, jup_side) = if source_is_quote { + (Side::Bid, JupiterSide::Bid) } else { - Side::Ask + (Side::Ask, JupiterSide::Ask) }; - let (user_quote_account, user_base_account) = if source_is_quote { - (*user_source_token_account, *user_destination_token_account) + if self.is_permissioned { + Ok(SwapAndAccountMetas { + swap: Swap::Openbook { side: { jup_side } }, + account_metas: vec![], + }) } else { - (*user_destination_token_account, *user_source_token_account) - }; - - let accounts = PlaceTakeOrder { - signer: *user_transfer_authority, - penalty_payer: *user_transfer_authority, - market: self.key, - market_authority: self.market.market_authority, - bids: self.market.bids, - asks: self.market.asks, - user_base_account, - user_quote_account, - market_base_vault: self.market.market_base_vault, - market_quote_vault: self.market.market_quote_vault, - event_heap: self.market.event_heap, - oracle_a: Option::from(self.market.oracle_a), - oracle_b: Option::from(self.market.oracle_b), - token_program: Token::id(), - system_program: System::id(), - open_orders_admin: None, - }; - - let mut account_metas = accounts.to_account_metas(None); - - let input_amount = i64::try_from(*in_amount)?; + let (user_quote_account, user_base_account) = if source_is_quote { + (*user_source_token_account, *user_destination_token_account) + } else { + (*user_destination_token_account, *user_source_token_account) + }; - let (max_base_lots, max_quote_lots_including_fees) = match side { - Side::Bid => ( - self.market.max_base_lots(), - input_amount / self.market.quote_lot_size - + input_amount % self.market.quote_lot_size, - ), - Side::Ask => ( - input_amount / self.market.base_lot_size, - self.market.max_quote_lots(), - ), - }; + let accounts = PlaceTakeOrder { + signer: *user_transfer_authority, + penalty_payer: *user_transfer_authority, + market: self.key, + market_authority: self.market.market_authority, + bids: self.market.bids, + asks: self.market.asks, + user_base_account, + user_quote_account, + market_base_vault: self.market.market_base_vault, + market_quote_vault: self.market.market_quote_vault, + event_heap: self.market.event_heap, + oracle_a: Option::from(self.market.oracle_a), + oracle_b: Option::from(self.market.oracle_b), + token_program: Token::id(), + system_program: System::id(), + open_orders_admin: None, + }; - let bids_ref = RefCell::new(self.bids); - let asks_ref = RefCell::new(self.asks); - let book = Orderbook { - bids: bids_ref.borrow_mut(), - asks: asks_ref.borrow_mut(), - }; + let mut account_metas = accounts.to_account_metas(None); - let remainigs = remaining_accounts_to_crank( - book, - side, - max_base_lots, - max_quote_lots_including_fees, - &self.market, - self.oracle_price, - self.timestamp, - )?; + let input_amount = i64::try_from(*in_amount)?; - let remainigs_accounts: Vec = remainigs - .iter() - .map(|&pubkey| AccountMeta::new(pubkey, false)) - .collect(); - account_metas.extend(remainigs_accounts); + let (max_base_lots, max_quote_lots_including_fees) = match side { + Side::Bid => ( + self.market.max_base_lots(), + input_amount / self.market.quote_lot_size + + input_amount % self.market.quote_lot_size, + ), + Side::Ask => ( + input_amount / self.market.base_lot_size, + self.market.max_quote_lots(), + ), + }; - let side = if source_is_quote { - JupiterSide::Bid - } else { - JupiterSide::Ask - }; + let bids_ref = RefCell::new(self.bids); + let asks_ref = RefCell::new(self.asks); + let book = Orderbook { + bids: bids_ref.borrow_mut(), + asks: asks_ref.borrow_mut(), + }; - Ok(SwapAndAccountMetas { - swap: Swap::Openbook { side: { side } }, - account_metas, - }) + let remainigs = remaining_accounts_to_crank( + book, + side, + max_base_lots, + max_quote_lots_including_fees, + &self.market, + self.oracle_price, + self.timestamp, + )?; + + let remainigs_accounts: Vec = remainigs + .iter() + .map(|&pubkey| AccountMeta::new(pubkey, false)) + .collect(); + account_metas.extend(remainigs_accounts); + + Ok(SwapAndAccountMetas { + swap: Swap::Openbook { side: { jup_side } }, + account_metas, + }) + } } fn clone_amm(&self) -> Box { @@ -362,141 +385,153 @@ mod test { println!("{:#?}", quote_params); println!("{:#?}", quote); - // hack to fix https://github.com/coral-xyz/anchor/issues/2738 - pub fn fixed_entry( - program_id: &Pubkey, - accounts: &[anchor_lang::prelude::AccountInfo], - data: &[u8], - ) -> anchor_lang::solana_program::entrypoint::ProgramResult { - let extended_lifetime_accs = unsafe { - core::mem::transmute::<_, &[anchor_lang::prelude::AccountInfo<'_>]>(accounts) + if openbook.market.open_orders_admin.is_some() { + println!("Permissioned market"); + assert_eq!(quote.in_amount, 0); + assert_eq!(quote.out_amount, 0); + assert!(quote.not_enough_liquidity); + } else { + // hack to fix https://github.com/coral-xyz/anchor/issues/2738 + pub fn fixed_entry( + program_id: &Pubkey, + accounts: &[anchor_lang::prelude::AccountInfo], + data: &[u8], + ) -> anchor_lang::solana_program::entrypoint::ProgramResult { + let extended_lifetime_accs = unsafe { + core::mem::transmute::<_, &[anchor_lang::prelude::AccountInfo<'_>]>( + accounts, + ) + }; + openbook_v2::entry(program_id, extended_lifetime_accs, data) + } + + let mut pt = + ProgramTest::new("openbook_v2", openbook_v2::id(), processor!(fixed_entry)); + + pt.add_account(market, market_account.account.clone()); + for (pubkey, account) in accounts_map.iter() { + pt.add_account(*pubkey, account.clone()); + } + + let initial_amount = 1_000_000_000_000_000; + + let mut add_token_account = |pubkey, owner, mint| { + let mut data = vec![0_u8; TokenAccount::LEN]; + let account = TokenAccount { + state: AccountState::Initialized, + mint, + owner, + amount: initial_amount, + ..TokenAccount::default() + }; + TokenAccount::pack(account, &mut data).unwrap(); + pt.add_account( + pubkey, + Account::create( + Rent::default().minimum_balance(data.len()), + data, + spl_token::ID, + false, + Epoch::default(), + ), + ); }; - openbook_v2::entry(program_id, extended_lifetime_accs, data) - } - let mut pt = - ProgramTest::new("openbook_v2", openbook_v2::id(), processor!(fixed_entry)); + let user = Keypair::new(); + let user_input_account = Pubkey::new_unique(); + let user_output_account = Pubkey::new_unique(); - pt.add_account(market, market_account.account.clone()); - for (pubkey, account) in accounts_map.iter() { - pt.add_account(*pubkey, account.clone()); - } - - let initial_amount = 1_000_000_000_000_000; - - let mut add_token_account = |pubkey, owner, mint| { - let mut data = vec![0_u8; TokenAccount::LEN]; - let account = TokenAccount { - state: AccountState::Initialized, - mint, - owner, - amount: initial_amount, - ..TokenAccount::default() - }; - TokenAccount::pack(account, &mut data).unwrap(); - pt.add_account( - pubkey, - Account::create( - Rent::default().minimum_balance(data.len()), - data, - spl_token::ID, - false, - Epoch::default(), - ), - ); - }; - - let user = Keypair::new(); - let user_input_account = Pubkey::new_unique(); - let user_output_account = Pubkey::new_unique(); + let market_data = Market::try_deserialize_from_slice( + &mut market_account.account.data.as_slice(), + )?; - let market_data = Market::try_deserialize(&mut market_account.account.data.as_slice())?; + add_token_account(user_input_account, user.pubkey(), input_mint); + add_token_account(user_output_account, user.pubkey(), output_mint); - add_token_account(user_input_account, user.pubkey(), input_mint); - add_token_account(user_output_account, user.pubkey(), output_mint); + let (mut banks_client, payer, recent_blockhash) = pt.start().await; - let (mut banks_client, payer, recent_blockhash) = pt.start().await; - - // This replicates the above logic in quote() so the asme amounts are used - let input_amount = i64::try_from(in_amount).unwrap(); - let (max_base_lots, max_quote_lots_including_fees) = match side { - Side::Bid => ( - market_data.max_base_lots(), - input_amount / market_data.quote_lot_size, - ), - Side::Ask => ( - input_amount / market_data.base_lot_size, - market_data.max_quote_lots(), - ), - }; + // This replicates the above logic in quote() so the asme amounts are used + let input_amount = i64::try_from(in_amount).unwrap(); + let (max_base_lots, max_quote_lots_including_fees) = match side { + Side::Bid => ( + market_data.max_base_lots(), + input_amount / market_data.quote_lot_size, + ), + Side::Ask => ( + input_amount / market_data.base_lot_size, + market_data.max_quote_lots(), + ), + }; - let (user_base_account, user_quote_account) = match side { - openbook_v2::state::Side::Ask => (user_input_account, user_output_account), - openbook_v2::state::Side::Bid => (user_output_account, user_input_account), - }; + let (user_base_account, user_quote_account) = match side { + openbook_v2::state::Side::Ask => (user_input_account, user_output_account), + openbook_v2::state::Side::Bid => (user_output_account, user_input_account), + }; - let ix = Instruction { - program_id: openbook_v2::id(), - accounts: anchor_lang::ToAccountMetas::to_account_metas( - &openbook_v2::accounts::PlaceTakeOrder { - signer: user.pubkey(), - penalty_payer: user.pubkey(), - market, - user_base_account, - user_quote_account, - market_authority: market_data.market_authority, - bids: market_data.bids, - asks: market_data.asks, - market_base_vault: market_data.market_base_vault, - market_quote_vault: market_data.market_quote_vault, - event_heap: market_data.event_heap, - oracle_a: Option::from(market_data.oracle_a), - oracle_b: Option::from(market_data.oracle_b), - token_program: Token::id(), - system_program: System::id(), - open_orders_admin: None, - }, - None, - ), - data: anchor_lang::InstructionData::data( - &openbook_v2::instruction::PlaceTakeOrder { - args: openbook_v2::PlaceTakeOrderArgs { - side, - price_lots: i64::MAX, - max_base_lots, - max_quote_lots_including_fees, - order_type: openbook_v2::state::PlaceOrderType::Market, - limit: MAXIMUM_TAKEN_ORDERS, + let ix = Instruction { + program_id: openbook_v2::id(), + accounts: anchor_lang::ToAccountMetas::to_account_metas( + &openbook_v2::accounts::PlaceTakeOrder { + signer: user.pubkey(), + penalty_payer: user.pubkey(), + market, + user_base_account, + user_quote_account, + market_authority: market_data.market_authority, + bids: market_data.bids, + asks: market_data.asks, + market_base_vault: market_data.market_base_vault, + market_quote_vault: market_data.market_quote_vault, + event_heap: market_data.event_heap, + oracle_a: Option::from(market_data.oracle_a), + oracle_b: Option::from(market_data.oracle_b), + token_program: Token::id(), + system_program: System::id(), + open_orders_admin: None, }, - }, - ), - }; + None, + ), + data: anchor_lang::InstructionData::data( + &openbook_v2::instruction::PlaceTakeOrder { + args: openbook_v2::PlaceTakeOrderArgs { + side, + price_lots: i64::MAX, + max_base_lots, + max_quote_lots_including_fees, + order_type: openbook_v2::state::PlaceOrderType::Market, + limit: MAXIMUM_TAKEN_ORDERS, + }, + }, + ), + }; - let tx = Transaction::new_signed_with_payer( - &[ix], - Some(&payer.pubkey()), - &[&payer, &user], - recent_blockhash, - ); - banks_client.process_transaction(tx).await.unwrap(); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&payer.pubkey()), + &[&payer, &user], + recent_blockhash, + ); + banks_client.process_transaction(tx).await.unwrap(); - // let input_account = banks_client.get_account(user).await.unwrap().unwrap(); + // let input_account = banks_client.get_account(user).await.unwrap().unwrap(); - let output_account = banks_client - .get_account(user_output_account) - .await - .unwrap() - .unwrap(); + let output_account = banks_client + .get_account(user_output_account) + .await + .unwrap() + .unwrap(); - let get_amount = - |account: Account| -> u64 { TokenAccount::unpack(&account.data).unwrap().amount }; + let get_amount = |account: Account| -> u64 { + TokenAccount::unpack(&account.data).unwrap().amount + }; - // let input_amount = get_amount(input_account); - let output_amount = get_amount(output_account); - // println!("{}", input_amount); - println!("{}", output_amount); + // let input_amount = get_amount(input_account); + let output_amount = get_amount(output_account); + // println!("{}", input_amount); + println!("{}", output_amount); - assert_eq!(output_amount - initial_amount, quote.out_amount); + assert_eq!(output_amount - initial_amount, quote.out_amount); + } } Ok(()) } diff --git a/lib/client/src/util.rs b/lib/client/src/util.rs index 02d0e9177..fd771debb 100644 --- a/lib/client/src/util.rs +++ b/lib/client/src/util.rs @@ -1,3 +1,4 @@ +use anchor_lang::{error::Error, AccountDeserialize, ZeroCopy}; use solana_client::{ client_error::Result as ClientResult, rpc_client::RpcClient, rpc_request::RpcError, }; @@ -6,9 +7,23 @@ use solana_sdk::{ clock::Slot, commitment_config::CommitmentConfig, signature::Signature, transaction::uses_durable_nonce, }; - use std::{thread, time}; +/// zero copy accounts could be larger than the underlying struct +/// https://github.com/coral-xyz/anchor/blob/08110e63fa5a7369830db972d4abdcfa1aaa7f2e/lang/src/accounts/account_loader.rs#L165 +/// https://github.com/coral-xyz/anchor/blob/08110e63fa5a7369830db972d4abdcfa1aaa7f2e/lang/attribute/account/src/lib.rs#L195 +pub trait ZeroCopyDeserialize { + fn try_deserialize_from_slice(buf: &mut &[u8]) -> Result + where + Self: Sized; +} + +impl ZeroCopyDeserialize for T { + fn try_deserialize_from_slice(buf: &mut &[u8]) -> Result { + Self::try_deserialize(&mut &buf[..std::mem::size_of::() + 8]) + } +} + /// Some Result<> types don't convert to anyhow::Result nicely. Force them through stringification. pub trait AnyhowWrap { type Value; diff --git a/package.json b/package.json index 3f9155713..0b7757e02 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@openbook-dex/openbook-v2", - "version": "0.1.9", + "version": "0.1.10", "description": "Typescript Client for openbook-v2 program.", "repository": "https://github.com/openbook-dex/openbook-v2/", "author": { diff --git a/programs/openbook-v2/fuzz/fuzz_targets/multiple_orders.rs b/programs/openbook-v2/fuzz/fuzz_targets/multiple_orders.rs index 1e579161e..94cd4e7b8 100644 --- a/programs/openbook-v2/fuzz/fuzz_targets/multiple_orders.rs +++ b/programs/openbook-v2/fuzz/fuzz_targets/multiple_orders.rs @@ -495,6 +495,7 @@ mod error_parser { e if e == OpenBookError::InvalidOrderPostMarket.into() => Corpus::Keep, e if e == OpenBookError::InvalidPostAmount.into() => Corpus::Keep, e if e == OpenBookError::InvalidPriceLots.into() => Corpus::Keep, + e if e == OpenBookError::OpenOrdersFull.into() => Corpus::Keep, e if e == OpenBookError::OraclePegInvalidOracleState.into() => Corpus::Keep, e if e == OpenBookError::WouldSelfTrade.into() => Corpus::Keep, e if e == TokenError::InsufficientFunds.into() => Corpus::Keep, diff --git a/programs/openbook-v2/src/accounts_ix/settle_funds.rs b/programs/openbook-v2/src/accounts_ix/settle_funds.rs index 50eb5ecc3..d6c826dd3 100644 --- a/programs/openbook-v2/src/accounts_ix/settle_funds.rs +++ b/programs/openbook-v2/src/accounts_ix/settle_funds.rs @@ -32,13 +32,13 @@ pub struct SettleFunds<'info> { #[account( mut, token::mint = market_base_vault.mint, - constraint = user_base_account.owner == open_orders_account.load()?.owner + constraint = open_orders_account.load()?.is_settle_destination_allowed(owner.key(), user_base_account.owner) )] pub user_base_account: Account<'info, TokenAccount>, #[account( mut, token::mint = market_quote_vault.mint, - constraint = user_quote_account.owner == open_orders_account.load()?.owner + constraint = open_orders_account.load()?.is_settle_destination_allowed(owner.key(), user_quote_account.owner) )] pub user_quote_account: Account<'info, TokenAccount>, #[account( diff --git a/programs/openbook-v2/src/state/open_orders_account.rs b/programs/openbook-v2/src/state/open_orders_account.rs index fdd66c904..e462c143a 100644 --- a/programs/openbook-v2/src/state/open_orders_account.rs +++ b/programs/openbook-v2/src/state/open_orders_account.rs @@ -85,6 +85,17 @@ impl OpenOrdersAccount { self.owner == ix_signer } + pub fn is_settle_destination_allowed(&self, ix_signer: Pubkey, account_owner: Pubkey) -> bool { + // delegate can withdraw to owner accounts + let delegate_option: Option = Option::from(self.delegate); + if Some(ix_signer) == delegate_option { + return self.owner == account_owner; + } + + // owner can withdraw to anywhere + ix_signer == self.owner + } + pub fn all_orders(&self) -> impl Iterator { self.open_orders.iter() } diff --git a/programs/openbook-v2/src/state/orderbook/book.rs b/programs/openbook-v2/src/state/orderbook/book.rs index c1c7f4e58..9a405a24c 100644 --- a/programs/openbook-v2/src/state/orderbook/book.rs +++ b/programs/openbook-v2/src/state/orderbook/book.rs @@ -162,18 +162,22 @@ impl<'a> Orderbook<'a> { if !side.is_price_within_limit(best_opposing_price, price_lots) { break; - } else if post_only { + } + if post_only { msg!("Order could not be placed due to PostOnly"); post_target = None; break; // return silently to not fail other instructions in tx - } else if limit == 0 { + } + if limit == 0 { msg!("Order matching limit reached"); post_target = None; break; } let max_match_by_quote = remaining_quote_lots / best_opposing_price; + // Do not post orders in the book due to bad pricing and negative spread if max_match_by_quote == 0 { + post_target = None; break; } diff --git a/programs/openbook-v2/tests/cases/test.rs b/programs/openbook-v2/tests/cases/test.rs index 5ac8929ae..32a166850 100644 --- a/programs/openbook-v2/tests/cases/test.rs +++ b/programs/openbook-v2/tests/cases/test.rs @@ -196,6 +196,170 @@ async fn test_simple_settle() -> Result<(), TransportError> { Ok(()) } +#[tokio::test] +async fn test_delegate_settle() -> Result<(), TransportError> { + let TestInitialize { + context, + collect_fee_admin, + owner, + payer, + mints, + owner_token_0, + owner_token_1, + market, + market_base_vault, + market_quote_vault, + price_lots, + tokens, + account_1, + account_2, + .. + } = TestContext::new_with_market(TestNewMarketInitialize { + close_market_admin_bool: true, + payer_as_delegate: true, + ..TestNewMarketInitialize::default() + }) + .await?; + let solana = &context.solana.clone(); + let payer_token_0 = context.users[1].token_accounts[0]; + let payer_token_1 = context.users[1].token_accounts[1]; + + // Set the initial oracle price + set_stub_oracle_price(solana, &tokens[1], collect_fee_admin, 1000.0).await; + + send_tx( + solana, + PlaceOrderInstruction { + open_orders_account: account_1, + open_orders_admin: None, + market, + signer: owner, + user_token_account: owner_token_1, + market_vault: market_quote_vault, + side: Side::Bid, + price_lots, + max_base_lots: 1, + max_quote_lots_including_fees: 10000, + + client_order_id: 0, + expiry_timestamp: 0, + order_type: PlaceOrderType::Limit, + self_trade_behavior: SelfTradeBehavior::default(), + remainings: vec![], + }, + ) + .await + .unwrap(); + + send_tx( + solana, + PlaceOrderInstruction { + open_orders_account: account_2, + open_orders_admin: None, + market, + signer: owner, + user_token_account: owner_token_0, + market_vault: market_base_vault, + side: Side::Ask, + price_lots, + max_base_lots: 1, + max_quote_lots_including_fees: 10000, + + client_order_id: 0, + expiry_timestamp: 0, + order_type: PlaceOrderType::Limit, + self_trade_behavior: SelfTradeBehavior::default(), + remainings: vec![], + }, + ) + .await + .unwrap(); + + send_tx( + solana, + ConsumeEventsInstruction { + consume_events_admin: None, + market, + open_orders_accounts: vec![account_1, account_2], + }, + ) + .await + .unwrap(); + + // delegate settle to own account fails + assert!(send_tx( + solana, + SettleFundsInstruction { + owner: payer, + market, + open_orders_account: account_1, + market_base_vault, + market_quote_vault, + user_base_account: payer_token_0, + user_quote_account: payer_token_1, + referrer_account: None, + }, + ) + .await + .is_err()); + + // delegate settle to owner succeeds + send_tx( + solana, + SettleFundsInstruction { + owner: payer, + market, + open_orders_account: account_1, + market_base_vault, + market_quote_vault, + user_base_account: owner_token_0, + user_quote_account: owner_token_1, + referrer_account: None, + }, + ) + .await + .unwrap(); + + { + let open_orders_account_1 = solana.get_account::(account_1).await; + let open_orders_account_2 = solana.get_account::(account_2).await; + + assert_eq!(open_orders_account_1.position.base_free_native, 0); + assert_eq!(open_orders_account_2.position.base_free_native, 0); + assert_eq!(open_orders_account_1.position.quote_free_native, 0); + assert_eq!(open_orders_account_2.position.quote_free_native, 99960); + } + + // owner settle to payer account succeeds + send_tx( + solana, + SettleFundsInstruction { + owner, + market, + open_orders_account: account_2, + market_base_vault, + market_quote_vault, + user_base_account: payer_token_0, + user_quote_account: payer_token_1, + referrer_account: None, + }, + ) + .await + .unwrap(); + + { + let open_orders_account_1 = solana.get_account::(account_1).await; + let open_orders_account_2 = solana.get_account::(account_2).await; + + assert_eq!(open_orders_account_1.position.base_free_native, 0); + assert_eq!(open_orders_account_2.position.base_free_native, 0); + assert_eq!(open_orders_account_1.position.quote_free_native, 0); + assert_eq!(open_orders_account_2.position.quote_free_native, 0); + } + + Ok(()) +} + #[tokio::test] async fn test_cancel_orders() -> Result<(), TransportError> { let TestInitialize { diff --git a/programs/openbook-v2/tests/cases/test_take_order.rs b/programs/openbook-v2/tests/cases/test_take_order.rs index 1912ddcef..618264e43 100644 --- a/programs/openbook-v2/tests/cases/test_take_order.rs +++ b/programs/openbook-v2/tests/cases/test_take_order.rs @@ -288,3 +288,82 @@ async fn test_take_bid_order() -> Result<(), TransportError> { Ok(()) } + +#[tokio::test] +async fn test_negative_spread_ask() -> Result<(), TransportError> { + let TestInitialize { + context, + owner, + owner_token_0, + owner_token_1, + market, + market_base_vault, + market_quote_vault, + account_1, + account_2, + .. + } = TestContext::new_with_market(TestNewMarketInitialize { + quote_lot_size: 100, + base_lot_size: 1_000_000_000, + ..TestNewMarketInitialize::default() + }) + .await?; + let solana = &context.solana.clone(); + + send_tx( + solana, + PlaceOrderInstruction { + open_orders_account: account_1, + open_orders_admin: None, + market, + signer: owner, + user_token_account: owner_token_1, + market_vault: market_quote_vault, + side: Side::Bid, + price_lots: 10_000, // $1 + max_base_lots: 1000000, // wahtever + max_quote_lots_including_fees: 10_000, + client_order_id: 1, + expiry_timestamp: 0, + order_type: PlaceOrderType::Limit, + self_trade_behavior: SelfTradeBehavior::default(), + remainings: vec![], + }, + ) + .await + .unwrap(); + + // This order doesn't take any due max_quote_lots_including_fees but it's also don't post in on the book + send_tx( + solana, + PlaceOrderInstruction { + open_orders_account: account_2, + open_orders_admin: None, + market, + signer: owner, + user_token_account: owner_token_0, + market_vault: market_base_vault, + side: Side::Ask, + price_lots: 7_500, + max_base_lots: 1, + max_quote_lots_including_fees: 7_500, + client_order_id: 25, + expiry_timestamp: 0, + order_type: PlaceOrderType::Limit, + self_trade_behavior: SelfTradeBehavior::AbortTransaction, + remainings: vec![], + }, + ) + .await + .unwrap(); + + let position = solana + .get_account::(account_2) + .await + .position; + + assert_eq!(position.asks_base_lots, 0); + assert_eq!(position.bids_base_lots, 0); + + Ok(()) +} diff --git a/programs/openbook-v2/tests/program_test/mod.rs b/programs/openbook-v2/tests/program_test/mod.rs index 14e4d14da..b65ee5437 100644 --- a/programs/openbook-v2/tests/program_test/mod.rs +++ b/programs/openbook-v2/tests/program_test/mod.rs @@ -289,6 +289,7 @@ pub struct TestNewMarketInitialize { pub consume_events_admin_bool: bool, pub time_expiry: i64, pub with_oracle: bool, + pub payer_as_delegate: bool, } impl Default for TestNewMarketInitialize { @@ -304,6 +305,7 @@ impl Default for TestNewMarketInitialize { consume_events_admin_bool: false, time_expiry: 0, with_oracle: true, + payer_as_delegate: false, } } } @@ -388,10 +390,18 @@ impl TestContext { let _indexer = create_open_orders_indexer(solana, &context.users[1], owner, market).await; + let delegate_opt = if args.payer_as_delegate { + Some(payer.pubkey()) + } else { + None + }; + let account_1 = - create_open_orders_account(solana, owner, market, 1, &context.users[1], None).await; + create_open_orders_account(solana, owner, market, 1, &context.users[1], delegate_opt) + .await; let account_2 = - create_open_orders_account(solana, owner, market, 2, &context.users[1], None).await; + create_open_orders_account(solana, owner, market, 2, &context.users[1], delegate_opt) + .await; let price_lots = { let market = solana.get_account::(market).await; diff --git a/ts/client/src/client.ts b/ts/client/src/client.ts index 8ee2c0fe1..06cd22bd8 100644 --- a/ts/client/src/client.ts +++ b/ts/client/src/client.ts @@ -465,25 +465,25 @@ export class OpenBookV2Client { ): Promise<[TransactionInstruction[], PublicKey]> { const ixs: TransactionInstruction[] = []; let accountIndex = new BN(1); - - if (openOrdersIndexer == null) { + + if (openOrdersIndexer == null) openOrdersIndexer = this.findOpenOrdersIndexer(owner); - try { - const storedIndexer = await this.deserializeOpenOrdersIndexerAccount( - openOrdersIndexer, - ); - if (storedIndexer == null) { - ixs.push( - await this.createOpenOrdersIndexerIx(openOrdersIndexer, owner), - ); - } else { - accountIndex = new BN(storedIndexer.createdCounter + 1); - } - } catch { + + try { + const storedIndexer = await this.deserializeOpenOrdersIndexerAccount( + openOrdersIndexer, + ); + if (storedIndexer == null) { ixs.push( await this.createOpenOrdersIndexerIx(openOrdersIndexer, owner), ); + } else { + accountIndex = new BN(storedIndexer.createdCounter + 1); } + } catch { + ixs.push( + await this.createOpenOrdersIndexerIx(openOrdersIndexer, owner), + ); } const openOrdersAccount = this.findOpenOrderAtIndex(owner, accountIndex); @@ -956,7 +956,7 @@ export class OpenBookV2Client { userBaseAccount: PublicKey, userQuoteAccount: PublicKey, referrerAccount: PublicKey | null, - penaltyPayer: PublicKey | null, + penaltyPayer: PublicKey, openOrdersDelegate?: Keypair, ): Promise<[TransactionInstruction, Signer[]]> { const ix = await this.program.methods @@ -973,7 +973,7 @@ export class OpenBookV2Client { userBaseAccount: userBaseAccount, userQuoteAccount: userQuoteAccount, referrerAccount: referrerAccount, - penaltyPayer: penaltyPayer ?? PublicKey.default, + penaltyPayer: penaltyPayer, }) .instruction();