index.ts 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. import { loadConfig, getPrivateKey } from '../config.js'
  2. import { JupiterSwapper, type SwapInfo } from '../solana/jupiter.js'
  3. const LIST_API = 'https://api2.byreal.io/byreal/api/dex/v2/position/list'
  4. const DETAIL_API = 'https://api2.byreal.io/byreal/api/dex/v2/position/detail'
  5. interface BonusInfo {
  6. fromCreatorPosition?: string
  7. [key: string]: unknown
  8. }
  9. interface PositionListItem {
  10. nftMintAddress?: string
  11. address?: string
  12. positionAddress?: string
  13. bonusInfo?: BonusInfo
  14. [key: string]: unknown
  15. }
  16. interface PositionListResult {
  17. total?: number
  18. positions?: PositionListItem[]
  19. [key: string]: unknown
  20. }
  21. interface ApiListResponse {
  22. retCode: number
  23. result?: {
  24. success?: boolean
  25. data?: PositionListResult
  26. [key: string]: unknown
  27. }
  28. }
  29. interface PositionDetailData {
  30. nftMintAddress?: string
  31. positionAddress?: string
  32. bonusInfo?: BonusInfo
  33. status?: number
  34. address?: string
  35. [key: string]: unknown
  36. }
  37. interface ApiDetailResponse {
  38. retCode: number
  39. result?: {
  40. success?: boolean
  41. data?: PositionDetailData
  42. [key: string]: unknown
  43. }
  44. }
  45. function isClosedStatus(status: number | undefined): boolean {
  46. if (status === 1) return true
  47. return false
  48. }
  49. async function fetchPositionList(
  50. userAddress: string,
  51. page: number,
  52. pageSize: number,
  53. tokenAddress?: string,
  54. ): Promise<ApiListResponse> {
  55. const params = new URLSearchParams({
  56. userAddress,
  57. page: String(page),
  58. pageSize: String(pageSize),
  59. status: String(0)
  60. })
  61. if (tokenAddress) params.set('tokenAddress', tokenAddress)
  62. const url = `${LIST_API}?${params.toString()}`
  63. const res = await fetch(url)
  64. return res.json() as Promise<ApiListResponse>
  65. }
  66. async function fetchPositionDetail(address: string): Promise<ApiDetailResponse> {
  67. const url = `${DETAIL_API}?address=${encodeURIComponent(address)}`
  68. const res = await fetch(url)
  69. return res.json() as Promise<ApiDetailResponse>
  70. }
  71. interface LpCloseApiResponse {
  72. success: boolean
  73. txid?: string
  74. [key: string]: unknown
  75. }
  76. async function callLpCloseApi(
  77. apiUrl: string,
  78. auth: string,
  79. nftMintAddress: string
  80. ): Promise<LpCloseApiResponse> {
  81. const res = await fetch(apiUrl, {
  82. method: 'POST',
  83. headers: {
  84. 'Content-Type': 'application/json',
  85. 'Authorization': auth,
  86. },
  87. body: JSON.stringify({ nftMintAddress }),
  88. })
  89. return res.json() as Promise<LpCloseApiResponse>
  90. }
  91. async function sendDiscordCloseResultNotification(
  92. webhookUrl: string,
  93. childPositionAddress: string,
  94. parentPositionAddress: string,
  95. success: boolean,
  96. txid?: string
  97. ): Promise<void> {
  98. const embed = {
  99. title: success ? '✅ 子仓位关闭成功' : '❌ 子仓位关闭失败',
  100. description: success
  101. ? '父仓位已关闭,已通过接口关闭子仓位。'
  102. : '父仓位已关闭,但调用关闭接口失败,请手动处理子仓位。',
  103. color: success ? 0x00ff00 : 0xff0000,
  104. fields: [
  105. {
  106. name: '📍 子仓位地址',
  107. value: `[${childPositionAddress.slice(0, 8)}...${childPositionAddress.slice(-8)}](https://solscan.io/account/${childPositionAddress})`,
  108. inline: false,
  109. },
  110. {
  111. name: '📍 父仓位地址',
  112. value: `[${parentPositionAddress.slice(0, 8)}...${parentPositionAddress.slice(-8)}](https://solscan.io/account/${parentPositionAddress})`,
  113. inline: false,
  114. },
  115. ...(success && txid
  116. ? [
  117. {
  118. name: '交易 ID',
  119. value: `[${txid.slice(0, 16)}...](https://solscan.io/tx/${txid})`,
  120. inline: false,
  121. },
  122. ]
  123. : []),
  124. ],
  125. timestamp: new Date().toISOString(),
  126. footer: { text: 'ByReal Auto Trading' },
  127. }
  128. await fetch(webhookUrl, {
  129. method: 'POST',
  130. headers: { 'Content-Type': 'application/json' },
  131. body: JSON.stringify({ embeds: [embed] }),
  132. })
  133. }
  134. async function sendDiscordSwapNotification(
  135. webhookUrl: string,
  136. closeTxid: string,
  137. swaps: SwapInfo[]
  138. ): Promise<void> {
  139. if (!swaps || swaps.length === 0) return
  140. const totalSwappedUsd = swaps.reduce((sum, s) => sum + s.swappedUsd, 0)
  141. const fields = [
  142. {
  143. name: '📤 关仓交易',
  144. value: `[${closeTxid.slice(0, 16)}...](https://solscan.io/tx/${closeTxid})`,
  145. inline: false,
  146. },
  147. {
  148. name: '💰 兑换总额',
  149. value: `$${totalSwappedUsd.toFixed(2)} → USDC`,
  150. inline: false,
  151. },
  152. ]
  153. swaps.forEach((swap, index) => {
  154. const symbol = swap.symbol || swap.mint.slice(0, 8)
  155. fields.push({
  156. name: `🔄 兑换 #${index + 1}`,
  157. value: `${symbol}: $${swap.swappedUsd.toFixed(2)}\n[Tx](https://solscan.io/tx/${swap.txSignature})`,
  158. inline: true,
  159. })
  160. })
  161. const embed = {
  162. title: '💱 代币兑换完成',
  163. description: `成功将 ${swaps.length} 种代币兑换为 USDC`,
  164. color: 0x3498db,
  165. fields,
  166. timestamp: new Date().toISOString(),
  167. footer: { text: 'ByReal Auto Trading' },
  168. }
  169. await fetch(webhookUrl, {
  170. method: 'POST',
  171. headers: { 'Content-Type': 'application/json' },
  172. body: JSON.stringify({ embeds: [embed] }),
  173. })
  174. }
  175. /**
  176. * 获取全部 LP 仓位(分页),检查每个仓位的 bonusInfo.fromCreatorPosition 对应父仓位是否已关闭,
  177. * 若已关闭则调用 lp-close 接口关闭子仓位,并根据接口结果发送 Discord 通知。
  178. */
  179. export async function checkParentPositionsClosed(): Promise<void> {
  180. const cfg = loadConfig()
  181. const closeCfg = cfg.closePosition
  182. if (!closeCfg) {
  183. console.log('[closePosition] 未配置 closePosition,跳过')
  184. return
  185. }
  186. const { userAddress, discordWebhookUrl, pageSize, lpCloseApiUrl, lpCloseAuth } = closeCfg
  187. const PAGE_SIZE = pageSize ?? 50
  188. let page = 1
  189. let total = 0
  190. const parentAddressesByChild = new Map<string, string>() // childAddress -> parentAddress
  191. do {
  192. const listRes = await fetchPositionList(userAddress, page, PAGE_SIZE)
  193. const data = listRes?.result?.data
  194. const list = data?.positions ?? []
  195. const totalFromApi = data?.total ?? 0
  196. if (page === 1) total = totalFromApi
  197. for (const item of list) {
  198. const childDetailRes = await fetchPositionDetail(item.positionAddress ?? '')
  199. const childDetail = childDetailRes?.result?.data as PositionDetailData
  200. if (!childDetailRes?.result?.success || childDetail == null) {
  201. console.warn(`[closePosition] 获取子仓位详情失败: ${item.address ?? item.positionAddress ?? ''}`)
  202. continue
  203. }
  204. const nftMintAddress = childDetail.nftMintAddress ?? ''
  205. if (childDetail.bonusInfo?.fromCreatorPosition) {
  206. parentAddressesByChild.set(nftMintAddress, (childDetail.bonusInfo?.fromCreatorPosition as string) ?? '')
  207. }
  208. }
  209. if (list.length < PAGE_SIZE || list.length === 0) break
  210. page++
  211. } while (page * PAGE_SIZE < total)
  212. const parentAddresses = [...new Set(parentAddressesByChild.values())]
  213. console.log(`[closePosition] 共 ${parentAddressesByChild.size} 条仓位含父仓位,去重后 ${parentAddresses.length} 个父仓位待检查`)
  214. for (const [childAddress, parentAddress] of parentAddressesByChild) {
  215. try {
  216. const detailRes = await fetchPositionDetail(parentAddress)
  217. const detail = detailRes?.result?.data
  218. if (!detailRes?.result?.success || detail == null) {
  219. console.warn(`[closePosition] 获取父仓位详情失败: ${parentAddress}`)
  220. continue
  221. }
  222. if (isClosedStatus(detail.status)) {
  223. console.log(`[closePosition] 父仓位已关闭: ${parentAddress},调用接口关闭子仓位`)
  224. try {
  225. const closeRes = await callLpCloseApi(lpCloseApiUrl, lpCloseAuth, childAddress)
  226. // await sendDiscordCloseResultNotification(
  227. // discordWebhookUrl,
  228. // childAddress,
  229. // parentAddress,
  230. // !!closeRes?.success,
  231. // closeRes?.txid
  232. // )
  233. if (closeRes?.success) {
  234. console.log(`[closePosition] 子仓位 ${childAddress} 关闭成功, txid: ${closeRes.txid ?? '-'}`)
  235. // 关闭成功后,分析交易并兑换多余代币为USDC
  236. const privateKey = getPrivateKey()
  237. if (closeRes.txid && privateKey) {
  238. try {
  239. const swapper = new JupiterSwapper(
  240. cfg.rpcHttp,
  241. privateKey,
  242. closeCfg.jupiterApiKey
  243. )
  244. const swapResult = await swapper.analyzeCloseTxAndSwapRemains(
  245. closeRes.txid,
  246. closeCfg.swapKeepUsdValue ?? 10,
  247. closeCfg.swapWhitelist ?? []
  248. )
  249. // 发送兑换结果Discord通知
  250. if (swapResult.swaps && swapResult.swaps.length > 0) {
  251. await sendDiscordSwapNotification(
  252. discordWebhookUrl,
  253. closeRes.txid,
  254. swapResult.swaps
  255. )
  256. }
  257. } catch (swapError) {
  258. console.error('[closePosition] Swap分析失败:', swapError)
  259. }
  260. }
  261. } else {
  262. console.warn(`[closePosition] 子仓位 ${childAddress} 关闭失败`)
  263. }
  264. } catch (e) {
  265. console.error('[closePosition] 调用关闭接口异常:', e)
  266. await sendDiscordCloseResultNotification(
  267. discordWebhookUrl,
  268. childAddress,
  269. parentAddress,
  270. false
  271. )
  272. }
  273. }
  274. } catch (e) {
  275. console.error(`[closePosition] 检查父仓位 ${parentAddress} 异常:`, e)
  276. }
  277. }
  278. }
  279. const INTERVAL_MS = 60 * 60 * 1000 // 每 60 分钟
  280. function run(): void {
  281. checkParentPositionsClosed().catch(console.error)
  282. }
  283. run()
  284. setInterval(run, INTERVAL_MS)
  285. console.log('[closePosition] 已启动,每', INTERVAL_MS / 60000, '分钟检查一次')