import { loadConfig, getPrivateKey } from '../config.js' import { JupiterSwapper, type SwapInfo } from '../solana/jupiter.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' interface BonusInfo { fromCreatorPosition?: string [key: string]: unknown } interface PositionListItem { nftMintAddress?: string 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 { nftMintAddress?: string 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', headers: { 'Content-Type': 'application/json', 'Authorization': auth, }, 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] }), }) } async function sendDiscordSwapNotification( webhookUrl: string, closeTxid: string, swaps: SwapInfo[] ): Promise { if (!swaps || swaps.length === 0) return const totalSwappedUsd = swaps.reduce((sum, s) => sum + s.swappedUsd, 0) const fields = [ { name: '📤 关仓交易', value: `[${closeTxid.slice(0, 16)}...](https://solscan.io/tx/${closeTxid})`, inline: false, }, { name: '💰 兑换总额', value: `$${totalSwappedUsd.toFixed(2)} → USDC`, inline: false, }, ] swaps.forEach((swap, index) => { const symbol = swap.symbol || swap.mint.slice(0, 8) fields.push({ name: `🔄 兑换 #${index + 1}`, value: `${symbol}: $${swap.swappedUsd.toFixed(2)}\n[Tx](https://solscan.io/tx/${swap.txSignature})`, inline: true, }) }) const embed = { title: '💱 代币兑换完成', description: `成功将 ${swaps.length} 种代币兑换为 USDC`, color: 0x3498db, fields, 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 nftMintAddress = childDetail.nftMintAddress ?? '' if (childDetail.bonusInfo?.fromCreatorPosition) { parentAddressesByChild.set(nftMintAddress, (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 ?? '-'}`) // 关闭成功后,分析交易并兑换多余代币为USDC const privateKey = getPrivateKey() if (closeRes.txid && privateKey) { try { const swapper = new JupiterSwapper( cfg.rpcHttp, privateKey, closeCfg.jupiterApiKey ) const swapResult = await swapper.analyzeCloseTxAndSwapRemains( closeRes.txid, closeCfg.swapKeepUsdValue ?? 10, closeCfg.swapWhitelist ?? [] ) // 发送兑换结果Discord通知 if (swapResult.swaps && swapResult.swaps.length > 0) { await sendDiscordSwapNotification( discordWebhookUrl, closeRes.txid, swapResult.swaps ) } } catch (swapError) { console.error('[closePosition] Swap分析失败:', swapError) } } } 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, '分钟检查一次')