Sfoglia il codice sorgente

Merge pull request #83 from fdundjer/feat/v2

feat: v2
Alexandru Higyedi 1 anno fa
parent
commit
2c7770f092

+ 34 - 10
.env.copy

@@ -1,16 +1,40 @@
+# Wallet
 PRIVATE_KEY=
+
+# Connection
 RPC_ENDPOINT=https://api.mainnet-beta.solana.com
 RPC_WEBSOCKET_ENDPOINT=wss://api.mainnet-beta.solana.com
+COMMITMENT_LEVEL=confirmed
+
+# Bot
+LOG_LEVEL=debug
+ONE_TOKEN_AT_A_TIME=true
+COMPUTE_UNIT_LIMIT=421197
+COMPUTE_UNIT_PRICE=101337
+PRE_LOAD_EXISTING_MARKETS=false
+CACHE_NEW_MARKETS=false
+
+# Buy
 QUOTE_MINT=WSOL
-QUOTE_AMOUNT=0.01
-COMMITMENT_LEVEL=finalized
+QUOTE_AMOUNT=0.001
+AUTO_BUY_DELAY=0
+MAX_BUY_RETRIES=10
+BUY_SLIPPAGE=5
+
+# Sell
+AUTO_SELL=true
+MAX_SELL_RETRIES=10
+AUTO_SELL_DELAY=0
+PRICE_CHECK_INTERVAL=2000
+PRICE_CHECK_DURATION=60000
+TAKE_PROFIT=25
+STOP_LOSS=15
+SELL_SLIPPAGE=5
+
+# Filters
 USE_SNIPE_LIST=false
 SNIPE_LIST_REFRESH_INTERVAL=30000
-CHECK_IF_MINT_IS_RENOUNCED=false
-AUTO_SELL=true
-MAX_SELL_RETRIES=5
-AUTO_SELL_DELAY=1000
-LOG_LEVEL=info
-MIN_POOL_SIZE=10
-MAX_POOL_SIZE=50
-ONE_TOKEN_AT_A_TIME=true
+CHECK_IF_MINT_IS_RENOUNCED=true
+CHECK_IF_BURNED=false
+MIN_POOL_SIZE=5
+MAX_POOL_SIZE=50

+ 57 - 39
README.md

@@ -14,55 +14,68 @@ To run the script you need to:
 - Convert some SOL to USDC or WSOL.
   - You need USDC or WSOL depending on the configuration set below.
 - Configure the script by updating `.env.copy` file (remove the .copy from the file name when done).
