Init repo

This commit is contained in:
armaniferrante 2021-04-29 22:20:44 -07:00
commit 35e6bfcd66
No known key found for this signature in database
GPG Key ID: 58BEF301E91F7828
14 changed files with 2627 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
target/
.anchor/

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "deps/serum-dex"]
path = deps/serum-dex
url = https://github.com/project-serum/serum-dex.git

55
.travis.yml Normal file
View File

@ -0,0 +1,55 @@
dist: bionic
language: rust
rust:
- stable
env:
global:
- NODE_VERSION="v14.7.0"
- SOLANA_VERSION="v1.6.6"
- ANCHOR_VERSION="v0.4.5"
git:
submodules: true
before_deploy:
- anchor build --verifiable
- echo "### SHA256 Checksums" > release_notes.md
- sha256sum target/deploy/swap.so > binary.txt
- sha256sum target/idl/swap.json > idl.txt
- cat *.txt >> release_notes.md
- echo "" >> release_notes.md
- echo "Built with Anchor [${ANCHOR_VERSION}](https://github.com/project-serum/anchor/releases/tag/${ANCHOR_VERSION})." >> release_notes.md
deploy:
provider: releases
edge: true
file:
- "target/deploy/swap.so"
- "target/idl/swap.json"
release_notes_file: release_notes.md
skip_cleanup: true
on:
tags: true
api_key:
secure: 1ixwvPLZd2ZleVAv0QuZcUdZW7JV94TBkCP2+UvJJSqi6kX2HDK5zzVQu5zBM6jOpmpjKUJpLRq6V56/Uj6tGt2w5mfvjeiu3YA5o6WUsMRsvdx+NbnIEIPBfX1ECDhn15FlfC+beGw5oewgjrf59t4Lk9KO5I7VkxdoUJwP0uWRp034fG85JufFziYCkJTobEuCDWthe0p7eym/4rC1nkG7RoOvAwD111QiatEi9AP6nBsi/wokY4u/qvcFjgNxMnpDi7NNLaLLZGDKk/mfpvvnRpYvGRf2xUnzsvSx68m9ycYGNiHrg+YbKVMFa78VbdJepuKheFe1ArpYPd6d0zhybvdsEYUB7PFx/4N5g639NpGX/AgYGtNLGD6pYOBy5cvsrs7JPymcQo1e7HIvtElW8wldSv5964WhCzX19E+/samPiRxVKXtoc3ognebM6IQaxEOtg4aXeV6VPvMvwcSn7V+lrdlMIZSW0Z7RCmsw668icb4yo7Kqt3VCVVuelrN+xvio78twsYcLeNWE2xxbpb1aEd/ZPWndij1+/O7gyheJHH0kkxU8mimrOe+K0XPn6Zk2yfWutsBzC3RdR/1qHR7JKfGN0XbXUrmbSnjcW5M7vVApDglymT9LLajMlvIcQIdS0nwyDfNkJDgVwxJgrTJpXVgfyHScC1AYJO4=
_defaults: &defaults
before_install:
- nvm install $NODE_VERSION
- npm install -g mocha
- npm install -g @project-serum/anchor
- npm install -g @project-serum/serum
- npm install -g @project-serum/common
- npm install -g @solana/spl-token
- sudo apt-get install -y pkg-config build-essential libudev-dev
- sh -c "$(curl -sSfL https://release.solana.com/${SOLANA_VERSION}/install)"
- export PATH="/home/travis/.local/share/solana/install/active_release/bin:$PATH"
- export NODE_PATH="/home/travis/.nvm/versions/node/${NODE_VERSION}/lib/node_modules/:${NODE_PATH}"
- yes | solana-keygen new
- cargo install --git https://github.com/project-serum/anchor --tag ${ANCHOR_VERSION} anchor-cli --locked
jobs:
include:
- <<: *defaults
name: Runs the tests
script:
- pushd deps/serum-dex/dex && cargo build-bpf && popd && anchor test

6
Anchor.toml Normal file
View File

@ -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"

1142
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

7
Cargo.toml Normal file
View File

@ -0,0 +1,7 @@
[workspace]
members = [
"programs/*"
]
exclude = [
"deps/serum-dex"
]

64
README.md Normal file
View File

@ -0,0 +1,64 @@
# Swap
Swap provides a convenient API to the Serum DEX for performing instantly
settled token swaps directly on the order book.
## Developing
This program 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 ../../../
```
### Build
[Anchor](https://github.com/project-serum/anchor) is used for developoment, and it's
recommended workflow is used here. To get started, see the [guide](https://project-serum.github.io/anchor/getting-started/introduction.html).
```bash
anchor build --verifiable
```
The `--verifiable` flag should be used before deploying so that your build artifacts
can be deterministically generated with docker.
### Test
```bash
anchor test
```
### Verify
To verify the program deployed on Solana matches your local source code, install
docker, `cd programs/swap`, and run
```bash
anchor verify <program-id | write-buffer>
```
A list of build artifacts can be found under [releases](https://github.com/project-serum/swap/releases).
### Run the Test
Run the test
```
anchor test
```

1
deps/serum-dex vendored Submodule

@ -0,0 +1 @@
Subproject commit 66904088599c1a8d42623f6a6d157cec46c8da62

12
migrations/deploy.js Normal file
View File

@ -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.
}

19
programs/swap/Cargo.toml Normal file
View File

@ -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 = "0.4.5"
anchor-spl = "0.4.5"

2
programs/swap/Xargo.toml Normal file
View File

@ -0,0 +1,2 @@
[target.bpfel-unknown-unknown.dependencies.std]
features = []

493
programs/swap/src/lib.rs Normal file
View File

@ -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<AccountInfo<'info>>) -> 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<AccountInfo<'info>>) -> 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<AccountInfo<'info>>,
) -> 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<AccountInfo<'info>>) -> 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<Side> 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<Swap>) -> Result<()> {
_is_valid_swap(&ctx.accounts.market.coin_wallet, &ctx.accounts.pc_wallet)
}
fn is_valid_swap_transitive(ctx: &Context<SwapTransitive>) -> 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,
}

311
tests/swap.js Normal file
View File

@ -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;
}

510
tests/utils/index.js Normal file
View File

@ -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,
};