UNPKG

orca-clmm-agent

Version:

Orca Whirlpool clmm library for automated position management

393 lines (392 loc) 20.3 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.getCreatedAssociatedTokenAccounts = exports.ASSOCIATED_TOKEN_PROGRAM_ID = exports.TOKEN_PROGRAM_ID = exports.PYUSD_MINT_ADDRESS = exports.USDC_MINT_ADDRESS = exports.SOL_MINT_ADDRESS = exports.INSUFFICIENT_FUNDS_ERROR = void 0; exports.fetchTokensWithBalanceByWallet = fetchTokensWithBalanceByWallet; exports.fetchNonZeroTokenBalances = fetchNonZeroTokenBalances; exports.loadKeypairFromFile = loadKeypairFromFile; exports.executeInstructions = executeInstructions; exports.awaitTransactionStatus = awaitTransactionStatus; exports.getTransactionDetails = getTransactionDetails; exports.closeAssociatedTokenAccount = closeAssociatedTokenAccount; exports.simulateTransaction = simulateTransaction; exports.checkForInstructionError = checkForInstructionError; const compute_budget_1 = require("@solana-program/compute-budget"); const orca_1 = require("./orca"); const fs_1 = require("fs"); const os_1 = require("os"); const path_1 = __importDefault(require("path")); const kit_1 = require("@solana/kit"); const utils_1 = require("./utils"); const date_fns_1 = require("date-fns"); const orca_types_1 = require("./orca.types"); const token_2022_1 = require("@solana-program/token-2022"); exports.INSUFFICIENT_FUNDS_ERROR = 1n; /** * Native SOL mint address */ exports.SOL_MINT_ADDRESS = "So11111111111111111111111111111111111111112"; exports.USDC_MINT_ADDRESS = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; exports.PYUSD_MINT_ADDRESS = "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo"; exports.TOKEN_PROGRAM_ID = (0, kit_1.address)("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"); exports.ASSOCIATED_TOKEN_PROGRAM_ID = (0, kit_1.address)("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"); //some tokens might need 2022 token program can be verified by fetching min account and checking owner field /** * Fetches SOL and token balances for a given wallet address * @param walletAddress The Solana wallet address to fetch balances for * @param rpcUrl Optional RPC URL (defaults to mainnet) * @returns Promise resolving to an array of tokens with balances (including SOL) */ async function fetchTokensWithBalanceByWallet(walletAddress, rpcUrl = "https://api.mainnet-beta.solana.com") { // Create RPC connection const rpc = (0, kit_1.createSolanaRpc)((0, kit_1.mainnet)(rpcUrl)); // Convert wallet address string to address const walletAddr = (0, kit_1.address)(walletAddress); // Fetch token accounts, SOL balance, and Orca pools in parallel const [tokensResponse, solBalanceResponse, pools] = await Promise.all([ // Fetch token accounts owned by the wallet rpc.getTokenAccountsByOwner(walletAddr, { programId: exports.TOKEN_PROGRAM_ID }, { encoding: "jsonParsed" }).send(), // Fetch SOL balance rpc.getBalance(walletAddr).send(), (0, orca_1.fetchOrcaPools)(), ]); const { value: tokensInWallet } = tokensResponse; const { value: solBalance } = solBalanceResponse; const tokens = [...pools.map((pool) => pool.tokenA), ...pools.map((pool) => pool.tokenB)]; // Create a unique set of tokens (removing duplicates) const uniqueTokens = tokens.reduce((acc, token) => { if (!acc.some((t) => t.address === token.address)) { acc.push(token); } return acc; }, []); // Map token accounts to token metadata with balance const tokensWithBalance = tokensInWallet .map(({ account }) => { const mint = account.data.parsed.info.mint; const match = uniqueTokens.find((token) => token.address === mint); if (!match) return null; return { ...match, balance: account.data.parsed.info.tokenAmount, }; }) .filter((token) => token !== null); // Find SOL token from uniqueTokens const solToken = uniqueTokens.find((token) => token.address === exports.SOL_MINT_ADDRESS || token.symbol === "SOL"); if (solToken) { // Create SOL token with balance const solTokenWithBalance = { ...solToken, balance: { amount: solBalance.toString(), decimals: 9, uiAmount: (0, utils_1.convertRawToDecimal)(solBalance, 9), uiAmountString: (0, utils_1.convertRawToDecimal)(solBalance, 9).toString(), }, }; // Add SOL to the beginning of the tokens array return [solTokenWithBalance, ...tokensWithBalance]; } // If SOL token not found in uniqueTokens, return just the other tokens return tokensWithBalance; } /** * Fetches token balances for a given wallet address and returns only tokens with non-zero balances * @param walletAddress The Solana wallet address to fetch balances for * @param rpcUrl Optional RPC URL (defaults to mainnet) * @returns Promise resolving to an array of tokens with balances */ async function fetchNonZeroTokenBalances(walletAddress, rpcUrl) { const tokens = await fetchTokensWithBalanceByWallet(walletAddress, rpcUrl); const filtered = tokens.filter((token) => Number(token.balance.uiAmount) > 0); const usdPrices = await Promise.all(filtered.map((token) => (0, utils_1.getUSDPrice)({ mintAddress: token.address }))); filtered.forEach((token, index) => { token.usdPrice = usdPrices[index]; token.usdValue = token.balance.uiAmount * token.usdPrice; }); return filtered; } async function loadKeypairFromFile(filePath) { const resolvedPath = path_1.default.resolve(filePath.startsWith("~") ? filePath.replace("~", (0, os_1.homedir)()) : filePath); const loadedKeyBytes = Uint8Array.from(JSON.parse((0, fs_1.readFileSync)(resolvedPath, "utf8"))); return loadedKeyBytes; } /** * Executes a set of instructions on the Solana blockchain * @param rpc The Solana RPC client * @param wallet The wallet to sign the transaction with * @param instructions The instructions to execute * @returns The signature of the executed transaction * @throws Error if the transaction fails */ async function executeInstructions(rpc, wallet, instructions) { //create transaction message const latestBlockHash = await rpc.getLatestBlockhash().send(); const transactionMessage = (0, kit_1.pipe)((0, kit_1.createTransactionMessage)({ version: 0 }), (tx) => (0, kit_1.setTransactionMessageFeePayer)(wallet.address, tx), (tx) => (0, kit_1.setTransactionMessageLifetimeUsingBlockhash)(latestBlockHash.value, tx), (tx) => (0, kit_1.appendTransactionMessageInstructions)(instructions, tx)); //estimate compute unit and estimate fee const getComputeUnitEstimateForTransactionMessage = (0, kit_1.getComputeUnitEstimateForTransactionMessageFactory)({ rpc, }); const [computeUnitEstimateBase, medianPrioritizationFee] = await Promise.all([ getComputeUnitEstimateForTransactionMessage(transactionMessage), rpc .getRecentPrioritizationFees() .send() .then((fees) => fees.map((fee) => Number(fee.prioritizationFee)).sort((a, b) => a - b)[Math.floor(fees.length / 2)]), ]); const computeUnitEstimate = computeUnitEstimateBase + 100000; //console.log(`Compute unit estimate: ${computeUnitEstimate}`); //console.log(`Median prioritization fee: ${medianPrioritizationFee}`); const transactionMessageWithComputeUnitInstructions = (0, kit_1.prependTransactionMessageInstructions)([(0, compute_budget_1.getSetComputeUnitLimitInstruction)({ units: computeUnitEstimate }), (0, compute_budget_1.getSetComputeUnitPriceInstruction)({ microLamports: medianPrioritizationFee })], transactionMessage); //sign and submit const signedTransaction = await (0, kit_1.signTransactionMessageWithSigners)(transactionMessageWithComputeUnitInstructions); const base64EncodedWireTransaction = (0, kit_1.getBase64EncodedWireTransaction)(signedTransaction); // Might need loop to get signature, if not loaded first try const signature = await rpc .sendTransaction(base64EncodedWireTransaction, { maxRetries: 3n, skipPreflight: true, //preflight -> simulation skipped encoding: "base64", //preflightCommitment: 'confirmed' // depth of simulation }) .send(); await (0, utils_1.sleep)(250); await awaitTransactionStatus(rpc, signature, "finalized"); const details = await getTransactionDetails(rpc, signature); const fee = details.meta?.fee || 0n; const feeDecimals = (0, utils_1.convertRawToDecimal)(fee, 9); console.log("Tx fee in SOL:", feeDecimals, "USD:", details.feeUSD); console.log("Tx Hash:", signature); return { signature, details }; } async function awaitTransactionStatus(rpc, signature, status) { const startTime = new Date(); while (true) { const statuses = await rpc.getSignatureStatuses([signature]).send(); if (statuses.value[0] && !statuses.value[0].err) { const confirmationStatus = statuses.value[0].confirmationStatus; if (confirmationStatus === status) { //console.log(`[awaitTransactionStatus] Transaction ${signature}: ${confirmationStatus}`) break; } } else if (statuses.value[0]?.err) { console.error(`[awaitTransactionStatus] Error from transaction`); const error = statuses.value[0].err; const isInstructionError = checkForInstructionError(error); if (isInstructionError) { console.error(`[awaitTransactionStatus] Transaction failed: ${error.InstructionError[1].Custom}`); const errCode = error.InstructionError[1].Custom; if (errCode === orca_1.TOKEN_MAX_EXCEEDED_ERROR) { throw new orca_types_1.OrcaError(`[awaitTransactionStatus] exceeds max amount`, orca_1.TOKEN_MAX_EXCEEDED_ERROR); } if (errCode === orca_1.TOKEN_MIN_SUBCEEDED_ERROR) { throw new orca_types_1.OrcaError(`[awaitTransactionStatus] subceeds min amount`, orca_1.TOKEN_MIN_SUBCEEDED_ERROR); } if (errCode === orca_1.INVALID_START_TICK_ERROR) { throw new orca_types_1.OrcaError(`[awaitTransactionStatus] invalid start tick`, orca_1.INVALID_START_TICK_ERROR); } } const solanaError = (0, kit_1.getSolanaErrorFromTransactionError)(error); throw solanaError; } await (0, utils_1.sleep)(1000); const elapsedTime = (0, date_fns_1.differenceInSeconds)(new Date(), startTime); if (elapsedTime > 60) { //console.error(`[awaitTransactionStatus] Transaction ${signature} timed out`); //TODO:handle SolanaError: custom program error: #6001 throw new Error(`[awaitTransactionStatus] Transaction timed out: ${signature}`); } } } async function getTransactionDetails(rpc, signature) { const details = await rpc .getTransaction(signature, { maxSupportedTransactionVersion: 0, encoding: "jsonParsed", }) .send(); if (!details) { throw new Error("Failed to get transaction details"); } //assuming index 0 is always wallet const accountKeys = details.transaction.message.accountKeys; const solMint = (0, kit_1.address)(exports.SOL_MINT_ADDRESS); //const wallet = details.transaction.message.accountKeys[0]; const preTokenBalances = details.meta?.preTokenBalances || []; const postTokenBalances = details.meta?.postTokenBalances || []; //calculate and get all tokens with their balances and balance change const changes = []; for (const pre of preTokenBalances) { //parse raw string const { amount, decimals } = pre.uiTokenAmount; const post = postTokenBalances?.find((p) => p.mint === pre.mint && p.owner === pre.owner); if (post) { const { amount: postAmount } = post.uiTokenAmount; const changeAmount = BigInt(postAmount) - BigInt(amount); if (changeAmount === 0n) continue; const change = { mint: pre.mint, owner: pre.owner, amount: BigInt(postAmount), amountDecimal: (0, utils_1.convertRawToDecimal)(BigInt(postAmount), decimals), change: changeAmount, changeDecimal: (0, utils_1.convertRawToDecimal)(changeAmount, decimals), }; changes.push(change); } } // Handle new tokens received that were not in preTokenBalances (e.g., first time swap) for (const post of postTokenBalances) { if (!preTokenBalances.some((pre) => pre.mint === post.mint && pre.owner === post.owner)) { const { amount, decimals } = post.uiTokenAmount; const changeAmount = BigInt(amount); if (changeAmount === 0n) continue; changes.push({ mint: post.mint, owner: post.owner, amount: changeAmount, amountDecimal: (0, utils_1.convertRawToDecimal)(changeAmount, decimals), change: changeAmount, changeDecimal: (0, utils_1.convertRawToDecimal)(changeAmount, decimals), }); } } const preBalances = details.meta?.preBalances || []; const postBalances = details.meta?.postBalances || []; for (let idx = 0; idx < accountKeys.length; idx++) { const account = accountKeys[idx]; const changeLamports = postBalances[idx] - preBalances[idx]; if (changeLamports !== 0n) { const change = { mint: solMint, owner: account.pubkey, amount: BigInt(postBalances[idx]), amountDecimal: (0, utils_1.convertRawToDecimal)(BigInt(postBalances[idx]), 9), change: BigInt(changeLamports), changeDecimal: (0, utils_1.convertRawToDecimal)(BigInt(changeLamports), 9), }; changes.push(change); } } const solPrice = await (0, utils_1.getUSDPrice)({ mintAddress: exports.SOL_MINT_ADDRESS }); const fee = details.meta?.fee || 0n; const feeDecimals = (0, utils_1.convertRawToDecimal)(fee, 9); const feeUSD = feeDecimals * solPrice; return { ...details, changes, feeUSD, signature }; } /** * Parses transaction details to find any associated token accounts created. * @param details Transaction details from {@link getTransactionDetails} * @returns Array of created ATA information */ const getCreatedAssociatedTokenAccounts = (details) => { const created = []; const accountKeys = details.transaction.message.accountKeys; const instructions = details.transaction.message.instructions; // First pass: detect created ATAs for (const ix of instructions) { const programMatch = ix.program === "spl-associated-token-account" || ix.programId === exports.ASSOCIATED_TOKEN_PROGRAM_ID.toString(); if (!programMatch) continue; // Handle both compiled and parsed (jsonParsed) instruction formats if (Array.isArray(ix.accounts) && ix.accounts.length >= 4) { // Compiled format: account indices point into message.accountKeys const ataKey = accountKeys[ix.accounts[1]]; const ownerKey = accountKeys[ix.accounts[2]]; const mintKey = accountKeys[ix.accounts[3]]; const ata = (0, kit_1.address)(ataKey.pubkey ?? ataKey); const owner = (0, kit_1.address)(ownerKey.pubkey ?? ownerKey); const mint = (0, kit_1.address)(mintKey.pubkey ?? mintKey); created.push({ ata, owner, mint }); } else if (ix.parsed && ix.parsed.info) { // Parsed format (jsonParsed): extract fields from the info object const info = ix.parsed.info; const ata = (0, kit_1.address)(info.account); const mint = (0, kit_1.address)(info.mint); // The owner field may be named owner, wallet, or source depending on SPL-ATA version const ownerRaw = info.owner ?? info.wallet ?? info.source; const owner = ownerRaw ? (0, kit_1.address)(ownerRaw) : undefined; created.push({ ata, owner, mint }); } } // Second pass: remove ATAs that are closed in the same transaction for (const ix of instructions) { if (ix.program === "spl-token" || ix.programId === exports.TOKEN_PROGRAM_ID.toString()) { if (ix.parsed?.type === "closeAccount") { const ataToClose = (0, kit_1.address)(ix.parsed.info.account); const index = created.findIndex((c) => c.ata.toString() === ataToClose.toString()); if (index !== -1) { created.splice(index, 1); } } } } return created; }; exports.getCreatedAssociatedTokenAccounts = getCreatedAssociatedTokenAccounts; async function closeAssociatedTokenAccount(rpc, wallet, ata) { //TODO: needs to be tested const closeTokenAccountIx = (0, token_2022_1.getCloseAccountInstruction)({ account: ata, destination: wallet.address, owner: wallet.address, }); return await executeInstructions(rpc, wallet, [closeTokenAccountIx]); } async function simulateTransaction(rpc, wallet, instructions) { //create transaction message const latestBlockHash = await rpc.getLatestBlockhash().send(); const transactionMessage = (0, kit_1.pipe)((0, kit_1.createTransactionMessage)({ version: 0 }), (tx) => (0, kit_1.setTransactionMessageFeePayer)(wallet.address, tx), (tx) => (0, kit_1.setTransactionMessageLifetimeUsingBlockhash)(latestBlockHash.value, tx), (tx) => (0, kit_1.appendTransactionMessageInstructions)(instructions, tx)); //estimate compute unit and estimate fee const getComputeUnitEstimateForTransactionMessage = (0, kit_1.getComputeUnitEstimateForTransactionMessageFactory)({ rpc, }); const computeUnitEstimate = (await getComputeUnitEstimateForTransactionMessage(transactionMessage)) + 100000; const medianPrioritizationFee = await rpc .getRecentPrioritizationFees() .send() .then((fees) => fees.map((fee) => Number(fee.prioritizationFee)).sort((a, b) => a - b)[Math.floor(fees.length / 2)]); console.log(`Compute unit estimate: ${computeUnitEstimate}`); console.log(`Median prioritization fee: ${medianPrioritizationFee}`); const transactionMessageWithComputeUnitInstructions = (0, kit_1.prependTransactionMessageInstructions)([ // @ts-ignore (0, compute_budget_1.getSetComputeUnitLimitInstruction)({ units: computeUnitEstimate }), // @ts-ignore (0, compute_budget_1.getSetComputeUnitPriceInstruction)({ microLamports: medianPrioritizationFee }), ], transactionMessage); //sign and submit // @ts-ignore const signedTransaction = await (0, kit_1.signTransactionMessageWithSigners)(transactionMessageWithComputeUnitInstructions); const base64EncodedWireTransaction = (0, kit_1.getBase64EncodedWireTransaction)(signedTransaction); const simulationResult = await rpc .simulateTransaction(base64EncodedWireTransaction, { //skipPreflight: true, encoding: "base64", }) .send(); if (simulationResult.value.err) { console.error("Simulation error:", simulationResult.value.err); throw simulationResult.value.err; } const estimatedFee = simulationResult.value.unitsConsumed || 0n; console.log("Estimated Fee:", estimatedFee); const decimal = (0, utils_1.convertRawToDecimal)(estimatedFee, 9); console.log("Estimated Fee in SOL:", decimal); return { estimatedFee: decimal, }; } function checkForInstructionError(err) { return (err?.InstructionError && typeof err.InstructionError[0] === "bigint" && err.InstructionError[1] !== undefined && typeof err.InstructionError[1].Custom === "bigint"); }