feat: add support for wsol swaps

This commit is contained in:
Filip Dunder
2024-01-30 09:57:39 +01:00
parent e8c8147bae
commit 636ef3e515
10 changed files with 252 additions and 127 deletions

View File

@ -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

View File

@ -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)

221
buy.ts
View File

@ -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;
if (!tokenAccountOut) {
logger.info(`No token account for ${baseMint}`);
return;
}
const { innerTransaction, address } = Liquidity.makeSwapFixedInInstruction(
{
poolKeys,
userKeys: {
tokenAccountIn: usdcTokenKey,
tokenAccountOut: tokenAccountOut,
owner: wallet.publicKey,
},
amountIn: USDC_AMOUNT * 1000000,
minAmountOut: 0,
},
poolKeys.version,
async function buy(
accountId: PublicKey,
accountData: LiquidityStateV4,
): Promise<void> {
const tokenAccount = existingTokenAccounts.get(
accountData.baseMint.toString(),
);
if (!tokenAccount) {
return;
}
tokenAccount.poolKeys = createPoolKeys(
accountId,
accountData,
tokenAccount.market!,
);
const { innerTransaction, address } = 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,
});
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(),
},
},
],

View File

@ -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',
);

View File

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

View File

@ -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) {

View File

@ -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 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@ -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 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB