diff --git a/ts/README.md b/ts/README.md index 41f8e19..3ee12d1 100644 --- a/ts/README.md +++ b/ts/README.md @@ -88,3 +88,12 @@ dex.runMarketMaker(market, owner, { quoteGeckoSymbol: "usd", }); ``` + +### Run a crank + +```javascript +dex.runCrank(market, owner, { + durationInSecs: 20, + verbose: true, +}); +``` diff --git a/ts/scripts/marketMaking.ts b/ts/scripts/marketMaking.ts index e067fa6..ff610b4 100644 --- a/ts/scripts/marketMaking.ts +++ b/ts/scripts/marketMaking.ts @@ -51,12 +51,17 @@ const main = async () => { console.log(`Funded owner with ${baseCoin.symbol} and ${quoteCoin.symbol}`); dex.runMarketMaker(market, owner, { - durationInSecs: 15, + durationInSecs: 30, orderCount: 3, initialBidSize: 1000, baseGeckoSymbol: "solana", quoteGeckoSymbol: "usd", }); + + dex.runCrank(market, owner, { + durationInSecs: 20, + verbose: true, + }); }; const runMain = async () => { diff --git a/ts/src/coin.ts b/ts/src/coin.ts index 400f782..788b94a 100644 --- a/ts/src/coin.ts +++ b/ts/src/coin.ts @@ -1,4 +1,8 @@ -import { getOrCreateAssociatedTokenAccount, mintTo } from "@solana/spl-token"; +import { + getMint, + getOrCreateAssociatedTokenAccount, + mintTo, +} from "@solana/spl-token"; import { Connection, Keypair, @@ -8,28 +12,130 @@ import { } from "@solana/web3.js"; export class Coin { - symbol: string; + private _symbol: string; - decimals: number; + private _decimals: number; - mint: PublicKey; + private _mint: PublicKey; - mintAuthority: Keypair; + private _mintAuthority: Keypair; - freezeAuthority: Keypair; + private _freezeAuthority: Keypair | null; constructor( symbol: string, decimals: number, mint: PublicKey, mintAuthority: Keypair, - freezeAuthority: Keypair, + freezeAuthority: Keypair | null, ) { - this.symbol = symbol; - this.decimals = decimals; - this.mint = mint; - this.mintAuthority = mintAuthority; - this.freezeAuthority = freezeAuthority; + this._symbol = symbol; + this._decimals = decimals; + this._mint = mint; + this._mintAuthority = mintAuthority; + this._freezeAuthority = freezeAuthority; + } + + public get symbol() { + return this._symbol; + } + + public get decimals() { + return this._decimals; + } + + public get mint() { + return this._mint; + } + + public get mintAuthority() { + return this._mintAuthority; + } + + public get freezeAuthority() { + return this._freezeAuthority; + } + + /** + * Load an exisiting mint as a Coin. + * + * @param connection The `Connection` object to connect to Solana. + * @param symbol The symbol to assign to the coin. + * @param mint The `PublicKey` of the Mint for the coin. + * @param mintAuthority The minting authority `Keypair` for the coin. + * @param freezeAuthority The optional freezing authority `Keypair` for the coin. + * @returns + */ + static async load( + connection: Connection, + symbol: string, + mint: PublicKey, + mintAuthority: Keypair, + freezeAuthority: Keypair | null, + ): Promise { + const { + decimals, + mintAuthority: tokenMintAuthority, + freezeAuthority: tokenFreezeAuthority, + } = await getMint(connection, mint, "confirmed"); + + // tokenMintAuthority has to be truthy since createMint requires a mint authority as well. + if ( + !tokenMintAuthority || + tokenMintAuthority.toBase58() !== mintAuthority.publicKey.toBase58() + ) { + throw new Error("Invalid Mint authority provided"); + } + + if (!!tokenFreezeAuthority !== !!freezeAuthority) { + throw new Error("Invalid Freeze authority provided"); + } + + if ( + tokenFreezeAuthority && + freezeAuthority && + tokenFreezeAuthority.toBase58() !== freezeAuthority.publicKey.toBase58() + ) { + throw new Error("Invalid Freeze authority provided"); + } + + return new Coin(symbol, decimals, mint, mintAuthority, freezeAuthority); + } + + /** + * Equality check between two `Coin`s. + * + * @param to The `Coin` object to compare to. + * @returns + */ + public isEqual(to: Coin) { + const { mintAuthority, freezeAuthority } = to; + + if ( + mintAuthority.publicKey.toBase58() !== + this.mintAuthority.publicKey.toBase58() + ) { + return false; + } + + if (!!freezeAuthority !== !!this.freezeAuthority) { + return false; + } + + if ( + freezeAuthority && + this.freezeAuthority && + freezeAuthority.publicKey.toBase58() !== + this.freezeAuthority.publicKey.toBase58() + ) { + return false; + } + + return ( + to.symbol === this.symbol && + to.decimals === this.decimals && + to.mint.toBase58() === this.mint.toBase58() + ); } /** @@ -72,6 +178,10 @@ export class Coin { owner: Keypair, connection: Connection, ): Promise { + if (!this.mintAuthority) { + throw new Error("Coin has no mint authority"); + } + const destination = await getOrCreateAssociatedTokenAccount( connection, owner, diff --git a/ts/src/dex.ts b/ts/src/dex.ts index 0b0b501..85a9f4f 100644 --- a/ts/src/dex.ts +++ b/ts/src/dex.ts @@ -41,23 +41,44 @@ export type MarketMakerOpts = { quoteGeckoSymbol: string; }; +export type CrankOpts = { + durationInSecs: number; + verbose: boolean; +}; + /** * Dex is a wrapper class for a deployed Serum Dex program. */ export class Dex { - public address: PublicKey; + private _address: PublicKey; - coins: Coin[]; + private _coins: Coin[]; - markets: DexMarket[]; + private _markets: DexMarket[]; - connection: Connection; + private _connection: Connection; constructor(address: PublicKey, connection: Connection) { - this.address = address; - this.connection = connection; - this.coins = []; - this.markets = []; + this._address = address; + this._connection = connection; + this._coins = []; + this._markets = []; + } + + public get coins() { + return this._coins; + } + + public get markets() { + return this._markets; + } + + public get connection() { + return this._connection; + } + + public get address() { + return this._address; } /** @@ -66,23 +87,29 @@ export class Dex { * @param symbol The symbol of the coin to create * @param decimals The decimals of the coin to create * @param payer The payer `Keypair` to use for the transactions - * @param mintAuthority The mint authority `Keypair` to use for the mint - * @param freezeAuthority The freeze authority `Keypair` to use for the mint + * @param mintAuthority The optional mint authority `Keypair` to use for the mint + * @param freezeAuthority The optionals freeze authority `Keypair` to use for the mint + * @param keypair The optional keypair for the Mint to be created, defaults to a random one * @returns */ public createCoin = async ( symbol: string, decimals: number, payer: Keypair, - mintAuthority: Keypair | null, + mintAuthority: Keypair, freezeAuthority: Keypair | null, + keypair?: Keypair, ): Promise => { const mint = await createMint( this.connection, payer, - mintAuthority ? mintAuthority.publicKey : null, + mintAuthority.publicKey, freezeAuthority ? freezeAuthority.publicKey : null, decimals, + keypair, + { + commitment: "confirmed", + }, ); const coin = new Coin( @@ -255,7 +282,7 @@ export class Dex { if (opts.durationInSecs < 0) throw new Error("Duration must be greater than 0."); - const child = fork(`${__dirname}/scripts/marketMaker`, null, { + const child = fork(`${__dirname}/scripts/marketMaker`, { // https://nodejs.org/api/child_process.html#optionsdetached // detached also doesn't seem to be making a difference. detached: true, @@ -263,7 +290,7 @@ export class Dex { }); console.log( - `Process ${child.pid}: Running Market Maker for ${market.baseCoin.symbol}/${market.quoteCoin.symbol}. Note: No crank running`, + `Process ${child.pid}: Running Market Maker for ${market.baseCoin.symbol}/${market.quoteCoin.symbol}.`, ); // unref doesn't seem to be making a difference for a forked process. @@ -289,4 +316,46 @@ export class Dex { return child; } + + /** + * Runs a crank on a separate node process for the given `DexMarket` for specified duration. + * + * @param market The `DexMarket` to run a crank for + * @param owner The owner `FileKeypair` consuming events. + * @param opts The crank options used + * @returns + */ + public runCrank( + market: DexMarket, + owner: FileKeypair, + opts: CrankOpts, + ): ChildProcess { + if (opts.durationInSecs < 0) + throw new Error("Duration must be greater than 0."); + + const child = fork(`${__dirname}/scripts/cranker`, { + // https://nodejs.org/api/child_process.html#optionsdetached + // detached also doesn't seem to be making a difference. + detached: true, + stdio: ["pipe", 0, 0, "ipc"], + }); + + console.log( + `Process ${child.pid}: Running Crank for ${market.baseCoin.symbol}/${market.quoteCoin.symbol}.`, + ); + + child.send({ + action: "start", + args: { + marketAddress: market.address.toString(), + programID: this.address.toString(), + rpcEndpoint: this.connection.rpcEndpoint, + ownerFilePath: owner.filePath, + duration: opts.durationInSecs * 1000, + verbose: opts.verbose ? "true" : "false", + }, + }); + + return child; + } } diff --git a/ts/src/index.ts b/ts/src/index.ts index 823ee18..f2f6a8e 100644 --- a/ts/src/index.ts +++ b/ts/src/index.ts @@ -2,4 +2,4 @@ export * from "./dex"; export * from "./coin"; export * from "./market"; export * from "./fileKeypair"; -export * from "./types"; +export { OrderType, TransactionWithSigners } from "./types"; diff --git a/ts/src/market.ts b/ts/src/market.ts index 05af787..4a9915c 100644 --- a/ts/src/market.ts +++ b/ts/src/market.ts @@ -19,7 +19,7 @@ import { import { Market as SerumMarket } from "@project-serum/serum"; import { Coin } from "./coin"; import { getDecimalCount, withAssociatedTokenAccount } from "./utils"; -import { OrderType, TransactionWithSigners } from "./types"; +import { OrderType, SelfTradeBehaviour, TransactionWithSigners } from "./types"; const REQUEST_QUEUE_SIZE = 5120 + 12; // https://github.com/mithraiclabs/psyoptions/blob/f0c9f73408a27676e0c7f156f5cae71f73f59c3f/programs/psy_american/src/lib.rs#L1003 const EVENT_QUEUE_SIZE = 262144 + 12; // https://github.com/mithraiclabs/psyoptions-ts/blob/ba1888ea83e634e1c7a8dad820fe67d053cf3f5c/packages/psy-american/src/instructions/initializeSerumMarket.ts#L84 @@ -289,6 +289,7 @@ export class DexMarket { orderType: OrderType, size: number, price: number, + selfTradeBehaviour?: SelfTradeBehaviour, ): Promise { try { DexMarket.sanityCheck(serumMarket, price, size); @@ -335,6 +336,7 @@ export class DexMarket { orderType, feeDiscountPubkey: null, openOrdersAddressKey: openOrders.address, + selfTradeBehavior: selfTradeBehaviour, }; const { transaction: placeOrderTx, signers: placeOrderSigners } = diff --git a/ts/src/scripts/cranker.ts b/ts/src/scripts/cranker.ts new file mode 100644 index 0000000..ee4c9b4 --- /dev/null +++ b/ts/src/scripts/cranker.ts @@ -0,0 +1,70 @@ +import { MessageType } from "../types"; +import { Market as SerumMarket } from "@project-serum/serum"; +import { Connection, Keypair, PublicKey, Transaction } from "@solana/web3.js"; +import { delay, logIfVerbose } from "../utils"; +import { FileKeypair } from "../fileKeypair"; + +const MAX_OPEN_ORDERS = 10; + +process.on("message", async (message: MessageType) => { + if (message.action === "start") { + await basicCranker(message.args); + } +}); + +const crank = async ( + market: SerumMarket, + owner: Keypair, + connection: Connection, + isVerbose: boolean, +) => { + const eventQueue = await market.loadEventQueue(connection); + logIfVerbose(`EventQueue length: ${eventQueue.length}`, isVerbose); + + if (eventQueue.length > 0) { + const orderedAccounts: PublicKey[] = eventQueue + .slice(0, MAX_OPEN_ORDERS) + .map((e) => e.openOrders) + .sort((a, b) => a.toBuffer().swap64().compare(b.toBuffer().swap64())); + + const tx = new Transaction(); + tx.add(market.makeConsumeEventsInstruction(orderedAccounts, 65535)); + + try { + const sig = await connection.sendTransaction(tx, [owner]); + await connection.confirmTransaction(sig, "confirmed"); + logIfVerbose(`ConsumeEvents: ${sig}`, isVerbose); + logIfVerbose(`------ ConsumeEvents Confirmed ------`, isVerbose); + } catch (err) { + logIfVerbose(`Error: ${err}`, isVerbose); + logIfVerbose(`------ ConsumeEvents Failed ------`, isVerbose); + } + } +}; + +const basicCranker = async (args) => { + setTimeout(() => { + console.log(`Exiting Cranker @ ${process.pid}`); + process.exit(0); + }, Number.parseInt(args.duration)); + + const isVerbose = args.verbose === "true"; + + const owner = FileKeypair.load(args.ownerFilePath); + const connection = new Connection(args.rpcEndpoint, "confirmed"); + + const serumMarket = await SerumMarket.load( + connection, + new PublicKey(args.marketAddress), + { commitment: "confirmed" }, + new PublicKey(args.programID), + ); + + await crank(serumMarket, owner.keypair, connection, isVerbose); + + do { + await delay(2000); + await crank(serumMarket, owner.keypair, connection, isVerbose); + // eslint-disable-next-line no-constant-condition + } while (true); +}; diff --git a/ts/src/scripts/marketMaker.ts b/ts/src/scripts/marketMaker.ts index 00a3f4c..1bb3889 100644 --- a/ts/src/scripts/marketMaker.ts +++ b/ts/src/scripts/marketMaker.ts @@ -10,12 +10,7 @@ import { FileKeypair } from "../fileKeypair"; import { DexMarket } from "../market"; import axios from "axios"; import { getDecimalCount, roundToDecimal } from "../utils"; - -type MessageType = { - action: "start"; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - args: any; -}; +import { MessageType } from "../types"; process.on("message", async (message: MessageType) => { if (message.action === "start") { @@ -121,6 +116,7 @@ const placeOrders = async ( "postOnly", buySize, buyPrice, + "decrementTake", ); tx.add(buyTransaction); signersArray.push(buySigners); @@ -136,6 +132,7 @@ const placeOrders = async ( "postOnly", sellSize, sellPrice, + "decrementTake", ); tx.add(sellTransaction); signersArray.push(sellSigners); @@ -169,7 +166,7 @@ const marketMaker = async (args) => { const owner = FileKeypair.load(args.ownerFilePath); - placeOrders(owner.keypair, serumMarket, connection, { + await placeOrders(owner.keypair, serumMarket, connection, { orderCount: Number.parseInt(args.orderCount), initialBidSize: Number.parseInt(args.initialBidSize), baseGeckoSymbol: args.baseGeckoSymbol, diff --git a/ts/src/types.ts b/ts/src/types.ts index 10e1450..4ce41ac 100644 --- a/ts/src/types.ts +++ b/ts/src/types.ts @@ -6,3 +6,13 @@ export type TransactionWithSigners = { }; export type OrderType = "limit" | "ioc" | "postOnly"; +export type SelfTradeBehaviour = + | "decrementTake" + | "cancelProvide" + | "abortTransaction"; + +export type MessageType = { + action: "start"; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + args: any; +}; diff --git a/ts/src/utils.ts b/ts/src/utils.ts index 7a3aa0f..db07944 100644 --- a/ts/src/utils.ts +++ b/ts/src/utils.ts @@ -77,3 +77,13 @@ export function roundToDecimal( ) { return decimals ? Math.round(value * 10 ** decimals) / 10 ** decimals : value; } + +export function logIfVerbose(message: string, isVerbose: boolean) { + if (isVerbose) { + console.log(message); + } +} + +export function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/ts/tests/dev.spec.ts b/ts/tests/dev.spec.ts index a9cc7f8..48248f9 100644 --- a/ts/tests/dev.spec.ts +++ b/ts/tests/dev.spec.ts @@ -48,15 +48,10 @@ describe("Serum Dev Tools", () => { }); it("can init dex market", async () => { - dexMarket = await dex.initDexMarket( - owner.keypair, - dex.getCoin("SAYA"), - dex.getCoin("SRM"), - { - lotSize: 1e-3, - tickSize: 1e-2, - }, - ); + dexMarket = await dex.initDexMarket(owner.keypair, baseCoin, quoteCoin, { + lotSize: 1e-3, + tickSize: 1e-2, + }); assert.equal( dexMarket.address.toBase58(), @@ -96,4 +91,41 @@ describe("Serum Dev Tools", () => { assert.equal(orders[0].size, 10); assert.equal(orders[0].side, "buy"); }); + + it("can load coins", async () => { + const tempCoin = await dex.createCoin( + "test", + 9, + owner.keypair, + owner.keypair, + null, + ); + + const loadedCoin = await Coin.load( + connection, + "test", + tempCoin.mint, + owner.keypair, + null, + ); + + assert.ok(tempCoin.isEqual(loadedCoin)); + assert.deepEqual(tempCoin, loadedCoin); + }); + + it("invalid freeze authority while load coins", async () => { + const tempCoin = await dex.createCoin( + "test", + 9, + owner.keypair, + owner.keypair, + owner.keypair, + ); + + try { + await Coin.load(connection, "test", tempCoin.mint, owner.keypair, null); + } catch (err) { + assert.ok(true); + } + }); }); diff --git a/ts/tsconfig.json b/ts/tsconfig.json index 3ee56cf..f3c8e92 100644 --- a/ts/tsconfig.json +++ b/ts/tsconfig.json @@ -4,6 +4,7 @@ "rootDir": ".", "typeRoots": ["./node_modules/@types"], "types": ["mocha", "chai", "node"], + "strictNullChecks": true }, "include": ["src/**/*.ts", "tests/**/*.ts", "**/*.test.ts"], "exclude": ["node_modules", "dist"],