Kaynağa Gözat

feat(lp-copy): add automatic Jupiter swap for insufficient balances

- Add Jupiter swap service (src/lib/jupiter.ts) for token swapping
- Implement balance checking before LP creation
- Auto-swap using USDC/USDT when token balance is insufficient
- Add 10% buffer to swap amount to prevent price slippage failures
- Add Jupiter API key support (JUPITER_API_KEY env var)
- Return swap transaction IDs in response for transparency
lushdog@outlook.com 1 ay önce
ebeveyn
işleme
02efb0288d
3 değiştirilmiş dosya ile 463 ekleme ve 2 silme
  1. 2 0
      .env.example
  2. 88 2
      src/app/api/lp-copy/route.ts
  3. 373 0
      src/lib/jupiter.ts

+ 2 - 0
.env.example

@@ -8,6 +8,8 @@ SOL_ENDPOINT=https://lb.drpc.live/solana/YOUR_API_KEY
 # Solana 私钥(用于签名交易,敏感信息,不要提交到 Git)
 SOL_SECRET_KEY=your_base58_encoded_secret_key
 
+JUPITER_API_KEY=your_jupiter_api_key_here
+
 # Basic Auth 配置
 # 生成 htpasswd 文件:
 # HASH=$(docker run --rm caddy:2-alpine caddy hash-password --plaintext 'your_password')

+ 88 - 2
src/app/api/lp-copy/route.ts

@@ -1,10 +1,16 @@
 import { NextRequest, NextResponse } from 'next/server'
-import { Keypair, PublicKey, VersionedTransaction } from '@solana/web3.js'
+import {
+	Keypair,
+	PublicKey,
+	VersionedTransaction,
+	Connection,
+} from '@solana/web3.js'
 import BN from 'bn.js'
 import { Decimal } from 'decimal.js'
 import bs58 from 'bs58'
 import { chain } from '@/lib/config'
 import { TickMath } from '@/lib/byreal-clmm-sdk/src/instructions/utils/tickMath'
+import { ensureSufficientBalances } from '@/lib/jupiter'
 
 async function copyLPPosition(
 	request: NextRequest,
@@ -519,7 +525,7 @@ async function copyLPPosition(
 			)
 		}
 
