Explorar o código

feat: add swap remaining tokens to USDC after close position

zhangchunrui hai 1 mes
pai
achega
a4f1d82069
Modificáronse 4 ficheiros con 497 adicións e 3 borrados
  1. 2 0
      .env.example
  2. 77 1
      src/closePosition/index.ts
  3. 16 2
      src/config.ts
  4. 402 0
      src/solana/jupiter.ts

+ 2 - 0
.env.example

@@ -0,0 +1,2 @@
+# 钱包私钥 (Base58编码) - 用于关闭仓位后兑换代币
+PRIVATE_KEY=your_base58_private_key_here

+ 77 - 1
src/closePosition/index.ts

@@ -1,4 +1,5 @@
-import { loadConfig } from '../config.js'
+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'
@@ -145,6 +146,53 @@ async function sendDiscordCloseResultNotification(
 	})
 }
 
+async function sendDiscordSwapNotification(
+	webhookUrl: string,
+	closeTxid: string,
+	swaps: SwapInfo[]
+): Promise<void> {
+	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 通知。
@@ -211,6 +259,34 @@ export async function checkParentPositionsClosed(): Promise<void> {
 					// )
 					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} 关闭失败`)
 					}

+ 16 - 2
src/config.ts

@@ -1,6 +1,7 @@
 import fs from 'node:fs'
 import path from 'node:path'
 import { z } from 'zod'
+import 'dotenv/config'
 
 const ConfigSchema = z.object({
 	rpcHttp: z.string().url(),
@@ -23,13 +24,26 @@ const ConfigSchema = z.object({
 		discordWebhookUrl: z.string().url(),
 		pageSize: z.number().min(1).max(100).default(50),
 		lpCloseApiUrl: z.string().min(1).default('https://love.hdlife.me/api/lp-index/lp-close'),
-		lpCloseAuth: z.string().min(1).default('Basic YWRtaW46YzU4ODk5Njc=')
+		lpCloseAuth: z.string().min(1).default('Basic YWRtaW46YzU4ODk5Njc='),
+		jupiterApiKey: z.string().optional(),
+		swapKeepUsdValue: z.number().min(0).default(10),
+		swapWhitelist: z.array(z.string().min(32)).default([
+			'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
+			'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB',
+			'So11111111111111111111111111111111111111112',
+			'USD1ttGY1N17NEEHLmELoaybftRBUSErhqYiQzvEmuB',
+			'AymATz4TCL9sWNEEV9Kvyz45CHVhDZ6kUgjTJPzLpU9P'
+		])
 	}).optional(),
 	blacklist: z.array(z.string().min(32)).default([]),
 	discordWebhookUrl: z.string().url()
 })
 
-export type AppConfig = z.infer<typeof ConfigSchema>;
+export type AppConfig = z.infer<typeof ConfigSchema>
+
+export function getPrivateKey(): string | undefined {
+	return process.env.PRIVATE_KEY
+}
 
 export function loadConfig(): AppConfig {
 	const configPath = process.env.CONFIG_PATH ?? path.resolve(process.cwd(), 'config.json')

+ 402 - 0
src/solana/jupiter.ts

@@ -0,0 +1,402 @@
+import { Connection, PublicKey, Keypair, VersionedTransaction, VersionedTransactionResponse } from '@solana/web3.js'
+import bs58 from 'bs58'
+
+const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'
+const TOKEN_PROGRAM_ID = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA')
+const TOKEN_2022_PROGRAM_ID = new PublicKey('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb')
+const ASSOCIATED_TOKEN_PROGRAM_ID = new PublicKey('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL')
+
+export interface SwapResult {
+	success: boolean
+	swaps: SwapInfo[]
+}
+
+export interface SwapInfo {
+	mint: string
+	symbol: string | null
+	amount: number
+	swappedUsd: number
+	txSignature: string
+}
+
+async function findAssociatedTokenAddress(
+	walletAddress: PublicKey,
+	tokenMintAddress: PublicKey,
+	programId: PublicKey = TOKEN_PROGRAM_ID
+): Promise<PublicKey> {
+	const [address] = await PublicKey.findProgramAddress(
+		[walletAddress.toBuffer(), programId.toBuffer(), tokenMintAddress.toBuffer()],
+		ASSOCIATED_TOKEN_PROGRAM_ID
+	)
+	return address
+}
+
+export class JupiterSwapper {
+	private connection: Connection
+	private keypair: Keypair
+	private walletAddress: string
+	private jupiterBaseUrl: string
+	private apiKey: string | undefined
+	private rpcUrl: string
+
+	constructor(rpcUrl: string, privateKey: string, apiKey?: string) {
+		this.rpcUrl = rpcUrl
+		this.connection = new Connection(rpcUrl, 'confirmed')
+		this.keypair = Keypair.fromSecretKey(bs58.decode(privateKey))
+		this.walletAddress = this.keypair.publicKey.toString()
+		this.jupiterBaseUrl = 'https://api.jup.ag/swap/v1'
+		this.apiKey = apiKey
+	}
+
+	getWalletAddress(): string {
+		return this.walletAddress
+	}
+
+	private getHeaders(): Record<string, string> {
+		const headers: Record<string, string> = {
+			Accept: 'application/json',
+			'Content-Type': 'application/json',
+		}
+		if (this.apiKey) {
+			headers['x-api-key'] = this.apiKey
+		}
+		return headers
+	}
+
+	private async getTokenProgramId(mintAddress: string): Promise<PublicKey> {
+		try {
+			const mintAccountInfo = await this.connection.getAccountInfo(new PublicKey(mintAddress))
+			if (mintAccountInfo) {
+				const owner = mintAccountInfo.owner.toString()
+				if (owner === TOKEN_2022_PROGRAM_ID.toString()) {
+					return TOKEN_2022_PROGRAM_ID
+				}
+			}
+			return TOKEN_PROGRAM_ID
+		} catch {
+			return TOKEN_PROGRAM_ID
+		}
+	}
+
+	async getTokenBalance(mintAddress: string): Promise<number> {
+		try {
+			if (mintAddress === 'So11111111111111111111111111111111111111112') {
+				const balance = await this.connection.getBalance(this.keypair.publicKey)
+				return balance / 1e9
+			}
+
+			const programId = await this.getTokenProgramId(mintAddress)
+			const tokenAccount = await findAssociatedTokenAddress(
+				this.keypair.publicKey,
+				new PublicKey(mintAddress),
+				programId
+			)
+
+			try {
+				const accountInfo = await this.connection.getTokenAccountBalance(tokenAccount)
+				return parseFloat(accountInfo.value.uiAmountString || '0')
+			} catch {
+				return 0
+			}
+		} catch (error) {
+			console.error(`Error getting balance for ${mintAddress}:`, error)
+			return 0
+		}
+	}
+
+	async getTokenPrices(tokenAddresses: string[]): Promise<Record<string, { price?: number; symbol?: string }>> {
+		try {
+			const ids = tokenAddresses.join(',')
+			const response = await fetch(`https://api.jup.ag/price/v2?ids=${ids}`, {
+				headers: this.getHeaders(),
+			})
+			const data = (await response.json()) as { data?: Record<string, { price?: number; symbol?: string }> }
+			return data.data || {}
+		} catch (error) {
+			console.error('Error fetching token prices:', error)
+			return {}
+		}
+	}
+
+	private async sleep(ms: number): Promise<void> {
+		return new Promise((resolve) => setTimeout(resolve, ms))
+	}
+
+	private async fetchQuote(
+		inputMint: string,
+		outputMint: string,
+		amount: string | number,
+		retries = 3,
+		swapMode = 'ExactIn',
+		restrictIntermediate = true
+	): Promise<unknown> {
+		const rawAmount = typeof amount === 'string' ? amount : String(Math.floor(Number(amount)))
+		if (!rawAmount || Number(rawAmount) <= 0) {
+			throw new Error(`Invalid quote amount: ${amount}`)
+		}
+
+		for (let attempt = 1; attempt <= retries; attempt++) {
+			try {
+				const params = new URLSearchParams({
+					inputMint,
+					outputMint,
+					amount: rawAmount,
+					swapMode,
+					slippageBps: '100',
+					onlyDirectRoutes: 'false',
+					restrictIntermediateTokens: String(restrictIntermediate),
+				})
+
+				const response = await fetch(`${this.jupiterBaseUrl}/quote?${params.toString()}`, {
+					headers: this.getHeaders(),
+				})
+
+				if (!response.ok) {
+					const data = (await response.json()) as { message?: string; error?: string }
+					throw new Error(data.message || data.error || `HTTP ${response.status}`)
+				}
+
+				return await response.json()
+			} catch (error) {
+				const message = error instanceof Error ? error.message : String(error)
+				console.warn(`Quote attempt ${attempt} failed: ${message}`)
+
+				if (attempt < retries) {
+					await this.sleep(2000 * attempt)
+				} else {
+					throw new Error(`Failed to fetch quote: ${message}`)
+				}
+			}
+		}
+
+		throw new Error('All quote attempts failed')
+	}
+
+	private async executeSwap(quoteData: unknown, retries = 3): Promise<{ swapTransaction: string }> {
+		for (let attempt = 1; attempt <= retries; attempt++) {
+			try {
+				const response = await fetch(`${this.jupiterBaseUrl}/swap`, {
+					method: 'POST',
+					headers: this.getHeaders(),
+					body: JSON.stringify({
+						quoteResponse: quoteData,
+						userPublicKey: this.walletAddress,
+						wrapAndUnwrapSol: true,
+						prioritizationFeeLamports: 10000,
+						dynamicSlippage: false,
+					}),
+				})
+
+				if (!response.ok) {
+					const data = (await response.json()) as { message?: string }
+					throw new Error(data.message || `HTTP ${response.status}`)
+				}
+
+				const data = (await response.json()) as { swapTransaction?: string }
+				if (!data.swapTransaction) {
+					throw new Error('Failed to get swap transaction')
+				}
+
+				return data as { swapTransaction: string }
+			} catch (error) {
+				const message = error instanceof Error ? error.message : String(error)
+				console.warn(`Swap execution attempt ${attempt} failed: ${message}`)
+
+				if (attempt < retries) {
+					await this.sleep(2000 * attempt)
+				} else {
+					throw new Error(`Failed to execute swap: ${message}`)
+				}
+			}
+		}
+
+		throw new Error('All swap execution attempts failed')
+	}
+
+	private extractBalanceChanges(tx: VersionedTransactionResponse | null): Array<{ mint: string; amount: number; change: number }> {
+		const changedTokens: Array<{ mint: string; amount: number; change: number }> = []
+
+		if (!tx?.meta?.preTokenBalances || !tx?.meta?.postTokenBalances) {
+			return changedTokens
+		}
+
+		const preBalances: Record<string, number> = {}
+
+		tx.meta.preTokenBalances.forEach((balance) => {
+			if (balance.owner === this.walletAddress) {
+				const key = `${balance.accountIndex}-${balance.mint}`
+				preBalances[key] = parseFloat(balance.uiTokenAmount?.uiAmountString || '0')
+			}
+		})
+
+		tx.meta.postTokenBalances.forEach((balance) => {
+			if (balance.owner === this.walletAddress) {
+				const key = `${balance.accountIndex}-${balance.mint}`
+				const pre = preBalances[key]
+				const postAmount = parseFloat(balance.uiTokenAmount?.uiAmountString || '0')
+
+				if (pre !== undefined) {
+					const diff = postAmount - pre
+					if (Math.abs(diff) > 0.000001) {
+						changedTokens.push({
+							mint: balance.mint,
+							amount: postAmount,
+							change: diff,
+						})
+					}
+				} else if (postAmount > 0.000001) {
+					changedTokens.push({
+						mint: balance.mint,
+						amount: postAmount,
+						change: postAmount,
+					})
+				}
+			}
+		})
+
+		return changedTokens
+	}
+
+	async analyzeCloseTxAndSwapRemains(
+		txSignature: string,
+		keepUsdValue: number,
+		whitelist: string[]
+	): Promise<SwapResult> {
+		if (!txSignature) {
+			console.warn('[Jupiter] No tx signature provided, skipping swap analysis')
+			return { success: false, swaps: [] }
+		}
+
+		if (!this.apiKey) {
+			console.warn('[Jupiter] No Jupiter API key provided, skipping swap')
+			return { success: false, swaps: [] }
+		}
+
+		console.log(`\n[Jupiter] Analyzing close transaction: ${txSignature.slice(0, 20)}...`)
+
+		const swaps: SwapInfo[] = []
+
+		try {
+			const tx = await this.connection.getTransaction(txSignature, {
+				commitment: 'confirmed',
+				maxSupportedTransactionVersion: 0,
+			})
+
+			if (!tx || !tx.meta) {
+				console.error('[Jupiter] Transaction not found or invalid')
+				return { success: false, swaps: [] }
+			}
+
+			const changedTokens = this.extractBalanceChanges(tx)
+			if (changedTokens.length === 0) {
+				console.log('[Jupiter] No token balance changes found')
+				return { success: true, swaps: [] }
+			}
+
+			console.log(`[Jupiter] Found ${changedTokens.length} tokens with balance changes`)
+
+			const mints = changedTokens.map((t) => t.mint)
+			const prices = await this.getTokenPrices(mints)
+
+			for (const token of changedTokens) {
+				const mint = token.mint
+				const symbol = prices[mint]?.symbol || null
+				const isWhitelisted = whitelist.includes(mint)
+
+				if (isWhitelisted) {
+					console.log(`[Jupiter] ${mint.slice(0, 8)}... is whitelisted, skipping swap`)
+					continue
+				}
+
+				const currentBalance = await this.getTokenBalance(mint)
+				if (currentBalance <= 0) {
+					console.log(`[Jupiter] ${mint.slice(0, 8)}... balance is 0, skipping`)
+					continue
+				}
+
+				const priceUsd = prices[mint]?.price || 0
+				if (priceUsd <= 0) {
+					console.warn(`[Jupiter] ${mint.slice(0, 8)}... price not available, skipping`)
+					continue
+				}
+
+				const totalValueUsd = currentBalance * priceUsd
+				console.log(
+					`[Jupiter] ${mint.slice(0, 8)}... : balance=${currentBalance.toFixed(4)}, price=$${priceUsd.toFixed(4)}, value=$${totalValueUsd.toFixed(2)}`
+				)
+
+				if (totalValueUsd <= keepUsdValue) {
+					console.log(`[Jupiter] Value $${totalValueUsd.toFixed(2)} <= $${keepUsdValue}, keeping all`)
+					continue
+				}
+
+				const swapValueUsd = totalValueUsd - keepUsdValue
+				console.log(`[Jupiter] Swapping $${swapValueUsd.toFixed(2)} worth to USDC (keeping $${keepUsdValue})`)
+
+				const swapAmountRaw = Math.floor((swapValueUsd / priceUsd) * Math.pow(10, 6))
+
+				try {
+					let quoteData: unknown
+					try {
+						quoteData = await this.fetchQuote(mint, USDC_MINT, swapAmountRaw, 2, 'ExactIn', true)
+					} catch (e) {
+						const message = e instanceof Error ? e.message : String(e)
+						if (message.includes('No routes found')) {
+							console.log('[Jupiter] Retrying with allow all intermediates...')
+							quoteData = await this.fetchQuote(mint, USDC_MINT, swapAmountRaw, 2, 'ExactIn', false)
+						} else {
+							throw e
+						}
+					}
+
+					const quote = quoteData as {
+						routePlan?: Array<{ swapInfo?: { label?: string } }>
+						outAmount?: string
+						priceImpactPct?: string
+					}
+					if (quote.routePlan && quote.routePlan.length > 0) {
+						const routeLabels = quote.routePlan.map((r) => r.swapInfo?.label || 'Unknown').join(' -> ')
+						console.log(`[Jupiter] Route: ${routeLabels}`)
+						console.log(`[Jupiter] Expected output: ${quote.outAmount} USDC, price impact: ${quote.priceImpactPct}%`)
+					}
+
+					const swapData = await this.executeSwap(quoteData)
+					const swapTransactionBuf = Buffer.from(swapData.swapTransaction, 'base64')
+					const transaction = VersionedTransaction.deserialize(swapTransactionBuf)
+					transaction.sign([this.keypair])
+
+					const signature = await this.connection.sendTransaction(transaction, {
+						maxRetries: 3,
+						skipPreflight: false,
+					})
+					console.log(`[Jupiter] Swap transaction sent: ${signature}`)
+
+					const confirmation = await this.connection.confirmTransaction(signature, 'confirmed')
+					if (confirmation.value.err) {
+						throw new Error(`Transaction failed: ${JSON.stringify(confirmation.value.err)}`)
+					}
+
+					console.log(`[Jupiter] Swap confirmed: https://solscan.io/tx/${signature}`)
+
+					swaps.push({
+						mint,
+						symbol,
+						amount: swapValueUsd / priceUsd,
+						swappedUsd: swapValueUsd,
+						txSignature: signature,
+					})
+				} catch (swapError) {
+					console.error(
+						`[Jupiter] Swap failed for ${mint.slice(0, 8)}...: ${swapError instanceof Error ? swapError.message : swapError}`
+					)
+				}
+			}
+
+			console.log('[Jupiter] Swap analysis completed\n')
+			return { success: true, swaps }
+		} catch (error) {
+			console.error('[Jupiter] Error analyzing close transaction:', error)
+			return { success: false, swaps }
+		}
+	}
+}