-  - PRIVATE_KEY (your wallet private key)
-  - RPC_ENDPOINT (https RPC endpoint)
-  - RPC_WEBSOCKET_ENDPOINT (websocket RPC endpoint)
-  - QUOTE_MINT (which pools to snipe, USDC or WSOL)
-  - QUOTE_AMOUNT (amount used to buy each new token)
-  - COMMITMENT_LEVEL
-  - USE_SNIPE_LIST (buy only tokens listed in snipe-list.txt)
-  - SNIPE_LIST_REFRESH_INTERVAL (how often snipe list should be refreshed in milliseconds)
-  - CHECK_IF_MINT_IS_RENOUNCED (script will buy only if mint is renounced)
-  - MIN_POOL_SIZE (EXPERIMENTAL) (script will buy only if pool size is greater than specified amount)
-    - set to 0 to disable pool size check
+  - Check [Configuration](#configuration) section bellow
 - Install dependencies by typing: `npm install`
-- Run the script by typing: `npm run buy` in terminal
+- Run the script by typing: `npm run start` in terminal
 
 You should see the following output:  
 ![output](readme/output.png)
 
-## Snipe list
-By default, script buys each token which has a new liquidity pool created and open for trading. 
-There are scenarios when you want to buy one specific token as soon as possible during the launch event.
-To achieve this, you'll have to use snipe list.
-- Change variable `USE_SNIPE_LIST` to `true`
-- Add token mint addresses you wish to buy in `snipe-list.txt` file
-  - Add each address as a new line
-
-This will prevent script from buying everything, and instead it will buy just listed tokens.
-You can update the list while script is running. Script will check for new values in specified interval (`SNIPE_LIST_REFRESH_INTERVAL`).
-
-Pool must not exist before the script starts.
-It will buy only when new pool is open for trading. If you want to buy token that will be launched in the future, make sure that script is running before the launch.
-
-## Auto Sell
-By default, auto sell is enabled. If you want to disable it, you need to:
-- Change variable `AUTO_SELL` to `false`
-- Update `MAX_SELL_RETRIES` to set the maximum number of retries for selling token
-- Update `AUTO_SELL_DELAY` to the number of milliseconds you want to wait before selling the token
-  - This will sell the token after the specified delay. (+- RPC node speed)
-
-If you set AUTO_SELL_DELAY to 0, token will be sold immediately after it is bought.
-
-There is no guarantee that the token will be sold at a profit or even sold at all. The developer is not responsible for any losses incurred by using this feature.
+### Configuration
+
+#### Wallet
+- `PRIVATE_KEY` - Your wallet's private key.
+
+#### Connection
+- `RPC_ENDPOINT` - HTTPS RPC endpoint for interacting with the Solana network.
+- `RPC_WEBSOCKET_ENDPOINT` - WebSocket RPC endpoint for real-time updates from the Solana network.
+- `COMMITMENT_LEVEL`- The commitment level of transactions (e.g., "finalized" for the highest level of security).
+
+#### Bot
+- `LOG_LEVEL` - Set logging level, e.g., `info`, `debug`, `trace`, etc.
+- `ONE_TOKEN_AT_A_TIME` - Set to `true` to process buying one token at a time.
+- `COMPUTE_UNIT_LIMIT` - Compute limit used to calculate fees.
+- `COMPUTE_UNIT_PRICE` - Compute price used to calculate fees.
+- `PRE_LOAD_EXISTING_MARKETS` - Bot will load all existing markets in memory on start.
+  - This option should not be used with public RPC.
+- `CACHE_NEW_MARKETS` - Set to `true` to cache new markets.
+  - This option should not be used with public RPC.
+
+#### Buy
+- `QUOTE_MINT` - Amount used to buy each new token.
+- `QUOTE_AMOUNT` - Which pools to snipe, USDC or WSOL.
+- `AUTO_BUY_DELAY` - Delay in milliseconds before buying a token.
+- `MAX_BUY_RETRIES` - Maximum number of retries for buying a token.
+- `BUY_SLIPPAGE` - Slippage %
+
+#### Sell
+- `AUTO_SELL` - Set to `true` to enable automatic selling of tokens.
+- `MAX_SELL_RETRIES` - Maximum number of retries for selling a token.
+- `AUTO_SELL_DELAY` -  Delay in milliseconds before auto-selling a token.
+- `PRICE_CHECK_INTERVAL` - Interval in milliseconds for checking the take profit and stop loss conditions.
+- `PRICE_CHECK_DURATION` - Time in milliseconds to wait for stop loss/take profit conditions.
+  - If you don't reach profit or loss bot will auto sell after this time.
+- `TAKE_PROFIT` - Percentage profit at which to take profit.
+  - Take profit is calculated based on quote mint.
+- `STOP_LOSS` - Percentage loss at which to stop the loss.
+  - Stop loss is calculated based on quote mint.
+- `SELL_SLIPPAGE` - Slippage %.
+
+#### Filters
+- `USE_SNIPE_LIST` - Set to `true` to enable buying only tokens listed in `snipe-list.txt`.
+  - Pool must not exist before the script starts.
+- `SNIPE_LIST_REFRESH_INTERVAL` - Interval in milliseconds to refresh the snipe list.
+- `CHECK_IF_MINT_IS_RENOUNCED` - Set to `true` to buy tokens only if their mint is renounced.
+- `CHECK_IF_BURNED` - Set to `true` to buy tokens only if their liqudity pool is burned.
+- `MIN_POOL_SIZE` - Bot will buy only if the pool size is greater than the specified amount.
+  - Set `0` to disable.
+- `MAX_POOL_SIZE` - Bot will buy only if the pool size is less than the specified amount.
+  - Set `0` to disable.
 
 ## Common issues
 If you have an error which is not listed here, please create a new issue in this repository.
 To collect more information on an issue, please change `LOG_LEVEL` to `debug`.
 
-### Empty transaction
-- If you see empty transactions on SolScan most likely fix is to change commitment level to `finalized`.
-
 ### Unsupported RPC node
 - If you see following error in your log file:  
   `Error: 410 Gone:  {"jsonrpc":"2.0","error":{"code": 410, "message":"The RPC call or parameters have been disabled."}, "id": "986f3599-b2b7-47c4-b951-074c19842bad" }`  
@@ -80,6 +93,11 @@ To collect more information on an issue, please change `LOG_LEVEL` to `debug`.
 ## Contact
 [![](https://img.shields.io/discord/1201826085655023616?color=5865F2&logo=Discord&style=flat-square)](https://discord.gg/xYUETCA2aP)
 
+- If you want to leave a tip, you can send it to the following address:
+`7gm6BPQrSBaTAYaJheuRevBNXcmKsgbkfBCVSjBnt9aP`
+
+- If you need custom features or assistance, feel free to contact the admin team on discord for dedicated support.
+
 ## Disclaimer
 
 Use this script at your own risk.

+ 393 - 0
bot.ts

@@ -0,0 +1,393 @@
+import {
+  ComputeBudgetProgram,
+  Connection,
+  Keypair,
+  PublicKey,
+  TransactionMessage,
+  VersionedTransaction,
+} from '@solana/web3.js';
+import {
+  createAssociatedTokenAccountIdempotentInstruction,
+  createCloseAccountInstruction,
+  getAccount,
+  getAssociatedTokenAddress,
+  RawAccount,
+  TOKEN_PROGRAM_ID,
+} from '@solana/spl-token';
+import {
+  Liquidity,
+  LiquidityPoolKeysV4,
+  LiquidityStateV4,
+  Percent,
+  Token,
+  TokenAmount,
+} from '@raydium-io/raydium-sdk';
+import { MarketCache, PoolCache, SnipeListCache } from './cache';
+import { PoolFilters } from './filters';
+import { TransactionExecutor } from './transactions';
+import { createPoolKeys, logger, NETWORK, sleep } from './helpers';
+import { Mutex } from 'async-mutex';
+import BN from 'bn.js';
+
+export interface BotConfig {
+  wallet: Keypair;
+  checkRenounced: boolean;
+  checkBurned: boolean;
+  minPoolSize: TokenAmount;
+  maxPoolSize: TokenAmount;
+  quoteToken: Token;
+  quoteAmount: TokenAmount;
+  quoteAta: PublicKey;
+  oneTokenAtATime: boolean;
+  useSnipeList: boolean;
+  autoSell: boolean;
+  autoBuyDelay: number;
+  autoSellDelay: number;
+  maxBuyRetries: number;
+  maxSellRetries: number;
+  unitLimit: number;
+  unitPrice: number;
+  takeProfit: number;
+  stopLoss: number;
+  buySlippage: number;
+  sellSlippage: number;
+  priceCheckInterval: number;
+  priceCheckDuration: number;
+}
+
+export class Bot {
+  private readonly poolFilters: PoolFilters;
+
+  // snipe list
+  private readonly snipeListCache?: SnipeListCache;
+
+  // one token at the time
+  private readonly mutex: Mutex;
+  private sellExecutionCount = 0;
+
+  constructor(
+    private readonly connection: Connection,
+    private readonly marketStorage: MarketCache,
+    private readonly poolStorage: PoolCache,
+    private readonly txExecutor: TransactionExecutor,
+    private readonly config: BotConfig,
+  ) {
+    this.mutex = new Mutex();
+    this.poolFilters = new PoolFilters(connection, {
+      quoteToken: this.config.quoteToken,
+      minPoolSize: this.config.minPoolSize,
+      maxPoolSize: this.config.maxPoolSize,
+    });
+
+    if (this.config.useSnipeList) {
+      this.snipeListCache = new SnipeListCache();
+      this.snipeListCache.init();
+    }
+  }
+
+  async validate() {
+    try {
+      await getAccount(this.connection, this.config.quoteAta, this.connection.commitment);
+    } catch (error) {
+      logger.error(
+        `${this.config.quoteToken.symbol} token account not found in wallet: ${this.config.wallet.publicKey.toString()}`,
+      );
+      return false;
+    }
+
+    return true;
+  }
+
+  public async buy(accountId: PublicKey, poolState: LiquidityStateV4) {
+    logger.trace({ mint: poolState.baseMint }, `Processing buy...`);
+
+    if (this.config.useSnipeList && !this.snipeListCache?.isInList(poolState.baseMint.toString())) {
+      logger.debug({ mint: poolState.baseMint.toString() }, `Skipping buy because token is not in a snipe list`);
+      return;
+    }
+
+    if (this.config.autoBuyDelay > 0) {
+      logger.debug({ mint: poolState.baseMint }, `Waiting for ${this.config.autoBuyDelay} ms before buy`);
+      await sleep(this.config.autoBuyDelay);
+    }
+
+    if (this.config.oneTokenAtATime) {
+      if (this.mutex.isLocked() || this.sellExecutionCount > 0) {
+        logger.debug(
+          { mint: poolState.baseMint.toString() },
+          `Skipping buy because one token at a time is turned on and token is already being processed`,
+        );
+        return;
+      }
+
+      await this.mutex.acquire();
+    }
+
+    try {
+      const shouldBuy = await this.poolFilters.execute(poolState);
+
+      if (!shouldBuy) {
+        logger.debug({ mint: poolState.baseMint.toString() }, `Skipping buy because pool doesn't match filters`);
+        return;
+      }
+
+      for (let i = 0; i < this.config.maxBuyRetries; i++) {
+        try {
+          const [market, mintAta] = await Promise.all([
+            this.marketStorage.get(poolState.marketId.toString()),
+            getAssociatedTokenAddress(poolState.baseMint, this.config.wallet.publicKey),
+          ]);
+          const poolKeys: LiquidityPoolKeysV4 = createPoolKeys(accountId, poolState, market);
+
+          logger.info(
+            { mint: poolState.baseMint.toString() },
+            `Send buy transaction attempt: ${i + 1}/${this.config.maxBuyRetries}`,
+          );
+          const tokenOut = new Token(TOKEN_PROGRAM_ID, poolKeys.baseMint, poolKeys.baseDecimals);
+          const result = await this.swap(
+            poolKeys,
+            this.config.quoteAta,
+            mintAta,
+            this.config.quoteToken,
+            tokenOut,
+            this.config.quoteAmount,
+            this.config.buySlippage,
+            this.config.wallet,
+            'buy',
+          );
+
+          if (result.confirmed) {
+            logger.info(
+              {
+                mint: poolState.baseMint.toString(),
+                signature: result.signature,
+                url: `https://solscan.io/tx/${result.signature}?cluster=${NETWORK}`,
+              },
+              `Confirmed buy tx`,
+            );
+
+            break;
+          }
+
+          logger.debug(
+            {
+              mint: poolState.baseMint.toString(),
+              signature: result.signature,
+            },
+            `Error confirming buy tx`,
+          );
+        } catch (error) {
+          logger.debug({ mint: poolState.baseMint.toString(), error }, `Error confirming buy transaction`);
+        }
+      }
+    } catch (error) {
+      logger.error({ mint: poolState.baseMint.toString(), error }, `Failed to buy token`);
+    } finally {
+      if (this.config.oneTokenAtATime) {
+        this.mutex.release();
+      }
+    }
+  }
+
+  public async sell(accountId: PublicKey, rawAccount: RawAccount) {
+    if (this.config.oneTokenAtATime) {
+      this.sellExecutionCount++;
+    }
+
+    try {
+      logger.trace({ mint: rawAccount.mint }, `Processing sell...`);
+
+      const poolData = await this.poolStorage.get(rawAccount.mint.toString());
+
+      if (!poolData) {
+        logger.trace({ mint: rawAccount.mint.toString() }, `Token pool data is not found, can't sell`);
+        return;
+      }
+
+      const tokenIn = new Token(TOKEN_PROGRAM_ID, poolData.state.baseMint, poolData.state.baseDecimal.toNumber());
+      const tokenAmountIn = new TokenAmount(tokenIn, rawAccount.amount, true);
+
+      if (tokenAmountIn.isZero()) {
+        logger.info({ mint: rawAccount.mint.toString() }, `Empty balance, can't sell`);
+        return;
+      }
+
+      if (this.config.autoSellDelay > 0) {
+        logger.debug({ mint: rawAccount.mint }, `Waiting for ${this.config.autoSellDelay} ms before sell`);
+        await sleep(this.config.autoSellDelay);
+      }
+
+      for (let i = 0; i < this.config.maxSellRetries; i++) {
+        try {
+          const market = await this.marketStorage.get(poolData.state.marketId.toString());
+          const poolKeys: LiquidityPoolKeysV4 = createPoolKeys(new PublicKey(poolData.id), poolData.state, market);
+
+          await this.priceMatch(tokenAmountIn, poolKeys);
+
+          logger.info(
+            { mint: rawAccount.mint },
+            `Send sell transaction attempt: ${i + 1}/${this.config.maxSellRetries}`,
+          );
+
+          const result = await this.swap(
+            poolKeys,
+            accountId,
+            this.config.quoteAta,
+            tokenIn,
+            this.config.quoteToken,
+            tokenAmountIn,
+            this.config.sellSlippage,
+            this.config.wallet,
+            'sell',
+          );
+
+          if (result.confirmed) {
+            logger.info(
+              {
+                dex: `https://dexscreener.com/solana/${rawAccount.mint.toString()}?maker=${this.config.wallet.publicKey}`,
+                mint: rawAccount.mint.toString(),
+                signature: result.signature,
+                url: `https://solscan.io/tx/${result.signature}?cluster=${NETWORK}`,
+              },
+              `Confirmed sell tx`,
+            );
+            break;
+          }
+
+          logger.info(
+            {
+              mint: rawAccount.mint.toString(),
+              signature: result.signature,
+            },
+            `Error confirming sell tx`,
+          );
+        } catch (error) {
+          logger.debug({ mint: rawAccount.mint.toString(), error }, `Error confirming sell transaction`);
+        }
+      }
+    } catch (error) {
+      logger.debug({ mint: rawAccount.mint.toString(), error }, `Failed to sell token`);
+    } finally {
+      if (this.config.oneTokenAtATime) {
+        this.sellExecutionCount--;
+      }
+    }
+  }
+
+  private async swap(
+    poolKeys: LiquidityPoolKeysV4,
+    ataIn: PublicKey,
+    ataOut: PublicKey,
+    tokenIn: Token,
+    tokenOut: Token,
+    amountIn: TokenAmount,
+    slippage: number,
+    wallet: Keypair,
+    direction: 'buy' | 'sell',
+  ) {
+    const slippagePercent = new Percent(slippage, 100);
+    const poolInfo = await Liquidity.fetchInfo({
+      connection: this.connection,
+      poolKeys,
+    });
+
+    const computedAmountOut = Liquidity.computeAmountOut({
+      poolKeys,
+      poolInfo,
+      amountIn,
+      currencyOut: tokenOut,
+      slippage: slippagePercent,
+    });
+
+    const latestBlockhash = await this.connection.getLatestBlockhash();
+    const { innerTransaction } = Liquidity.makeSwapFixedInInstruction(
+      {
+        poolKeys: poolKeys,
+        userKeys: {
+          tokenAccountIn: ataIn,
+          tokenAccountOut: ataOut,
+          owner: wallet.publicKey,
+        },
+        amountIn: amountIn.raw,
+        minAmountOut: computedAmountOut.minAmountOut.raw,
+      },
+      poolKeys.version,
+    );
+
+    const messageV0 = new TransactionMessage({
+      payerKey: wallet.publicKey,
+      recentBlockhash: latestBlockhash.blockhash,
+      instructions: [
+        ComputeBudgetProgram.setComputeUnitPrice({ microLamports: this.config.unitPrice }),
+        ComputeBudgetProgram.setComputeUnitLimit({ units: this.config.unitLimit }),
+        ...(direction === 'buy'
+          ? [
+              createAssociatedTokenAccountIdempotentInstruction(
+                wallet.publicKey,
+                ataOut,
+                wallet.publicKey,
+                tokenOut.mint,
+              ),
+            ]
+          : []),
+        ...innerTransaction.instructions,
+        ...(direction === 'sell' ? [createCloseAccountInstruction(ataIn, wallet.publicKey, wallet.publicKey)] : []),
+      ],
+    }).compileToV0Message();
+
+    const transaction = new VersionedTransaction(messageV0);
+    transaction.sign([wallet, ...innerTransaction.signers]);
+
+    return this.txExecutor.executeAndConfirm(transaction, latestBlockhash);
+  }
+
+  private async priceMatch(amountIn: TokenAmount, poolKeys: LiquidityPoolKeysV4) {
+    const profitFraction = this.config.quoteAmount.mul(this.config.takeProfit).numerator.div(new BN(100));
+    const profitAmount = new TokenAmount(this.config.quoteToken, profitFraction, true);
+    const takeProfit = this.config.quoteAmount.add(profitAmount);
+
+    const lossFraction = this.config.quoteAmount.mul(this.config.stopLoss).numerator.div(new BN(100));
+    const lossAmount = new TokenAmount(this.config.quoteToken, lossFraction, true);
+    const stopLoss = this.config.quoteAmount.subtract(lossAmount);
+    const slippage = new Percent(this.config.sellSlippage, 100);
+
+    const timesToCheck = this.config.priceCheckDuration / this.config.priceCheckInterval;
+    let timesChecked = 0;
+
+    do {
+      try {
+        const poolInfo = await Liquidity.fetchInfo({
+          connection: this.connection,
+          poolKeys,
+        });
+
+        const amountOut = Liquidity.computeAmountOut({
+          poolKeys,
+          poolInfo,
+          amountIn: amountIn,
+          currencyOut: this.config.quoteToken,
+          slippage,
+        }).amountOut;
+
+        logger.debug(
+          { mint: poolKeys.baseMint.toString() },
+          `Take profit: ${takeProfit.toFixed()} | Stop loss: ${stopLoss.toFixed()} | Current: ${amountOut.toFixed()}`,
+        );
+
+        if (amountOut.lt(stopLoss)){
+          break;
+        }
+
+        if (amountOut.gt(takeProfit)){
+          break;
+        }
+
+        await sleep(this.config.priceCheckInterval);
+      } catch (e) {
+        logger.trace({ mint: poolKeys.baseMint.toString(), e }, `Failed to check token price`);
+      } finally {
+        timesChecked++;
+      }
+    } while (timesChecked < timesToCheck);
+  }
+}

+ 0 - 555
buy.ts

@@ -1,555 +0,0 @@
-import {
-  BigNumberish,
-  Liquidity,
-  LIQUIDITY_STATE_LAYOUT_V4,
-  LiquidityPoolKeys,
-  LiquidityStateV4,
-  MARKET_STATE_LAYOUT_V3,
-  MarketStateV3,
-  Token,
-  TokenAmount,
-} from '@raydium-io/raydium-sdk';
-import {
-  AccountLayout,
-  createAssociatedTokenAccountIdempotentInstruction,
-  createCloseAccountInstruction,
-  getAssociatedTokenAddressSync,
-  TOKEN_PROGRAM_ID,
-} from '@solana/spl-token';
-import {
-  Keypair,
-  Connection,
-  PublicKey,
-  ComputeBudgetProgram,
-  KeyedAccountInfo,
-  TransactionMessage,
-  VersionedTransaction,
-} from '@solana/web3.js';
-import { getTokenAccounts, RAYDIUM_LIQUIDITY_PROGRAM_ID_V4, OPENBOOK_PROGRAM_ID, createPoolKeys } from './liquidity';
-import { logger } from './utils';
-import { getMinimalMarketV3, MinimalMarketLayoutV3 } from './market';
-import { MintLayout } from './types';
-import bs58 from 'bs58';
-import * as fs from 'fs';
-import * as path from 'path';
-import {
-  AUTO_SELL,
-  AUTO_SELL_DELAY,
-  CHECK_IF_MINT_IS_RENOUNCED,
-  COMMITMENT_LEVEL,
-  LOG_LEVEL,
-  MAX_SELL_RETRIES,
-  NETWORK,
-  PRIVATE_KEY,
-  QUOTE_AMOUNT,
-  QUOTE_MINT,
-  RPC_ENDPOINT,
-  RPC_WEBSOCKET_ENDPOINT,
-  SNIPE_LIST_REFRESH_INTERVAL,
-  USE_SNIPE_LIST,
-  MIN_POOL_SIZE,
-  MAX_POOL_SIZE,
-  ONE_TOKEN_AT_A_TIME,
-} from './constants';
-
-const solanaConnection = new Connection(RPC_ENDPOINT, {
-  wsEndpoint: RPC_WEBSOCKET_ENDPOINT,
-});
-
-export interface MinimalTokenAccountData {
-  mint: PublicKey;
-  address: PublicKey;
-  poolKeys?: LiquidityPoolKeys;
-  market?: MinimalMarketLayoutV3;
-}
-
-const existingLiquidityPools: Set<string> = new Set<string>();
-const existingOpenBookMarkets: Set<string> = new Set<string>();
-const existingTokenAccounts: Map<string, MinimalTokenAccountData> = new Map<string, MinimalTokenAccountData>();
-
-let wallet: Keypair;
-let quoteToken: Token;
-let quoteTokenAssociatedAddress: PublicKey;
-let quoteAmount: TokenAmount;
-let quoteMinPoolSizeAmount: TokenAmount;
-let quoteMaxPoolSizeAmount: TokenAmount;
-let processingToken: Boolean = false;
-
-
-
-let snipeList: string[] = [];
-
-async function init(): Promise<void> {
-  logger.level = LOG_LEVEL;
-
-  // get wallet
-  wallet = Keypair.fromSecretKey(bs58.decode(PRIVATE_KEY));
-  logger.info(`Wallet Address: ${wallet.publicKey}`);
-
-  // get quote mint and amount
-  switch (QUOTE_MINT) {
-    case 'WSOL': {
-      quoteToken = Token.WSOL;
-      quoteAmount = new TokenAmount(Token.WSOL, QUOTE_AMOUNT, false);
-      quoteMinPoolSizeAmount = new TokenAmount(quoteToken, MIN_POOL_SIZE, false);
-      quoteMaxPoolSizeAmount = new TokenAmount(quoteToken, MAX_POOL_SIZE, false);
-      break;
-    }
-    case 'USDC': {
-      quoteToken = new Token(
-        TOKEN_PROGRAM_ID,
-        new PublicKey('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'),
-        6,
-        'USDC',
-        'USDC',
-      );
-      quoteAmount = new TokenAmount(quoteToken, QUOTE_AMOUNT, false);
-      quoteMinPoolSizeAmount = new TokenAmount(quoteToken, MIN_POOL_SIZE, false);
-      quoteMaxPoolSizeAmount = new TokenAmount(quoteToken, MAX_POOL_SIZE, false);
-      break;
-    }
-    default: {
-      throw new Error(`Unsupported quote mint "${QUOTE_MINT}". Supported values are USDC and WSOL`);
-    }
-  }
-
-  logger.info(`Snipe list: ${USE_SNIPE_LIST}`);
-  logger.info(`Check mint renounced: ${CHECK_IF_MINT_IS_RENOUNCED}`);
-  logger.info(
-    `Min pool size: ${quoteMinPoolSizeAmount.isZero() ? 'false' : quoteMinPoolSizeAmount.toFixed()} ${quoteToken.symbol}`,
-  );
-  logger.info(
-    `Max pool size: ${quoteMaxPoolSizeAmount.isZero() ? 'false' : quoteMaxPoolSizeAmount.toFixed()} ${quoteToken.symbol}`,
-  );
-  logger.info(`One token at a time: ${ONE_TOKEN_AT_A_TIME}`);
-  logger.info(`Buy amount: ${quoteAmount.toFixed()} ${quoteToken.symbol}`);
-  logger.info(`Auto sell: ${AUTO_SELL}`);
-  logger.info(`Sell delay: ${AUTO_SELL_DELAY === 0 ? 'false' : AUTO_SELL_DELAY}`);
-
-  // check existing wallet for associated token account of quote mint
-  const tokenAccounts = await getTokenAccounts(solanaConnection, wallet.publicKey, COMMITMENT_LEVEL);
-
-  for (const ta of tokenAccounts) {
-    existingTokenAccounts.set(ta.accountInfo.mint.toString(), <MinimalTokenAccountData>{
-      mint: ta.accountInfo.mint,
-      address: ta.pubkey,
-    });
-  }
-
-  const tokenAccount = tokenAccounts.find((acc) => acc.accountInfo.mint.toString() === quoteToken.mint.toString())!;
-
-  if (!tokenAccount) {
-    throw new Error(`No ${quoteToken.symbol} token account found in wallet: ${wallet.publicKey}`);
-  }
-
-  quoteTokenAssociatedAddress = tokenAccount.pubkey;
-
-  // load tokens to snipe
-  loadSnipeList();
-}
-
-function saveTokenAccount(mint: PublicKey, accountData: MinimalMarketLayoutV3) {
-  const ata = getAssociatedTokenAddressSync(mint, wallet.publicKey);
-  const tokenAccount = <MinimalTokenAccountData>{
-    address: ata,
-    mint: mint,
-    market: <MinimalMarketLayoutV3>{
-      bids: accountData.bids,
-      asks: accountData.asks,
-      eventQueue: accountData.eventQueue,
-    },
-  };
-  existingTokenAccounts.set(mint.toString(), tokenAccount);
-  return tokenAccount;
-}
-
-export async function processRaydiumPool(id: PublicKey, poolState: LiquidityStateV4) {
-  if (!shouldBuy(poolState.baseMint.toString())) {
-    return;
-  }
-
-  if (!quoteMinPoolSizeAmount.isZero()) {
-    const poolSize = new TokenAmount(quoteToken, poolState.swapQuoteInAmount, true);
-    logger.info(`Processing pool: ${id.toString()} with ${poolSize.toFixed()} ${quoteToken.symbol} in liquidity`);
-
-    if (poolSize.lt(quoteMinPoolSizeAmount)) {
-      logger.warn(
-        {
-          mint: poolState.baseMint,
-          pooled: `${poolSize.toFixed()} ${quoteToken.symbol}`,
-        },
-        `Skipping pool, smaller than ${quoteMinPoolSizeAmount.toFixed()} ${quoteToken.symbol}`,
-        `Swap quote in amount: ${poolSize.toFixed()}`,
-      );
-      logger.info(`-------------------🤖🔧------------------- \n`);
-      return;
-    }
-  }
-
-  if (!quoteMaxPoolSizeAmount.isZero()) {
-    const poolSize = new TokenAmount(quoteToken, poolState.swapQuoteInAmount, true);
-
-    if (poolSize.gt(quoteMaxPoolSizeAmount)) {
-      logger.warn(
-        {
-          mint: poolState.baseMint,
-          pooled: `${poolSize.toFixed()} ${quoteToken.symbol}`,
-        },
-        `Skipping pool, bigger than ${quoteMaxPoolSizeAmount.toFixed()} ${quoteToken.symbol}`,
-        `Swap quote in amount: ${poolSize.toFixed()}`,
-      );
-      logger.info(`-------------------🤖🔧------------------- \n`);
-      return;
-    }
-  }
-
-  if (CHECK_IF_MINT_IS_RENOUNCED) {
-    const mintOption = await checkMintable(poolState.baseMint);
-
-    if (mintOption !== true) {
-      logger.warn({ mint: poolState.baseMint }, 'Skipping, owner can mint tokens!');
-      return;
-    }
-  }
-
-  await buy(id, poolState);
-}
-
-export async function checkMintable(vault: PublicKey): Promise<boolean | undefined> {
-  try {
-    let { data } = (await solanaConnection.getAccountInfo(vault)) || {};
-    if (!data) {
-      return;
-    }
-    const deserialize = MintLayout.decode(data);
-    return deserialize.mintAuthorityOption === 0;
-  } catch (e) {
-    logger.debug(e);
-    logger.error({ mint: vault }, `Failed to check if mint is renounced`);
-  }
-}
-
-export async function processOpenBookMarket(updatedAccountInfo: KeyedAccountInfo) {
-  let accountData: MarketStateV3 | undefined;
-  try {
-    accountData = MARKET_STATE_LAYOUT_V3.decode(updatedAccountInfo.accountInfo.data);
-
-    // to be competitive, we collect market data before buying the token...
-    if (existingTokenAccounts.has(accountData.baseMint.toString())) {
-      return;
-    }
-
-    saveTokenAccount(accountData.baseMint, accountData);
-  } catch (e) {
-    logger.debug(e);
-    logger.error({ mint: accountData?.baseMint }, `Failed to process market`);
-  }
-}
-
-async function buy(accountId: PublicKey, accountData: LiquidityStateV4): Promise<void> {
-  try {
-    let tokenAccount = existingTokenAccounts.get(accountData.baseMint.toString());
-
-    if (!tokenAccount) {
-      // it's possible that we didn't have time to fetch open book data
-      const market = await getMinimalMarketV3(solanaConnection, accountData.marketId, COMMITMENT_LEVEL);
-      tokenAccount = saveTokenAccount(accountData.baseMint, market);
-    }
-
-    tokenAccount.poolKeys = createPoolKeys(accountId, accountData, tokenAccount.market!);
-    const { innerTransaction } = Liquidity.makeSwapFixedInInstruction(
-      {
-        poolKeys: tokenAccount.poolKeys,
-        userKeys: {
-          tokenAccountIn: quoteTokenAssociatedAddress,
-          tokenAccountOut: tokenAccount.address,
-          owner: wallet.publicKey,
-        },
-        amountIn: quoteAmount.raw,
-        minAmountOut: 0,
-      },
-      tokenAccount.poolKeys.version,
-    );
-
-    const latestBlockhash = await solanaConnection.getLatestBlockhash({
-      commitment: COMMITMENT_LEVEL,
-    });
-    const messageV0 = new TransactionMessage({
-      payerKey: wallet.publicKey,
-      recentBlockhash: latestBlockhash.blockhash,
-      instructions: [
-        ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 421197 }),
-        ComputeBudgetProgram.setComputeUnitLimit({ units: 101337 }),
-        createAssociatedTokenAccountIdempotentInstruction(
-          wallet.publicKey,
-          tokenAccount.address,
-          wallet.publicKey,
-          accountData.baseMint,
-        ),
-        ...innerTransaction.instructions,
-      ],
-    }).compileToV0Message();
-    const transaction = new VersionedTransaction(messageV0);
-    transaction.sign([wallet, ...innerTransaction.signers]);
-    const signature = await solanaConnection.sendRawTransaction(transaction.serialize(), {
-      preflightCommitment: COMMITMENT_LEVEL,
-    });
-    logger.info({ mint: accountData.baseMint, signature }, `Sent buy tx`);
-    processingToken = true;
-
-    const confirmation = await solanaConnection.confirmTransaction(
-      {
-        signature,
-        lastValidBlockHeight: latestBlockhash.lastValidBlockHeight,
-        blockhash: latestBlockhash.blockhash,
-      },
-      COMMITMENT_LEVEL,
-    );
-    if (!confirmation.value.err) {
-      logger.info(`-------------------🟢------------------- `);
-      logger.info(
-        {
-          mint: accountData.baseMint,
-          signature,
-          url: `https://solscan.io/tx/${signature}?cluster=${NETWORK}`,
-        },
-        `Confirmed buy tx`,
-      );
-    } else {
-      logger.debug(confirmation.value.err);
-      logger.info({ mint: accountData.baseMint, signature }, `Error confirming buy tx`);
-    }
-  } catch (e) {
-    logger.debug(e);
-    processingToken = false;
-    logger.error({ mint: accountData.baseMint }, `Failed to buy token`);
-  }
-}
-
-async function sell(accountId: PublicKey, mint: PublicKey, amount: BigNumberish): Promise<void> {
-  let sold = false;
-  let retries = 0;
-
-  if (AUTO_SELL_DELAY > 0) {
-    await new Promise((resolve) => setTimeout(resolve, AUTO_SELL_DELAY));
-  }
-
-  do {
-    try {
-      const tokenAccount = existingTokenAccounts.get(mint.toString());
-
-      if (!tokenAccount) {
-        return;
-      }
-
-      if (!tokenAccount.poolKeys) {
-        logger.warn({ mint }, 'No pool keys found');
-        return;
-      }
-
-      if (amount === 0) {
-        logger.info(
-          {
-            mint: tokenAccount.mint,
-          },
-          `Empty balance, can't sell`,
-        );
-        return;
-      }
-
-      const { innerTransaction } = Liquidity.makeSwapFixedInInstruction(
-        {
-          poolKeys: tokenAccount.poolKeys!,
-          userKeys: {
-            tokenAccountOut: quoteTokenAssociatedAddress,
-            tokenAccountIn: tokenAccount.address,
-            owner: wallet.publicKey,
-          },
-          amountIn: amount,
-          minAmountOut: 0,
-        },
-        tokenAccount.poolKeys!.version,
-      );
-
-      const latestBlockhash = await solanaConnection.getLatestBlockhash({
-        commitment: COMMITMENT_LEVEL,
-      });
-      const messageV0 = new TransactionMessage({
-        payerKey: wallet.publicKey,
-        recentBlockhash: latestBlockhash.blockhash,
-        instructions: [
-          ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 421197 }),
-          ComputeBudgetProgram.setComputeUnitLimit({ units: 101337 }),
-          ...innerTransaction.instructions,
-          createCloseAccountInstruction(tokenAccount.address, wallet.publicKey, wallet.publicKey),
-        ],
-      }).compileToV0Message();
-      const transaction = new VersionedTransaction(messageV0);
-      transaction.sign([wallet, ...innerTransaction.signers]);
-      const signature = await solanaConnection.sendRawTransaction(transaction.serialize(), {
-        preflightCommitment: COMMITMENT_LEVEL,
-      });
-      logger.info({ mint, signature }, `Sent sell tx`);
-      const confirmation = await solanaConnection.confirmTransaction(
-        {
-          signature,
-          lastValidBlockHeight: latestBlockhash.lastValidBlockHeight,
-          blockhash: latestBlockhash.blockhash,
-        },
-        COMMITMENT_LEVEL,
-      );
-      if (confirmation.value.err) {
-        logger.debug(confirmation.value.err);
-        logger.info({ mint, signature }, `Error confirming sell tx`);
-        continue;
-      }
-      logger.info(`-------------------🔴------------------- `);
-      logger.info(
-        {
-          dex: `https://dexscreener.com/solana/${mint}?maker=${wallet.publicKey}`,
-          mint,
-          signature,
-          url: `https://solscan.io/tx/${signature}?cluster=${NETWORK}`,
-        },
-        `Confirmed sell tx`,
-      );
-      sold = true;
-      processingToken = false;
-    } catch (e: any) {
-      // wait for a bit before retrying
-      await new Promise((resolve) => setTimeout(resolve, 100));
-      retries++;
-      logger.debug(e);
-      logger.error({ mint }, `Failed to sell token, retry: ${retries}/${MAX_SELL_RETRIES}`);
-    }
-  } while (!sold && retries < MAX_SELL_RETRIES);
-  processingToken = false;
-}
-
-function loadSnipeList() {
-  if (!USE_SNIPE_LIST) {
-    return;
-  }
-
-  const count = snipeList.length;
-  const data = fs.readFileSync(path.join(__dirname, 'snipe-list.txt'), 'utf-8');
-  snipeList = data
-    .split('\n')
-    .map((a) => a.trim())
-    .filter((a) => a);
-
-  if (snipeList.length != count) {
-    logger.info(`Loaded snipe list: ${snipeList.length}`);
-  }
-}
-
-function shouldBuy(key: string): boolean {
-  logger.info(`-------------------🤖🔧------------------- `);
-  logger.info(`Processing token: ${processingToken}`)
-  return USE_SNIPE_LIST ? snipeList.includes(key) : ONE_TOKEN_AT_A_TIME ? !processingToken : true
-}
-
-const runListener = async () => {
-  await init();
-  const runTimestamp = Math.floor(new Date().getTime() / 1000);
-  const raydiumSubscriptionId = solanaConnection.onProgramAccountChange(
-    RAYDIUM_LIQUIDITY_PROGRAM_ID_V4,
-    async (updatedAccountInfo) => {
-      const key = updatedAccountInfo.accountId.toString();
-      const poolState = LIQUIDITY_STATE_LAYOUT_V4.decode(updatedAccountInfo.accountInfo.data);
-      const poolOpenTime = parseInt(poolState.poolOpenTime.toString());
-      const existing = existingLiquidityPools.has(key);
-
-      if (poolOpenTime > runTimestamp && !existing) {
-        existingLiquidityPools.add(key);
-        const _ = processRaydiumPool(updatedAccountInfo.accountId, poolState);
-      }
-    },
-    COMMITMENT_LEVEL,
-    [
-      { dataSize: LIQUIDITY_STATE_LAYOUT_V4.span },
-      {
-        memcmp: {
-          offset: LIQUIDITY_STATE_LAYOUT_V4.offsetOf('quoteMint'),
-          bytes: quoteToken.mint.toBase58(),
-        },
-      },
-      {
-        memcmp: {
-          offset: LIQUIDITY_STATE_LAYOUT_V4.offsetOf('marketProgramId'),
-          bytes: OPENBOOK_PROGRAM_ID.toBase58(),
-        },
-      },
-      {
-        memcmp: {
-          offset: LIQUIDITY_STATE_LAYOUT_V4.offsetOf('status'),
-          bytes: bs58.encode([6, 0, 0, 0, 0, 0, 0, 0]),
-        },
-      },
-    ],
-  );
-
-  const openBookSubscriptionId = solanaConnection.onProgramAccountChange(
-    OPENBOOK_PROGRAM_ID,
-    async (updatedAccountInfo) => {
-      const key = updatedAccountInfo.accountId.toString();
-      const existing = existingOpenBookMarkets.has(key);
-      if (!existing) {
-        existingOpenBookMarkets.add(key);
-        const _ = processOpenBookMarket(updatedAccountInfo);
-      }
-    },
-    COMMITMENT_LEVEL,
-    [
-      { dataSize: MARKET_STATE_LAYOUT_V3.span },
-      {
-        memcmp: {
-          offset: MARKET_STATE_LAYOUT_V3.offsetOf('quoteMint'),
-          bytes: quoteToken.mint.toBase58(),
-        },
-      },
-    ],
-  );
-
-  if (AUTO_SELL) {
-    const walletSubscriptionId = solanaConnection.onProgramAccountChange(
-      TOKEN_PROGRAM_ID,
-      async (updatedAccountInfo) => {
-        const accountData = AccountLayout.decode(updatedAccountInfo.accountInfo!.data);
-
-        if (updatedAccountInfo.accountId.equals(quoteTokenAssociatedAddress)) {
-          return;
-        }
-
-        const _ = sell(updatedAccountInfo.accountId, accountData.mint, accountData.amount);
-      },
-      COMMITMENT_LEVEL,
-      [
-        {
-          dataSize: 165,
-        },
-        {
-          memcmp: {
-            offset: 32,
-            bytes: wallet.publicKey.toBase58(),
-          },
-        },
-      ],
-    );
-
-    logger.info(`Listening for wallet changes: ${walletSubscriptionId}`);
-  }
-
-  logger.info(`Listening for raydium changes: ${raydiumSubscriptionId}`);
-  logger.info(`Listening for open book changes: ${openBookSubscriptionId}`);
-
-  logger.info('------------------- 🚀 ---------------------');
-  logger.info('Bot is running! Press CTRL + C to stop it.');
-  logger.info('------------------- 🚀 ---------------------');
-
-  if (USE_SNIPE_LIST) {
-    setInterval(loadSnipeList, SNIPE_LIST_REFRESH_INTERVAL);
-  }
-};
-
-runListener();