-		// 从环境变量读取私钥
+		// 从环境变量读取私钥(提前到这里,因为余额检查需要用到)
 		const secretKey = process.env.SOL_SECRET_KEY
 		if (!secretKey) {
 			return NextResponse.json(
@@ -530,6 +536,86 @@ async function copyLPPosition(
 
 		const userKeypair = Keypair.fromSecretKey(bs58.decode(secretKey))
 		const userAddress = userKeypair.publicKey
+
+		// ========== 检查并确保余额充足 ==========
+		console.log('\n--- Checking Wallet Balances ---')
+
+		// 计算实际需要的最小代币数量(不包含 slippage)
+		const neededBaseAmount = baseAmount
+		const neededOtherAmount =
+			base === 'MintA'
+				? otherAmountMax.mul(new BN(10000)).div(new BN(10200)) // 减去 2% slippage
+				: otherAmountMax.mul(new BN(10000)).div(new BN(10500)) // 减去 5% slippage
+
+		// 准备代币信息
+		const tokenAInfo = {
+			mint: poolInfo.mintA.toBase58(),
+			amount:
+				base === 'MintA'
+					? new Decimal(neededBaseAmount.toString())
+							.div(10 ** poolInfo.mintDecimalsA)
+							.toNumber()
+					: new Decimal(neededOtherAmount.toString())
+							.div(10 ** poolInfo.mintDecimalsA)
+							.toNumber(),
+			decimals: poolInfo.mintDecimalsA,
+		}
+
+		const tokenBInfo = {
+			mint: poolInfo.mintB.toBase58(),
+			amount:
+				base === 'MintB'
+					? new Decimal(neededBaseAmount.toString())
+							.div(10 ** poolInfo.mintDecimalsB)
+							.toNumber()
+					: new Decimal(neededOtherAmount.toString())
+							.div(10 ** poolInfo.mintDecimalsB)
+							.toNumber(),
+			decimals: poolInfo.mintDecimalsB,
+		}
+
+		console.log(`Token A (${tokenAInfo.mint.slice(0, 8)}...):`, {
+			required: tokenAInfo.amount.toFixed(6),
+			decimals: tokenAInfo.decimals,
+		})
+		console.log(`Token B (${tokenBInfo.mint.slice(0, 8)}...):`, {
+			required: tokenBInfo.amount.toFixed(6),
+			decimals: tokenBInfo.decimals,
+		})
+
+		// 创建 connection
+		const rpcUrl =
+			process.env.SOL_RPC_URL || 'https://api.mainnet-beta.solana.com'
+		const connection = new Connection(rpcUrl, 'confirmed')
+
+		// 检查并补充余额
+		const balanceCheck = await ensureSufficientBalances(
+			connection,
+			userKeypair,
+			tokenAInfo,
+			tokenBInfo
+		)
+
+		if (!balanceCheck.success) {
+			console.error('Failed to ensure sufficient balances:', balanceCheck.error)
+			return NextResponse.json(
+				{
+					error: '余额不足且自动换币失败',
+					details: balanceCheck.error,
+					swapTxids: balanceCheck.swapTxids,
+				},
+				{ status: 400 }
+			)
+		}
+
+		if (balanceCheck.swapTxids.length > 0) {
+			console.log('\n--- Swap Transactions ---')
+			balanceCheck.swapTxids.forEach((txid, index) => {
+				console.log(`Swap ${index + 1}: https://solscan.io/tx/${txid}`)
+			})
+		}
+
+		console.log('\n--- Balances Sufficient, Proceeding ---')
 		console.log('User Address:', userAddress.toBase58())
 		console.log('\n--- Executing Transaction ---')
 		console.log('Creating position on-chain...')

+ 373 - 0
src/lib/jupiter.ts

@@ -0,0 +1,373 @@
+import {
+	Connection,
+	PublicKey,
+	Keypair,
+	VersionedTransaction,
+} from '@solana/web3.js'
+import { getAssociatedTokenAddress } from '@solana/spl-token'
+import ky from 'ky'
+
+// USDC 和 USDT 作为默认的输入代币(用于兑换)
+const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'
+const USDT_MINT = 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB'
+const SOL_MINT = 'So11111111111111111111111111111111111111112'
+
+export interface JupiterQuote {
+	inputMint: string
+	inAmount: string
+	outputMint: string
+	outAmount: string
+	swapMode: string
+	slippageBps: number
+	priceImpactPct: string
+	routePlan: Array<{
+		swapInfo: {
+			label?: string
+			[key: string]: unknown
+		}
+	}>
+}
+
+export interface JupiterSwapResponse {
+	swapTransaction: string
+}
+
+/**
+ * 获取代币余额
+ */
+export async function getTokenBalance(
+	connection: Connection,
+	walletAddress: PublicKey,
+	mintAddress: string
+): Promise<number> {
+	try {
+		// SOL 余额
+		if (mintAddress === SOL_MINT) {
+			const balance = await connection.getBalance(walletAddress)
+			return balance / 1e9
+		}
+
+		// SPL Token 余额
+		const tokenAccount = await getAssociatedTokenAddress(
+			new PublicKey(mintAddress),
+			walletAddress
+		)
+
+		try {
+			const accountInfo = await connection.getTokenAccountBalance(tokenAccount)
+			return accountInfo.value.uiAmount ?? 0
+		} catch {
+			// Token account 不存在,余额为 0
+			return 0
+		}
+	} catch (error) {
+		console.error(`Error getting balance for ${mintAddress}:`, error)
+		return 0
+	}
+}
+
+/**
+ * 获取 Jupiter API 请求头
+ */
+function getJupiterHeaders(): Record<string, string> {
+	const headers: Record<string, string> = {
+		Accept: 'application/json',
+	}
+
+	const apiKey = process.env.JUPITER_API_KEY
+	if (apiKey) {
+		headers['x-api-key'] = apiKey
+	}
+
+	return headers
+}
+
+/**
+ * 从 Jupiter API 获取 quote
+ */
+export async function fetchJupiterQuote(
+	inputMint: string,
+	outputMint: string,
+	amount: number,
+	swapMode: 'ExactIn' | 'ExactOut' = 'ExactOut',
+	slippageBps: number = 200 // 2%
+): Promise<JupiterQuote> {
+	const jupiterBaseUrl =
+		process.env.JUPITER_API_URL || 'https://api.jup.ag/swap/v1'
+
+	const searchParams = new URLSearchParams({
+		inputMint,
+		outputMint,
+		amount: String(amount),
+		swapMode,
+		slippageBps: String(slippageBps),
+		onlyDirectRoutes: 'false',
+		restrictIntermediateTokens: 'true',
+	})
+
+	const response = await ky
+		.get(`${jupiterBaseUrl}/quote`, {
+			searchParams,
+			timeout: 30000,
+			headers: getJupiterHeaders(),
+		})
+		.json<JupiterQuote>()
+
+	return response
+}
+
+/**
+ * 执行 Jupiter swap
+ */
+export async function executeJupiterSwap(
+	quoteData: JupiterQuote,
+	walletAddress: string
+): Promise<JupiterSwapResponse> {
+	const jupiterBaseUrl =
+		process.env.JUPITER_API_URL || 'https://api.jup.ag/swap/v1'
+
+	const headers = {
+		...getJupiterHeaders(),
+		'Content-Type': 'application/json',
+	}
+
+	const response = await ky
+		.post(`${jupiterBaseUrl}/swap`, {
+			json: {
+				quoteResponse: quoteData,
+				userPublicKey: walletAddress,
+				wrapAndUnwrapSol: true,
+				prioritizationFeeLamports: 10000,
+				dynamicSlippage: false,
+			},
+			timeout: 30000,
+			headers,
+		})
+		.json<JupiterSwapResponse>()
+
+	return response
+}
+
+/**
+ * 如果需要,执行 swap 以获取足够的代币余额
+ * @returns 是否成功获取足够的余额
+ */
+export async function swapIfNeeded(
+	connection: Connection,
+	keypair: Keypair,
+	outputMint: string,
+	requiredAmount: number,
+	decimals: number,
+	inputMint: string = USDC_MINT
+): Promise<{ success: boolean; txid?: string; error?: string }> {
+	const walletAddress = keypair.publicKey
+
+	// 检查当前余额
+	const currentBalance = await getTokenBalance(
+		connection,
+		walletAddress,
+		outputMint
+	)
+
+	if (currentBalance >= requiredAmount) {
+		console.log(
+			`Sufficient balance: ${currentBalance.toFixed(6)} >= ${requiredAmount.toFixed(6)}`
+		)
+		return { success: true }
+	}
+
+	// 增加 10% buffer 防止价格波动导致余额不足
+	const neededAmount = (requiredAmount - currentBalance) * 1.1
+	console.log(
+		`Insufficient balance. Need ${neededAmount.toFixed(6)} more of ${outputMint.slice(0, 8)}... (with 10% buffer)`
+	)
+	console.log(
+		`Initiating swap from ${inputMint.slice(0, 8)}... to ${outputMint.slice(0, 8)}...`
+	)
+
+	try {
+		// 计算需要的 raw amount
+		const outputAmount = Math.ceil(neededAmount * Math.pow(10, decimals))
+
+		console.log(
+			`Swap: ${inputMint.slice(0, 8)}... -> ${outputMint.slice(0, 8)}...`
+		)
+		console.log(
+			`Required output: ${neededAmount} (decimals: ${decimals}, raw: ${outputAmount})`
+		)
+
+		// 获取 quote (使用 ExactOut 模式)
+		const quoteData = await fetchJupiterQuote(
+			inputMint,
+			outputMint,
+			outputAmount,
+			'ExactOut',
+			200 // 2% slippage
+		)
+
+		// 记录路由信息
+		if (quoteData.routePlan && quoteData.routePlan.length > 0) {
+			const routeLabels = quoteData.routePlan
+				.map((r) => r.swapInfo?.label || 'Unknown')
+				.join(' -> ')
+			console.log(`Route: ${routeLabels}`)
+			console.log(
+				`Expected output: ${quoteData.outAmount} (${quoteData.swapMode})`
+			)
+			console.log(`Price impact: ${quoteData.priceImpactPct}%`)
+		}
+
+		// 执行 swap
+		const swapData = await executeJupiterSwap(
+			quoteData,
+			walletAddress.toBase58()
+		)
+
+		// 反序列化并签名交易
+		const swapTransactionBuf = Buffer.from(swapData.swapTransaction, 'base64')
+		const transaction = VersionedTransaction.deserialize(swapTransactionBuf)
+
+		transaction.sign([keypair])
+
+		// 发送交易
+		const signature = await connection.sendTransaction(transaction, {
+			maxRetries: 3,
+			skipPreflight: false,
+		})
+
+		console.log(`Swap transaction sent: ${signature}`)
+
+		// 确认交易
+		const confirmation = await connection.confirmTransaction(
+			signature,
+			'confirmed'
+		)
+
+		if (confirmation.value.err) {
+			throw new Error(`Transaction failed: ${confirmation.value.err}`)
+		}
+
+		console.log(`Swap confirmed: https://solscan.io/tx/${signature}`)
+
+		// 检查新余额
+		const newBalance = await getTokenBalance(
+			connection,
+			walletAddress,
+			outputMint
+		)
+		console.log(`New balance: ${newBalance.toFixed(6)}`)
+
+		if (newBalance < requiredAmount) {
+			return {
+				success: false,
+				txid: signature,
+				error: `Swap completed but balance still insufficient: ${newBalance.toFixed(6)} < ${requiredAmount.toFixed(6)}`,
+			}
+		}
+
+		return { success: true, txid: signature }
+	} catch (error) {
+		const errorMessage =
+			error instanceof Error ? error.message : 'Unknown swap error'
+		console.error('Swap failed:', errorMessage)
+		return {
+			success: false,
+			error: errorMessage,
+		}
+	}
+}
+
+/**
+ * 确保 LP 所需的两种代币都有足够余额
+ * 如果不足,尝试使用 USDC 兑换
+ */
+export async function ensureSufficientBalances(
+	connection: Connection,
+	keypair: Keypair,
+	tokenA: { mint: string; amount: number; decimals: number },
+	tokenB: { mint: string; amount: number; decimals: number }
+): Promise<{
+	success: boolean
+	swapTxids: string[]
+	error?: string
+}> {
+	const swapTxids: string[] = []
+
+	// 检查 tokenA
+	console.log(`\n--- Checking Token A (${tokenA.mint.slice(0, 8)}...) ---`)
+	const resultA = await swapIfNeeded(
+		connection,
+		keypair,
+		tokenA.mint,
+		tokenA.amount,
+		tokenA.decimals,
+		USDC_MINT
+	)
+
+	if (!resultA.success) {
+		// 如果 USDC 失败,尝试使用 USDT
+		console.log('USDC swap failed, trying USDT...')
+		const resultA_USDT = await swapIfNeeded(
+			connection,
+			keypair,
+			tokenA.mint,
+			tokenA.amount,
+			tokenA.decimals,
+			USDT_MINT
+		)
+
+		if (!resultA_USDT.success) {
+			return {
+				success: false,
+				swapTxids,
+				error: `Failed to get sufficient balance for Token A: ${resultA.error || resultA_USDT.error}`,
+			}
+		}
+
+		if (resultA_USDT.txid) {
+			swapTxids.push(resultA_USDT.txid)
+		}
+	} else if (resultA.txid) {
+		swapTxids.push(resultA.txid)
+	}
+
+	// 检查 tokenB
+	console.log(`\n--- Checking Token B (${tokenB.mint.slice(0, 8)}...) ---`)
+	const resultB = await swapIfNeeded(
+		connection,
+		keypair,
+		tokenB.mint,
+		tokenB.amount,
+		tokenB.decimals,
+		USDC_MINT
+	)
+
+	if (!resultB.success) {
+		// 如果 USDC 失败,尝试使用 USDT
+		console.log('USDC swap failed, trying USDT...')
+		const resultB_USDT = await swapIfNeeded(
+			connection,
+			keypair,
+			tokenB.mint,
+			tokenB.amount,
+			tokenB.decimals,
+			USDT_MINT
+		)
+
+		if (!resultB_USDT.success) {
+			return {
+				success: false,
+				swapTxids,
+				error: `Failed to get sufficient balance for Token B: ${resultB.error || resultB_USDT.error}`,
+			}
+		}
+
+		if (resultB_USDT.txid) {
+			swapTxids.push(resultB_USDT.txid)
+		}
+	} else if (resultB.txid) {
+		swapTxids.push(resultB.txid)
+	}
+
+	return { success: true, swapTxids }
+}