UNPKG

edwin-sdk

Version:

SDK for integrating AI agents with DeFi protocols

600 lines (523 loc) 25.9 kB
import { SolanaWalletClient } from '../../core/wallets/solana_wallet'; import DLMM, { StrategyType, BinLiquidity, PositionData, LbPosition, PositionInfo } from '@meteora-ag/dlmm'; import { Keypair, PublicKey } from '@solana/web3.js'; import { BN } from '@coral-xyz/anchor'; import edwinLogger from '../../utils/logger'; import { calculateAmounts, extractBalanceChanges, verifyAddLiquidityTokenAmounts } from './utils'; import { withRetry } from '../../utils'; import { MeteoraStatisticalBugError } from './errors'; import { AddLiquidityParameters, RemoveLiquidityParameters, PoolParameters, GetPoolsParameters } from './parameters'; import { InsufficientBalanceError } from '../../errors'; interface MeteoraPoolResult { pairs: MeteoraPool[]; } interface MeteoraPool { address: string; name: string; bin_step: number; base_fee_percentage: string; max_fee_percentage: string; protocol_fee_percentage: string; liquidity: string; fees_24h: number; trade_volume_24h: number; current_price: number; apr: number; } interface MeteoraPoolOutput { address: string; name: string; bin_step: number; base_fee_percentage: string; max_fee_percentage: string; protocol_fee_percentage: string; liquidity: string; fees_24h: number; trade_volume_24h: number; current_price: number; apr_percentage: number; } interface Position { address: string; pair_address: string; } export class MeteoraProtocol { private static readonly BASE_URL = 'https://dlmm-api.meteora.ag'; private wallet: SolanaWalletClient; constructor(wallet: SolanaWalletClient) { this.wallet = wallet; } async getPortfolio(): Promise<string> { return ''; } async getPositionInfo(positionAddress: string): Promise<Position> { try { const response = await fetch(`https://dlmm-api.meteora.ag/position_v2/${positionAddress}`); if (!response.ok) { throw new Error(`Failed to fetch position info: ${response.statusText}`); } return await response.json(); } catch (error: unknown) { edwinLogger.error('Error fetching Meteora position info:', error); const message = error instanceof Error ? error.message : String(error); throw new Error(`Failed to get Meteora position info: ${message}`); } } async getPools(params: GetPoolsParameters): Promise<MeteoraPoolOutput[]> { const { asset, assetB } = params; const limit = 10; if (!asset || !assetB) { throw new Error('Asset A and Asset B are required for Meteora getPools'); } const response = await fetch( `${MeteoraProtocol.BASE_URL}/pair/all_with_pagination?search_term=${asset}-${assetB}&limit=${limit}` ); const result: MeteoraPoolResult = await response.json(); if (!result.pairs) { throw new Error(`No pool found for ${asset}-${assetB}`); } return result.pairs.map(pool => ({ address: pool.address, name: pool.name, bin_step: pool.bin_step, base_fee_percentage: pool.base_fee_percentage, max_fee_percentage: pool.max_fee_percentage, protocol_fee_percentage: pool.protocol_fee_percentage, liquidity: pool.liquidity, fees_24h: pool.fees_24h, trade_volume_24h: pool.trade_volume_24h, current_price: pool.current_price, apr_percentage: pool.apr, })); } async getPositionsFromPool(params: PoolParameters): Promise<Array<LbPosition>> { const { poolAddress } = params; if (!poolAddress) { throw new Error('Pool address is required for Meteora getPositionsFromPool'); } const connection = this.wallet.getConnection(); const dlmmPool = await DLMM.create(connection, new PublicKey(poolAddress)); const { userPositions } = await withRetry( async () => dlmmPool.getPositionsByUserAndLbPair(this.wallet.publicKey), 'Meteora get user positions' ); return userPositions; } async getPositions(): Promise<Map<string, PositionInfo>> { try { const connection = this.wallet.getConnection(); return await withRetry( async () => DLMM.getAllLbPairPositionsByUser(connection, this.wallet.publicKey), 'Meteora getPositions' ); } catch (error: unknown) { edwinLogger.error('Meteora getPositions error:', error); const message = error instanceof Error ? error.message : String(error); throw new Error(`Meteora getPositions failed: ${message}`); } } async getActiveBin(params: PoolParameters): Promise<BinLiquidity> { const { poolAddress } = params; if (!poolAddress) { throw new Error('Pool address is required for Meteora getActiveBin'); } return await withRetry(async () => { // Ensure we use the Helius RPC URL from environment const connection = this.wallet.getConnection(); const dlmmPool = await DLMM.create(connection, new PublicKey(poolAddress)); return dlmmPool.getActiveBin(); }, 'Meteora getActiveBin'); } /** * Helper method for adding liquidity to a Meteora pool * @returns Transaction signature */ private async innerAddLiquidity( poolAddress: string, amount: string, amountB: string, rangeInterval: number = 10 ): Promise<string> { // Ensure we use the Helius RPC URL from environment to avoid rate limits const connection = this.wallet.getConnection(); const dlmmPool = await withRetry( async () => DLMM.create(connection, new PublicKey(poolAddress)), 'Meteora create pool' ); const balance = await this.wallet.getBalance(dlmmPool.tokenX.publicKey.toString()); if (balance < Number(amount)) { throw new InsufficientBalanceError(Number(amount), balance, dlmmPool.tokenX.publicKey.toString()); } const balanceB = await this.wallet.getBalance(dlmmPool.tokenY.publicKey.toString()); if (balanceB < Number(amountB)) { throw new InsufficientBalanceError(Number(amountB), balanceB, dlmmPool.tokenY.publicKey.toString()); } // Wrap the position check in retry logic const positionInfo = await withRetry( async () => dlmmPool.getPositionsByUserAndLbPair(this.wallet.publicKey), 'Meteora get user positions' ); const existingPosition = positionInfo?.userPositions?.[0]; const activeBin = await withRetry(async () => dlmmPool.getActiveBin(), 'Meteora get active bin'); const activeBinPricePerToken = dlmmPool.fromPricePerLamport(Number(activeBin.price)); const [totalXAmount, totalYAmount]: [BN, BN] = await calculateAmounts( amount, amountB, activeBinPricePerToken, dlmmPool ); if (totalXAmount.isZero() && totalYAmount.isZero()) { throw new TypeError('Total liquidity trying to add is 0'); } edwinLogger.debug(`Adding liquidity with Total X amount: ${totalXAmount}, Total Y amount: ${totalYAmount}`); let tx; let positionPubKey: PublicKey; const signers: Keypair[] = []; if (existingPosition) { edwinLogger.debug(`Adding liquidity to existing position`); // Get min and max bin ids from the existing position const binData = existingPosition.positionData.positionBinData; const minBinId = Math.min(...binData.map(bin => bin.binId)); const maxBinId = Math.max(...binData.map(bin => bin.binId)); positionPubKey = existingPosition.publicKey; // Add liquidity to the existing position await dlmmPool.refetchStates(); tx = await dlmmPool.addLiquidityByStrategy({ positionPubKey: positionPubKey, user: this.wallet.publicKey, totalXAmount, totalYAmount, strategy: { maxBinId, minBinId, strategyType: StrategyType.Spot, }, }); } else { // Create new position edwinLogger.debug(`Opening new position`); const minBinId = activeBin.binId - rangeInterval; const maxBinId = activeBin.binId + rangeInterval; // Create a new keypair for the position const newPositionKeypair = Keypair.generate(); positionPubKey = newPositionKeypair.publicKey; signers.push(newPositionKeypair); await dlmmPool.refetchStates(); // Add a short delay to avoid hitting rate limits await new Promise(resolve => setTimeout(resolve, 1000)); // Use try-catch to handle compute unit estimation failures gracefully // Add simple retry logic for rate limiting (429 errors) let retryCount = 0; const maxRetries = 3; let _lastError: unknown; while (retryCount <= maxRetries) { try { tx = await dlmmPool.initializePositionAndAddLiquidityByStrategy({ positionPubKey: positionPubKey, user: this.wallet.publicKey, totalXAmount, totalYAmount, strategy: { maxBinId, minBinId, strategyType: StrategyType.Spot, }, }); break; // Success, exit retry loop } catch (error: unknown) { _lastError = error; // Log the actual error for debugging edwinLogger.debug('Error in initializePositionAndAddLiquidityByStrategy:', error); // Check if it's a rate limiting error const errorMessage = error instanceof Error ? error.message : String(error); const isRateLimit = errorMessage.includes('429') || errorMessage.includes('Request failed with status code 429') || // eslint-disable-next-line @typescript-eslint/no-explicit-any (error as any)?.response?.status === 429; if (isRateLimit && retryCount < maxRetries) { retryCount++; edwinLogger.warn(`Rate limited, retrying in 3 seconds (attempt ${retryCount}/${maxRetries})`); await new Promise(resolve => setTimeout(resolve, 3000)); continue; } // If not a rate limiting error or out of retries, throw the error throw error; } } // Ensure tx is defined if (!tx) { throw new Error('Failed to create transaction after retries'); } } // Debug logging before sending transaction const signersToUse = signers || []; if (signersToUse.length > 0) { edwinLogger.debug(`Sending transaction with ${signersToUse.length} additional signers:`); signersToUse.forEach((signer, index) => { edwinLogger.debug(` Signer ${index}: ${signer.publicKey.toString()}`); }); } else { edwinLogger.debug('Sending transaction with no additional signers'); } // Send the transaction with additional signers // KeypairClient will handle both the main keypair and additional signers const signature = await this.wallet.sendTransaction(connection, tx, signersToUse); // Wait for transaction confirmation const { value: confirmation } = await connection.confirmTransaction(signature, 'confirmed'); if (confirmation.err) { throw new Error(`Transaction failed: Signature: ${signature}, Error: ${confirmation.err.toString()}`); } edwinLogger.info(`Transaction successful: ${signature}`); // Store the position address in our logs for reference edwinLogger.info(`Position public key: ${positionPubKey.toString()}`); // Just return the transaction signature return signature; } async addLiquidity(params: AddLiquidityParameters): Promise<string> { const { amount, amountB, poolAddress, rangeInterval } = params; edwinLogger.info( `Calling Meteora protocol to add liquidity to pool ${poolAddress} with ${amount} and ${amountB}` ); try { if (!amount) { throw new Error('Amount for Asset A is required for Meteora liquidity provision'); } else if (!amountB) { throw new Error('Amount for Asset B is required for Meteora liquidity provision'); } else if (!poolAddress) { throw new Error('Pool address is required for Meteora liquidity provision'); } // Retry logic at the higher level to catch all rate limiting errors let retryCount = 0; const maxRetries = 3; while (retryCount <= maxRetries) { try { const result = await this.innerAddLiquidity( poolAddress, amount.toString(), amountB.toString(), rangeInterval ?? undefined ); return result; } catch (error: unknown) { // Handle MeteoraStatisticalBugError if (error instanceof MeteoraStatisticalBugError) { edwinLogger.info( 'Encountered Meteora statistical bug, closing position before raising error...' ); try { await withRetry( async () => this.removeLiquidity({ poolAddress, positionAddress: error.positionAddress, shouldClosePosition: true, }), 'Meteora remove liquidity' ); } catch (closeError) { edwinLogger.error('Failed to close position:', closeError); } throw error; } // Check if it's a rate limiting error at any level const errorMessage = error instanceof Error ? error.message : String(error); const isRateLimit = errorMessage.includes('429') || errorMessage.includes('Request failed with status code 429') || // eslint-disable-next-line @typescript-eslint/no-explicit-any (error as any)?.response?.status === 429; if (isRateLimit && retryCount < maxRetries) { retryCount++; // Check for Retry-After header in the response let retryAfterSeconds = 5; // Default fallback // eslint-disable-next-line @typescript-eslint/no-explicit-any const errorWithResponse = error as any; if (errorWithResponse?.response?.headers) { const retryAfterHeader = errorWithResponse.response.headers['retry-after'] || errorWithResponse.response.headers['Retry-After']; if (retryAfterHeader) { const parsedRetryAfter = parseInt(retryAfterHeader, 10); if (!isNaN(parsedRetryAfter)) { retryAfterSeconds = Math.min(parsedRetryAfter, 30); // Cap at 30 seconds edwinLogger.info(`Server requested retry after ${retryAfterSeconds} seconds`); } } } edwinLogger.warn( `Rate limited, retrying in ${retryAfterSeconds} seconds (attempt ${retryCount}/${maxRetries})` ); await new Promise(resolve => setTimeout(resolve, retryAfterSeconds * 1000)); continue; } // If not a rate limit error or out of retries, re-throw throw error; } } // This should never be reached, but satisfies TypeScript throw new Error('Unexpected error: retry loop completed without result'); } catch (error: unknown) { edwinLogger.error('Meteora add liquidity error:', error); const message = error instanceof Error ? error.message : String(error); throw new Error(`Meteora add liquidity failed: ${message}`); } } /** * Get position information from a transaction hash * @param txHash The transaction hash/signature from the addLiquidity operation * @returns Position address and liquidity added */ async getPositionInfoFromTransaction( txHash: string ): Promise<{ positionAddress: string; liquidityAdded: [number, number] }> { try { const connection = this.wallet.getConnection(); // Fetch transaction information const txInfo = await connection.getParsedTransaction(txHash, { maxSupportedTransactionVersion: 0 }); if (!txInfo || !txInfo.meta) { throw new Error('Transaction information not found'); } // Extract the position account (will be a signer other than the wallet) const positionAccount = txInfo.transaction.message.accountKeys.find( (account: { signer: boolean; pubkey: PublicKey }) => account.signer && !account.pubkey.equals(this.wallet.publicKey) ); if (!positionAccount) { throw new Error('Position account not found in transaction'); } const positionPubKey = positionAccount.pubkey.toString(); // Get token amounts const verifiedTokenAmounts = await verifyAddLiquidityTokenAmounts(connection, txHash); if (verifiedTokenAmounts.length !== 2) { throw new Error('Expected 2 token amounts in tx verification, got ' + verifiedTokenAmounts.length); } return { positionAddress: positionPubKey, liquidityAdded: [verifiedTokenAmounts[0].uiAmount, verifiedTokenAmounts[1].uiAmount], }; } catch (error) { edwinLogger.error('Error getting position info from transaction:', error); throw new Error(`Failed to get position info: ${error}`); } } async claimFees(params: PoolParameters): Promise<string> { const { poolAddress } = params; try { // Use Helius connection to avoid rate limits const connection = this.wallet.getConnection(); const dlmmPool = await DLMM.create(connection, new PublicKey(poolAddress)); const { userPositions } = await dlmmPool.getPositionsByUserAndLbPair(this.wallet.publicKey); if (!userPositions || userPositions.length === 0) { throw new Error('No positions found in this pool'); } // Get the first position's data and log fees before claiming const position = userPositions[0]; const positionBefore: PositionData = position.positionData; // Create claim fee transaction const claimFeeTx = await dlmmPool.claimSwapFee({ owner: this.wallet.publicKey, position: position, }); if (!claimFeeTx) { throw new Error('Failed to create claim fee transaction'); } // Sign the transaction await this.wallet.signTransaction(claimFeeTx); // Send the transaction const signature = await connection.sendRawTransaction(claimFeeTx.serialize(), { skipPreflight: true, maxRetries: 3, }); await connection.confirmTransaction(signature, 'confirmed'); // Get updated position data after claiming const { userPositions: updatedPositions } = await dlmmPool.getPositionsByUserAndLbPair( this.wallet.publicKey ); const updatedPosition = updatedPositions[0].positionData; return `Successfully claimed fees from pool ${poolAddress} Transaction signature: ${signature} Fees claimed: - Token X: ${positionBefore.feeX.sub(updatedPosition.feeX).toString()} - Token Y: ${positionBefore.feeY.sub(updatedPosition.feeY).toString()}`; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Meteora claim fees failed: ${message}`); } } async removeLiquidity( params: RemoveLiquidityParameters ): Promise<{ liquidityRemoved: [number, number]; feesClaimed: [number, number] }> { const { poolAddress, positionAddress, shouldClosePosition } = params; try { if (!poolAddress) { throw new Error('Pool address is required for Meteora liquidity removal'); } const shouldClaimAndClose = shouldClosePosition ?? true; // Use Helius connection to avoid rate limits const connection = this.wallet.getConnection(); const dlmmPool = await withRetry( async () => DLMM.create(connection, new PublicKey(poolAddress)), 'Meteora create pool' ); let position: LbPosition; if (!positionAddress) { const positionInfo = await withRetry( async () => dlmmPool.getPositionsByUserAndLbPair(this.wallet.publicKey), 'Meteora get user positions' ); const userPositions = positionInfo?.userPositions; if (!userPositions || userPositions.length === 0) { throw new Error('No positions found in this pool'); } // Get just the first position. Can be expanded in the future position = userPositions[0]; } else { position = await withRetry( async () => dlmmPool.getPosition(new PublicKey(positionAddress)), 'Meteora get position' ); } const binData = position.positionData.positionBinData; const binIdsToRemove = binData.map(bin => bin.binId); // Remove 100% of liquidity from all bins const removeLiquidityTx = await dlmmPool.removeLiquidity({ position: position.publicKey, user: this.wallet.publicKey, fromBinId: Math.min(...binIdsToRemove), toBinId: Math.max(...binIdsToRemove), bps: new BN(100 * 100), // 100% shouldClaimAndClose: shouldClaimAndClose, }); // Handle multiple transactions if needed // Sum the total liquidity and fees claimed per token const tokenXAddress = dlmmPool.tokenX.publicKey.toString(); const tokenYAddress = dlmmPool.tokenY.publicKey.toString(); const liquidityRemoved: [number, number] = [0, 0]; const feesClaimed: [number, number] = [0, 0]; // Process transactions (could be an array) const txArray = Array.isArray(removeLiquidityTx) ? removeLiquidityTx : [removeLiquidityTx]; for (const tx of txArray) { // Sign the transaction await this.wallet.signTransaction(tx); // Send the transaction const signature = await connection.sendRawTransaction(tx.serialize(), { skipPreflight: true, maxRetries: 3, }); await connection.confirmTransaction(signature, 'confirmed'); edwinLogger.info(`Transaction successful: ${signature}`); const balanceChanges = await extractBalanceChanges(connection, signature, tokenXAddress, tokenYAddress); liquidityRemoved[0] += balanceChanges.liquidityRemoved[0]; liquidityRemoved[1] += balanceChanges.liquidityRemoved[1]; feesClaimed[0] += balanceChanges.feesClaimed[0]; feesClaimed[1] += balanceChanges.feesClaimed[1]; } return { liquidityRemoved, feesClaimed }; } catch (error: unknown) { edwinLogger.error('Meteora remove liquidity error:', error); const message = error instanceof Error ? error.message : String(error); throw new Error(`Meteora remove liquidity failed: ${message}`); } } }