buy.ts 16 KB

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