diff --git a/Makefile b/Makefile index e4e42c9..5295d35 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ PHONY: build test format build: - cargo build + cargo build-sbf test: cargo test diff --git a/src/constants.rs b/src/constants.rs index 80acc71..026757d 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -1,2 +1,3 @@ pub const BPS: i64 = 10_000; pub const T: u64 = 1; // fixed time horizon +pub const PRICE_SCALE: i64 = 1_000_000; // 6-decimal price scaling factor diff --git a/src/entrypoint.rs b/src/entrypoint.rs index 737e2e2..4e6b1d2 100644 --- a/src/entrypoint.rs +++ b/src/entrypoint.rs @@ -6,7 +6,8 @@ use pinocchio::{ }; use crate::instructions::{ - process_deposit, process_initialize_market, process_update_config, process_withdraw, + process_deposit, process_execute_swap, process_initialize_market, process_update_config, + process_withdraw, }; program_entrypoint!(process_instruction); @@ -23,6 +24,7 @@ fn process_instruction( Some((2, data)) => process_update_config(program_id, accounts, data), Some((3, data)) => process_deposit(program_id, accounts, data), Some((4, data)) => process_withdraw(program_id, accounts, data), + Some((5, data)) => process_execute_swap(program_id, accounts, data), _ => Err(ProgramError::InvalidInstructionData), } } diff --git a/src/instructions/execute_swap.rs b/src/instructions/execute_swap.rs new file mode 100644 index 0000000..98512c6 --- /dev/null +++ b/src/instructions/execute_swap.rs @@ -0,0 +1,134 @@ +use pinocchio::{ + AccountView, Address, ProgramResult, + cpi::{Seed, Signer}, + error::ProgramError, +}; +use pinocchio_token::instructions::Transfer; + +use crate::{ + constants::PRICE_SCALE, math::compute_price_spread, state::MakerConfig, utils::impl_load_ix, +}; + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct SwapArgs { + pub amount_in: u64, + pub min_amount_out: u64, + pub mid_price: i64, + pub side: u8, + pub _padding: [u8; 7], +} + +impl_load_ix!(SwapArgs); + +pub fn process_execute_swap( + _program_id: &Address, + accounts: &[AccountView], + data: &[u8], +) -> ProgramResult { + let [ + user, + maker_config, + user_base_ata, + vault_base, + user_quote_ata, + vault_quote, + _token_program, + _remaining @ .., + ] = accounts + else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + let args = SwapArgs::load(data)?; + + if !user.is_signer() { + return Err(ProgramError::MissingRequiredSignature); + } + + if args.side > 1 { + return Err(ProgramError::InvalidInstructionData); + } + + let (bump, current_inventory, spread) = { + let config = MakerConfig::load(unsafe { maker_config.borrow_unchecked() })?; + let current_inventory = u64::from_le_bytes(config.current_inventory); + let spread = compute_price_spread(config, args.mid_price, current_inventory)?; + (config.bump, current_inventory, spread) + }; + + let amount_out: u64 = if args.side == 0 { + if spread.ask <= 0 { + return Err(ProgramError::InvalidArgument); + } + (args.amount_in as i64) + .checked_mul(PRICE_SCALE) + .ok_or(ProgramError::ArithmeticOverflow)? + .checked_div(spread.ask) + .ok_or(ProgramError::ArithmeticOverflow)? as u64 + } else { + if spread.bid <= 0 { + return Err(ProgramError::InvalidArgument); + } + (args.amount_in as i64) + .checked_mul(spread.bid) + .ok_or(ProgramError::ArithmeticOverflow)? + .checked_div(PRICE_SCALE) + .ok_or(ProgramError::ArithmeticOverflow)? as u64 + }; + + if amount_out < args.min_amount_out { + return Err(ProgramError::InvalidArgument); + } + + let bump_arr = [bump]; + let seeds = [Seed::from(b"maker_config" as &[u8]), Seed::from(&bump_arr)]; + let signer = Signer::from(&seeds[..]); + + let new_inventory: u64 = if args.side == 0 { + Transfer { + from: user_quote_ata, + to: vault_quote, + authority: user, + amount: args.amount_in, + } + .invoke()?; + + Transfer { + from: vault_base, + to: user_base_ata, + authority: maker_config, + amount: amount_out, + } + .invoke_signed(&[signer])?; + + current_inventory + .checked_sub(amount_out) + .ok_or(ProgramError::ArithmeticOverflow)? + } else { + Transfer { + from: user_base_ata, + to: vault_base, + authority: user, + amount: args.amount_in, + } + .invoke()?; + + Transfer { + from: vault_quote, + to: user_quote_ata, + authority: maker_config, + amount: amount_out, + } + .invoke_signed(&[signer])?; + + current_inventory + .checked_add(args.amount_in) + .ok_or(ProgramError::ArithmeticOverflow)? + }; + + let config = MakerConfig::load_mut(unsafe { maker_config.borrow_unchecked_mut() })?; + config.current_inventory = new_inventory.to_le_bytes(); + + Ok(()) +} diff --git a/src/instructions/initialize_market.rs b/src/instructions/initialize_market.rs index 17dd6ba..a9a86b6 100644 --- a/src/instructions/initialize_market.rs +++ b/src/instructions/initialize_market.rs @@ -57,6 +57,7 @@ pub fn process_initialize_market( config.risk_aversion = args.risk_aversion.to_le_bytes(); config.variance = args.variance.to_le_bytes(); config.spread_factor = args.spread_factor.to_le_bytes(); + config.current_inventory = args.target_inventory.to_le_bytes(); config.market_maker = *maker.address().as_array(); config.bump = args.bump; config._padding = [0u8; 7]; diff --git a/src/instructions/mod.rs b/src/instructions/mod.rs index f33fbf6..227dc45 100644 --- a/src/instructions/mod.rs +++ b/src/instructions/mod.rs @@ -1,9 +1,11 @@ pub mod deposit; +pub mod execute_swap; pub mod initialize_market; pub mod update_config; pub mod withdraw; pub use deposit::*; +pub use execute_swap::*; pub use initialize_market::*; pub use update_config::*; pub use withdraw::*; diff --git a/src/state/maker_config.rs b/src/state/maker_config.rs index 7ee66f4..94a425a 100644 --- a/src/state/maker_config.rs +++ b/src/state/maker_config.rs @@ -7,6 +7,7 @@ pub struct MakerConfig { pub risk_aversion: [u8; 8], // bps pub variance: [u8; 8], pub spread_factor: [u8; 8], + pub current_inventory: [u8; 8], // live base token inventory, updated on every swap pub market_maker: [u8; 32], pub bump: u8, pub _padding: [u8; 7], diff --git a/src/tests/initialize_market.rs b/src/tests/initialize_market.rs index 5192bc1..de2d2d7 100644 --- a/src/tests/initialize_market.rs +++ b/src/tests/initialize_market.rs @@ -59,5 +59,9 @@ fn test_initialize_market() { ); assert_eq!(u64::from_le_bytes(config.risk_aversion), args.risk_aversion); assert_eq!(config.market_maker, admin.pubkey().to_bytes()); + assert_eq!( + u64::from_le_bytes(config.current_inventory), + args.target_inventory + ); assert_eq!(config.bump, bump); } diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 4cae44f..bd8f748 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -12,3 +12,6 @@ pub mod deposit; #[cfg(test)] pub mod withdraw; + +#[cfg(test)] +pub mod swap; diff --git a/src/tests/swap.rs b/src/tests/swap.rs new file mode 100644 index 0000000..a693b2c --- /dev/null +++ b/src/tests/swap.rs @@ -0,0 +1,239 @@ +use super::helpers::program_id; +use crate::{ + constants::PRICE_SCALE, + instructions::SwapArgs, + state::MakerConfig, + tests::helpers::{MarketSetup, setup_market_and_deposit}, +}; +use litesvm_token::{CreateAssociatedTokenAccount, MintTo, TOKEN_ID as TOKEN_PROGRAM_ID}; +use solana_instruction::{AccountMeta, Instruction}; +use solana_keypair::Keypair; +use solana_message::Message; +use solana_native_token::LAMPORTS_PER_SOL; +use solana_signer::Signer; +use solana_transaction::Transaction; +use spl_token::solana_program::program_pack::Pack; + +const MID_PRICE: i64 = PRICE_SCALE; +const DEPOSIT_AMOUNT: u64 = 2_000_000_000; + +struct SwapSetup { + inner: MarketSetup, + user: Keypair, + user_base_ata: solana_pubkey::Pubkey, + vault_quote: solana_pubkey::Pubkey, + user_quote_ata: solana_pubkey::Pubkey, +} + +fn setup_swap() -> SwapSetup { + let mut inner = setup_market_and_deposit(DEPOSIT_AMOUNT); + + let user = Keypair::new(); + inner + .svm + .airdrop(&user.pubkey(), 10 * LAMPORTS_PER_SOL) + .unwrap(); + + let user_base_ata = + CreateAssociatedTokenAccount::new(&mut inner.svm, &inner.admin, &inner.mint) + .owner(&user.pubkey()) + .send() + .unwrap(); + + let quote_mint = litesvm_token::CreateMint::new(&mut inner.svm, &inner.admin) + .decimals(6) + .authority(&inner.admin.pubkey()) + .send() + .unwrap(); + + let user_quote_ata = + CreateAssociatedTokenAccount::new(&mut inner.svm, &inner.admin, "e_mint) + .owner(&user.pubkey()) + .send() + .unwrap(); + + let vault_quote = CreateAssociatedTokenAccount::new(&mut inner.svm, &inner.admin, "e_mint) + .owner(&inner.config_pda) + .send() + .unwrap(); + + MintTo::new( + &mut inner.svm, + &inner.admin, + "e_mint, + &user_quote_ata, + DEPOSIT_AMOUNT, + ) + .send() + .unwrap(); + + MintTo::new( + &mut inner.svm, + &inner.admin, + "e_mint, + &vault_quote, + DEPOSIT_AMOUNT, + ) + .send() + .unwrap(); + + MintTo::new( + &mut inner.svm, + &inner.admin, + &inner.mint, + &user_base_ata, + DEPOSIT_AMOUNT, + ) + .send() + .unwrap(); + + SwapSetup { + inner, + user, + user_base_ata, + vault_quote, + user_quote_ata, + } +} + +fn make_swap_ix(setup: &SwapSetup, args: &SwapArgs) -> Instruction { + let mut data = vec![5u8]; + data.extend_from_slice(unsafe { + core::slice::from_raw_parts( + args as *const _ as *const u8, + core::mem::size_of::(), + ) + }); + + Instruction { + program_id: program_id(), + accounts: vec![ + AccountMeta::new(setup.user.pubkey(), true), + AccountMeta::new(setup.inner.config_pda, false), + AccountMeta::new(setup.user_base_ata, false), + AccountMeta::new(setup.inner.vault, false), + AccountMeta::new(setup.user_quote_ata, false), + AccountMeta::new(setup.vault_quote, false), + AccountMeta::new_readonly(TOKEN_PROGRAM_ID, false), + ], + data, + } +} + +#[test] +fn test_buy_base() { + let mut setup = setup_swap(); + let amount_in = 1_000_100_000u64; + let args = SwapArgs { + amount_in, + min_amount_out: 900_000_000, + mid_price: MID_PRICE, + side: 0, + _padding: [0u8; 7], + }; + + let ix = make_swap_ix(&setup, &args); + let tx = setup + .inner + .svm + .send_transaction(Transaction::new( + &[&setup.user], + Message::new(&[ix], Some(&setup.user.pubkey())), + setup.inner.svm.latest_blockhash(), + )) + .unwrap(); + + println!("{:<12} | {:>6} CUs", "swap_buy", tx.compute_units_consumed); + + let user_base = setup.inner.svm.get_account(&setup.user_base_ata).unwrap(); + let user_base_data = spl_token::state::Account::unpack(&user_base.data).unwrap(); + let received = user_base_data.amount - DEPOSIT_AMOUNT; + assert!(received > 0, "user should have received base tokens"); + + let vault_base = setup.inner.svm.get_account(&setup.inner.vault).unwrap(); + let vault_base_data = spl_token::state::Account::unpack(&vault_base.data).unwrap(); + assert_eq!(vault_base_data.amount, DEPOSIT_AMOUNT - received); + + let config_acc = setup + .inner + .svm + .get_account(&setup.inner.config_pda) + .unwrap(); + let config = MakerConfig::load(&config_acc.data).unwrap(); + assert_eq!( + u64::from_le_bytes(config.current_inventory), + DEPOSIT_AMOUNT - received + ); +} + +#[test] +fn test_sell_base() { + let mut setup = setup_swap(); + let amount_in = 1_000_000_000u64; + let args = SwapArgs { + amount_in, + min_amount_out: 900_000_000, + mid_price: MID_PRICE, + side: 1, + _padding: [0u8; 7], + }; + + let ix = make_swap_ix(&setup, &args); + let tx = setup + .inner + .svm + .send_transaction(Transaction::new( + &[&setup.user], + Message::new(&[ix], Some(&setup.user.pubkey())), + setup.inner.svm.latest_blockhash(), + )) + .unwrap(); + + println!("{:<12} | {:>6} CUs", "swap_sell", tx.compute_units_consumed); + + let user_base = setup.inner.svm.get_account(&setup.user_base_ata).unwrap(); + let user_base_data = spl_token::state::Account::unpack(&user_base.data).unwrap(); + assert_eq!(user_base_data.amount, DEPOSIT_AMOUNT - amount_in); + + let user_quote = setup.inner.svm.get_account(&setup.user_quote_ata).unwrap(); + let user_quote_data = spl_token::state::Account::unpack(&user_quote.data).unwrap(); + assert!( + user_quote_data.amount > DEPOSIT_AMOUNT, + "user should have received quote tokens" + ); + + let config_acc = setup + .inner + .svm + .get_account(&setup.inner.config_pda) + .unwrap(); + let config = MakerConfig::load(&config_acc.data).unwrap(); + assert_eq!( + u64::from_le_bytes(config.current_inventory), + DEPOSIT_AMOUNT + amount_in + ); +} + +#[test] +#[should_panic] +fn test_swap_slippage_exceeded() { + let mut setup = setup_swap(); + let args = SwapArgs { + amount_in: 1_000_100_000, + min_amount_out: u64::MAX, + mid_price: MID_PRICE, + side: 0, + _padding: [0u8; 7], + }; + + let ix = make_swap_ix(&setup, &args); + setup + .inner + .svm + .send_transaction(Transaction::new( + &[&setup.user], + Message::new(&[ix], Some(&setup.user.pubkey())), + setup.inner.svm.latest_blockhash(), + )) + .unwrap(); +}