Selaa lähdekoodia

feat: auto-close

lushdog@outlook.com 5 päivää sitten
vanhempi
commit
fbd46cf682
5 muutettua tiedostoa jossa 253 lisäystä ja 2 poistoa
  1. 5 0
      config.json
  2. 2 1
      package.json
  3. 236 0
      src/closePosition/index.ts
  4. 8 1
      src/config.ts
  5. 2 0
      src/index.ts

+ 5 - 0
config.json

@@ -117,5 +117,10 @@
 			}
 		},
 		"url": "http://91.108.80.73/api/lp-copy"
+	},
+	"closePosition": {
+		"userAddress": "DYjYoq1CCLgqj4qUsHS9rGrkC1XkqS2KDTEH5DT8LoVe",
+		"discordWebhookUrl": "https://discord.com/api/webhooks/1466657706155704431/vuLDyQUlyH9COoq5zRzrbI-mexMZYPJ9Yh_afkxCHKn9gfItIkIE_DtOO-JRp50EtBzy",
+		"pageSize": 50
 	}
 }

+ 2 - 1
package.json

@@ -8,7 +8,8 @@
     "start": "node dist/index.js",
     "build": "tsc -p tsconfig.json",
     "lint": "eslint .",
-    "lint:fix": "eslint . --fix"
+    "lint:fix": "eslint . --fix",
+		"closePosition": "tsx src/closePosition/index.ts"
   },
   "dependencies": {
     "@solana/web3.js": "^1.98.0",

+ 236 - 0
src/closePosition/index.ts

@@ -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, '分钟检查一次')

+ 8 - 1
src/config.ts

@@ -17,7 +17,14 @@ const ConfigSchema = z.object({
 		enabled: z.boolean().default(false),
 		programId: z.string().min(32),
 		logIncludes: z.array(z.string().min(1)).default([])
-	})
+	}),
+	closePosition: z.object({
+		userAddress: z.string().min(32),
+		discordWebhookUrl: z.string().url(),
+		pageSize: z.number().min(1).max(100).default(50),
+		lpCloseApiUrl: z.string().min(1).default('http://91.108.80.73/api/lp-index/lp-close'),
+		lpCloseAuth: z.string().min(1).default('Basic YWRtaW46YzU4ODk5Njc=')
+	}).optional()
 })
 
 export type AppConfig = z.infer<typeof ConfigSchema>;

+ 2 - 0
src/index.ts

@@ -3,6 +3,8 @@ import { loadConfig } from './config.js'
 import { listenOpenPosition } from './solana/openPositionListener.js'
 import { copyPosition } from './copy/index.js'
 import { sendDiscordMessage } from './discord/index.js'
+// 引入后会自动启动:每 60 分钟检查父仓位是否关闭,并调接口关闭子仓位 + Discord 通知
+import './closePosition/index.js'
 
 function nowIso() {
 	return new Date().toISOString()