index.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667
  1. import { ParsedTransactionWithMeta, PublicKey } from '@solana/web3.js'
  2. import BN from 'bn.js'
  3. import { getConnection } from '../solana/connection'
  4. import { getUserAddress, signerCallback } from '../solana/wallet'
  5. import { config } from '../config'
  6. import {
  7. addCopyHistory,
  8. updateCopyHistory,
  9. upsertPositionMapping,
  10. getPositionMappingByTargetNft,
  11. getPositionMappingByTargetPosition,
  12. updatePositionMappingStatus,
  13. getWatchedAddressByAddress,
  14. getSetting,
  15. } from '../db/queries'
  16. import type { ParsedOperation } from '../monitor/types'
  17. import { scaleAmount } from './ratio'
  18. import { getTokenPrices, calculateCopyScale } from './price'
  19. import { ensureSufficientBalances, getUsdcBalance, swapTokensBackToUsdc, USDC_MINT } from './swap'
  20. import { sendDiscordNotification } from '../discord'
  21. // We import from the built SDK dist using relative path
  22. // eslint-disable-next-line @typescript-eslint/no-require-imports
  23. const { Chain, BYREAL_CLMM_PROGRAM_ID } = require('../clmm-sdk/dist/index.js')
  24. function sleep(ms: number) {
  25. return new Promise((resolve) => setTimeout(resolve, ms))
  26. }
  27. /**
  28. * Extract our NFT mint from a createPosition TX.
  29. * The NFT mint is the second signer (non-wallet signer) in the transaction.
  30. */
  31. async function extractOurNftMint(txid: string, walletAddress: string): Promise<string> {
  32. const connection = getConnection()
  33. // Wait a bit for TX to be confirmed
  34. await sleep(2000)
  35. for (let attempt = 0; attempt < 3; attempt++) {
  36. try {
  37. const tx: ParsedTransactionWithMeta | null = await connection.getParsedTransaction(txid, {
  38. commitment: 'confirmed',
  39. maxSupportedTransactionVersion: 0,
  40. })
  41. if (!tx) {
  42. if (attempt < 2) {
  43. await sleep(2000)
  44. continue
  45. }
  46. return ''
  47. }
  48. const accountKeys = tx.transaction.message.accountKeys
  49. for (const acc of accountKeys) {
  50. const pubkey = typeof acc === 'string' ? acc : 'pubkey' in acc ? acc.pubkey.toBase58() : ''
  51. const isSigner = typeof acc === 'string' ? false : 'signer' in acc ? (acc as { signer: boolean }).signer : false
  52. if (isSigner && pubkey && pubkey !== walletAddress) {
  53. return pubkey
  54. }
  55. }
  56. } catch (e) {
  57. if (attempt < 2) {
  58. await sleep(2000)
  59. continue
  60. }
  61. }
  62. }
  63. return ''
  64. }
  65. export class CopyEngine {
  66. private chain: InstanceType<typeof Chain>
  67. private connection = getConnection()
  68. constructor() {
  69. this.chain = new Chain({
  70. connection: this.connection,
  71. programId: BYREAL_CLMM_PROGRAM_ID,
  72. })
  73. }
  74. async executeCopy(operation: ParsedOperation): Promise<void> {
  75. console.log(`[CopyEngine] Executing ${operation.type} copy for tx ${operation.signature}`)
  76. switch (operation.type) {
  77. case 'open_position':
  78. return this.copyOpenPosition(operation)
  79. case 'add_liquidity':
  80. return this.copyAddLiquidity(operation)
  81. case 'decrease_liquidity':
  82. return this.copyDecreaseLiquidity(operation)
  83. case 'close_position':
  84. return this.copyClosePosition(operation)
  85. }
  86. }
  87. /**
  88. * 手动关仓(供 API 调用)
  89. */
  90. async manualClosePosition(ourNftMint: string, poolId: string): Promise<string> {
  91. const nftMint = new PublicKey(ourNftMint)
  92. const poolInfo = await this.chain.getRawPoolInfoByPoolId(new PublicKey(poolId))
  93. const mintA = poolInfo.mintA.toBase58()
  94. const mintB = poolInfo.mintB.toBase58()
  95. const txid = await this.chain.decreaseFullLiquidity({
  96. userAddress: getUserAddress(),
  97. nftMint,
  98. closePosition: true,
  99. slippage: 0.99,
  100. signerCallback,
  101. })
  102. console.log(`[CopyEngine] Manual close position TX: ${txid}`)
  103. sendDiscordNotification({
  104. operation: 'close_position',
  105. status: 'success',
  106. targetAddress: 'manual',
  107. ourTxSig: txid,
  108. ourNftMint: ourNftMint,
  109. })
  110. // Swap received tokens back to USDC (if enabled)
  111. if (this.isSwapAfterCloseEnabled()) {
  112. await sleep(3000)
  113. await swapTokensBackToUsdc({
  114. connection: this.connection,
  115. mints: [mintA, mintB],
  116. })
  117. }
  118. return txid
  119. }
  120. /**
  121. * 检查关仓后是否需要 swap 回 USDC
  122. */
  123. private isSwapAfterCloseEnabled(): boolean {
  124. const val = getSetting('swap_after_close')
  125. return val !== 'false'
  126. }
  127. /**
  128. * 获取地址的倍率和最大值设置(优先用地址单独设置,否则用全局默认)
  129. */
  130. private getAddressSettings(signerAddress: string): { multiplier: number; maxUsd: number } {
  131. const addrRow = getWatchedAddressByAddress(signerAddress)
  132. return {
  133. multiplier: addrRow?.copy_multiplier ?? config.copyMultiplier,
  134. maxUsd: addrRow?.copy_max_usd ?? config.copyMaxUsd,
  135. }
  136. }
  137. /**
  138. * 检查 USDC 余额是否足够
  139. */
  140. private async checkUsdcBalance(requiredUsd: number): Promise<boolean> {
  141. const balance = await getUsdcBalance(this.connection)
  142. const balanceUsd = Number(balance) / 1e6
  143. if (balanceUsd < requiredUsd) {
  144. console.log(
  145. `[CopyEngine] Insufficient USDC balance: $${balanceUsd.toFixed(2)} < $${requiredUsd.toFixed(2)}, skipping`,
  146. )
  147. return false
  148. }
  149. return true
  150. }
  151. private async copyOpenPosition(op: ParsedOperation) {
  152. const historyId = addCopyHistory({
  153. targetAddress: op.signer,
  154. targetTxSig: op.signature,
  155. operation: 'open_position',
  156. targetNftMint: op.nftMint,
  157. poolId: op.poolId,
  158. targetAmountA: op.amountA,
  159. targetAmountB: op.amountB,
  160. status: 'executing',
  161. })
  162. try {
  163. // Get pool info
  164. const poolInfo = await this.chain.getRawPoolInfoByPoolId(new PublicKey(op.poolId))
  165. const mintA = op.mintA || poolInfo.mintA.toBase58()
  166. const mintB = op.mintB || poolInfo.mintB.toBase58()
  167. const decimalsA = poolInfo.mintDecimalsA as number
  168. const decimalsB = poolInfo.mintDecimalsB as number
  169. // Get tick range - prefer from event data, fall back to fetching position
  170. let tickLower = op.tickLower
  171. let tickUpper = op.tickUpper
  172. if (tickLower === undefined || tickUpper === undefined) {
  173. const posInfo = await this.chain.getRawPositionInfoByNftMint(new PublicKey(op.nftMint))
  174. if (posInfo) {
  175. tickLower = posInfo.tickLowerIndex
  176. tickUpper = posInfo.tickUpperIndex
  177. } else {
  178. throw new Error('Cannot determine tick range for position')
  179. }
  180. }
  181. // Get token prices and calculate copy scale
  182. const prices = await getTokenPrices(mintA, mintB)
  183. const priceA = prices[mintA]
  184. const priceB = prices[mintB]
  185. if (!priceA || !priceB) {
  186. throw new Error(`Cannot get token prices: A=${priceA || 'N/A'}, B=${priceB || 'N/A'}`)
  187. }
  188. const addrSettings = this.getAddressSettings(op.signer)
  189. const { ratio, targetUsd, ourUsd } = calculateCopyScale({
  190. targetAmountA: op.amountA || '0',
  191. targetAmountB: op.amountB || '0',
  192. decimalsA,
  193. decimalsB,
  194. priceA,
  195. priceB,
  196. multiplier: addrSettings.multiplier,
  197. maxUsd: addrSettings.maxUsd,
  198. })
  199. console.log(
  200. `[CopyEngine] Target: $${targetUsd.toFixed(2)}, Multiplier: ${addrSettings.multiplier}x, ` +
  201. `Max: $${addrSettings.maxUsd}, Our: $${ourUsd.toFixed(2)} (ratio: ${ratio.toFixed(4)})`,
  202. )
  203. if (ratio <= 0) {
  204. throw new Error('Copy ratio is zero - target position value is zero or prices unavailable')
  205. }
  206. // Check USDC balance before proceeding
  207. const neededUsd = mintA === USDC_MINT || mintB === USDC_MINT ? ourUsd * 0.6 : ourUsd
  208. if (!(await this.checkUsdcBalance(neededUsd))) {
  209. const skipMsg = `Insufficient USDC balance for $${ourUsd.toFixed(2)} position`
  210. updateCopyHistory(historyId, { status: 'skipped', errorMessage: skipMsg })
  211. sendDiscordNotification({
  212. operation: 'open_position',
  213. status: 'skipped',
  214. targetAddress: op.signer,
  215. targetTxSig: op.signature,
  216. errorMessage: skipMsg,
  217. })
  218. return
  219. }
  220. // Scale amounts by calculated ratio
  221. const scaledAmountA = op.amountA ? scaleAmount(new BN(op.amountA), ratio) : new BN(0)
  222. const scaledAmountB = op.amountB ? scaleAmount(new BN(op.amountB), ratio) : new BN(0)
  223. // Determine base token and otherAmountMax
  224. // 直接用 scaledAmount 作为 otherAmountMax(只是上限,SDK 不会多用)
  225. // 参考 byreal-copy: base='MintA', baseAmount=scaledAmount0, otherAmountMax=scaledAmount1
  226. const base = scaledAmountA.gt(new BN(0)) ? 'MintA' : 'MintB'
  227. const baseAmount = base === 'MintA' ? scaledAmountA : scaledAmountB
  228. const otherAmountMax = base === 'MintA' ? scaledAmountB : scaledAmountA
  229. if (baseAmount.isZero()) {
  230. throw new Error('Scaled amount is zero')
  231. }
  232. // Ensure token balances (ExactOut: check balance, swap deficit)
  233. const swapResult = await ensureSufficientBalances({
  234. connection: this.connection,
  235. tokenA: { mint: mintA, requiredAmount: scaledAmountA.toString() },
  236. tokenB: { mint: mintB, requiredAmount: scaledAmountB.toString() },
  237. })
  238. if (!swapResult.success) {
  239. throw new Error(`Token swap failed: ${swapResult.error}`)
  240. }
  241. if (swapResult.swapTxids.length > 0) {
  242. updateCopyHistory(historyId, { swapTxSig: swapResult.swapTxids.join(',') })
  243. await sleep(2000) // Wait for swap to settle
  244. }
  245. // Execute position creation with referer_position memo
  246. const txid = await this.chain.createPosition({
  247. userAddress: getUserAddress(),
  248. poolInfo,
  249. tickLower: tickLower!,
  250. tickUpper: tickUpper!,
  251. base,
  252. baseAmount,
  253. otherAmountMax,
  254. refererPosition: new PublicKey(op.personalPosition),
  255. signerCallback,
  256. })
  257. console.log(`[CopyEngine] Open position TX: ${txid}`)
  258. // Extract our NFT mint from the confirmed transaction
  259. const ourNftMint = await extractOurNftMint(txid, getUserAddress().toBase58())
  260. if (ourNftMint) {
  261. console.log(`[CopyEngine] Our NFT mint: ${ourNftMint}`)
  262. } else {
  263. console.warn(`[CopyEngine] Could not extract our NFT mint from TX ${txid}`)
  264. }
  265. updateCopyHistory(historyId, {
  266. ourNftMint: ourNftMint || undefined,
  267. ourTxSig: txid,
  268. ourAmountA: scaledAmountA.toString(),
  269. ourAmountB: scaledAmountB.toString(),
  270. status: 'success',
  271. })
  272. // Store position mapping with our NFT mint
  273. upsertPositionMapping({
  274. targetAddress: op.signer,
  275. targetNftMint: op.nftMint,
  276. targetPersonalPosition: op.personalPosition,
  277. ourNftMint: ourNftMint || undefined,
  278. poolId: op.poolId,
  279. tickLower: tickLower!,
  280. tickUpper: tickUpper!,
  281. })
  282. sendDiscordNotification({
  283. operation: 'open_position',
  284. status: 'success',
  285. targetAddress: op.signer,
  286. targetTxSig: op.signature,
  287. ourTxSig: txid,
  288. ourNftMint: ourNftMint || undefined,
  289. extraFields: [{ name: '金额', value: `$${ourUsd.toFixed(2)}`, inline: true }],
  290. })
  291. } catch (e) {
  292. const msg = e instanceof Error ? e.message : String(e)
  293. console.error(`[CopyEngine] Open position failed:`, msg)
  294. updateCopyHistory(historyId, { status: 'failed', errorMessage: msg })
  295. sendDiscordNotification({
  296. operation: 'open_position',
  297. status: 'failed',
  298. targetAddress: op.signer,
  299. targetTxSig: op.signature,
  300. errorMessage: msg,
  301. })
  302. }
  303. }
  304. private async copyAddLiquidity(op: ParsedOperation) {
  305. // Find our corresponding position
  306. const mapping = op.nftMint
  307. ? getPositionMappingByTargetNft(op.nftMint)
  308. : getPositionMappingByTargetPosition(op.personalPosition)
  309. if (!mapping || !mapping.our_nft_mint) {
  310. console.log(`[CopyEngine] No matching position for add_liquidity, skipping`)
  311. addCopyHistory({
  312. targetAddress: op.signer,
  313. targetTxSig: op.signature,
  314. operation: 'add_liquidity',
  315. targetNftMint: op.nftMint,
  316. poolId: op.poolId,
  317. status: 'skipped',
  318. })
  319. return
  320. }
  321. const historyId = addCopyHistory({
  322. targetAddress: op.signer,
  323. targetTxSig: op.signature,
  324. operation: 'add_liquidity',
  325. targetNftMint: op.nftMint,
  326. poolId: op.poolId,
  327. targetAmountA: op.amountA,
  328. targetAmountB: op.amountB,
  329. status: 'executing',
  330. })
  331. try {
  332. const ourNftMint = new PublicKey(mapping.our_nft_mint)
  333. const poolInfo = await this.chain.getRawPoolInfoByPoolId(new PublicKey(mapping.pool_id))
  334. const mintA = op.mintA || poolInfo.mintA.toBase58()
  335. const mintB = op.mintB || poolInfo.mintB.toBase58()
  336. const decimalsA = poolInfo.mintDecimalsA as number
  337. const decimalsB = poolInfo.mintDecimalsB as number
  338. // Get token prices and calculate copy scale
  339. const prices = await getTokenPrices(mintA, mintB)
  340. const priceA = prices[mintA]
  341. const priceB = prices[mintB]
  342. if (!priceA || !priceB) {
  343. throw new Error(`Cannot get token prices: A=${priceA || 'N/A'}, B=${priceB || 'N/A'}`)
  344. }
  345. const addrSettings = this.getAddressSettings(op.signer)
  346. const { ratio, targetUsd, ourUsd } = calculateCopyScale({
  347. targetAmountA: op.amountA || '0',
  348. targetAmountB: op.amountB || '0',
  349. decimalsA,
  350. decimalsB,
  351. priceA,
  352. priceB,
  353. multiplier: addrSettings.multiplier,
  354. maxUsd: addrSettings.maxUsd,
  355. })
  356. console.log(
  357. `[CopyEngine] Add liquidity - Target: $${targetUsd.toFixed(2)}, Our: $${ourUsd.toFixed(2)} (ratio: ${ratio.toFixed(4)})`,
  358. )
  359. if (ratio <= 0) {
  360. throw new Error('Copy ratio is zero')
  361. }
  362. // Check USDC balance before proceeding
  363. const neededUsd = mintA === USDC_MINT || mintB === USDC_MINT ? ourUsd * 0.6 : ourUsd
  364. if (!(await this.checkUsdcBalance(neededUsd))) {
  365. const skipMsg = `Insufficient USDC balance for $${ourUsd.toFixed(2)} add liquidity`
  366. updateCopyHistory(historyId, { status: 'skipped', errorMessage: skipMsg })
  367. sendDiscordNotification({
  368. operation: 'add_liquidity',
  369. status: 'skipped',
  370. targetAddress: op.signer,
  371. targetTxSig: op.signature,
  372. errorMessage: skipMsg,
  373. })
  374. return
  375. }
  376. // Scale amounts
  377. const scaledAmountA = op.amountA ? scaleAmount(new BN(op.amountA), ratio) : new BN(0)
  378. const scaledAmountB = op.amountB ? scaleAmount(new BN(op.amountB), ratio) : new BN(0)
  379. const base = scaledAmountA.gt(new BN(0)) ? 'MintA' : 'MintB'
  380. const baseAmount = base === 'MintA' ? scaledAmountA : scaledAmountB
  381. const otherAmountMax = base === 'MintA' ? scaledAmountB : scaledAmountA
  382. if (baseAmount.isZero()) {
  383. throw new Error('Scaled amount is zero')
  384. }
  385. // Ensure token balances (ExactOut)
  386. const swapResult = await ensureSufficientBalances({
  387. connection: this.connection,
  388. tokenA: { mint: mintA, requiredAmount: scaledAmountA.toString() },
  389. tokenB: { mint: mintB, requiredAmount: scaledAmountB.toString() },
  390. })
  391. if (!swapResult.success) {
  392. throw new Error(`Token swap failed: ${swapResult.error}`)
  393. }
  394. if (swapResult.swapTxids.length > 0) {
  395. await sleep(2000)
  396. }
  397. const txid = await this.chain.addLiquidity({
  398. userAddress: getUserAddress(),
  399. nftMint: ourNftMint,
  400. base,
  401. baseAmount,
  402. otherAmountMax,
  403. signerCallback,
  404. })
  405. console.log(`[CopyEngine] Add liquidity TX: ${txid}`)
  406. updateCopyHistory(historyId, {
  407. ourNftMint: mapping.our_nft_mint,
  408. ourTxSig: txid,
  409. ourAmountA: scaledAmountA.toString(),
  410. ourAmountB: scaledAmountB.toString(),
  411. status: 'success',
  412. })
  413. sendDiscordNotification({
  414. operation: 'add_liquidity',
  415. status: 'success',
  416. targetAddress: op.signer,
  417. targetTxSig: op.signature,
  418. ourTxSig: txid,
  419. ourNftMint: mapping.our_nft_mint,
  420. extraFields: [{ name: '金额', value: `$${ourUsd.toFixed(2)}`, inline: true }],
  421. })
  422. } catch (e) {
  423. const msg = e instanceof Error ? e.message : String(e)
  424. console.error(`[CopyEngine] Add liquidity failed:`, msg)
  425. updateCopyHistory(historyId, { status: 'failed', errorMessage: msg })
  426. sendDiscordNotification({
  427. operation: 'add_liquidity',
  428. status: 'failed',
  429. targetAddress: op.signer,
  430. targetTxSig: op.signature,
  431. errorMessage: msg,
  432. })
  433. }
  434. }
  435. private async copyDecreaseLiquidity(op: ParsedOperation) {
  436. const mapping = op.nftMint
  437. ? getPositionMappingByTargetNft(op.nftMint)
  438. : getPositionMappingByTargetPosition(op.personalPosition)
  439. if (!mapping || !mapping.our_nft_mint) {
  440. console.log(`[CopyEngine] No matching position for decrease_liquidity, skipping`)
  441. addCopyHistory({
  442. targetAddress: op.signer,
  443. targetTxSig: op.signature,
  444. operation: 'decrease_liquidity',
  445. status: 'skipped',
  446. })
  447. return
  448. }
  449. const historyId = addCopyHistory({
  450. targetAddress: op.signer,
  451. targetTxSig: op.signature,
  452. operation: 'decrease_liquidity',
  453. targetNftMint: op.nftMint,
  454. poolId: op.poolId,
  455. targetAmountA: op.amountA,
  456. targetAmountB: op.amountB,
  457. status: 'executing',
  458. })
  459. try {
  460. const ourNftMint = new PublicKey(mapping.our_nft_mint)
  461. // Get our position info
  462. const ourPositionInfo = await this.chain.getRawPositionInfoByNftMint(ourNftMint)
  463. if (!ourPositionInfo) {
  464. throw new Error('Our position not found on chain')
  465. }
  466. // 简单方式(参考 byreal-copy):直接用 BN.min(目标减少量, 我们的流动性)
  467. let liquidityToDecrease: BN
  468. if (op.liquidity) {
  469. liquidityToDecrease = BN.min(new BN(op.liquidity), ourPositionInfo.liquidity)
  470. } else {
  471. // 没有流动性数据,减少 50%
  472. liquidityToDecrease = ourPositionInfo.liquidity.div(new BN(2))
  473. }
  474. if (liquidityToDecrease.isZero()) {
  475. throw new Error('Nothing to decrease')
  476. }
  477. console.log(
  478. `[CopyEngine] Decrease: target=${op.liquidity || 'N/A'}, ours=${ourPositionInfo.liquidity.toString()}, decreasing=${liquidityToDecrease.toString()}`,
  479. )
  480. // Use generous slippage for decrease — we're removing our own liquidity,
  481. // price can move significantly between detection and execution
  482. const txid = await this.chain.decreaseLiquidity({
  483. userAddress: getUserAddress(),
  484. nftMint: ourNftMint,
  485. liquidity: liquidityToDecrease,
  486. slippage: 0.99,
  487. signerCallback,
  488. })
  489. console.log(`[CopyEngine] Decrease liquidity TX: ${txid}`)
  490. updateCopyHistory(historyId, {
  491. ourNftMint: mapping.our_nft_mint,
  492. ourTxSig: txid,
  493. status: 'success',
  494. })
  495. sendDiscordNotification({
  496. operation: 'decrease_liquidity',
  497. status: 'success',
  498. targetAddress: op.signer,
  499. targetTxSig: op.signature,
  500. ourTxSig: txid,
  501. ourNftMint: mapping.our_nft_mint,
  502. })
  503. } catch (e) {
  504. const msg = e instanceof Error ? e.message : String(e)
  505. console.error(`[CopyEngine] Decrease liquidity failed:`, msg)
  506. updateCopyHistory(historyId, { status: 'failed', errorMessage: msg })
  507. sendDiscordNotification({
  508. operation: 'decrease_liquidity',
  509. status: 'failed',
  510. targetAddress: op.signer,
  511. targetTxSig: op.signature,
  512. errorMessage: msg,
  513. })
  514. }
  515. }
  516. private async copyClosePosition(op: ParsedOperation) {
  517. const mapping = op.nftMint
  518. ? getPositionMappingByTargetNft(op.nftMint)
  519. : getPositionMappingByTargetPosition(op.personalPosition)
  520. if (!mapping || !mapping.our_nft_mint) {
  521. console.log(`[CopyEngine] No matching position for close_position, skipping`)
  522. addCopyHistory({
  523. targetAddress: op.signer,
  524. targetTxSig: op.signature,
  525. operation: 'close_position',
  526. status: 'skipped',
  527. })
  528. return
  529. }
  530. const historyId = addCopyHistory({
  531. targetAddress: op.signer,
  532. targetTxSig: op.signature,
  533. operation: 'close_position',
  534. targetNftMint: op.nftMint,
  535. poolId: mapping.pool_id,
  536. status: 'executing',
  537. })
  538. try {
  539. const ourNftMint = new PublicKey(mapping.our_nft_mint)
  540. const poolInfo = await this.chain.getRawPoolInfoByPoolId(new PublicKey(mapping.pool_id))
  541. const mintA = poolInfo.mintA.toBase58()
  542. const mintB = poolInfo.mintB.toBase58()
  543. const txid = await this.chain.decreaseFullLiquidity({
  544. userAddress: getUserAddress(),
  545. nftMint: ourNftMint,
  546. closePosition: true,
  547. slippage: 0.99,
  548. signerCallback,
  549. })
  550. console.log(`[CopyEngine] Close position TX: ${txid}`)
  551. updateCopyHistory(historyId, {
  552. ourNftMint: mapping.our_nft_mint,
  553. ourTxSig: txid,
  554. status: 'success',
  555. })
  556. updatePositionMappingStatus(mapping.target_nft_mint, 'closed')
  557. sendDiscordNotification({
  558. operation: 'close_position',
  559. status: 'success',
  560. targetAddress: op.signer,
  561. targetTxSig: op.signature,
  562. ourTxSig: txid,
  563. ourNftMint: mapping.our_nft_mint,
  564. })
  565. // Swap received tokens back to USDC (if enabled)
  566. if (this.isSwapAfterCloseEnabled()) {
  567. await sleep(3000)
  568. const swapBack = await swapTokensBackToUsdc({
  569. connection: this.connection,
  570. mints: [mintA, mintB],
  571. })
  572. if (swapBack.swapTxids.length > 0) {
  573. console.log(`[CopyEngine] Swapped back to USDC: ${swapBack.swapTxids.join(', ')}`)
  574. }
  575. }
  576. } catch (e) {
  577. const msg = e instanceof Error ? e.message : String(e)
  578. console.error(`[CopyEngine] Close position failed:`, msg)
  579. updateCopyHistory(historyId, { status: 'failed', errorMessage: msg })
  580. sendDiscordNotification({
  581. operation: 'close_position',
  582. status: 'failed',
  583. targetAddress: op.signer,
  584. targetTxSig: op.signature,
  585. errorMessage: msg,
  586. })
  587. }
  588. }
  589. }