diff --git a/Cargo.lock b/Cargo.lock index 320ba4157..cd733ded7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -537,6 +537,7 @@ dependencies = [ "futures", "itertools 0.14.0", "lazy-regex", + "price", "regex", "serde", "serde_json", @@ -554,6 +555,7 @@ dependencies = [ "spl-associated-token-account-interface", "spl-token-2022-interface", "spl-token-interface", + "static_assertions", "strum", "strum_macros", "tokio", @@ -952,6 +954,7 @@ dependencies = [ "pinocchio-token", "pinocchio-token-2022", "pinocchio-token-interface", + "price", "solana-pubkey", "solana-sdk", "static_assertions", @@ -5876,6 +5879,7 @@ dependencies = [ "dropset-interface", "itertools 0.14.0", "lazy-regex", + "price", "serde", "serde_json", "solana-sdk", diff --git a/client/Cargo.toml b/client/Cargo.toml index 9e9cbb5e8..3d99e6996 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -13,6 +13,7 @@ dropset-interface = { path = "../interface", features = ["client", "solana-progr itertools.workspace = true futures.workspace = true lazy-regex.workspace = true +price = { path = "../price" } regex.workspace = true solana-client.workspace = true solana-commitment-config.workspace = true @@ -28,6 +29,7 @@ solana-transaction-status-client-types.workspace = true spl-associated-token-account-interface.workspace = true spl-token-interface.workspace = true spl-token-2022-interface.workspace = true +static_assertions.workspace = true strum.workspace = true strum_macros.workspace = true tokio.workspace = true diff --git a/client/examples/post_and_cancel.rs b/client/examples/post_and_cancel.rs new file mode 100644 index 000000000..08f23d715 --- /dev/null +++ b/client/examples/post_and_cancel.rs @@ -0,0 +1,115 @@ +use std::collections::HashSet; + +use client::{ + context::market::MarketContext, + transactions::{ + CustomRpcClient, + SendTransactionConfig, + }, +}; +use dropset_interface::{ + instructions::{ + CancelOrderInstructionData, + PostOrderInstructionData, + }, + state::sector::NIL, +}; +use price::{ + to_biased_exponent, + to_order_info, +}; +use solana_sdk::signer::Signer; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let rpc = &CustomRpcClient::new( + None, + Some(SendTransactionConfig { + compute_budget: Some(2000000), + debug_logs: Some(true), + program_id_filter: HashSet::from([dropset_interface::program::ID.into()]), + }), + ); + let payer = rpc.fund_new_account().await?; + + let market_ctx = MarketContext::new_market(rpc).await?; + let register = market_ctx.register_market(payer.pubkey(), 10); + + market_ctx.base.create_ata_for(rpc, &payer).await?; + market_ctx.quote.create_ata_for(rpc, &payer).await?; + + market_ctx.base.mint_to(rpc, &payer, 10000).await?; + market_ctx.quote.mint_to(rpc, &payer, 10000).await?; + + let deposit = market_ctx.deposit_base(payer.pubkey(), 1000, NIL); + + rpc.send_and_confirm_txn(&payer, &[&payer], &[register.into(), deposit.into()]) + .await?; + + let market = market_ctx.view_market(rpc)?; + println!("Market after user deposit\n{:#?}", market); + + let user_seat = market_ctx + .find_seat(rpc, &payer.pubkey())? + .expect("User should have been registered on deposit"); + + let (price_mantissa, base_scalar, base_exponent, quote_exponent) = ( + 10_000_000, + 500, + to_biased_exponent!(0), + to_biased_exponent!(0), + ); + let order_info = to_order_info(price_mantissa, base_scalar, base_exponent, quote_exponent) + .expect("Should be a valid order"); + + // Post an ask. The user provides base as collateral and receives quote when filled. + let is_bid = false; + let post_ask = market_ctx.post_order( + payer.pubkey(), + PostOrderInstructionData::new( + price_mantissa, + base_scalar, + base_exponent, + quote_exponent, + is_bid, + user_seat.index, + ), + ); + + let res = rpc + .send_and_confirm_txn(&payer, &[&payer], &[post_ask.into()]) + .await?; + + println!( + "Post ask transaction signature: {}", + res.parsed_transaction.signature + ); + + let market = market_ctx.view_market(rpc)?; + println!("Market after posting user ask:\n{:#?}", market); + + let user_seat = market_ctx.find_seat(rpc, &payer.pubkey())?.unwrap(); + println!("User seat after posting ask: {user_seat:#?}"); + + let cancel_ask = market_ctx.cancel_order( + user_seat.user, + CancelOrderInstructionData::new(order_info.encoded_price.as_u32(), is_bid, user_seat.index), + ); + + let res = rpc + .send_and_confirm_txn(&payer, &[&payer], &[cancel_ask.into()]) + .await?; + + println!( + "Cancel ask transaction signature: {}", + res.parsed_transaction.signature + ); + + let user_seat = market_ctx.find_seat(rpc, &payer.pubkey())?.unwrap(); + println!("User seat after canceling ask: {user_seat:#?}"); + + let market = market_ctx.view_market(rpc)?; + println!("Market after canceling user ask:\n{:#?}", market); + + Ok(()) +} diff --git a/client/examples/post_asks.rs b/client/examples/post_asks.rs new file mode 100644 index 000000000..1a14b3bf3 --- /dev/null +++ b/client/examples/post_asks.rs @@ -0,0 +1,118 @@ +use std::collections::HashSet; + +use client::{ + context::market::MarketContext, + transactions::{ + CustomRpcClient, + SendTransactionConfig, + }, +}; +use dropset_interface::{ + instructions::PostOrderInstructionData, + state::sector::NIL, +}; +use itertools::Itertools; +use price::to_biased_exponent; +use solana_sdk::signer::Signer; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let rpc = &CustomRpcClient::new( + None, + Some(SendTransactionConfig { + compute_budget: Some(2000000), + debug_logs: Some(true), + program_id_filter: HashSet::from([dropset_interface::program::ID.into()]), + }), + ); + let payer = rpc.fund_new_account().await?; + + let market_ctx = MarketContext::new_market(rpc).await?; + let register = market_ctx.register_market(payer.pubkey(), 10); + + market_ctx.base.create_ata_for(rpc, &payer).await?; + market_ctx.quote.create_ata_for(rpc, &payer).await?; + + market_ctx.base.mint_to(rpc, &payer, 10000).await?; + market_ctx.quote.mint_to(rpc, &payer, 10000).await?; + + let deposit = market_ctx.deposit_base(payer.pubkey(), 10000, NIL); + + rpc.send_and_confirm_txn(&payer, &[&payer], &[register.into(), deposit.into()]) + .await?; + + let market = market_ctx.view_market(rpc)?; + println!("Market after user deposit\n{:#?}", market); + + let user_seat = market_ctx + .find_seat(rpc, &payer.pubkey())? + .expect("User should have been registered on deposit"); + + let (price_mantissa, base_scalar, base_exponent, quote_exponent) = ( + 10_000_000, + 500, + to_biased_exponent!(0), + to_biased_exponent!(0), + ); + + // Post an ask. The user provides base as collateral and receives quote when filled. + let is_bid = false; + let post_ask = market_ctx.post_order( + payer.pubkey(), + PostOrderInstructionData::new( + price_mantissa, + base_scalar, + base_exponent, + quote_exponent, + is_bid, + user_seat.index, + ), + ); + + let res = rpc + .send_and_confirm_txn(&payer, &[&payer], &[post_ask.into()]) + .await?; + + println!( + "Post ask transaction signature: {}", + res.parsed_transaction.signature + ); + + let market = market_ctx.view_market(rpc)?; + println!("Market after posting user ask:\n{:#?}", market); + + let user_seat = market_ctx.find_seat(rpc, &payer.pubkey())?.unwrap(); + println!("User seat after posting ask: {user_seat:#?}"); + + // Post an ask. The user provides base as collateral and receives quote when filled. + let is_bid = false; + + let ask_instructions = (1..5) + .map(|i| { + market_ctx + .post_order( + payer.pubkey(), + PostOrderInstructionData::new( + price_mantissa + i, + base_scalar, + base_exponent, + quote_exponent, + is_bid, + user_seat.index, + ), + ) + .into() + }) + .collect_vec(); + + rpc.send_and_confirm_txn(&payer, &[&payer], &ask_instructions) + .await?; + + let market = market_ctx.view_market(rpc)?; + println!("Market after posting many user asks:\n{:#?}", market); + + let user_seat = market_ctx.find_seat(rpc, &payer.pubkey())?.unwrap(); + println!("User seat after posting many asks: {user_seat:#?}"); + + Ok(()) +} diff --git a/client/src/context/market.rs b/client/src/context/market.rs index 5d6d985ee..5ec8e9c89 100644 --- a/client/src/context/market.rs +++ b/client/src/context/market.rs @@ -4,8 +4,10 @@ use dropset_interface::{ instructions::{ generated_client::*, + CancelOrderInstructionData, CloseSeatInstructionData, DepositInstructionData, + PostOrderInstructionData, RegisterMarketInstructionData, WithdrawInstructionData, }, @@ -167,6 +169,38 @@ impl MarketContext { self.withdraw(user, data, false) } + pub fn post_order( + &self, + user: Pubkey, + data: PostOrderInstructionData, + ) -> SingleSignerInstruction { + PostOrder { + event_authority: event_authority::ID.into(), + user, + market_account: self.market, + dropset_program: dropset::ID.into(), + } + .create_instruction(data) + .try_into() + .expect("Should be a single signer instruction") + } + + pub fn cancel_order( + &self, + user: Pubkey, + data: CancelOrderInstructionData, + ) -> SingleSignerInstruction { + CancelOrder { + event_authority: event_authority::ID.into(), + user, + market_account: self.market, + dropset_program: dropset::ID.into(), + } + .create_instruction(data) + .try_into() + .expect("Should be a single signer instruction") + } + fn deposit( &self, user: Pubkey, diff --git a/interface/src/error.rs b/interface/src/error.rs index da6f5bcdf..984014e9a 100644 --- a/interface/src/error.rs +++ b/interface/src/error.rs @@ -1,6 +1,7 @@ //! Common error types and conversion helpers to represent them as error message strings. use pinocchio::program_error::ProgramError; +use price::OrderInfoError; #[derive(Clone, Debug, Eq, PartialEq)] #[cfg_attr(feature = "client", derive(strum_macros::FromRepr))] @@ -37,6 +38,12 @@ pub enum DropsetError { OrderWithPriceAlreadyExists, UserHasMaxOrders, OrderNotFound, + ExponentUnderflow, + ArithmeticOverflow, + InvalidPriceMantissa, + InvalidBiasedExponent, + InfinityIsNotAFloat, + PostOnlyWouldImmediatelyFill, } impl From for ProgramError { @@ -46,6 +53,19 @@ impl From for ProgramError { } } +impl From for DropsetError { + #[inline(always)] + fn from(order_error: OrderInfoError) -> Self { + match order_error { + OrderInfoError::ExponentUnderflow => DropsetError::ExponentUnderflow, + OrderInfoError::ArithmeticOverflow => DropsetError::ArithmeticOverflow, + OrderInfoError::InvalidPriceMantissa => DropsetError::InvalidPriceMantissa, + OrderInfoError::InvalidBiasedExponent => DropsetError::InvalidBiasedExponent, + OrderInfoError::InfinityIsNotAFloat => DropsetError::InfinityIsNotAFloat, + } + } +} + impl From for &'static str { fn from(value: DropsetError) -> Self { match value { @@ -80,6 +100,12 @@ impl From for &'static str { DropsetError::OrderWithPriceAlreadyExists => "An order with this price already exists", DropsetError::UserHasMaxOrders => "User already has the max number of open orders", DropsetError::OrderNotFound => "Order not found", + DropsetError::ExponentUnderflow => "Order exponent underflow", + DropsetError::ArithmeticOverflow => "Order arithmetic overflow", + DropsetError::InvalidPriceMantissa => "Invalid price mantissa in price calculation", + DropsetError::InvalidBiasedExponent => "Invalid biased exponent in price calculation", + DropsetError::InfinityIsNotAFloat => "Can't convert infinity to a float value", + DropsetError::PostOnlyWouldImmediatelyFill => "Post only order would immediately fill", } } } diff --git a/interface/src/events/mod.rs b/interface/src/events/mod.rs index c33a93529..835cf4f4a 100644 --- a/interface/src/events/mod.rs +++ b/interface/src/events/mod.rs @@ -43,6 +43,15 @@ pub enum DropsetEventTag { WithdrawEvent, #[args(market: [u8; 32], "The newly registered market.")] RegisterMarketEvent, + #[args(is_bid: bool, "Whether or not the order is a bid. If false, the order is an ask.")] + #[args(user_seat_sector_index: u32, "The user's market seat sector index.")] + #[args(order_sector_index: u32, "The posted order's sector index.")] + #[args(base_atoms: u64, "The size of the order's base atoms to fill.")] + #[args(quote_atoms: u64, "The size of the order's quote atoms to fill.")] + PostOrderEvent, + #[args(is_bid: bool, "Whether or not the order is a bid. If false, the order is an ask.")] + #[args(user_seat_sector_index: u32, "The user's market seat sector index.")] + CancelOrderEvent, #[args(seat_sector_index: u32, "The user's market seat sector index.")] CloseSeatEvent, } diff --git a/interface/src/instructions.rs b/interface/src/instructions.rs index fcd4afbdc..c0279900f 100644 --- a/interface/src/instructions.rs +++ b/interface/src/instructions.rs @@ -50,7 +50,7 @@ pub enum DropsetInstruction { #[args(sector_index_hint: u32, "A hint indicating which sector the user's seat resides in (pass `NIL` when registering a new seat).")] Deposit, - #[account(0, name = "event_authority", desc = "The event authority PDA signer.")] + #[account(0, name = "event_authority", desc = "The event authority PDA signer.")] #[account(1, signer, writable, name = "user", desc = "The user registering the market.")] #[account(2, writable, name = "market_account", desc = "The market account PDA.")] #[account(3, writable, name = "base_market_ata", desc = "The market's associated token account for the base mint.")] @@ -77,6 +77,27 @@ pub enum DropsetInstruction { #[args(sector_index_hint: u32, "A hint indicating which sector the user's seat resides in.")] Withdraw, + #[account(0, name = "event_authority", desc = "The event authority PDA signer.")] + #[account(1, signer, name = "user", desc = "The user posting an order.")] + #[account(2, writable, name = "market_account", desc = "The market account PDA.")] + #[account(3, name = "dropset_program", desc = "The dropset program itself, used for the self-CPI.")] + #[args(price_mantissa: u32, "The price mantissa.")] + #[args(base_scalar: u64, "The scalar for the base token.")] + #[args(base_exponent_biased: u8, "The biased base exponent.")] + #[args(quote_exponent_biased: u8, "The biased quote exponent.")] + #[args(is_bid: bool, "Whether or not the order is a bid. If false, the order is an ask.")] + #[args(user_sector_index_hint: u32, "A hint indicating which sector the user's seat resides in.")] + PostOrder, + + #[account(0, name = "event_authority", desc = "The event authority PDA signer.")] + #[account(1, signer, name = "user", desc = "The user canceling an order.")] + #[account(2, writable, name = "market_account", desc = "The market account PDA.")] + #[account(3, name = "dropset_program", desc = "The dropset program itself, used for the self-CPI.")] + #[args(encoded_price: u32, "The encoded price for the order to cancel.")] + #[args(is_bid: bool, "Whether or not the order is a bid. If false, the order is an ask.")] + #[args(user_sector_index_hint: u32, "A hint indicating which sector the user's seat resides in.")] + CancelOrder, + // FlushEvents is an internal instruction and can only be called by the program. It does have // instruction data, but it is not used by the program. #[account(0, signer, name = "event_authority", desc = "The event authority PDA signer.")] diff --git a/interface/src/state/asks_dll.rs b/interface/src/state/asks_dll.rs new file mode 100644 index 000000000..d4c233c8c --- /dev/null +++ b/interface/src/state/asks_dll.rs @@ -0,0 +1,112 @@ +//! Doubly linked list of ask order nodes with [`crate::state::order::Order`] payloads. + +use crate::{ + error::{ + DropsetError, + DropsetResult, + }, + state::{ + linked_list::{ + LinkedList, + LinkedListOperations, + }, + market::Market, + market_header::MarketHeader, + order::{ + Order, + OrdersCollection, + }, + sector::{ + SectorIndex, + NIL, + }, + }, +}; + +pub struct AskOrders; + +impl OrdersCollection for AskOrders { + /// Asks are inserted in ascending order. The top of the book (first price on the book) is thus + /// the lowest price. + /// + /// Inserting a new ask at an existing price has the lowest time order precedence among all asks + /// of that price, so in order to find the insertion index for a new ask, find the first price + /// that is greater than the new ask and insert before it. + /// + /// If the ask is the highest price on the book, it's inserted at the end. + #[inline(always)] + fn find_new_order_next_index( + list: &LinkedList<'_, T>, + new_order: &Order, + ) -> SectorIndex { + // Find the first price that is greater than the new ask. + for (index, node) in list.iter() { + let order = node.load_payload::(); + if order.encoded_price() > new_order.encoded_price() { + return index; + } + } + + // If the node is to be inserted at the end of the list, the new `next` index is `NIL`, + // since the new node is the new tail. + NIL + } + + /// A post-only ask order can only be posted if the input price > the highest bid, because it + /// would immediately take otherwise. + /// + /// If this condition is satisfied or if the bid side is empty, the order cannot cross and may + /// be posted. + #[inline(always)] + fn post_only_crossing_check(order: &Order, market: &Market) -> DropsetResult + where + H: AsRef, + S: AsRef<[u8]>, + { + let ask_price = order.encoded_price(); + let first_bid_node = market.iter_bids().next(); + match first_bid_node { + // Check that the ask price is greater than the first bid (the highest bid). + Some((_idx, bid_node)) => { + let highest_bid = bid_node.load_payload::(); + if ask_price > highest_bid.encoded_price() { + Ok(()) + } else { + Err(DropsetError::PostOnlyWouldImmediatelyFill) + } + } + // There are no bid orders, so the ask cannot cross and may be posted. + None => Ok(()), + } + } +} + +pub type AskOrdersLinkedList<'a> = LinkedList<'a, AskOrders>; + +/// Operations for the sorted, doubly linked list of nodes containing ask +/// [`crate::state::order::Order`] payloads. +impl LinkedListOperations for AskOrders { + fn head(header: &MarketHeader) -> SectorIndex { + header.asks_dll_head() + } + + fn set_head(header: &mut MarketHeader, new_index: SectorIndex) { + header.set_asks_dll_head(new_index); + } + + fn tail(header: &MarketHeader) -> SectorIndex { + header.asks_dll_tail() + } + + fn set_tail(header: &mut MarketHeader, new_index: SectorIndex) { + header.set_asks_dll_tail(new_index); + } + + fn increment_num_nodes(header: &mut MarketHeader) { + header.increment_num_asks(); + } + + fn decrement_num_nodes(header: &mut MarketHeader) { + header.decrement_num_asks(); + } +} diff --git a/interface/src/state/bids_dll.rs b/interface/src/state/bids_dll.rs new file mode 100644 index 000000000..4f0c208ac --- /dev/null +++ b/interface/src/state/bids_dll.rs @@ -0,0 +1,112 @@ +//! Doubly linked list of bid order nodes with [`crate::state::order::Order`] payloads. + +use crate::{ + error::{ + DropsetError, + DropsetResult, + }, + state::{ + linked_list::{ + LinkedList, + LinkedListOperations, + }, + market::Market, + market_header::MarketHeader, + order::{ + Order, + OrdersCollection, + }, + sector::{ + SectorIndex, + NIL, + }, + }, +}; + +pub struct BidOrders; + +impl OrdersCollection for BidOrders { + /// Bids are inserted in descending order. The top of the book (first price on the book) is thus + /// the highest price. + /// + /// Inserting a new bid at an existing price has the lowest time order precedence among all bids + /// of that price, so in order to find the insertion index for a new bid, find the first price + /// that is less than the new bid and insert before it. + /// + /// If the bid is the lowest price on the book, it's inserted at the end. + #[inline(always)] + fn find_new_order_next_index( + list: &LinkedList<'_, T>, + new_order: &Order, + ) -> SectorIndex { + // Find the first price that is less than the new bid. + for (index, node) in list.iter() { + let order = node.load_payload::(); + if order.encoded_price() < new_order.encoded_price() { + return index; + } + } + + // If the node is to be inserted at the end of the list, the new `next` index is `NIL`, + // since the new node is the new tail. + NIL + } + + /// A post-only bid order can only be posted if the input price < the lowest ask, because it + /// would immediately take otherwise. + /// + /// If this condition is satisfied or if the ask side is empty, the order cannot cross and may + /// be posted. + #[inline(always)] + fn post_only_crossing_check(order: &Order, market: &Market) -> DropsetResult + where + H: AsRef, + S: AsRef<[u8]>, + { + let bid_price = order.encoded_price(); + let first_ask_node = market.iter_asks().next(); + match first_ask_node { + // Check that the bid price is less than the first ask (the lowest ask). + Some((_idx, ask_node)) => { + let lowest_ask = ask_node.load_payload::(); + if bid_price < lowest_ask.encoded_price() { + Ok(()) + } else { + Err(DropsetError::PostOnlyWouldImmediatelyFill) + } + } + // There are no ask orders, so the bid cannot cross and may be posted. + None => Ok(()), + } + } +} + +pub type BidOrdersLinkedList<'a> = LinkedList<'a, BidOrders>; + +/// Operations for the sorted, doubly linked list of nodes containing bid +/// [`crate::state::order::Order`] payloads. +impl LinkedListOperations for BidOrders { + fn head(header: &MarketHeader) -> SectorIndex { + header.bids_dll_head() + } + + fn set_head(header: &mut MarketHeader, new_index: SectorIndex) { + header.set_bids_dll_head(new_index); + } + + fn tail(header: &MarketHeader) -> SectorIndex { + header.bids_dll_tail() + } + + fn set_tail(header: &mut MarketHeader, new_index: SectorIndex) { + header.set_bids_dll_tail(new_index); + } + + fn increment_num_nodes(header: &mut MarketHeader) { + header.increment_num_bids(); + } + + fn decrement_num_nodes(header: &mut MarketHeader) { + header.decrement_num_bids(); + } +} diff --git a/interface/src/state/free_stack.rs b/interface/src/state/free_stack.rs index 8f4ea240d..c44c8cc22 100644 --- a/interface/src/state/free_stack.rs +++ b/interface/src/state/free_stack.rs @@ -10,6 +10,7 @@ use crate::{ state::{ market_header::MarketHeader, node::{ + AllBitPatternsValid, Node, NodePayload, NODE_PAYLOAD_SIZE, @@ -59,6 +60,9 @@ const_assert_eq!(1, align_of::()); // Safety: FreeNodePayload's size is checked below. unsafe impl NodePayload for FreeNodePayload {} +// Safety: All bit patterns are valid. +unsafe impl AllBitPatternsValid for FreeNodePayload {} + const_assert_eq!(size_of::(), NODE_PAYLOAD_SIZE); impl<'a> Stack<'a> { diff --git a/interface/src/state/market.rs b/interface/src/state/market.rs index d4e417ad6..ceba1d954 100644 --- a/interface/src/state/market.rs +++ b/interface/src/state/market.rs @@ -2,6 +2,8 @@ //! storage sectors into a unified on-chain representation. use crate::state::{ + asks_dll::AskOrdersLinkedList, + bids_dll::BidOrdersLinkedList, free_stack::Stack, linked_list::LinkedListIter, market_header::{ @@ -22,12 +24,21 @@ pub type MarketRef<'a> = Market<&'a MarketHeader, &'a [u8]>; pub type MarketRefMut<'a> = Market<&'a mut MarketHeader, &'a mut [u8]>; impl AsRef for &MarketHeader { + #[inline(always)] + fn as_ref(&self) -> &MarketHeader { + self + } +} + +impl AsRef for &mut MarketHeader { + #[inline(always)] fn as_ref(&self) -> &MarketHeader { self } } impl AsMut for &mut MarketHeader { + #[inline(always)] fn as_mut(&mut self) -> &mut MarketHeader { self } @@ -74,12 +85,38 @@ impl<'a> MarketRefMut<'a> { } #[inline(always)] - pub fn seat_list(&mut self) -> SeatsLinkedList { + pub fn seats(&mut self) -> SeatsLinkedList { SeatsLinkedList::new_from_parts(self.header, self.sectors) } + + #[inline(always)] + pub fn bids(&mut self) -> BidOrdersLinkedList { + BidOrdersLinkedList::new_from_parts(self.header, self.sectors) + } + + #[inline(always)] + pub fn asks(&mut self) -> AskOrdersLinkedList { + AskOrdersLinkedList::new_from_parts(self.header, self.sectors) + } } impl, S: AsRef<[u8]>> Market { + #[inline(always)] + pub fn iter_bids(&self) -> LinkedListIter<'_> { + LinkedListIter { + curr: self.header.as_ref().bids_dll_head(), + sectors: self.sectors.as_ref(), + } + } + + #[inline(always)] + pub fn iter_asks(&self) -> LinkedListIter<'_> { + LinkedListIter { + curr: self.header.as_ref().asks_dll_head(), + sectors: self.sectors.as_ref(), + } + } + #[inline(always)] pub fn iter_seats(&self) -> LinkedListIter<'_> { LinkedListIter { diff --git a/interface/src/state/market_header.rs b/interface/src/state/market_header.rs index 06d834b62..674aa0c2e 100644 --- a/interface/src/state/market_header.rs +++ b/interface/src/state/market_header.rs @@ -44,6 +44,10 @@ pub struct MarketHeader { discriminant: LeU64, /// The u32 total number of fully initialized seats as LE bytes. num_seats: LeU32, + /// The u32 total number of fully initialized bid orders as LE bytes. + num_bids: LeU32, + /// The u32 total number of fully initialized ask orders as LE bytes. + num_asks: LeU32, /// The u32 total number of sectors in the free stack as LE bytes. num_free_sectors: LeU32, /// The u32 sector index of the first node in the stack of free nodes as LE bytes. @@ -52,6 +56,14 @@ pub struct MarketHeader { seats_dll_head: LeSectorIndex, /// The u32 sector index of the last node in the doubly linked list of seat nodes as LE bytes. seats_dll_tail: LeSectorIndex, + /// The u32 sector index of the first node in the doubly linked list of bid nodes as LE bytes. + bids_dll_head: LeSectorIndex, + /// The u32 sector index of the last node in the doubly linked list of bid nodes as LE bytes. + bids_dll_tail: LeSectorIndex, + /// The u32 sector index of the first node in the doubly linked list of ask nodes as LE bytes. + asks_dll_head: LeSectorIndex, + /// The u32 sector index of the last node in the doubly linked list of ask nodes as LE bytes. + asks_dll_tail: LeSectorIndex, /// The market's base mint public key. pub base_mint: Pubkey, /// The market's quote mint public key. @@ -74,10 +86,16 @@ unsafe impl Transmutable for MarketHeader { const LEN: usize = 0 /* discriminant */ + size_of::() /* num_seats */ + size_of::() + /* num_bids */ + size_of::() + /* num_asks */ + size_of::() /* num_free_sectors */ + size_of::() /* free_stack_top */ + size_of::() /* seats_dll_head */ + size_of::() /* seats_dll_tail */ + size_of::() + /* bids_dll_head */ + size_of::() + /* bids_dll_tail */ + size_of::() + /* asks_dll_head */ + size_of::() + /* asks_dll_tail */ + size_of::() /* base_mint */ + size_of::() /* quote_mint */ + size_of::() /* market_bump */ + size_of::() @@ -111,10 +129,16 @@ impl MarketHeader { let header = MarketHeader { discriminant: MARKET_ACCOUNT_DISCRIMINANT.to_le_bytes(), num_seats: [0; U32_SIZE], + num_bids: [0; U32_SIZE], + num_asks: [0; U32_SIZE], num_free_sectors: [0; U32_SIZE], free_stack_top: LE_NIL, seats_dll_head: LE_NIL, seats_dll_tail: LE_NIL, + bids_dll_head: LE_NIL, + bids_dll_tail: LE_NIL, + asks_dll_head: LE_NIL, + asks_dll_tail: LE_NIL, base_mint: *base_mint, quote_mint: *quote_mint, market_bump, @@ -152,6 +176,36 @@ impl MarketHeader { self.num_seats = self.num_seats().saturating_sub(1).to_le_bytes(); } + #[inline(always)] + pub fn num_bids(&self) -> u32 { + u32::from_le_bytes(self.num_bids) + } + + #[inline(always)] + pub fn increment_num_bids(&mut self) { + self.num_bids = self.num_bids().saturating_add(1).to_le_bytes(); + } + + #[inline(always)] + pub fn decrement_num_bids(&mut self) { + self.num_bids = self.num_bids().saturating_sub(1).to_le_bytes(); + } + + #[inline(always)] + pub fn num_asks(&self) -> u32 { + u32::from_le_bytes(self.num_asks) + } + + #[inline(always)] + pub fn increment_num_asks(&mut self) { + self.num_asks = self.num_asks().saturating_add(1).to_le_bytes(); + } + + #[inline(always)] + pub fn decrement_num_asks(&mut self) { + self.num_asks = self.num_asks().saturating_sub(1).to_le_bytes(); + } + #[inline(always)] pub fn num_free_sectors(&self) -> u32 { u32::from_le_bytes(self.num_free_sectors) @@ -197,6 +251,46 @@ impl MarketHeader { self.seats_dll_tail = index.to_le_bytes(); } + #[inline(always)] + pub fn bids_dll_head(&self) -> SectorIndex { + u32::from_le_bytes(self.bids_dll_head) + } + + #[inline(always)] + pub fn set_bids_dll_head(&mut self, index: SectorIndex) { + self.bids_dll_head = index.to_le_bytes(); + } + + #[inline(always)] + pub fn bids_dll_tail(&self) -> SectorIndex { + u32::from_le_bytes(self.bids_dll_tail) + } + + #[inline(always)] + pub fn set_bids_dll_tail(&mut self, index: SectorIndex) { + self.bids_dll_tail = index.to_le_bytes(); + } + + #[inline(always)] + pub fn asks_dll_head(&self) -> SectorIndex { + u32::from_le_bytes(self.asks_dll_head) + } + + #[inline(always)] + pub fn set_asks_dll_head(&mut self, index: SectorIndex) { + self.asks_dll_head = index.to_le_bytes(); + } + + #[inline(always)] + pub fn asks_dll_tail(&self) -> SectorIndex { + u32::from_le_bytes(self.asks_dll_tail) + } + + #[inline(always)] + pub fn set_asks_dll_tail(&mut self, index: SectorIndex) { + self.asks_dll_tail = index.to_le_bytes(); + } + #[inline(always)] pub fn num_events(&self) -> u64 { u64::from_le_bytes(self.num_events) diff --git a/interface/src/state/market_seat.rs b/interface/src/state/market_seat.rs index 2135ea7c7..f19436018 100644 --- a/interface/src/state/market_seat.rs +++ b/interface/src/state/market_seat.rs @@ -3,14 +3,21 @@ use pinocchio::pubkey::Pubkey; use static_assertions::const_assert_eq; -use crate::state::{ - node::{ - NodePayload, - NODE_PAYLOAD_SIZE, +use crate::{ + error::{ + DropsetError, + DropsetResult, + }, + state::{ + node::{ + AllBitPatternsValid, + NodePayload, + NODE_PAYLOAD_SIZE, + }, + transmutable::Transmutable, + user_order_sectors::UserOrderSectors, + U64_SIZE, }, - transmutable::Transmutable, - user_order_sectors::UserOrderSectors, - U64_SIZE, }; /// Represents a user's position within a market. @@ -20,14 +27,12 @@ pub struct MarketSeat { /// The user's public key. pub user: Pubkey, /// The u64 amount of base the maker can withdraw as LE bytes. - /// Updated on place, cancel, deposit, withdraw. base_available: [u8; U64_SIZE], /// The u64 amount of quote the maker can withdraw as LE bytes. - /// Updated on place, cancel, deposit, withdraw. quote_available: [u8; U64_SIZE], /// The mapping for a user's order prices to order sector indices. /// This facilitates O(1) indexing from a user's seat -> their orders. - user_order_sectors: UserOrderSectors, + pub user_order_sectors: UserOrderSectors, } impl MarketSeat { @@ -61,11 +66,59 @@ impl MarketSeat { } #[inline(always)] - pub fn as_array(&self) -> &[u8; MarketSeat::LEN] { - // Safety: - // - `MarketSeat` is always `LEN` bytes; size and alignment are checked with const asserts. - // - All fields are byte-safe, `Copy`, non-pointer/reference u8 arrays. - unsafe { &*(self as *const Self as *const [u8; MarketSeat::LEN]) } + pub fn try_decrement_base_available(&mut self, amount: u64) -> DropsetResult { + let remaining = price::checked_sub!( + self.base_available(), + amount, + DropsetError::InsufficientUserBalance + )?; + self.set_base_available(remaining); + + Ok(()) + } + + #[inline(always)] + pub fn try_decrement_quote_available(&mut self, amount: u64) -> DropsetResult { + let remaining = price::checked_sub!( + self.quote_available(), + amount, + DropsetError::InsufficientUserBalance + )?; + self.set_quote_available(remaining); + + Ok(()) + } + + #[inline(always)] + pub fn try_increment_base_available(&mut self, amount: u64) -> DropsetResult { + let new_amount = self.base_available().checked_add(amount).ok_or_else(|| { + pinocchio::hint::cold_path(); + DropsetError::ArithmeticOverflow + })?; + self.set_base_available(new_amount); + + Ok(()) + } + + #[inline(always)] + pub fn try_increment_quote_available(&mut self, amount: u64) -> DropsetResult { + let new_amount = self.quote_available().checked_add(amount).ok_or_else(|| { + pinocchio::hint::cold_path(); + DropsetError::ArithmeticOverflow + })?; + self.set_quote_available(new_amount); + + Ok(()) + } + + /// This method is sound because: + /// + /// - `Self` is exactly `Self::LEN` bytes. + /// - Size and alignment are verified with const assertions. + /// - All fields are byte-safe, `Copy`, non-pointer/reference u8 arrays. + #[inline(always)] + pub fn as_bytes(&self) -> &[u8; Self::LEN] { + unsafe { &*(self as *const Self as *const [u8; Self::LEN]) } } } @@ -89,3 +142,6 @@ const_assert_eq!(align_of::(), 1); // Safety: Const asserts ensure size_of::() == NODE_PAYLOAD_SIZE. unsafe impl NodePayload for MarketSeat {} + +// Safety: All bit patterns are valid. +unsafe impl AllBitPatternsValid for MarketSeat {} diff --git a/interface/src/state/mod.rs b/interface/src/state/mod.rs index 416404207..961f7d089 100644 --- a/interface/src/state/mod.rs +++ b/interface/src/state/mod.rs @@ -1,12 +1,15 @@ //! Core on-chain state definitions, covering markets, seats, nodes, and low-level data structures //! for indexing and iteration. +pub mod asks_dll; +pub mod bids_dll; pub mod free_stack; pub mod linked_list; pub mod market; pub mod market_header; pub mod market_seat; pub mod node; +pub mod order; pub mod seats_dll; pub mod sector; pub mod transmutable; diff --git a/interface/src/state/node.rs b/interface/src/state/node.rs index 84e4b37e0..d52d5a4af 100644 --- a/interface/src/state/node.rs +++ b/interface/src/state/node.rs @@ -50,6 +50,17 @@ pub struct Node { /// [`NodePayload`]. pub unsafe trait NodePayload: Transmutable {} +/// Marker trait to indicate that the type is valid for all bit patterns as long as the size +/// constraint is satisfied. It therefore doesn't require a check on individual bytes prior to +/// transmutation. +/// +/// That is, it has no invalid enum variants, isn't a bool, etc. +/// +/// # Safety +/// +/// Implementor guarantees that all bit patterns are valid for some `T:`[`AllBitPatternsValid`]. +pub unsafe trait AllBitPatternsValid: Transmutable {} + // Safety: // // - Stable layout with `#[repr(C)]`. @@ -110,13 +121,13 @@ impl Node { } #[inline(always)] - pub fn load_payload(&self) -> &T { + pub fn load_payload(&self) -> &T { // Safety: All `NodePayload` implementations should have a length of `NODE_PAYLOAD_SIZE`. unsafe { T::load_unchecked(&self.payload) } } #[inline(always)] - pub fn load_payload_mut(&mut self) -> &mut T { + pub fn load_payload_mut(&mut self) -> &mut T { // Safety: All `NodePayload` implementations should have a length of `NODE_PAYLOAD_SIZE`. unsafe { T::load_unchecked_mut(&mut self.payload) } } diff --git a/interface/src/state/order.rs b/interface/src/state/order.rs new file mode 100644 index 000000000..753b2d941 --- /dev/null +++ b/interface/src/state/order.rs @@ -0,0 +1,218 @@ +use price::{ + LeEncodedPrice, + OrderInfo, +}; +use static_assertions::const_assert_eq; + +use crate::{ + error::DropsetResult, + state::{ + linked_list::{ + LinkedList, + LinkedListOperations, + }, + market::Market, + market_header::MarketHeader, + node::{ + AllBitPatternsValid, + NodePayload, + NODE_PAYLOAD_SIZE, + }, + sector::{ + LeSectorIndex, + SectorIndex, + }, + transmutable::Transmutable, + U64_SIZE, + }, +}; + +/// Marker trait to indicate that a struct represents a collection of orders. +pub trait OrdersCollection { + /// Find the insertion point for a new order by returning what the new order node's `next_index` + /// should be after insertion. + /// + /// That is, given some `new` order, the list would be updated from this: + /// + /// `prev => next` + /// To this: + /// `prev => new => next` + /// + /// where this function returns the `next` node's sector index. + fn find_new_order_next_index( + list: &LinkedList<'_, T>, + new_order: &Order, + ) -> SectorIndex; + + /// A post-only order must not execute immediately, so it must fail if it would cross the book + /// and match against resting liquidity. + fn post_only_crossing_check(order: &Order, market: &Market) -> DropsetResult + where + H: AsRef, + S: AsRef<[u8]>; +} + +const ORDER_PADDING: usize = NODE_PAYLOAD_SIZE + - (size_of::() + size_of::() + U64_SIZE + U64_SIZE); + +/// Represents a maker order in the orderbook. +#[repr(C)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Order { + /// The LE bytes representing an [`EncodedPrice`]. + encoded_price: LeEncodedPrice, + /// This enables O(1) indexing from a user/maker's orders -> their seat. + user_seat: LeSectorIndex, + /// The u64 number of base atoms left remaining to fill as LE bytes. + base_remaining: [u8; U64_SIZE], + /// The u64 number of quote atoms left remaining to fill as LE bytes. + quote_remaining: [u8; U64_SIZE], + /// Padding to fill the rest of the node payload size. + _padding: [u8; ORDER_PADDING], +} + +impl Order { + /// Create a new order from the order info and the user seat. + #[inline(always)] + pub fn new(order_info: OrderInfo, user_seat: SectorIndex) -> Self { + Self { + encoded_price: order_info.encoded_price.into(), + user_seat: user_seat.to_le_bytes(), + base_remaining: order_info.base_atoms.to_le_bytes(), + quote_remaining: order_info.quote_atoms.to_le_bytes(), + _padding: [0u8; ORDER_PADDING], + } + } + + #[inline(always)] + pub fn le_encoded_price(&self) -> &LeEncodedPrice { + &self.encoded_price + } + + #[inline(always)] + pub fn encoded_price(&self) -> u32 { + u32::from_le_bytes(self.encoded_price.as_array()) + } + + #[inline(always)] + pub fn user_seat(&self) -> u32 { + u32::from_le_bytes(self.user_seat) + } + + #[inline(always)] + pub fn base_remaining(&self) -> u64 { + u64::from_le_bytes(self.base_remaining) + } + + #[inline(always)] + pub fn set_base_remaining(&mut self, amount: u64) { + self.base_remaining = amount.to_le_bytes(); + } + + #[inline(always)] + pub fn quote_remaining(&self) -> u64 { + u64::from_le_bytes(self.quote_remaining) + } + + #[inline(always)] + pub fn set_quote_remaining(&mut self, amount: u64) { + self.quote_remaining = amount.to_le_bytes(); + } + + /// This method is sound because: + /// + /// - `Self` is exactly `Self::LEN` bytes. + /// - Size and alignment are verified with const assertions. + /// - All fields are byte-safe, `Copy`, non-pointer/reference u8 arrays. + #[inline(always)] + pub fn as_bytes(&self) -> &[u8; Self::LEN] { + unsafe { &*(self as *const Self as *const [u8; Self::LEN]) } + } +} + +// Safety: +// +// - Stable layout with `#[repr(C)]`. +// - `size_of` and `align_of` are checked below. +// - All bit patterns are valid. +unsafe impl Transmutable for Order { + const LEN: usize = NODE_PAYLOAD_SIZE; + + #[inline(always)] + fn validate_bit_patterns(_bytes: &[u8]) -> crate::error::DropsetResult { + // All bit patterns are valid: no enums, bools, or other types with invalid states. + Ok(()) + } +} + +const_assert_eq!(size_of::(), NODE_PAYLOAD_SIZE); +const_assert_eq!(align_of::(), 1); + +// Safety: Const asserts ensure size_of::() == NODE_PAYLOAD_SIZE. +unsafe impl NodePayload for Order {} + +// Safety: All bit patterns are valid. +unsafe impl AllBitPatternsValid for Order {} + +#[cfg(test)] +mod tests { + use price::{ + to_order_info, + EncodedPrice, + }; + + use super::*; + + #[test] + fn new_order_happy_path() { + let order_info = to_order_info(10_000_000, 5, 8, 0).expect("Should create order info"); + let base_in_order = order_info.base_atoms; + let quote_in_order = order_info.quote_atoms; + let encoded_price_in_order = order_info.encoded_price; + let user_seat = 17; + let order = Order::new(order_info, user_seat); + assert_eq!(base_in_order, order.base_remaining()); + assert_eq!(quote_in_order, order.quote_remaining()); + assert_eq!(encoded_price_in_order.as_u32(), order.encoded_price()); + assert_eq!(user_seat, order.user_seat()); + } + + #[test] + fn order_mutators() { + let order_info = to_order_info(10_000_000, 5, 8, 0).expect("Should create order info"); + let user_seat = 17; + let mut order = Order::new(order_info, user_seat); + let new_base = 27364; + let new_quote = 123876; + assert_ne!(order.base_remaining(), new_base); + assert_ne!(order.quote_remaining(), new_base); + order.set_base_remaining(new_base); + order.set_quote_remaining(new_quote); + assert_eq!(order.base_remaining(), new_base); + assert_eq!(order.quote_remaining(), new_base); + } + + #[test] + fn test_as_bytes() { + const BASE_ATOMS: u64 = 1234; + const QUOTE_ATOMS: u64 = 4321; + let order_info = OrderInfo { + encoded_price: EncodedPrice::zero(), + base_atoms: BASE_ATOMS, + quote_atoms: QUOTE_ATOMS, + }; + const USER_SEAT: SectorIndex = 9191; + let order = Order::new(order_info, USER_SEAT); + assert_eq!( + [ + &0u32.to_le_bytes(), // Encoded price. + &USER_SEAT.to_le_bytes(), // User seat. + BASE_ATOMS.to_le_bytes().as_ref(), // Base remaining. + QUOTE_ATOMS.to_le_bytes().as_ref(), // Quote remaining. + [0u8; ORDER_PADDING].as_ref(), // Padding. + ] + .concat(), + order.as_bytes() + ); + } +} diff --git a/interface/src/state/user_order_sectors.rs b/interface/src/state/user_order_sectors.rs index 098314235..87504ae18 100644 --- a/interface/src/state/user_order_sectors.rs +++ b/interface/src/state/user_order_sectors.rs @@ -24,7 +24,7 @@ use crate::{ pub const MAX_ORDERS: u8 = 5; /// The [`OrderSectors`] that maps the prices of a user's bids and asks to their corresponding -/// sector indices in the market account data. +/// orders' sector indices in the market account data. /// /// `bids` and `asks` both have a maximum [`MAX_ORDERS`] orders. #[repr(C)] @@ -35,8 +35,9 @@ pub struct UserOrderSectors { } /// An array of [`MAX_ORDERS`] [`PriceToIndex`]s that maps unique prices to a sector index. -/// By default, [`PriceToIndex`]s are free orders and map an encoded price u32 value of `0` -/// to the [`LE_NIL`] sector index. +/// +/// By default, each [`PriceToIndex`] represents an unused item by mapping an encoded price u32 +/// value of `0` to the [`LE_NIL`] sector index. #[repr(transparent)] #[derive(Clone, Debug, PartialEq, Eq)] pub struct OrderSectors([PriceToIndex; MAX_ORDERS as usize]); @@ -72,7 +73,11 @@ impl OrderSectors { /// The `sector_index` passed to this method should be non-NIL or the node after mutation will /// continue to be treated as a free node. #[inline(always)] - pub fn add(&mut self, new_price: &LeEncodedPrice, new_index: &LeSectorIndex) -> DropsetResult { + pub fn add( + &mut self, + new_price: &LeEncodedPrice, + order_index: &LeSectorIndex, + ) -> DropsetResult { // Check if the price already exists in a node and fail early if it does. if self .iter() @@ -87,7 +92,7 @@ impl OrderSectors { .ok_or(DropsetError::UserHasMaxOrders)?; node.encoded_price = *new_price; - node.sector_index = *new_index; + node.sector_index = *order_index; Ok(()) } @@ -95,18 +100,25 @@ impl OrderSectors { /// Fallibly remove a [`PriceToIndex`] from a user's orders. /// /// Fails if the user does not have an order corresponding to the passed encoded price. + /// + /// Note that the encoded price does not have to be validated since it's doing a simple match + /// on equality and isn't stored anywhere. + /// + /// Returns the mapped order's sector index. #[inline(always)] - pub fn remove(&mut self, price_to_remove: &LeEncodedPrice) -> DropsetResult { + pub fn remove(&mut self, encoded_price: u32) -> Result { let node = self .0 .iter_mut() - .find(|node| node.encoded_price.as_slice() == price_to_remove.as_slice()) + .find(|node| node.encoded_price.as_slice() == &encoded_price.to_le_bytes()) .ok_or(DropsetError::OrderNotFound)?; + let sector_index = node.sector_index; + node.encoded_price = LeEncodedPrice::zero(); node.sector_index = LE_NIL; - Ok(()) + Ok(sector_index) } #[inline(always)] @@ -125,7 +137,7 @@ impl OrderSectors { /// If the sector index equals [`LE_NIL`], it's considered a freed node, otherwise, it contains an /// existing, valid pair of encoded price to sector index. #[repr(C)] -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, PartialEq, Eq)] pub struct PriceToIndex { pub encoded_price: LeEncodedPrice, pub sector_index: LeSectorIndex, @@ -210,6 +222,33 @@ unsafe impl Transmutable for PriceToIndex { const_assert_eq!(PriceToIndex::LEN, size_of::()); const_assert_eq!(align_of::(), 1); +// ------------------------------------------------------------------------------------------------- +// Create readable debug views for the encoded price to order sector mapping. +#[allow(dead_code)] +#[derive(Debug)] +struct PriceToIndexView { + pub encoded_price: u32, + pub sector_index: SectorIndex, +} + +impl From<&PriceToIndex> for PriceToIndexView { + fn from(value: &PriceToIndex) -> Self { + Self { + encoded_price: u32::from_le_bytes(value.encoded_price.as_array()), + sector_index: SectorIndex::from_le_bytes(value.sector_index), + } + } +} + +impl core::fmt::Debug for PriceToIndex { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let is_in_use = !self.is_free(); + let node: Option = is_in_use.then(|| self.into()); + write!(f, "{:#?}", node) + } +} + +// ------------------------------------------------------------------------------------------------- #[cfg(test)] mod tests { use price::{ @@ -345,7 +384,7 @@ mod tests { to_biased_exponent!(1), ValidatedPriceMantissa::try_from(12_345_678).unwrap(), ); - let failed_remove = order_sectors.bids.remove(&bid_encoded_price.into()); + let failed_remove = order_sectors.bids.remove(bid_encoded_price.as_u32()); assert!(matches!(failed_remove, Err(DropsetError::OrderNotFound))); } @@ -373,7 +412,10 @@ mod tests { }); assert_eq!(num_orders_in_use, 1); - assert!(order_sectors.bids.remove(&bid_encoded_price.into()).is_ok()); + assert!(order_sectors + .bids + .remove(bid_encoded_price.as_u32()) + .is_ok()); assert!(order_sectors.bids.iter().all(|bid| bid.is_free())); } @@ -415,7 +457,7 @@ mod tests { } #[test] - fn replace_arbitrary_order() { + fn repost_arbitrary_order() { let mut order_sectors = UserOrderSectors::default(); let index_and_mantissa_pairs: [(u32, ValidatedPriceMantissa); MAX_ORDERS as usize] = [ (1, ValidatedPriceMantissa::try_from(11_111_111).unwrap()), @@ -460,7 +502,7 @@ mod tests { assert_ne!(old_sector_index, new_sector_index); // Remove the old price. - assert!(order_sectors.bids.remove(&old_price.into()).is_ok()); + assert!(order_sectors.bids.remove(old_price.as_u32()).is_ok()); // Add the new price. assert!(order_sectors diff --git a/package.json b/package.json index 7f49fd820..973efefac 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "examples:deposit_and_withdraw": "cargo run --example deposit_and_withdraw", "examples:close_seat": "cargo run --example close_seat", "examples:many_instructions": "cargo run --example many_instructions", + "examples:post_asks": "cargo run --example post_asks", + "examples:post_and_cancel": "cargo run --example post_and_cancel", "examples:two_seats": "cargo run --example two_seats" } } diff --git a/price/src/encoded_price.rs b/price/src/encoded_price.rs index 5428937cf..97c55b197 100644 --- a/price/src/encoded_price.rs +++ b/price/src/encoded_price.rs @@ -76,6 +76,11 @@ impl EncodedPrice { pub struct LeEncodedPrice([u8; U32_SIZE]); impl LeEncodedPrice { + #[inline(always)] + pub fn as_array(&self) -> [u8; U32_SIZE] { + self.0 + } + #[inline(always)] pub fn as_slice(&self) -> &[u8; U32_SIZE] { &self.0 diff --git a/program/Cargo.toml b/program/Cargo.toml index 32bd415d2..f3cfeb411 100644 --- a/program/Cargo.toml +++ b/program/Cargo.toml @@ -17,6 +17,7 @@ pinocchio-system.workspace = true pinocchio-token.workspace = true pinocchio-token-interface.workspace = true pinocchio-token-2022.workspace = true +price = { path = "../price" } static_assertions.workspace = true [dev-dependencies] diff --git a/program/src/context/cancel_order_context.rs b/program/src/context/cancel_order_context.rs new file mode 100644 index 000000000..c772e0e87 --- /dev/null +++ b/program/src/context/cancel_order_context.rs @@ -0,0 +1,49 @@ +//! See [`CancelOrderContext`]. + +use dropset_interface::instructions::generated_pinocchio::CancelOrder; +use pinocchio::{ + account_info::AccountInfo, + program_error::ProgramError, +}; + +use crate::validation::market_account_info::MarketAccountInfo; + +/// The account context for the [`CancelOrder`] instruction, validating the market account passed +/// in. +#[derive(Clone)] +pub struct CancelOrderContext<'a> { + // The event authority is validated by the inevitable `FlushEvents` self-CPI. + pub event_authority: &'a AccountInfo, + pub user: &'a AccountInfo, + pub market_account: MarketAccountInfo<'a>, +} + +impl<'a> CancelOrderContext<'a> { + /// # Safety + /// + /// Caller guarantees: + /// - WRITE accounts are not currently borrowed in *any* capacity. + /// - READ accounts are not currently mutably borrowed. + /// + /// ### Accounts + /// 0. `[READ]` Market account + pub unsafe fn load( + accounts: &'a [AccountInfo], + ) -> Result, ProgramError> { + let CancelOrder { + event_authority, + user, + market_account, + dropset_program: _, + } = CancelOrder::load_accounts(accounts)?; + + // Safety: Scoped borrow of market account data. + let market_account = unsafe { MarketAccountInfo::new(market_account) }?; + + Ok(Self { + event_authority, + user, + market_account, + }) + } +} diff --git a/program/src/context/mod.rs b/program/src/context/mod.rs index 1a8af0921..8856fb844 100644 --- a/program/src/context/mod.rs +++ b/program/src/context/mod.rs @@ -3,9 +3,11 @@ //! Each context groups and validates the accounts required by its corresponding instruction before //! execution. +pub mod cancel_order_context; pub mod close_seat_context; pub mod deposit_withdraw_context; pub mod flush_events_context; +pub mod post_order_context; pub mod register_market_context; /// The account infos necessary to emit events with the event buffer. diff --git a/program/src/context/post_order_context.rs b/program/src/context/post_order_context.rs new file mode 100644 index 000000000..335ab646d --- /dev/null +++ b/program/src/context/post_order_context.rs @@ -0,0 +1,46 @@ +//! See [`PostOrderContext`]. + +use dropset_interface::instructions::generated_pinocchio::PostOrder; +use pinocchio::{ + account_info::AccountInfo, + program_error::ProgramError, +}; + +use crate::validation::market_account_info::MarketAccountInfo; + +/// The account context for the [`PostOrder`] instruction, validating the market account passed in. +#[derive(Clone)] +pub struct PostOrderContext<'a> { + // The event authority is validated by the inevitable `FlushEvents` self-CPI. + pub event_authority: &'a AccountInfo, + pub user: &'a AccountInfo, + pub market_account: MarketAccountInfo<'a>, +} + +impl<'a> PostOrderContext<'a> { + /// # Safety + /// + /// Caller guarantees: + /// - WRITE accounts are not currently borrowed in *any* capacity. + /// - READ accounts are not currently mutably borrowed. + /// + /// ### Accounts + /// 0. `[READ]` Market account + pub unsafe fn load(accounts: &'a [AccountInfo]) -> Result, ProgramError> { + let PostOrder { + event_authority, + user, + market_account, + dropset_program: _, + } = PostOrder::load_accounts(accounts)?; + + // Safety: Scoped borrow of market account data. + let market_account = unsafe { MarketAccountInfo::new(market_account) }?; + + Ok(Self { + event_authority, + user, + market_account, + }) + } +} diff --git a/program/src/entrypoint.rs b/program/src/entrypoint.rs index b9c544153..28c087045 100644 --- a/program/src/entrypoint.rs +++ b/program/src/entrypoint.rs @@ -58,6 +58,12 @@ pub fn process_instruction( DropsetInstruction::CloseSeat => { process_close_seat(accounts, instruction_data, event_buffer) } + DropsetInstruction::PostOrder => { + process_post_order(accounts, instruction_data, event_buffer) + } + DropsetInstruction::CancelOrder => { + process_cancel_order(accounts, instruction_data, event_buffer) + } DropsetInstruction::FlushEvents => { return process_flush_events(accounts, instruction_data) } diff --git a/program/src/instructions/cancel_order.rs b/program/src/instructions/cancel_order.rs new file mode 100644 index 000000000..5a7b0524d --- /dev/null +++ b/program/src/instructions/cancel_order.rs @@ -0,0 +1,119 @@ +//! See [`process_cancel_order`]. + +#[cfg(feature = "debug")] +use dropset_interface::events::CancelOrderEventInstructionData; +use dropset_interface::{ + instructions::CancelOrderInstructionData, + state::{ + market_seat::MarketSeat, + node::Node, + sector::SectorIndex, + }, +}; +use pinocchio::{ + account_info::AccountInfo, + program_error::ProgramError, +}; + +use crate::{ + context::{ + cancel_order_context::CancelOrderContext, + EventBufferContext, + }, + events::EventBuffer, + shared::{ + order_operations::load_order_from_sector_index, + seat_operations::find_mut_seat_with_hint, + }, +}; + +/// Instruction handler logic for cancelling a user's bid or ask order on the market's order book. +/// +/// # Safety +/// +/// Caller guarantees the safety contract detailed in +/// [`dropset_interface::instructions::generated_pinocchio::CancelOrder`]. +#[inline(never)] +pub unsafe fn process_cancel_order<'a>( + accounts: &'a [AccountInfo], + instruction_data: &[u8], + _event_buffer: &mut EventBuffer, +) -> Result, ProgramError> { + let CancelOrderInstructionData { + encoded_price, + is_bid, + user_sector_index_hint, + } = CancelOrderInstructionData::unpack_pinocchio(instruction_data)?; + let mut ctx = CancelOrderContext::load(accounts)?; + + // Remove the order from the user seat's order sectors mapping. + let order_sector_index = { + // Safety: Scoped mutable borrow of the market account. + let market = unsafe { ctx.market_account.load_unchecked_mut() }; + Node::check_in_bounds(market.sectors, user_sector_index_hint)?; + // Safety: The user sector index hint was just verified in-bounds. + let user_seat = + unsafe { find_mut_seat_with_hint(market, user_sector_index_hint, ctx.user.key()) }?; + if is_bid { + SectorIndex::from_le_bytes(user_seat.user_order_sectors.bids.remove(encoded_price)?) + } else { + SectorIndex::from_le_bytes(user_seat.user_order_sectors.asks.remove(encoded_price)?) + } + }; + + // Load the order given the order sector index. + let order = { + // Safety: Scoped borrow of the market account. + let market = unsafe { ctx.market_account.load_unchecked() }; + // Safety: The order sector index returned from the `remove` method still points to a + // sector with a valid order. All order sector indices in a user seat are thus in-bounds and + // don't need to be explicitly verified as in-bounds. + debug_assert!(Node::check_in_bounds(market.sectors, order_sector_index).is_ok()); + load_order_from_sector_index(market, order_sector_index) + }; + + // Increment the user's collateral in their market seat by the amount remaining in the order. + if is_bid { + // If the user placed a bid, they provided quote as collateral. + let order_size_remaining = order.quote_remaining(); + // Safety: Scoped mutable borrow of the market account. + let market = unsafe { ctx.market_account.load_unchecked_mut() }; + // Safety: The seat index hint was validated above and the user's seat hasn't changed. + let node = unsafe { Node::from_sector_index_mut(market.sectors, user_sector_index_hint) }; + let user_seat = node.load_payload_mut::(); + user_seat.try_increment_quote_available(order_size_remaining)?; + } else { + // If the user placed an ask, they provided base as collateral. + let order_size_remaining = order.base_remaining(); + // Safety: Scoped mutable borrow of the market account. + let market = unsafe { ctx.market_account.load_unchecked_mut() }; + // Safety: The seat index hint was validated above and the user's seat hasn't changed. + let node = unsafe { Node::from_sector_index_mut(market.sectors, user_sector_index_hint) }; + let user_seat = node.load_payload_mut::(); + user_seat.try_increment_base_available(order_size_remaining)?; + } + + // Remove the order at the order sector index from the appropriate orders collection. + unsafe { + // Safety: Scoped mutable borrow of the market account. + let mut market = ctx.market_account.load_unchecked_mut(); + // Safety: The order sector index from the `remove` method is still in-bounds. + if is_bid { + market.bids().remove_at(order_sector_index); + } else { + market.asks().remove_at(order_sector_index); + } + } + + #[cfg(feature = "debug")] + _event_buffer.add_to_buffer( + CancelOrderEventInstructionData::new(is_bid, user_sector_index_hint), + ctx.event_authority, + ctx.market_account.clone(), + )?; + + Ok(EventBufferContext { + event_authority: ctx.event_authority, + market_account: ctx.market_account, + }) +} diff --git a/program/src/instructions/close_seat.rs b/program/src/instructions/close_seat.rs index 7d35f6e34..ecb28775c 100644 --- a/program/src/instructions/close_seat.rs +++ b/program/src/instructions/close_seat.rs @@ -18,7 +18,7 @@ use crate::{ }, events::EventBuffer, market_signer, - shared::market_operations::find_seat_with_hint, + shared::seat_operations::find_seat_with_hint, }; /// Instruction handler logic for closing an existing market seat and reclaiming associated funds. @@ -57,7 +57,7 @@ pub fn process_close_seat<'a>( ctx.market_account // Safety: Scoped mutable borrow of market account data to remove the seat. .load_unchecked_mut() - .seat_list() + .seats() // Safety: The index hint was verified as in-bounds. .remove_at(sector_index_hint) }; diff --git a/program/src/instructions/deposit.rs b/program/src/instructions/deposit.rs index 11bbcbe6f..0dce5abf9 100644 --- a/program/src/instructions/deposit.rs +++ b/program/src/instructions/deposit.rs @@ -21,9 +21,9 @@ use crate::{ }, events::EventBuffer, shared::{ - market_operations::{ + seat_operations::{ find_mut_seat_with_hint, - insert_market_seat, + try_insert_market_seat, }, token_utils::market_transfers::deposit_non_zero_to_market, }, @@ -106,7 +106,7 @@ pub unsafe fn process_deposit<'a>( }; // Attempts to insert the user into the linked list. If the user already exists, this fails. - insert_market_seat(&mut market.seat_list(), seat)? + try_insert_market_seat(&mut market.seats(), seat)? }; event_buffer.add_to_buffer( diff --git a/program/src/instructions/mod.rs b/program/src/instructions/mod.rs index d4d48eb2d..d9dc597a3 100644 --- a/program/src/instructions/mod.rs +++ b/program/src/instructions/mod.rs @@ -4,15 +4,19 @@ //! on-chain logic for each supported operation. pub mod batch; +pub mod cancel_order; pub mod close_seat; pub mod deposit; pub mod flush_events; +pub mod post_order; pub mod register_market; pub mod withdraw; pub use batch::process_batch; +pub use cancel_order::process_cancel_order; pub use close_seat::process_close_seat; pub use deposit::process_deposit; pub use flush_events::process_flush_events; +pub use post_order::process_post_order; pub use register_market::process_register_market; pub use withdraw::process_withdraw; diff --git a/program/src/instructions/post_order.rs b/program/src/instructions/post_order.rs new file mode 100644 index 000000000..904c2b7dc --- /dev/null +++ b/program/src/instructions/post_order.rs @@ -0,0 +1,136 @@ +//! See [`process_post_order`]. + +#[cfg(feature = "debug")] +use dropset_interface::events::PostOrderEventInstructionData; +use dropset_interface::{ + error::DropsetError, + instructions::PostOrderInstructionData, + state::{ + asks_dll::AskOrders, + bids_dll::BidOrders, + market::MarketRefMut, + node::Node, + order::{ + Order, + OrdersCollection, + }, + }, +}; +use pinocchio::{ + account_info::AccountInfo, + program_error::ProgramError, +}; +use price::to_order_info; + +use crate::{ + context::{ + post_order_context::PostOrderContext, + EventBufferContext, + }, + events::EventBuffer, + shared::{ + order_operations::insert_order, + seat_operations::find_mut_seat_with_hint, + }, +}; + +/// Instruction handler logic for posting a user's bid or ask order on the market's order book. +/// +/// # Safety +/// +/// Caller guarantees the safety contract detailed in +/// [`dropset_interface::instructions::generated_pinocchio::PostOrder`]. +#[inline(never)] +pub unsafe fn process_post_order<'a>( + accounts: &'a [AccountInfo], + instruction_data: &[u8], + _event_buffer: &mut EventBuffer, +) -> Result, ProgramError> { + let PostOrderInstructionData { + price_mantissa, + base_scalar, + base_exponent_biased, + quote_exponent_biased, + is_bid, + user_sector_index_hint, + } = PostOrderInstructionData::unpack_pinocchio(instruction_data)?; + let mut ctx = PostOrderContext::load(accounts)?; + + let order_info = to_order_info( + price_mantissa, + base_scalar, + base_exponent_biased, + quote_exponent_biased, + ) + .map_err(DropsetError::from)?; + + let (base_atoms, quote_atoms) = (order_info.base_atoms, order_info.quote_atoms); + + // To avoid convoluted borrow checking rules, optimistically insert the order with the index + // hint passed in, assuming it's valid. It's verified later when mutating the market seat. + let order = Order::new(order_info, user_sector_index_hint); + let le_encoded_price = *order.le_encoded_price(); + let order_sector_index = { + // Safety: Scoped mutable borrow of the market account to insert the order. + let mut market: MarketRefMut = unsafe { ctx.market_account.load_unchecked_mut() }; + + if is_bid { + BidOrders::post_only_crossing_check(&order, &market)?; + insert_order(&mut market.bids(), order) + } else { + AskOrders::post_only_crossing_check(&order, &market)?; + insert_order(&mut market.asks(), order) + } + }?; + + { + // Safety: Scoped mutable borrow of the market account to mutate the user's seat. + let market = unsafe { ctx.market_account.load_unchecked_mut() }; + Node::check_in_bounds(market.sectors, user_sector_index_hint)?; + // Find and verify the user's seat with the given index hint. + // Safety: The index hint was just verified as in-bounds. + let user_seat = find_mut_seat_with_hint(market, user_sector_index_hint, ctx.user.key())?; + + let order_sector_index_bytes = order_sector_index.to_le_bytes(); + + // 1. Check that the user has enough collateral to place the order and update their seat + // with the resulting decremented amount. + // 2. Update the user seat's mapped order sectors. This also checks for duplicate prices so + // that all of a user's orders have a unique price. + if is_bid { + // 1. If the user is posting a bid, they intend to provide quote and receive base. + user_seat.try_decrement_quote_available(quote_atoms)?; + // 2. Add the order to the user's bids. + user_seat + .user_order_sectors + .bids + .add(&le_encoded_price, &order_sector_index_bytes)?; + } else { + // 1. If the user is posting an ask, they intend to provide base and receive quote. + user_seat.try_decrement_base_available(base_atoms)?; + // 2. Add the order to the user's asks. + user_seat + .user_order_sectors + .asks + .add(&le_encoded_price, &order_sector_index_bytes)?; + } + } + + #[cfg(feature = "debug")] + _event_buffer.add_to_buffer( + PostOrderEventInstructionData::new( + is_bid, + user_sector_index_hint, + order_sector_index, + base_atoms, + quote_atoms, + ), + ctx.event_authority, + ctx.market_account.clone(), + )?; + + Ok(EventBufferContext { + event_authority: ctx.event_authority, + market_account: ctx.market_account, + }) +} diff --git a/program/src/instructions/withdraw.rs b/program/src/instructions/withdraw.rs index 997da9e8e..233223976 100644 --- a/program/src/instructions/withdraw.rs +++ b/program/src/instructions/withdraw.rs @@ -18,7 +18,7 @@ use crate::{ }, events::EventBuffer, shared::{ - market_operations::find_mut_seat_with_hint, + seat_operations::find_mut_seat_with_hint, token_utils::market_transfers::withdraw_non_zero_from_market, }, }; diff --git a/program/src/shared/market_operations.rs b/program/src/shared/market_operations.rs index 77dcbce8f..368b3a0e3 100644 --- a/program/src/shared/market_operations.rs +++ b/program/src/shared/market_operations.rs @@ -1,112 +1,18 @@ -//! Core reusable logic for manipulating market data structures, including inserting, removing, and -//! traversing market seats. +//! General operations on market account data. use dropset_interface::{ error::DropsetError, state::{ market::{ Market, - MarketRef, MarketRefMut, }, market_header::MarketHeader, - market_seat::MarketSeat, - node::Node, - seats_dll::SeatsLinkedList, - sector::{ - SectorIndex, - NIL, - SECTOR_SIZE, - }, + sector::SECTOR_SIZE, transmutable::Transmutable, }, }; -use pinocchio::pubkey::{ - pubkey_eq, - Pubkey, -}; - -pub fn insert_market_seat( - list: &mut SeatsLinkedList, - seat: MarketSeat, -) -> Result { - let (prev_index, insert_before_index) = find_insert_before_index(list, &seat.user); - let seat_bytes = seat.as_array(); - - // Return an error early if the user already exists in the seat list at the previous index. - if prev_index != NIL { - // Safety: `prev_index` is non-NIL and was returned by an iterator, so it must be in-bounds. - let prev_node = unsafe { Node::from_sector_index(list.sectors, prev_index) }; - let prev_seat = prev_node.load_payload::(); - if pubkey_eq(&seat.user, &prev_seat.user) { - return Err(DropsetError::UserAlreadyExists); - } - } - - if insert_before_index == list.header.seats_dll_head() { - list.push_front(seat_bytes) - } else if insert_before_index == NIL { - list.push_back(seat_bytes) - } else { - // Safety: `index` was returned by the iterator so it must be in-bounds. - unsafe { list.insert_before(insert_before_index, seat_bytes) } - } -} - -/// Returns the index a node should be inserted before and the `prev_index` relative to the index -/// to be inserted at as: -/// -/// (prev_index, insert_before_index) -fn find_insert_before_index(list: &SeatsLinkedList, user: &Pubkey) -> (SectorIndex, SectorIndex) { - for (index, node) in list.iter() { - let seat = node.load_payload::(); - if user < &seat.user { - return (node.prev(), index); - } - } - // The `prev` index at the end of the list is the tail. - (list.header.seats_dll_tail(), NIL) -} - -/// Tries to find a market seat given an index hint. -/// -/// # Safety -/// -/// Caller guarantees `hint` is in-bounds of `market.sectors` bytes. -pub unsafe fn find_seat_with_hint<'a>( - market: MarketRef<'a>, - hint: SectorIndex, - user: &Pubkey, -) -> Result<&'a MarketSeat, DropsetError> { - // Safety: Caller guarantees `hint` is in-bounds. - let node = unsafe { Node::from_sector_index(market.sectors, hint) }; - let seat = node.load_payload::(); - if pubkey_eq(user, &seat.user) { - Ok(seat) - } else { - Err(DropsetError::InvalidIndexHint) - } -} - -/// Tries to find a mutable market seat given an index hint. -/// -/// # Safety -/// -/// Caller guarantees `hint` is in-bounds of `market.sectors` bytes. -pub unsafe fn find_mut_seat_with_hint<'a>( - market: MarketRefMut<'a>, - hint: SectorIndex, - user: &Pubkey, -) -> Result<&'a mut MarketSeat, DropsetError> { - // Safety: Caller guarantees `hint` is in-bounds. - let node = unsafe { Node::from_sector_index_mut(market.sectors, hint) }; - let seat = node.load_payload_mut::(); - if pubkey_eq(user, &seat.user) { - Ok(seat) - } else { - Err(DropsetError::InvalidIndexHint) - } -} +use pinocchio::pubkey::Pubkey; /// Initializes a freshly created market account. This function skips checks based on the assumption /// that the market has just been created on-chain. @@ -160,12 +66,16 @@ pub fn initialize_market_account_data<'a>( pub mod tests { use dropset_interface::state::{ market_seat::MarketSeat, - sector::SECTOR_SIZE, + sector::{ + SectorIndex, + SECTOR_SIZE, + }, transmutable::Transmutable, }; use pinocchio_pubkey::pubkey; use super::initialize_market_account_data; + use crate::shared::seat_operations::try_insert_market_seat; extern crate std; use std::{ @@ -187,7 +97,7 @@ pub mod tests { ) .expect("Should initialize market data"); - let mut seat_list = market.seat_list(); + let mut seat_list = market.seats(); let [zero, one, two, three, ten, forty] = vec![ [vec![0; 31], vec![0]].concat().try_into().unwrap(), @@ -214,7 +124,7 @@ pub mod tests { ]; seats.clone().into_iter().for_each(|seat| { - assert!(insert_market_seat(&mut seat_list, seat).is_ok()); + assert!(try_insert_market_seat(&mut seat_list, seat).is_ok()); }); let resulting_seat_list: Vec<(SectorIndex, &MarketSeat)> = seat_list diff --git a/program/src/shared/mod.rs b/program/src/shared/mod.rs index 137e9b598..37b312d33 100644 --- a/program/src/shared/mod.rs +++ b/program/src/shared/mod.rs @@ -2,5 +2,7 @@ pub mod account_resize; pub mod market_operations; +pub mod order_operations; +pub mod seat_operations; pub mod seeds; pub mod token_utils; diff --git a/program/src/shared/order_operations.rs b/program/src/shared/order_operations.rs new file mode 100644 index 000000000..aceeac53a --- /dev/null +++ b/program/src/shared/order_operations.rs @@ -0,0 +1,331 @@ +//! Core logic for manipulating and traversing [`Order`]s in the [`OrdersLinkedList`]. + +use dropset_interface::{ + error::DropsetError, + state::{ + linked_list::{ + LinkedList, + LinkedListOperations, + }, + market::MarketRef, + node::Node, + order::{ + Order, + OrdersCollection, + }, + sector::{ + SectorIndex, + NIL, + }, + }, +}; + +/// Insert a new user order into the orders collection. +/// +/// NOTE: this function solely inserts the order into the orders collection. It doesn't update the +/// user's seat nor does it check for duplicate prices posted by the same user. +pub fn insert_order( + list: &mut LinkedList<'_, T>, + order: Order, +) -> Result { + let sector_index = { + let next_index = T::find_new_order_next_index(list, &order); + let order_bytes = order.as_bytes(); + + if next_index == T::head(list.header) { + list.push_front(order_bytes) + } else if next_index == NIL { + list.push_back(order_bytes) + } else { + // Safety: The index used here was returned by the iterator so it must be in-bounds. + unsafe { list.insert_before(next_index, order_bytes) } + } + }?; + + Ok(sector_index) +} + +/// Converts a sector index to an order given a sector index. +/// +/// Caller should ensure that `validated_sector_index` is indeed a sector index pointing to a valid +/// order. +/// +/// # Safety +/// +/// Caller guarantees `validated_sector_index` is in-bounds of `market.sectors` bytes. +pub unsafe fn load_order_from_sector_index( + market: MarketRef<'_>, + validated_sector_index: SectorIndex, +) -> &'_ Order { + // Safety: Caller guarantees 'validated_sector_index' is in-bounds. + let node = unsafe { Node::from_sector_index(market.sectors, validated_sector_index) }; + node.load_payload::() +} + +#[cfg(test)] +mod tests { + extern crate std; + + use std::{ + vec, + vec::*, + }; + + use dropset_interface::state::{ + asks_dll::AskOrders, + bids_dll::BidOrders, + linked_list::{ + LinkedList, + LinkedListOperations, + }, + market::MarketRefMut, + market_header::MarketHeader, + order::{ + Order, + OrdersCollection, + }, + sector::{ + SectorIndex, + NIL, + SECTOR_SIZE, + }, + transmutable::Transmutable, + }; + use pinocchio_pubkey::pubkey; + use price::{ + to_biased_exponent, + to_order_info, + UNBIASED_MAX, + }; + + use crate::shared::{ + market_operations::initialize_market_account_data, + order_operations::insert_order, + }; + + const N_SECTORS: usize = 10; + const MARKET_LEN: usize = MarketHeader::LEN + SECTOR_SIZE * N_SECTORS; + + /// Test utility function to insert an order and expect (unwrap) the result. + pub fn insert_helper( + list: &mut LinkedList<'_, T>, + order: &Order, + ) -> SectorIndex { + insert_order(list, order.clone()).expect("Should insert order") + } + + /// Test utility function to create a simple market with a fixed amount of sectors. + fn create_simple_market(bytes: &mut [u8; MARKET_LEN]) -> MarketRefMut<'_> { + initialize_market_account_data( + bytes, + &pubkey!("11111111111111111111111111111111111111111111"), + &pubkey!("22222222222222222222222222222222222222222222"), + 254, + ) + .expect("Should initialize market data") + } + + /// Test utility function to create orders where the output encoded price is equal to the input + /// input price mantissa. + fn create_test_order(price_mantissa: u32, user_seat: SectorIndex) -> Order { + let order_info = to_order_info( + price_mantissa, + 1, + to_biased_exponent!(UNBIASED_MAX), + to_biased_exponent!(-1), + ) + .expect("The unit test should pass a valid price mantissa"); + + // The biased base and quote exponent consts passed in should ensure that the encoded price + // has no exponent and thus equal the price mantissa exactly. + assert_eq!(order_info.encoded_price.as_u32(), price_mantissa); + + // The user seat passed should emulate a valid sector index. + assert_ne!(user_seat, NIL); + + Order::new(order_info, user_seat) + } + + /// Test utility function to convert asks or bids into a vec of (encoded_price, seat) pairs. + fn to_prices_and_seats( + list: &LinkedList<'_, T>, + ) -> Vec<(u32, u32)> { + list.iter() + .map(|(_, node)| { + let order = node.load_payload::(); + (order.encoded_price(), order.user_seat()) + }) + .collect() + } + + /// Test utility function to convert asks or bids into a vec of encoded prices. + fn to_prices(list: &LinkedList<'_, T>) -> Vec { + list.iter() + .map(|(_, node)| node.load_payload::().encoded_price()) + .collect() + } + + #[test] + fn test_simple_order_infos() { + const ZERO: u32 = 0; + let get_encoded_price_u32 = + |price_mantissa| create_test_order(price_mantissa, ZERO).encoded_price(); + assert_eq!(get_encoded_price_u32(10_000_000), 10_000_000); + assert_eq!(get_encoded_price_u32(10_000_001), 10_000_001); + assert_eq!(get_encoded_price_u32(10_000_002), 10_000_002); + assert_eq!(get_encoded_price_u32(20_000_000), 20_000_000); + assert_eq!(get_encoded_price_u32(99_999_999), 99_999_999); + } + + #[test] + fn test_time_order_precedence() { + // Orders with the same price should be sorted based on earliest inserted. + let bytes = &mut [0u8; MARKET_LEN]; + let mut market = create_simple_market(bytes); + + let (low, mid, high) = (11_111_111, 22_222_222, 33_333_333); + + let orders = [ + create_test_order(mid, 2), + create_test_order(high, 5), + create_test_order(mid, 3), + create_test_order(low, 1), + create_test_order(mid, 4), + ]; + + let asks = &mut market.asks(); + for order in orders.clone() { + insert_helper(asks, &order); + } + + assert_eq!( + to_prices_and_seats(asks), + vec![(low, 1), (mid, 2), (mid, 3), (mid, 4), (high, 5)] + ); + + let bids = &mut market.bids(); + for order in orders { + insert_helper(bids, &order); + } + + assert_eq!( + to_prices_and_seats(bids), + vec![(high, 5), (mid, 2), (mid, 3), (mid, 4), (low, 1)] + ); + } + + #[test] + fn test_price_order_precedence() { + let bytes = &mut [0u8; MARKET_LEN]; + let mut market = create_simple_market(bytes); + + let [order_1, order_2, order_3] = [ + create_test_order(10_000_000, 1), + create_test_order(20_000_000, 2), + // A user can have multiple orders, so use user_seat 1 again to ensure the user seat is + // not factored into the sorting implementation. + create_test_order(30_000_000, 1), + ]; + + let asks = &mut market.asks(); + // Insert out of order (in terms of price) as (2, 1, 3). + insert_helper(asks, &order_2); + insert_helper(asks, &order_1); + insert_helper(asks, &order_3); + + // Asks should have lowest prices first, so they should now be: (1, 2, 3). + let expected_ask_prices_and_seats = vec![(10_000_000, 1), (20_000_000, 2), (30_000_000, 1)]; + assert_eq!(to_prices_and_seats(asks), expected_ask_prices_and_seats); + + let bids = &mut market.bids(); + // Insert out of order (in terms of price) as (2, 1, 3). + insert_helper(bids, &order_2); + insert_helper(bids, &order_1); + insert_helper(bids, &order_3); + + // Bids should have highest prices first, so they should now be: (3, 2, 1). + let expected_bid_prices_and_seats = vec![(30_000_000, 1), (20_000_000, 2), (10_000_000, 1)]; + assert_eq!(to_prices_and_seats(bids), expected_bid_prices_and_seats); + } + + #[test] + fn test_insert_head_mid_and_tail_asks() { + let bytes = &mut [0u8; MARKET_LEN]; + let mut market = create_simple_market(bytes); + + let [order_10, order_20, order_30, order_40] = [ + create_test_order(10_000_000, 1), + create_test_order(20_000_000, 2), + create_test_order(30_000_000, 3), + create_test_order(40_000_000, 4), + ]; + + let asks = &mut market.asks(); + + // First order should be the head and tail: [20] + // ^^ + assert_eq!(insert_helper(asks, &order_20), AskOrders::head(asks.header)); + assert_eq!(AskOrders::head(asks.header), AskOrders::tail(asks.header)); + assert_eq!(to_prices(asks), [20_000_000]); + + // Second order should be the head. [10, 20] + // ^^ + assert_eq!(insert_helper(asks, &order_10), AskOrders::head(asks.header)); + assert_eq!(to_prices(asks), [10_000_000, 20_000_000]); + + // Third order should be the tail. [10, 20, 40] + // ^^ + assert_eq!(insert_helper(asks, &order_40), AskOrders::tail(asks.header)); + assert_eq!(to_prices(asks), [10_000_000, 20_000_000, 40_000_000]); + + // Fourth order should be neither head nor tail: [10, 20, 30, 40] + // ^^ + let neither_head_nor_tail = insert_helper(asks, &order_30); + assert_ne!(AskOrders::head(asks.header), neither_head_nor_tail); + assert_ne!(AskOrders::tail(asks.header), neither_head_nor_tail); + assert_eq!( + to_prices(asks), + [10_000_000, 20_000_000, 30_000_000, 40_000_000] + ); + } + #[test] + fn test_insert_head_mid_and_tail_bids() { + let bytes = &mut [0u8; MARKET_LEN]; + let mut market = create_simple_market(bytes); + + let [order_10, order_20, order_30, order_40] = [ + create_test_order(10_000_000, 1), + create_test_order(20_000_000, 2), + create_test_order(30_000_000, 3), + create_test_order(40_000_000, 4), + ]; + + let bids = &mut market.bids(); + + // First order should be the head and tail: [20] + // ^^ + assert_eq!(insert_helper(bids, &order_20), BidOrders::head(bids.header)); + assert_eq!(BidOrders::head(bids.header), BidOrders::tail(bids.header)); + assert_eq!(to_prices(bids), [20_000_000]); + + // Second order should be the head. [40, 20] + // ^^ + assert_eq!(insert_helper(bids, &order_40), BidOrders::head(bids.header)); + assert_eq!(to_prices(bids), [40_000_000, 20_000_000]); + + // Third order should be the tail. [40, 20, 10] + // ^^ + assert_eq!(insert_helper(bids, &order_10), BidOrders::tail(bids.header)); + assert_eq!(to_prices(bids), [40_000_000, 20_000_000, 10_000_000]); + + // Fourth order should be neither head nor tail: [40, 30, 20, 10] + // ^^ + let neither_head_nor_tail = insert_helper(bids, &order_30); + assert_ne!(BidOrders::head(bids.header), neither_head_nor_tail); + assert_ne!(BidOrders::tail(bids.header), neither_head_nor_tail); + assert_eq!( + to_prices(bids), + [40_000_000, 30_000_000, 20_000_000, 10_000_000] + ); + } +} diff --git a/program/src/shared/seat_operations.rs b/program/src/shared/seat_operations.rs new file mode 100644 index 000000000..647651a2b --- /dev/null +++ b/program/src/shared/seat_operations.rs @@ -0,0 +1,115 @@ +//! Core logic for manipulating and traversing [`MarketSeat`]s. + +use dropset_interface::{ + error::DropsetError, + state::{ + market::{ + MarketRef, + MarketRefMut, + }, + market_seat::MarketSeat, + node::Node, + seats_dll::SeatsLinkedList, + sector::{ + SectorIndex, + NIL, + }, + }, +}; +use pinocchio::pubkey::{ + pubkey_eq, + Pubkey, +}; + +pub fn try_insert_market_seat( + list: &mut SeatsLinkedList, + seat: MarketSeat, +) -> Result { + let (prev_index, next_index) = find_new_seat_prev_and_next(list, &seat.user); + let seat_bytes = seat.as_bytes(); + + // Return an error early if the user already exists in the seat list at the previous index. + if prev_index != NIL { + // Safety: `prev_index` is non-NIL and was returned by an iterator, so it must be in-bounds. + let prev_node = unsafe { Node::from_sector_index(list.sectors, prev_index) }; + let prev_seat = prev_node.load_payload::(); + if pubkey_eq(&seat.user, &prev_seat.user) { + return Err(DropsetError::UserAlreadyExists); + } + } + + if next_index == list.header.seats_dll_head() { + list.push_front(seat_bytes) + } else if next_index == NIL { + list.push_back(seat_bytes) + } else { + // Safety: The index used here was returned by the iterator so it must be in-bounds. + unsafe { list.insert_before(next_index, seat_bytes) } + } +} + +/// This function returns the new prev and next indices for the new node. Thus the list would be +/// updated from this: +/// +/// prev => next +/// +/// To this: +/// +/// prev => new => next +/// +/// where this function returns `(prev, next)` as sector indices. +#[inline(always)] +fn find_new_seat_prev_and_next( + list: &SeatsLinkedList, + user: &Pubkey, +) -> (SectorIndex, SectorIndex) { + for (index, node) in list.iter() { + let seat = node.load_payload::(); + if user < &seat.user { + return (node.prev(), index); + } + } + // If the node is to be inserted at the end of the list, the new `prev` is the current tail + // and the new `next` is `NIL`, since the new node is the new tail. + (list.header.seats_dll_tail(), NIL) +} + +/// Tries to find a market seat given an index hint. +/// +/// # Safety +/// +/// Caller guarantees `hint` is in-bounds of `market.sectors` bytes. +pub unsafe fn find_seat_with_hint<'a>( + market: MarketRef<'a>, + hint: SectorIndex, + user: &Pubkey, +) -> Result<&'a MarketSeat, DropsetError> { + // Safety: Caller guarantees `hint` is in-bounds. + let node = unsafe { Node::from_sector_index(market.sectors, hint) }; + let seat = node.load_payload::(); + if pubkey_eq(user, &seat.user) { + Ok(seat) + } else { + Err(DropsetError::InvalidIndexHint) + } +} + +/// Tries to find a mutable market seat given an index hint. +/// +/// # Safety +/// +/// Caller guarantees `hint` is in-bounds of `market.sectors` bytes. +pub unsafe fn find_mut_seat_with_hint<'a>( + market: MarketRefMut<'a>, + hint: SectorIndex, + user: &Pubkey, +) -> Result<&'a mut MarketSeat, DropsetError> { + // Safety: Caller guarantees `hint` is in-bounds. + let node = unsafe { Node::from_sector_index_mut(market.sectors, hint) }; + let seat = node.load_payload_mut::(); + if pubkey_eq(user, &seat.user) { + Ok(seat) + } else { + Err(DropsetError::InvalidIndexHint) + } +} diff --git a/transaction-parser/Cargo.toml b/transaction-parser/Cargo.toml index 686330932..ac9f7341f 100644 --- a/transaction-parser/Cargo.toml +++ b/transaction-parser/Cargo.toml @@ -10,6 +10,7 @@ dropset = { path = "../program" } dropset-interface = { path = "../interface", features = ["client", "solana-program"], default-features = false } itertools.workspace = true lazy-regex.workspace = true +price = { path = "../price" } solana-sdk.workspace = true solana-transaction-status.workspace = true solana-transaction-status-client-types.workspace = true diff --git a/transaction-parser/src/events/dropset_event.rs b/transaction-parser/src/events/dropset_event.rs index f74914f23..b343cdddc 100644 --- a/transaction-parser/src/events/dropset_event.rs +++ b/transaction-parser/src/events/dropset_event.rs @@ -2,10 +2,12 @@ //! events or contiguous instruction data. use dropset_interface::events::{ + CancelOrderEventInstructionData, CloseSeatEventInstructionData, DepositEventInstructionData, DropsetEventTag, HeaderEventInstructionData, + PostOrderEventInstructionData, RegisterMarketEventInstructionData, WithdrawEventInstructionData, }; @@ -19,6 +21,8 @@ pub enum DropsetEvent { Withdraw(WithdrawEventInstructionData), RegisterMarket(display_types::DisplayRegisterMarketData), CloseSeat(CloseSeatEventInstructionData), + PostOrder(PostOrderEventInstructionData), + CancelOrder(CancelOrderEventInstructionData), } impl DropsetEvent { @@ -29,6 +33,8 @@ impl DropsetEvent { Self::Withdraw(_) => WithdrawEventInstructionData::LEN_WITH_TAG, Self::RegisterMarket(_) => RegisterMarketEventInstructionData::LEN_WITH_TAG, Self::CloseSeat(_) => CloseSeatEventInstructionData::LEN_WITH_TAG, + Self::PostOrder(_) => PostOrderEventInstructionData::LEN_WITH_TAG, + Self::CancelOrder(_) => CancelOrderEventInstructionData::LEN_WITH_TAG, } } } @@ -103,6 +109,12 @@ impl DropsetEvent { DropsetEventTag::CloseSeatEvent => Ok(DropsetEvent::CloseSeat( CloseSeatEventInstructionData::unpack_client(data).map_err(|_| err())?, )), + DropsetEventTag::PostOrderEvent => Ok(DropsetEvent::PostOrder( + PostOrderEventInstructionData::unpack_client(data).map_err(|_| err())?, + )), + DropsetEventTag::CancelOrderEvent => Ok(DropsetEvent::CancelOrder( + CancelOrderEventInstructionData::unpack_client(data).map_err(|_| err())?, + )), } } } diff --git a/transaction-parser/src/views.rs b/transaction-parser/src/views.rs index d6b22244b..fe00095ae 100644 --- a/transaction-parser/src/views.rs +++ b/transaction-parser/src/views.rs @@ -6,8 +6,10 @@ use dropset_interface::state::{ market_header::MarketHeader, market_seat::MarketSeat, node::Node, + order::Order, sector::SectorIndex, transmutable::Transmutable, + user_order_sectors::UserOrderSectors, }; use solana_sdk::pubkey::Pubkey; @@ -15,10 +17,16 @@ use solana_sdk::pubkey::Pubkey; pub struct MarketHeaderView { pub discriminant: u64, pub num_seats: u32, + pub num_bids: u32, + pub num_asks: u32, pub num_free_sectors: u32, pub free_stack_top: SectorIndex, pub seats_dll_head: SectorIndex, pub seats_dll_tail: SectorIndex, + pub bids_dll_head: SectorIndex, + pub bids_dll_tail: SectorIndex, + pub asks_dll_head: SectorIndex, + pub asks_dll_tail: SectorIndex, pub base_mint: Pubkey, pub quote_mint: Pubkey, pub market_bump: u8, @@ -38,6 +46,8 @@ pub struct MarketView { pub struct MarketViewAll { pub header: MarketHeaderView, pub seats: Vec, + pub bids: Vec, + pub asks: Vec, } /// Attempts to parse a Dropset market account from raw Solana account fields and convert it into a @@ -78,6 +88,18 @@ pub struct MarketSeatView { pub user: Pubkey, pub base_available: u64, pub quote_available: u64, + pub user_order_sectors: UserOrderSectors, +} + +#[derive(Debug)] +pub struct OrderView { + pub prev_index: SectorIndex, + pub index: SectorIndex, + pub next_index: SectorIndex, + pub encoded_price: u32, + pub user_seat: SectorIndex, + pub base_remaining: u64, + pub quote_remaining: u64, } impl From<(SectorIndex, &Node)> for MarketSeatView { @@ -91,6 +113,23 @@ impl From<(SectorIndex, &Node)> for MarketSeatView { user: seat.user.into(), base_available: seat.base_available(), quote_available: seat.quote_available(), + user_order_sectors: seat.user_order_sectors.clone(), + } + } +} + +impl From<(SectorIndex, &Node)> for OrderView { + fn from(index_and_order: (SectorIndex, &Node)) -> Self { + let (sector_index, node) = index_and_order; + let order = node.load_payload::(); + Self { + prev_index: node.prev(), + index: sector_index, + next_index: node.next(), + encoded_price: order.encoded_price(), + user_seat: order.user_seat(), + base_remaining: order.base_remaining(), + quote_remaining: order.quote_remaining(), } } } @@ -100,10 +139,16 @@ impl From<&MarketHeader> for MarketHeaderView { Self { discriminant: header.discriminant(), num_seats: header.num_seats(), + num_bids: header.num_bids(), + num_asks: header.num_asks(), num_free_sectors: header.num_free_sectors(), free_stack_top: header.free_stack_top(), seats_dll_head: header.seats_dll_head(), seats_dll_tail: header.seats_dll_tail(), + bids_dll_head: header.bids_dll_head(), + bids_dll_tail: header.bids_dll_tail(), + asks_dll_head: header.asks_dll_head(), + asks_dll_tail: header.asks_dll_tail(), base_mint: header.base_mint.into(), quote_mint: header.quote_mint.into(), market_bump: header.market_bump, @@ -113,20 +158,13 @@ impl From<&MarketHeader> for MarketHeaderView { } } -impl From> for MarketView { - fn from(market: MarketRef) -> Self { - Self { - header: market.header.into(), - sectors: market.iter_seats().map(MarketSeatView::from).collect(), - } - } -} - impl From> for MarketViewAll { fn from(market: MarketRef<'_>) -> Self { Self { header: market.header.into(), seats: market.iter_seats().map(MarketSeatView::from).collect(), + bids: market.iter_bids().map(OrderView::from).collect(), + asks: market.iter_asks().map(OrderView::from).collect(), } } }