diff --git a/docker-compose.yml b/docker-compose.yml index 8eb3fd5..b25f452 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ version: '3' services: localnet: - image: "solanalabs/solana:stable" + image: "solanalabs/solana:v1.4.18" ports: - "8899:8899" - "8900:8900" diff --git a/pyserum/_layouts/instructions.py b/pyserum/_layouts/instructions.py index c8cd102..b44ca88 100644 --- a/pyserum/_layouts/instructions.py +++ b/pyserum/_layouts/instructions.py @@ -16,6 +16,9 @@ class InstructionType(IntEnum): CancelOrder = 4 SettleFunds = 5 CancelOrderByClientID = 6 + NewOrderV3 = 10 + CancelOrderV2 = 11 + CancelOrderByClientIdV2 = 12 _VERSION = 0 @@ -49,6 +52,24 @@ _CANCEL_ORDER = cStruct( _CANCEL_ORDER_BY_CLIENTID = cStruct("client_id" / Int64ul) +_NEW_ORDER_V3 = cStruct( + "side" / Int32ul, # Enum + "limit_price" / Int64ul, + "max_base_quantity" / Int64ul, + "max_quote_quantity" / Int64ul, + "self_trade_behavior" / Int32ul, + "order_type" / Int32ul, # Enum + "client_id" / Int64ul, + "limit" / Int16ul, +) + +_CANCEL_ORDER_V2 = cStruct( + "side" / Int32ul, # Enum + "order_id" / KEY, +) + +_CANCEL_ORDER_BY_CLIENTID_V2 = cStruct("client_id" / Int64ul) + INSTRUCTIONS_LAYOUT = cStruct( "version" / Const(_VERSION, Int8ul), "instruction_type" / Int32ul, @@ -63,6 +84,9 @@ INSTRUCTIONS_LAYOUT = cStruct( InstructionType.CancelOrder: _CANCEL_ORDER, InstructionType.SettleFunds: Pass, # Empty list InstructionType.CancelOrderByClientID: _CANCEL_ORDER_BY_CLIENTID, + InstructionType.NewOrderV3: _NEW_ORDER_V3, + InstructionType.CancelOrderV2: _CANCEL_ORDER_V2, + InstructionType.CancelOrderByClientIdV2: _CANCEL_ORDER_BY_CLIENTID_V2, }, ), ) diff --git a/pyserum/enums.py b/pyserum/enums.py index e873b1b..fb8dce0 100644 --- a/pyserum/enums.py +++ b/pyserum/enums.py @@ -21,3 +21,9 @@ class OrderType(IntEnum): """""" PostOnly = 2 """""" + + +class SelfTradeBehavior(IntEnum): + DecrementTake = 0 + CancelProvide = 1 + AbortTransaction = 2 diff --git a/pyserum/instructions.py b/pyserum/instructions.py index eecb1db..bbdc11c 100644 --- a/pyserum/instructions.py +++ b/pyserum/instructions.py @@ -1,5 +1,5 @@ """Serum Dex Instructions.""" -from typing import Any, Dict, List, NamedTuple +from typing import Any, Dict, List, NamedTuple, Optional from solana.publickey import PublicKey from solana.sysvar import SYSVAR_RENT_PUBKEY @@ -8,7 +8,7 @@ from solana.utils.validate import validate_instruction_keys, validate_instructio from spl.token.constants import TOKEN_PROGRAM_ID # type: ignore # TODO: Fix and remove ignore. from ._layouts.instructions import INSTRUCTIONS_LAYOUT, InstructionType -from .enums import OrderType, Side +from .enums import OrderType, SelfTradeBehavior, Side # V2 DEFAULT_DEX_PROGRAM_ID = PublicKey("EUqojwWA2rd19FZrzeBncJsm38Jm1hEhE3zsmX3bRc2o") @@ -177,6 +177,96 @@ class SettleFundsParams(NamedTuple): program_id: PublicKey = DEFAULT_DEX_PROGRAM_ID +class NewOrderV3Params(NamedTuple): + """New order params.""" + + market: PublicKey + """""" + open_orders: PublicKey + """""" + payer: PublicKey + """""" + owner: PublicKey + """""" + request_queue: PublicKey + """""" + event_queue: PublicKey + """""" + bids: PublicKey + """""" + asks: PublicKey + """""" + base_vault: PublicKey + """""" + quote_vault: PublicKey + """""" + side: Side + """""" + limit_price: int + """""" + max_base_quantity: int + """""" + max_quote_quantity: int + """""" + order_type: OrderType + """""" + self_trade_behavior: SelfTradeBehavior + """""" + limit: Optional[int] + """""" + client_id: int = 0 + """""" + program_id: PublicKey = DEFAULT_DEX_PROGRAM_ID + """""" + fee_discount_pubkey: Optional[PublicKey] = None + + +class CancelOrderV2Params(NamedTuple): + """Cancel order params.""" + + market: PublicKey + """""" + bids: PublicKey + """""" + asks: PublicKey + """""" + event_queue: PublicKey + """""" + open_orders: PublicKey + """""" + owner: PublicKey + """""" + side: Side + """""" + order_id: int + """""" + open_orders_slot: int + """""" + program_id: PublicKey = DEFAULT_DEX_PROGRAM_ID + """""" + + +class CancelOrderByClientIDV2Params(NamedTuple): + """Cancel order by client ID params.""" + + market: PublicKey + """""" + bids: PublicKey + """""" + asks: PublicKey + """""" + event_queue: PublicKey + """""" + open_orders: PublicKey + """""" + owner: PublicKey + """""" + client_id: int + """""" + program_id: PublicKey = DEFAULT_DEX_PROGRAM_ID + """""" + + def __parse_and_validate_instruction(instruction: TransactionInstruction, instruction_type: InstructionType) -> Any: instruction_type_to_length_map: Dict[InstructionType, int] = { InstructionType.InitializeMarket: 9, @@ -186,6 +276,9 @@ def __parse_and_validate_instruction(instruction: TransactionInstruction, instru InstructionType.CancelOrder: 4, InstructionType.CancelOrderByClientID: 4, InstructionType.SettleFunds: 9, + InstructionType.NewOrderV3: 12, + InstructionType.CancelOrderV2: 6, + InstructionType.CancelOrderByClientIdV2: 6, } validate_instruction_keys(instruction, instruction_type_to_length_map[instruction_type]) data = INSTRUCTIONS_LAYOUT.parse(instruction.data) @@ -297,6 +390,58 @@ def decode_cancel_order_by_client_id(instruction: TransactionInstruction) -> Can ) +def decode_new_order_v3(instruction: TransactionInstruction) -> NewOrderV3Params: + data = __parse_and_validate_instruction(instruction, InstructionType.NewOrderV3) + return NewOrderV3Params( + market=instruction.keys[0].pubkey, + open_orders=instruction.keys[1].pubkey, + request_queue=instruction.keys[2].pubkey, + event_queue=instruction.keys[3].pubkey, + bids=instruction.keys[4].pubkey, + asks=instruction.keys[5].pubkey, + payer=instruction.keys[6].pubkey, + owner=instruction.keys[7].pubkey, + base_vault=instruction.keys[8].pubkey, + quote_vault=instruction.keys[9].pubkey, + side=data.args.side, + limit_price=data.args.limit_price, + max_base_quantity=data.args.max_base_quantity, + max_quote_quantity=data.args.max_quote_quantity, + self_trade_behavior=SelfTradeBehavior(data.args.self_trade_behavior), + order_type=OrderType(data.args.order_type), + client_id=data.args.client_id, + limit=data.args.limit, + ) + + +def decode_cancel_order_v2(instruction: TransactionInstruction) -> CancelOrderV2Params: + data = __parse_and_validate_instruction(instruction, InstructionType.CancelOrderV2) + return CancelOrderV2Params( + market=instruction.keys[0].pubkey, + bids=instruction.keys[1].pubkey, + asks=instruction.keys[2].pubkey, + open_orders=instruction.keys[3].pubkey, + owner=instruction.keys[4].pubkey, + event_queue=instruction.keys[5].pubkey, + side=Side(data.args.side), + order_id=int.from_bytes(data.args.order_id, "little"), + open_orders_slot=data.args.open_orders_slot, + ) + + +def decode_cancel_order_by_client_id_v2(instruction: TransactionInstruction) -> CancelOrderByClientIDV2Params: + data = __parse_and_validate_instruction(instruction, InstructionType.CancelOrderByClientIdV2) + return CancelOrderByClientIDV2Params( + market=instruction.keys[0].pubkey, + bids=instruction.keys[1].pubkey, + asks=instruction.keys[2].pubkey, + open_orders=instruction.keys[3].pubkey, + owner=instruction.keys[4].pubkey, + event_queue=instruction.keys[5].pubkey, + client_id=data.args.client_id, + ) + + def initialize_market(params: InitializeMarketParams) -> TransactionInstruction: """Generate a transaction instruction to initialize a Serum market.""" return TransactionInstruction( @@ -453,3 +598,91 @@ def cancel_order_by_client_id(params: CancelOrderByClientIDParams) -> Transactio ) ), ) + + +def new_order_v3(params: NewOrderV3Params) -> TransactionInstruction: + """Generate a transaction instruction to place new order.""" + touched_keys = [ + AccountMeta(pubkey=params.market, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.open_orders, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.request_queue, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.event_queue, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.bids, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.asks, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.payer, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.owner, is_signer=True, is_writable=False), + AccountMeta(pubkey=params.base_vault, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.quote_vault, is_signer=False, is_writable=True), + AccountMeta(pubkey=TOKEN_PROGRAM_ID, is_signer=False, is_writable=False), + AccountMeta(pubkey=SYSVAR_RENT_PUBKEY, is_signer=False, is_writable=False), + ] + if params.fee_discount_pubkey: + touched_keys.append( + AccountMeta(pubkey=params.fee_discount_pubkey, is_signer=False, is_writable=False), + ) + return TransactionInstruction( + keys=touched_keys, + program_id=params.program_id, + data=INSTRUCTIONS_LAYOUT.build( + dict( + instruction_type=InstructionType.NewOrderV3, + args=dict( + side=params.side, + limit_price=params.limit_price, + max_base_quantity=params.max_base_quantity, + max_quote_quantity=params.max_quote_quantity, + self_trade_behavior=params.self_trade_behavior, + order_type=params.order_type, + client_id=params.client_id, + limit=65535, + ), + ) + ), + ) + + +def cancel_order_v2(params: CancelOrderV2Params) -> TransactionInstruction: + """Generate a transaction instruction to cancel order.""" + return TransactionInstruction( + keys=[ + AccountMeta(pubkey=params.market, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.bids, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.asks, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.open_orders, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.owner, is_signer=True, is_writable=False), + AccountMeta(pubkey=params.event_queue, is_signer=False, is_writable=True), + ], + program_id=params.program_id, + data=INSTRUCTIONS_LAYOUT.build( + dict( + instruction_type=InstructionType.CancelOrderV2, + args=dict( + side=params.side, + order_id=params.order_id.to_bytes(16, byteorder="little"), + ), + ) + ), + ) + + +def cancel_order_by_client_id_v2(params: CancelOrderByClientIDV2Params) -> TransactionInstruction: + """Generate a transaction instruction to cancel order by client id.""" + return TransactionInstruction( + keys=[ + AccountMeta(pubkey=params.market, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.bids, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.asks, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.open_orders, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.owner, is_signer=True, is_writable=False), + AccountMeta(pubkey=params.event_queue, is_signer=False, is_writable=True), + ], + program_id=params.program_id, + data=INSTRUCTIONS_LAYOUT.build( + dict( + instruction_type=InstructionType.CancelOrderByClientIdV2, + args=dict( + client_id=params.client_id, + ), + ) + ), + ) diff --git a/pyserum/market/market.py b/pyserum/market/market.py index 1e5f70f..6b1505a 100644 --- a/pyserum/market/market.py +++ b/pyserum/market/market.py @@ -20,7 +20,7 @@ import pyserum.instructions as instructions import pyserum.market.types as t from .._layouts.open_orders import OPEN_ORDERS_LAYOUT -from ..enums import OrderType, Side +from ..enums import OrderType, SelfTradeBehavior, Side from ..open_orders_account import OpenOrdersAccount, make_create_account_instruction from ..utils import load_bytes_data from ._internal.queue import decode_event_queue, decode_request_queue @@ -36,13 +36,10 @@ class Market: logger = logging.getLogger("pyserum.market.Market") - def __init__( - self, - conn: Client, - market_state: MarketState, - ) -> None: + def __init__(self, conn: Client, market_state: MarketState, force_use_request_queue: bool = False) -> None: self._conn = conn self.state = market_state + self.force_use_request_queue = force_use_request_queue @staticmethod # pylint: disable=unused-argument @@ -50,6 +47,7 @@ class Market: conn: Client, market_address: PublicKey, program_id: PublicKey = instructions.DEFAULT_DEX_PROGRAM_ID, + force_use_request_queue: bool = False, ) -> Market: """Factory method to create a Market. @@ -58,7 +56,20 @@ class Market: :param program_id: The program id of the given market, it will use the default value if not provided. """ market_state = MarketState.load(conn, market_address, program_id) - return Market(conn, market_state) + return Market(conn, market_state, force_use_request_queue) + + def _use_request_queue(self) -> bool: + return ( + # DEX Version 1 + self.state.program_id == PublicKey("4ckmDgGdxQoPDLUkDT3vHgSAkzA3QRdNq5ywwY4sUSJn") + or + # DEX Version 1 + self.state.program_id == PublicKey("BJ3jrUzddfuSrZHXSCxMUUQsjKEyLmuuyZebkcaFp2fg") + or + # DEX Version 2 + self.state.program_id == PublicKey("EUqojwWA2rd19FZrzeBncJsm38Jm1hEhE3zsmX3bRc2o") + or self.force_use_request_queue + ) def support_srm_fee_discounts(self) -> bool: raise NotImplementedError("support_srm_fee_discounts not implemented") @@ -158,8 +169,8 @@ class Market: owner: Account, order_type: OrderType, side: Side, - limit_price: int, - max_quantity: int, + limit_price: float, + max_quantity: float, client_id: int = 0, opts: TxOpts = TxOpts(), ) -> RPCResponse: # TODO: Add open_orders_address_key param and fee_discount_pubkey @@ -245,7 +256,7 @@ class Market: @staticmethod def _get_lamport_need_for_sol_wrapping( - price: int, size: int, side: Side, open_orders_accounts: List[OpenOrdersAccount] + price: float, size: float, side: Side, open_orders_accounts: List[OpenOrdersAccount] ) -> int: lamports = 0 if side == Side.Buy: @@ -265,30 +276,56 @@ class Market: owner: Account, order_type: OrderType, side: Side, - limit_price: int, - max_quantity: int, + limit_price: float, + max_quantity: float, client_id: int, open_order_account: PublicKey, + fee_discount_pubkey: PublicKey = None, ) -> TransactionInstruction: if self.state.base_size_number_to_lots(max_quantity) < 0: raise Exception("Size lot %d is too small" % max_quantity) if self.state.price_number_to_lots(limit_price) < 0: raise Exception("Price lot %d is too small" % limit_price) - return instructions.new_order( - instructions.NewOrderParams( + if self._use_request_queue(): + return instructions.new_order( + instructions.NewOrderParams( + market=self.state.public_key(), + open_orders=open_order_account, + payer=payer, + owner=owner.public_key(), + request_queue=self.state.request_queue(), + base_vault=self.state.base_vault(), + quote_vault=self.state.quote_vault(), + side=side, + limit_price=self.state.price_number_to_lots(limit_price), + max_quantity=self.state.base_size_number_to_lots(max_quantity), + order_type=order_type, + client_id=client_id, + program_id=self.state.program_id(), + ) + ) + return instructions.new_order_v3( + instructions.NewOrderV3Params( market=self.state.public_key(), open_orders=open_order_account, payer=payer, owner=owner.public_key(), request_queue=self.state.request_queue(), + event_queue=self.state.event_queue(), + bids=self.state.bids(), + asks=self.state.asks(), base_vault=self.state.base_vault(), quote_vault=self.state.quote_vault(), side=side, - limit_price=limit_price, - max_quantity=max_quantity, + limit_price=self.state.price_number_to_lots(limit_price), + max_base_quantity=self.state.base_size_number_to_lots(max_quantity), + max_quote_quantity=self.state.quote_size_number_to_lots(max_quantity * limit_price), order_type=order_type, client_id=client_id, program_id=self.state.program_id(), + self_trade_behavior=SelfTradeBehavior.DecrementTake, + fee_discount_pubkey=fee_discount_pubkey, + limit=65535, ) ) @@ -301,12 +338,25 @@ class Market: def make_cancel_order_by_client_id_instruction( self, owner: Account, open_orders_account: PublicKey, client_id: int ) -> TransactionInstruction: - return instructions.cancel_order_by_client_id( - instructions.CancelOrderByClientIDParams( + if self._use_request_queue(): + return instructions.cancel_order_by_client_id( + instructions.CancelOrderByClientIDParams( + market=self.state.public_key(), + owner=owner.public_key(), + open_orders=open_orders_account, + request_queue=self.state.request_queue(), + client_id=client_id, + program_id=self.state.program_id(), + ) + ) + return instructions.cancel_order_by_client_id_v2( + instructions.CancelOrderByClientIDV2Params( market=self.state.public_key(), owner=owner.public_key(), open_orders=open_orders_account, - request_queue=self.state.request_queue(), + bids=self.state.bids(), + asks=self.state.asks(), + event_queue=self.state.event_queue(), client_id=client_id, program_id=self.state.program_id(), ) @@ -316,23 +366,39 @@ class Market: txn = Transaction().add(self.make_cancel_order_instruction(owner.public_key(), order)) return self._conn.send_transaction(txn, owner, opts=opts) + def make_cancel_order_instruction(self, owner: PublicKey, order: t.Order) -> TransactionInstruction: + if self._use_request_queue(): + return instructions.cancel_order( + instructions.CancelOrderParams( + market=self.state.public_key(), + owner=owner, + open_orders=order.open_order_address, + request_queue=self.state.request_queue(), + side=order.side, + order_id=order.order_id, + open_orders_slot=order.open_order_slot, + program_id=self.state.program_id(), + ) + ) + return instructions.cancel_order_v2( + instructions.CancelOrderV2Params( + market=self.state.public_key(), + owner=owner, + open_orders=order.open_order_address, + bids=self.state.bids(), + asks=self.state.asks(), + event_queue=self.state.event_queue(), + side=order.side, + order_id=order.order_id, + open_orders_slot=order.open_order_slot, + program_id=self.state.program_id(), + ) + ) + def match_orders(self, fee_payer: Account, limit: int, opts: TxOpts = TxOpts()) -> RPCResponse: txn = Transaction().add(self.make_match_orders_instruction(limit)) return self._conn.send_transaction(txn, fee_payer, opts=opts) - def make_cancel_order_instruction(self, owner: PublicKey, order: t.Order) -> TransactionInstruction: - params = instructions.CancelOrderParams( - market=self.state.public_key(), - owner=owner, - open_orders=order.open_order_address, - request_queue=self.state.request_queue(), - side=order.side, - order_id=order.order_id, - open_orders_slot=order.open_order_slot, - program_id=self.state.program_id(), - ) - return instructions.cancel_order(params) - def make_match_orders_instruction(self, limit: int) -> TransactionInstruction: params = instructions.MatchOrdersParams( market=self.state.public_key(), diff --git a/pyserum/market/state.py b/pyserum/market/state.py index 019977c..387ab48 100644 --- a/pyserum/market/state.py +++ b/pyserum/market/state.py @@ -147,4 +147,10 @@ class MarketState: # pylint: disable=too-many-public-methods return float(size * self.base_lot_size()) / self.base_spl_token_multiplier() def base_size_number_to_lots(self, size: float) -> int: - return int(math.floor(size * 10 ** self._base_mint_decimals) / self.base_lot_size()) + return int(math.floor(size * self.base_spl_token_multiplier()) / self.base_lot_size()) + + def quote_size_lots_to_number(self, size: int) -> float: + return float(size * self.quote_lot_size()) / self.quote_spl_token_multiplier() + + def quote_size_number_to_lots(self, size: float) -> int: + return int(math.floor(size * self.quote_spl_token_multiplier()) / self.quote_lot_size()) diff --git a/scripts/bootstrap_dex.sh b/scripts/bootstrap_dex.sh index 28ba902..08215b2 100755 --- a/scripts/bootstrap_dex.sh +++ b/scripts/bootstrap_dex.sh @@ -13,15 +13,17 @@ else exit 1 fi -docker-compose up -d + if ! hash solana 2>/dev/null; then echo Installing Solana tool suite ... - curl -sSf https://raw.githubusercontent.com/solana-labs/solana/v1.3.9/install/solana-install-init.sh | sh -s - v1.3.9 + curl -sSf https://raw.githubusercontent.com/solana-labs/solana/v1.5.8/install/solana-install-init.sh | SOLANA_RELEASE=v1.5.8 sh -s - v1.5.8 export PATH="/home/runner/.local/share/solana/install/active_release/bin:$PATH" echo Generating keypair ... solana-keygen new -o ~/.config/solana/id.json --no-passphrase --silent fi -solana config set --url "http://localhost:8899" + +solana-test-validator & +solana config set --url "http://127.0.0.1:8899" curl -s -L "https://github.com/serum-community/serum-dex/releases/download/v2/serum_dex-$os_type.so" > serum_dex.so sleep 1 solana airdrop 10000 diff --git a/tests/integration/test_market.py b/tests/integration/test_market.py index c722e2c..c787c88 100644 --- a/tests/integration/test_market.py +++ b/tests/integration/test_market.py @@ -13,7 +13,7 @@ from pyserum.market import Market @pytest.mark.integration @pytest.fixture(scope="module") def bootstrapped_market(http_client: Client, stubbed_market_pk: PublicKey, stubbed_dex_program_pk: PublicKey) -> Market: - return Market.load(http_client, stubbed_market_pk, stubbed_dex_program_pk) + return Market.load(http_client, stubbed_market_pk, stubbed_dex_program_pk, force_use_request_queue=True) @pytest.mark.integration