transaction-parser.ts 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. import { BorshInstructionCoder } from '@coral-xyz/anchor'
  2. import { IDL } from '@meteora-ag/dlmm'
  3. import {
  4. MessageCompiledInstruction,
  5. PublicKey,
  6. TransactionResponse,
  7. VersionedTransactionResponse,
  8. } from '@solana/web3.js'
  9. import bs58 from 'bs58'
  10. import BN from 'bn.js'
  11. import {
  12. ADD_LIQUIDITY_NAMES,
  13. CLOSE_POSITION_NAMES,
  14. DLMM_PROGRAM_ID,
  15. INIT_POSITION_NAMES,
  16. REBALANCE_NAMES,
  17. REMOVE_LIQUIDITY_NAMES,
  18. } from '../constants'
  19. import type { DlmmActionType, ParsedDlmmOperation } from '../meteora/types'
  20. // Instruction name mapping: Anchor IDL uses snake_case, we use camelCase internally
  21. const INSTRUCTION_NAME_MAP: Record<string, string> = {
  22. add_liquidity: 'addLiquidity',
  23. add_liquidity_by_strategy: 'addLiquidityByStrategy',
  24. add_liquidity_by_strategy2: 'addLiquidityByStrategy2',
  25. add_liquidity_by_strategy_one_side: 'addLiquidityByStrategyOneSide',
  26. remove_liquidity: 'removeLiquidity',
  27. remove_liquidity_by_range: 'removeLiquidityByRange',
  28. remove_liquidity_by_range2: 'removeLiquidityByRange2',
  29. close_position: 'closePosition',
  30. close_position2: 'closePosition2',
  31. close_position_if_empty: 'closePositionIfEmpty',
  32. initialize_position: 'initializePosition',
  33. initialize_position2: 'initializePosition2',
  34. rebalance_liquidity: 'rebalanceLiquidity',
  35. }
  36. // Account index mapping for each instruction (snake_case IDL names)
  37. // Verified against IDL account layouts
  38. const ACCOUNT_MAPS: Record<
  39. string,
  40. { position: number; lbPair: number; sender: number }
  41. > = {
  42. // Add liquidity
  43. add_liquidity: { position: 0, lbPair: 1, sender: 11 },
  44. add_liquidity_by_strategy: { position: 0, lbPair: 1, sender: 11 },
  45. add_liquidity_by_strategy2: { position: 0, lbPair: 1, sender: 9 },
  46. add_liquidity_by_strategy_one_side: { position: 0, lbPair: 1, sender: 8 },
  47. // Remove liquidity
  48. remove_liquidity: { position: 0, lbPair: 1, sender: 11 },
  49. remove_liquidity_by_range: { position: 0, lbPair: 1, sender: 11 },
  50. remove_liquidity_by_range2: { position: 0, lbPair: 1, sender: 9 },
  51. // Close position
  52. close_position: { position: 0, lbPair: 1, sender: 4 },
  53. close_position2: { position: 0, lbPair: -1, sender: 1 },
  54. // close_position_if_empty: accounts = [position, sender, rent_receiver, event_authority, program]
  55. close_position_if_empty: { position: 0, lbPair: -1, sender: 1 },
  56. // Initialize position
  57. initialize_position: { position: 1, lbPair: 2, sender: 0 },
  58. initialize_position2: { position: 1, lbPair: 2, sender: 0 },
  59. // Rebalance liquidity
  60. // IDL: [position, lb_pair, bin_array_bitmap_extension, user_token_x, user_token_y,
  61. // reserve_x, reserve_y, token_x_mint, token_y_mint, owner, ...]
  62. rebalance_liquidity: { position: 0, lbPair: 1, sender: 9 },
  63. }
  64. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  65. const coder = new BorshInstructionCoder(IDL as any)
  66. function classifyAction(name: string): DlmmActionType | null {
  67. const camelName = INSTRUCTION_NAME_MAP[name] ?? name
  68. if (ADD_LIQUIDITY_NAMES.has(camelName)) return 'ADD_LIQUIDITY'
  69. if (REMOVE_LIQUIDITY_NAMES.has(camelName)) return 'REMOVE_LIQUIDITY'
  70. if (CLOSE_POSITION_NAMES.has(camelName)) return 'CLOSE_POSITION'
  71. if (INIT_POSITION_NAMES.has(camelName)) return 'INIT_POSITION'
  72. if (REBALANCE_NAMES.has(camelName)) return 'REBALANCE_LIQUIDITY'
  73. return null
  74. }
  75. function resolveAllAccountKeys(
  76. tx: VersionedTransactionResponse | TransactionResponse
  77. ): PublicKey[] {
  78. const msg = tx.transaction.message
  79. // Versioned (v0) transactions
  80. if ('getAccountKeys' in msg) {
  81. // Must pass loaded addresses from lookup tables to avoid
  82. // "address table lookups were not resolved" error on v0 txs
  83. const toKey = (k: string | PublicKey) =>
  84. typeof k === 'string' ? new PublicKey(k) : k
  85. const accountKeysFromLookups = tx.meta?.loadedAddresses
  86. ? {
  87. writable: tx.meta.loadedAddresses.writable.map(toKey),
  88. readonly: tx.meta.loadedAddresses.readonly.map(toKey),
  89. }
  90. : undefined
  91. const keys = (
  92. msg as {
  93. getAccountKeys: (opts: object) => {
  94. length: number
  95. get: (i: number) => PublicKey | undefined
  96. }
  97. }
  98. ).getAccountKeys({ accountKeysFromLookups })
  99. const allKeys: PublicKey[] = []
  100. for (let i = 0; i < keys.length; i++) {
  101. const key = keys.get(i)
  102. if (key) allKeys.push(key)
  103. }
  104. return allKeys
  105. }
  106. // Legacy transactions
  107. if ('accountKeys' in msg) {
  108. return (msg as { accountKeys: PublicKey[] }).accountKeys
  109. }
  110. return []
  111. }
  112. interface ParsedInstruction {
  113. programIdIndex: number
  114. accounts: number[]
  115. dataBuffer: Buffer
  116. }
  117. function extractInstructions(
  118. tx: VersionedTransactionResponse | TransactionResponse
  119. ): ParsedInstruction[] {
  120. const msg = tx.transaction.message
  121. if ('compiledInstructions' in msg) {
  122. // Versioned transaction: data is Uint8Array
  123. return (
  124. msg as { compiledInstructions: MessageCompiledInstruction[] }
  125. ).compiledInstructions.map((ix) => ({
  126. programIdIndex: ix.programIdIndex,
  127. accounts: [...ix.accountKeyIndexes],
  128. dataBuffer: Buffer.from(ix.data),
  129. }))
  130. }
  131. if ('instructions' in msg) {
  132. // Legacy transaction: data is base58 encoded string
  133. return (
  134. msg as {
  135. instructions: {
  136. programIdIndex: number
  137. accounts: number[]
  138. data: string
  139. }[]
  140. }
  141. ).instructions.map((ix) => ({
  142. programIdIndex: ix.programIdIndex,
  143. accounts: ix.accounts,
  144. dataBuffer: Buffer.from(bs58.decode(ix.data)),
  145. }))
  146. }
  147. return []
  148. }
  149. export function parseTransaction(
  150. tx: VersionedTransactionResponse | TransactionResponse
  151. ): ParsedDlmmOperation[] {
  152. if (!tx.meta || tx.meta.err) return []
  153. const accountKeys = resolveAllAccountKeys(tx)
  154. const instructions = extractInstructions(tx)
  155. const operations: ParsedDlmmOperation[] = []
  156. for (const ix of instructions) {
  157. const programId = accountKeys[ix.programIdIndex]
  158. if (!programId || !programId.equals(DLMM_PROGRAM_ID)) continue
  159. try {
  160. const decoded = coder.decode(ix.dataBuffer)
  161. if (!decoded) continue
  162. const action = classifyAction(decoded.name)
  163. if (!action) continue
  164. const accountMap = ACCOUNT_MAPS[decoded.name]
  165. if (!accountMap) continue
  166. const positionAddress = accountKeys[ix.accounts[accountMap.position]]
  167. const lbPairAddress =
  168. accountMap.lbPair >= 0
  169. ? accountKeys[ix.accounts[accountMap.lbPair]]
  170. : undefined
  171. const senderAddress = accountKeys[ix.accounts[accountMap.sender]]
  172. if (!positionAddress || !senderAddress) continue
  173. const op: ParsedDlmmOperation = {
  174. action,
  175. instructionName: INSTRUCTION_NAME_MAP[decoded.name] ?? decoded.name,
  176. positionAddress,
  177. lbPairAddress: lbPairAddress ?? positionAddress,
  178. senderAddress,
  179. }
  180. // Extract add liquidity parameters
  181. if (action === 'ADD_LIQUIDITY') {
  182. const data = decoded.data as Record<string, unknown>
  183. const liqParam = data.liquidity_parameter as
  184. | Record<string, unknown>
  185. | undefined
  186. if (liqParam) {
  187. op.totalXAmount = new BN(liqParam.amount_x?.toString() ?? '0')
  188. op.totalYAmount = new BN(liqParam.amount_y?.toString() ?? '0')
  189. const strategyParams = liqParam.strategy_parameters as
  190. | Record<string, unknown>
  191. | undefined
  192. if (strategyParams) {
  193. op.minBinId = strategyParams.min_bin_id as number
  194. op.maxBinId = strategyParams.max_bin_id as number
  195. const strategyType = strategyParams.strategy_type as
  196. | Record<string, unknown>
  197. | undefined
  198. if (strategyType) {
  199. const strategyName = Object.keys(strategyType)[0]
  200. const strategyTypes = [
  201. 'SpotOneSide',
  202. 'CurveOneSide',
  203. 'BidAskOneSide',
  204. 'SpotBalanced',
  205. 'CurveBalanced',
  206. 'BidAskBalanced',
  207. 'SpotImBalanced',
  208. 'CurveImBalanced',
  209. 'BidAskImBalanced',
  210. ]
  211. op.strategyType = strategyTypes.indexOf(strategyName)
  212. }
  213. }
  214. }
  215. }
  216. // Extract remove liquidity parameters
  217. if (action === 'REMOVE_LIQUIDITY') {
  218. const data = decoded.data as Record<string, unknown>
  219. if (data.from_bin_id !== undefined) {
  220. op.fromBinId = data.from_bin_id as number
  221. op.toBinId = data.to_bin_id as number
  222. op.bpsToRemove = data.bps_to_remove as number
  223. }
  224. }
  225. // Extract rebalance liquidity parameters
  226. if (action === 'REBALANCE_LIQUIDITY') {
  227. const data = decoded.data as Record<string, unknown>
  228. const rebalanceParams = data.rebalance_liquidity_params as
  229. | Record<string, unknown>
  230. | undefined
  231. if (rebalanceParams) {
  232. op.rebalanceActiveId = rebalanceParams.active_id as number
  233. const adds = rebalanceParams.adds as
  234. | Record<string, unknown>[]
  235. | undefined
  236. if (adds && adds.length > 0) {
  237. op.rebalanceAdds = adds.map((add) => ({
  238. minDeltaId: add.min_delta_id as number,
  239. maxDeltaId: add.max_delta_id as number,
  240. }))
  241. // Compute absolute bin IDs from deltas for downstream use
  242. const activeId = op.rebalanceActiveId ?? 0
  243. const allMinBins = adds.map(
  244. (a) => activeId + (a.min_delta_id as number)
  245. )
  246. const allMaxBins = adds.map(
  247. (a) => activeId + (a.max_delta_id as number)
  248. )
  249. op.minBinId = Math.min(...allMinBins)
  250. op.maxBinId = Math.max(...allMaxBins)
  251. }
  252. const removes = rebalanceParams.removes as
  253. | Record<string, unknown>[]
  254. | undefined
  255. if (removes && removes.length > 0) {
  256. op.rebalanceRemoves = removes.map((rem) => ({
  257. fromBinId: rem.from_bin_id as number,
  258. toBinId: rem.to_bin_id as number,
  259. bpsToRemove: rem.bps_to_remove as number,
  260. }))
  261. }
  262. }
  263. }
  264. // Extract init position parameters
  265. if (action === 'INIT_POSITION') {
  266. const data = decoded.data as Record<string, unknown>
  267. if (data.lower_bin_id !== undefined) {
  268. op.minBinId = data.lower_bin_id as number
  269. const width = data.width as number
  270. if (width) {
  271. op.maxBinId = (data.lower_bin_id as number) + width - 1
  272. }
  273. }
  274. }
  275. operations.push(op)
  276. } catch {
  277. // Skip instructions that fail to decode
  278. continue
  279. }
  280. }
  281. return operations
  282. }