dlmm-client.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  1. import DLMM from '@meteora-ag/dlmm'
  2. import {
  3. Connection,
  4. Keypair,
  5. PublicKey,
  6. sendAndConfirmTransaction,
  7. } from '@solana/web3.js'
  8. import BN from 'bn.js'
  9. import {
  10. DEFAULT_COPY_RATIO,
  11. DEFAULT_MAX_POSITION_SIZE_SOL,
  12. DEFAULT_SLIPPAGE_BPS,
  13. } from '../constants'
  14. import type { ParsedDlmmOperation, WorkerSettings } from './types'
  15. import { getEnvNumber } from '../utils'
  16. export function getSettings(): WorkerSettings {
  17. return {
  18. autoCopyEnabled: process.env.AUTO_COPY_ENABLED !== 'false',
  19. copyRatio: getEnvNumber('COPY_RATIO', DEFAULT_COPY_RATIO),
  20. maxPositionSizeSol: getEnvNumber(
  21. 'MAX_POSITION_SIZE_SOL',
  22. DEFAULT_MAX_POSITION_SIZE_SOL
  23. ),
  24. slippageBps: getEnvNumber('SLIPPAGE_BPS', DEFAULT_SLIPPAGE_BPS),
  25. }
  26. }
  27. function scaleAmount(amount: BN, ratio: number): BN {
  28. const scaledBps = Math.round(ratio * 10000)
  29. return amount.mul(new BN(scaledBps)).div(new BN(10000))
  30. }
  31. /**
  32. * Map on-chain strategy type (0-8) to SDK StrategyType (0-2).
  33. *
  34. * On-chain enum order (from DLMM IDL):
  35. * 0=SpotOneSide, 1=CurveOneSide, 2=BidAskOneSide,
  36. * 3=SpotBalanced, 4=CurveBalanced, 5=BidAskBalanced,
  37. * 6=SpotImBalanced, 7=CurveImBalanced, 8=BidAskImBalanced
  38. *
  39. * SDK StrategyType: Spot=0, Curve=1, BidAsk=2
  40. */
  41. function toSdkStrategyType(onChainType: number | undefined): number {
  42. if (onChainType === undefined || onChainType < 0) return 0 // Default: Spot
  43. return onChainType % 3
  44. }
  45. export async function copyOpenPosition(
  46. connection: Connection,
  47. follower: Keypair,
  48. leaderOp: ParsedDlmmOperation,
  49. settings: WorkerSettings
  50. ): Promise<{ positionAddress: PublicKey; txSignature: string }> {
  51. const dlmmPool = await DLMM.create(connection, leaderOp.lbPairAddress)
  52. const followerAmountX = scaleAmount(
  53. leaderOp.totalXAmount ?? new BN(0),
  54. settings.copyRatio
  55. )
  56. const followerAmountY = scaleAmount(
  57. leaderOp.totalYAmount ?? new BN(0),
  58. settings.copyRatio
  59. )
  60. const activeBin = await dlmmPool.getActiveBin()
  61. const minBinId = leaderOp.minBinId ?? activeBin.binId
  62. const maxBinId = leaderOp.maxBinId ?? activeBin.binId
  63. const sdkStrategyType = toSdkStrategyType(leaderOp.strategyType)
  64. console.log(
  65. `[DLMM] Opening position: bins [${minBinId}, ${maxBinId}], activeBin ${activeBin.binId}, strategy ${sdkStrategyType}, amountX ${followerAmountX.toString()}, amountY ${followerAmountY.toString()}`
  66. )
  67. // Generate position keypair — must be included as signer
  68. const positionKeypair = Keypair.generate()
  69. const createPositionTx =
  70. await dlmmPool.initializePositionAndAddLiquidityByStrategy({
  71. positionPubKey: positionKeypair.publicKey,
  72. user: follower.publicKey,
  73. totalXAmount: followerAmountX,
  74. totalYAmount: followerAmountY,
  75. strategy: {
  76. minBinId,
  77. maxBinId,
  78. strategyType: sdkStrategyType,
  79. },
  80. slippage: settings.slippageBps / 100, // Convert BPS to percentage
  81. })
  82. const txSignature = await sendAndConfirmTransaction(
  83. connection,
  84. createPositionTx,
  85. [follower, positionKeypair],
  86. { commitment: 'confirmed' }
  87. )
  88. return { positionAddress: positionKeypair.publicKey, txSignature }
  89. }
  90. export async function copyRemoveLiquidity(
  91. connection: Connection,
  92. follower: Keypair,
  93. followerPositionAddress: PublicKey,
  94. lbPairAddress: PublicKey,
  95. bpsToRemove: number = 10000, // 100% default
  96. settings: WorkerSettings
  97. ): Promise<string> {
  98. const dlmmPool = await DLMM.create(connection, lbPairAddress)
  99. const { userPositions } = await dlmmPool.getPositionsByUserAndLbPair(
  100. follower.publicKey
  101. )
  102. const position = userPositions.find((p) =>
  103. p.publicKey.equals(followerPositionAddress)
  104. )
  105. if (!position) {
  106. throw new Error(
  107. `Follower position ${followerPositionAddress.toBase58()} not found`
  108. )
  109. }
  110. const binIds = position.positionData.positionBinData.map((b) => b.binId)
  111. if (binIds.length === 0) {
  112. throw new Error('Position has no bins')
  113. }
  114. const removeLiqTxs = await dlmmPool.removeLiquidity({
  115. user: follower.publicKey,
  116. position: followerPositionAddress,
  117. fromBinId: Math.min(...binIds),
  118. toBinId: Math.max(...binIds),
  119. bps: new BN(bpsToRemove),
  120. shouldClaimAndClose: bpsToRemove >= 10000,
  121. })
  122. let lastSignature = ''
  123. for (const tx of removeLiqTxs) {
  124. lastSignature = await sendAndConfirmTransaction(
  125. connection,
  126. tx,
  127. [follower],
  128. { commitment: 'confirmed' }
  129. )
  130. }
  131. return lastSignature
  132. }
  133. export async function copyRebalanceLiquidity(
  134. connection: Connection,
  135. follower: Keypair,
  136. followerPositionAddress: PublicKey,
  137. lbPairAddress: PublicKey,
  138. leaderOp: ParsedDlmmOperation,
  139. settings: WorkerSettings
  140. ): Promise<string> {
  141. const dlmmPool = await DLMM.create(connection, lbPairAddress)
  142. const { userPositions } = await dlmmPool.getPositionsByUserAndLbPair(
  143. follower.publicKey
  144. )
  145. const position = userPositions.find((p) =>
  146. p.publicKey.equals(followerPositionAddress)
  147. )
  148. if (!position) {
  149. throw new Error(
  150. `Follower position ${followerPositionAddress.toBase58()} not found for rebalance`
  151. )
  152. }
  153. // Step 1: Remove all liquidity from the current position
  154. const binIds = position.positionData.positionBinData.map((b) => b.binId)
  155. if (binIds.length > 0) {
  156. const removeLiqTxs = await dlmmPool.removeLiquidity({
  157. user: follower.publicKey,
  158. position: followerPositionAddress,
  159. fromBinId: Math.min(...binIds),
  160. toBinId: Math.max(...binIds),
  161. bps: new BN(10000), // Remove 100%
  162. shouldClaimAndClose: false, // Keep position open for re-adding
  163. })
  164. for (const tx of removeLiqTxs) {
  165. await sendAndConfirmTransaction(connection, tx, [follower], {
  166. commitment: 'confirmed',
  167. })
  168. }
  169. }
  170. // Step 2: Add liquidity to the new bin range using delta-based strategy
  171. // The leader's rebalance uses delta IDs relative to active bin
  172. // We use the same deltas relative to the current active bin
  173. const activeBin = await dlmmPool.getActiveBin()
  174. const activeId = activeBin.binId
  175. let minBinId = leaderOp.minBinId ?? 0
  176. let maxBinId = leaderOp.maxBinId ?? 0
  177. // If we have delta-based adds, compute absolute bin IDs from the current active bin
  178. // (not the leader's active bin, since it may have moved)
  179. if (leaderOp.rebalanceAdds && leaderOp.rebalanceAdds.length > 0) {
  180. const allMinBins = leaderOp.rebalanceAdds.map(
  181. (a) => activeId + a.minDeltaId
  182. )
  183. const allMaxBins = leaderOp.rebalanceAdds.map(
  184. (a) => activeId + a.maxDeltaId
  185. )
  186. minBinId = Math.min(...allMinBins)
  187. maxBinId = Math.max(...allMaxBins)
  188. }
  189. // Validate bin range — if both are 0, the parameter extraction failed
  190. if (minBinId === 0 && maxBinId === 0 && activeId !== 0) {
  191. throw new Error(
  192. `Rebalance failed: could not determine target bin range (activeBin=${activeId}). ` +
  193. `Leader rebalanceAdds: ${JSON.stringify(leaderOp.rebalanceAdds)}`
  194. )
  195. }
  196. console.log(
  197. `[DLMM] Rebalancing: new bins [${minBinId}, ${maxBinId}], activeBin ${activeId}`
  198. )
  199. // For rebalance, use the follower's actual available token balances
  200. // (not the leader's amounts — those are just safety caps from the IDL).
  201. // After step 1 removed all liquidity, the tokens are in the follower's accounts.
  202. const WSOL_MINT = new PublicKey(
  203. 'So11111111111111111111111111111111111111112'
  204. )
  205. const tokenXMint = dlmmPool.tokenX.publicKey
  206. const tokenYMint = dlmmPool.tokenY.publicKey
  207. const SOL_RESERVE = 10_000_000 // Keep 0.01 SOL for tx fees
  208. let followerAmountX = new BN(0)
  209. let followerAmountY = new BN(0)
  210. // Token X balance
  211. if (tokenXMint.equals(WSOL_MINT)) {
  212. const balance = await connection.getBalance(follower.publicKey)
  213. followerAmountX = new BN(Math.max(0, balance - SOL_RESERVE))
  214. } else {
  215. const xAccounts = await connection.getParsedTokenAccountsByOwner(
  216. follower.publicKey,
  217. { mint: tokenXMint }
  218. )
  219. for (const { account } of xAccounts.value) {
  220. followerAmountX = followerAmountX.add(
  221. new BN(
  222. (
  223. account.data.parsed as {
  224. info: { tokenAmount: { amount: string } }
  225. }
  226. ).info.tokenAmount.amount
  227. )
  228. )
  229. }
  230. }
  231. // Token Y balance
  232. if (tokenYMint.equals(WSOL_MINT)) {
  233. const balance = await connection.getBalance(follower.publicKey)
  234. followerAmountY = new BN(Math.max(0, balance - SOL_RESERVE))
  235. } else {
  236. const yAccounts = await connection.getParsedTokenAccountsByOwner(
  237. follower.publicKey,
  238. { mint: tokenYMint }
  239. )
  240. for (const { account } of yAccounts.value) {
  241. followerAmountY = followerAmountY.add(
  242. new BN(
  243. (
  244. account.data.parsed as {
  245. info: { tokenAmount: { amount: string } }
  246. }
  247. ).info.tokenAmount.amount
  248. )
  249. )
  250. }
  251. }
  252. if (followerAmountX.isZero() && followerAmountY.isZero()) {
  253. throw new Error(
  254. 'Rebalance failed: follower has no token balance to re-deposit after removing liquidity.'
  255. )
  256. }
  257. const sdkStrategyType = toSdkStrategyType(leaderOp.strategyType)
  258. console.log(
  259. `[DLMM] Rebalance addLiquidity: strategy ${sdkStrategyType}, amountX ${followerAmountX.toString()}, amountY ${followerAmountY.toString()}`
  260. )
  261. const addLiqTx = await dlmmPool.addLiquidityByStrategy({
  262. positionPubKey: followerPositionAddress,
  263. user: follower.publicKey,
  264. totalXAmount: followerAmountX,
  265. totalYAmount: followerAmountY,
  266. strategy: {
  267. minBinId,
  268. maxBinId,
  269. strategyType: sdkStrategyType,
  270. },
  271. slippage: settings.slippageBps / 100, // Convert BPS to percentage
  272. })
  273. const txSignature = await sendAndConfirmTransaction(
  274. connection,
  275. addLiqTx,
  276. [follower],
  277. { commitment: 'confirmed' }
  278. )
  279. return txSignature
  280. }
  281. export async function copyClosePosition(
  282. connection: Connection,
  283. follower: Keypair,
  284. followerPositionAddress: PublicKey,
  285. lbPairAddress: PublicKey
  286. ): Promise<string> {
  287. // First check if the position account still exists on-chain.
  288. // It may have been already closed by removeLiquidity(shouldClaimAndClose=true).
  289. const positionAccountInfo = await connection.getAccountInfo(
  290. followerPositionAddress
  291. )
  292. if (!positionAccountInfo) {
  293. console.log(
  294. `[DLMM] Position ${followerPositionAddress.toBase58()} already closed on-chain, skipping close`
  295. )
  296. return 'already-closed'
  297. }
  298. const dlmmPool = await DLMM.create(connection, lbPairAddress)
  299. // Need full LbPosition object for closePosition
  300. const { userPositions } = await dlmmPool.getPositionsByUserAndLbPair(
  301. follower.publicKey
  302. )
  303. const position = userPositions.find((p) =>
  304. p.publicKey.equals(followerPositionAddress)
  305. )
  306. if (!position) {
  307. // Position account exists but not found by SDK (edge case).
  308. // This shouldn't happen if the account exists on-chain, but
  309. // handle it gracefully.
  310. console.warn(
  311. `[DLMM] Position ${followerPositionAddress.toBase58()} exists on-chain but not returned by SDK`
  312. )
  313. throw new Error(
  314. `Position ${followerPositionAddress.toBase58()} not found for close`
  315. )
  316. }
  317. const closePositionTx = await dlmmPool.closePosition({
  318. owner: follower.publicKey,
  319. position,
  320. })
  321. const txSignature = await sendAndConfirmTransaction(
  322. connection,
  323. closePositionTx,
  324. [follower],
  325. { commitment: 'confirmed' }
  326. )
  327. return txSignature
  328. }