+ 3 - 0
cache/index.ts

@@ -0,0 +1,3 @@
+export * from './market.cache';
+export * from './pool.cache';
+export * from './snipe-list.cache';

+ 58 - 0
cache/market.cache.ts

@@ -0,0 +1,58 @@
+import { Connection, PublicKey } from '@solana/web3.js';
+import { getMinimalMarketV3, logger, MINIMAL_MARKET_STATE_LAYOUT_V3, MinimalMarketLayoutV3 } from '../helpers';
+import { MAINNET_PROGRAM_ID, MARKET_STATE_LAYOUT_V3, Token } from '@raydium-io/raydium-sdk';
+
+export class MarketCache {
+  private readonly keys: Map<string, MinimalMarketLayoutV3> = new Map<string, MinimalMarketLayoutV3>();
+  constructor(private readonly connection: Connection) {}
+
+  async init(config: { quoteToken: Token }) {
+    logger.debug({}, `Fetching all existing ${config.quoteToken.symbol} markets...`);
+
+    const accounts = await this.connection.getProgramAccounts(MAINNET_PROGRAM_ID.OPENBOOK_MARKET, {
+      commitment: this.connection.commitment,
+      dataSlice: {
+        offset: MARKET_STATE_LAYOUT_V3.offsetOf('eventQueue'),
+        length: MINIMAL_MARKET_STATE_LAYOUT_V3.span,
+      },
+      filters: [
+        { dataSize: MARKET_STATE_LAYOUT_V3.span },
+        {
+          memcmp: {
+            offset: MARKET_STATE_LAYOUT_V3.offsetOf('quoteMint'),
+            bytes: config.quoteToken.mint.toBase58(),
+          },
+        },
+      ],
+    });
+
+    for (const account of accounts) {
+      const market = MINIMAL_MARKET_STATE_LAYOUT_V3.decode(account.account.data);
+      this.keys.set(account.pubkey.toString(), market);
+    }
+
+    logger.debug({}, `Cached ${this.keys.size} markets`);
+  }
+
+  public save(marketId: string, keys: MinimalMarketLayoutV3) {
+    if (!this.keys.has(marketId)) {
+      logger.trace({}, `Caching new market: ${marketId}`);
+      this.keys.set(marketId, keys);
+    }
+  }
+
+  public async get(marketId: string): Promise<MinimalMarketLayoutV3> {
+    if (this.keys.has(marketId)) {
+      return this.keys.get(marketId)!;
+    }
+
+    logger.trace({}, `Fetching new market keys for ${marketId}`);
+    const market = await this.fetch(marketId);
+    this.keys.set(marketId, market);
+    return market;
+  }
+
+  private fetch(marketId: string): Promise<MinimalMarketLayoutV3> {
+    return getMinimalMarketV3(this.connection, new PublicKey(marketId), this.connection.commitment);
+  }
+}

