edwin-sdk
Version:
SDK for integrating AI agents with DeFi protocols
600 lines (523 loc) • 25.9 kB
text/typescript
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}`);
}
}
}