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 { 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 } async function fetchPositionDetail(address: string): Promise { const url = `${DETAIL_API}?address=${encodeURIComponent(address)}` const res = await fetch(url) return res.json() as Promise } interface LpCloseApiResponse { success: boolean txid?: string [key: string]: unknown } async function callLpCloseApi( apiUrl: string, auth: string, nftMintAddress: string ): Promise { const res = await fetch(apiUrl, { method: 'POST', body: JSON.stringify({ nftMintAddress }), }) return res.json() as Promise } async function sendDiscordCloseResultNotification( webhookUrl: string, childPositionAddress: string, parentPositionAddress: string, success: boolean, txid?: string ): Promise { 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 { 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() // 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, '分钟检查一次')