diff --git a/package.json b/package.json index 237eab4c..eb108a09 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "main": "lib/index.js", "license": "Apache-2.0", "dependencies": { - "@drift-labs/jit-proxy": "0.12.17", - "@drift-labs/sdk": "2.104.0-beta.30", + "@drift-labs/jit-proxy": "0.12.21", + "@drift-labs/sdk": "2.104.0-beta.34", "@opentelemetry/api": "1.7.0", "@opentelemetry/auto-instrumentations-node": "0.31.2", "@opentelemetry/exporter-prometheus": "0.31.0", @@ -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/liquidator.ts b/src/bots/liquidator.ts index e088ef44..14c38f1c 100644 --- a/src/bots/liquidator.ts +++ b/src/bots/liquidator.ts @@ -82,6 +82,7 @@ import { calculateAccountValueUsd, checkIfAccountExists, handleSimResultError, + isSolLstToken, simulateAndGetTxWithCUs, SimulateAndGetTxWithCUsResponse, } from '../utils'; @@ -1259,10 +1260,17 @@ export class LiquidatorBot implements Bot { let outMarket: SpotMarketAccount | undefined; let inMarket: SpotMarketAccount | undefined; let amountIn: BN | undefined; + const spotMarketIsSolLst = isSolLstToken(spotMarketIndex); if (isVariant(orderDirection, 'long')) { - // sell USDC, buy spotMarketIndex - inMarket = this.driftClient.getSpotMarketAccount(0); - outMarket = this.driftClient.getSpotMarketAccount(spotMarketIndex); + if (spotMarketIsSolLst) { + // sell SOL, buy the LST + inMarket = this.driftClient.getSpotMarketAccount(1); + outMarket = this.driftClient.getSpotMarketAccount(spotMarketIndex); + } else { + // sell USDC, buy spotMarketIndex + inMarket = this.driftClient.getSpotMarketAccount(0); + outMarket = this.driftClient.getSpotMarketAccount(spotMarketIndex); + } if (!inMarket || !outMarket) { logger.error('failed to get spot markets'); return undefined; @@ -1274,9 +1282,15 @@ export class LiquidatorBot implements Bot { .mul(inPrecision) .div(PRICE_PRECISION.mul(outPrecision)); } else { - // sell spotMarketIndex, buy USDC - inMarket = this.driftClient.getSpotMarketAccount(spotMarketIndex); - outMarket = this.driftClient.getSpotMarketAccount(0); + if (spotMarketIsSolLst) { + // sell spotMarketIndex, buy SOL + inMarket = this.driftClient.getSpotMarketAccount(spotMarketIndex); + outMarket = this.driftClient.getSpotMarketAccount(1); + } else { + // sell spotMarketIndex, buy USDC + inMarket = this.driftClient.getSpotMarketAccount(spotMarketIndex); + outMarket = this.driftClient.getSpotMarketAccount(0); + } amountIn = baseAmountIn; } 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/src/utils.ts b/src/utils.ts index b8bfe0f1..d442f263 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1553,3 +1553,14 @@ export function getMarketsAndOracleInfosToLoad( spotIndexes && spotIndexes.length > 0 ? spotIndexes : undefined, }; } + +export function isSolLstToken(spotMarketIndex: number): boolean { + return [ + 2, // mSOL + 6, // jitoSOL + 8, // bSOL + 16, // INF + 17, // dSOL + 25, // BNSOL + ].includes(spotMarketIndex); +} diff --git a/yarn.lock b/yarn.lock index c9bd1f3f..1b8936de 100644 --- a/yarn.lock +++ b/yarn.lock @@ -168,19 +168,19 @@ enabled "2.0.x" kuler "^2.0.0" -"@drift-labs/jit-proxy@0.12.17": - version "0.12.17" - resolved "https://registry.yarnpkg.com/@drift-labs/jit-proxy/-/jit-proxy-0.12.17.tgz#b1adfbdf586bbef31c5ef7adf6719a214f424d97" - integrity sha512-N3PJp3npOWBgYsKX2tWpfL+wCyk7stFM9GRu87sGDwmUSkvTah7Mj9r5xG1UxmDp8Gyo5alMprdsoDW8bsX3Ug== +"@drift-labs/jit-proxy@0.12.21": + version "0.12.21" + resolved "https://registry.yarnpkg.com/@drift-labs/jit-proxy/-/jit-proxy-0.12.21.tgz#7f4303b78966b7ef23d53cdd58ce252086b5a8ee" + integrity sha512-RQ5wvvzesYAgVXW+KhRGZNlEgQYF1TUoZAiUtT6vskuKronXMRI8HPzE9f8YeOAx2CsIWDBUhXqaduUMuy7EwQ== dependencies: "@coral-xyz/anchor" "0.26.0" - "@drift-labs/sdk" "2.104.0-beta.30" + "@drift-labs/sdk" "2.104.0-beta.34" "@solana/web3.js" "1.91.7" -"@drift-labs/sdk@2.104.0-beta.30": - version "2.104.0-beta.30" - resolved "https://registry.yarnpkg.com/@drift-labs/sdk/-/sdk-2.104.0-beta.30.tgz#7906065e0df019c27d14967f3a7019091abb802c" - integrity sha512-lwIeXozTHv1sZjS/yu+RZXlz2pTC+DA5YXqpOqfwgk6roUaXcr9t7T7rjs3YVe6Uv3C465UHMAcWRNRZOIBIIA== +"@drift-labs/sdk@2.104.0-beta.34": + version "2.104.0-beta.34" + resolved "https://registry.yarnpkg.com/@drift-labs/sdk/-/sdk-2.104.0-beta.34.tgz#665ade52bc9af6333110246d597948e613e75fa3" + integrity sha512-ObXjbuAEP+hYzUVkvccMPCZbC00EwV6lEWPEb3fyHPqQUjjxklwTdZqpOJYyBBq/bKD/FYtoqw6PS4qgZ+m0wQ== dependencies: "@coral-xyz/anchor" "0.29.0" "@coral-xyz/anchor-30" "npm:@coral-xyz/anchor@0.30.1" @@ -1137,6 +1137,14 @@ dependencies: bn.js "^5.2.1" +"@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" @@ -3702,6 +3710,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" @@ -5235,7 +5248,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==