Преглед на файлове

refactor: 重写 openPositionListener 交易解析,移除 fetchPositionDetails

- 使用 getParsedTransaction + discriminator 精确匹配 open_position 指令
- 通过 token balance 提取 mintA/mintB,替代不稳定的固定索引
- 通过 Jupiter Price API v3 获取价格,Helius DAS API 获取 symbol
- 从程序日志解码 CreatePersonalPositionEvent 计算 totalDeposit
- 移除 100 秒延迟的 fetchPositionDetails API 调用
- 修复 base58/base64 编码问题,修复 async 回调静默失败

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
lushdog@outlook.com преди 1 седмица
родител
ревизия
bde509a8cc
променени са 2 файла, в които са добавени 289 реда и са изтрити 135 реда
  1. 2 1
      src/index.ts
  2. 287 134
      src/solana/openPositionListener.ts

+ 2 - 1
src/index.ts

@@ -37,7 +37,8 @@ async function main() {
 			programId: openCfg.programId,
 			commitment: 'confirmed',
 			logIncludes: openCfg.logIncludes ?? [],
-			maxSupportedTransactionVersion: 0
+			maxSupportedTransactionVersion: 0,
+			jupiterApiKey: cfg.closePosition?.jupiterApiKey
 		},
 		(ev) => {
 			// 打印详细日志,包括币对和价格等

+ 287 - 134
src/solana/openPositionListener.ts

@@ -3,8 +3,10 @@ import {
 	PublicKey,
 	type Finality,
 	type LogsCallback,
-	type TransactionSignature
+	type TransactionSignature,
+	type ParsedTransactionWithMeta
 } from '@solana/web3.js'
+import bs58 from 'bs58'
 
 export type OpenPositionEvent = {
 	signature: TransactionSignature
@@ -14,7 +16,7 @@ export type OpenPositionEvent = {
 	accounts?: string[]
 	tokenMints?: string[]
 	positionAccount?: string
-	logs?: string[] // 添加原始日志
+	logs?: string[]
 	positionDetails?: {
 		positionAddress: string
 		providerAddress: string
@@ -43,37 +45,197 @@ export type OpenPositionListenerOpts = {
 	commitment?: 'processed' | 'confirmed' | 'finalized'
 	logIncludes?: string[]
 	maxSupportedTransactionVersion?: number
+	jupiterApiKey?: string
 }
 
-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
+// open_position_with_token22_nft instruction discriminator
+const OPEN_POSITION_DISCRIMINATOR = [77, 255, 174, 82, 125, 29, 201, 46]
+
+// CreatePersonalPositionEvent discriminator
+const CREATE_POSITION_EVENT_DISC = [100, 30, 87, 249, 196, 223, 154, 206]
+
+function matchDiscriminator(data: Buffer, expected: number[]): boolean {
+	if (data.length < 8) return false
+	for (let i = 0; i < 8; i++) {
+		if (data[i] !== expected[i]) return false
+	}
+	return true
+}
+
+/**
+ * Decode CreatePersonalPositionEvent from program logs.
+ * Layout: [8 disc][32 poolState][32 minter][32 nftOwner][4 tickLower][4 tickUpper][16 liquidity][8 amount0][8 amount1]
+ */
+function decodeCreatePositionEvent(logs: string[]): {
+	poolState: string
+	minter: string
+	nftOwner: string
+	tickLower: number
+	tickUpper: number
+	liquidity: string
+	amount0: string
+	amount1: string
+} | null {
+	for (const log of logs) {
+		if (!log.startsWith('Program data:')) continue
+		const b64 = log.replace('Program data: ', '')
+		try {
+			const buf = Buffer.from(b64, 'base64')
+			if (buf.length >= 144 && matchDiscriminator(buf, CREATE_POSITION_EVENT_DISC)) {
+				let off = 8
+				const poolState = new PublicKey(buf.slice(off, off + 32)).toBase58(); off += 32
+				const minter = new PublicKey(buf.slice(off, off + 32)).toBase58(); off += 32
+				const nftOwner = new PublicKey(buf.slice(off, off + 32)).toBase58(); off += 32
+				const tickLower = buf.readInt32LE(off); off += 4
+				const tickUpper = buf.readInt32LE(off); off += 4
+				const lo = buf.readBigUInt64LE(off)
+				const hi = buf.readBigUInt64LE(off + 8)
+				const liquidity = (hi << 64n) | lo; off += 16
+				const amount0 = buf.readBigUInt64LE(off); off += 8
+				const amount1 = buf.readBigUInt64LE(off)
+				return {
+					poolState,
+					minter,
+					nftOwner,
+					tickLower,
+					tickUpper,
+					liquidity: liquidity.toString(),
+					amount0: amount0.toString(),
+					amount1: amount1.toString(),
 				}
 			}
-			totalDeposit: string
-			upperTick: number
-			lowerTick: number
+		} catch {
+			// skip unparseable entries
+		}
+	}
+	return null
+}
+
+/**
+ * Find the open_position_with_token22_nft instruction by matching discriminator.
+ * This is more reliable than just matching programId when a transaction has multiple BYREAL instructions.
+ */
+function findOpenPositionInstruction(tx: ParsedTransactionWithMeta, programId: string) {
+	for (const ix of tx.transaction.message.instructions) {
+		if (!('programId' in ix) || ix.programId.toBase58() !== programId) continue
+		if ('data' in ix && typeof ix.data === 'string') {
+			// getParsedTransaction 返回的 data 是 base58 编码
+			const dataBuf = Buffer.from(bs58.decode(ix.data))
+			if (matchDiscriminator(dataBuf, OPEN_POSITION_DISCRIMINATOR)) return ix
+		}
+	}
+	return null
+}
+
+/**
+ * Extract account public keys from a parsed instruction.
+ */
+function getInstructionAccounts(ix: ReturnType<typeof findOpenPositionInstruction>): string[] {
+	if (ix && 'accounts' in ix && Array.isArray(ix.accounts)) {
+		return (ix.accounts as PublicKey[]).map((a) => a.toBase58())
+	}
+	return []
+}
+
+/**
+ * 通过交易的 token balance 信息查找某个 token account 的 mint 地址。
+ * token_account_0 (index 9) 和 token_account_1 (index 10) 是用户的 token 账户,
+ * 它们的 mint 就是池子的 mintA 和 mintB。
+ */
+function findMintForTokenAccount(
+	tx: ParsedTransactionWithMeta,
+	tokenAccountKey: string
+): { mint: string; decimals: number } | undefined {
+	const accountKeys = tx.transaction.message.accountKeys.map(k =>
+		typeof k === 'string' ? k : k.pubkey.toBase58()
+	)
+	const accountIndex = accountKeys.indexOf(tokenAccountKey)
+	if (accountIndex === -1) return undefined
+
+	const allBalances = [
+		...(tx.meta?.preTokenBalances || []),
+		...(tx.meta?.postTokenBalances || []),
+	]
+	for (const bal of allBalances) {
+		if (bal.accountIndex === accountIndex) {
+			return { mint: bal.mint, decimals: bal.uiTokenAmount.decimals }
 		}
 	}
+	return undefined
+}
+
+/**
+ * Get token decimals from transaction's token balances.
+ */
+function getTokenDecimals(
+	tx: ParsedTransactionWithMeta,
+	mintAddress: string
+): number | undefined {
+	const balances = [
+		...(tx.meta?.preTokenBalances || []),
+		...(tx.meta?.postTokenBalances || []),
+	]
+	for (const bal of balances) {
+		if (bal.mint === mintAddress) {
+			return bal.uiTokenAmount.decimals
+		}
+	}
+	return undefined
+}
+
+type TokenInfo = { symbol: string; decimals: number; price: string }
+
+/**
+ * 通过 Jupiter Price API v3 获取价格,通过 Helius DAS API 获取 symbol
+ */
+async function fetchTokensInfo(
+	mintAddresses: string[],
+	rpcUrl: string,
+	apiKey?: string
+): Promise<Record<string, TokenInfo>> {
+	const result: Record<string, TokenInfo> = {}
+	const headers: Record<string, string> = { Accept: 'application/json' }
+	if (apiKey) headers['x-api-key'] = apiKey
+
+	// 并行: Jupiter Price API v3 (价格+decimals) + Helius DAS getAssetBatch (symbol)
+	const [priceRes, assetRes] = await Promise.allSettled([
+		fetch(`https://api.jup.ag/price/v3?ids=${mintAddresses.join(',')}`, { headers, signal: AbortSignal.timeout(8000) })
+			.then(r => r.ok ? r.json() as Promise<Record<string, { usdPrice?: number; decimals?: number }>> : null),
+		fetch(rpcUrl, {
+			method: 'POST',
+			headers: { 'Content-Type': 'application/json' },
+			signal: AbortSignal.timeout(8000),
+			body: JSON.stringify({
+				jsonrpc: '2.0', id: 1,
+				method: 'getAssetBatch',
+				params: { ids: mintAddresses }
+			})
+		}).then(r => r.ok ? r.json() as Promise<{
+			result?: Array<{ id: string; content?: { metadata?: { symbol?: string } } }>
+		}> : null)
+	])
+
+	// 处理 Jupiter 价格
+	if (priceRes.status === 'fulfilled' && priceRes.value) {
+		for (const mint of mintAddresses) {
+			const info = priceRes.value[mint]
+			if (info?.usdPrice) {
+				result[mint] = { symbol: '', decimals: info.decimals ?? 0, price: String(info.usdPrice) }
+			}
+		}
+	}
+
+	// 处理 Helius DAS symbol
+	if (assetRes.status === 'fulfilled' && assetRes.value?.result) {
+		for (const asset of assetRes.value.result) {
+			if (!asset?.id) continue
+			const symbol = asset.content?.metadata?.symbol || ''
+			if (!result[asset.id]) result[asset.id] = { symbol: '', decimals: 0, price: '' }
+			if (symbol) result[asset.id].symbol = symbol
+		}
+	}
+
+	return result
 }
 
 export function listenOpenPosition(
@@ -86,124 +248,116 @@ 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 cb: LogsCallback = (logs, ctx) => {
+		const lines = logs.logs ?? []
+		if (logIncludes.length && !logIncludes.every((k) => lines.some((l) => l.includes(k)))) return
 
-			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
-			}
+		// 用 void + catch 包裹 async 逻辑,避免未捕获的 Promise rejection 静默吞掉错误
+		void (async () => {
 
-			const pool = positionData.pool
-			if (!pool) {
-				return undefined
-			}
+			// 使用 getParsedTransaction 获取解析后的交易,指令账户已解析为 PublicKey
+			const tx = await conn.getParsedTransaction(logs.signature, {
+				commitment: txFinality,
+				maxSupportedTransactionVersion: opts.maxSupportedTransactionVersion ?? 0
+			})
+			if (!tx?.meta || tx.meta.err) return
 
-			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
-		}
-	}
+			// 通过 discriminator 精确匹配 open_position_with_token22_nft 指令
+			const byrealIx = findOpenPositionInstruction(tx, opts.programId)
+			if (!byrealIx) return
 
-	const cb: LogsCallback = async (logs, ctx) => {
-		const lines = logs.logs ?? []
-		if (logIncludes.length && !logIncludes.every((k) => lines.some((l) => l.includes(k)))) return
+			// 从指令账户中按已知索引提取地址 (open_position_with_token22_nft)
+			// 索引来自 IDL 定义:
+			//   2: position_nft_mint (nftMint)
+			//   4: pool_state (poolId)
+			//   8: personal_position (positionAddress)
+			//   9: token_account_0 (用户的 tokenA 账户)
+			//  10: token_account_1 (用户的 tokenB 账户)
+			const ixAccounts = getInstructionAccounts(byrealIx)
+			const nftMintAddress = ixAccounts[2] || ''
+			const poolAddress = ixAccounts[4] || ''
+			const positionAddress = ixAccounts[8] || ''
 
-		// 拉完整交易,方便你后续做“真正的字段解析”
-		const tx = await conn.getTransaction(logs.signature, {
-			commitment: txFinality,
-			maxSupportedTransactionVersion: opts.maxSupportedTransactionVersion ?? 0
-		})
+			// 从 token_account_0/1 的 token balance 中提取真正的 mint 地址
+			// 比直接用 ixAccounts[18]/[19] 更可靠,不受额外账户偏移影响
+			const tokenAccount0 = ixAccounts[9] || ''
+			const tokenAccount1 = ixAccounts[10] || ''
+			const mintAResult = findMintForTokenAccount(tx, tokenAccount0)
+			const mintBResult = findMintForTokenAccount(tx, tokenAccount1)
+			const mintAAddress = mintAResult?.mint || ixAccounts[18] || ''
+			const mintBAddress = mintBResult?.mint || ixAccounts[19] || ''
 
-		const accounts = tx
-			? (() => {
-				const message = tx.transaction.message as any
-				const keys = message.staticAccountKeys ?? message.accountKeys
-				return keys?.map((k: any) => k.toBase58())
-			})()
-			: undefined
-
-		// 从 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
+			// 获取 provider (签名者) 地址
+			const accountKeys = tx.transaction.message.accountKeys
+			let providerAddress = ''
+			for (const key of accountKeys) {
+				if (typeof key !== 'string' && 'signer' in key && key.signer) {
+					providerAddress = key.pubkey.toBase58()
+					break
+				}
 			}
-		}
 
-		// 如果没找到,使用默认索引 8 作为后备
-		if (!positionAccount && accounts && accounts.length > 7) {
-			positionAccount = accounts[8]
-		}
+			// 从程序日志中解码 CreatePersonalPositionEvent
+			const txLogs = tx.meta.logMessages || []
+			const createEvent = decodeCreatePositionEvent(txLogs)
 
-		// 如果有 positionAccount,通过 API 获取详细信息
-		// 延迟100秒后获取,因为positionAccount可能还没被写入 
-		await new Promise(resolve => setTimeout(resolve, 100 * 1000))
+			if (!positionAddress) {
+				console.log(`[${new Date().toISOString()}] 无法从交易中解析 positionAddress: ${logs.signature}`)
+				return
+			}
 
-		let positionDetails = positionAccount ? await fetchPositionDetails(positionAccount) : undefined
-		// 如果positionDetails为空,则分别尝试accounts[6]和accounts[7] accounts[9] 
-		if (!positionDetails && accounts && accounts.length > 6) {
-			positionDetails = await fetchPositionDetails(accounts[6])
-		}
-		if (!positionDetails && accounts && accounts.length > 7) {
-			positionDetails = await fetchPositionDetails(accounts[7])
-		}
-		if (!positionDetails && accounts && accounts.length > 9) {
-			positionDetails = await fetchPositionDetails(accounts[9])
-		}
-		if (!positionDetails) {
-			console.log('positionAccountIndex', positionAccountIndex)
-			// console.log('找不到地址', JSON.stringify(tx, null, 2))
-			console.log('找不到地址')
-			console.log('accounts', accounts)
-			console.log('tx', logs.signature)
+			// 通过 Jupiter Price API + Helius DAS API 获取代币信息
+			const tokensInfo = await fetchTokensInfo([mintAAddress, mintBAddress], conn.rpcEndpoint, opts.jupiterApiKey)
+			const mintAInfo = tokensInfo[mintAAddress]
+			const mintBInfo = tokensInfo[mintBAddress]
 
-		}
+			// 优先使用交易 balance 中的 decimals,其次用 API 返回的
+			const mintADecimals = mintAResult?.decimals ?? mintAInfo?.decimals ?? getTokenDecimals(tx, mintAAddress) ?? 0
+			const mintBDecimals = mintBResult?.decimals ?? mintBInfo?.decimals ?? getTokenDecimals(tx, mintBAddress) ?? 0
+
+			// 通过事件中的 amount + 价格计算 totalDeposit (USD)
+			let totalDeposit = ''
+			if (createEvent && mintAInfo?.price && mintBInfo?.price) {
+				const amountA = Number(createEvent.amount0) / Math.pow(10, mintADecimals)
+				const amountB = Number(createEvent.amount1) / Math.pow(10, mintBDecimals)
+				const valueA = amountA * Number(mintAInfo.price)
+				const valueB = amountB * Number(mintBInfo.price)
+				totalDeposit = (valueA + valueB).toFixed(2)
+			}
 
-		onEvent({
-			signature: logs.signature,
-			slot: ctx.slot,
-			blockTime: tx?.blockTime ?? null,
-			programId: opts.programId,
-			positionAccount,
-			positionDetails
+			console.log(`[${new Date().toISOString()}] 交易解析成功: sig=${logs.signature.slice(0, 8)}... position=${positionAddress.slice(0, 8)}... nftMint=${nftMintAddress.slice(0, 8)}... ${mintAInfo?.symbol || '?'}/${mintBInfo?.symbol || '?'} $${totalDeposit || '?'}`)
+
+			onEvent({
+				signature: logs.signature,
+				slot: ctx.slot,
+				blockTime: tx.blockTime ?? null,
+				programId: opts.programId,
+				positionAccount: positionAddress,
+				positionDetails: {
+					positionAddress,
+					providerAddress,
+					nftMintAddress,
+					poolAddress,
+					mintA: {
+						address: mintAAddress,
+						symbol: mintAInfo?.symbol || '',
+						decimals: mintADecimals,
+						price: mintAInfo?.price || '',
+					},
+					mintB: {
+						address: mintBAddress,
+						symbol: mintBInfo?.symbol || '',
+						decimals: mintBDecimals,
+						price: mintBInfo?.price || '',
+					},
+					totalDeposit,
+					upperTick: createEvent?.tickUpper ?? 0,
+					lowerTick: createEvent?.tickLower ?? 0,
+				},
+			})
+
+		})().catch(err => {
+			console.error(`[${new Date().toISOString()}] openPositionListener 回调异常: ${logs.signature}`, err)
 		})
 	}
 
@@ -215,4 +369,3 @@ export function listenOpenPosition(
 		}
 	}
 }
-