buy.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637
  1. import {
  2. Liquidity,
  3. LIQUIDITY_STATE_LAYOUT_V4,
  4. LiquidityPoolKeys,
  5. LiquidityStateV4,
  6. MARKET_STATE_LAYOUT_V2,
  7. MARKET_STATE_LAYOUT_V3,
  8. MarketStateV3,
  9. Token,
  10. TokenAmount,
  11. } from '@raydium-io/raydium-sdk';
  12. import {
  13. createAssociatedTokenAccountIdempotentInstruction,
  14. getAssociatedTokenAddressSync,
  15. TOKEN_PROGRAM_ID,
  16. } from '@solana/spl-token';
  17. import {
  18. Keypair,
  19. Connection,
  20. PublicKey,
  21. ComputeBudgetProgram,
  22. KeyedAccountInfo,
  23. TransactionMessage,
  24. VersionedTransaction,
  25. Commitment,
  26. } from '@solana/web3.js';
  27. import {
  28. getAllAccountsV4,
  29. getTokenAccounts,
  30. RAYDIUM_LIQUIDITY_PROGRAM_ID_V4,
  31. OPENBOOK_PROGRAM_ID,
  32. createPoolKeys,
  33. } from './liquidity';
  34. import { retrieveEnvVariable } from './utils';
  35. import { getAllMarketsV3, MinimalMarketLayoutV3 } from './market';
  36. import pino from 'pino';
  37. import bs58 from 'bs58';
  38. import * as fs from 'fs';
  39. import * as path from 'path';
  40. import BN from 'bn.js';
  41. const transport = pino.transport({
  42. targets: [
  43. // {
  44. // level: 'trace',
  45. // target: 'pino/file',
  46. // options: {
  47. // destination: 'buy.log',
  48. // },
  49. // },
  50. {
  51. level: 'trace',
  52. target: 'pino-pretty',
  53. options: {},
  54. },
  55. ],
  56. });
  57. export const logger = pino(
  58. {
  59. redact: ['poolKeys'],
  60. serializers: {
  61. error: pino.stdSerializers.err,
  62. },
  63. base: undefined,
  64. },
  65. transport,
  66. );
  67. const network = 'mainnet-beta';
  68. const RPC_ENDPOINT = retrieveEnvVariable('RPC_ENDPOINT', logger);
  69. const RPC_WEBSOCKET_ENDPOINT = retrieveEnvVariable(
  70. 'RPC_WEBSOCKET_ENDPOINT',
  71. logger,
  72. );
  73. const solanaConnection = new Connection(RPC_ENDPOINT, {
  74. wsEndpoint: RPC_WEBSOCKET_ENDPOINT,
  75. });
  76. export type MinimalTokenAccountData = {
  77. mint: PublicKey;
  78. address: PublicKey;
  79. poolKeys?: LiquidityPoolKeys;
  80. market?: MinimalMarketLayoutV3;
  81. };
  82. let existingLiquidityPools: Set<string> = new Set<string>();
  83. let existingOpenBookMarkets: Set<string> = new Set<string>();
  84. let existingTokenAccounts: Map<string, MinimalTokenAccountData> = new Map<
  85. string,
  86. MinimalTokenAccountData
  87. >();
  88. let wallet: Keypair;
  89. let quoteToken: Token;
  90. let quoteTokenAssociatedAddress: PublicKey;
  91. let quoteAmount: TokenAmount;
  92. let commitment: Commitment = retrieveEnvVariable(
  93. 'COMMITMENT_LEVEL',
  94. logger,
  95. ) as Commitment;
  96. const USE_SNIPE_LIST = retrieveEnvVariable('USE_SNIPE_LIST', logger) === 'true';
  97. const SNIPE_LIST_REFRESH_INTERVAL = Number(
  98. retrieveEnvVariable('SNIPE_LIST_REFRESH_INTERVAL', logger),
  99. );
  100. let snipeList: string[] = [];
  101. async function init(): Promise<void> {
  102. // get wallet
  103. const PRIVATE_KEY = retrieveEnvVariable('PRIVATE_KEY', logger);
  104. wallet = Keypair.fromSecretKey(bs58.decode(PRIVATE_KEY));
  105. logger.info(`Wallet Address: ${wallet.publicKey}`);
  106. // get quote mint and amount
  107. const QUOTE_MINT = retrieveEnvVariable('QUOTE_MINT', logger);
  108. const QUOTE_AMOUNT = retrieveEnvVariable('QUOTE_AMOUNT', logger);
  109. switch (QUOTE_MINT) {
  110. case 'WSOL': {
  111. quoteToken = Token.WSOL;
  112. quoteAmount = new TokenAmount(Token.WSOL, QUOTE_AMOUNT, false);
  113. break;
  114. }
  115. case 'USDC': {
  116. quoteToken = new Token(
  117. TOKEN_PROGRAM_ID,
  118. new PublicKey('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'),
  119. 6,
  120. 'USDC',
  121. 'USDC',
  122. );
  123. quoteAmount = new TokenAmount(quoteToken, QUOTE_AMOUNT, false);
  124. break;
  125. }
  126. default: {
  127. throw new Error(
  128. `Unsupported quote mint "${QUOTE_MINT}". Supported values are USDC and WSOL`,
  129. );
  130. }
  131. }
  132. logger.info(
  133. `Script will buy all new tokens using ${QUOTE_MINT}. Amount that will be used to buy each token is: ${quoteAmount.toFixed().toString()}`,
  134. );
  135. let message = {
  136. embeds: [
  137. {
  138. title: `Script will buy all new tokens using ${QUOTE_MINT}. Amount that will be used to buy each token is: ${quoteAmount.toFixed().toString()}`,
  139. color: 1127128,
  140. },
  141. ],
  142. };
  143. // post it to discord
  144. const DISCORD_WEBHOOK = retrieveEnvVariable('DISCORD_WEBHOOK', logger);
  145. // use native fetch to post to discord
  146. fetch(DISCORD_WEBHOOK, {
  147. method: 'POST',
  148. headers: {
  149. 'Content-Type': 'application/json',
  150. },
  151. body: JSON.stringify(message),
  152. });
  153. // get all existing liquidity pools
  154. const allLiquidityPools = await getAllAccountsV4(
  155. solanaConnection,
  156. quoteToken.mint,
  157. commitment,
  158. );
  159. existingLiquidityPools = new Set(
  160. allLiquidityPools.map((p) => p.id.toString()),
  161. );
  162. // get all open-book markets
  163. const allMarkets = await getAllMarketsV3(
  164. solanaConnection,
  165. quoteToken.mint,
  166. commitment,
  167. );
  168. existingOpenBookMarkets = new Set(allMarkets.map((p) => p.id.toString()));
  169. logger.info(
  170. `Total ${quoteToken.symbol} markets ${existingOpenBookMarkets.size}`,
  171. );
  172. logger.info(
  173. `Total ${quoteToken.symbol} pools ${existingLiquidityPools.size}`,
  174. );
  175. // post to discord webhook
  176. message = {
  177. embeds: [
  178. {
  179. title: `Total ${quoteToken.symbol} markets ${existingOpenBookMarkets.size}`,
  180. color: 1127128,
  181. },
  182. {
  183. title: `Total ${quoteToken.symbol} pools ${existingLiquidityPools.size}`,
  184. color: 14177041,
  185. },
  186. ],
  187. };
  188. // post it to discord
  189. fetch(DISCORD_WEBHOOK, {
  190. method: 'POST',
  191. headers: {
  192. 'Content-Type': 'application/json',
  193. },
  194. body: JSON.stringify(message),
  195. });
  196. // check existing wallet for associated token account of quote mint
  197. const tokenAccounts = await getTokenAccounts(
  198. solanaConnection,
  199. wallet.publicKey,
  200. commitment,
  201. );
  202. for (const ta of tokenAccounts) {
  203. existingTokenAccounts.set(ta.accountInfo.mint.toString(), <
  204. MinimalTokenAccountData
  205. >{
  206. mint: ta.accountInfo.mint,
  207. address: ta.pubkey,
  208. });
  209. }
  210. const tokenAccount = tokenAccounts.find(
  211. (acc) => acc.accountInfo.mint.toString() === quoteToken.mint.toString(),
  212. )!;
  213. if (!tokenAccount) {
  214. throw new Error(
  215. `No ${quoteToken.symbol} token account found in wallet: ${wallet.publicKey}`,
  216. );
  217. }
  218. quoteTokenAssociatedAddress = tokenAccount.pubkey;
  219. // load tokens to snipe
  220. loadSnipeList();
  221. }
  222. // Auto sell if enabled in .env file
  223. const AUTO_SELL = retrieveEnvVariable('AUTO_SELL', logger);
  224. export async function processRaydiumPool(updatedAccountInfo: KeyedAccountInfo) {
  225. let accountData: LiquidityStateV4 | undefined;
  226. try {
  227. accountData = LIQUIDITY_STATE_LAYOUT_V4.decode(
  228. updatedAccountInfo.accountInfo.data,
  229. );
  230. if (!shouldBuy(accountData.baseMint.toString())) {
  231. return;
  232. }
  233. await buy(updatedAccountInfo.accountId, accountData);
  234. if (AUTO_SELL === 'true') {
  235. const SELL_DELAY = Number(retrieveEnvVariable('SELL_DELAY', logger));
  236. await new Promise((resolve) => setTimeout(resolve, SELL_DELAY));
  237. await sell(updatedAccountInfo.accountId, accountData);
  238. } else {
  239. logger.info(
  240. `Auto sell is disabled. To enable it, set AUTO_SELL=true in .env file`,
  241. );
  242. }
  243. } catch (e) {
  244. logger.error({ ...accountData, error: e }, `Failed to process pool`);
  245. }
  246. }
  247. export async function processOpenBookMarket(
  248. updatedAccountInfo: KeyedAccountInfo,
  249. ) {
  250. let accountData: MarketStateV3 | undefined;
  251. try {
  252. accountData = MARKET_STATE_LAYOUT_V3.decode(
  253. updatedAccountInfo.accountInfo.data,
  254. );
  255. // to be competitive, we collect market data before buying the token...
  256. if (existingTokenAccounts.has(accountData.baseMint.toString())) {
  257. return;
  258. }
  259. const ata = getAssociatedTokenAddressSync(
  260. accountData.baseMint,
  261. wallet.publicKey,
  262. );
  263. existingTokenAccounts.set(accountData.baseMint.toString(), <
  264. MinimalTokenAccountData
  265. >{
  266. address: ata,
  267. mint: accountData.baseMint,
  268. market: <MinimalMarketLayoutV3>{
  269. bids: accountData.bids,
  270. asks: accountData.asks,
  271. eventQueue: accountData.eventQueue,
  272. },
  273. });
  274. } catch (e) {
  275. logger.error({ ...accountData, error: e }, `Failed to process market`);
  276. }
  277. }
  278. async function buy(
  279. accountId: PublicKey,
  280. accountData: LiquidityStateV4,
  281. ): Promise<void> {
  282. const tokenAccount = existingTokenAccounts.get(
  283. accountData.baseMint.toString(),
  284. );
  285. if (!tokenAccount) {
  286. return;
  287. }
  288. tokenAccount.poolKeys = createPoolKeys(
  289. accountId,
  290. accountData,
  291. tokenAccount.market!,
  292. );
  293. const { innerTransaction, address } = Liquidity.makeSwapFixedInInstruction(
  294. {
  295. poolKeys: tokenAccount.poolKeys,
  296. userKeys: {
  297. tokenAccountIn: quoteTokenAssociatedAddress,
  298. tokenAccountOut: tokenAccount.address,
  299. owner: wallet.publicKey,
  300. },
  301. amountIn: quoteAmount.raw,
  302. minAmountOut: 0,
  303. },
  304. tokenAccount.poolKeys.version,
  305. );
  306. const latestBlockhash = await solanaConnection.getLatestBlockhash({
  307. commitment: commitment,
  308. });
  309. const messageV0 = new TransactionMessage({
  310. payerKey: wallet.publicKey,
  311. recentBlockhash: latestBlockhash.blockhash,
  312. instructions: [
  313. ComputeBudgetProgram.setComputeUnitLimit({ units: 400000 }),
  314. ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 30000 }),
  315. createAssociatedTokenAccountIdempotentInstruction(
  316. wallet.publicKey,
  317. tokenAccount.address,
  318. wallet.publicKey,
  319. accountData.baseMint,
  320. ),
  321. ...innerTransaction.instructions,
  322. ],
  323. }).compileToV0Message();
  324. const transaction = new VersionedTransaction(messageV0);
  325. transaction.sign([wallet, ...innerTransaction.signers]);
  326. const signature = await solanaConnection.sendRawTransaction(
  327. transaction.serialize(),
  328. {
  329. maxRetries: 20,
  330. preflightCommitment: commitment,
  331. },
  332. );
  333. logger.info(
  334. {
  335. mint: accountData.baseMint,
  336. url: `https://solscan.io/tx/${signature}?cluster=${network}`,
  337. },
  338. 'Buy',
  339. );
  340. // post to discord webhook
  341. const message = {
  342. embeds: [
  343. {
  344. title: `Bought token: ${accountData.baseMint.toBase58()}`,
  345. color: 1127128,
  346. url: `https://solscan.io/tx/${signature}?cluster=${network}`,
  347. },
  348. ],
  349. };
  350. const DISCORD_WEBHOOK = retrieveEnvVariable('DISCORD_WEBHOOK', logger);
  351. // use native fetch to post to discord
  352. fetch(DISCORD_WEBHOOK, {
  353. method: 'POST',
  354. headers: {
  355. 'Content-Type': 'application/json',
  356. },
  357. body: JSON.stringify(message),
  358. });
  359. }
  360. const maxRetries = 60;
  361. async function sell(
  362. accountId: PublicKey,
  363. accountData: LiquidityStateV4,
  364. ): Promise<void> {
  365. const tokenAccount = existingTokenAccounts.get(
  366. accountData.baseMint.toString(),
  367. );
  368. if (!tokenAccount) {
  369. return;
  370. }
  371. let retries = 0;
  372. let balanceFound = false;
  373. while (retries < maxRetries) {
  374. try {
  375. const balanceResponse = (await solanaConnection.getTokenAccountBalance(tokenAccount.address)).value.amount;
  376. if (balanceResponse !== null && Number(balanceResponse) > 0 && !balanceFound) {
  377. balanceFound = true;
  378. console.log("Token balance: ", balanceResponse);
  379. // send to discord
  380. const tokenBalanceMessage = {
  381. embeds: [
  382. {
  383. title: `Token balance: ${balanceResponse}`,
  384. color: 1127128,
  385. },
  386. ],
  387. };
  388. const DISCORD_WEBHOOK = retrieveEnvVariable('DISCORD_WEBHOOK', logger);
  389. // use native fetch to post to discord
  390. fetch(DISCORD_WEBHOOK, {
  391. method: 'POST',
  392. headers: {
  393. 'Content-Type': 'application/json',
  394. },
  395. body: JSON.stringify(tokenBalanceMessage),
  396. });
  397. tokenAccount.poolKeys = createPoolKeys(
  398. accountId,
  399. accountData,
  400. tokenAccount.market!,
  401. );
  402. const { innerTransaction, address } = Liquidity.makeSwapFixedInInstruction(
  403. {
  404. poolKeys: tokenAccount.poolKeys,
  405. userKeys: {
  406. tokenAccountIn: tokenAccount.address,
  407. tokenAccountOut: quoteTokenAssociatedAddress,
  408. owner: wallet.publicKey,
  409. },
  410. amountIn: new BN(balanceResponse),
  411. minAmountOut: 0,
  412. },
  413. tokenAccount.poolKeys.version,
  414. );
  415. const latestBlockhash = await solanaConnection.getLatestBlockhash({
  416. commitment: commitment,
  417. });
  418. const messageV0 = new TransactionMessage({
  419. payerKey: wallet.publicKey,
  420. recentBlockhash: latestBlockhash.blockhash,
  421. instructions: [
  422. ComputeBudgetProgram.setComputeUnitLimit({ units: 400000 }),
  423. ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 200000 }),
  424. createAssociatedTokenAccountIdempotentInstruction(
  425. wallet.publicKey,
  426. tokenAccount.address,
  427. wallet.publicKey,
  428. accountData.baseMint,
  429. ),
  430. ...innerTransaction.instructions,
  431. ],
  432. }).compileToV0Message();
  433. const transaction = new VersionedTransaction(messageV0);
  434. transaction.sign([wallet, ...innerTransaction.signers]);
  435. const signature = await solanaConnection.sendRawTransaction(
  436. transaction.serialize(),
  437. {
  438. maxRetries: 5,
  439. preflightCommitment: commitment,
  440. },
  441. );
  442. logger.info(
  443. {
  444. mint: accountData.baseMint,
  445. url: `https://solscan.io/tx/${signature}?cluster=${network}`,
  446. },
  447. 'sell',
  448. );
  449. // post to discord webhook
  450. const sellMessage = {
  451. embeds: [
  452. {
  453. title: `Sold token: ${accountData.baseMint.toBase58()}`,
  454. color: 1127128,
  455. url: `https://solscan.io/tx/${signature}?cluster=${network}`,
  456. },
  457. ],
  458. };
  459. // const DISCORD_WEBHOOK = retrieveEnvVariable('DISCORD_WEBHOOK', logger);
  460. // use native fetch to post to discord
  461. fetch(DISCORD_WEBHOOK, {
  462. method: 'POST',
  463. headers: {
  464. 'Content-Type': 'application/json',
  465. },
  466. body: JSON.stringify(sellMessage),
  467. });
  468. break;
  469. }
  470. } catch (error) {
  471. }
  472. retries++;
  473. await new Promise((resolve) => setTimeout(resolve, 1000));
  474. }
  475. }
  476. function loadSnipeList() {
  477. if (!USE_SNIPE_LIST) {
  478. return;
  479. }
  480. const count = snipeList.length;
  481. const data = fs.readFileSync(path.join(__dirname, 'snipe-list.txt'), 'utf-8');
  482. snipeList = data
  483. .split('\n')
  484. .map((a) => a.trim())
  485. .filter((a) => a);
  486. if (snipeList.length != count) {
  487. logger.info(`Loaded snipe list: ${snipeList.length}`);
  488. }
  489. }
  490. function shouldBuy(key: string): boolean {
  491. return USE_SNIPE_LIST ? snipeList.includes(key) : true;
  492. }
  493. const runListener = async () => {
  494. await init();
  495. const raydiumSubscriptionId = solanaConnection.onProgramAccountChange(
  496. RAYDIUM_LIQUIDITY_PROGRAM_ID_V4,
  497. async (updatedAccountInfo) => {
  498. const key = updatedAccountInfo.accountId.toString();
  499. const existing = existingLiquidityPools.has(key);
  500. if (!existing) {
  501. existingLiquidityPools.add(key);
  502. const _ = processRaydiumPool(updatedAccountInfo);
  503. }
  504. },
  505. commitment,
  506. [
  507. { dataSize: LIQUIDITY_STATE_LAYOUT_V4.span },
  508. {
  509. memcmp: {
  510. offset: LIQUIDITY_STATE_LAYOUT_V4.offsetOf('quoteMint'),
  511. bytes: quoteToken.mint.toBase58(),
  512. },
  513. },
  514. {
  515. memcmp: {
  516. offset: LIQUIDITY_STATE_LAYOUT_V4.offsetOf('marketProgramId'),
  517. bytes: OPENBOOK_PROGRAM_ID.toBase58(),
  518. },
  519. },
  520. {
  521. memcmp: {
  522. offset: LIQUIDITY_STATE_LAYOUT_V4.offsetOf('status'),
  523. bytes: bs58.encode([6, 0, 0, 0, 0, 0, 0, 0]),
  524. },
  525. },
  526. ],
  527. );
  528. const openBookSubscriptionId = solanaConnection.onProgramAccountChange(
  529. OPENBOOK_PROGRAM_ID,
  530. async (updatedAccountInfo) => {
  531. const key = updatedAccountInfo.accountId.toString();
  532. const existing = existingOpenBookMarkets.has(key);
  533. if (!existing) {
  534. existingOpenBookMarkets.add(key);
  535. const _ = processOpenBookMarket(updatedAccountInfo);
  536. }
  537. },
  538. commitment,
  539. [
  540. { dataSize: MARKET_STATE_LAYOUT_V2.span },
  541. {
  542. memcmp: {
  543. offset: MARKET_STATE_LAYOUT_V2.offsetOf('quoteMint'),
  544. bytes: quoteToken.mint.toBase58(),
  545. },
  546. },
  547. ],
  548. );
  549. logger.info(`Listening for raydium changes: ${raydiumSubscriptionId}`);
  550. logger.info(`Listening for open book changes: ${openBookSubscriptionId}`);
  551. // post to discord webhook
  552. const message = {
  553. embeds: [
  554. {
  555. title: `Listening for raydium changes: ${raydiumSubscriptionId}`,
  556. color: 1127128,
  557. },
  558. {
  559. title: `Listening for open book changes: ${openBookSubscriptionId}`,
  560. color: 14177041,
  561. },
  562. ],
  563. };
  564. const DISCORD_WEBHOOK = retrieveEnvVariable('DISCORD_WEBHOOK', logger);
  565. // use native fetch to post to discord
  566. fetch(DISCORD_WEBHOOK, {
  567. method: 'POST',
  568. headers: {
  569. 'Content-Type': 'application/json',
  570. },
  571. body: JSON.stringify(message),
  572. });
  573. if (USE_SNIPE_LIST) {
  574. setInterval(loadSnipeList, SNIPE_LIST_REFRESH_INTERVAL);
  575. }
  576. };
  577. runListener();