import DLMM from '@meteora-ag/dlmm' import { Connection, Keypair, PublicKey, sendAndConfirmTransaction, } from '@solana/web3.js' import BN from 'bn.js' import { DEFAULT_COPY_RATIO, DEFAULT_MAX_POSITION_SIZE_SOL, DEFAULT_SLIPPAGE_BPS, } from '../constants' import type { ParsedDlmmOperation, WorkerSettings } from './types' import { getEnvNumber } from '../utils' export function getSettings(): WorkerSettings { return { autoCopyEnabled: process.env.AUTO_COPY_ENABLED !== 'false', copyRatio: getEnvNumber('COPY_RATIO', DEFAULT_COPY_RATIO), maxPositionSizeSol: getEnvNumber( 'MAX_POSITION_SIZE_SOL', DEFAULT_MAX_POSITION_SIZE_SOL ), slippageBps: getEnvNumber('SLIPPAGE_BPS', DEFAULT_SLIPPAGE_BPS), } } function scaleAmount(amount: BN, ratio: number): BN { const scaledBps = Math.round(ratio * 10000) return amount.mul(new BN(scaledBps)).div(new BN(10000)) } /** * Map on-chain strategy type (0-8) to SDK StrategyType (0-2). * * On-chain enum order (from DLMM IDL): * 0=SpotOneSide, 1=CurveOneSide, 2=BidAskOneSide, * 3=SpotBalanced, 4=CurveBalanced, 5=BidAskBalanced, * 6=SpotImBalanced, 7=CurveImBalanced, 8=BidAskImBalanced * * SDK StrategyType: Spot=0, Curve=1, BidAsk=2 */ function toSdkStrategyType(onChainType: number | undefined): number { if (onChainType === undefined || onChainType < 0) return 0 // Default: Spot return onChainType % 3 } export async function copyOpenPosition( connection: Connection, follower: Keypair, leaderOp: ParsedDlmmOperation, settings: WorkerSettings ): Promise<{ positionAddress: PublicKey; txSignature: string }> { const dlmmPool = await DLMM.create(connection, leaderOp.lbPairAddress) const followerAmountX = scaleAmount( leaderOp.totalXAmount ?? new BN(0), settings.copyRatio ) const followerAmountY = scaleAmount( leaderOp.totalYAmount ?? new BN(0), settings.copyRatio ) const activeBin = await dlmmPool.getActiveBin() const minBinId = leaderOp.minBinId ?? activeBin.binId const maxBinId = leaderOp.maxBinId ?? activeBin.binId const sdkStrategyType = toSdkStrategyType(leaderOp.strategyType) console.log( `[DLMM] Opening position: bins [${minBinId}, ${maxBinId}], activeBin ${activeBin.binId}, strategy ${sdkStrategyType}, amountX ${followerAmountX.toString()}, amountY ${followerAmountY.toString()}` ) // Generate position keypair — must be included as signer const positionKeypair = Keypair.generate() const createPositionTx = await dlmmPool.initializePositionAndAddLiquidityByStrategy({ positionPubKey: positionKeypair.publicKey, user: follower.publicKey, totalXAmount: followerAmountX, totalYAmount: followerAmountY, strategy: { minBinId, maxBinId, strategyType: sdkStrategyType, }, slippage: settings.slippageBps / 100, // Convert BPS to percentage }) const txSignature = await sendAndConfirmTransaction( connection, createPositionTx, [follower, positionKeypair], { commitment: 'confirmed' } ) return { positionAddress: positionKeypair.publicKey, txSignature } } export async function copyRemoveLiquidity( connection: Connection, follower: Keypair, followerPositionAddress: PublicKey, lbPairAddress: PublicKey, bpsToRemove: number = 10000, // 100% default settings: WorkerSettings ): Promise { const dlmmPool = await DLMM.create(connection, lbPairAddress) const { userPositions } = await dlmmPool.getPositionsByUserAndLbPair( follower.publicKey ) const position = userPositions.find((p) => p.publicKey.equals(followerPositionAddress) ) if (!position) { throw new Error( `Follower position ${followerPositionAddress.toBase58()} not found` ) } const binIds = position.positionData.positionBinData.map((b) => b.binId) if (binIds.length === 0) { throw new Error('Position has no bins') } const removeLiqTxs = await dlmmPool.removeLiquidity({ user: follower.publicKey, position: followerPositionAddress, fromBinId: Math.min(...binIds), toBinId: Math.max(...binIds), bps: new BN(bpsToRemove), shouldClaimAndClose: bpsToRemove >= 10000, }) let lastSignature = '' for (const tx of removeLiqTxs) { lastSignature = await sendAndConfirmTransaction( connection, tx, [follower], { commitment: 'confirmed' } ) } return lastSignature } export async function copyRebalanceLiquidity( connection: Connection, follower: Keypair, followerPositionAddress: PublicKey, lbPairAddress: PublicKey, leaderOp: ParsedDlmmOperation, settings: WorkerSettings ): Promise { const dlmmPool = await DLMM.create(connection, lbPairAddress) const { userPositions } = await dlmmPool.getPositionsByUserAndLbPair( follower.publicKey ) const position = userPositions.find((p) => p.publicKey.equals(followerPositionAddress) ) if (!position) { throw new Error( `Follower position ${followerPositionAddress.toBase58()} not found for rebalance` ) } // Step 1: Remove all liquidity from the current position const binIds = position.positionData.positionBinData.map((b) => b.binId) if (binIds.length > 0) { const removeLiqTxs = await dlmmPool.removeLiquidity({ user: follower.publicKey, position: followerPositionAddress, fromBinId: Math.min(...binIds), toBinId: Math.max(...binIds), bps: new BN(10000), // Remove 100% shouldClaimAndClose: false, // Keep position open for re-adding }) for (const tx of removeLiqTxs) { await sendAndConfirmTransaction(connection, tx, [follower], { commitment: 'confirmed', }) } } // Step 2: Add liquidity to the new bin range using delta-based strategy // The leader's rebalance uses delta IDs relative to active bin // We use the same deltas relative to the current active bin const activeBin = await dlmmPool.getActiveBin() const activeId = activeBin.binId let minBinId = leaderOp.minBinId ?? 0 let maxBinId = leaderOp.maxBinId ?? 0 // If we have delta-based adds, compute absolute bin IDs from the current active bin // (not the leader's active bin, since it may have moved) if (leaderOp.rebalanceAdds && leaderOp.rebalanceAdds.length > 0) { const allMinBins = leaderOp.rebalanceAdds.map( (a) => activeId + a.minDeltaId ) const allMaxBins = leaderOp.rebalanceAdds.map( (a) => activeId + a.maxDeltaId ) minBinId = Math.min(...allMinBins) maxBinId = Math.max(...allMaxBins) } // Validate bin range — if both are 0, the parameter extraction failed if (minBinId === 0 && maxBinId === 0 && activeId !== 0) { throw new Error( `Rebalance failed: could not determine target bin range (activeBin=${activeId}). ` + `Leader rebalanceAdds: ${JSON.stringify(leaderOp.rebalanceAdds)}` ) } console.log( `[DLMM] Rebalancing: new bins [${minBinId}, ${maxBinId}], activeBin ${activeId}` ) // For rebalance, use the follower's actual available token balances // (not the leader's amounts — those are just safety caps from the IDL). // After step 1 removed all liquidity, the tokens are in the follower's accounts. const WSOL_MINT = new PublicKey( 'So11111111111111111111111111111111111111112' ) const tokenXMint = dlmmPool.tokenX.publicKey const tokenYMint = dlmmPool.tokenY.publicKey const SOL_RESERVE = 10_000_000 // Keep 0.01 SOL for tx fees let followerAmountX = new BN(0) let followerAmountY = new BN(0) // Token X balance if (tokenXMint.equals(WSOL_MINT)) { const balance = await connection.getBalance(follower.publicKey) followerAmountX = new BN(Math.max(0, balance - SOL_RESERVE)) } else { const xAccounts = await connection.getParsedTokenAccountsByOwner( follower.publicKey, { mint: tokenXMint } ) for (const { account } of xAccounts.value) { followerAmountX = followerAmountX.add( new BN( ( account.data.parsed as { info: { tokenAmount: { amount: string } } } ).info.tokenAmount.amount ) ) } } // Token Y balance if (tokenYMint.equals(WSOL_MINT)) { const balance = await connection.getBalance(follower.publicKey) followerAmountY = new BN(Math.max(0, balance - SOL_RESERVE)) } else { const yAccounts = await connection.getParsedTokenAccountsByOwner( follower.publicKey, { mint: tokenYMint } ) for (const { account } of yAccounts.value) { followerAmountY = followerAmountY.add( new BN( ( account.data.parsed as { info: { tokenAmount: { amount: string } } } ).info.tokenAmount.amount ) ) } } if (followerAmountX.isZero() && followerAmountY.isZero()) { throw new Error( 'Rebalance failed: follower has no token balance to re-deposit after removing liquidity.' ) } const sdkStrategyType = toSdkStrategyType(leaderOp.strategyType) console.log( `[DLMM] Rebalance addLiquidity: strategy ${sdkStrategyType}, amountX ${followerAmountX.toString()}, amountY ${followerAmountY.toString()}` ) const addLiqTx = await dlmmPool.addLiquidityByStrategy({ positionPubKey: followerPositionAddress, user: follower.publicKey, totalXAmount: followerAmountX, totalYAmount: followerAmountY, strategy: { minBinId, maxBinId, strategyType: sdkStrategyType, }, slippage: settings.slippageBps / 100, // Convert BPS to percentage }) const txSignature = await sendAndConfirmTransaction( connection, addLiqTx, [follower], { commitment: 'confirmed' } ) return txSignature } export async function copyClosePosition( connection: Connection, follower: Keypair, followerPositionAddress: PublicKey, lbPairAddress: PublicKey ): Promise { // First check if the position account still exists on-chain. // It may have been already closed by removeLiquidity(shouldClaimAndClose=true). const positionAccountInfo = await connection.getAccountInfo( followerPositionAddress ) if (!positionAccountInfo) { console.log( `[DLMM] Position ${followerPositionAddress.toBase58()} already closed on-chain, skipping close` ) return 'already-closed' } const dlmmPool = await DLMM.create(connection, lbPairAddress) // Need full LbPosition object for closePosition const { userPositions } = await dlmmPool.getPositionsByUserAndLbPair( follower.publicKey ) const position = userPositions.find((p) => p.publicKey.equals(followerPositionAddress) ) if (!position) { // Position account exists but not found by SDK (edge case). // This shouldn't happen if the account exists on-chain, but // handle it gracefully. console.warn( `[DLMM] Position ${followerPositionAddress.toBase58()} exists on-chain but not returned by SDK` ) throw new Error( `Position ${followerPositionAddress.toBase58()} not found for close` ) } const closePositionTx = await dlmmPool.closePosition({ owner: follower.publicKey, position, }) const txSignature = await sendAndConfirmTransaction( connection, closePositionTx, [follower], { commitment: 'confirmed' } ) return txSignature }