| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378 |
- 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<string> {
- 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<string> {
- 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<string> {
- // 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
- }
|