+ 20 - 0
cache/pool.cache.ts

@@ -0,0 +1,20 @@
+import { LiquidityStateV4 } from '@raydium-io/raydium-sdk';
+import { logger } from '../helpers';
+
+export class PoolCache {
+  private readonly keys: Map<string, { id: string; state: LiquidityStateV4 }> = new Map<
+    string,
+    { id: string; state: LiquidityStateV4 }
+  >();
+
+  public save(id: string, state: LiquidityStateV4) {
+    if (!this.keys.has(state.baseMint.toString())) {
+      logger.trace(`Caching new pool for mint: ${state.baseMint.toString()}`);
+      this.keys.set(state.baseMint.toString(), { id, state });
+    }
+  }
+
+  public async get(mint: string): Promise<{ id: string; state: LiquidityStateV4 }> {
+    return this.keys.get(mint)!;
+  }
+}

+ 35 - 0
cache/snipe-list.cache.ts

@@ -0,0 +1,35 @@
+import fs from 'fs';
+import path from 'path';
+import { logger, SNIPE_LIST_REFRESH_INTERVAL } from '../helpers';
+
+export class SnipeListCache {
+  private snipeList: string[] = [];
+  private fileLocation = path.join(__dirname, '../snipe-list.txt');
+
+  constructor() {
+    setInterval(() => this.loadSnipeList(), SNIPE_LIST_REFRESH_INTERVAL);
+  }
+
+  public init() {
+    this.loadSnipeList();
+  }
+
+  public isInList(mint: string) {
+    return this.snipeList.includes(mint);
+  }
+
+  private loadSnipeList() {
+    logger.trace(`Refreshing snipe list...`);
+
+    const count = this.snipeList.length;
+    const data = fs.readFileSync(this.fileLocation, 'utf-8');
+    this.snipeList = data
+      .split('\n')
+      .map((a) => a.trim())
+      .filter((a) => a);
+
+    if (this.snipeList.length != count) {
+      logger.info(`Loaded snipe list: ${this.snipeList.length}`);
+    }
+  }
+}

