index.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. import { loadConfig } from '../config.js'
  2. const LIST_API = 'https://api2.byreal.io/byreal/api/dex/v2/position/list'
  3. const DETAIL_API = 'https://api2.byreal.io/byreal/api/dex/v2/position/detail'
  4. // const COPY_INFO_API = 'https://api2.byreal.io/byreal/api/dex/v2/copy/info'
  5. interface BonusInfo {
  6. fromCreatorPosition?: string
  7. [key: string]: unknown
  8. }
  9. interface PositionListItem {
  10. address?: string
  11. positionAddress?: string
  12. bonusInfo?: BonusInfo
  13. [key: string]: unknown
  14. }
  15. interface PositionListResult {
  16. total?: number
  17. positions?: PositionListItem[]
  18. [key: string]: unknown
  19. }
  20. interface ApiListResponse {
  21. retCode: number
  22. result?: {
  23. success?: boolean
  24. data?: PositionListResult
  25. [key: string]: unknown
  26. }
  27. }
  28. interface PositionDetailData {
  29. positionAddress?: string
  30. bonusInfo?: BonusInfo
  31. status?: number
  32. address?: string
  33. [key: string]: unknown
  34. }
  35. interface ApiDetailResponse {
  36. retCode: number
  37. result?: {
  38. success?: boolean
  39. data?: PositionDetailData
  40. [key: string]: unknown
  41. }
  42. }
  43. function isClosedStatus(status: number | undefined): boolean {
  44. if (status === 1) return true
  45. return false
  46. }
  47. async function fetchPositionList(
  48. userAddress: string,
  49. page: number,
  50. pageSize: number,
  51. tokenAddress?: string,
  52. ): Promise<ApiListResponse> {
  53. const params = new URLSearchParams({
  54. userAddress,
  55. page: String(page),
  56. pageSize: String(pageSize),
  57. status: String(0)
  58. })
  59. if (tokenAddress) params.set('tokenAddress', tokenAddress)
  60. const url = `${LIST_API}?${params.toString()}`
  61. const res = await fetch(url)
  62. return res.json() as Promise<ApiListResponse>
  63. }
  64. async function fetchPositionDetail(address: string): Promise<ApiDetailResponse> {
  65. const url = `${DETAIL_API}?address=${encodeURIComponent(address)}`
  66. const res = await fetch(url)
  67. return res.json() as Promise<ApiDetailResponse>
  68. }
  69. interface LpCloseApiResponse {
  70. success: boolean
  71. txid?: string
  72. [key: string]: unknown
  73. }
  74. async function callLpCloseApi(
  75. apiUrl: string,
  76. auth: string,
  77. nftMintAddress: string
  78. ): Promise<LpCloseApiResponse> {
  79. const res = await fetch(apiUrl, {
  80. method: 'POST',
  81. body: JSON.stringify({ nftMintAddress }),
  82. })
  83. return res.json() as Promise<LpCloseApiResponse>
  84. }
  85. async function sendDiscordCloseResultNotification(
  86. webhookUrl: string,
  87. childPositionAddress: string,
  88. parentPositionAddress: string,
  89. success: boolean,
  90. txid?: string
  91. ): Promise<void> {
  92. const embed = {
  93. title: success ? '✅ 子仓位关闭成功' : '❌ 子仓位关闭失败',
  94. description: success
  95. ? '父仓位已关闭,已通过接口关闭子仓位。'
  96. : '父仓位已关闭,但调用关闭接口失败,请手动处理子仓位。',
  97. color: success ? 0x00ff00 : 0xff0000,
  98. fields: [
  99. {
  100. name: '📍 子仓位地址',
  101. value: `[${childPositionAddress.slice(0, 8)}...${childPositionAddress.slice(-8)}](https://solscan.io/account/${childPositionAddress})`,
  102. inline: false,
  103. },
  104. {
  105. name: '📍 父仓位地址',
  106. value: `[${parentPositionAddress.slice(0, 8)}...${parentPositionAddress.slice(-8)}](https://solscan.io/account/${parentPositionAddress})`,
  107. inline: false,
  108. },
  109. ...(success && txid
  110. ? [
  111. {
  112. name: '交易 ID',
  113. value: `[${txid.slice(0, 16)}...](https://solscan.io/tx/${txid})`,
  114. inline: false,
  115. },
  116. ]
  117. : []),
  118. ],
  119. timestamp: new Date().toISOString(),
  120. footer: { text: 'ByReal Auto Trading' },
  121. }
  122. await fetch(webhookUrl, {
  123. method: 'POST',
  124. headers: { 'Content-Type': 'application/json' },
  125. body: JSON.stringify({ embeds: [embed] }),
  126. })
  127. }
  128. /**
  129. * 获取全部 LP 仓位(分页),检查每个仓位的 bonusInfo.fromCreatorPosition 对应父仓位是否已关闭,
  130. * 若已关闭则调用 lp-close 接口关闭子仓位,并根据接口结果发送 Discord 通知。
  131. */
  132. export async function checkParentPositionsClosed(): Promise<void> {
  133. const cfg = loadConfig()
  134. const closeCfg = cfg.closePosition
  135. if (!closeCfg) {
  136. console.log('[closePosition] 未配置 closePosition,跳过')
  137. return
  138. }
  139. const { userAddress, discordWebhookUrl, pageSize, lpCloseApiUrl, lpCloseAuth } = closeCfg
  140. const PAGE_SIZE = pageSize ?? 50
  141. let page = 1
  142. let total = 0
  143. const parentAddressesByChild = new Map<string, string>() // childAddress -> parentAddress
  144. do {
  145. const listRes = await fetchPositionList(userAddress, page, PAGE_SIZE)
  146. const data = listRes?.result?.data
  147. const list = data?.positions ?? []
  148. const totalFromApi = data?.total ?? 0
  149. if (page === 1) total = totalFromApi
  150. for (const item of list) {
  151. const childDetailRes = await fetchPositionDetail(item.positionAddress ?? '')
  152. const childDetail = childDetailRes?.result?.data as PositionDetailData
  153. if (!childDetailRes?.result?.success || childDetail == null) {
  154. console.warn(`[closePosition] 获取子仓位详情失败: ${item.address ?? item.positionAddress ?? ''}`)
  155. continue
  156. }
  157. const childAddress = childDetail.positionAddress ?? ''
  158. if (childDetail.bonusInfo?.fromCreatorPosition) {
  159. parentAddressesByChild.set(childAddress, (childDetail.bonusInfo?.fromCreatorPosition as string) ?? '')
  160. }
  161. }
  162. if (list.length < PAGE_SIZE || list.length === 0) break
  163. page++
  164. } while (page * PAGE_SIZE < total)
  165. const parentAddresses = [...new Set(parentAddressesByChild.values())]
  166. console.log(`[closePosition] 共 ${parentAddressesByChild.size} 条仓位含父仓位,去重后 ${parentAddresses.length} 个父仓位待检查`)
  167. for (const [childAddress, parentAddress] of parentAddressesByChild) {
  168. try {
  169. const detailRes = await fetchPositionDetail(parentAddress)
  170. const detail = detailRes?.result?.data
  171. if (!detailRes?.result?.success || detail == null) {
  172. console.warn(`[closePosition] 获取父仓位详情失败: ${parentAddress}`)
  173. continue
  174. }
  175. if (isClosedStatus(detail.status)) {
  176. console.log(`[closePosition] 父仓位已关闭: ${parentAddress},调用接口关闭子仓位`)
  177. try {
  178. const closeRes = await callLpCloseApi(lpCloseApiUrl, lpCloseAuth, childAddress)
  179. await sendDiscordCloseResultNotification(
  180. discordWebhookUrl,
  181. childAddress,
  182. parentAddress,
  183. !!closeRes?.success,
  184. closeRes?.txid
  185. )
  186. if (closeRes?.success) {
  187. console.log(`[closePosition] 子仓位 ${childAddress} 关闭成功, txid: ${closeRes.txid ?? '-'}`)
  188. } else {
  189. console.warn(`[closePosition] 子仓位 ${childAddress} 关闭失败`)
  190. }
  191. } catch (e) {
  192. console.error('[closePosition] 调用关闭接口异常:', e)
  193. await sendDiscordCloseResultNotification(
  194. discordWebhookUrl,
  195. childAddress,
  196. parentAddress,
  197. false
  198. )
  199. }
  200. }
  201. } catch (e) {
  202. console.error(`[closePosition] 检查父仓位 ${parentAddress} 异常:`, e)
  203. }
  204. }
  205. }
  206. const INTERVAL_MS = 60 * 60 * 1000 // 每 60 分钟
  207. function run(): void {
  208. checkParentPositionsClosed().catch(console.error)
  209. }
  210. run()
  211. setInterval(run, INTERVAL_MS)
  212. console.log('[closePosition] 已启动,每', INTERVAL_MS / 60000, '分钟检查一次')