|
@@ -0,0 +1,236 @@
|
|
|
|
|
+import { loadConfig } from '../config.js'
|
|
|
|
|
+
|
|
|
|
|
+const LIST_API = 'https://api2.byreal.io/byreal/api/dex/v2/position/list'
|
|
|
|
|
+const DETAIL_API = 'https://api2.byreal.io/byreal/api/dex/v2/position/detail'
|
|
|
|
|
+// const COPY_INFO_API = 'https://api2.byreal.io/byreal/api/dex/v2/copy/info'
|
|
|
|
|
+
|
|
|
|
|
+interface BonusInfo {
|
|
|
|
|
+ fromCreatorPosition?: string
|
|
|
|
|
+ [key: string]: unknown
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface PositionListItem {
|
|
|
|
|
+ address?: string
|
|
|
|
|
+ positionAddress?: string
|
|
|
|
|
+ bonusInfo?: BonusInfo
|
|
|
|
|
+ [key: string]: unknown
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface PositionListResult {
|
|
|
|
|
+ total?: number
|
|
|
|
|
+ positions?: PositionListItem[]
|
|
|
|
|
+ [key: string]: unknown
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface ApiListResponse {
|
|
|
|
|
+ retCode: number
|
|
|
|
|
+ result?: {
|
|
|
|
|
+ success?: boolean
|
|
|
|
|
+ data?: PositionListResult
|
|
|
|
|
+ [key: string]: unknown
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface PositionDetailData {
|
|
|
|
|
+ positionAddress?: string
|
|
|
|
|
+ bonusInfo?: BonusInfo
|
|
|
|
|
+ status?: number
|
|
|
|
|
+ address?: string
|
|
|
|
|
+ [key: string]: unknown
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface ApiDetailResponse {
|
|
|
|
|
+ retCode: number
|
|
|
|
|
+ result?: {
|
|
|
|
|
+ success?: boolean
|
|
|
|
|
+ data?: PositionDetailData
|
|
|
|
|
+ [key: string]: unknown
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function isClosedStatus(status: number | undefined): boolean {
|
|
|
|
|
+ if (status === 1) return true
|
|
|
|
|
+ return false
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function fetchPositionList(
|
|
|
|
|
+ userAddress: string,
|
|
|
|
|
+ page: number,
|
|
|
|
|
+ pageSize: number,
|
|
|
|
|
+ tokenAddress?: string,
|
|
|
|
|
+): Promise<ApiListResponse> {
|
|
|
|
|
+ const params = new URLSearchParams({
|
|
|
|
|
+ userAddress,
|
|
|
|
|
+ page: String(page),
|
|
|
|
|
+ pageSize: String(pageSize),
|
|
|
|
|
+ status: String(0)
|
|
|
|
|
+ })
|
|
|
|
|
+ if (tokenAddress) params.set('tokenAddress', tokenAddress)
|
|
|
|
|
+ const url = `${LIST_API}?${params.toString()}`
|
|
|
|
|
+ const res = await fetch(url)
|
|
|
|
|
+ return res.json() as Promise<ApiListResponse>
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function fetchPositionDetail(address: string): Promise<ApiDetailResponse> {
|
|
|
|
|
+ const url = `${DETAIL_API}?address=${encodeURIComponent(address)}`
|
|
|
|
|
+ const res = await fetch(url)
|
|
|
|
|
+ return res.json() as Promise<ApiDetailResponse>
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface LpCloseApiResponse {
|
|
|
|
|
+ success: boolean
|
|
|
|
|
+ txid?: string
|
|
|
|
|
+ [key: string]: unknown
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function callLpCloseApi(
|
|
|
|
|
+ apiUrl: string,
|
|
|
|
|
+ auth: string,
|
|
|
|
|
+ nftMintAddress: string
|
|
|
|
|
+): Promise<LpCloseApiResponse> {
|
|
|
|
|
+ const res = await fetch(apiUrl, {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ body: JSON.stringify({ nftMintAddress }),
|
|
|
|
|
+ })
|
|
|
|
|
+ return res.json() as Promise<LpCloseApiResponse>
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function sendDiscordCloseResultNotification(
|
|
|
|
|
+ webhookUrl: string,
|
|
|
|
|
+ childPositionAddress: string,
|
|
|
|
|
+ parentPositionAddress: string,
|
|
|
|
|
+ success: boolean,
|
|
|
|
|
+ txid?: string
|
|
|
|
|
+): Promise<void> {
|
|
|
|
|
+ const embed = {
|
|
|
|
|
+ title: success ? '✅ 子仓位关闭成功' : '❌ 子仓位关闭失败',
|
|
|
|
|
+ description: success
|
|
|
|
|
+ ? '父仓位已关闭,已通过接口关闭子仓位。'
|
|
|
|
|
+ : '父仓位已关闭,但调用关闭接口失败,请手动处理子仓位。',
|
|
|
|
|
+ color: success ? 0x00ff00 : 0xff0000,
|
|
|
|
|
+ fields: [
|
|
|
|
|
+ {
|
|
|
|
|
+ name: '📍 子仓位地址',
|
|
|
|
|
+ value: `[${childPositionAddress.slice(0, 8)}...${childPositionAddress.slice(-8)}](https://solscan.io/account/${childPositionAddress})`,
|
|
|
|
|
+ inline: false,
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ name: '📍 父仓位地址',
|
|
|
|
|
+ value: `[${parentPositionAddress.slice(0, 8)}...${parentPositionAddress.slice(-8)}](https://solscan.io/account/${parentPositionAddress})`,
|
|
|
|
|
+ inline: false,
|
|
|
|
|
+ },
|
|
|
|
|
+ ...(success && txid
|
|
|
|
|
+ ? [
|
|
|
|
|
+ {
|
|
|
|
|
+ name: '交易 ID',
|
|
|
|
|
+ value: `[${txid.slice(0, 16)}...](https://solscan.io/tx/${txid})`,
|
|
|
|
|
+ inline: false,
|
|
|
|
|
+ },
|
|
|
|
|
+ ]
|
|
|
|
|
+ : []),
|
|
|
|
|
+ ],
|
|
|
|
|
+ timestamp: new Date().toISOString(),
|
|
|
|
|
+ footer: { text: 'ByReal Auto Trading' },
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ await fetch(webhookUrl, {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
+ body: JSON.stringify({ embeds: [embed] }),
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 获取全部 LP 仓位(分页),检查每个仓位的 bonusInfo.fromCreatorPosition 对应父仓位是否已关闭,
|
|
|
|
|
+ * 若已关闭则调用 lp-close 接口关闭子仓位,并根据接口结果发送 Discord 通知。
|
|
|
|
|
+ */
|
|
|
|
|
+export async function checkParentPositionsClosed(): Promise<void> {
|
|
|
|
|
+ const cfg = loadConfig()
|
|
|
|
|
+ const closeCfg = cfg.closePosition
|
|
|
|
|
+ if (!closeCfg) {
|
|
|
|
|
+ console.log('[closePosition] 未配置 closePosition,跳过')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const { userAddress, discordWebhookUrl, pageSize, lpCloseApiUrl, lpCloseAuth } = closeCfg
|
|
|
|
|
+ const PAGE_SIZE = pageSize ?? 50
|
|
|
|
|
+ let page = 1
|
|
|
|
|
+ let total = 0
|
|
|
|
|
+ const parentAddressesByChild = new Map<string, string>() // childAddress -> parentAddress
|
|
|
|
|
+
|
|
|
|
|
+ do {
|
|
|
|
|
+ const listRes = await fetchPositionList(userAddress, page, PAGE_SIZE)
|
|
|
|
|
+ const data = listRes?.result?.data
|
|
|
|
|
+ const list = data?.positions ?? []
|
|
|
|
|
+ const totalFromApi = data?.total ?? 0
|
|
|
|
|
+ if (page === 1) total = totalFromApi
|
|
|
|
|
+
|
|
|
|
|
+ for (const item of list) {
|
|
|
|
|
+ const childDetailRes = await fetchPositionDetail(item.positionAddress ?? '')
|
|
|
|
|
+ const childDetail = childDetailRes?.result?.data as PositionDetailData
|
|
|
|
|
+ if (!childDetailRes?.result?.success || childDetail == null) {
|
|
|
|
|
+ console.warn(`[closePosition] 获取子仓位详情失败: ${item.address ?? item.positionAddress ?? ''}`)
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ const childAddress = childDetail.positionAddress ?? ''
|
|
|
|
|
+ if (childDetail.bonusInfo?.fromCreatorPosition) {
|
|
|
|
|
+ parentAddressesByChild.set(childAddress, (childDetail.bonusInfo?.fromCreatorPosition as string) ?? '')
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (list.length < PAGE_SIZE || list.length === 0) break
|
|
|
|
|
+ page++
|
|
|
|
|
+ } while (page * PAGE_SIZE < total)
|
|
|
|
|
+
|
|
|
|
|
+ const parentAddresses = [...new Set(parentAddressesByChild.values())]
|
|
|
|
|
+ console.log(`[closePosition] 共 ${parentAddressesByChild.size} 条仓位含父仓位,去重后 ${parentAddresses.length} 个父仓位待检查`)
|
|
|
|
|
+
|
|
|
|
|
+ for (const [childAddress, parentAddress] of parentAddressesByChild) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const detailRes = await fetchPositionDetail(parentAddress)
|
|
|
|
|
+ const detail = detailRes?.result?.data
|
|
|
|
|
+ if (!detailRes?.result?.success || detail == null) {
|
|
|
|
|
+ console.warn(`[closePosition] 获取父仓位详情失败: ${parentAddress}`)
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ if (isClosedStatus(detail.status)) {
|
|
|
|
|
+ console.log(`[closePosition] 父仓位已关闭: ${parentAddress},调用接口关闭子仓位`)
|
|
|
|
|
+ try {
|
|
|
|
|
+ const closeRes = await callLpCloseApi(lpCloseApiUrl, lpCloseAuth, childAddress)
|
|
|
|
|
+ await sendDiscordCloseResultNotification(
|
|
|
|
|
+ discordWebhookUrl,
|
|
|
|
|
+ childAddress,
|
|
|
|
|
+ parentAddress,
|
|
|
|
|
+ !!closeRes?.success,
|
|
|
|
|
+ closeRes?.txid
|
|
|
|
|
+ )
|
|
|
|
|
+ if (closeRes?.success) {
|
|
|
|
|
+ console.log(`[closePosition] 子仓位 ${childAddress} 关闭成功, txid: ${closeRes.txid ?? '-'}`)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ console.warn(`[closePosition] 子仓位 ${childAddress} 关闭失败`)
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ console.error('[closePosition] 调用关闭接口异常:', e)
|
|
|
|
|
+ await sendDiscordCloseResultNotification(
|
|
|
|
|
+ discordWebhookUrl,
|
|
|
|
|
+ childAddress,
|
|
|
|
|
+ parentAddress,
|
|
|
|
|
+ false
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ console.error(`[closePosition] 检查父仓位 ${parentAddress} 异常:`, e)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const INTERVAL_MS = 60 * 60 * 1000 // 每 60 分钟
|
|
|
|
|
+
|
|
|
|
|
+function run(): void {
|
|
|
|
|
+ checkParentPositionsClosed().catch(console.error)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+run()
|
|
|
|
|
+setInterval(run, INTERVAL_MS)
|
|
|
|
|
+console.log('[closePosition] 已启动,每', INTERVAL_MS / 60000, '分钟检查一次')
|