orca-clmm-agent
Version:
Orca Whirlpool clmm library for automated position management
393 lines (392 loc) • 20.3 kB
JavaScript
;
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");
}