Bladeren bron

feat: add support for wsol swaps

Filip Dunder 1 jaar geleden
bovenliggende
commit
636ef3e515
10 gewijzigde bestanden met toevoegingen van 242 en 117 verwijderingen
  1. 5 3
      .env.copy
  2. 34 7
      README.md
  3. 139 62
      buy.ts
  4. 0 6
      common/constants.ts
  5. 0 1
      common/index.ts
  6. 54 7
      liquidity/liquidity.ts
  7. 10 5
      market/market.ts
  8. BIN
      output.png
  9. 0 26
      utils/utils.ts
  10. BIN
      wsol.png

+ 5 - 3
.env.copy

@@ -1,4 +1,6 @@
 PRIVATE_KEY=
-RPC_ENDPOINT=
-RPC_WEBSOCKET_ENDPOINT=
-```
+RPC_ENDPOINT=https://api.mainnet-beta.solana.com
+RPC_WEBSOCKET_ENDPOINT=wss://api.mainnet-beta.solana.com
+QUOTE_MINT=WSOL
+QUOTE_AMOUNT=0.1
+COMMITMENT_LEVEL=finalized

+ 34 - 7
README.md

@@ -1,17 +1,44 @@
 # Solana Sniper Bot
-Proof of concept - 2023-04-20
-
 This code is written as proof of concept for demonstrating how we can buy new tokens immediately after liquidity pool is created.
 
-Script listens to new raydium USDC pools and buys token for a fixed amount in USDC.
-Depending on speed of RPC node, the purchase usually happens before token is available on Raydium for swapping.
+Script listens to new raydium USDC/SOL pools and buys token for a fixed amount in USDC/SOL.
+Depending on speed of RPC node, the purchase usually happens before token is available on Raydium UI for swapping.
 
 # Setup
 In order to run the script you need to:
 - Create a new empty Solana wallet
 - Transfer some SOL to it.
-- Convert some SOL to USDC.
-  - We need USDC because the script is buying USDC pairs.
-- Set your PRIVATE_KEY, RPC_ENDPOINT and RPC_WEBSOCKET_ENDPOINT in the .env file (remove the .copy from the file name when done)
+- Convert some SOL to USDC or WSOL.
+  - You need USDC or WSOL depending on configuration set below.
+- Set your 
+  - PRIVATE_KEY (your wallet private key)
+  - RPC_ENDPOINT (https endpoint like helius/quicknode)
+  - RPC_WEBSOCKET_ENDPOINT (websocket endpoint like helius/quicknode)
+  - QUOTE_MINT (which pools to look at, USDC or WSOL)
+  - QUOTE_AMOUNT (amount used to buy each new token)
+  - COMMITMENT_LEVEL
+
+  in the .env file (remove the .copy from the file name when done). 
+  Make sure to replace default values.
+
 - Install dependencies by typing: `npm install`
 - Run the script by typing: `npm run buy` in terminal
+
+You should see following output:
+![output](output.png)
+
+# Support
+
+## 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" }`
+it means your RPC node doesn't support methods needed to execute script.
+  - FIX: Change your RPC node. You can use Helius or Quicknode.
+
+
+- If you see following error in your log file:
+`Error: No SOL token account found in wallet: `
+it means that wallet you provided doesn't have USDC/WSOL token account.
+  - FIX: Go to dex and swap some SOL to USDC/WSOL. For example when you swap sol to wsol you should see it in wallet as shown below:
+![wsol](wsol.png)
+

+ 139 - 62
buy.ts

@@ -1,10 +1,19 @@
 import {
   Liquidity,
   LIQUIDITY_STATE_LAYOUT_V4,
+  LiquidityPoolKeys,
   LiquidityStateV4,
   MARKET_STATE_LAYOUT_V2,
+  MARKET_STATE_LAYOUT_V3,
+  MarketStateV3,
+  Token,
+  TokenAmount,
 } from '@raydium-io/raydium-sdk';
