diff --git a/README.md b/README.md index 6115fe5..44e374b 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ for (let order of asks) { order.orderId, order.owner.toBase58(), order.price, - order.quantity, + order.size, order.side, ); } diff --git a/package.json b/package.json index 988b279..c749140 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@project-serum/serum", - "version": "0.1.1", + "version": "0.2.0", "description": "Library for interacting with the serum dex", "license": "MIT", "repository": "project-serum/serum-js", diff --git a/src/instructions.js b/src/instructions.js index 44aceca..dfbbe3d 100644 --- a/src/instructions.js +++ b/src/instructions.js @@ -9,6 +9,7 @@ import { zeros, } from './layout'; import { PublicKey, TransactionInstruction } from '@solana/web3.js'; +import { TOKEN_PROGRAM_ID } from './token-instructions'; export const DEX_PROGRAM_ID = new PublicKey( '9o1FisE366msTQcEvXapyMorTLmvezrxSD8DnM5e5XKw', @@ -130,8 +131,9 @@ export class DexInstructions { { pubkey: payer, isSigner: false, isWritable: true }, { pubkey: owner, isSigner: true, isWritable: false }, { pubkey: requestQueue, isSigner: false, isWritable: true }, - { pubkey: baseVault, isSigner: false, isWritable: false }, - { pubkey: quoteVault, isSigner: false, isWritable: false }, + { pubkey: baseVault, isSigner: false, isWritable: true }, + { pubkey: quoteVault, isSigner: false, isWritable: true }, + { pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, ], programId: DEX_PROGRAM_ID, data: encodeInstruction({ diff --git a/src/market.js b/src/market.js index dc9ec85..05c95bc 100644 --- a/src/market.js +++ b/src/market.js @@ -1,8 +1,14 @@ import { blob, seq, struct, u8 } from 'buffer-layout'; import { publicKeyLayout, u128, u64, WideBits } from './layout'; import { SLAB_LAYOUT } from './slab'; -import { DEX_PROGRAM_ID } from './instructions'; +import { DEX_PROGRAM_ID, DexInstructions } from './instructions'; import BN from 'bn.js'; +import { + Account, + PublicKey, + SystemProgram, + Transaction, +} from '@solana/web3.js'; const ACCOUNT_FLAGS_LAYOUT = new WideBits(); ACCOUNT_FLAGS_LAYOUT.addBoolean('initialized'); @@ -59,8 +65,8 @@ export class Market { this._quoteMintDecimals = quoteMintDecimals; } - static decode(buffer) { - return MARKET_STATE_LAYOUT.decode(buffer); + static get LAYOUT() { + return MARKET_STATE_LAYOUT; } static async load(connection, address) { @@ -69,6 +75,13 @@ export class Market { throw new Error('Address not owned by program'); } const decoded = MARKET_STATE_LAYOUT.decode(data); + if ( + !decoded.accountFlags.initialized || + !decoded.accountFlags.market || + !decoded.ownAddress.equals(address) + ) { + throw new Error('Invalid market'); + } const [baseMintDecimals, quoteMintDecimals] = await Promise.all([ getMintDecimals(connection, decoded.baseMint), getMintDecimals(connection, decoded.quoteMint), @@ -76,6 +89,22 @@ export class Market { return new Market(decoded, baseMintDecimals, quoteMintDecimals); } + get address() { + return this._decoded.ownAddress; + } + + get publicKey() { + return this.address; + } + + get baseMintAddress() { + return this._decoded.baseMint; + } + + get quoteMintAddress() { + return this._decoded.quoteMint; + } + async loadBids(connection) { const { data } = await connection.getAccountInfo(this._decoded.bids); return Orderbook.decode(this, data); @@ -86,18 +115,98 @@ export class Market { return Orderbook.decode(this, data); } + async findBaseTokenAccountsForOwner(connection, ownerAddress) { + return ( + await connection.getTokenAccountsByOwner(ownerAddress, { + mint: this.baseMintAddress, + }) + ).value; + } + + async findQuoteTokenAccountsForOwner(connection, ownerAddress) { + return ( + await connection.getTokenAccountsByOwner(ownerAddress, { + mint: this.quoteMintAddress, + }) + ).value; + } + + async findOpenOrdersAccountsForOwner(connection, ownerAddress) { + return OpenOrders.findForMarketAndOwner( + connection, + this.address, + ownerAddress, + ); + } + async placeOrder( connection, { owner, payer, side, price, size, orderType = 'limit' }, ) { - throw new Error('not yet implemented'); + const { transaction, signers } = await this.makePlaceOrderTransaction( + connection, + { + owner, + payer, + side, + price, + size, + orderType, + }, + ); + return await connection.sendTransaction(transaction, signers); + } + + async makePlaceOrderTransaction( + connection, + { owner, payer, side, price, size, orderType = 'limit' }, + ) { + const ownerAddress = owner.publicKey ?? owner; + const openOrdersAccounts = await this.findOpenOrdersAccountsForOwner( + connection, + ownerAddress, + ); // TODO: cache this + const transaction = new Transaction(); + const signers = [owner]; + let openOrdersAddress; + if (openOrdersAccounts.length === 0) { + const newOpenOrdersAccount = new Account(); + transaction.add( + await OpenOrders.makeCreateAccountTransaction( + connection, + this.address, + ownerAddress, + newOpenOrdersAccount.publicKey, + ), + ); + openOrdersAddress = newOpenOrdersAccount.publicKey; + signers.push(newOpenOrdersAccount); + } else { + openOrdersAddress = openOrdersAccounts[0].address; + } + transaction.add( + DexInstructions.newOrder({ + market: this.address, + requestQueue: this._decoded.requestQueue, + baseVault: this._decoded.baseVault, + quoteVault: this._decoded.quoteVault, + openOrders: openOrdersAddress, + owner: ownerAddress, + payer, + side, + limitPrice: this.priceNumberToLots(price), + maxQuantity: this.baseSizeNumberToLots(size), + orderType, + }), + ); + return { transaction, signers }; } async cancelOrder(connection, order) { throw new Error('not yet implemented'); } - priceBnToNumber(price) { + priceLotsToNumber(price) { return divideBnToNumber( price .mul(this._decoded.quoteLotSize) @@ -106,19 +215,47 @@ export class Market { ); } - baseSizeBnToNumber(size) { + priceNumberToLots(price) { + return new BN( + Math.round( + (price * + Math.pow(10, this._quoteMintDecimals) * + this._decoded.baseLotSize.toNumber()) / + (Math.pow(10, this._baseMintDecimals) * + this._decoded.quoteLotSize.toNumber()), + ), + ); + } + + baseSizeLotsToNumber(size) { return divideBnToNumber( size.mul(this._decoded.baseLotSize), new BN(10).pow(this._baseMintDecimals), ); } - quoteSizeBnToNumber(size) { + baseSizeNumberToLots(size) { + const native = new BN( + Math.round(size * Math.pow(10, this._baseMintDecimals)), + ); + // rounds down to the nearest lot size + return native.div(this._decoded.baseLotSize); + } + + quoteSizeLotsToNumber(size) { return divideBnToNumber( size.mul(this._decoded.quoteLotSize), new BN(10).pow(this._quoteMintDecimals), ); } + + quoteSizeNumberToLots(size) { + const native = new BN( + Math.round(size * Math.pow(10, this._quoteMintDecimals)), + ); + // rounds down to the nearest lot size + return native.div(this._decoded.quoteLotSize); + } } export const OPEN_ORDERS_LAYOUT = struct([ @@ -139,7 +276,85 @@ export const OPEN_ORDERS_LAYOUT = struct([ seq(u128(), 128, 'orders'), ]); -export class OpenOrders {} +export class OpenOrders { + constructor(address, decoded) { + this.address = address; + Object.assign(this, decoded); + } + + static get LAYOUT() { + return OPEN_ORDERS_LAYOUT; + } + + static async findForMarketAndOwner(connection, marketAddress, ownerAddress) { + const filters = [ + { + memcmp: { + offset: OPEN_ORDERS_LAYOUT.offsetOf('market'), + bytes: marketAddress.toBase58(), + }, + }, + { + memcmp: { + offset: OPEN_ORDERS_LAYOUT.offsetOf('owner'), + bytes: ownerAddress.toBase58(), + }, + }, + { + dataSize: OPEN_ORDERS_LAYOUT.span, + }, + ]; + const accounts = await getFilteredProgramAccounts( + connection, + DEX_PROGRAM_ID, + filters, + ); + return accounts.map(({ publicKey, accountInfo }) => + OpenOrders.fromAccountInfo(publicKey, accountInfo), + ); + } + + static async load(connection, address) { + const accountInfo = await connection.getAccountInfo(address); + if (accountInfo === null) { + throw new Error('Open orders account not found'); + } + return OpenOrders.fromAccountInfo(connection, accountInfo); + } + + static fromAccountInfo(address, accountInfo) { + const { owner, data } = accountInfo; + if (!owner.equals(DEX_PROGRAM_ID)) { + throw new Error('Address not owned by program'); + } + const decoded = OPEN_ORDERS_LAYOUT.decode(data); + if (!decoded.accountFlags.initialized || !decoded.accountFlags.openOrders) { + throw new Error('Invalid open orders account'); + } + return new OpenOrders(address, decoded); + } + + static async makeCreateAccountTransaction( + connection, + marketAddress, + ownerAddress, + newAccountAddress, + ) { + return SystemProgram.createAccount({ + fromPubkey: ownerAddress, + newAccountPubkey: newAccountAddress, + lamports: await connection.getMinimumBalanceForRentExemption( + OPEN_ORDERS_LAYOUT.span, + ), + space: OPEN_ORDERS_LAYOUT.span, + programId: DEX_PROGRAM_ID, + }); + } + + get publicKey() { + return this.address; + } +} export const ORDERBOOK_LAYOUT = struct([ accountFlags('accountFlags'), @@ -174,9 +389,11 @@ export class Orderbook { levels.push([price, quantity]); } } - return levels.map(([price, size]) => [ - this.market.priceBnToNumber(price), - this.market.baseSizeBnToNumber(size.mul(this.market.baseLotSize)), + return levels.map(([priceLots, sizeLots]) => [ + this.market.priceLotsToNumber(priceLots), + this.market.baseSizeLotsToNumber(sizeLots.mul(this.market.baseLotSize)), + priceLots, + sizeLots, ]); } @@ -187,8 +404,10 @@ export class Orderbook { orderId: key, ownerSlot, owner, - price: this.market.priceBnToNumber(price), - quantity: this.market.baseSizeBnToNumber(quantity), + price: this.market.priceLotsToNumber(price), + priceLots: price, + size: this.market.baseSizeLotsToNumber(quantity), + sizeLots: quantity, side: this.isBids ? 'buy' : 'sell', }; } @@ -213,3 +432,28 @@ export async function getMintDecimals(connection, mint) { const { decimals } = MINT_LAYOUT.decode(data); return decimals; } + +async function getFilteredProgramAccounts(connection, programId, filters) { + const resp = await connection._rpcRequest('getProgramAccounts', [ + programId.toBase58(), + { + commitment: connection.commitment, + filters, + encoding: 'binary64', + }, + ]); + if (resp.error) { + throw new Error(resp.error.message); + } + return resp.result.map( + ({ pubkey, account: { data, executable, owner, lamports } }) => ({ + publicKey: new PublicKey(pubkey), + accountInfo: { + data: Buffer.from(data, 'base64'), + executable, + owner: new PublicKey(owner), + lamports, + }, + }), + ); +} diff --git a/src/token-instructions.js b/src/token-instructions.js new file mode 100644 index 0000000..4d797e3 --- /dev/null +++ b/src/token-instructions.js @@ -0,0 +1,138 @@ +import * as BufferLayout from 'buffer-layout'; +import { PublicKey, TransactionInstruction } from '@solana/web3.js'; + +export const TOKEN_PROGRAM_ID = new PublicKey( + 'TokenSVp5gheXUvJ6jGWGeCsgPKgnE3YgdGKRVCMY9o', +); + +export const WRAPPED_SOL_MINT = new PublicKey( + 'So11111111111111111111111111111111111111111', +); + +const LAYOUT = BufferLayout.union(BufferLayout.u8('instruction')); +LAYOUT.addVariant( + 0, + BufferLayout.struct([ + // TODO: does this need to be aligned? + BufferLayout.nu64('amount'), + BufferLayout.u8('decimals'), + ]), + 'initializeMint', +); +LAYOUT.addVariant(1, BufferLayout.struct([]), 'initializeAccount'); +LAYOUT.addVariant( + 3, + BufferLayout.struct([BufferLayout.nu64('amount')]), + 'transfer', +); +LAYOUT.addVariant( + 4, + BufferLayout.struct([BufferLayout.nu64('amount')]), + 'approve', +); +LAYOUT.addVariant( + 7, + BufferLayout.struct([BufferLayout.nu64('amount')]), + 'mintTo', +); +LAYOUT.addVariant( + 8, + BufferLayout.struct([BufferLayout.nu64('amount')]), + 'burn', +); + +const instructionMaxSpan = Math.max( + ...Object.values(LAYOUT.registry).map((r) => r.span), +); + +function encodeTokenInstructionData(instruction) { + const b = Buffer.alloc(instructionMaxSpan); + const span = LAYOUT.encode(instruction, b); + return b.slice(0, span); +} + +export function initializeMint({ + mint, + amount, + decimals, + initialAccount, + mintOwner, +}) { + const keys = [{ pubkey: mint, isSigner: false, isWritable: true }]; + if (amount) { + keys.push({ pubkey: initialAccount, isSigner: false, isWritable: true }); + } + if (mintOwner) { + keys.push({ pubkey: mintOwner, isSigner: false, isWritable: false }); + } + return new TransactionInstruction({ + keys, + data: encodeTokenInstructionData({ + initializeMint: { + amount, + decimals, + }, + }), + programId: TOKEN_PROGRAM_ID, + }); +} + +export function initializeAccount({ account, mint, owner }) { + const keys = [ + { pubkey: account, isSigner: false, isWritable: true }, + { pubkey: mint, isSigner: false, isWritable: false }, + { pubkey: owner, isSigner: false, isWritable: false }, + ]; + return new TransactionInstruction({ + keys, + data: encodeTokenInstructionData({ + initializeAccount: {}, + }), + programId: TOKEN_PROGRAM_ID, + }); +} + +export function transfer({ source, destination, amount, owner }) { + const keys = [ + { pubkey: source, isSigner: false, isWritable: true }, + { pubkey: destination, isSigner: false, isWritable: true }, + { pubkey: owner, isSigner: true, isWritable: false }, + ]; + return new TransactionInstruction({ + keys, + data: encodeTokenInstructionData({ + transfer: { amount }, + }), + programId: TOKEN_PROGRAM_ID, + }); +} + +export function approve({ source, delegate, amount, owner }) { + const keys = [ + { pubkey: source, isSigner: false, isWritable: true }, + { pubkey: delegate, isSigner: false, isWritable: false }, + { pubkey: owner, isSigner: true, isWritable: false }, + ]; + return new TransactionInstruction({ + keys, + data: encodeTokenInstructionData({ + approve: { amount }, + }), + programId: TOKEN_PROGRAM_ID, + }); +} + +export function mintTo({ mint, account, amount, owner }) { + const keys = [ + { pubkey: mint, isSigner: false, isWritable: true }, + { pubkey: account, isSigner: false, isWritable: true }, + { pubkey: owner, isSigner: true, isWritable: false }, + ]; + return new TransactionInstruction({ + keys, + data: encodeTokenInstructionData({ + mintTo: { amount }, + }), + programId: TOKEN_PROGRAM_ID, + }); +}