瀏覽代碼

添加 git

zhangchunrui 2 周之前
父節點
當前提交
6c466618ba
共有 14 個文件被更改,包括 564 次插入294 次删除
  1. 39 0
      .dockerignore
  2. 1 0
      .gitignore
  3. 44 0
      Dockerfile
  4. 16 5
      config.example.json
  5. 22 5
      config.json
  6. 1 1
      eslint.config.js
  7. 2 1
      package.json
  8. 0 76
      src/chain/chain.ts
  9. 14 23
      src/config.ts
  10. 162 0
      src/copy/index.ts
  11. 74 0
      src/discord/index.ts
  12. 54 55
      src/index.ts
  13. 135 67
      src/solana/openPositionListener.ts
  14. 0 61
      src/solana/programAccountListener.ts

+ 39 - 0
.dockerignore

@@ -0,0 +1,39 @@
+# 依赖
+node_modules
+.pnpm-store
+
+# 构建产物
+dist
+
+# 开发工具
+.vscode
+.idea
+*.swp
+*.swo
+*~
+
+# 日志
+*.log
+npm-debug.log*
+pnpm-debug.log*
+
+# 环境变量
+.env
+.env.local
+.env.*.local
+
+# Git
+.git
+.gitignore
+
+# 文档
+README.md
+*.md
+
+# 测试
+coverage
+.nyc_output
+
+# 其他
+.DS_Store
+Thumbs.db

+ 1 - 0
.gitignore

@@ -5,6 +5,7 @@ node_modules
 # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
 
 # dependencies
+dist/
 /node_modules
 /.pnp
 .pnp.*

+ 44 - 0
Dockerfile

@@ -0,0 +1,44 @@
+# 使用 Node.js LTS 版本作为基础镜像
+FROM node:20-alpine AS builder
+
+# 安装 pnpm
+RUN npm install -g pnpm
+
+# 设置工作目录
+WORKDIR /app
+
+# 复制包管理文件
+COPY package.json pnpm-lock.yaml ./
+
+# 安装依赖
+RUN pnpm install --frozen-lockfile
+
+# 复制源代码和配置文件
+COPY tsconfig.json ./
+COPY src ./src
+COPY config.json ./
+
+# 构建项目
+RUN pnpm build
+
+# 生产阶段
+FROM node:20-alpine
+
+# 安装 pnpm
+RUN npm install -g pnpm
+
+# 设置工作目录
+WORKDIR /app
+
+# 从构建阶段复制依赖和构建产物
+COPY --from=builder /app/package.json ./
+COPY --from=builder /app/pnpm-lock.yaml ./
+COPY --from=builder /app/node_modules ./node_modules
+COPY --from=builder /app/dist ./dist
+COPY --from=builder /app/config.json ./
+
+# 暴露端口(如果需要)
+# EXPOSE 3000
+
+# 运行应用
+CMD ["pnpm", "start"]

+ 16 - 5
config.example.json

@@ -1,12 +1,23 @@
 {
-  "rpcHttp": "https://api.mainnet-beta.solana.com",
-  "rpcWs": "wss://api.mainnet-beta.solana.com",
-  "dexProgramIds": ["RVKd61ztZW9m8qKz3S5x3oZ3ZbP2Qd1Yj8pBq1q1q1q"],
-  "accountDataSize": null,
+  "rpcHttp": "https://lb.drpc.live/solana/AhGsqYdgvUCHlgOaTEXNInthrW6W6XYR8JeA_qr8MPTs",
+  "rpcWs": "wss://lb.drpc.live/solana/AhGsqYdgvUCHlgOaTEXNInthrW6W6XYR8JeA_qr8MPTs",
   "openPosition": {
     "enabled": true,
     "programId": "REALQqNEomY6cQGZJUGwywTBD2UmDT32rZcNnfxQ5N2",
     "logIncludes": ["Instruction: OpenPositionWithToken22Nft"]
+  },
+  "positionCopy": {
+    "enabled": true,
+    "config": {
+      "WhiteWhale/USDC": {
+        "minimumDeposit": 100,
+        "copyMount": {
+          "1000": 3,
+          "500": 1,
+          "100": 0.3
+        }
+      }
+    },
+    "url": "http://91.108.80.73/api/lp-copy"
   }
 }
-

+ 22 - 5
config.json

