index.ts 19 KB

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