-import { getOrCreateAssociatedTokenAccount } from '@solana/spl-token';
+import {
+  createAssociatedTokenAccountIdempotentInstruction,
+  getAssociatedTokenAddressSync,
+  TOKEN_PROGRAM_ID,
+} from '@solana/spl-token';
 import {
   Keypair,
   Connection,
@@ -13,17 +22,17 @@ import {
   KeyedAccountInfo,
   TransactionMessage,
   VersionedTransaction,
+  Commitment,
 } from '@solana/web3.js';
 import {
   getAllAccountsV4,
   getTokenAccounts,
-  getAccountPoolKeysFromAccountDataV4,
   RAYDIUM_LIQUIDITY_PROGRAM_ID_V4,
   OPENBOOK_PROGRAM_ID,
+  createPoolKeys,
 } from './liquidity';
-import { retry, retrieveEnvVariable } from './utils';
-import { USDC_AMOUNT, USDC_TOKEN_ID } from './common';
-import { getAllMarketsV3 } from './market';
+import { retrieveEnvVariable } from './utils';
+import { getAllMarketsV3, MinimalMarketLayoutV3 } from './market';
 import pino from 'pino';
 import bs58 from 'bs58';
 
@@ -71,6 +80,9 @@ const solanaConnection = new Connection(RPC_ENDPOINT, {
 export type MinimalTokenAccountData = {
   mint: PublicKey;
   address: PublicKey;
+  ata: PublicKey;
+  poolKeys?: LiquidityPoolKeys;
+  market?: MinimalMarketLayoutV3;
 };
 
 let existingLiquidityPools: Set<string> = new Set<string>();
@@ -81,36 +93,95 @@ let existingTokenAccounts: Map<string, MinimalTokenAccountData> = new Map<
 >();
 
 let wallet: Keypair;
-let usdcTokenKey: PublicKey;
-const PRIVATE_KEY = retrieveEnvVariable('PRIVATE_KEY', logger);
+let quoteToken: Token;
+let quoteTokenAssociatedAddress: PublicKey;
+let quoteAmount: TokenAmount;
+let commitment: Commitment = retrieveEnvVariable('COMMITMENT_LEVEL', logger) as Commitment;
 
 async function init(): Promise<void> {
+  // get wallet
+  const PRIVATE_KEY = retrieveEnvVariable('PRIVATE_KEY', logger);
   wallet = Keypair.fromSecretKey(bs58.decode(PRIVATE_KEY));
-  logger.info(`Wallet Address: ${wallet.publicKey.toString()}`);
-  const allLiquidityPools = await getAllAccountsV4(solanaConnection);
+  logger.info(`Wallet Address: ${wallet.publicKey}`);
+
+  // get quote mint and amount
+  const QUOTE_MINT = retrieveEnvVariable('QUOTE_MINT', logger);
+  const QUOTE_AMOUNT = retrieveEnvVariable('QUOTE_AMOUNT', logger);
+  switch (QUOTE_MINT) {
+    case 'WSOL': {
+      quoteToken = Token.WSOL;
+      quoteAmount = new TokenAmount(Token.WSOL, QUOTE_AMOUNT, false);
+      break;
+    }
+    case 'USDC': {
+      quoteToken = new Token(
+        TOKEN_PROGRAM_ID,
+        new PublicKey('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'),
+        6,
+        'USDC',
+        'USDC',
+      );
+      quoteAmount = new TokenAmount(quoteToken, QUOTE_AMOUNT, false);
+      break;
+    }
+    default: {
+      throw new Error(
+        `Unsupported quote mint "${QUOTE_MINT}". Supported values are USDC and WSOL`,
+      );
+    }
+  }
+
+  logger.info(
+    `Script will buy all new tokens using ${QUOTE_MINT}. Amount that will be used to buy each token is: ${quoteAmount.toFixed().toString()}`
+  );
+
+  // get all existing liquidity pools
+  const allLiquidityPools = await getAllAccountsV4(
+    solanaConnection,
+    quoteToken.mint,
+    commitment,
+  );
   existingLiquidityPools = new Set(
     allLiquidityPools.map((p) => p.id.toString()),
   );
-  const allMarkets = await getAllMarketsV3(solanaConnection);
+
+  // get all open-book markets
+  const allMarkets = await getAllMarketsV3(solanaConnection, quoteToken.mint, commitment);
   existingOpenBookMarkets = new Set(allMarkets.map((p) => p.id.toString()));
   const tokenAccounts = await getTokenAccounts(
     solanaConnection,
     wallet.publicKey,
+    commitment,
   );
-  logger.info(`Total USDC markets ${existingOpenBookMarkets.size}`);
-  logger.info(`Total USDC pools ${existingLiquidityPools.size}`);
-  tokenAccounts.forEach((ta) => {
+
+  logger.info(
+    `Total ${quoteToken.symbol} markets ${existingOpenBookMarkets.size}`,
+  );
+  logger.info(
+    `Total ${quoteToken.symbol} pools ${existingLiquidityPools.size}`,
+  );
+
+  // check existing wallet for associated token account of quote mint
+  for (const ta of tokenAccounts) {
     existingTokenAccounts.set(ta.accountInfo.mint.toString(), <
       MinimalTokenAccountData
     >{
       mint: ta.accountInfo.mint,
       address: ta.pubkey,
     });
-  });
-  const token = tokenAccounts.find(
-    (acc) => acc.accountInfo.mint.toString() === USDC_TOKEN_ID.toString(),
+  }
+
+  const tokenAccount = tokenAccounts.find(
+    (acc) => acc.accountInfo.mint.toString() === quoteToken.mint.toString(),
   )!;
-  usdcTokenKey = token!.pubkey;
+
+  if (!tokenAccount) {
+    throw new Error(
+      `No ${quoteToken.symbol} token account found in wallet: ${wallet.publicKey}`,
+    );
+  }
+
+  quoteTokenAssociatedAddress = tokenAccount.pubkey;
 }
 
 export async function processRaydiumPool(updatedAccountInfo: KeyedAccountInfo) {
@@ -128,92 +199,98 @@ export async function processRaydiumPool(updatedAccountInfo: KeyedAccountInfo) {
 export async function processOpenBookMarket(
   updatedAccountInfo: KeyedAccountInfo,
 ) {
-  let accountData: any;
+  let accountData: MarketStateV3 | undefined;
   try {
-    accountData = MARKET_STATE_LAYOUT_V2.decode(
+    accountData = MARKET_STATE_LAYOUT_V3.decode(
       updatedAccountInfo.accountInfo.data,
     );
 
-    // to be competitive, we create token account before buying the token...
+    // to be competitive, we collect market data before buying the token...
     if (existingTokenAccounts.has(accountData.baseMint.toString())) {
       return;
     }
 
-    const destinationAccount = await getOrCreateAssociatedTokenAccount(
-      solanaConnection,
-      wallet,
+    const ata = getAssociatedTokenAddressSync(
       accountData.baseMint,
       wallet.publicKey,
     );
     existingTokenAccounts.set(accountData.baseMint.toString(), <
       MinimalTokenAccountData
     >{
-      address: destinationAccount.address,
-      mint: destinationAccount.mint,
+      address: ata,
+      mint: accountData.baseMint,
+      market: <MinimalMarketLayoutV3>{
+        bids: accountData.bids,
+        asks: accountData.asks,
+        eventQueue: accountData.eventQueue,
+      },
     });
-    logger.info(
-      accountData,
-      `Created destination account: ${destinationAccount.address}`,
-    );
   } catch (e) {
     logger.error({ ...accountData, error: e }, `Failed to process market`);
   }
 }
 
-async function buy(accountId: PublicKey, accountData: any): Promise<void> {
-  const [poolKeys, latestBlockhash] = await Promise.all([
-    getAccountPoolKeysFromAccountDataV4(
-      solanaConnection,
-      accountId,
-      accountData,
-    ),
-    solanaConnection.getLatestBlockhash({ commitment: 'processed' }),
-  ]);
-
-  const baseMint = poolKeys.baseMint.toString();
-  const tokenAccountOut =
-    existingTokenAccounts && existingTokenAccounts.get(baseMint)?.address;
+async function buy(
+  accountId: PublicKey,
+  accountData: LiquidityStateV4,
+): Promise<void> {
+  const tokenAccount = existingTokenAccounts.get(
+    accountData.baseMint.toString(),
+  );
 
-  if (!tokenAccountOut) {
-    logger.info(`No token account for ${baseMint}`);
+  if (!tokenAccount) {
     return;
   }
+
+  tokenAccount.poolKeys = createPoolKeys(
+    accountId,
+    accountData,
+    tokenAccount.market!,
+  );
   const { innerTransaction, address } = Liquidity.makeSwapFixedInInstruction(
     {
-      poolKeys,
+      poolKeys: tokenAccount.poolKeys,
       userKeys: {
-        tokenAccountIn: usdcTokenKey,
-        tokenAccountOut: tokenAccountOut,
+        tokenAccountIn: quoteTokenAssociatedAddress,
+        tokenAccountOut: tokenAccount.address,
         owner: wallet.publicKey,
       },
-      amountIn: USDC_AMOUNT * 1000000,
+      amountIn: quoteAmount.raw,
       minAmountOut: 0,
     },
-    poolKeys.version,
+    tokenAccount.poolKeys.version,
   );
 
+  const latestBlockhash = await solanaConnection.getLatestBlockhash({
+    commitment: commitment,
+  });
   const messageV0 = new TransactionMessage({
     payerKey: wallet.publicKey,
     recentBlockhash: latestBlockhash.blockhash,
     instructions: [
       ComputeBudgetProgram.setComputeUnitLimit({ units: 400000 }),
       ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 30000 }),
+      createAssociatedTokenAccountIdempotentInstruction(
+        wallet.publicKey,
+        tokenAccount.address,
+        wallet.publicKey,
+        accountData.baseMint,
+      ),
       ...innerTransaction.instructions,
     ],
   }).compileToV0Message();
   const transaction = new VersionedTransaction(messageV0);
   transaction.sign([wallet, ...innerTransaction.signers]);
-  const rawTransaction = transaction.serialize();
-  const signature = await retry(
-    () =>
-      solanaConnection.sendRawTransaction(rawTransaction, {
-        skipPreflight: true,
-      }),
-    { retryIntervalMs: 10, retries: 50 }, // TODO handle retries more efficiently
+  const signature = await solanaConnection.sendRawTransaction(
+    transaction.serialize(),
+    {
+      maxRetries: 5,
+      preflightCommitment: commitment,
+    },
   );
   logger.info(
     {
-      ...accountData,
+      mint: accountData.baseMint,
       url: `https://solscan.io/tx/${signature}?cluster=${network}`,
     },
     'Buy',
@@ -233,13 +310,13 @@ const runListener = async () => {
         const _ = processRaydiumPool(updatedAccountInfo);
       }
     },
-    'processed',
+    commitment,
     [
       { dataSize: LIQUIDITY_STATE_LAYOUT_V4.span },
       {
         memcmp: {
           offset: LIQUIDITY_STATE_LAYOUT_V4.offsetOf('quoteMint'),
-          bytes: USDC_TOKEN_ID.toBase58(),
+          bytes: quoteToken.mint.toBase58(),
         },
       },
       {
@@ -251,7 +328,7 @@ const runListener = async () => {
       {
         memcmp: {
           offset: LIQUIDITY_STATE_LAYOUT_V4.offsetOf('status'),
-          bytes: '14421D35quxec7'
+          bytes: bs58.encode([6, 0, 0, 0, 0, 0, 0, 0]),
         },
       },
     ],
@@ -268,13 +345,13 @@ const runListener = async () => {
         const _ = processOpenBookMarket(updatedAccountInfo);
       }
     },
-    'processed',
+    commitment,
     [
       { dataSize: MARKET_STATE_LAYOUT_V2.span },
       {
         memcmp: {
           offset: MARKET_STATE_LAYOUT_V2.offsetOf('quoteMint'),
-          bytes: USDC_TOKEN_ID.toBase58(),
+          bytes: quoteToken.mint.toBase58(),
         },
       },
     ],

+ 0 - 6
common/constants.ts

@@ -1,6 +0,0 @@
-import { PublicKey } from "@solana/web3.js";
-
-export const USDC_AMOUNT = 0.1; // how much do we spend on each token
-export const USDC_TOKEN_ID = new PublicKey(
-  'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
-);

+ 0 - 1
common/index.ts

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

+ 54 - 7
liquidity/liquidity.ts

@@ -12,7 +12,7 @@ import {
   LiquidityStateV4,
 } from '@raydium-io/raydium-sdk';
 import { TOKEN_PROGRAM_ID } from '@solana/spl-token';
-import { USDC_TOKEN_ID } from '../common';
+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;
@@ -31,19 +31,21 @@ export type MinimalLiquidityAccountData = {
 
 export async function getAllAccountsV4(
   connection: Connection,
+  quoteMint: PublicKey,
+  commitment?: Commitment,
 ): Promise<MinimalLiquidityAccountData[]> {
   const { span } = LIQUIDITY_STATE_LAYOUT_V4;
   const accounts = await connection.getProgramAccounts(
     RAYDIUM_LIQUIDITY_PROGRAM_ID_V4,
     {
       dataSlice: { offset: 0, length: 0 },
-      commitment: 'processed',
+      commitment: commitment,
       filters: [
         { dataSize: span },
         {
           memcmp: {
             offset: LIQUIDITY_STATE_LAYOUT_V4.offsetOf('quoteMint'),
-            bytes: USDC_TOKEN_ID.toBase58(),
+            bytes: quoteMint.toBase58(),
           },
         },
         {
@@ -66,6 +68,46 @@ export async function getAllAccountsV4(
   );
 }
 
+export function createPoolKeys(
+  id: PublicKey,
+  accountData: LiquidityStateV4,
+  minimalMarketLayoutV3: MinimalMarketLayoutV3,
+): LiquidityPoolKeys {
+  return {
+    id,
+    baseMint: accountData.baseMint,
+    quoteMint: accountData.quoteMint,
+    lpMint: accountData.lpMint,
+    baseDecimals: accountData.baseDecimal.toNumber(),
+    quoteDecimals: accountData.quoteDecimal.toNumber(),
+    lpDecimals: 5,
+    version: 4,
+    programId: RAYDIUM_LIQUIDITY_PROGRAM_ID_V4,
+    authority: Liquidity.getAssociatedAuthority({
+      programId: RAYDIUM_LIQUIDITY_PROGRAM_ID_V4,
+    }).publicKey,
+    openOrders: accountData.openOrders,
+    targetOrders: accountData.targetOrders,
+    baseVault: accountData.baseVault,
+    quoteVault: accountData.quoteVault,
+    marketVersion: 3,
+    marketProgramId: accountData.marketProgramId,
+    marketId: accountData.marketId,
+    marketAuthority: Market.getAssociatedAuthority({
+      programId: accountData.marketProgramId,
+      marketId: accountData.marketId,
+    }).publicKey,
+    marketBaseVault: accountData.baseVault,
+    marketQuoteVault: accountData.quoteVault,
+    marketBids: minimalMarketLayoutV3.bids,
+    marketAsks: minimalMarketLayoutV3.asks,
+    marketEventQueue: minimalMarketLayoutV3.eventQueue,
+    withdrawQueue: accountData.withdrawQueue,
+    lpVault: accountData.lpVault,
+    lookupTableAccount: PublicKey.default,
+  };
+}
+
 export async function getAccountPoolKeysFromAccountDataV4(
   connection: Connection,
   id: PublicKey,
@@ -73,7 +115,7 @@ export async function getAccountPoolKeysFromAccountDataV4(
   commitment?: Commitment,
 ): Promise<LiquidityPoolKeys> {
   const marketInfo = await connection.getAccountInfo(accountData.marketId, {
-    commitment: commitment ?? 'processed',
+    commitment: commitment,
     dataSlice: {
       offset: 253, // eventQueue
       length: 32 * 3,
@@ -122,10 +164,15 @@ export async function getAccountPoolKeysFromAccountDataV4(
 export async function getTokenAccounts(
   connection: Connection,
   owner: PublicKey,
+  commitment?: Commitment,
 ) {
-  const tokenResp = await connection.getTokenAccountsByOwner(owner, {
-    programId: TOKEN_PROGRAM_ID,
-  });
+  const tokenResp = await connection.getTokenAccountsByOwner(
+    owner,
+    {
+      programId: TOKEN_PROGRAM_ID,
+    },
+    commitment,
+  );
 
   const accounts: TokenAccount[] = [];
   for (const { pubkey, account } of tokenResp.value) {

+ 10 - 5
market/market.ts

@@ -1,31 +1,36 @@
-import { Connection, PublicKey } from '@solana/web3.js';
+import { Commitment, Connection, PublicKey } from '@solana/web3.js';
 import {
+  GetStructureSchema,
   MARKET_STATE_LAYOUT_V3,
 } from '@raydium-io/raydium-sdk';
-import { USDC_TOKEN_ID } from '../common';
 import {
+  MINIMAL_MARKET_STATE_LAYOUT_V3,
   OPENBOOK_PROGRAM_ID,
-
 } from '../liquidity';
 
 export type MinimalOpenBookAccountData = {
   id: PublicKey;
   programId: PublicKey;
 };
+export type MinimalMarketStateLayoutV3 = typeof MINIMAL_MARKET_STATE_LAYOUT_V3;
+export type MinimalMarketLayoutV3 =
+  GetStructureSchema<MinimalMarketStateLayoutV3>;
 
 export async function getAllMarketsV3(
   connection: Connection,
+  quoteMint: PublicKey,
+  commitment?: Commitment,
 ): Promise<MinimalOpenBookAccountData[]> {
   const { span } = MARKET_STATE_LAYOUT_V3;
   const accounts = await connection.getProgramAccounts(OPENBOOK_PROGRAM_ID, {
     dataSlice: { offset: 0, length: 0 },
-    commitment: 'processed',
+    commitment: commitment,
     filters: [
       { dataSize: span },
       {
         memcmp: {
           offset: MARKET_STATE_LAYOUT_V3.offsetOf('quoteMint'),
-          bytes: USDC_TOKEN_ID.toBase58(),
+          bytes: quoteMint.toBase58(),
         },
       },
     ],

BIN
output.png


+ 0 - 26
utils/utils.ts

@@ -2,32 +2,6 @@ import { Logger } from "pino";
 import dotenv from 'dotenv';
 dotenv.config();
 
-/**
- * Runs the function `fn`
- * and retries automatically if it fails.
- *
- * Tries max `1 + retries` times
- * with `retryIntervalMs` milliseconds between retries.
- *
- * From https://mtsknn.fi/blog/js-retry-on-fail/
- */
-export const retry = async <T>(
-  fn: () => Promise<T> | T,
-  { retries, retryIntervalMs }: { retries: number; retryIntervalMs: number },
-): Promise<T> => {
-  try {
-    return await fn();
-  } catch (error) {
-    if (retries <= 0) {
-      throw error;
-    }
-    await sleep(retryIntervalMs);
-    return retry(fn, { retries: retries - 1, retryIntervalMs });
-  }
-};
-
-export const sleep = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
-
 export const retrieveEnvVariable = (variableName: string, logger: Logger) => {
     const variable = process.env[variableName] || '';
     if (!variable) {

BIN
wsol.png