|
|
@@ -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 }
|
|
|
+}
|