diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..75d25886 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "examples/swap/deps/serum-dex"] + path = examples/swap/deps/serum-dex + url = https://github.com/project-serum/serum-dex diff --git a/.travis.yml b/.travis.yml index a38dddca..1536d6d1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,6 +6,8 @@ cache: cargo env: global: - NODE_VERSION="14.7.0" +git: + submodules: true _defaults: &defaults before_install: @@ -65,6 +67,7 @@ jobs: script: - pushd examples/chat && yarn && anchor test && popd - pushd examples/ido-pool && yarn && anchor test && popd + - pushd examples/swap/deps/serum-dex/dex && cargo build-bpf && cd ../../../ && anchor test && popd - pushd examples/tutorial/basic-0 && anchor test && popd - pushd examples/tutorial/basic-1 && anchor test && popd - pushd examples/tutorial/basic-2 && anchor test && popd diff --git a/Cargo.lock b/Cargo.lock index efbaa1c4..90057b42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -42,6 +42,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-traits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b2d54853319fd101b8dd81de382bcbf3e03410a64d8928bbee85a3e7dcde483" + [[package]] name = "anchor-attribute-access-control" version = "0.4.4" @@ -191,6 +197,8 @@ name = "anchor-spl" version = "0.4.4" dependencies = [ "anchor-lang", + "lazy_static", + "serum_dex", "solana-program", "spl-token 3.1.0", ] @@ -987,6 +995,26 @@ dependencies = [ "cfg-if 1.0.0", ] +[[package]] +name = "enumflags2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83c8d82922337cd23a15f88b70d8e4ef5f11da38dd7cdb55e84dd5de99695da0" +dependencies = [ + "enumflags2_derive", +] + +[[package]] +name = "enumflags2_derive" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "946ee94e3dbf58fdd324f9ce245c7b238d46a66f00e86a020b71996349e46cce" +dependencies = [ + "proc-macro2 1.0.24", + "quote 1.0.9", + "syn 1.0.67", +] + [[package]] name = "env_logger" version = "0.8.3" @@ -1012,6 +1040,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "835a3dc7d1ec9e75e2b5fb4ba75396837112d2060b03f7d43bc1897c7f7211da" +[[package]] +name = "field-offset" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf539fba70056b50f40a22e0da30639518a12ee18c35807858a63b158cb6dde7" +dependencies = [ + "memoffset 0.6.1", + "rustc_version 0.3.3", +] + [[package]] name = "filetime" version = "0.2.14" @@ -2008,7 +2046,7 @@ checksum = "f842b1982eb6c2fe34036a4fbfb06dd185a3f5c8edfaacdf7d1ea10b07de6252" dependencies = [ "lock_api 0.3.4", "parking_lot_core 0.6.2", - "rustc_version", + "rustc_version 0.2.3", ] [[package]] @@ -2042,7 +2080,7 @@ dependencies = [ "cloudabi", "libc", "redox_syscall 0.1.57", - "rustc_version", + "rustc_version 0.2.3", "smallvec 0.6.14", "winapi 0.3.9", ] @@ -2514,6 +2552,15 @@ dependencies = [ "semver 0.9.0", ] +[[package]] +name = "rustc_version" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dfe2087c51c460008730de8b57e6a320782fbfb312e1f4d520e6c6fae155ee" +dependencies = [ + "semver 0.11.0", +] + [[package]] name = "rustls" version = "0.19.0" @@ -2539,6 +2586,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" +[[package]] +name = "safe-transmute" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d95e7284b4bd97e24af76023904cd0157c9cc9da0310beb4139a1e88a748d47" + [[package]] name = "same-file" version = "1.0.6" @@ -2708,7 +2761,7 @@ dependencies = [ [[package]] name = "serum-common" version = "0.1.0" -source = "git+https://github.com/project-serum/serum-dex#480cfefdbd7789c1fa2ac4474c6456b507f9a78f" +source = "git+https://github.com/project-serum/serum-dex#e264db2c9cc326f246a8fb108becb7d71bba3e72" dependencies = [ "anyhow", "arrayref", @@ -2723,6 +2776,29 @@ dependencies = [ "spl-token 2.0.8", ] +[[package]] +name = "serum_dex" +version = "0.2.0" +source = "git+https://github.com/project-serum/serum-dex#e264db2c9cc326f246a8fb108becb7d71bba3e72" +dependencies = [ + "arrayref", + "bincode", + "bytemuck", + "byteorder", + "enumflags2", + "field-offset", + "itertools", + "num-traits", + "num_enum", + "safe-transmute", + "serde", + "solana-program", + "spl-token 3.1.0", + "static_assertions", + "thiserror", + "without-alloc", +] + [[package]] name = "sha-1" version = "0.8.2" @@ -3000,7 +3076,7 @@ dependencies = [ "generic-array 0.14.4", "log", "memmap2", - "rustc_version", + "rustc_version 0.2.3", "serde", "serde_derive", "sha2 0.9.3", @@ -3018,7 +3094,7 @@ dependencies = [ "lazy_static", "proc-macro2 1.0.24", "quote 1.0.9", - "rustc_version", + "rustc_version 0.2.3", "syn 1.0.67", ] @@ -3101,7 +3177,7 @@ dependencies = [ "num-derive", "num-traits", "rand 0.7.3", - "rustc_version", + "rustc_version 0.2.3", "rustversion", "serde", "serde_bytes", @@ -3174,7 +3250,7 @@ dependencies = [ "rand 0.7.3", "rayon", "regex", - "rustc_version", + "rustc_version 0.2.3", "serde", "serde_derive", "solana-config-program", @@ -3223,7 +3299,7 @@ dependencies = [ "rand 0.7.3", "rand_chacha 0.2.2", "rand_core 0.6.2", - "rustc_version", + "rustc_version 0.2.3", "rustversion", "serde", "serde_bytes", @@ -3278,7 +3354,7 @@ dependencies = [ "log", "num-derive", "num-traits", - "rustc_version", + "rustc_version 0.2.3", "serde", "serde_derive", "solana-config-program", @@ -3322,7 +3398,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d85d6da83a490f9e2a889828fd6b3014e306ee1747429e01b3fe6d78da1dec43" dependencies = [ "log", - "rustc_version", + "rustc_version 0.2.3", "serde", "serde_derive", "solana-frozen-abi", @@ -3341,7 +3417,7 @@ dependencies = [ "log", "num-derive", "num-traits", - "rustc_version", + "rustc_version 0.2.3", "serde", "serde_derive", "solana-frozen-abi", @@ -3412,6 +3488,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.8.0" @@ -4197,6 +4279,15 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "without-alloc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e34736feff52a0b3e5680927e947a4d8fac1f0b80dc8120b080dd8de24d75e2" +dependencies = [ + "alloc-traits", +] + [[package]] name = "ws2_32-sys" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index cfeea846..23f35075 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,3 +8,6 @@ members = [ "lang/syn", "spl", ] +exclude = [ + "examples/swap/deps/serum-dex" +] \ No newline at end of file diff --git a/examples/swap/Anchor.toml b/examples/swap/Anchor.toml new file mode 100644 index 00000000..171d8134 --- /dev/null +++ b/examples/swap/Anchor.toml @@ -0,0 +1,6 @@ +cluster = "localnet" +wallet = "~/.config/solana/id.json" + +[[test.genesis]] +address = "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin" +program = "./deps/serum-dex/dex/target/deploy/serum_dex.so" diff --git a/examples/swap/Cargo.toml b/examples/swap/Cargo.toml new file mode 100644 index 00000000..52ed11f4 --- /dev/null +++ b/examples/swap/Cargo.toml @@ -0,0 +1,7 @@ +[workspace] +members = [ + "programs/*" +] +exclude = [ + "deps/serum-dex" +] \ No newline at end of file diff --git a/examples/swap/README.md b/examples/swap/README.md new file mode 100644 index 00000000..60778c27 --- /dev/null +++ b/examples/swap/README.md @@ -0,0 +1,34 @@ +# Swap + +An example swap program that provides a convenient API to the Serum orderbook +for performing instantly settled token swaps. + +## Usage + +This example requires building the Serum DEX from source, which is done using +git submodules. + +### Install Submodules + +Pull the source + +``` +git submodule init +git submodule update +``` + +### Build the DEX + +Build it + +``` +cd deps/serum-dex/dex/ && cargo build-bpf && cd ../../../ +``` + +### Run the Test + +Run the test + +``` +anchor test +``` diff --git a/examples/swap/deps/serum-dex b/examples/swap/deps/serum-dex new file mode 160000 index 00000000..19c8e37b --- /dev/null +++ b/examples/swap/deps/serum-dex @@ -0,0 +1 @@ +Subproject commit 19c8e37bf41d044a084b21e58182a50d119d46a2 diff --git a/examples/swap/migrations/deploy.js b/examples/swap/migrations/deploy.js new file mode 100644 index 00000000..325cf3d0 --- /dev/null +++ b/examples/swap/migrations/deploy.js @@ -0,0 +1,12 @@ +// Migrations are an early feature. Currently, they're nothing more than this +// single deploy script that's invoked from the CLI, injecting a provider +// configured from the workspace's Anchor.toml. + +const anchor = require("@project-serum/anchor"); + +module.exports = async function (provider) { + // Configure client to use the provider. + anchor.setProvider(provider); + + // Add your deploy script here. +} diff --git a/examples/swap/programs/swap/Cargo.toml b/examples/swap/programs/swap/Cargo.toml new file mode 100644 index 00000000..9f8b5cce --- /dev/null +++ b/examples/swap/programs/swap/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "swap" +version = "0.1.0" +description = "Created with Anchor" +edition = "2018" + +[lib] +crate-type = ["cdylib", "lib"] +name = "swap" + +[features] +no-entrypoint = [] +no-idl = [] +cpi = ["no-entrypoint"] +default = [] + +[dependencies] +anchor-lang = { path = "../../../../lang" } +anchor-spl = { path = "../../../../spl" } diff --git a/examples/swap/programs/swap/Xargo.toml b/examples/swap/programs/swap/Xargo.toml new file mode 100644 index 00000000..475fb71e --- /dev/null +++ b/examples/swap/programs/swap/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] diff --git a/examples/swap/programs/swap/src/lib.rs b/examples/swap/programs/swap/src/lib.rs new file mode 100644 index 00000000..6d8182f9 --- /dev/null +++ b/examples/swap/programs/swap/src/lib.rs @@ -0,0 +1,493 @@ +//! Program to perform instantly settled token swaps on the Serum DEX. +//! +//! Before using any instruction here, a user must first create an open orders +//! account on all markets being used. This only needs to be done once. As a +//! convention established by the DEX, this should be done via the system +//! program create account instruction in the same transaction as the user's +//! first trade. Then, the DEX will lazily initialize the open orders account. + +use anchor_lang::prelude::*; +use anchor_spl::dex; +use anchor_spl::dex::serum_dex::instruction::SelfTradeBehavior; +use anchor_spl::dex::serum_dex::matching::{OrderType, Side as SerumSide}; +use anchor_spl::dex::serum_dex::state::MarketState; +use anchor_spl::token; +use std::num::NonZeroU64; + +#[program] +pub mod swap { + use super::*; + + /// Swaps two tokens on a single A/B market, where A is the base currency + /// and B is the quote currency. This is just a direct IOC trade that + /// instantly settles. + /// + /// When side is "bid", then swaps B for A. When side is "ask", then swaps + /// A for B. + /// + /// Arguments: + /// + /// * `side` - The direction to swap. + /// * `amount` - The amount to swap *from* + /// * `min_expected_swap_amount` - The minimum amount of the *to* token the + /// client expects to receive from the swap. The instruction fails if + /// execution would result in less. + #[access_control(is_valid_swap(&ctx))] + pub fn swap<'info>( + ctx: Context<'_, '_, '_, 'info, Swap<'info>>, + side: Side, + amount: u64, + min_expected_swap_amount: u64, + ) -> Result<()> { + // Optional referral account (earns a referral fee). + let referral = ctx.remaining_accounts.iter().next().map(Clone::clone); + + // Side determines swap direction. + let (from_token, to_token) = match side { + Side::Bid => (&ctx.accounts.pc_wallet, &ctx.accounts.market.coin_wallet), + Side::Ask => (&ctx.accounts.market.coin_wallet, &ctx.accounts.pc_wallet), + }; + + // Token balances before the trade. + let from_amount_before = token::accessor::amount(from_token)?; + let to_amount_before = token::accessor::amount(to_token)?; + + // Execute trade. + let orderbook: OrderbookClient<'info> = (&*ctx.accounts).into(); + match side { + Side::Bid => orderbook.buy(amount, referral.clone())?, + Side::Ask => orderbook.sell(amount, referral.clone())?, + }; + orderbook.settle(referral)?; + + // Token balances after the trade. + let from_amount_after = token::accessor::amount(from_token)?; + let to_amount_after = token::accessor::amount(to_token)?; + + // Calculate the delta, i.e. the amount swapped. + let from_amount = from_amount_before.checked_sub(from_amount_after).unwrap(); + let to_amount = to_amount_after.checked_sub(to_amount_before).unwrap(); + + // Safety checks. + apply_risk_checks(DidSwap { + authority: *ctx.accounts.authority.key, + given_amount: amount, + min_expected_swap_amount, + from_amount, + to_amount, + spill_amount: 0, + from_mint: token::accessor::mint(from_token)?, + to_mint: token::accessor::mint(to_token)?, + quote_mint: match side { + Side::Bid => token::accessor::mint(from_token)?, + Side::Ask => token::accessor::mint(to_token)?, + }, + })?; + + Ok(()) + } + + /// Swaps two base currencies across two different markets. + /// + /// That is, suppose there are two markets, A/USD(x) and B/USD(x). + /// Then swaps token A for token B via + /// + /// * IOC (immediate or cancel) sell order on A/USD(x) market. + /// * Settle open orders to get USD(x). + /// * IOC buy order on B/USD(x) market to convert USD(x) to token B. + /// * Settle open orders to get token B. + /// + /// Arguments: + /// + /// * `amount` - The amount to swap *from*. + /// * `min_expected_swap_amount - The minimum amount of the *to* token the + /// client expects to receive from the swap. The instruction fails if + /// execution would result in less. + #[access_control(is_valid_swap_transitive(&ctx))] + pub fn swap_transitive<'info>( + ctx: Context<'_, '_, '_, 'info, SwapTransitive<'info>>, + amount: u64, + min_expected_swap_amount: u64, + ) -> Result<()> { + // Optional referral account (earns a referral fee). + let referral = ctx.remaining_accounts.iter().next().map(Clone::clone); + + // Leg 1: Sell Token A for USD(x) (or whatever quote currency is used). + let (from_amount, sell_proceeds) = { + // Token balances before the trade. + let base_before = token::accessor::amount(&ctx.accounts.from.coin_wallet)?; + let quote_before = token::accessor::amount(&ctx.accounts.pc_wallet)?; + + // Execute the trade. + let orderbook = ctx.accounts.orderbook_from(); + orderbook.sell(amount, referral.clone())?; + orderbook.settle(referral.clone())?; + + // Token balances after the trade. + let base_after = token::accessor::amount(&ctx.accounts.from.coin_wallet)?; + let quote_after = token::accessor::amount(&ctx.accounts.pc_wallet)?; + + // Report the delta. + ( + base_before.checked_sub(base_after).unwrap(), + quote_after.checked_sub(quote_before).unwrap(), + ) + }; + + // Leg 2: Buy Token B with USD(x) (or whatever quote currency is used). + let (to_amount, spill_amount) = { + // Token balances before the trade. + let base_before = token::accessor::amount(&ctx.accounts.to.coin_wallet)?; + let quote_before = token::accessor::amount(&ctx.accounts.pc_wallet)?; + + // Execute the trade. + let orderbook = ctx.accounts.orderbook_to(); + orderbook.buy(sell_proceeds, referral.clone())?; + orderbook.settle(referral)?; + + // Token balances after the trade. + let base_after = token::accessor::amount(&ctx.accounts.to.coin_wallet)?; + let quote_after = token::accessor::amount(&ctx.accounts.pc_wallet)?; + + // Report the delta. + ( + base_after.checked_sub(base_before).unwrap(), + quote_before.checked_sub(quote_after).unwrap(), + ) + }; + + // Safety checks. + apply_risk_checks(DidSwap { + given_amount: amount, + min_expected_swap_amount, + from_amount, + to_amount, + spill_amount, + from_mint: token::accessor::mint(&ctx.accounts.from.coin_wallet)?, + to_mint: token::accessor::mint(&ctx.accounts.to.coin_wallet)?, + quote_mint: token::accessor::mint(&ctx.accounts.pc_wallet)?, + authority: *ctx.accounts.authority.key, + })?; + + Ok(()) + } +} + +// Asserts the swap event is valid. +fn apply_risk_checks(event: DidSwap) -> Result<()> { + // Reject if the resulting amount is less than the client's expectation. + if event.to_amount < event.min_expected_swap_amount { + return Err(ErrorCode::SlippageExceeded.into()); + } + emit!(event); + Ok(()) +} + +// The only constraint imposed on these accounts is that the market's base +// currency mint is not equal to the quote currency's. All other checks are +// done by the DEX on CPI. +#[derive(Accounts)] +pub struct Swap<'info> { + market: MarketAccounts<'info>, + #[account(signer)] + authority: AccountInfo<'info>, + #[account(mut)] + pc_wallet: AccountInfo<'info>, + // Programs. + dex_program: AccountInfo<'info>, + token_program: AccountInfo<'info>, + // Sysvars. + rent: AccountInfo<'info>, +} + +impl<'info> From<&Swap<'info>> for OrderbookClient<'info> { + fn from(accounts: &Swap<'info>) -> OrderbookClient<'info> { + OrderbookClient { + market: accounts.market.clone(), + authority: accounts.authority.clone(), + pc_wallet: accounts.pc_wallet.clone(), + dex_program: accounts.dex_program.clone(), + token_program: accounts.token_program.clone(), + rent: accounts.rent.clone(), + } + } +} + +// The only constraint imposed on these accounts is that the from market's +// base currency's is not equal to the to market's base currency. All other +// checks are done by the DEX on CPI (and the quote currency is ensured to be +// the same on both markets since there's only one account field for it). +#[derive(Accounts)] +pub struct SwapTransitive<'info> { + from: MarketAccounts<'info>, + to: MarketAccounts<'info>, + // Must be the authority over all open orders accounts used. + #[account(signer)] + authority: AccountInfo<'info>, + #[account(mut)] + pc_wallet: AccountInfo<'info>, + // Programs. + dex_program: AccountInfo<'info>, + token_program: AccountInfo<'info>, + // Sysvars. + rent: AccountInfo<'info>, +} + +impl<'info> SwapTransitive<'info> { + fn orderbook_from(&self) -> OrderbookClient<'info> { + OrderbookClient { + market: self.from.clone(), + authority: self.authority.clone(), + pc_wallet: self.pc_wallet.clone(), + dex_program: self.dex_program.clone(), + token_program: self.token_program.clone(), + rent: self.rent.clone(), + } + } + fn orderbook_to(&self) -> OrderbookClient<'info> { + OrderbookClient { + market: self.to.clone(), + authority: self.authority.clone(), + pc_wallet: self.pc_wallet.clone(), + dex_program: self.dex_program.clone(), + token_program: self.token_program.clone(), + rent: self.rent.clone(), + } + } +} + +// Client for sending orders to the Serum DEX. +struct OrderbookClient<'info> { + market: MarketAccounts<'info>, + authority: AccountInfo<'info>, + pc_wallet: AccountInfo<'info>, + dex_program: AccountInfo<'info>, + token_program: AccountInfo<'info>, + rent: AccountInfo<'info>, +} + +impl<'info> OrderbookClient<'info> { + // Executes the sell order portion of the swap, purchasing as much of the + // quote currency as possible for the given `base_amount`. + // + // `base_amount` is the "native" amount of the base currency, i.e., token + // amount including decimals. + fn sell(&self, base_amount: u64, referral: Option>) -> ProgramResult { + let limit_price = 1; + let max_coin_qty = { + // The loaded market must be dropped before CPI. + let market = MarketState::load(&self.market.market, &dex::ID)?; + coin_lots(&market, base_amount) + }; + let max_native_pc_qty = u64::MAX; + self.order_cpi( + limit_price, + max_coin_qty, + max_native_pc_qty, + Side::Ask, + referral, + ) + } + + // Executes the buy order portion of the swap, purchasing as much of the + // base currency as possible, for the given `quote_amount`. + // + // `quote_amount` is the "native" amount of the quote currency, i.e., token + // amount including decimals. + fn buy(&self, quote_amount: u64, referral: Option>) -> ProgramResult { + let limit_price = u64::MAX; + let max_coin_qty = u64::MAX; + let max_native_pc_qty = quote_amount; + self.order_cpi( + limit_price, + max_coin_qty, + max_native_pc_qty, + Side::Bid, + referral, + ) + } + + // Executes a new order on the serum dex via CPI. + // + // * `limit_price` - the limit order price in lot units. + // * `max_coin_qty`- the max number of the base currency lot units. + // * `max_native_pc_qty` - the max number of quote currency in native token + // units (includes decimals). + // * `side` - bid or ask, i.e. the type of order. + // * `referral` - referral account, earning a fee. + fn order_cpi( + &self, + limit_price: u64, + max_coin_qty: u64, + max_native_pc_qty: u64, + side: Side, + referral: Option>, + ) -> ProgramResult { + // Client order id is only used for cancels. Not used here so hardcode. + let client_order_id = 0; + // Limit is the dex's custom compute budge parameter, setting an upper + // bound on the number of matching cycles the program can perform + // before giving up and posting the remaining unmatched order. + let limit = 65535; + + let dex_accs = dex::NewOrderV3 { + market: self.market.market.clone(), + open_orders: self.market.open_orders.clone(), + request_queue: self.market.request_queue.clone(), + event_queue: self.market.event_queue.clone(), + market_bids: self.market.bids.clone(), + market_asks: self.market.asks.clone(), + order_payer_token_account: self.market.order_payer_token_account.clone(), + open_orders_authority: self.authority.clone(), + coin_vault: self.market.coin_vault.clone(), + pc_vault: self.market.pc_vault.clone(), + token_program: self.token_program.clone(), + rent: self.rent.clone(), + }; + let mut ctx = CpiContext::new(self.dex_program.clone(), dex_accs); + if let Some(referral) = referral { + ctx = ctx.with_remaining_accounts(vec![referral]); + } + dex::new_order_v3( + ctx, + side.into(), + NonZeroU64::new(limit_price).unwrap(), + NonZeroU64::new(max_coin_qty).unwrap(), + NonZeroU64::new(max_native_pc_qty).unwrap(), + SelfTradeBehavior::DecrementTake, + OrderType::ImmediateOrCancel, + client_order_id, + limit, + ) + } + + fn settle(&self, referral: Option>) -> ProgramResult { + let settle_accs = dex::SettleFunds { + market: self.market.market.clone(), + open_orders: self.market.open_orders.clone(), + open_orders_authority: self.authority.clone(), + coin_vault: self.market.coin_vault.clone(), + pc_vault: self.market.pc_vault.clone(), + coin_wallet: self.market.coin_wallet.clone(), + pc_wallet: self.pc_wallet.clone(), + vault_signer: self.market.vault_signer.clone(), + token_program: self.token_program.clone(), + }; + let mut ctx = CpiContext::new(self.dex_program.clone(), settle_accs); + if let Some(referral) = referral { + ctx = ctx.with_remaining_accounts(vec![referral]); + } + dex::settle_funds(ctx) + } +} + +// Returns the amount of lots for the base currency of a trade with `size`. +fn coin_lots(market: &MarketState, size: u64) -> u64 { + size.checked_div(market.coin_lot_size).unwrap() +} + +// Market accounts are the accounts used to place orders against the dex minus +// common accounts, i.e., program ids, sysvars, and the `pc_wallet`. +#[derive(Accounts, Clone)] +pub struct MarketAccounts<'info> { + #[account(mut)] + market: AccountInfo<'info>, + #[account(mut)] + open_orders: AccountInfo<'info>, + #[account(mut)] + request_queue: AccountInfo<'info>, + #[account(mut)] + event_queue: AccountInfo<'info>, + #[account(mut)] + bids: AccountInfo<'info>, + #[account(mut)] + asks: AccountInfo<'info>, + // The `spl_token::Account` that funds will be taken from, i.e., transferred + // from the user into the market's vault. + // + // For bids, this is the base currency. For asks, the quote. + #[account(mut)] + order_payer_token_account: AccountInfo<'info>, + // Also known as the "base" currency. For a given A/B market, + // this is the vault for the A mint. + #[account(mut)] + coin_vault: AccountInfo<'info>, + // Also known as the "quote" currency. For a given A/B market, + // this is the vault for the B mint. + #[account(mut)] + pc_vault: AccountInfo<'info>, + // PDA owner of the DEX's token accounts for base + quote currencies. + vault_signer: AccountInfo<'info>, + // User wallets. + #[account(mut)] + coin_wallet: AccountInfo<'info>, +} + +#[derive(AnchorSerialize, AnchorDeserialize)] +pub enum Side { + Bid, + Ask, +} + +impl From for SerumSide { + fn from(side: Side) -> SerumSide { + match side { + Side::Bid => SerumSide::Bid, + Side::Ask => SerumSide::Ask, + } + } +} + +// Access control modifiers. + +fn is_valid_swap(ctx: &Context) -> Result<()> { + _is_valid_swap(&ctx.accounts.market.coin_wallet, &ctx.accounts.pc_wallet) +} + +fn is_valid_swap_transitive(ctx: &Context) -> Result<()> { + _is_valid_swap(&ctx.accounts.from.coin_wallet, &ctx.accounts.to.coin_wallet) +} + +// Validates the tokens being swapped are of different mints. +fn _is_valid_swap<'info>(from: &AccountInfo<'info>, to: &AccountInfo<'info>) -> Result<()> { + let from_token_mint = token::accessor::mint(from)?; + let to_token_mint = token::accessor::mint(to)?; + if from_token_mint == to_token_mint { + return Err(ErrorCode::SwapTokensCannotMatch.into()); + } + Ok(()) +} + +// Event emitted when a swap occurs for two base currencies on two different +// markets (quoted in the same token). +#[event] +pub struct DidSwap { + // User given (max) amount to swap. + pub given_amount: u64, + // The minimum amount of the *to* token expected to be received from + // executing the swap. + pub min_expected_swap_amount: u64, + // Amount of the `from` token sold. + pub from_amount: u64, + // Amount of the `to` token purchased. + pub to_amount: u64, + // Amount of the quote currency accumulated from the swap. + pub spill_amount: u64, + // Mint sold. + pub from_mint: Pubkey, + // Mint purchased. + pub to_mint: Pubkey, + // Mint of the token used as the quote currency in the two markets used + // for swapping. + pub quote_mint: Pubkey, + // User that signed the transaction. + pub authority: Pubkey, +} + +#[error] +pub enum ErrorCode { + #[msg("The tokens being swapped must have different mints")] + SwapTokensCannotMatch, + #[msg("Slippage tolerance exceeded")] + SlippageExceeded, +} diff --git a/examples/swap/tests/swap.js b/examples/swap/tests/swap.js new file mode 100644 index 00000000..6b906863 --- /dev/null +++ b/examples/swap/tests/swap.js @@ -0,0 +1,311 @@ +const assert = require("assert"); +const anchor = require("@project-serum/anchor"); +const BN = anchor.BN; +const OpenOrders = require("@project-serum/serum").OpenOrders; +const TOKEN_PROGRAM_ID = require("@solana/spl-token").TOKEN_PROGRAM_ID; +const serumCmn = require("@project-serum/common"); +const utils = require("./utils"); + +// Taker fee rate (bps). +const TAKER_FEE = 0.0022; + +describe("swap", () => { + // Configure the client to use the local cluster. + anchor.setProvider(anchor.Provider.env()); + + // Swap program client. + const program = anchor.workspace.Swap; + + // Accounts used to setup the orderbook. + let ORDERBOOK_ENV, + // Accounts used for A -> USDC swap transactions. + SWAP_A_USDC_ACCOUNTS, + // Accounts used for USDC -> A swap transactions. + SWAP_USDC_A_ACCOUNTS, + // Serum DEX vault PDA for market A/USDC. + marketAVaultSigner, + // Serum DEX vault PDA for market B/USDC. + marketBVaultSigner; + + // Open orders accounts on the two markets for the provider. + const openOrdersA = new anchor.web3.Account(); + const openOrdersB = new anchor.web3.Account(); + + it("BOILERPLATE: Sets up two markets with resting orders", async () => { + ORDERBOOK_ENV = await utils.setupTwoMarkets({ + provider: program.provider, + }); + }); + + it("BOILERPLATE: Sets up reusable accounts", async () => { + const marketA = ORDERBOOK_ENV.marketA; + const marketB = ORDERBOOK_ENV.marketB; + + const [vaultSignerA] = await utils.getVaultOwnerAndNonce( + marketA._decoded.ownAddress + ); + const [vaultSignerB] = await utils.getVaultOwnerAndNonce( + marketB._decoded.ownAddress + ); + marketAVaultSigner = vaultSignerA; + marketBVaultSigner = vaultSignerB; + + SWAP_USDC_A_ACCOUNTS = { + market: { + market: marketA._decoded.ownAddress, + requestQueue: marketA._decoded.requestQueue, + eventQueue: marketA._decoded.eventQueue, + bids: marketA._decoded.bids, + asks: marketA._decoded.asks, + coinVault: marketA._decoded.baseVault, + pcVault: marketA._decoded.quoteVault, + vaultSigner: marketAVaultSigner, + // User params. + openOrders: openOrdersA.publicKey, + orderPayerTokenAccount: ORDERBOOK_ENV.godUsdc, + coinWallet: ORDERBOOK_ENV.godA, + }, + pcWallet: ORDERBOOK_ENV.godUsdc, + authority: program.provider.wallet.publicKey, + dexProgram: utils.DEX_PID, + tokenProgram: TOKEN_PROGRAM_ID, + rent: anchor.web3.SYSVAR_RENT_PUBKEY, + }; + SWAP_A_USDC_ACCOUNTS = { + ...SWAP_USDC_A_ACCOUNTS, + market: { + ...SWAP_USDC_A_ACCOUNTS.market, + orderPayerTokenAccount: ORDERBOOK_ENV.godA, + }, + }; + }); + + it("Swaps from USDC to Token A", async () => { + const marketA = ORDERBOOK_ENV.marketA; + + // Swap exactly enough USDC to get 1.2 A tokens (best offer price is 6.041 USDC). + const expectedResultantAmount = 7.2; + const bestOfferPrice = 6.041; + const amountToSpend = expectedResultantAmount * bestOfferPrice; + const swapAmount = new BN((amountToSpend / (1 - TAKER_FEE)) * 10 ** 6); + + const [tokenAChange, usdcChange] = await withBalanceChange( + program.provider, + [ORDERBOOK_ENV.godA, ORDERBOOK_ENV.godUsdc], + async () => { + await program.rpc.swap(Side.Bid, swapAmount, new BN(1.0), { + accounts: SWAP_USDC_A_ACCOUNTS, + instructions: [ + // First order to this market so one must create the open orders account. + await OpenOrders.makeCreateAccountTransaction( + program.provider.connection, + marketA._decoded.ownAddress, + program.provider.wallet.publicKey, + openOrdersA.publicKey, + utils.DEX_PID + ), + // Might as well create the second open orders account while we're here. + // In prod, this should actually be done within the same tx as an + // order to market B. + await OpenOrders.makeCreateAccountTransaction( + program.provider.connection, + ORDERBOOK_ENV.marketB._decoded.ownAddress, + program.provider.wallet.publicKey, + openOrdersB.publicKey, + utils.DEX_PID + ), + ], + signers: [openOrdersA, openOrdersB], + }); + } + ); + + assert.ok(tokenAChange === expectedResultantAmount); + assert.ok(usdcChange === -swapAmount.toNumber() / 10 ** 6); + }); + + it("Swaps from Token A to USDC", async () => { + const marketA = ORDERBOOK_ENV.marketA; + + // Swap out A tokens for USDC. + const swapAmount = 8.1; + const bestBidPrice = 6.004; + const amountToFill = swapAmount * bestBidPrice; + const takerFee = 0.0022; + const resultantAmount = new BN(amountToFill * (1 - TAKER_FEE) * 10 ** 6); + + const [tokenAChange, usdcChange] = await withBalanceChange( + program.provider, + [ORDERBOOK_ENV.godA, ORDERBOOK_ENV.godUsdc], + async () => { + await program.rpc.swap( + Side.Ask, + new BN(swapAmount * 10 ** 6), + new BN(swapAmount), + { + accounts: SWAP_A_USDC_ACCOUNTS, + } + ); + } + ); + + assert.ok(tokenAChange === -swapAmount); + assert.ok(usdcChange === resultantAmount.toNumber() / 10 ** 6); + }); + + it("Swaps from Token A to Token B", async () => { + const marketA = ORDERBOOK_ENV.marketA; + const marketB = ORDERBOOK_ENV.marketB; + const swapAmount = 10; + const [tokenAChange, tokenBChange, usdcChange] = await withBalanceChange( + program.provider, + [ORDERBOOK_ENV.godA, ORDERBOOK_ENV.godB, ORDERBOOK_ENV.godUsdc], + async () => { + // Perform the actual swap. + await program.rpc.swapTransitive( + new BN(swapAmount * 10 ** 6), + new BN(swapAmount - 1), + { + accounts: { + from: { + market: marketA._decoded.ownAddress, + requestQueue: marketA._decoded.requestQueue, + eventQueue: marketA._decoded.eventQueue, + bids: marketA._decoded.bids, + asks: marketA._decoded.asks, + coinVault: marketA._decoded.baseVault, + pcVault: marketA._decoded.quoteVault, + vaultSigner: marketAVaultSigner, + // User params. + openOrders: openOrdersA.publicKey, + // Swapping from A -> USDC. + orderPayerTokenAccount: ORDERBOOK_ENV.godA, + coinWallet: ORDERBOOK_ENV.godA, + }, + to: { + market: marketB._decoded.ownAddress, + requestQueue: marketB._decoded.requestQueue, + eventQueue: marketB._decoded.eventQueue, + bids: marketB._decoded.bids, + asks: marketB._decoded.asks, + coinVault: marketB._decoded.baseVault, + pcVault: marketB._decoded.quoteVault, + vaultSigner: marketBVaultSigner, + // User params. + openOrders: openOrdersB.publicKey, + // Swapping from USDC -> B. + orderPayerTokenAccount: ORDERBOOK_ENV.godUsdc, + coinWallet: ORDERBOOK_ENV.godB, + }, + pcWallet: ORDERBOOK_ENV.godUsdc, + authority: program.provider.wallet.publicKey, + dexProgram: utils.DEX_PID, + tokenProgram: TOKEN_PROGRAM_ID, + rent: anchor.web3.SYSVAR_RENT_PUBKEY, + }, + } + ); + } + ); + + assert.ok(tokenAChange === -swapAmount); + // TODO: calculate this dynamically from the swap amount. + assert.ok(tokenBChange === 9.8); + assert.ok(usdcChange === 0); + }); + + it("Swaps from Token B to Token A", async () => { + const marketA = ORDERBOOK_ENV.marketA; + const marketB = ORDERBOOK_ENV.marketB; + const swapAmount = 23; + const [tokenAChange, tokenBChange, usdcChange] = await withBalanceChange( + program.provider, + [ORDERBOOK_ENV.godA, ORDERBOOK_ENV.godB, ORDERBOOK_ENV.godUsdc], + async () => { + // Perform the actual swap. + await program.rpc.swapTransitive( + new BN(swapAmount * 10 ** 6), + new BN(swapAmount - 1), + { + accounts: { + from: { + market: marketB._decoded.ownAddress, + requestQueue: marketB._decoded.requestQueue, + eventQueue: marketB._decoded.eventQueue, + bids: marketB._decoded.bids, + asks: marketB._decoded.asks, + coinVault: marketB._decoded.baseVault, + pcVault: marketB._decoded.quoteVault, + vaultSigner: marketBVaultSigner, + // User params. + openOrders: openOrdersB.publicKey, + // Swapping from B -> USDC. + orderPayerTokenAccount: ORDERBOOK_ENV.godB, + coinWallet: ORDERBOOK_ENV.godB, + }, + to: { + market: marketA._decoded.ownAddress, + requestQueue: marketA._decoded.requestQueue, + eventQueue: marketA._decoded.eventQueue, + bids: marketA._decoded.bids, + asks: marketA._decoded.asks, + coinVault: marketA._decoded.baseVault, + pcVault: marketA._decoded.quoteVault, + vaultSigner: marketAVaultSigner, + // User params. + openOrders: openOrdersA.publicKey, + // Swapping from USDC -> A. + orderPayerTokenAccount: ORDERBOOK_ENV.godUsdc, + coinWallet: ORDERBOOK_ENV.godA, + }, + pcWallet: ORDERBOOK_ENV.godUsdc, + authority: program.provider.wallet.publicKey, + dexProgram: utils.DEX_PID, + tokenProgram: TOKEN_PROGRAM_ID, + rent: anchor.web3.SYSVAR_RENT_PUBKEY, + }, + } + ); + } + ); + + // TODO: calculate this dynamically from the swap amount. + assert.ok(tokenAChange === 22.6); + assert.ok(tokenBChange === -swapAmount); + assert.ok(usdcChange === 0); + }); +}); + +// Side rust enum used for the program's RPC API. +const Side = { + Bid: { bid: {} }, + Ask: { ask: {} }, +}; + +// Executes a closure. Returning the change in balances from before and after +// its execution. +async function withBalanceChange(provider, addrs, fn) { + const beforeBalances = []; + for (let k = 0; k < addrs.length; k += 1) { + beforeBalances.push( + (await serumCmn.getTokenAccount(provider, addrs[k])).amount + ); + } + + await fn(); + + const afterBalances = []; + for (let k = 0; k < addrs.length; k += 1) { + afterBalances.push( + (await serumCmn.getTokenAccount(provider, addrs[k])).amount + ); + } + + const deltas = []; + for (let k = 0; k < addrs.length; k += 1) { + deltas.push( + (afterBalances[k].toNumber() - beforeBalances[k].toNumber()) / 10 ** 6 + ); + } + return deltas; +} diff --git a/examples/swap/tests/utils/index.js b/examples/swap/tests/utils/index.js new file mode 100644 index 00000000..62744438 --- /dev/null +++ b/examples/swap/tests/utils/index.js @@ -0,0 +1,510 @@ +// Boilerplate utils to bootstrap an orderbook for testing on a localnet. +// not super relevant to the point of the example, though may be useful to +// include into your own workspace for testing. +// +// TODO: Modernize all these apis. This is all quite clunky. + +const Token = require("@solana/spl-token").Token; +const TOKEN_PROGRAM_ID = require("@solana/spl-token").TOKEN_PROGRAM_ID; +const TokenInstructions = require("@project-serum/serum").TokenInstructions; +const Market = require("@project-serum/serum").Market; +const DexInstructions = require("@project-serum/serum").DexInstructions; +const web3 = require("@project-serum/anchor").web3; +const Connection = web3.Connection; +const BN = require("@project-serum/anchor").BN; +const serumCmn = require("@project-serum/common"); +const Account = web3.Account; +const Transaction = web3.Transaction; +const PublicKey = web3.PublicKey; +const SystemProgram = web3.SystemProgram; +const DEX_PID = new PublicKey("9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin"); + +async function setupTwoMarkets({ provider }) { + // Setup mints with initial tokens owned by the provider. + const decimals = 6; + const [MINT_A, GOD_A] = await serumCmn.createMintAndVault( + provider, + new BN(1000000000000000), + undefined, + decimals + ); + const [MINT_B, GOD_B] = await serumCmn.createMintAndVault( + provider, + new BN(1000000000000000), + undefined, + decimals + ); + const [USDC, GOD_USDC] = await serumCmn.createMintAndVault( + provider, + new BN(1000000000000000), + undefined, + decimals + ); + + // Create a funded account to act as market maker. + const amount = 100000 * 10 ** decimals; + const marketMaker = await fundAccount({ + provider, + mints: [ + { god: GOD_A, mint: MINT_A, amount, decimals }, + { god: GOD_B, mint: MINT_B, amount, decimals }, + { god: GOD_USDC, mint: USDC, amount, decimals }, + ], + }); + + // Setup A/USDC and B/USDC markets with resting orders. + const asks = [ + [6.041, 7.8], + [6.051, 72.3], + [6.055, 5.4], + [6.067, 15.7], + [6.077, 390.0], + [6.09, 24.0], + [6.11, 36.3], + [6.133, 300.0], + [6.167, 687.8], + ]; + const bids = [ + [6.004, 8.5], + [5.995, 12.9], + [5.987, 6.2], + [5.978, 15.3], + [5.965, 82.8], + [5.961, 25.4], + ]; + + MARKET_A_USDC = await setupMarket({ + baseMint: MINT_A, + quoteMint: USDC, + marketMaker: { + account: marketMaker.account, + baseToken: marketMaker.tokens[MINT_A.toString()], + quoteToken: marketMaker.tokens[USDC.toString()], + }, + bids, + asks, + provider, + }); + MARKET_B_USDC = await setupMarket({ + baseMint: MINT_B, + quoteMint: USDC, + marketMaker: { + account: marketMaker.account, + baseToken: marketMaker.tokens[MINT_B.toString()], + quoteToken: marketMaker.tokens[USDC.toString()], + }, + bids, + asks, + provider, + }); + + return { + marketA: MARKET_A_USDC, + marketB: MARKET_B_USDC, + marketMaker, + mintA: MINT_A, + mintB: MINT_B, + usdc: USDC, + godA: GOD_A, + godB: GOD_B, + godUsdc: GOD_USDC, + }; +} + +// Creates everything needed for an orderbook to be running +// +// * Mints for both the base and quote currencies. +// * Lists the market. +// * Provides resting orders on the market. +// +// Returns a client that can be used to interact with the market +// (and some other data, e.g., the mints and market maker account). +async function initOrderbook({ provider, bids, asks }) { + if (!bids || !asks) { + asks = [ + [6.041, 7.8], + [6.051, 72.3], + [6.055, 5.4], + [6.067, 15.7], + [6.077, 390.0], + [6.09, 24.0], + [6.11, 36.3], + [6.133, 300.0], + [6.167, 687.8], + ]; + bids = [ + [6.004, 8.5], + [5.995, 12.9], + [5.987, 6.2], + [5.978, 15.3], + [5.965, 82.8], + [5.961, 25.4], + ]; + } + // Create base and quote currency mints. + const decimals = 6; + const [MINT_A, GOD_A] = await serumCmn.createMintAndVault( + provider, + new BN(1000000000000000), + undefined, + decimals + ); + const [USDC, GOD_USDC] = await serumCmn.createMintAndVault( + provider, + new BN(1000000000000000), + undefined, + decimals + ); + + // Create a funded account to act as market maker. + const amount = 100000 * 10 ** decimals; + const marketMaker = await fundAccount({ + provider, + mints: [ + { god: GOD_A, mint: MINT_A, amount, decimals }, + { god: GOD_USDC, mint: USDC, amount, decimals }, + ], + }); + + marketClient = await setupMarket({ + baseMint: MINT_A, + quoteMint: USDC, + marketMaker: { + account: marketMaker.account, + baseToken: marketMaker.tokens[MINT_A.toString()], + quoteToken: marketMaker.tokens[USDC.toString()], + }, + bids, + asks, + provider, + }); + + return { + marketClient, + baseMint: MINT_A, + quoteMint: USDC, + marketMaker, + }; +} + +async function fundAccount({ provider, mints }) { + const MARKET_MAKER = new Account(); + + const marketMaker = { + tokens: {}, + account: MARKET_MAKER, + }; + + // Transfer lamports to market maker. + await provider.send( + (() => { + const tx = new Transaction(); + tx.add( + SystemProgram.transfer({ + fromPubkey: provider.wallet.publicKey, + toPubkey: MARKET_MAKER.publicKey, + lamports: 100000000000, + }) + ); + return tx; + })() + ); + + // Transfer SPL tokens to the market maker. + for (let k = 0; k < mints.length; k += 1) { + const { mint, god, amount, decimals } = mints[k]; + let MINT_A = mint; + let GOD_A = god; + // Setup token accounts owned by the market maker. + const mintAClient = new Token( + provider.connection, + MINT_A, + TOKEN_PROGRAM_ID, + provider.wallet.payer // node only + ); + const marketMakerTokenA = await mintAClient.createAccount( + MARKET_MAKER.publicKey + ); + + await provider.send( + (() => { + const tx = new Transaction(); + tx.add( + Token.createTransferCheckedInstruction( + TOKEN_PROGRAM_ID, + GOD_A, + MINT_A, + marketMakerTokenA, + provider.wallet.publicKey, + [], + amount, + decimals + ) + ); + return tx; + })() + ); + + marketMaker.tokens[mint.toString()] = marketMakerTokenA; + } + + return marketMaker; +} + +async function setupMarket({ + provider, + marketMaker, + baseMint, + quoteMint, + bids, + asks, +}) { + const marketAPublicKey = await listMarket({ + connection: provider.connection, + wallet: provider.wallet, + baseMint: baseMint, + quoteMint: quoteMint, + baseLotSize: 100000, + quoteLotSize: 100, + dexProgramId: DEX_PID, + feeRateBps: 0, + }); + const MARKET_A_USDC = await Market.load( + provider.connection, + marketAPublicKey, + { commitment: "recent" }, + DEX_PID + ); + for (let k = 0; k < asks.length; k += 1) { + let ask = asks[k]; + const { + transaction, + signers, + } = await MARKET_A_USDC.makePlaceOrderTransaction(provider.connection, { + owner: marketMaker.account, + payer: marketMaker.baseToken, + side: "sell", + price: ask[0], + size: ask[1], + orderType: "postOnly", + clientId: undefined, + openOrdersAddressKey: undefined, + openOrdersAccount: undefined, + feeDiscountPubkey: null, + selfTradeBehavior: "abortTransaction", + }); + await provider.send(transaction, signers.concat(marketMaker.account)); + } + + for (let k = 0; k < bids.length; k += 1) { + let bid = bids[k]; + const { + transaction, + signers, + } = await MARKET_A_USDC.makePlaceOrderTransaction(provider.connection, { + owner: marketMaker.account, + payer: marketMaker.quoteToken, + side: "buy", + price: bid[0], + size: bid[1], + orderType: "postOnly", + clientId: undefined, + openOrdersAddressKey: undefined, + openOrdersAccount: undefined, + feeDiscountPubkey: null, + selfTradeBehavior: "abortTransaction", + }); + await provider.send(transaction, signers.concat(marketMaker.account)); + } + + return MARKET_A_USDC; +} + +async function listMarket({ + connection, + wallet, + baseMint, + quoteMint, + baseLotSize, + quoteLotSize, + dexProgramId, + feeRateBps, +}) { + const market = new Account(); + const requestQueue = new Account(); + const eventQueue = new Account(); + const bids = new Account(); + const asks = new Account(); + const baseVault = new Account(); + const quoteVault = new Account(); + const quoteDustThreshold = new BN(100); + + const [vaultOwner, vaultSignerNonce] = await getVaultOwnerAndNonce( + market.publicKey, + dexProgramId + ); + + const tx1 = new Transaction(); + tx1.add( + SystemProgram.createAccount({ + fromPubkey: wallet.publicKey, + newAccountPubkey: baseVault.publicKey, + lamports: await connection.getMinimumBalanceForRentExemption(165), + space: 165, + programId: TOKEN_PROGRAM_ID, + }), + SystemProgram.createAccount({ + fromPubkey: wallet.publicKey, + newAccountPubkey: quoteVault.publicKey, + lamports: await connection.getMinimumBalanceForRentExemption(165), + space: 165, + programId: TOKEN_PROGRAM_ID, + }), + TokenInstructions.initializeAccount({ + account: baseVault.publicKey, + mint: baseMint, + owner: vaultOwner, + }), + TokenInstructions.initializeAccount({ + account: quoteVault.publicKey, + mint: quoteMint, + owner: vaultOwner, + }) + ); + + const tx2 = new Transaction(); + tx2.add( + SystemProgram.createAccount({ + fromPubkey: wallet.publicKey, + newAccountPubkey: market.publicKey, + lamports: await connection.getMinimumBalanceForRentExemption( + Market.getLayout(dexProgramId).span + ), + space: Market.getLayout(dexProgramId).span, + programId: dexProgramId, + }), + SystemProgram.createAccount({ + fromPubkey: wallet.publicKey, + newAccountPubkey: requestQueue.publicKey, + lamports: await connection.getMinimumBalanceForRentExemption(5120 + 12), + space: 5120 + 12, + programId: dexProgramId, + }), + SystemProgram.createAccount({ + fromPubkey: wallet.publicKey, + newAccountPubkey: eventQueue.publicKey, + lamports: await connection.getMinimumBalanceForRentExemption(262144 + 12), + space: 262144 + 12, + programId: dexProgramId, + }), + SystemProgram.createAccount({ + fromPubkey: wallet.publicKey, + newAccountPubkey: bids.publicKey, + lamports: await connection.getMinimumBalanceForRentExemption(65536 + 12), + space: 65536 + 12, + programId: dexProgramId, + }), + SystemProgram.createAccount({ + fromPubkey: wallet.publicKey, + newAccountPubkey: asks.publicKey, + lamports: await connection.getMinimumBalanceForRentExemption(65536 + 12), + space: 65536 + 12, + programId: dexProgramId, + }), + DexInstructions.initializeMarket({ + market: market.publicKey, + requestQueue: requestQueue.publicKey, + eventQueue: eventQueue.publicKey, + bids: bids.publicKey, + asks: asks.publicKey, + baseVault: baseVault.publicKey, + quoteVault: quoteVault.publicKey, + baseMint, + quoteMint, + baseLotSize: new BN(baseLotSize), + quoteLotSize: new BN(quoteLotSize), + feeRateBps, + vaultSignerNonce, + quoteDustThreshold, + programId: dexProgramId, + }) + ); + + const signedTransactions = await signTransactions({ + transactionsAndSigners: [ + { transaction: tx1, signers: [baseVault, quoteVault] }, + { + transaction: tx2, + signers: [market, requestQueue, eventQueue, bids, asks], + }, + ], + wallet, + connection, + }); + for (let signedTransaction of signedTransactions) { + await sendAndConfirmRawTransaction( + connection, + signedTransaction.serialize() + ); + } + const acc = await connection.getAccountInfo(market.publicKey); + + return market.publicKey; +} + +async function signTransactions({ + transactionsAndSigners, + wallet, + connection, +}) { + const blockhash = (await connection.getRecentBlockhash("max")).blockhash; + transactionsAndSigners.forEach(({ transaction, signers = [] }) => { + transaction.recentBlockhash = blockhash; + transaction.setSigners( + wallet.publicKey, + ...signers.map((s) => s.publicKey) + ); + if (signers?.length > 0) { + transaction.partialSign(...signers); + } + }); + return await wallet.signAllTransactions( + transactionsAndSigners.map(({ transaction }) => transaction) + ); +} + +async function sendAndConfirmRawTransaction( + connection, + raw, + commitment = "recent" +) { + let tx = await connection.sendRawTransaction(raw, { + skipPreflight: true, + }); + return await connection.confirmTransaction(tx, commitment); +} + +async function getVaultOwnerAndNonce(marketPublicKey, dexProgramId = DEX_PID) { + const nonce = new BN(0); + while (nonce.toNumber() < 255) { + try { + const vaultOwner = await PublicKey.createProgramAddress( + [marketPublicKey.toBuffer(), nonce.toArrayLike(Buffer, "le", 8)], + dexProgramId + ); + return [vaultOwner, nonce]; + } catch (e) { + nonce.iaddn(1); + } + } + throw new Error("Unable to find nonce"); +} + +module.exports = { + fundAccount, + setupMarket, + initOrderbook, + setupTwoMarkets, + DEX_PID, + getVaultOwnerAndNonce, +}; diff --git a/lang/src/context.rs b/lang/src/context.rs index d7075dff..c2491d54 100644 --- a/lang/src/context.rs +++ b/lang/src/context.rs @@ -34,6 +34,7 @@ where T: ToAccountMetas + ToAccountInfos<'info>, { pub accounts: T, + pub remaining_accounts: Vec>, pub program: AccountInfo<'info>, pub signer_seeds: &'a [&'b [&'c [u8]]], } @@ -46,6 +47,7 @@ where Self { accounts, program, + remaining_accounts: Vec::new(), signer_seeds: &[], } } @@ -59,6 +61,7 @@ where accounts, program, signer_seeds, + remaining_accounts: Vec::new(), } } @@ -66,6 +69,20 @@ where self.signer_seeds = signer_seeds; self } + + pub fn with_remaining_accounts(mut self, ra: Vec>) -> Self { + self.remaining_accounts = ra; + self + } +} + +impl<'info, T: Accounts<'info>> ToAccountInfos<'info> for CpiContext<'_, '_, '_, 'info, T> { + fn to_account_infos(&self) -> Vec> { + let mut infos = self.accounts.to_account_infos(); + infos.extend_from_slice(&self.remaining_accounts); + infos.push(self.program.clone()); + infos + } } /// Context specifying non-argument inputs for cross-program-invocations @@ -83,6 +100,7 @@ impl<'a, 'b, 'c, 'info, T: Accounts<'info>> CpiStateContext<'a, 'b, 'c, 'info, T accounts, program, signer_seeds: &[], + remaining_accounts: Vec::new(), }, } } @@ -99,6 +117,7 @@ impl<'a, 'b, 'c, 'info, T: Accounts<'info>> CpiStateContext<'a, 'b, 'c, 'info, T accounts, program, signer_seeds, + remaining_accounts: Vec::new(), }, } } diff --git a/lang/syn/src/codegen/program.rs b/lang/syn/src/codegen/program.rs index e9ce0203..944a001a 100644 --- a/lang/syn/src/codegen/program.rs +++ b/lang/syn/src/codegen/program.rs @@ -82,6 +82,10 @@ pub fn generate(program: Program) -> proc_macro2::TokenStream { }; dispatch(program_id, accounts, sighash, ix_data) + .map_err(|e| { + anchor_lang::solana_program::msg!(&e.to_string()); + e + }) } #dispatch @@ -355,9 +359,12 @@ pub fn generate_non_inlined_handlers(program: &Program) -> proc_macro2::TokenStr accounts: &mut anchor_lang::idl::IdlCreateAccounts, data_len: u64, ) -> ProgramResult { + if program_id != accounts.program.key { + return Err(anchor_lang::solana_program::program_error::ProgramError::Custom(98)); // todo proper error + } // Create the IDL's account. let from = accounts.from.key; - let (base, nonce) = Pubkey::find_program_address(&[], accounts.program.key); + let (base, nonce) = Pubkey::find_program_address(&[], program_id); let seed = anchor_lang::idl::IdlAccount::seed(); let owner = accounts.program.key; let to = Pubkey::create_with_seed(&base, seed, owner).unwrap(); @@ -513,10 +520,7 @@ pub fn generate_non_inlined_handlers(program: &Program) -> proc_macro2::TokenStr remaining_accounts, ), #(#ctor_untyped_args),* - ).map_err(|e| { - anchor_lang::solana_program::msg!(&e.to_string()); - e - })?; + )?; } // Exit routines. @@ -546,10 +550,7 @@ pub fn generate_non_inlined_handlers(program: &Program) -> proc_macro2::TokenStr remaining_accounts, ), #(#ctor_untyped_args),* - ).map_err(|e| { - anchor_lang::solana_program::msg!(&e.to_string()); - e - })?; + )?; // Create the solana account for the ctor data. let from = ctor_accounts.from.key; @@ -647,10 +648,7 @@ pub fn generate_non_inlined_handlers(program: &Program) -> proc_macro2::TokenStr state.#ix_name( ctx, #(#ix_arg_names),* - ).map_err(|e| { - anchor_lang::solana_program::msg!(&e.to_string()); - e - })?; + )?; } // Serialize the state and save it to storage. accounts.exit(program_id)?; @@ -693,10 +691,7 @@ pub fn generate_non_inlined_handlers(program: &Program) -> proc_macro2::TokenStr state.#ix_name( ctx, #(#ix_arg_names),* - ).map_err(|e| { - anchor_lang::solana_program::msg!(&e.to_string()); - e - })?; + )?; // Serialize the state and save it to storage. accounts.exit(program_id)?; @@ -781,10 +776,7 @@ pub fn generate_non_inlined_handlers(program: &Program) -> proc_macro2::TokenStr state.#ix_name( ctx, #(#ix_arg_names),* - ).map_err(|e| { - anchor_lang::solana_program::msg!(&e.to_string()); - e - })?; + )?; // Serialize the state and save it to storage. accounts.exit(program_id)?; @@ -813,10 +805,7 @@ pub fn generate_non_inlined_handlers(program: &Program) -> proc_macro2::TokenStr #state_name::#ix_name( Context::new(program_id, &mut accounts, remaining_accounts), #(#ix_arg_names),* - ).map_err(|e| { - anchor_lang::solana_program::msg!(&e.to_string()); - e - })?; + )?; accounts.exit(program_id) } } @@ -849,10 +838,7 @@ pub fn generate_non_inlined_handlers(program: &Program) -> proc_macro2::TokenStr #program_name::#ix_name( Context::new(program_id, &mut accounts, remaining_accounts), #(#ix_arg_names),* - ).map_err(|e| { - anchor_lang::solana_program::msg!(&e.to_string()); - e - })?; + )?; accounts.exit(program_id) } } diff --git a/spl/Cargo.toml b/spl/Cargo.toml index dbce6db3..75f324ab 100644 --- a/spl/Cargo.toml +++ b/spl/Cargo.toml @@ -8,5 +8,7 @@ description = "CPI clients for SPL programs" [dependencies] anchor-lang = { path = "../lang", version = "0.4.4", features = ["derive"] } -spl-token = { version = "3.0.1", features = ["no-entrypoint"] } +lazy_static = "1.4.0" +serum_dex = { git = "https://github.com/project-serum/serum-dex", features = ["no-entrypoint"] } solana-program = "1.6.6" +spl-token = { version = "3.0.1", features = ["no-entrypoint"] } diff --git a/spl/src/dex.rs b/spl/src/dex.rs new file mode 100644 index 00000000..022299d3 --- /dev/null +++ b/spl/src/dex.rs @@ -0,0 +1,114 @@ +use anchor_lang::solana_program::account_info::AccountInfo; +use anchor_lang::solana_program::entrypoint::ProgramResult; +use anchor_lang::{Accounts, CpiContext, ToAccountInfos}; +use serum_dex::instruction::SelfTradeBehavior; +use serum_dex::matching::{OrderType, Side}; +use std::num::NonZeroU64; + +pub use serum_dex; + +anchor_lang::solana_program::declare_id!("9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin"); + +pub fn new_order_v3<'info>( + ctx: CpiContext<'_, '_, '_, 'info, NewOrderV3<'info>>, + side: Side, + limit_price: NonZeroU64, + max_coin_qty: NonZeroU64, + max_native_pc_qty_including_fees: NonZeroU64, + self_trade_behavior: SelfTradeBehavior, + order_type: OrderType, + client_order_id: u64, + limit: u16, +) -> ProgramResult { + let referral = ctx.remaining_accounts.iter().next(); + let ix = serum_dex::instruction::new_order( + ctx.accounts.market.key, + ctx.accounts.open_orders.key, + ctx.accounts.request_queue.key, + ctx.accounts.event_queue.key, + ctx.accounts.market_bids.key, + ctx.accounts.market_asks.key, + ctx.accounts.order_payer_token_account.key, + ctx.accounts.open_orders_authority.key, + ctx.accounts.coin_vault.key, + ctx.accounts.pc_vault.key, + ctx.accounts.token_program.key, + ctx.accounts.rent.key, + referral.map(|r| r.key), + &ID, + side, + limit_price, + max_coin_qty, + order_type, + client_order_id, + self_trade_behavior, + limit, + max_native_pc_qty_including_fees, + )?; + solana_program::program::invoke_signed( + &ix, + &ToAccountInfos::to_account_infos(&ctx), + ctx.signer_seeds, + )?; + Ok(()) +} + +pub fn settle_funds<'info>( + ctx: CpiContext<'_, '_, '_, 'info, SettleFunds<'info>>, +) -> ProgramResult { + let referral = ctx.remaining_accounts.iter().next(); + let ix = serum_dex::instruction::settle_funds( + &ID, + ctx.accounts.market.key, + ctx.accounts.token_program.key, + ctx.accounts.open_orders.key, + ctx.accounts.open_orders_authority.key, + ctx.accounts.coin_vault.key, + ctx.accounts.coin_wallet.key, + ctx.accounts.pc_vault.key, + ctx.accounts.pc_wallet.key, + referral.map(|r| r.key), + ctx.accounts.vault_signer.key, + )?; + solana_program::program::invoke_signed( + &ix, + &ToAccountInfos::to_account_infos(&ctx), + ctx.signer_seeds, + )?; + Ok(()) +} + +#[derive(Accounts)] +pub struct NewOrderV3<'info> { + pub market: AccountInfo<'info>, + pub open_orders: AccountInfo<'info>, + pub request_queue: AccountInfo<'info>, + pub event_queue: AccountInfo<'info>, + pub market_bids: AccountInfo<'info>, + pub market_asks: AccountInfo<'info>, + // Token account where funds are transferred from for the order. If + // posting a bid market A/B, then this is the SPL token account for B. + pub order_payer_token_account: AccountInfo<'info>, + pub open_orders_authority: AccountInfo<'info>, + // Also known as the "base" currency. For a given A/B market, + // this is the vault for the A mint. + pub coin_vault: AccountInfo<'info>, + // Also known as the "quote" currency. For a given A/B market, + // this is the vault for the B mint. + pub pc_vault: AccountInfo<'info>, + pub token_program: AccountInfo<'info>, + pub rent: AccountInfo<'info>, +} + +#[derive(Accounts)] +pub struct SettleFunds<'info> { + pub market: AccountInfo<'info>, + pub open_orders: AccountInfo<'info>, + pub open_orders_authority: AccountInfo<'info>, + pub coin_vault: AccountInfo<'info>, + pub pc_vault: AccountInfo<'info>, + pub coin_wallet: AccountInfo<'info>, + pub pc_wallet: AccountInfo<'info>, + pub vault_signer: AccountInfo<'info>, + pub token_program: AccountInfo<'info>, +} diff --git a/spl/src/lib.rs b/spl/src/lib.rs index e5c4fadf..f79e0160 100644 --- a/spl/src/lib.rs +++ b/spl/src/lib.rs @@ -1,2 +1,3 @@ +pub mod dex; pub mod shmem; pub mod token; diff --git a/spl/src/token.rs b/spl/src/token.rs index e4d768d1..8d8f209e 100644 --- a/spl/src/token.rs +++ b/spl/src/token.rs @@ -3,6 +3,7 @@ use anchor_lang::solana_program::account_info::AccountInfo; use anchor_lang::solana_program::entrypoint::ProgramResult; use anchor_lang::solana_program::program_error::ProgramError; use anchor_lang::solana_program::program_pack::Pack; +use anchor_lang::solana_program::pubkey::Pubkey; use anchor_lang::{Accounts, CpiContext}; use std::ops::Deref; @@ -201,3 +202,23 @@ impl Deref for Mint { &self.0 } } + +// Field parsers to save compute. All account validation is assumed to be done +// outside of these methods. +pub mod accessor { + use super::*; + + pub fn amount<'info>(account: &AccountInfo<'info>) -> Result { + let bytes = account.try_borrow_data()?; + let mut amount_bytes = [0u8; 8]; + amount_bytes.copy_from_slice(&bytes[64..72]); + Ok(u64::from_le_bytes(amount_bytes)) + } + + pub fn mint<'info>(account: &AccountInfo<'info>) -> Result { + let bytes = account.try_borrow_data()?; + let mut mint_bytes = [0u8; 32]; + mint_bytes.copy_from_slice(&bytes[..32]); + Ok(Pubkey::new_from_array(mint_bytes)) + } +}