+ 0 - 20
constants/constants.ts

@@ -1,20 +0,0 @@
-import { Commitment } from "@solana/web3.js";
-import { logger, retrieveEnvVariable } from "../utils";
-
-export const NETWORK = 'mainnet-beta';
-export const COMMITMENT_LEVEL: Commitment = retrieveEnvVariable('COMMITMENT_LEVEL', logger) as Commitment;
-export const RPC_ENDPOINT = retrieveEnvVariable('RPC_ENDPOINT', logger);
-export const RPC_WEBSOCKET_ENDPOINT = retrieveEnvVariable('RPC_WEBSOCKET_ENDPOINT', logger);
-export const LOG_LEVEL = retrieveEnvVariable('LOG_LEVEL', logger);
-export const CHECK_IF_MINT_IS_RENOUNCED = retrieveEnvVariable('CHECK_IF_MINT_IS_RENOUNCED', logger) === 'true';
-export const USE_SNIPE_LIST = retrieveEnvVariable('USE_SNIPE_LIST', logger) === 'true';
-export const SNIPE_LIST_REFRESH_INTERVAL = Number(retrieveEnvVariable('SNIPE_LIST_REFRESH_INTERVAL', logger));
-export const AUTO_SELL = retrieveEnvVariable('AUTO_SELL', logger) === 'true';
-export const MAX_SELL_RETRIES = Number(retrieveEnvVariable('MAX_SELL_RETRIES', logger));
-export const AUTO_SELL_DELAY = Number(retrieveEnvVariable('AUTO_SELL_DELAY', logger));
-export const PRIVATE_KEY = retrieveEnvVariable('PRIVATE_KEY', logger);
-export const QUOTE_MINT = retrieveEnvVariable('QUOTE_MINT', logger);
-export const QUOTE_AMOUNT = retrieveEnvVariable('QUOTE_AMOUNT', logger);
-export const MIN_POOL_SIZE = retrieveEnvVariable('MIN_POOL_SIZE', logger);
-export const MAX_POOL_SIZE = retrieveEnvVariable('MAX_POOL_SIZE', logger);
-export const ONE_TOKEN_AT_A_TIME = retrieveEnvVariable('ONE_TOKEN_AT_A_TIME', logger) === 'true';

+ 0 - 1
constants/index.ts

@@ -1 +0,0 @@
-export * from './constants';

+ 24 - 0
filters/burn.filter.ts

@@ -0,0 +1,24 @@
+import { Filter, FilterResult } from './pool-filters';
+import { Connection } from '@solana/web3.js';
+import { LiquidityStateV4 } from '@raydium-io/raydium-sdk';
+import { logger } from '../helpers';
+
+export class BurnFilter implements Filter {
+  constructor(private readonly connection: Connection) {}
+
+  async execute(poolState: LiquidityStateV4): Promise<FilterResult> {
+    try {
+      const amount = await this.connection.getTokenSupply(poolState.lpMint, this.connection.commitment);
+      const burned = amount.value.uiAmount === 0;
+      return { ok: burned, message: burned ? undefined : "Burned -> Creator didn't burn LP" };
+    } catch (e: any) {
+      if (e.code == -32602) {
+        return { ok: true };
+      }
+
+      logger.error({ mint: poolState.baseMint }, `Failed to check if LP is burned`);
+    }
+
+    return { ok: false, message: 'Failed to check if LP is burned' };
+  }
+}

+ 4 - 0
filters/index.ts

@@ -0,0 +1,4 @@
+export * from './burn.filter';
+export * from './pool-filters';
+export * from './pool-size.filter';
+export * from './renounced.filter';

+ 61 - 0
filters/pool-filters.ts

@@ -0,0 +1,61 @@
+import { Connection } from '@solana/web3.js';
+import { LiquidityStateV4, Token, TokenAmount } from '@raydium-io/raydium-sdk';
+import { BurnFilter } from './burn.filter';
+import { RenouncedFilter } from './renounced.filter';
+import { PoolSizeFilter } from './pool-size.filter';
+import { CHECK_IF_BURNED, CHECK_IF_MINT_IS_RENOUNCED, logger } from '../helpers';
+
+export interface Filter {
+  execute(poolState: LiquidityStateV4): Promise<FilterResult>;
+}
+
+export interface FilterResult {
+  ok: boolean;
+  message?: string;
+}
+
+export interface PoolFilterArgs {
+  minPoolSize: TokenAmount;
+  maxPoolSize: TokenAmount;
+  quoteToken: Token;
+}
+
+export class PoolFilters {
+  private readonly filters: Filter[] = [];
+
+  constructor(
+    readonly connection: Connection,
+    readonly args: PoolFilterArgs,
+  ) {
+    if (CHECK_IF_BURNED) {
+      this.filters.push(new BurnFilter(connection));
+    }
+
+    if (CHECK_IF_MINT_IS_RENOUNCED) {
+      this.filters.push(new RenouncedFilter(connection));
+    }
+
+    if (!args.minPoolSize.isZero() || !args.maxPoolSize.isZero()) {
+      this.filters.push(new PoolSizeFilter(connection, args.quoteToken, args.minPoolSize, args.maxPoolSize));
+    }
+  }
+
+  public async execute(poolState: LiquidityStateV4): Promise<boolean> {
+    if (this.filters.length === 0) {
+      return true;
+    }
+
+    const result = await Promise.all(this.filters.map((f) => f.execute(poolState)));
+    const pass = result.every((r) => r.ok);
+
+    if (pass) {
+      return true;
+    }
+
+    for (const filterResult of result.filter((r) => !r.ok)) {
+      logger.info(filterResult.message);
+    }
+
+    return false;
+  }
+}

+ 43 - 0
filters/pool-size.filter.ts