@@ -1,14 +1,31 @@
 {
   "rpcHttp": "https://lb.drpc.live/solana/AhGsqYdgvUCHlgOaTEXNInthrW6W6XYR8JeA_qr8MPTs",
   "rpcWs": "wss://lb.drpc.live/solana/AhGsqYdgvUCHlgOaTEXNInthrW6W6XYR8JeA_qr8MPTs",
-  "dexProgramIds": [
-    "RVKd61ztZW9m8qKz3S5x3oZ3ZbP2Qd1Yj8pBq1q1q1q"
-  ],
-  "accountDataSize": null,
   "openPosition": {
     "enabled": true,
     "programId": "REALQqNEomY6cQGZJUGwywTBD2UmDT32rZcNnfxQ5N2",
     "logIncludes": ["Instruction: OpenPositionWithToken22Nft"]
+  },
+  "positionCopy": {
+    "enabled": true,
+    "config": {
+      "WhiteWhale/USDC": {
+        "minimumDeposit": 100,
+        "copyMount": {
+          "1000": 3,
+          "500": 1,
+          "100": 0.3
+        }
+      },
+      "GAS/USDC": {
+        "minimumDeposit": 50,
+        "copyMount": {
+          "1000": 2,
+          "500": 1,
+          "50": 0.1
+        }
+      }
+    },
+    "url": "http://91.108.80.73/api/lp-copy"
   }
 }
-

+ 1 - 1
eslint.config.js

@@ -20,7 +20,7 @@ export default [
 
       semi: ["error", "never"],
 
-      indent: ["error", "tab", { SwitchCase: 1 }]
+      indent: ["error", "tab", { SwitchCase: 2 }]
     }
   }
 ];

+ 2 - 1
package.json

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

+ 0 - 76
src/chain/chain.ts

@@ -1,76 +0,0 @@
-import { Connection, PublicKey, type GetProgramAccountsFilter } from '@solana/web3.js'
-export type PositionInfo = {
-	positionAccount: string
-	nftMint: string
-	dataBase64: string
-	tickLower?: number
-	tickUpper?: number
-}
-
-export type PositionLayout = {
-	accountDataSize?: number
-	nftMintOffset: number
-	tickLowerOffset?: number
-	tickUpperOffset?: number
-}
-
-function readI32LE(buf: Buffer, offset: number) {
-	return buf.readInt32LE(offset)
-}
-
-export class Chain {
-	constructor(
-		private readonly conn: Connection,
-		private readonly clmmProgramId: PublicKey,
-		private readonly positionLayout?: PositionLayout
-	) {}
-
-	/**
-	 * 通过 position NFT mint 反查 position 账户。
-	 * 说明:这里依赖 position 账户布局(至少需要 nftMintOffset),否则无法在链上高效定位。
-	 */
-	async getPositionInfoByNftMint(nftMint: string): Promise<PositionInfo | null> {
-		if (!this.positionLayout) return null
-
-		const filters: GetProgramAccountsFilter[] = [
-			{
-				memcmp: {
-					offset: this.positionLayout.nftMintOffset,
-					bytes: nftMint // web3.js 支持 base58 pubkey 直接作为 bytes
-				}
-			}
-		]
-		if (this.positionLayout.accountDataSize) {
-			filters.unshift({ dataSize: this.positionLayout.accountDataSize })
-		}
-
-		const accts = await this.conn.getProgramAccounts(this.clmmProgramId, {
-			commitment: 'confirmed',
-			filters
-		})
-		if (!accts.length) return null
-
-		// 一般应该唯一;若不唯一,取第一个并把其余交给上层再处理
-		const acct = accts[0]
-		const dataBuf = Buffer.from(acct.account.data)
-		const dataBase64 = dataBuf.toString('base64')
-
-		const tickLower =
-			typeof this.positionLayout.tickLowerOffset === 'number'
-				? readI32LE(dataBuf, this.positionLayout.tickLowerOffset)
-				: undefined
-		const tickUpper =
-			typeof this.positionLayout.tickUpperOffset === 'number'
-				? readI32LE(dataBuf, this.positionLayout.tickUpperOffset)
-				: undefined
-
-		return {
-			positionAccount: acct.pubkey.toBase58(),
-			nftMint,
-			dataBase64,
-			tickLower,
-			tickUpper
-		}
-	}
-}
-

+ 14 - 23
src/config.ts

