diff --git a/programs/manifest/src/program/error.rs b/programs/manifest/src/program/error.rs index c95943ace..b584fd9d5 100644 --- a/programs/manifest/src/program/error.rs +++ b/programs/manifest/src/program/error.rs @@ -62,7 +62,10 @@ macro_rules! require { if $test { Ok(()) } else { + #[cfg(target_os = "solana")] solana_program::msg!("[{}:{}] {}", std::file!(), std::line!(), std::format_args!($($arg)*)); + #[cfg(not(target_os = "solana"))] + std::println!("[{}:{}] {}", std::file!(), std::line!(), std::format_args!($($arg)*)); Err(($err)) } }; diff --git a/programs/manifest/src/quantities.rs b/programs/manifest/src/quantities.rs index b297296ba..8c981c2f0 100644 --- a/programs/manifest/src/quantities.rs +++ b/programs/manifest/src/quantities.rs @@ -1,8 +1,9 @@ use crate::program::ManifestError; use borsh::{BorshDeserialize as Deserialize, BorshSerialize as Serialize}; use bytemuck::{Pod, Zeroable}; +use hypertree::trace; use shank::ShankAccount; -use solana_program::{msg, program_error::ProgramError}; +use solana_program::program_error::ProgramError; use static_assertions::{const_assert, const_assert_eq}; use std::{ cmp::Ordering, @@ -318,16 +319,12 @@ impl QuoteAtomsPerBaseAtom { mantissa: u32, exponent: i8, ) -> Result { - if mantissa == 0 { - msg!("price can not be zero"); - return Err(PriceConversionError(0x0)); - } if exponent > Self::MAX_EXP { - msg!("invalid exponent {exponent} > 8 would truncate",); + trace!("invalid exponent {exponent} > 8 would truncate",); return Err(PriceConversionError(0x1)); } if exponent < Self::MIN_EXP { - msg!("invalid exponent {exponent} < -18 would truncate",); + trace!("invalid exponent {exponent} < -18 would truncate",); return Err(PriceConversionError(0x2)); } Ok(Self::from_mantissa_and_exponent_(mantissa, exponent)) @@ -349,6 +346,11 @@ impl QuoteAtomsPerBaseAtom { // this doesn't need a check, will never overflow: u64::MAX * D18 < u128::MAX let dividend = D18.wrapping_mul(quote_atoms.inner as u128); let inner: u128 = u64_slice_to_u128(self.inner); + trace!( + "checked_base_for_quote {dividend}/{inner} {round_up} {}>{}", + dividend.div_ceil(inner), + dividend.div(inner) + ); let base_atoms = if round_up { dividend.div_ceil(inner) } else { @@ -464,30 +466,53 @@ impl From for ProgramError { } } +#[inline(always)] +fn encode_mantissa_and_exponent(value: f64) -> (u32, i8) { + let mut exponent: i8 = 0; + // prevent overflow when casting to u32 + while exponent < QuoteAtomsPerBaseAtom::MAX_EXP + && calculate_mantissa(value, exponent) > u32::MAX as f64 + { + exponent += 1; + } + // prevent underflow and maximize precision available + while exponent > QuoteAtomsPerBaseAtom::MIN_EXP + && calculate_mantissa(value, exponent) < (u32::MAX / 10) as f64 + { + exponent -= 1; + } + (calculate_mantissa(value, exponent) as u32, exponent) +} + +#[inline(always)] +fn calculate_mantissa(value: f64, exp: i8) -> f64 { + (value * 10f64.powi(-exp as i32)).round() +} + impl TryFrom for QuoteAtomsPerBaseAtom { type Error = PriceConversionError; fn try_from(value: f64) -> Result { - let mantissa = value * D18F; - if mantissa.is_infinite() { - msg!("infinite can not be expressed as fixed point decimal"); + if value.is_infinite() { + trace!("infinite can not be expressed as fixed point decimal"); return Err(PriceConversionError(0xC)); } - if mantissa.is_nan() { - msg!("nan can not be expressed as fixed point decimal"); + if value.is_nan() { + trace!("nan can not be expressed as fixed point decimal"); return Err(PriceConversionError(0xD)); } - if mantissa > u128::MAX as f64 { - msg!("price is too large"); + if value.is_sign_negative() { + trace!("price {value} can not be negative"); return Err(PriceConversionError(0xE)); } - if mantissa.is_sign_negative() { - msg!("price can not be negative"); + if calculate_mantissa(value, Self::MAX_EXP) > u32::MAX as f64 { + trace!("price {value} is too large"); return Err(PriceConversionError(0xF)); } - Ok(QuoteAtomsPerBaseAtom { - inner: u128_to_u64_slice(mantissa.round() as u128), - }) + + let (mantissa, exponent) = encode_mantissa_and_exponent(value); + + Self::try_from_mantissa_and_exponent(mantissa, exponent) } } @@ -594,10 +619,12 @@ fn test_price_limits() { ) .is_ok()); assert!(QuoteAtomsPerBaseAtom::try_from(0f64).is_ok()); - assert!(QuoteAtomsPerBaseAtom::try_from(u64::MAX as f64).is_ok()); + assert!(QuoteAtomsPerBaseAtom::try_from( + u32::MAX as f64 * 10f64.powi(QuoteAtomsPerBaseAtom::MAX_EXP as i32) + ) + .is_ok()); // failures - assert!(QuoteAtomsPerBaseAtom::try_from_mantissa_and_exponent(0, 0).is_err()); assert!(QuoteAtomsPerBaseAtom::try_from_mantissa_and_exponent( 1, QuoteAtomsPerBaseAtom::MAX_EXP + 1 diff --git a/programs/manifest/src/state/global.rs b/programs/manifest/src/state/global.rs index 82ab36c73..b9d745bb9 100644 --- a/programs/manifest/src/state/global.rs +++ b/programs/manifest/src/state/global.rs @@ -492,7 +492,7 @@ impl, Dynamic: DerefOrBorrowMut<[u8]>> GlobalAtoms::new( resting_order .get_num_base_atoms() - .checked_mul(resting_order.get_price(), false) + .checked_mul(resting_order.get_price(), true) .unwrap() .as_u64(), ) diff --git a/programs/manifest/src/state/market.rs b/programs/manifest/src/state/market.rs index 6b5c46263..2acd22ae1 100644 --- a/programs/manifest/src/state/market.rs +++ b/programs/manifest/src/state/market.rs @@ -282,6 +282,7 @@ impl, Dynamic: DerefOrBorrow<[u8]>> fixed.get_quote_mint() } + // TODO: adapt to new rounding pub fn impact_quote_atoms( &self, is_bid: bool, @@ -326,6 +327,7 @@ impl, Dynamic: DerefOrBorrow<[u8]>> return Ok(total_quote_atoms_matched); } + // TODO: adapt to new rounding pub fn impact_base_atoms( &self, is_bid: bool, @@ -341,7 +343,7 @@ impl, Dynamic: DerefOrBorrow<[u8]>> self.get_bids() }; - let mut total_base_atoms_matched: BaseAtoms = BaseAtoms::ZERO; + let mut total_matched_base_atoms: BaseAtoms = BaseAtoms::ZERO; let mut remaining_quote_atoms: QuoteAtoms = limit_quote_atoms; for (_, other_order) in book.iter::() { if other_order.is_expired(now_slot) { @@ -350,11 +352,19 @@ impl, Dynamic: DerefOrBorrow<[u8]>> let matched_price: QuoteAtomsPerBaseAtom = other_order.get_price(); // caller signal can ensure quote is a lower or upper bound by rounding of base amount - let base_atoms_limit = + let price_limited_base_atoms = matched_price.checked_base_for_quote(remaining_quote_atoms, round_up)?; - let matched_base_atoms = other_order.get_num_base_atoms().min(base_atoms_limit); - let matched_quote_atoms = - matched_price.checked_quote_for_base(matched_base_atoms, is_bid)?; + let did_fully_match_resting_order = + price_limited_base_atoms >= other_order.get_num_base_atoms(); + let matched_base_atoms = if did_fully_match_resting_order { + other_order.get_num_base_atoms() + } else { + price_limited_base_atoms + }; + let matched_quote_atoms = matched_price.checked_quote_for_base( + matched_base_atoms, + is_bid != did_fully_match_resting_order, + )?; // TODO: Clean this up into a separate function. if other_order.get_order_type() == OrderType::Global { @@ -382,9 +392,9 @@ impl, Dynamic: DerefOrBorrow<[u8]>> } } - total_base_atoms_matched = total_base_atoms_matched.checked_add(matched_base_atoms)?; + total_matched_base_atoms = total_matched_base_atoms.checked_add(matched_base_atoms)?; - if matched_base_atoms == base_atoms_limit { + if matched_base_atoms == price_limited_base_atoms { break; } @@ -394,7 +404,7 @@ impl, Dynamic: DerefOrBorrow<[u8]>> } } - return Ok(total_base_atoms_matched); + return Ok(total_matched_base_atoms); } pub fn get_order_by_index(&self, index: DataIndex) -> &RestingOrder { @@ -617,6 +627,10 @@ impl, Dynamic: DerefOrBorrowMut<[u8]>> // Got a match. First make sure we are allowed to match. We check // inside the matching rather than skipping the matching altogether // because post only orders should fail, not produce a crossed book. + trace!( + "match {} {order_type:?} {price:?} with {other_order:?}", + if is_bid { "bid" } else { "ask" } + ); assert_can_take(order_type)?; let maker_sequence_number = other_order.get_sequence_number(); @@ -631,10 +645,12 @@ impl, Dynamic: DerefOrBorrowMut<[u8]>> let matched_price: QuoteAtomsPerBaseAtom = other_order.get_price(); - // Round in favor of the maker. There is a later check that this - // rounding still results in a price that is fine for the taker. - let quote_atoms_traded: QuoteAtoms = - matched_price.checked_quote_for_base(base_atoms_traded, is_bid)?; + // on full fill: round in favor of the taker + // on partial fill: round in favor of the maker + let quote_atoms_traded: QuoteAtoms = matched_price.checked_quote_for_base( + base_atoms_traded, + is_bid != did_fully_match_resting_order, + )?; // If it is a global order, just in time bring the funds over, or // remove from the tree and continue on to the next order. @@ -699,14 +715,14 @@ impl, Dynamic: DerefOrBorrowMut<[u8]>> // These are only used when is_bid, included up here for borrow checker reasons. let other_order: &RestingOrder = get_helper::>(dynamic, current_order_index).get_value(); - let previous_maker_quote_atoms_allocated: QuoteAtoms = matched_price - .checked_quote_for_base(other_order.get_num_base_atoms(), false)?; + let previous_maker_quote_atoms_allocated: QuoteAtoms = + matched_price.checked_quote_for_base(other_order.get_num_base_atoms(), true)?; let new_maker_quote_atoms_allocated: QuoteAtoms = matched_price .checked_quote_for_base( other_order .get_num_base_atoms() .checked_sub(base_atoms_traded)?, - false, + true, )?; update_balance( dynamic, @@ -793,23 +809,10 @@ impl, Dynamic: DerefOrBorrowMut<[u8]>> remaining_base_atoms = remaining_base_atoms.checked_sub(base_atoms_traded)?; current_order_index = next_order_index; } else { - let mut cloned_other_order: RestingOrder = - get_helper::>(dynamic, current_order_index) - .get_value() - .clone(); - cloned_other_order.reduce(base_atoms_traded)?; - // Remove and reinsert the other order. Their effective price - // and thus their sorting in the orderbook may have changed. - remove_order_from_tree(fixed, dynamic, current_order_index, !is_bid)?; - insert_order_into_tree( - !is_bid, - fixed, - dynamic, - current_order_index, - &cloned_other_order, - ); - get_mut_helper::>(dynamic, current_order_index) - .set_payload_type(MarketDataTreeNodeType::RestingOrder as u8); + let other_order: &mut RestingOrder = + get_mut_helper::>(dynamic, current_order_index) + .get_mut_value(); + other_order.reduce(base_atoms_traded)?; remaining_base_atoms = BaseAtoms::ZERO; break; } @@ -888,15 +891,15 @@ impl, Dynamic: DerefOrBorrowMut<[u8]>> )?; try_to_add_to_global(&global_trade_accounts_opt.as_ref().unwrap(), &resting_order)?; } else { - // Place the remaining. This rounds down quote atoms because it is a best - // case for the maker. + // Place the remaining. + // Rounds up quote atoms so price can be rounded in favor of taker update_balance( dynamic, trader_index, !is_bid, false, if is_bid { - (remaining_base_atoms.checked_mul(price, false)) + (remaining_base_atoms.checked_mul(price, true)) .unwrap() .into() } else { @@ -1130,7 +1133,7 @@ fn insert_order_into_tree( tree.insert(free_address, *resting_order); if is_bid { trace!( - "insert order bid root:{}->{} max:{}->{}->{}", + "insert order bid {resting_order:?} root:{}->{} max:{}->{}->{}", fixed.bids_root_index, tree.get_root_index(), fixed.bids_best_index, @@ -1141,7 +1144,7 @@ fn insert_order_into_tree( fixed.bids_best_index = tree.get_max_index(); } else { trace!( - "insert order ask root:{}->{} max:{}->{}->{}", + "insert order ask {resting_order:?} root:{}->{} max:{}->{}->{}", fixed.asks_root_index, tree.get_root_index(), fixed.asks_best_index, @@ -1192,19 +1195,6 @@ fn remove_and_update_balances( get_helper::>(dynamic, order_to_remove_index).get_value(); let other_is_bid: bool = other_order.get_is_bid(); - // Return the exact number of atoms if the resting order is an - // ask. If the resting order is bid, multiply by price and round - // in favor of the maker which here means down. The maker places - // the minimum number of atoms required. - let amount_atoms_to_return: u64 = if other_is_bid { - other_order - .get_price() - .checked_quote_for_base(other_order.get_num_base_atoms(), false)? - .as_u64() - } else { - other_order.get_num_base_atoms().as_u64() - }; - // Global order balances are accounted for on the global accounts, not on the market. if other_order.is_global() { let global_trade_accounts_opt: &Option = if other_is_bid { @@ -1218,6 +1208,18 @@ fn remove_and_update_balances( .trader; remove_from_global(&global_trade_accounts_opt, maker)?; } else { + // Return the exact number of atoms if the resting order is an + // ask. If the resting order is bid, multiply by price and round + // in favor of the taker which here means up. The maker places + // the minimum number of atoms required. + let amount_atoms_to_return: u64 = if other_is_bid { + other_order + .get_price() + .checked_quote_for_base(other_order.get_num_base_atoms(), true)? + .as_u64() + } else { + other_order.get_num_base_atoms().as_u64() + }; update_balance( dynamic, other_order.get_trader_index(), diff --git a/programs/manifest/src/state/resting_order.rs b/programs/manifest/src/state/resting_order.rs index dac9f0147..2464714b2 100644 --- a/programs/manifest/src/state/resting_order.rs +++ b/programs/manifest/src/state/resting_order.rs @@ -57,27 +57,23 @@ pub fn order_type_can_take(order_type: OrderType) -> bool { #[derive(Default, Debug, Copy, Clone, Zeroable, Pod)] pub struct RestingOrder { price: QuoteAtomsPerBaseAtom, - // Sort key is the worst effective price someone could get by - // trading with me due to the rounding being in my favor as a maker. - effective_price: QuoteAtomsPerBaseAtom, num_base_atoms: BaseAtoms, sequence_number: u64, trader_index: DataIndex, last_valid_slot: u32, is_bid: PodBool, order_type: OrderType, - _padding: [u8; 6], + _padding: [u8; 22], } // 16 + // price -// 16 + // effective_price // 8 + // num_base_atoms // 8 + // sequence_number // 4 + // trader_index // 4 + // last_valid_slot // 1 + // is_bid // 1 + // order_type -// 6 // padding +// 22 // padding // = 64 const_assert_eq!(size_of::(), RESTING_ORDER_SIZE); const_assert_eq!(size_of::() % 8, 0); @@ -97,11 +93,10 @@ impl RestingOrder { num_base_atoms, last_valid_slot, price, - effective_price: price.checked_effective_price(num_base_atoms, is_bid)?, sequence_number, is_bid: PodBool::from_bool(is_bid), order_type, - _padding: [0; 6], + _padding: Default::default(), }) } @@ -148,9 +143,6 @@ impl RestingOrder { pub fn reduce(&mut self, size: BaseAtoms) -> ProgramResult { self.num_base_atoms = self.num_base_atoms.checked_sub(size)?; - self.effective_price = self - .price - .checked_effective_price(self.num_base_atoms, self.get_is_bid())?; Ok(()) } } @@ -162,9 +154,9 @@ impl Ord for RestingOrder { debug_assert!(self.get_is_bid() == other.get_is_bid()); if self.get_is_bid() { - (self.effective_price).cmp(&(other.effective_price)) + (self.price).cmp(&other.price) } else { - (other.effective_price).cmp(&(self.effective_price)) + (other.price).cmp(&(self.price)) } } } @@ -185,11 +177,7 @@ impl Eq for RestingOrder {} impl std::fmt::Display for RestingOrder { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!( - f, - "{}@{}|E:{})", - self.num_base_atoms, self.price, self.effective_price - ) + write!(f, "{}@{}", self.num_base_atoms, self.price) } } @@ -223,25 +211,24 @@ mod test { let resting_order_1: RestingOrder = RestingOrder::new( 0, BaseAtoms::new(1), - QuoteAtomsPerBaseAtom::try_from(1.0).unwrap(), + QuoteAtomsPerBaseAtom::try_from(1.00000000000001).unwrap(), 0, NO_EXPIRATION_LAST_VALID_SLOT, - false, + true, OrderType::Limit, ) .unwrap(); - // This is better because the effective price for the other is 2. let resting_order_2: RestingOrder = RestingOrder::new( 0, BaseAtoms::new(1_000_000_000), QuoteAtomsPerBaseAtom::try_from(1.01).unwrap(), 0, NO_EXPIRATION_LAST_VALID_SLOT, - false, + true, OrderType::Limit, ) .unwrap(); - assert!(resting_order_1 > resting_order_2); + assert!(resting_order_1 < resting_order_2); assert!(resting_order_1 != resting_order_2); let resting_order_1: RestingOrder = RestingOrder::new( @@ -254,7 +241,6 @@ mod test { OrderType::Limit, ) .unwrap(); - // This is better because the effective price for the other is 2. let resting_order_2: RestingOrder = RestingOrder::new( 0, BaseAtoms::new(1_000_000_000), @@ -265,7 +251,7 @@ mod test { OrderType::Limit, ) .unwrap(); - assert!(resting_order_1 < resting_order_2); + assert!(resting_order_1 > resting_order_2); assert!(resting_order_1 != resting_order_2); } diff --git a/programs/manifest/tests/cases/matching.rs b/programs/manifest/tests/cases/matching.rs new file mode 100644 index 000000000..018719170 --- /dev/null +++ b/programs/manifest/tests/cases/matching.rs @@ -0,0 +1,233 @@ +use manifest::{ + program::batch_update::{CancelOrderParams, PlaceOrderParams}, + state::{OrderType, NO_EXPIRATION_LAST_VALID_SLOT}, +}; +use solana_sdk::signer::Signer; + +use crate::{TestFixture, SOL_UNIT_SIZE, USDC_UNIT_SIZE}; + +async fn scenario( + fixture: &mut TestFixture, + maker_is_bid: bool, + price_mantissa: u32, + price_exponent: i8, + place_atoms: u64, + match_atoms: u64, +) -> anyhow::Result<()> { + fixture + .batch_update_for_keypair( + None, + vec![], + vec![PlaceOrderParams::new( + place_atoms, + price_mantissa, + price_exponent, + maker_is_bid, + OrderType::Limit, + NO_EXPIRATION_LAST_VALID_SLOT, + )], + &fixture.payer_keypair(), + ) + .await?; + + fixture + .batch_update_for_keypair( + None, + vec![], + vec![PlaceOrderParams::new( + match_atoms, + price_mantissa, + price_exponent, + !maker_is_bid, + OrderType::Limit, + NO_EXPIRATION_LAST_VALID_SLOT, + )], + &fixture.second_keypair.insecure_clone(), + ) + .await?; + + // Seat is first, then the first order + fixture + .batch_update_for_keypair( + None, + vec![CancelOrderParams::new(0)], + vec![], + &fixture.payer_keypair(), + ) + .await?; + + Ok(()) +} + +async fn verify_balances( + test_fixture: &mut TestFixture, + maker_base_atoms: u64, + maker_quote_atoms: u64, + taker_base_atoms: u64, + taker_quote_atoms: u64, +) -> anyhow::Result<()> { + assert_eq!( + test_fixture + .market_fixture + .get_base_balance_atoms(&test_fixture.payer()) + .await, + maker_base_atoms + ); + assert_eq!( + test_fixture + .market_fixture + .get_quote_balance_atoms(&test_fixture.payer()) + .await, + maker_quote_atoms + ); + + assert_eq!( + test_fixture + .market_fixture + .get_base_balance_atoms(&test_fixture.second_keypair.pubkey()) + .await, + taker_base_atoms + ); + + assert_eq!( + test_fixture + .market_fixture + .get_quote_balance_atoms(&test_fixture.second_keypair.pubkey()) + .await, + taker_quote_atoms + ); + + Ok(()) +} + +/* +Scenarios: +F - Taker fully matches maker order +P - Taker partially matches maker order (remainder is cancelled) +E - Order is exact quote = price * base_atoms +R - Order is rounded quote = round(price * base) + +Maker | Taker | Case / Comment +E | EF | test_match_full_no_rounding +E | EP | test_match_partial_no_rounding +E | RF | impossible - not tested +E | RP | test_match_partial_exact_place_round_match +R | EF | impossible - not tested +R | EP | test_match_partial_round_place_exact_match +R | RF | test_match_full_round_place_round_match +R | RP | test_match_partial_round_place_round_match +*/ + +#[tokio::test] +async fn test_match_full_no_rounding() -> anyhow::Result<()> { + let mut test_fixture: TestFixture = TestFixture::try_new_for_matching_test().await?; + + let err = scenario( + &mut test_fixture, + false, + 1, + -3, + 1_000 * SOL_UNIT_SIZE, + 1_000 * SOL_UNIT_SIZE, + ) + .await; + assert!(err.is_err(), "expect cancel to fail due to full match"); + + verify_balances( + &mut test_fixture, + 0, + 11_000 * USDC_UNIT_SIZE, + 2_000 * SOL_UNIT_SIZE, + 9_000 * USDC_UNIT_SIZE, + ) + .await +} + +#[tokio::test] +async fn test_match_partial_no_rounding() -> anyhow::Result<()> { + let mut test_fixture: TestFixture = TestFixture::try_new_for_matching_test().await?; + + let _ = scenario( + &mut test_fixture, + false, + 1, + -3, + 1_000 * SOL_UNIT_SIZE, + 500 * SOL_UNIT_SIZE, + ) + .await; + + verify_balances( + &mut test_fixture, + 500 * SOL_UNIT_SIZE, + 10_500 * USDC_UNIT_SIZE, + 1_500 * SOL_UNIT_SIZE, + 9_500 * USDC_UNIT_SIZE, + ) + .await +} + +#[tokio::test] +async fn test_match_partial_exact_place_round_match() -> anyhow::Result<()> { + let mut test_fixture: TestFixture = TestFixture::try_new_for_matching_test().await?; + + let _ = scenario(&mut test_fixture, false, 1, -3, 1000, 1).await; + + verify_balances( + &mut test_fixture, + 1000 * SOL_UNIT_SIZE - 1, + 10000 * USDC_UNIT_SIZE + 1, + 1000 * SOL_UNIT_SIZE + 1, + 10000 * USDC_UNIT_SIZE - 1, + ) + .await +} + +#[tokio::test] +async fn test_match_partial_round_place_exact_match() -> anyhow::Result<()> { + let mut test_fixture: TestFixture = TestFixture::try_new_for_matching_test().await?; + + let _ = scenario(&mut test_fixture, false, 1, -3, 1111, 1000).await; + + verify_balances( + &mut test_fixture, + 1000 * SOL_UNIT_SIZE - 1000, + 10000 * USDC_UNIT_SIZE + 1, + 1000 * SOL_UNIT_SIZE + 1000, + 10000 * USDC_UNIT_SIZE - 1, + ) + .await +} + +#[tokio::test] +async fn test_match_full_round_place_round_match() -> anyhow::Result<()> { + let mut test_fixture: TestFixture = TestFixture::try_new_for_matching_test().await?; + + let err = scenario(&mut test_fixture, false, 1, -3, 1, 1).await; + assert!(err.is_err(), "expect cancel to fail due to full match"); + + verify_balances( + &mut test_fixture, + 1000 * SOL_UNIT_SIZE - 1, + 10000 * USDC_UNIT_SIZE, + 1000 * SOL_UNIT_SIZE + 1, + 10000 * USDC_UNIT_SIZE, + ) + .await +} + +#[tokio::test] +async fn test_match_partial_round_place_round_match() -> anyhow::Result<()> { + let mut test_fixture: TestFixture = TestFixture::try_new_for_matching_test().await?; + + let _ = scenario(&mut test_fixture, false, 1, -3, 2, 1).await; + + verify_balances( + &mut test_fixture, + 1000 * SOL_UNIT_SIZE - 1, + 10000 * USDC_UNIT_SIZE + 1, + 1000 * SOL_UNIT_SIZE + 1, + 10000 * USDC_UNIT_SIZE - 1, + ) + .await +} diff --git a/programs/manifest/tests/cases/mod.rs b/programs/manifest/tests/cases/mod.rs index b4e8b6d0e..a728acc45 100644 --- a/programs/manifest/tests/cases/mod.rs +++ b/programs/manifest/tests/cases/mod.rs @@ -5,6 +5,7 @@ pub mod create_market; pub mod deposit; pub mod global; pub mod loaders; +pub mod matching; pub mod place_order; pub mod swap; pub mod token22; diff --git a/programs/manifest/tests/cases/swap.rs b/programs/manifest/tests/cases/swap.rs index 9afefe169..8cca37b22 100644 --- a/programs/manifest/tests/cases/swap.rs +++ b/programs/manifest/tests/cases/swap.rs @@ -7,6 +7,7 @@ use manifest::{ global_deposit_instruction, global_withdraw_instruction, swap_instruction, ManifestInstruction, SwapParams, }, + quantities::{BaseAtoms, WrapperU64}, state::{constants::NO_EXPIRATION_LAST_VALID_SLOT, OrderType}, validation::get_vault_address, }; @@ -36,42 +37,50 @@ async fn swap_test() -> anyhow::Result<()> { async fn swap_full_match_test_sell_exact_in() -> anyhow::Result<()> { let mut test_fixture: TestFixture = TestFixture::new().await; + // second keypair is the maker let second_keypair: Keypair = test_fixture.second_keypair.insecure_clone(); test_fixture.claim_seat_for_keypair(&second_keypair).await?; + + // all amounts in tokens, "a" signifies rounded atom + // needs 2x(10+a) + 4x5+a = 40+3a usdc test_fixture - .deposit_for_keypair(Token::USDC, 3_000 * USDC_UNIT_SIZE, &second_keypair) + .deposit_for_keypair(Token::USDC, 40 * USDC_UNIT_SIZE + 3, &second_keypair) .await?; + // price is sub-atomic: ~10 SOL/USDC + // will round towards taker test_fixture .place_order_for_keypair( Side::Bid, 1 * SOL_UNIT_SIZE, - 1, - 0, + 1_000_000_001, + -11, NO_EXPIRATION_LAST_VALID_SLOT, OrderType::Limit, &second_keypair, ) .await?; + // this order expires test_fixture .place_order_for_keypair( Side::Bid, 1 * SOL_UNIT_SIZE, - 1, - 0, + 1_000_000_001, + -11, 10, OrderType::Limit, &second_keypair, ) .await?; + // will round towards maker test_fixture .place_order_for_keypair( Side::Bid, - 2 * SOL_UNIT_SIZE, - 5, - -1, + 4 * SOL_UNIT_SIZE, + 500_000_001, + -11, NO_EXPIRATION_LAST_VALID_SLOT, OrderType::Limit, &second_keypair, @@ -85,28 +94,53 @@ async fn swap_full_match_test_sell_exact_in() -> anyhow::Result<()> { test_fixture.advance_time_seconds(20).await; - assert_eq!( - test_fixture.payer_sol_fixture.balance_atoms().await, - 3 * SOL_UNIT_SIZE - ); - assert_eq!(test_fixture.payer_usdc_fixture.balance_atoms().await, 0); test_fixture - .swap(3 * SOL_UNIT_SIZE, 2_000 * USDC_UNIT_SIZE, true, true) + .swap(3 * SOL_UNIT_SIZE, 20 * USDC_UNIT_SIZE, true, true) .await?; + // matched: + // 1 SOL * 10+a SOL/USDC = 10 USDC + // 2 SOL * 5+a SOL/USC = 10+1 USDC + // taker has: + // 10 USDC / 5+a SOL/USDC = 2-3a SOL + // taker has 3-3 = 0 sol & 10+a + 2x5 = 20+a usdc assert_eq!(test_fixture.payer_sol_fixture.balance_atoms().await, 0); assert_eq!( test_fixture.payer_usdc_fixture.balance_atoms().await, - 2_000 * USDC_UNIT_SIZE + 20 * USDC_UNIT_SIZE + 1 ); + // maker has unlocked: + // 3 SOL + // 10+1a USDC from expired order test_fixture .withdraw_for_keypair(Token::SOL, 3 * SOL_UNIT_SIZE, &second_keypair) .await?; test_fixture - .withdraw_for_keypair(Token::USDC, 1_000 * USDC_UNIT_SIZE, &second_keypair) + .withdraw_for_keypair(Token::USDC, 10 * USDC_UNIT_SIZE + 1, &second_keypair) .await?; + // maker has resting: + // 5 - 3 = 2 sol @ 5+a + // 2x5+a = 10+a + let orders = test_fixture.market_fixture.get_resting_orders().await; + let resting = orders.first().unwrap(); + assert_eq!(resting.get_num_base_atoms(), 2 * SOL_UNIT_SIZE); + assert_eq!( + resting + .get_price() + .checked_quote_for_base(BaseAtoms::new(10u64.pow(11)), false) + .unwrap(), + 500_000_001 + ); + assert_eq!( + resting + .get_price() + .checked_quote_for_base(resting.get_num_base_atoms(), true) + .unwrap(), + 10 * USDC_UNIT_SIZE + 1 + ); + Ok(()) } @@ -114,42 +148,50 @@ async fn swap_full_match_test_sell_exact_in() -> anyhow::Result<()> { async fn swap_full_match_test_sell_exact_out() -> anyhow::Result<()> { let mut test_fixture: TestFixture = TestFixture::new().await; + // second keypair is the maker let second_keypair: Keypair = test_fixture.second_keypair.insecure_clone(); test_fixture.claim_seat_for_keypair(&second_keypair).await?; + + // all amounts in tokens, "a" signifies rounded atom + // needs 2x(10+a) + 4x(5)+a = 40+3a usdc test_fixture - .deposit_for_keypair(Token::USDC, 3_000 * USDC_UNIT_SIZE, &second_keypair) + .deposit_for_keypair(Token::USDC, 40 * USDC_UNIT_SIZE + 3, &second_keypair) .await?; + // price is sub-atomic: ~10 SOL/USDC + // will round towards taker test_fixture .place_order_for_keypair( Side::Bid, 1 * SOL_UNIT_SIZE, - 1, - 0, + 1_000_000_001, + -11, NO_EXPIRATION_LAST_VALID_SLOT, OrderType::Limit, &second_keypair, ) .await?; + // this order expires test_fixture .place_order_for_keypair( Side::Bid, 1 * SOL_UNIT_SIZE, - 1, - 0, + 1_000_000_001, + -11, 10, OrderType::Limit, &second_keypair, ) .await?; + // will round towards maker test_fixture .place_order_for_keypair( Side::Bid, - 2 * SOL_UNIT_SIZE, - 5, - -1, + 4 * SOL_UNIT_SIZE, + 500_000_001, + -11, NO_EXPIRATION_LAST_VALID_SLOT, OrderType::Limit, &second_keypair, @@ -163,28 +205,54 @@ async fn swap_full_match_test_sell_exact_out() -> anyhow::Result<()> { test_fixture.advance_time_seconds(20).await; - assert_eq!( - test_fixture.payer_sol_fixture.balance_atoms().await, - 3 * SOL_UNIT_SIZE - ); - assert_eq!(test_fixture.payer_usdc_fixture.balance_atoms().await, 0); test_fixture - .swap(3 * SOL_UNIT_SIZE, 2_000 * USDC_UNIT_SIZE, true, false) + .swap(3 * SOL_UNIT_SIZE, 20 * USDC_UNIT_SIZE + 1, true, false) .await?; - assert_eq!(test_fixture.payer_sol_fixture.balance_atoms().await, 0); + // matched: + // 1 SOL * 10+a SOL/USDC = 10+a USDC + // 10 USDC / 5+a SOL/USDC = 2-3a SOL + // taker has: + // 3 - 1 - (2-3a) = 3a SOL + // 10+a + 2x5 = 20+a USDC + assert_eq!(test_fixture.payer_sol_fixture.balance_atoms().await, 3); assert_eq!( test_fixture.payer_usdc_fixture.balance_atoms().await, - 2_000 * USDC_UNIT_SIZE + 20 * USDC_UNIT_SIZE + 1 ); + // maker has unlocked: + // 1 + 2-3a = 3-3a sol + // 10+1a usdc from expired order test_fixture - .withdraw_for_keypair(Token::SOL, 3 * SOL_UNIT_SIZE, &second_keypair) + .withdraw_for_keypair(Token::SOL, 3 * SOL_UNIT_SIZE - 3, &second_keypair) .await?; test_fixture - .withdraw_for_keypair(Token::USDC, 1_000 * USDC_UNIT_SIZE, &second_keypair) + .withdraw_for_keypair(Token::USDC, 10 * USDC_UNIT_SIZE + 1, &second_keypair) .await?; + // maker has resting: + // 5 - (3-3a) = 2+3a sol @ 5+a + // ~2x~5+a = 10+a + let orders = test_fixture.market_fixture.get_resting_orders().await; + println!("{orders:?}"); + let resting = orders.first().unwrap(); + assert_eq!(resting.get_num_base_atoms(), 2 * SOL_UNIT_SIZE + 3); + assert_eq!( + resting + .get_price() + .checked_quote_for_base(BaseAtoms::new(10u64.pow(11)), false) + .unwrap(), + 500_000_001 + ); + assert_eq!( + resting + .get_price() + .checked_quote_for_base(resting.get_num_base_atoms(), true) + .unwrap(), + 10 * USDC_UNIT_SIZE + 1 + ); + Ok(()) } @@ -194,40 +262,47 @@ async fn swap_full_match_test_buy_exact_in() -> anyhow::Result<()> { let second_keypair: Keypair = test_fixture.second_keypair.insecure_clone(); test_fixture.claim_seat_for_keypair(&second_keypair).await?; + + // all amounts in tokens, "a" signifies rounded atom + // need 1 + 1 + 3 = 5 SOL test_fixture - .deposit_for_keypair(Token::SOL, 3 * SOL_UNIT_SIZE, &second_keypair) + .deposit_for_keypair(Token::SOL, 5 * SOL_UNIT_SIZE, &second_keypair) .await?; + // price is sub-atomic: ~10 SOL/USDC + // will round towards taker test_fixture .place_order_for_keypair( Side::Ask, 1 * SOL_UNIT_SIZE, - 1, - 0, + 1_000_000_001, + -11, NO_EXPIRATION_LAST_VALID_SLOT, OrderType::Limit, &second_keypair, ) .await?; + // this order expires test_fixture .place_order_for_keypair( Side::Ask, 1 * SOL_UNIT_SIZE, - 1, - 0, + 1_000_000_001, + -11, 10, OrderType::Limit, &second_keypair, ) .await?; + // will round towards maker test_fixture .place_order_for_keypair( Side::Ask, - 1 * SOL_UNIT_SIZE, - 2, - 0, + 3 * SOL_UNIT_SIZE, + 1_500_000_001, + -11, NO_EXPIRATION_LAST_VALID_SLOT, OrderType::Limit, &second_keypair, @@ -236,33 +311,151 @@ async fn swap_full_match_test_buy_exact_in() -> anyhow::Result<()> { test_fixture .usdc_mint_fixture - .mint_to(&test_fixture.payer_usdc_fixture.key, 3_000 * USDC_UNIT_SIZE) + .mint_to(&test_fixture.payer_usdc_fixture.key, 40 * USDC_UNIT_SIZE) .await; test_fixture.advance_time_seconds(20).await; - assert_eq!(test_fixture.payer_sol_fixture.balance_atoms().await, 0); + test_fixture + .swap(40 * USDC_UNIT_SIZE, 3 * SOL_UNIT_SIZE - 2, false, true) + .await?; + + // matched: + // 1 SOL * 10+a SOL/USDC = 10 USDC + // 30 USDC / 15+a SOL/USDC = 2-2a SOL + // taker has: + // 1 + 2-2a = 3-2a SOL + // 40 - 10 - 30 = 0 USDC assert_eq!( - test_fixture.payer_usdc_fixture.balance_atoms().await, - 3_000 * USDC_UNIT_SIZE + test_fixture.payer_sol_fixture.balance_atoms().await, + 3 * SOL_UNIT_SIZE - 2 ); + assert_eq!(test_fixture.payer_usdc_fixture.balance_atoms().await, 0); + + // maker has unlocked: + // 5 - (1+2a) - (3-2a) = 1 SOL + // 10 + 30 = 40 USDC test_fixture - .swap(3000 * USDC_UNIT_SIZE, 2 * SOL_UNIT_SIZE, false, true) + .withdraw_for_keypair(Token::SOL, 1 * SOL_UNIT_SIZE, &second_keypair) + .await?; + test_fixture + .withdraw_for_keypair(Token::USDC, 40 * USDC_UNIT_SIZE, &second_keypair) .await?; + // maker has resting 1+2a SOL @ 15+a SOL/USDC + let orders = test_fixture.market_fixture.get_resting_orders().await; + let resting = orders.first().unwrap(); + assert_eq!(resting.get_num_base_atoms(), 1 * SOL_UNIT_SIZE + 2); + assert_eq!( + resting + .get_price() + .checked_quote_for_base(BaseAtoms::new(10u64.pow(11)), false) + .unwrap(), + 1_500_000_001 + ); + + Ok(()) +} + +#[tokio::test] +async fn swap_full_match_test_buy_exact_out() -> anyhow::Result<()> { + let mut test_fixture: TestFixture = TestFixture::new().await; + + let second_keypair: Keypair = test_fixture.second_keypair.insecure_clone(); + test_fixture.claim_seat_for_keypair(&second_keypair).await?; + + // need 1 + 1 + 3 = 5 SOL + test_fixture + .deposit_for_keypair(Token::SOL, 5 * SOL_UNIT_SIZE, &second_keypair) + .await?; + + // price is sub-atomic: ~10 SOL/USDC + // will round towards taker + test_fixture + .place_order_for_keypair( + Side::Ask, + 1 * SOL_UNIT_SIZE, + 1_000_000_001, + -11, + NO_EXPIRATION_LAST_VALID_SLOT, + OrderType::Limit, + &second_keypair, + ) + .await?; + + // this order expires + test_fixture + .place_order_for_keypair( + Side::Ask, + 1 * SOL_UNIT_SIZE, + 1_000_000_001, + -11, + 10, + OrderType::Limit, + &second_keypair, + ) + .await?; + + // will round towards maker + test_fixture + .place_order_for_keypair( + Side::Ask, + 3 * SOL_UNIT_SIZE, + 1_500_000_001, + -11, + NO_EXPIRATION_LAST_VALID_SLOT, + OrderType::Limit, + &second_keypair, + ) + .await?; + + test_fixture + .usdc_mint_fixture + .mint_to( + &test_fixture.payer_usdc_fixture.key, + 40 * USDC_UNIT_SIZE + 1, + ) + .await; + + test_fixture.advance_time_seconds(20).await; + + test_fixture + .swap(40 * USDC_UNIT_SIZE + 1, 3 * SOL_UNIT_SIZE, false, false) + .await?; + + // matched: + // 1 SOL x 10+a SOL/USDC = 10 USDC + // 2 SOL x 15+a SOL/USDC = 30+a USDC + // taker has: + // 1 + 2 = 3 SOL + // 40+a - 10 - (30+a) = 0 USDC assert_eq!( test_fixture.payer_sol_fixture.balance_atoms().await, - 2 * SOL_UNIT_SIZE + 3 * SOL_UNIT_SIZE ); assert_eq!(test_fixture.payer_usdc_fixture.balance_atoms().await, 0); + // maker has unlocked: + // 5 - 1 - 3 = 1 SOL + // 10 + 30+a = 40+a USDC test_fixture .withdraw_for_keypair(Token::SOL, 1 * SOL_UNIT_SIZE, &second_keypair) .await?; test_fixture - .withdraw_for_keypair(Token::USDC, 2_000 * USDC_UNIT_SIZE, &second_keypair) + .withdraw_for_keypair(Token::USDC, 40 * USDC_UNIT_SIZE + 1, &second_keypair) .await?; + // maker has resting 1 SOL @ 15+a SOL/USDC + let orders = test_fixture.market_fixture.get_resting_orders().await; + let resting = orders.first().unwrap(); + assert_eq!(resting.get_num_base_atoms(), 1 * SOL_UNIT_SIZE); + assert_eq!( + resting + .get_price() + .checked_quote_for_base(BaseAtoms::new(10u64.pow(11)), false) + .unwrap(), + 1_500_000_001 + ); Ok(()) } diff --git a/programs/manifest/tests/program_test/fixtures.rs b/programs/manifest/tests/program_test/fixtures.rs index 4db7ca11c..9e0e7c368 100644 --- a/programs/manifest/tests/program_test/fixtures.rs +++ b/programs/manifest/tests/program_test/fixtures.rs @@ -118,6 +118,28 @@ impl TestFixture { } } + pub async fn try_new_for_matching_test() -> anyhow::Result { + let mut test_fixture = TestFixture::new().await; + let second_keypair = test_fixture.second_keypair.insecure_clone(); + + test_fixture.claim_seat().await?; + test_fixture + .deposit(Token::SOL, 1_000 * SOL_UNIT_SIZE) + .await?; + test_fixture + .deposit(Token::USDC, 10_000 * USDC_UNIT_SIZE) + .await?; + + test_fixture.claim_seat_for_keypair(&second_keypair).await?; + test_fixture + .deposit_for_keypair(Token::SOL, 1_000 * SOL_UNIT_SIZE, &second_keypair) + .await?; + test_fixture + .deposit_for_keypair(Token::USDC, 10_000 * USDC_UNIT_SIZE, &second_keypair) + .await?; + Ok(test_fixture) + } + pub async fn try_load( &self, address: &Pubkey, diff --git a/programs/wrapper/Cargo.toml b/programs/wrapper/Cargo.toml index 79f10467f..c137372b7 100644 --- a/programs/wrapper/Cargo.toml +++ b/programs/wrapper/Cargo.toml @@ -15,7 +15,7 @@ name = "wrapper" no-entrypoint = [] cpi = ["no-entrypoint"] default = [] -trace = [] +trace = [ "manifest/trace" ] test = [] [dependencies]