@@ -0,0 +1,43 @@
+import { Filter, FilterResult } from './pool-filters';
+import { LiquidityStateV4, Token, TokenAmount } from '@raydium-io/raydium-sdk';
+import { Connection } from '@solana/web3.js';
+import { logger } from '../helpers';
+
+export class PoolSizeFilter implements Filter {
+  constructor(
+    private readonly connection: Connection,
+    private readonly quoteToken: Token,
+    private readonly minPoolSize: TokenAmount,
+    private readonly maxPoolSize: TokenAmount,
+  ) {}
+
+  async execute(poolState: LiquidityStateV4): Promise<FilterResult> {
+    try {
+      const response = await this.connection.getTokenAccountBalance(poolState.quoteVault, this.connection.commitment);
+      const poolSize = new TokenAmount(this.quoteToken, response.value.amount, true);
+      let inRange = true;
+
+      if (!this.maxPoolSize?.isZero()) {
+        inRange = poolSize.lt(this.maxPoolSize);
+
+        if (!inRange) {
+          return { ok: false, message: `PoolSize -> Pool size ${poolSize.toFixed()} > ${this.maxPoolSize.toFixed()}` };
+        }
+      }
+
+      if (!this.minPoolSize?.isZero()) {
+        inRange = poolSize.gt(this.minPoolSize);
+
+        if (!inRange) {
+          return { ok: false, message: `PoolSize -> Pool size ${poolSize.toFixed()} < ${this.minPoolSize.toFixed()}` };
+        }
+      }
+
+      return { ok: inRange };
+    } catch (error) {
+      logger.error({ mint: poolState.baseMint }, `Failed to check pool size`);
+    }
+
+    return { ok: false, message: 'PoolSize -> Failed to check pool size' };
+  }
+}

+ 26 - 0
filters/renounced.filter.ts

@@ -0,0 +1,26 @@
+import { Filter, FilterResult } from './pool-filters';
+import { MintLayout } from '@solana/spl-token';
+import { Connection } from '@solana/web3.js';
+import { LiquidityStateV4 } from '@raydium-io/raydium-sdk';
+import { logger } from '../helpers';
+
+export class RenouncedFilter implements Filter {
+  constructor(private readonly connection: Connection) {}
+
+  async execute(poolState: LiquidityStateV4): Promise<FilterResult> {
+    try {
+      const accountInfo = await this.connection.getAccountInfo(poolState.baseMint, this.connection.commitment);
+      if (!accountInfo?.data) {
+        return { ok: false, message: 'Renounced -> Failed to fetch account data' };
+      }
+
+      const deserialize = MintLayout.decode(accountInfo.data);
+      const renounced = deserialize.mintAuthorityOption === 0;
+      return { ok: renounced, message: renounced ? undefined : 'Renounced -> Creator can mint more tokens' };
+    } catch (e) {
+      logger.error({ mint: poolState.baseMint }, `Failed to check if mint is renounced`);
+    }
+
+    return { ok: false, message: 'Renounced -> Failed to check if mint is renounced' };
+  }
+}

+ 57 - 0
helpers/constants.ts

@@ -0,0 +1,57 @@
+import { Logger } from 'pino';
+import dotenv from 'dotenv';
+import { Commitment } from '@solana/web3.js';
+import { logger } from './logger';
+
+dotenv.config();
+
+const retrieveEnvVariable = (variableName: string, logger: Logger) => {
+  const variable = process.env[variableName] || '';
+  if (!variable) {
+    logger.error(`${variableName} is not set`);
+    process.exit(1);
+  }
+  return variable;
+};
+
+// Wallet
+export const PRIVATE_KEY = retrieveEnvVariable('PRIVATE_KEY', logger);
+
+// Connection
+export const NETWORK = 'mainnet-beta';
+export const COMMITMENT_LEVEL: Commitment = retrieveEnvVariable('COMMITMENT_LEVEL', logger) as Commitment;
+export const RPC_ENDPOINT = retrieveEnvVariable('RPC_ENDPOINT', logger);
+export const RPC_WEBSOCKET_ENDPOINT = retrieveEnvVariable('RPC_WEBSOCKET_ENDPOINT', logger);
+
+// Bot
+export const LOG_LEVEL = retrieveEnvVariable('LOG_LEVEL', logger);
+export const ONE_TOKEN_AT_A_TIME = retrieveEnvVariable('ONE_TOKEN_AT_A_TIME', logger) === 'true';
+export const COMPUTE_UNIT_LIMIT = Number(retrieveEnvVariable('COMPUTE_UNIT_LIMIT', logger));
+export const COMPUTE_UNIT_PRICE = Number(retrieveEnvVariable('COMPUTE_UNIT_PRICE', logger));
+export const PRE_LOAD_EXISTING_MARKETS = retrieveEnvVariable('PRE_LOAD_EXISTING_MARKETS', logger) === 'true';
+export const CACHE_NEW_MARKETS = retrieveEnvVariable('CACHE_NEW_MARKETS', logger) === 'true';
+
+// Buy
+export const AUTO_BUY_DELAY = Number(retrieveEnvVariable('AUTO_BUY_DELAY', logger));
+export const QUOTE_MINT = retrieveEnvVariable('QUOTE_MINT', logger);
+export const QUOTE_AMOUNT = retrieveEnvVariable('QUOTE_AMOUNT', logger);
+export const MAX_BUY_RETRIES = Number(retrieveEnvVariable('MAX_BUY_RETRIES', logger));
+export const BUY_SLIPPAGE = Number(retrieveEnvVariable('BUY_SLIPPAGE', logger));
+
+// Sell
+export const AUTO_SELL = retrieveEnvVariable('AUTO_SELL', logger) === 'true';
+export const AUTO_SELL_DELAY = Number(retrieveEnvVariable('AUTO_SELL_DELAY', logger));
+export const MAX_SELL_RETRIES = Number(retrieveEnvVariable('MAX_SELL_RETRIES', logger));
+export const TAKE_PROFIT = Number(retrieveEnvVariable('TAKE_PROFIT', logger));
+export const STOP_LOSS = Number(retrieveEnvVariable('STOP_LOSS', logger));
+export const PRICE_CHECK_INTERVAL = Number(retrieveEnvVariable('PRICE_CHECK_INTERVAL', logger));
+export const PRICE_CHECK_DURATION = Number(retrieveEnvVariable('PRICE_CHECK_DURATION', logger));
+export const SELL_SLIPPAGE = Number(retrieveEnvVariable('SELL_SLIPPAGE', logger));
+
+// Filters
+export const CHECK_IF_MINT_IS_RENOUNCED = retrieveEnvVariable('CHECK_IF_MINT_IS_RENOUNCED', logger) === 'true';
+export const CHECK_IF_BURNED = retrieveEnvVariable('CHECK_IF_BURNED', logger) === 'true';
+export const MIN_POOL_SIZE = retrieveEnvVariable('MIN_POOL_SIZE', logger);
+export const MAX_POOL_SIZE = retrieveEnvVariable('MAX_POOL_SIZE', logger);
+export const USE_SNIPE_LIST = retrieveEnvVariable('USE_SNIPE_LIST', logger) === 'true';
+export const SNIPE_LIST_REFRESH_INTERVAL = Number(retrieveEnvVariable('SNIPE_LIST_REFRESH_INTERVAL', logger));

+ 7 - 0
helpers/index.ts

@@ -0,0 +1,7 @@
+export * from './market';
+export * from './liquidity';
+export * from './logger';
+export * from './constants';
+export * from './token';
+export * from './wallet';
+export * from './promises'

+ 5 - 50
liquidity/liquidity.ts → helpers/liquidity.ts