@@ -5,28 +5,19 @@ import { z } from 'zod'
 const ConfigSchema = z.object({
 	rpcHttp: z.string().url(),
 	rpcWs: z.string().url(),
-	dexProgramIds: z.array(z.string().min(32)),
-	accountDataSize: z.number().int().positive().nullable().optional(),
-	openPosition: z
-		.object({
-			enabled: z.boolean().default(false),
-			programId: z.string().min(32),
-			logIncludes: z.array(z.string().min(1)).default([]),
-
-			// 可选:用于通过 position NFT mint 反查 position 账户 & 解码 tick 等字段
-			positionLayout: z
-				.object({
-					// position 账户 dataSize(不确定可不填)
-					accountDataSize: z.number().int().positive().optional(),
-					// position 账户里 nftMint 的偏移(memcmp 过滤用)
-					nftMintOffset: z.number().int().nonnegative(),
-					// 可选:tickLower/tickUpper 偏移(i32 little-endian)
-					tickLowerOffset: z.number().int().nonnegative().optional(),
-					tickUpperOffset: z.number().int().nonnegative().optional()
-				})
-				.optional()
-		})
-		.optional()
+	positionCopy: z.object({
+		enabled: z.boolean().default(false),
+		config: z.record(z.string().min(1), z.object({
+			minimumDeposit: z.number().min(0),
+			copyMount: z.record(z.string().min(1), z.number().min(0))
+		})),
+		url: z.string().url()
+	}),
+	openPosition: z.object({
+		enabled: z.boolean().default(false),
+		programId: z.string().min(32),
+		logIncludes: z.array(z.string().min(1)).default([])
+	})
 })
 
 export type AppConfig = z.infer<typeof ConfigSchema>;
