From f6bdbf959a969a5e487bccd3edd0155f02506ebc Mon Sep 17 00:00:00 2001 From: moosecat Date: Wed, 18 Dec 2024 14:09:09 -0800 Subject: [PATCH] Nour/pyth lazer cranker (#321) * add pyth lazer cranker * rm unncessary libraries * remove unnecessary code * add entrypoint for the cranker * added improvements * increase the chunk size --- package.json | 1 + src/bots/pythLazerCranker.ts | 268 +++++++++++++++++++++++++++++++++++ src/config.ts | 12 ++ src/index.ts | 14 ++ yarn.lock | 17 ++- 5 files changed, 310 insertions(+), 2 deletions(-) create mode 100644 src/bots/pythLazerCranker.ts diff --git a/package.json b/package.json index fa590639..19c76068 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@project-serum/anchor": "0.19.1-beta.1", "@project-serum/serum": "0.13.65", "@pythnetwork/price-service-client": "1.9.0", + "@pythnetwork/pyth-lazer-sdk": "^0.1.1", "@solana/spl-token": "0.3.7", "@solana/web3.js": "1.92.3", "@types/bn.js": "5.1.5", diff --git a/src/bots/pythLazerCranker.ts b/src/bots/pythLazerCranker.ts new file mode 100644 index 00000000..f8382e42 --- /dev/null +++ b/src/bots/pythLazerCranker.ts @@ -0,0 +1,268 @@ +import { Bot } from '../types'; +import { logger } from '../logger'; +import { GlobalConfig, PythLazerCrankerBotConfig } from '../config'; +import { PriceUpdateAccount } from '@pythnetwork/pyth-solana-receiver/lib/PythSolanaReceiver'; +import { + BlockhashSubscriber, + DriftClient, + getOracleClient, + getPythLazerOraclePublicKey, + OracleClient, + OracleSource, + PriorityFeeSubscriber, + TxSigAndSlot, +} from '@drift-labs/sdk'; +import { BundleSender } from '../bundleSender'; +import { + AddressLookupTableAccount, + ComputeBudgetProgram, +} from '@solana/web3.js'; +import { chunks, simulateAndGetTxWithCUs, sleepMs } from '../utils'; +import { Agent, setGlobalDispatcher } from 'undici'; +import { PythLazerClient } from '@pythnetwork/pyth-lazer-sdk'; + +setGlobalDispatcher( + new Agent({ + connections: 200, + }) +); + +const SIM_CU_ESTIMATE_MULTIPLIER = 1.5; + +export class PythLazerCrankerBot implements Bot { + private wsClient: PythLazerClient; + private pythOracleClient: OracleClient; + readonly decodeFunc: (name: string, data: Buffer) => PriceUpdateAccount; + + public name: string; + public dryRun: boolean; + private intervalMs: number; + private feedIdChunkToPriceMessage: Map = new Map(); + public defaultIntervalMs = 30_000; + + private blockhashSubscriber: BlockhashSubscriber; + private health: boolean = true; + private slotStalenessThresholdRestart: number = 300; + private txSuccessRateThreshold: number = 0.5; + + constructor( + private globalConfig: GlobalConfig, + private crankConfigs: PythLazerCrankerBotConfig, + private driftClient: DriftClient, + private priorityFeeSubscriber?: PriorityFeeSubscriber, + private bundleSender?: BundleSender, + private lookupTableAccounts: AddressLookupTableAccount[] = [] + ) { + this.name = crankConfigs.botId; + this.dryRun = crankConfigs.dryRun; + this.intervalMs = crankConfigs.intervalMs; + if (!globalConfig.hermesEndpoint) { + throw new Error('Missing hermesEndpoint in global config'); + } + + if (globalConfig.driftEnv != 'devnet') { + throw new Error('Only devnet drift env is supported'); + } + + const hermesEndpointParts = globalConfig.hermesEndpoint.split('?token='); + this.wsClient = new PythLazerClient( + hermesEndpointParts[0], + hermesEndpointParts[1] + ); + + this.pythOracleClient = getOracleClient( + OracleSource.PYTH_LAZER, + driftClient.connection, + driftClient.program + ); + this.decodeFunc = + this.driftClient.program.account.pythLazerOracle.coder.accounts.decodeUnchecked.bind( + this.driftClient.program.account.pythLazerOracle.coder.accounts + ); + + this.blockhashSubscriber = new BlockhashSubscriber({ + connection: driftClient.connection, + }); + this.txSuccessRateThreshold = crankConfigs.txSuccessRateThreshold; + this.slotStalenessThresholdRestart = + crankConfigs.slotStalenessThresholdRestart; + } + + async init(): Promise { + logger.info(`Initializing ${this.name} bot`); + await this.blockhashSubscriber.subscribe(); + this.lookupTableAccounts.push( + await this.driftClient.fetchMarketLookupTableAccount() + ); + + const updateConfigs = this.crankConfigs.updateConfigs; + + let subscriptionId = 1; + for (const configChunk of chunks(Object.keys(updateConfigs), 11)) { + const priceFeedIds: number[] = configChunk.map((alias) => { + return updateConfigs[alias].feedId; + }); + + const sendMessage = () => + this.wsClient.send({ + type: 'subscribe', + subscriptionId, + priceFeedIds, + properties: ['price'], + chains: ['solana'], + deliveryFormat: 'json', + channel: 'fixed_rate@200ms', + jsonBinaryEncoding: 'hex', + }); + if (this.wsClient.ws.readyState != 1) { + this.wsClient.ws.addEventListener('open', () => { + sendMessage(); + }); + } else { + sendMessage(); + } + + this.wsClient.addMessageListener((message) => { + switch (message.type) { + case 'json': { + if (message.value.type == 'streamUpdated') { + if (message.value.solana?.data) + this.feedIdChunkToPriceMessage.set( + priceFeedIds, + message.value.solana.data + ); + } + break; + } + default: { + break; + } + } + }); + subscriptionId++; + } + + this.priorityFeeSubscriber?.updateAddresses( + Object.keys(this.feedIdChunkToPriceMessage) + .flat() + .map((feedId) => + getPythLazerOraclePublicKey( + this.driftClient.program.programId, + Number(feedId) + ) + ) + ); + } + + async reset(): Promise { + logger.info(`Resetting ${this.name} bot`); + this.blockhashSubscriber.unsubscribe(); + await this.driftClient.unsubscribe(); + this.wsClient.ws.close(); + } + + async startIntervalLoop(intervalMs = this.intervalMs): Promise { + logger.info(`Starting ${this.name} bot with interval ${intervalMs} ms`); + await sleepMs(5000); + await this.runCrankLoop(); + + setInterval(async () => { + await this.runCrankLoop(); + }, intervalMs); + } + + private async getBlockhashForTx(): Promise { + const cachedBlockhash = this.blockhashSubscriber.getLatestBlockhash(10); + if (cachedBlockhash) { + return cachedBlockhash.blockhash as string; + } + + const recentBlockhash = + await this.driftClient.connection.getLatestBlockhash({ + commitment: 'confirmed', + }); + + return recentBlockhash.blockhash; + } + + async runCrankLoop() { + for (const [ + feedIds, + priceMessage, + ] of this.feedIdChunkToPriceMessage.entries()) { + const ixs = [ + ComputeBudgetProgram.setComputeUnitLimit({ + units: 1_400_000, + }), + ]; + if (this.globalConfig.useJito) { + ixs.push(this.bundleSender!.getTipIx()); + const simResult = await simulateAndGetTxWithCUs({ + ixs, + connection: this.driftClient.connection, + payerPublicKey: this.driftClient.wallet.publicKey, + lookupTableAccounts: this.lookupTableAccounts, + cuLimitMultiplier: SIM_CU_ESTIMATE_MULTIPLIER, + doSimulation: true, + recentBlockhash: await this.getBlockhashForTx(), + }); + simResult.tx.sign([ + // @ts-ignore + this.driftClient.wallet.payer, + ]); + this.bundleSender?.sendTransactions( + [simResult.tx], + undefined, + undefined, + false + ); + } else { + const priorityFees = Math.floor( + (this.priorityFeeSubscriber?.getCustomStrategyResult() || 0) * + this.driftClient.txSender.getSuggestedPriorityFeeMultiplier() + ); + logger.info( + `Priority fees to use: ${priorityFees} with multiplier: ${this.driftClient.txSender.getSuggestedPriorityFeeMultiplier()}` + ); + ixs.push( + ComputeBudgetProgram.setComputeUnitPrice({ + microLamports: priorityFees, + }) + ); + } + const pythLazerIxs = + await this.driftClient.getPostPythLazerOracleUpdateIxs( + feedIds, + priceMessage, + ixs + ); + ixs.push(...pythLazerIxs); + const simResult = await simulateAndGetTxWithCUs({ + ixs, + connection: this.driftClient.connection, + payerPublicKey: this.driftClient.wallet.publicKey, + lookupTableAccounts: this.lookupTableAccounts, + cuLimitMultiplier: SIM_CU_ESTIMATE_MULTIPLIER, + doSimulation: true, + recentBlockhash: await this.getBlockhashForTx(), + }); + const startTime = Date.now(); + this.driftClient + .sendTransaction(simResult.tx) + .then((txSigAndSlot: TxSigAndSlot) => { + logger.info( + `Posted pyth lazer oracles for ${feedIds} update atomic tx: ${ + txSigAndSlot.txSig + }, took ${Date.now() - startTime}ms` + ); + }) + .catch((e) => { + console.log(e); + }); + } + } + + async healthCheck(): Promise { + return this.health; + } +} diff --git a/src/config.ts b/src/config.ts index b1721a26..d74de0ee 100644 --- a/src/config.ts +++ b/src/config.ts @@ -108,6 +108,17 @@ export type PythCrankerBotConfig = BaseBotConfig & { }; }; +export type PythLazerCrankerBotConfig = BaseBotConfig & { + slotStalenessThresholdRestart: number; + txSuccessRateThreshold: number; + intervalMs: number; + updateConfigs: { + [key: string]: { + feedId: number; + }; + }; +}; + export type SwitchboardCrankerBotConfig = BaseBotConfig & { intervalMs: number; queuePubkey: string; @@ -135,6 +146,7 @@ export type BotConfigMap = { userIdleFlipper?: BaseBotConfig; markTwapCrank?: BaseBotConfig; pythCranker?: PythCrankerBotConfig; + pythLazerCranker?: PythLazerCrankerBotConfig; switchboardCranker?: SwitchboardCrankerBotConfig; swiftTaker?: BaseBotConfig; swiftMaker?: BaseBotConfig; diff --git a/src/index.ts b/src/index.ts index a6a42d39..7fff67b7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -75,6 +75,7 @@ import { webhookMessage } from './webhook'; import { PythPriceFeedSubscriber } from './pythPriceFeedSubscriber'; import { PythCrankerBot } from './bots/pythCranker'; import { SwitchboardCrankerBot } from './bots/switchboardCranker'; +import { PythLazerCrankerBot } from './bots/pythLazerCranker'; require('dotenv').config(); const commitHash = process.env.COMMIT ?? ''; @@ -562,6 +563,19 @@ const runBot = async () => { ) ); } + if (configHasBot(config, 'pythLazerCranker')) { + needPriorityFeeSubscriber = true; + bots.push( + new PythLazerCrankerBot( + config.global, + config.botConfigs!.pythLazerCranker!, + driftClient, + priorityFeeSubscriber, + bundleSender, + [] + ) + ); + } if (configHasBot(config, 'switchboardCranker')) { needPriorityFeeSubscriber = true; needDriftStateWatcher = true; diff --git a/yarn.lock b/yarn.lock index a242d25c..26871db5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1138,7 +1138,15 @@ dependencies: bn.js "^5.2.1" -"@pythnetwork/pyth-solana-receiver@0.7.0": +"@pythnetwork/pyth-lazer-sdk@^0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@pythnetwork/pyth-lazer-sdk/-/pyth-lazer-sdk-0.1.1.tgz#5242c04f9b4f6ee0d3cc1aad228dfcb85b5e6498" + integrity sha512-/Zr9qbNi9YZb9Nl3ilkUKgeSQovevsXV57pIGrw04NFUmK4Ua92o2SyK8RRaqcw8zYtiDbseU1CgWHCfGYjRRQ== + dependencies: + isomorphic-ws "^5.0.0" + ws "^8.18.0" + +"@pythnetwork/pyth-solana-receiver@^0.7.0": version "0.7.0" resolved "https://registry.yarnpkg.com/@pythnetwork/pyth-solana-receiver/-/pyth-solana-receiver-0.7.0.tgz#253a0d15a135d625ceca7ba1b47940dd03b9cab6" integrity sha512-OoEAHh92RPRdKkfjkcKGrjC+t0F3SEL754iKFmixN9zyS8pIfZSVfFntmkHa9pWmqEMxdx/i925a8B5ny8Tuvg== @@ -3703,6 +3711,11 @@ isomorphic-ws@^4.0.1: resolved "https://registry.yarnpkg.com/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz#55fd4cd6c5e6491e76dc125938dd863f5cd4f2dc" integrity sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w== +isomorphic-ws@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz#e5529148912ecb9b451b46ed44d53dae1ce04bbf" + integrity sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw== + jayson@^4.0.0, jayson@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/jayson/-/jayson-4.1.0.tgz#60dc946a85197317f2b1439d672a8b0a99cea2f9" @@ -5236,7 +5249,7 @@ wrappy@1: resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== -ws@8.18.0: +ws@8.18.0, ws@^8.18.0: version "8.18.0" resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc" integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==