@@ -1,26 +1,6 @@
-import { Commitment, Connection, PublicKey } from '@solana/web3.js';
-import {
-  Liquidity,
-  LiquidityPoolKeys,
-  Market,
-  TokenAccount,
-  SPL_ACCOUNT_LAYOUT,
-  publicKey,
-  struct,
-  MAINNET_PROGRAM_ID,
-  LiquidityStateV4,
-} from '@raydium-io/raydium-sdk';
-import { TOKEN_PROGRAM_ID } from '@solana/spl-token';
-import { MinimalMarketLayoutV3 } from '../market';
-
-export const RAYDIUM_LIQUIDITY_PROGRAM_ID_V4 = MAINNET_PROGRAM_ID.AmmV4;
-export const OPENBOOK_PROGRAM_ID = MAINNET_PROGRAM_ID.OPENBOOK_MARKET;
-
-export const MINIMAL_MARKET_STATE_LAYOUT_V3 = struct([
-  publicKey('eventQueue'),
-  publicKey('bids'),
-  publicKey('asks'),
-]);
+import { PublicKey } from '@solana/web3.js';
+import { Liquidity, LiquidityPoolKeys, LiquidityStateV4, MAINNET_PROGRAM_ID, Market } from '@raydium-io/raydium-sdk';
+import { MinimalMarketLayoutV3 } from './market';
 
 export function createPoolKeys(
   id: PublicKey,
@@ -36,9 +16,9 @@ export function createPoolKeys(
     quoteDecimals: accountData.quoteDecimal.toNumber(),
     lpDecimals: 5,
     version: 4,
-    programId: RAYDIUM_LIQUIDITY_PROGRAM_ID_V4,
+    programId: MAINNET_PROGRAM_ID.AmmV4,
     authority: Liquidity.getAssociatedAuthority({
-      programId: RAYDIUM_LIQUIDITY_PROGRAM_ID_V4,
+      programId: MAINNET_PROGRAM_ID.AmmV4,
     }).publicKey,
     openOrders: accountData.openOrders,
     targetOrders: accountData.targetOrders,
@@ -61,28 +41,3 @@ export function createPoolKeys(
     lookupTableAccount: PublicKey.default,
   };
 }
-
-export async function getTokenAccounts(
-  connection: Connection,
-  owner: PublicKey,
-  commitment?: Commitment,
-) {
-  const tokenResp = await connection.getTokenAccountsByOwner(
-    owner,
-    {
-      programId: TOKEN_PROGRAM_ID,
-    },
-    commitment,
-  );
-
-  const accounts: TokenAccount[] = [];
-  for (const { pubkey, account } of tokenResp.value) {
-    accounts.push({
-      pubkey,
-      programId: account.owner,
-      accountInfo: SPL_ACCOUNT_LAYOUT.decode(account.data),
-    });
-  }
-
-  return accounts;
-}

+ 1 - 1
utils/logger.ts → helpers/logger.ts

@@ -1,4 +1,4 @@
-import pino from "pino";
+import pino from 'pino';
 
 const transport = pino.transport({
   target: 'pino-pretty',

+ 3 - 4
market/market.ts → helpers/market.ts

@@ -1,10 +1,9 @@
 import { Commitment, Connection, PublicKey } from '@solana/web3.js';
-import { GetStructureSchema, MARKET_STATE_LAYOUT_V3 } from '@raydium-io/raydium-sdk';
-import { MINIMAL_MARKET_STATE_LAYOUT_V3 } from '../liquidity';
+import { GetStructureSchema, MARKET_STATE_LAYOUT_V3, publicKey, struct } from '@raydium-io/raydium-sdk';
 
+export const MINIMAL_MARKET_STATE_LAYOUT_V3 = struct([publicKey('eventQueue'), publicKey('bids'), publicKey('asks')]);
 export type MinimalMarketStateLayoutV3 = typeof MINIMAL_MARKET_STATE_LAYOUT_V3;
-export type MinimalMarketLayoutV3 =
-  GetStructureSchema<MinimalMarketStateLayoutV3>;
+export type MinimalMarketLayoutV3 = GetStructureSchema<MinimalMarketStateLayoutV3>;
 
 export async function getMinimalMarketV3(
   connection: Connection,

+ 1 - 0
helpers/promises.ts

@@ -0,0 +1 @@
+export const sleep = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));

+ 23 - 0
helpers/token.ts

@@ -0,0 +1,23 @@
+import { Token } from '@raydium-io/raydium-sdk';
+import { TOKEN_PROGRAM_ID } from '@solana/spl-token';
+import { PublicKey } from '@solana/web3.js';
+
+export function getToken(token: string) {
+  switch (token) {
+    case 'WSOL': {
+      return Token.WSOL;
+    }
+    case 'USDC': {
+      return new Token(
+        TOKEN_PROGRAM_ID,
+        new PublicKey('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'),
+        6,
+        'USDC',
+        'USDC',
+      );
+    }
+    default: {
+      throw new Error(`Unsupported quote mint "${token}". Supported values are USDC and WSOL`);
+    }
+  }
+}

+ 21 - 0
helpers/wallet.ts

@@ -0,0 +1,21 @@
+import { Keypair } from '@solana/web3.js';
+import bs58 from 'bs58';
+import { mnemonicToSeedSync } from 'bip39';
+import { derivePath } from 'ed25519-hd-key';
+
+export function getWallet(wallet: string): Keypair {
+  // most likely someone pasted the private key in binary format
+  if (wallet.startsWith('[')) {
+    return Keypair.fromSecretKey(JSON.parse(wallet));
+  }
+
+  // most likely someone pasted mnemonic
+  if (wallet.split(' ').length > 1) {
+    const seed = mnemonicToSeedSync(wallet, '');
+    const path = `m/44'/501'/0'/0'`; // we assume it's first path
+    return Keypair.fromSeed(derivePath(path, seed.toString('hex')).key);
+  }
+
+  // most likely someone pasted base58 encoded private key
+  return Keypair.fromSecretKey(bs58.decode(wallet));
+}

+ 192 - 0
index.ts

@@ -0,0 +1,192 @@
+import { MarketCache, PoolCache } from './cache';
+import { Listeners } from './listeners';
+import { Connection, KeyedAccountInfo, Keypair } from '@solana/web3.js';
+import { LIQUIDITY_STATE_LAYOUT_V4, MARKET_STATE_LAYOUT_V3, Token, TokenAmount } from '@raydium-io/raydium-sdk';
+import { AccountLayout, getAssociatedTokenAddressSync } from '@solana/spl-token';
+import { Bot, BotConfig } from './bot';
+import { DefaultTransactionExecutor } from './transactions';
+import {
+  getToken,
+  getWallet,
+  logger,
+  COMMITMENT_LEVEL,
+  RPC_ENDPOINT,
+  RPC_WEBSOCKET_ENDPOINT,
+  PRE_LOAD_EXISTING_MARKETS,
+  LOG_LEVEL,
+  CHECK_IF_MINT_IS_RENOUNCED,
+  CHECK_IF_BURNED,
+  QUOTE_MINT,
+  MAX_POOL_SIZE,
+  MIN_POOL_SIZE,
+  QUOTE_AMOUNT,
+  PRIVATE_KEY,
+  USE_SNIPE_LIST,
+  ONE_TOKEN_AT_A_TIME,
+  AUTO_SELL_DELAY,
+  MAX_SELL_RETRIES,
+  AUTO_SELL,
+  MAX_BUY_RETRIES,
+  AUTO_BUY_DELAY,
+  COMPUTE_UNIT_LIMIT,
+  COMPUTE_UNIT_PRICE,
+  CACHE_NEW_MARKETS,
+  TAKE_PROFIT,
+  STOP_LOSS,
+  BUY_SLIPPAGE,
+  SELL_SLIPPAGE,
+  PRICE_CHECK_DURATION,
+  PRICE_CHECK_INTERVAL, SNIPE_LIST_REFRESH_INTERVAL,
+} from './helpers';
+import { version } from './package.json';
+
+const connection = new Connection(RPC_ENDPOINT, {
+  wsEndpoint: RPC_WEBSOCKET_ENDPOINT,
+  commitment: COMMITMENT_LEVEL,
+});
+
+function printDetails(wallet: Keypair, quoteToken: Token, botConfig: BotConfig) {
+  logger.info(`  
+                                        ..   :-===++++-     
+                                .-==+++++++- =+++++++++-    
+            ..:::--===+=.=:     .+++++++++++:=+++++++++:    
+    .==+++++++++++++++=:+++:    .+++++++++++.=++++++++-.    
+    .-+++++++++++++++=:=++++-   .+++++++++=:.=+++++-::-.    
+     -:+++++++++++++=:+++++++-  .++++++++-:- =+++++=-:      
+      -:++++++=++++=:++++=++++= .++++++++++- =+++++:        
+       -:++++-:=++=:++++=:-+++++:+++++====--:::::::.        
+        ::=+-:::==:=+++=::-:--::::::::::---------::.        
+         ::-:  .::::::::.  --------:::..                    
+          :-    .:.-:::.                                    
+
+          WARP DRIVE ACTIVATED 🚀🐟
+          Made with ❤️ by humans.
+          Version: ${version}                                          
+  `);
+
+  logger.info('------- CONFIGURATION START -------');
+  logger.info(`Wallet: ${wallet.publicKey.toString()}`);
+
+  logger.info('- Bot -');
+  logger.info(`Compute Unit limit: ${botConfig.unitLimit}`);
+  logger.info(`Compute Unit price (micro lamports): ${botConfig.unitPrice}`);
+  logger.info(`Single token at the time: ${botConfig.oneTokenAtATime}`);
+  logger.info(`Pre load existing markets: ${PRE_LOAD_EXISTING_MARKETS}`);
+  logger.info(`Cache new markets: ${CACHE_NEW_MARKETS}`);
+  logger.info(`Log level: ${LOG_LEVEL}`);
+
+  logger.info('- Buy -');
+  logger.info(`Buy amount: ${botConfig.quoteAmount.toFixed()} ${botConfig.quoteToken.name}`);
+  logger.info(`Auto buy delay: ${botConfig.autoBuyDelay} ms`);
+  logger.info(`Max buy retries: ${botConfig.maxBuyRetries}`);
+  logger.info(`Buy amount (${quoteToken.symbol}): ${botConfig.quoteAmount.toFixed()}`);
+  logger.info(`Buy slippage: ${botConfig.buySlippage}%`);
+
+  logger.info('- Sell -');
+  logger.info(`Auto sell: ${AUTO_SELL}`);
+  logger.info(`Auto sell delay: ${botConfig.autoSellDelay} ms`);
+  logger.info(`Max sell retries: ${botConfig.maxSellRetries}`);
+  logger.info(`Sell slippage: ${botConfig.sellSlippage}%`);
+  logger.info(`Price check interval: ${botConfig.priceCheckInterval} ms`);
+  logger.info(`Price check duration: ${botConfig.priceCheckDuration} ms`);
+  logger.info(`Take profit: ${botConfig.takeProfit}%`);
+  logger.info(`Stop loss: ${botConfig.stopLoss}%`);
+
+  logger.info('- Filters -');
+  logger.info(`Snipe list: ${botConfig.useSnipeList}`);
+  logger.info(`Snipe list refresh interval: ${SNIPE_LIST_REFRESH_INTERVAL} ms`);
+  logger.info(`Check renounced: ${botConfig.checkRenounced}`);
+  logger.info(`Check burned: ${botConfig.checkBurned}`);
+  logger.info(`Min pool size: ${botConfig.minPoolSize.toFixed()}`);
+  logger.info(`Max pool size: ${botConfig.maxPoolSize.toFixed()}`);
+
+  logger.info('------- CONFIGURATION END -------');
+
+  logger.info('Bot is running! Press CTRL + C to stop it.');
+}
+
+const runListener = async () => {
+  logger.level = LOG_LEVEL;
+  logger.info('Bot is starting...');
+
+  const marketCache = new MarketCache(connection);
+  const poolCache = new PoolCache();
+  const txExecutor = new DefaultTransactionExecutor(connection);
+  const wallet = getWallet(PRIVATE_KEY.trim());
+  const quoteToken = getToken(QUOTE_MINT);
+  const botConfig = <BotConfig>{
+    wallet,
+    quoteAta: getAssociatedTokenAddressSync(quoteToken.mint, wallet.publicKey),
+    checkRenounced: CHECK_IF_MINT_IS_RENOUNCED,
+    checkBurned: CHECK_IF_BURNED,
+    minPoolSize: new TokenAmount(quoteToken, MIN_POOL_SIZE, false),
+    maxPoolSize: new TokenAmount(quoteToken, MAX_POOL_SIZE, false),
+    quoteToken,
+    quoteAmount: new TokenAmount(quoteToken, QUOTE_AMOUNT, false),
+    oneTokenAtATime: ONE_TOKEN_AT_A_TIME,
+    useSnipeList: USE_SNIPE_LIST,
+    autoSellDelay: AUTO_SELL_DELAY,
+    maxSellRetries: MAX_SELL_RETRIES,
+    autoBuyDelay: AUTO_BUY_DELAY,
+    maxBuyRetries: MAX_BUY_RETRIES,
+    unitLimit: COMPUTE_UNIT_LIMIT,
+    unitPrice: COMPUTE_UNIT_PRICE,
+    takeProfit: TAKE_PROFIT,
+    stopLoss: STOP_LOSS,
+    buySlippage: BUY_SLIPPAGE,
+    sellSlippage: SELL_SLIPPAGE,
+    priceCheckInterval: PRICE_CHECK_INTERVAL,
+    priceCheckDuration: PRICE_CHECK_DURATION,
+  };
+
+  const bot = new Bot(connection, marketCache, poolCache, txExecutor, botConfig);
+  const valid = await bot.validate();
+
+  if (!valid) {
+    logger.info('Bot is exiting...');
+    process.exit(1);
+  }
+
+  if (PRE_LOAD_EXISTING_MARKETS) {
+    await marketCache.init({ quoteToken });
+  }
+
+  const runTimestamp = Math.floor(new Date().getTime() / 1000);
+  const listeners = new Listeners(connection);
+  await listeners.start({
+    walletPublicKey: wallet.publicKey,
+    quoteToken,
+    autoSell: AUTO_SELL,
+    cacheNewMarkets: CACHE_NEW_MARKETS,
+  });
+
+  listeners.on('market', (updatedAccountInfo: KeyedAccountInfo) => {
+    const marketState = MARKET_STATE_LAYOUT_V3.decode(updatedAccountInfo.accountInfo.data);
+    marketCache.save(updatedAccountInfo.accountId.toString(), marketState);
+  });
+
+  listeners.on('pool', async (updatedAccountInfo: KeyedAccountInfo) => {
+    const poolState = LIQUIDITY_STATE_LAYOUT_V4.decode(updatedAccountInfo.accountInfo.data);
+    const poolOpenTime = parseInt(poolState.poolOpenTime.toString());
+    const exists = await poolCache.get(poolState.baseMint.toString());
+
+    if (!exists && poolOpenTime > runTimestamp) {
+      poolCache.save(updatedAccountInfo.accountId.toString(), poolState);
+      await bot.buy(updatedAccountInfo.accountId, poolState);
+    }
+  });
+
+  listeners.on('wallet', async (updatedAccountInfo: KeyedAccountInfo) => {
+    const accountData = AccountLayout.decode(updatedAccountInfo.accountInfo.data);
+
+    if (accountData.mint.equals(quoteToken.mint)) {
+      return;
+    }
+
+    await bot.sell(updatedAccountInfo.accountId, accountData);
+  });
+
+  printDetails(wallet, quoteToken, botConfig);
+};
+
+runListener();

+ 0 - 1
liquidity/index.ts

@@ -1 +0,0 @@
-export * from './liquidity';

+ 1 - 0
listeners/index.ts

@@ -0,0 +1 @@
+export * from './listeners';

+ 112 - 0
listeners/listeners.ts

@@ -0,0 +1,112 @@
+import { LIQUIDITY_STATE_LAYOUT_V4, MAINNET_PROGRAM_ID, MARKET_STATE_LAYOUT_V3, Token } from '@raydium-io/raydium-sdk';
+import bs58 from 'bs58';
+import { Connection, PublicKey } from '@solana/web3.js';
+import { TOKEN_PROGRAM_ID } from '@solana/spl-token';
+import { EventEmitter } from 'events';
+
+export class Listeners extends EventEmitter {
+  private subscriptions: number[] = [];
+
+  constructor(private readonly connection: Connection) {
+    super();
+  }
+
+  public async start(config: {
+    walletPublicKey: PublicKey;
+    quoteToken: Token;
+    autoSell: boolean;
+    cacheNewMarkets: boolean;
+  }) {
+    if (config.cacheNewMarkets) {
+      const openBookSubscription = await this.subscribeToOpenBookMarkets(config);
+      this.subscriptions.push(openBookSubscription);
+    }
+
+    const raydiumSubscription = await this.subscribeToRaydiumPools(config);
+    this.subscriptions.push(raydiumSubscription);
+
+    if (config.autoSell) {
+      const walletSubscription = await this.subscribeToWalletChanges(config);
+      this.subscriptions.push(walletSubscription);
+    }
+  }
+
+  private async subscribeToOpenBookMarkets(config: { quoteToken: Token }) {
+    return this.connection.onProgramAccountChange(
+      MAINNET_PROGRAM_ID.OPENBOOK_MARKET,
+      async (updatedAccountInfo) => {
+        this.emit('market', updatedAccountInfo);
+      },
+      this.connection.commitment,
+      [
+        { dataSize: MARKET_STATE_LAYOUT_V3.span },
+        {
+          memcmp: {
+            offset: MARKET_STATE_LAYOUT_V3.offsetOf('quoteMint'),
+            bytes: config.quoteToken.mint.toBase58(),
+          },
+        },
+      ],
+    );
+  }
+
+  private async subscribeToRaydiumPools(config: { quoteToken: Token }) {
+    return this.connection.onProgramAccountChange(
+      MAINNET_PROGRAM_ID.AmmV4,
+      async (updatedAccountInfo) => {
+        this.emit('pool', updatedAccountInfo);
+      },
+      this.connection.commitment,
+      [
+        { dataSize: LIQUIDITY_STATE_LAYOUT_V4.span },
+        {
+          memcmp: {
+            offset: LIQUIDITY_STATE_LAYOUT_V4.offsetOf('quoteMint'),
+            bytes: config.quoteToken.mint.toBase58(),
+          },
+        },
+        {
+          memcmp: {
+            offset: LIQUIDITY_STATE_LAYOUT_V4.offsetOf('marketProgramId'),
+            bytes: MAINNET_PROGRAM_ID.OPENBOOK_MARKET.toBase58(),
+          },
+        },
+        {
+          memcmp: {
+            offset: LIQUIDITY_STATE_LAYOUT_V4.offsetOf('status'),
+            bytes: bs58.encode([6, 0, 0, 0, 0, 0, 0, 0]),
+          },
+        },
+      ],
+    );
+  }
+
+  private async subscribeToWalletChanges(config: { walletPublicKey: PublicKey }) {
+    return this.connection.onProgramAccountChange(
+      TOKEN_PROGRAM_ID,
+      async (updatedAccountInfo) => {
+        this.emit('wallet', updatedAccountInfo);
+      },
+      this.connection.commitment,
+      [
+        {
+          dataSize: 165,
+        },
+        {
+          memcmp: {
+            offset: 32,
+            bytes: config.walletPublicKey.toBase58(),
+          },
+        },
+      ],
+    );
+  }
+
+  public async stop() {
+    for (let i = this.subscriptions.length; i >= 0; --i) {
+      const subscription = this.subscriptions[i];
+      await this.connection.removeAccountChangeListener(subscription);
+      this.subscriptions.splice(i, 1);
+    }
+  }
+}

+ 0 - 1
market/index.ts

@@ -1 +0,0 @@
-export * from './market';

File diff suppressed because it is too large
+ 2989 - 411
package-lock.json


+ 10 - 3
package.json

@@ -1,18 +1,25 @@
 {
-  "name": "solana-sniper-bot",
+  "name": "warp-solana-bot",
   "author": "Filip Dundjer",
+  "homepage": "https://warp.id",
+  "version": "2.0.0",
   "scripts": {
-    "buy": "ts-node buy.ts",
+    "start": "ts-node index.ts",
     "tsc": "tsc --noEmit"
   },
   "dependencies": {
     "@raydium-io/raydium-sdk": "^1.3.1-beta.47",
     "@solana/spl-token": "^0.4.0",
     "@solana/web3.js": "^1.89.1",
+    "async-mutex": "^0.5.0",
     "bigint-buffer": "^1.1.5",
+    "bip39": "^3.1.0",
     "bn.js": "^5.2.1",
     "bs58": "^5.0.0",
     "dotenv": "^16.4.1",
+    "ed25519-hd-key": "^1.3.0",
+    "i": "^0.3.7",
+    "npm": "^10.5.2",
     "pino": "^8.18.0",
     "pino-pretty": "^10.3.1",
     "pino-std-serializers": "^6.2.2"
@@ -23,4 +30,4 @@
     "ts-node": "^10.9.2",
     "typescript": "^5.3.3"
   }
-}
+}

BIN
readme/output.png


+ 37 - 0
transactions/default-transaction-executor.ts

@@ -0,0 +1,37 @@
+import { BlockhashWithExpiryBlockHeight, Connection, Transaction, VersionedTransaction } from '@solana/web3.js';
+import { TransactionExecutor } from './transaction-executor.interface';
+import { logger } from '../helpers';
+
+export class DefaultTransactionExecutor implements TransactionExecutor {
+  constructor(private readonly connection: Connection) {}
+
+  public async executeAndConfirm(
+    transaction: Transaction | VersionedTransaction,
+    latestBlockhash: BlockhashWithExpiryBlockHeight,
+  ): Promise<{ confirmed: boolean; signature: string }> {
+    logger.debug('Executing transaction...');
+    const signature = await this.execute(transaction);
+
+    logger.debug({ signature }, 'Confirming transaction...');
+    return this.confirm(signature, latestBlockhash);
+  }
+
+  private async execute(transaction: Transaction | VersionedTransaction) {
+    return this.connection.sendRawTransaction(transaction.serialize(), {
+      preflightCommitment: this.connection.commitment,
+    });
+  }
+
+  private async confirm(signature: string, latestBlockhash: BlockhashWithExpiryBlockHeight) {
+    const confirmation = await this.connection.confirmTransaction(
+      {
+        signature,
+        lastValidBlockHeight: latestBlockhash.lastValidBlockHeight,
+        blockhash: latestBlockhash.blockhash,
+      },
+      this.connection.commitment,
+    );
+
+    return { confirmed: !confirmation.value.err, signature };
+  }
+}

+ 2 - 0
transactions/index.ts

@@ -0,0 +1,2 @@
+export * from './default-transaction-executor';
+export * from './transaction-executor.interface';

+ 8 - 0
transactions/transaction-executor.interface.ts

@@ -0,0 +1,8 @@
+import { BlockhashWithExpiryBlockHeight, Transaction, VersionedTransaction } from '@solana/web3.js';
+
+export interface TransactionExecutor {
+  executeAndConfirm(
+    transaction: Transaction | VersionedTransaction,
+    latestBlockhash: BlockhashWithExpiryBlockHeight,
+  ): Promise<{ confirmed: boolean; signature: string }>;
+}

+ 0 - 1
types/index.ts

@@ -1 +0,0 @@
-export * from './mint';

+ 0 - 44
types/mint.ts

@@ -1,44 +0,0 @@
-import { struct, u32, u8 } from '@solana/buffer-layout';
-import { bool, publicKey, u64 } from '@solana/buffer-layout-utils';
-import { Commitment, Connection, PublicKey } from '@solana/web3.js';
-
-/** Information about a mint */
-export interface Mint {
-    /** Address of the mint */
-    address: PublicKey;
-    /**
-     * Optional authority used to mint new tokens. The mint authority may only be provided during mint creation.
-     * If no mint authority is present then the mint has a fixed supply and no further tokens may be minted.
-     */
-    mintAuthority: PublicKey | null;
-    /** Total supply of tokens */
-    supply: bigint;
-    /** Number of base 10 digits to the right of the decimal place */
-    decimals: number;
-    /** Is this mint initialized */
-    isInitialized: boolean;
-    /** Optional authority to freeze token accounts */
-    freezeAuthority: PublicKey | null;
-}
-
-/** Mint as stored by the program */
-export interface RawMint {
-    mintAuthorityOption: 1 | 0;
-    mintAuthority: PublicKey;
-    supply: bigint;
-    decimals: number;
-    isInitialized: boolean;
-    freezeAuthorityOption: 1 | 0;
-    freezeAuthority: PublicKey;
-}
-
-/** Buffer layout for de/serializing a mint */
-export const MintLayout = struct<RawMint>([
-    u32('mintAuthorityOption'),
-    publicKey('mintAuthority'),
-    u64('supply'),
-    u8('decimals'),
-    bool('isInitialized'),
-    u32('freezeAuthorityOption'),
-    publicKey('freezeAuthority'),
-]);

+ 0 - 2
utils/index.ts

@@ -1,2 +0,0 @@
-export * from './utils';
-export * from './logger';

+ 0 - 13
utils/utils.ts

@@ -1,13 +0,0 @@
-import { Logger } from 'pino';
-import dotenv from 'dotenv';
-
-dotenv.config();
-
-export const retrieveEnvVariable = (variableName: string, logger: Logger) => {
-  const variable = process.env[variableName] || '';
-  if (!variable) {
-    logger.error(`${variableName} is not set`);
-    process.exit(1);
-  }
-  return variable;
-};

Some files were not shown because too many files changed in this diff