@@ -36,7 +27,7 @@ export function loadConfig(): AppConfig {
 	if (!fs.existsSync(configPath)) {
 		throw new Error(
 			`找不到配置文件:${configPath}\n` +
-        '请复制 config.example.json 为 config.json 并填写你的 rpc/ws 与 dexProgramIds'
+        '请复制 config.example.json 为 config.json 并填写你的 rpc/ws 与 openPosition 配置'
 		)
 	}
 	const raw = fs.readFileSync(configPath, 'utf-8')

+ 162 - 0
src/copy/index.ts

@@ -0,0 +1,162 @@
+import { loadConfig } from '../config.js'
+import type { OpenPositionEvent } from '../solana/openPositionListener.js'
+
+const cfg = loadConfig()
+
+export async function copyPosition(positionDetails: OpenPositionEvent['positionDetails']) {
+	if (!positionDetails) return
+
+	const mintA = positionDetails?.mintA || undefined
+	const mintB = positionDetails?.mintB || undefined
+	const mintASymbol = mintA?.symbol || undefined
+	const mintBSymbol = mintB?.symbol || undefined
+	const totalDeposit = positionDetails?.totalDeposit || undefined
+	// const upperTick = positionDetails?.upperTick || undefined
+	// const lowerTick = positionDetails?.lowerTick || undefined
+	const positionAddress = positionDetails?.positionAddress || ''
+	const providerAddress = positionDetails?.providerAddress || ''
+	const nftMintAddress = positionDetails?.nftMintAddress || ''
+	const copyConfig = cfg?.positionCopy?.config?.[`${mintASymbol}/${mintBSymbol}`] || undefined
+
+	let maxUsdValue = 0.2
+
+	if (!copyConfig) {
+		return 'no copy config'
+	}
+	const minimumDeposit = copyConfig?.minimumDeposit || 0
+
+	if (Number(totalDeposit) < minimumDeposit) {
+		return
+	}
+	if (Number(totalDeposit) > 1000) {
+		maxUsdValue = copyConfig?.copyMount?.[1000] || 2
+	} else if (Number(totalDeposit) > 500) {
+		maxUsdValue = copyConfig?.copyMount?.[500] || 1
+	} else if (Number(totalDeposit) > minimumDeposit) {
+		maxUsdValue = copyConfig?.copyMount?.[minimumDeposit] || 0.2
+	}
+
+	console.log(`[${Date.now()}] 复制仓位: ${providerAddress.slice(0, 4)}...${providerAddress.slice(-4)} ${mintASymbol}/${mintBSymbol} ${maxUsdValue}$`)
+
+	return fetch(cfg?.positionCopy?.url, {
+		method: 'POST',
+		headers: {
+			'Content-Type': 'application/json',
+			'Authorization': 'Basic YWRtaW46YzU4ODk5Njc='
+		},
+		body: JSON.stringify({
+			nftMintAddress,
+			positionAddress,
+			maxUsdValue,
+		}),
+	}).then(res => res.json()).then(() => {
+		console.log(`[${Date.now()}] 复制仓位成功`)
+		// 发送Discord通知,使用embeds
+		const solscanBaseUrl = 'https://solscan.io'
+		const embed = {
+			title: '✅ 仓位复制成功',
+			description: `成功复制仓位:**${mintASymbol}/${mintBSymbol}**`,
+			color: 0x00ff00, // 绿色
+			fields: [
+				{
+					name: '💰 币对',
+					value: `${mintASymbol}/${mintBSymbol}`,
+					inline: true
+				},
+				{
+					name: '💵 总存款',
+					value: `$${Number(totalDeposit).toFixed(2)}`,
+					inline: true
+				},
+				{
+					name: '📊 复制金额',
+					value: `$${maxUsdValue.toFixed(2)}`,
+					inline: true
+				},
+				{
+					name: '👤 提供者地址',
+					value: `[${providerAddress.slice(0, 4)}...${providerAddress.slice(-4)}](${solscanBaseUrl}/account/${providerAddress})`,
+					inline: false
+				},
+				{
+					name: '📍 仓位地址',
+					value: `[${positionAddress.slice(0, 8)}...${positionAddress.slice(-8)}](${solscanBaseUrl}/account/${positionAddress})`,
+					inline: false
+				},
+				{
+					name: '🎨 NFT Mint',
+					value: `[${nftMintAddress.slice(0, 8)}...${nftMintAddress.slice(-8)}](${solscanBaseUrl}/token/${nftMintAddress})`,
+					inline: false
+				}
+			],
+			timestamp: new Date().toISOString(),
+			footer: {
+				text: 'ByReal Auto Trading'
+			}
+		}
+
+		// 如果有价格信息,添加到字段中
+		if (mintA?.price && mintB?.price) {
+			embed.fields.push({
+				name: '💹 价格信息',
+				value: `${mintASymbol}: $${Number(mintA.price).toFixed(6)}\n${mintBSymbol}: $${Number(mintB.price).toFixed(6)}`,
+				inline: false
+			})
+		}
+
+		return fetch('https://discord.com/api/webhooks/1457714616636280978/YFMGaZEj2gJwUjINpFfJIkagG1I3SLZRwz9bGpc2OlGFWBVa88r73cMIkBpX3iGpSIjV', {
+			method: 'POST',
+			headers: {
+				'Content-Type': 'application/json',
+			},
+			body: JSON.stringify({
+				embeds: [embed]
+			}),
+		})
+	}).catch(err => {
+		// 发送失败通知
+		const errorEmbed = {
+			title: '❌ 仓位复制失败',
+			description: `复制仓位失败:**${mintASymbol}/${mintBSymbol}**`,
+			color: 0xff0000, // 红色
+			fields: [
+				{
+					name: '💰 币对',
+					value: `${mintASymbol}/${mintBSymbol}`,
+					inline: true
+				},
+				{
+					name: '💵 总存款',
+					value: `$${Number(totalDeposit).toFixed(2)}`,
+					inline: true
+				},
+				{
+					name: '📍 仓位地址',
+					value: `[${positionAddress.slice(0, 8)}...${positionAddress.slice(-8)}](https://solscan.io/account/${positionAddress})`,
+					inline: false
+				},
+				{
+					name: '⚠️ 错误信息',
+					value: `\`\`\`${err instanceof Error ? err.message : String(err)}\`\`\``,
+					inline: false
+				}
+			],
+			timestamp: new Date().toISOString(),
+			footer: {
+				text: 'ByReal Auto Trading'
+			}
+		}
+
+		return fetch('https://discord.com/api/webhooks/1457714616636280978/YFMGaZEj2gJwUjINpFfJIkagG1I3SLZRwz9bGpc2OlGFWBVa88r73cMIkBpX3iGpSIjV', {
+			method: 'POST',
+			headers: {
+				'Content-Type': 'application/json',
+			},
+			body: JSON.stringify({
+				embeds: [errorEmbed]
+			}),
+		}).catch(notifyErr => {
+			console.error(`[${Date.now()}] 发送Discord通知失败: ${notifyErr}`)
+		})
+	})
+}

+ 74 - 0
src/discord/index.ts

@@ -0,0 +1,74 @@
+import type { OpenPositionEvent } from '../solana/openPositionListener.js'
+
+export async function sendDiscordMessage(positionDetails: OpenPositionEvent['positionDetails']) {
+	if (!positionDetails) {
+		return
+	}
+
+	// 检查金额是否大于 100 美元
+	const totalDeposit = Number(positionDetails.totalDeposit || 0)
+	if (totalDeposit <= 100) {
+		return // 金额小于等于 100 美元,不发送通知
+	}
+
+	const mintA = positionDetails.mintA
+	const mintB = positionDetails.mintB
+	const mintASymbol = mintA?.symbol || 'Unknown'
+	const mintBSymbol = mintB?.symbol || 'Unknown'
+	const tokenPair = `${mintASymbol}/${mintBSymbol}`
+	const providerAddress = positionDetails.providerAddress
+	const positionAddress = positionDetails.positionAddress
+
+	const embed = {
+		title: '🆕 新仓位发现',
+		color: 0x00b098, // 绿色
+		fields: [
+			{
+				name: '创建地址',
+				value: `[${providerAddress.slice(0, 8)}...${providerAddress.slice(-8)}](https://solscan.io/account/${providerAddress})`,
+				inline: true,
+			},
+			{
+				name: '仓位地址',
+				value: `[${positionAddress.slice(0, 8)}...${positionAddress.slice(-8)}](https://solscan.io/account/${positionAddress})`,
+				inline: false,
+			},
+			{
+				name: '交易对',
+				value: `\`${tokenPair}\``,
+				inline: true,
+			},
+			{
+				name: '总存款',
+				value: `$${totalDeposit.toFixed(2)}`,
+				inline: true,
+			},
+		],
+		timestamp: new Date().toISOString(),
+		url: `https://www.byreal.io/en/portfolio?userAddress=${providerAddress}&tab=current&positionAddress=${positionAddress}`,
+		footer: {
+			text: 'ByReal Auto Trading'
+		}
+	}
+
+	// 添加价格信息
+	if (mintA?.price && mintB?.price) {
+		embed.fields.push({
+			name: '💹 价格信息',
+			value: `${mintASymbol}: $${Number(mintA.price).toFixed(6)}\n${mintBSymbol}: $${Number(mintB.price).toFixed(6)}`,
+			inline: false
+		})
+	}
+
+	return fetch('https://discord.com/api/webhooks/1457714616636280978/YFMGaZEj2gJwUjINpFfJIkagG1I3SLZRwz9bGpc2OlGFWBVa88r73cMIkBpX3iGpSIjV', {
+		method: 'POST',
+		headers: {
+			'Content-Type': 'application/json',
+		},
+		body: JSON.stringify({
+			embeds: [embed]
+		})
+	}).catch(err => {
+		console.error(`[${Date.now()}] 发送Discord通知失败: ${err}`)
+	})
+}

+ 54 - 55
src/index.ts

@@ -1,8 +1,8 @@
-import { Connection, PublicKey } from '@solana/web3.js'
+import { Connection } from '@solana/web3.js'
 import { loadConfig } from './config.js'
-import { listenProgramAccounts } from './solana/programAccountListener.js'
 import { listenOpenPosition } from './solana/openPositionListener.js'
-import { Chain } from './chain/chain.js'
+import { copyPosition } from './copy/index.js'
+import { sendDiscordMessage } from './discord/index.js'
 
 function nowIso() {
 	return new Date().toISOString()
@@ -15,69 +15,69 @@ async function main() {
 		wsEndpoint: cfg.rpcWs
 	})
 
+	const openCfg = cfg.openPosition
+	if (!openCfg?.enabled) {
+		console.error(`[${nowIso()}] openPosition 未启用,请检查配置`)
+		process.exit(1)
+	}
+
 	console.log(`[${nowIso()}] 连接 Solana RPC`)
 	console.log(`- http: ${cfg.rpcHttp}`)
 	console.log(`- ws:   ${cfg.rpcWs}`)
-	console.log(`- 监听 programIds: ${cfg.dexProgramIds.join(', ')}`)
-	if (typeof cfg.accountDataSize === 'number') {
-		console.log(`- 过滤 dataSize: ${cfg.accountDataSize}`)
+	console.log(`- 监听 programId: ${openCfg.programId}`)
+	if (openCfg.logIncludes && openCfg.logIncludes.length > 0) {
+		console.log(`- logIncludes: ${openCfg.logIncludes.join(', ')}`)
 	}
 
-	const listener = listenProgramAccounts(
+	const openListener = listenOpenPosition(
 		conn,
-		{ programIds: cfg.dexProgramIds, dataSize: cfg.accountDataSize ?? null, commitment: 'confirmed' },
+		{
+			programId: openCfg.programId,
+			commitment: 'confirmed',
+			logIncludes: openCfg.logIncludes ?? [],
+			maxSupportedTransactionVersion: 0
+		},
 		(ev) => {
-			// 这里只做“LP相关账户变更”的底层事件输出:后续可按具体 DEX 的 pool layout 再解码成 tokenA/tokenB/LP mint 等字段
-			console.log(
-				JSON.stringify(
-					{
-						ts: nowIso(),
-						kind: 'program_account_change',
-						...ev
-					},
-					null,
-					0
-				)
-			)
+			// 打印详细日志,包括币对和价格等
+			const positionDetails = ev.positionDetails
+			if (!positionDetails) {
+				return
+			}
+			const mintA = positionDetails?.mintA || undefined
+			const mintB = positionDetails?.mintB || undefined
+			const mintASymbol = mintA?.symbol || undefined
+			const mintBSymbol = mintB?.symbol || undefined
+			const mintAPrice = mintA?.price || undefined
+			const mintBPrice = mintB?.price || undefined
+			
+			// 发送 Discord 通知(金额大于 100 美元)
+			sendDiscordMessage(positionDetails).catch(err => {
+				console.error(`[${Date.now()}] 发送Discord通知失败: ${err}`)
+			})
+			
+			copyPosition(positionDetails).then(res => {
+				if (res === 'no copy config') {
+					console.log('无复制配置,跳过复制仓位', res)
+				}
+			}).catch(err => {
+				console.error(`[${Date.now()}] 复制仓位失败: ${err}`)
+			})
+			console.log(`发现新仓位: ${JSON.stringify({
+				ts: nowIso(),
+				positionAddress: positionDetails?.positionAddress || undefined,
+				providerAddress: positionDetails?.providerAddress || undefined,
+				totalDeposit: positionDetails?.totalDeposit || undefined,
+				tokenPair: `${mintASymbol}/${mintBSymbol}`,
+				mintAPrice,
+				mintBPrice,
+			}, null, 2)}`)
+			// 
 		}
 	)
 
-	const openCfg = cfg.openPosition
-	const chain =
-		openCfg?.enabled === true && openCfg.positionLayout
-			? new Chain(conn, new PublicKey(openCfg.programId), openCfg.positionLayout)
-			: null
-	const openListener =
-		openCfg?.enabled === true
-			? listenOpenPosition(
-				conn,
-				{
-					programId: openCfg.programId,
-					commitment: 'confirmed',
-					logIncludes: openCfg.logIncludes ?? [],
-					maxSupportedTransactionVersion: 0,
-					getPositionInfoByNftMint: chain ? chain.getPositionInfoByNftMint.bind(chain) : undefined
-				},
-				(ev) => {
-					console.log(
-						JSON.stringify(
-							{
-								ts: nowIso(),
-								kind: 'open_position',
-								...ev
-							},
-							null,
-							0
-						)
-					)
-				}
-			)
-			: null
-
 	const shutdown = async (sig: string) => {
 		console.log(`[${nowIso()}] 收到 ${sig},正在取消订阅...`)
-		await listener.close()
-		if (openListener) await openListener.close()
+		await openListener.close()
 		process.exit(0)
 	}
 	process.on('SIGINT', () => void shutdown('SIGINT'))
@@ -88,4 +88,3 @@ main().catch((e) => {
 	console.error(e)
 	process.exit(1)
 })
-

+ 135 - 67
src/solana/openPositionListener.ts

@@ -3,7 +3,6 @@ import {
 	PublicKey,
 	type Finality,
 	type LogsCallback,
-	type ParsedInstruction,
 	type TransactionSignature
 } from '@solana/web3.js'
 
@@ -12,17 +11,30 @@ export type OpenPositionEvent = {
 	slot: number
 	blockTime?: number | null
 	programId: string
-	logs: string[]
 	accounts?: string[]
 	tokenMints?: string[]
 	positionAccount?: string
-	positionAccountDataBase64?: string
-	positionFromNft?: {
-		nftMint: string
-		positionAccount: string
-		dataBase64: string
-		tickLower?: number
-		tickUpper?: number
+	logs?: string[] // 添加原始日志
+	positionDetails?: {
+		positionAddress: string
+		providerAddress: string
+		nftMintAddress: string
+		poolAddress: string
+		mintA: {
+			address: string
+			symbol: string
+			decimals: number
+			price: string
+		}
+		mintB: {
+			address: string
+			symbol: string
+			decimals: number
+			price: string
+		}
+		totalDeposit: string
+		upperTick: number
+		lowerTick: number
 	}
 }
 
@@ -31,12 +43,37 @@ export type OpenPositionListenerOpts = {
 	commitment?: 'processed' | 'confirmed' | 'finalized'
 	logIncludes?: string[]
 	maxSupportedTransactionVersion?: number
-	getPositionInfoByNftMint?: (nftMint: string) => Promise<{
-		positionAccount: string
-		dataBase64: string
-		tickLower?: number
-		tickUpper?: number
-	} | null>
+}
+
+type PositionApiResponse = {
+	retCode: number
+	retMsg: string
+	result?: {
+		success?: boolean
+		data?: {
+			positionAddress: string
+			providerAddress: string
+			nftMintAddress: string
+			pool: {
+				poolAddress: string
+				mintA: {
+					address: string
+					symbol: string
+					decimals: number
+					price: string
+				}
+				mintB: {
+					address: string
+					symbol: string
+					decimals: number
+					price: string
+				}
+			}
+			totalDeposit: string
+			upperTick: number
+			lowerTick: number
+		}
+	}
 }
 
 export function listenOpenPosition(
@@ -49,6 +86,57 @@ export function listenOpenPosition(
 	const programKey = new PublicKey(opts.programId)
 	const logIncludes = opts.logIncludes ?? []
 
+	// 通过 API 获取 position 详细信息
+	async function fetchPositionDetails(
+		positionAddress: string
+	): Promise<OpenPositionEvent['positionDetails'] | undefined> {
+		try {
+			const url = `https://api2.byreal.io/byreal/api/dex/v2/position/detail?address=${positionAddress}`
+			const response = await fetch(url)
+			if (!response.ok) {
+				return undefined
+			}
+
+			const data = (await response.json()) as PositionApiResponse
+			if (data.retCode !== 0 || !data.result?.data) {
+				return undefined
+			}
+			const positionData = data.result.data
+			if (!positionData) {
+				return undefined
+			}
+
+			const pool = positionData.pool
+			if (!pool) {
+				return undefined
+			}
+
+			return {
+				positionAddress: positionData.positionAddress,
+				providerAddress: positionData.providerAddress,
+				nftMintAddress: positionData.nftMintAddress,
+				poolAddress: pool.poolAddress,
+				mintA: {
+					address: pool.mintA.address,
+					symbol: pool.mintA.symbol,
+					decimals: pool.mintA.decimals,
+					price: pool.mintA.price
+				},
+				mintB: {
+					address: pool.mintB.address,
+					symbol: pool.mintB.symbol,
+					decimals: pool.mintB.decimals,
+					price: pool.mintB.price
+				},
+				totalDeposit: positionData.totalDeposit,
+				upperTick: positionData.upperTick,
+				lowerTick: positionData.lowerTick
+			}
+		} catch {
+			return undefined
+		}
+	}
+
 	const cb: LogsCallback = async (logs, ctx) => {
 		const lines = logs.logs ?? []
 		if (logIncludes.length && !logIncludes.every((k) => lines.some((l) => l.includes(k)))) return
@@ -61,58 +149,42 @@ export function listenOpenPosition(
 
 		const accounts = tx
 			? (() => {
-				const keys = tx.transaction.message.getAccountKeys()
-				const lookup = keys.accountKeysFromLookups
-				const all = [
-					...keys.staticAccountKeys,
-					...(lookup?.writable ?? []),
-					...(lookup?.readonly ?? [])
-				]
-				return all.map((k) => k.toBase58())
+				const keys = tx.transaction.message.staticAccountKeys
+				return keys.map((k) => k.toBase58())
 			})()
 			: undefined
 
-		const tokenMints = tx?.meta?.postTokenBalances?.map((b) => b.mint) ?? tx?.meta?.preTokenBalances?.map((b) => b.mint) ?? []
-		const uniqTokenMints = Array.from(new Set(tokenMints))
-		const nftMint = uniqTokenMints[0]
-
-		const positionFromNft =
-			nftMint && opts.getPositionInfoByNftMint
-				? await opts.getPositionInfoByNftMint(nftMint).then((r) =>
-					r
-						? {
-							nftMint,
-							positionAccount: r.positionAccount,
-							dataBase64: r.dataBase64,
-							tickLower: r.tickLower,
-							tickUpper: r.tickUpper
-						}
-						: undefined
-				)
-				: undefined
-
-		// 从 innerInstructions 里找出本次交易新建的 position 账户(owner=programId)
-		const positionAccount = (() => {
-			const inners = tx?.meta?.innerInstructions ?? []
-			for (const inner of inners) {
-				for (const ix of inner.instructions) {
-					const pix = ix as Partial<ParsedInstruction>
-					if (!pix.parsed || pix.program !== 'system') continue
-					const parsedAny = pix.parsed as unknown
-					if (typeof parsedAny !== 'object' || parsedAny === null) continue
-					const p = parsedAny as { type?: unknown; info?: unknown }
-					if (p.type !== 'createAccount') continue
-					const info = p.info as { owner?: unknown; newAccount?: unknown } | undefined
-					if (info?.owner === opts.programId && typeof info?.newAccount === 'string') return info.newAccount
-				}
+		// 从 innerInstructions 中获取 positionAccount
+		// 直接使用第一个 inner instruction 的第一个指令的 accounts[1]
+		let positionAccount: string | undefined
+		let positionAccountIndex: number | undefined
+
+		if (tx?.meta?.innerInstructions?.[0]?.instructions?.[0]?.accounts?.[1] !== undefined && accounts) {
+			const accountIndex = tx.meta.innerInstructions[0].instructions[0].accounts[1]
+			if (typeof accountIndex === 'number' && accounts[accountIndex]) {
+				positionAccount = accounts[accountIndex]
+				positionAccountIndex = accountIndex
 			}
-			return undefined
-		})()
+		}
+
+		// 如果没找到,使用默认索引 7 作为后备
+		if (!positionAccount && accounts && accounts.length > 7) {
+			positionAccount = accounts[6]
+			positionAccountIndex = 6
+		}
+
+		// 如果有 positionAccount,通过 API 获取详细信息
+		// 延迟100秒后获取,因为positionAccount可能还没被写入 
+		await new Promise(resolve => setTimeout(resolve, 100 * 1000))
+
+		const positionDetails = positionAccount ? await fetchPositionDetails(positionAccount) : undefined
+		if (!positionDetails) {
+			console.log('positionAccountIndex', positionAccountIndex)
+			// console.log('找不到地址', JSON.stringify(tx, null, 2))
+			console.log('找不到地址')
+			console.log('accounts', accounts)
+			console.log('tx', logs.signature)
 
-		let positionAccountData: string | undefined
-		if (positionAccount) {
-			const acc = await conn.getAccountInfo(new PublicKey(positionAccount), txFinality)
-			if (acc?.data) positionAccountData = Buffer.from(acc.data).toString('base64')
 		}
 
 		onEvent({
@@ -120,12 +192,8 @@ export function listenOpenPosition(
 			slot: ctx.slot,
 			blockTime: tx?.blockTime ?? null,
 			programId: opts.programId,
-			logs: lines,
-			accounts,
-			tokenMints: uniqTokenMints.length ? uniqTokenMints : undefined,
 			positionAccount,
-			positionAccountDataBase64: positionAccountData,
-			positionFromNft
+			positionDetails
 		})
 	}
 

+ 0 - 61
src/solana/programAccountListener.ts

@@ -1,61 +0,0 @@
-import { Connection, PublicKey } from '@solana/web3.js'
-
-export type ProgramAccountChange = {
-  programId: string;
-  pubkey: string;
-  slot: number;
-  lamports: number;
-  owner: string;
-  executable: boolean;
-  rentEpoch?: number;
-  dataBase64: string;
-};
-
-export type ProgramAccountListenerOpts = {
-  programIds: string[];
-  commitment?: 'processed' | 'confirmed' | 'finalized';
-  dataSize?: number | null;
-};
-
-export function listenProgramAccounts(
-	conn: Connection,
-	opts: ProgramAccountListenerOpts,
-	onChange: (ev: ProgramAccountChange) => void
-): { close: () => Promise<void> } {
-	const commitment = opts.commitment ?? 'confirmed'
-	const subIds: number[] = []
-
-	for (const pid of opts.programIds) {
-		const programKey = new PublicKey(pid)
-		const filters =
-      typeof opts.dataSize === 'number' ? [{ dataSize: opts.dataSize }] : []
-
-		const subId = conn.onProgramAccountChange(
-			programKey,
-			(keyedAccountInfo, ctx) => {
-				const ai = keyedAccountInfo.accountInfo
-				const dataBase64 = Buffer.from(ai.data).toString('base64')
-				onChange({
-					programId: pid,
-					pubkey: keyedAccountInfo.accountId.toBase58(),
-					slot: ctx.slot,
-					lamports: ai.lamports,
-					owner: ai.owner.toBase58(),
-					executable: ai.executable,
-					rentEpoch: ai.rentEpoch,
-					dataBase64
-				})
-			},
-			commitment,
-			filters.length ? filters : undefined
-		)
-		subIds.push(subId)
-	}
-
-	return {
-		async close() {
-			await Promise.all(subIds.map((id) => conn.removeProgramAccountChangeListener(id)))
-		}
-	}
-}
-