lushdog@outlook.com 4 săptămâni în urmă
părinte
comite
66eb2df849
3 a modificat fișierele cu 675 adăugiri și 0 ștergeri
  1. 288 0
      src/app/api/swap/route.ts
  2. 5 0
      src/app/components/Navigation.tsx
  3. 382 0
      src/app/swap/page.tsx

+ 288 - 0
src/app/api/swap/route.ts

@@ -0,0 +1,288 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { Keypair, Connection } from '@solana/web3.js'
+import bs58 from 'bs58'
+import {
+	fetchUltraOrder,
+	executeUltraSwap,
+	getTokenBalance,
+	fetchJupiterQuote,
+} from '@/lib/jupiter'
+
+const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'
+const USDT_MINT = 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB'
+
+export async function POST(request: NextRequest) {
+	try {
+		const body = await request.json()
+		const {
+			inputMint,
+			outputMint,
+			amount,
+			mode = 'usd',
+			slippageBps = 200,
+		} = body
+
+		if (!inputMint || !outputMint) {
+			return NextResponse.json(
+				{ error: 'inputMint and outputMint are required' },
+				{ status: 400 }
+			)
+		}
+
+		if (!amount || Number(amount) <= 0) {
+			return NextResponse.json(
+				{ error: 'amount must be greater than 0' },
+				{ status: 400 }
+			)
+		}
+
+		if (inputMint === outputMint) {
+			return NextResponse.json(
+				{ error: 'inputMint and outputMint cannot be the same' },
+				{ status: 400 }
+			)
+		}
+
+		const secretKey = process.env.SOL_SECRET_KEY
+		if (!secretKey) {
+			return NextResponse.json(
+				{ error: 'SOL_SECRET_KEY not configured' },
+				{ status: 500 }
+			)
+		}
+
+		const userKeypair = Keypair.fromSecretKey(bs58.decode(secretKey))
+		const walletAddress = userKeypair.publicKey.toBase58()
+
+		const rpcUrl =
+			process.env.SOL_RPC_URL || 'https://api.mainnet-beta.solana.com'
+		const connection = new Connection(rpcUrl, 'confirmed')
+
+		let inputAmountRaw: number
+
+		if (mode === 'usd') {
+			const usdValue = Number(amount)
+			const isInputStablecoin =
+				inputMint === USDC_MINT || inputMint === USDT_MINT
+
+			if (!isInputStablecoin) {
+				return NextResponse.json(
+					{
+						error: 'USD mode requires input token to be USDC or USDT',
+					},
+					{ status: 400 }
+				)
+			}
+
+			inputAmountRaw = Math.floor(usdValue * 1e6)
+
+			if (inputAmountRaw < 5e4) {
+				return NextResponse.json(
+					{ error: 'USD value too small (minimum $0.05)' },
+					{ status: 400 }
+				)
+			}
+
+			console.log(
+				`Swap (USD mode): $${usdValue} -> ${outputMint.slice(0, 8)}...`
+			)
+		} else {
+			const response = await fetch(
+				'https://api2.byreal.io/byreal/api/dex/v2/mint/list?page=1&pageSize=500&sort=desc'
+			)
+			const data = await response.json()
+			const mintList = data.result?.data?.records || []
+
+			const inputToken = mintList.find(
+				(m: { address: string }) => m.address === inputMint
+			)
+			const decimals = inputToken?.decimals || 9
+
+			inputAmountRaw = Math.floor(Number(amount) * Math.pow(10, decimals))
+
+			if (inputAmountRaw <= 0) {
+				return NextResponse.json(
+					{ error: 'Invalid token amount' },
+					{ status: 400 }
+				)
+			}
+
+			console.log(
+				`Swap (Amount mode): ${amount} ${inputToken?.symbol || inputMint.slice(0, 8)}... -> ${outputMint.slice(0, 8)}...`
+			)
+		}
+
+		const orderData = await fetchUltraOrder(
+			inputMint,
+			outputMint,
+			inputAmountRaw,
+			walletAddress,
+			slippageBps
+		)
+
+		if (!orderData.transaction) {
+			const errorMsg = orderData.errorMessage || 'No transaction returned'
+			console.error(`Ultra order error: ${errorMsg}`)
+			return NextResponse.json({ error: errorMsg }, { status: 400 })
+		}
+
+		if (orderData.routePlan && orderData.routePlan.length > 0) {
+			const routeLabels = orderData.routePlan
+				.map((r) => r.swapInfo?.label || 'Unknown')
+				.join(' -> ')
+			console.log(`Route: ${routeLabels}`)
+			console.log(
+				`Expected output: ${orderData.outAmount}, price impact: ${orderData.priceImpactPct}%`
+			)
+		}
+
+		const { VersionedTransaction } = await import('@solana/web3.js')
+		const swapTransactionBuf = Buffer.from(orderData.transaction, 'base64')
+		const transaction = VersionedTransaction.deserialize(swapTransactionBuf)
+		transaction.sign([userKeypair])
+
+		const signedTransaction = Buffer.from(transaction.serialize()).toString(
+			'base64'
+		)
+
+		const executeResult = await executeUltraSwap(
+			signedTransaction,
+			orderData.requestId
+		)
+
+		if (executeResult.status !== 'Success') {
+			throw new Error(executeResult.error || 'Execute failed')
+		}
+
+		const signature = executeResult.signature!
+		console.log(`Swap confirmed: https://solscan.io/tx/${signature}`)
+
+		const newBalance = await getTokenBalance(
+			connection,
+			userKeypair.publicKey,
+			outputMint
+		)
+		console.log(`New output token balance: ${newBalance.toFixed(6)}`)
+
+		return NextResponse.json({
+			success: true,
+			txid: signature,
+			inAmount: orderData.inAmount,
+			outAmount: orderData.outAmount,
+			priceImpact: orderData.priceImpactPct,
+			route: orderData.routePlan?.map((r) => r.swapInfo?.label).join(' -> '),
+			newBalance,
+		})
+	} catch (error: unknown) {
+		console.error('Swap Error:', error)
+		const errorMessage =
+			error instanceof Error ? error.message : 'Failed to swap'
+		return NextResponse.json({ error: errorMessage }, { status: 500 })
+	}
+}
+
+export async function GET(request: NextRequest) {
+	try {
+		const { searchParams } = new URL(request.url)
+		const inputMint = searchParams.get('inputMint')
+		const outputMint = searchParams.get('outputMint')
+		const amount = searchParams.get('amount')
+		const mode = searchParams.get('mode') || 'usd'
+
+		if (!inputMint || !outputMint || !amount) {
+			return NextResponse.json(
+				{ error: 'inputMint, outputMint and amount are required' },
+				{ status: 400 }
+			)
+		}
+
+		let inputAmountRaw: number
+
+		if (mode === 'usd') {
+			const usdValue = Number(amount)
+			const isInputStablecoin =
+				inputMint === USDC_MINT || inputMint === USDT_MINT
+
+			if (!isInputStablecoin) {
+				const response = await fetch(
+					'https://api2.byreal.io/byreal/api/dex/v2/mint/list?page=1&pageSize=500&sort=desc'
+				)
+				const data = await response.json()
+				const mintList = data.result?.data?.records || []
+
+				const inputToken = mintList.find(
+					(m: { address: string }) => m.address === inputMint
+				)
+				const price = Number(inputToken?.price || 0)
+
+				if (price <= 0) {
+					return NextResponse.json(
+						{ error: 'Cannot determine input token price' },
+						{ status: 400 }
+					)
+				}
+
+				const decimals = inputToken?.decimals || 9
+				inputAmountRaw = Math.floor((usdValue / price) * Math.pow(10, decimals))
+			} else {
+				inputAmountRaw = Math.floor(usdValue * 1e6)
+			}
+		} else {
+			const response = await fetch(
+				'https://api2.byreal.io/byreal/api/dex/v2/mint/list?page=1&pageSize=500&sort=desc'
+			)
+			const data = await response.json()
+			const mintList = data.result?.data?.records || []
+
+			const inputToken = mintList.find(
+				(m: { address: string }) => m.address === inputMint
+			)
+			const decimals = inputToken?.decimals || 9
+
+			inputAmountRaw = Math.floor(Number(amount) * Math.pow(10, decimals))
+		}
+
+		if (inputAmountRaw <= 0) {
+			return NextResponse.json({ error: 'Invalid amount' }, { status: 400 })
+		}
+
+		const quoteData = await fetchJupiterQuote(
+			inputMint,
+			outputMint,
+			inputAmountRaw,
+			'ExactIn',
+			200
+		)
+
+		const response = await fetch(
+			'https://api2.byreal.io/byreal/api/dex/v2/mint/list?page=1&pageSize=500&sort=desc'
+		)
+		const mintData = await response.json()
+		const mintList = mintData.result?.data?.records || []
+
+		const outputToken = mintList.find(
+			(m: { address: string }) => m.address === outputMint
+		)
+		const outputDecimals = outputToken?.decimals || 9
+
+		const outAmountRaw = BigInt(quoteData.outAmount)
+		const outAmountUi = Number(outAmountRaw) / Math.pow(10, outputDecimals)
+
+		return NextResponse.json({
+			success: true,
+			inAmount: quoteData.inAmount,
+			outAmount: quoteData.outAmount,
+			outAmountUi,
+			priceImpact: quoteData.priceImpactPct,
+			route: quoteData.routePlan
+				?.map((r) => r.swapInfo?.label)
+				.filter(Boolean)
+				.join(' -> '),
+		})
+	} catch (error: unknown) {
+		console.error('Quote Error:', error)
+		const errorMessage =
+			error instanceof Error ? error.message : 'Failed to get quote'
+		return NextResponse.json({ error: errorMessage }, { status: 500 })
+	}
+}

+ 5 - 0
src/app/components/Navigation.tsx

@@ -14,6 +14,10 @@ const menuItems = [
 		key: '/my-lp',
 		label: 'My LP',
 	},
+	{
+		key: '/swap',
+		label: 'Swap',
+	},
 	{
 		key: '/lp-copy',
 		label: '自动复制LP',
@@ -29,6 +33,7 @@ export default function Navigation() {
 	const selectedKey = {
 		'/position': '/position',
 		'/my-lp': '/my-lp',
+		'/swap': '/swap',
 		'/lp-copy': '/lp-copy',
 	}[pathname]
 

+ 382 - 0
src/app/swap/page.tsx

@@ -0,0 +1,382 @@
+'use client'
+
+import { useEffect, useState, useCallback } from 'react'
+import {
+	Button,
+	Select,
+	InputNumber,
+	Switch,
+	App,
+	Spin,
+	Image,
+	Card,
+} from 'antd'
+import { SwapOutlined } from '@ant-design/icons'
+
+interface MintInfo {
+	mint: string
+	symbol: string
+	decimals: number
+	logoURI: string
+	price: string
+	address: string
+}
+
+interface QuoteInfo {
+	inAmount: string
+	outAmount: string
+	outAmountUi: number
+	priceImpact: string
+	route: string
+}
+
+const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'
+
+function SwapPageContent() {
+	const { message } = App.useApp()
+	const [mintList, setMintList] = useState<MintInfo[]>([])
+	const [loading, setLoading] = useState(false)
+	const [quoting, setQuoting] = useState(false)
+	const [swapLoading, setSwapLoading] = useState(false)
+
+	const [inputToken, setInputToken] = useState<string>(USDC_MINT)
+	const [outputToken, setOutputToken] = useState<string>('')
+	const [inputAmount, setInputAmount] = useState<number | null>(null)
+	const [isUsdMode, setIsUsdMode] = useState(true)
+	const [quoteInfo, setQuoteInfo] = useState<QuoteInfo | null>(null)
+
+	const fetchMintList = async () => {
+		setLoading(true)
+		try {
+			const response = await fetch('/api/my-lp/mintList')
+			const data = await response.json()
+			setMintList(data.records || [])
+		} catch (error) {
+			console.error('Failed to fetch mint list:', error)
+			message.error('获取代币列表失败')
+		} finally {
+			setLoading(false)
+		}
+	}
+
+	const getQuote = useCallback(async () => {
+		if (!inputToken || !outputToken || !inputAmount || inputAmount <= 0) {
+			setQuoteInfo(null)
+			return
+		}
+
+		setQuoting(true)
+		try {
+			const response = await fetch(
+				`/api/swap?inputMint=${inputToken}&outputMint=${outputToken}&amount=${inputAmount}&mode=${isUsdMode ? 'usd' : 'amount'}`
+			)
+			const data = await response.json()
+
+			if (data.success) {
+				setQuoteInfo({
+					inAmount: data.inAmount,
+					outAmount: data.outAmount,
+					outAmountUi: data.outAmountUi,
+					priceImpact: data.priceImpact,
+					route: data.route,
+				})
+			} else {
+				setQuoteInfo(null)
+				console.error('Quote error:', data.error)
+			}
+		} catch (error) {
+			console.error('Failed to get quote:', error)
+			setQuoteInfo(null)
+		} finally {
+			setQuoting(false)
+		}
+	}, [inputToken, outputToken, inputAmount, isUsdMode])
+
+	useEffect(() => {
+		fetchMintList()
+	}, [])
+
+	useEffect(() => {
+		const timer = setTimeout(() => {
+			if (inputAmount && inputAmount > 0) {
+				getQuote()
+			}
+		}, 500)
+
+		return () => clearTimeout(timer)
+	}, [inputAmount, inputToken, outputToken, isUsdMode, getQuote])
+
+	const handleSwap = async () => {
+		if (!inputToken || !outputToken || !inputAmount || inputAmount <= 0) {
+			message.warning('请选择代币并输入金额')
+			return
+		}
+
+		setSwapLoading(true)
+		message.loading({ content: '正在兑换...', key: 'swap', duration: 0 })
+
+		try {
+			const response = await fetch('/api/swap', {
+				method: 'POST',
+				headers: { 'Content-Type': 'application/json' },
+				body: JSON.stringify({
+					inputMint: inputToken,
+					outputMint: outputToken,
+					amount: inputAmount,
+					mode: isUsdMode ? 'usd' : 'amount',
+					slippageBps: 200,
+				}),
+			})
+
+			const data = await response.json()
+
+			if (data.success) {
+				message.success({
+					content: `兑换成功!交易: ${data.txid.slice(0, 8)}...`,
+					key: 'swap',
+				})
+				window.open(`https://solscan.io/tx/${data.txid}`, '_blank')
+				setInputAmount(null)
+				setQuoteInfo(null)
+			} else {
+				message.error({ content: data.error || '兑换失败', key: 'swap' })
+			}
+		} catch (error) {
+			console.error('Swap error:', error)
+			message.error({ content: '兑换失败', key: 'swap' })
+		} finally {
+			setSwapLoading(false)
+		}
+	}
+
+	const handleSwitchTokens = () => {
+		const temp = inputToken
+		setInputToken(outputToken)
+		setOutputToken(temp)
+		setQuoteInfo(null)
+	}
+
+	const getTokenInfo = (address: string): MintInfo | undefined => {
+		return mintList.find((m) => m.address === address)
+	}
+
+	const formatNumber = (num: number, decimals: number = 6): string => {
+		if (num >= 1000) {
+			return num.toFixed(2)
+		} else if (num >= 1) {
+			return num.toFixed(4)
+		}
+		return num.toFixed(decimals)
+	}
+
+	const getPlaceholder = (): string => {
+		if (isUsdMode) {
+			return '输入 USD 金额'
+		}
+		const token = getTokenInfo(inputToken)
+		return `输入 ${token?.symbol || '代币'} 数量`
+	}
+
+	return (
+		<main style={{ padding: '24px', maxWidth: '500px', margin: '0 auto' }}>
+			<Card title="代币兑换">
+				<Spin spinning={loading}>
+					<div className="space-y-4">
+						<div className="flex items-center justify-between mb-4">
+							<span className="text-sm text-gray-500">
+								{isUsdMode ? '按 USD 金额' : '按代币数量'}
+							</span>
+							<Switch
+								checked={isUsdMode}
+								onChange={setIsUsdMode}
+								checkedChildren="USD"
+								unCheckedChildren="数量"
+							/>
+						</div>
+
+						<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
+							<div className="text-xs text-gray-400 mb-2">支付</div>
+							<div className="flex items-center gap-2 mb-2">
+								<Select
+									value={inputToken}
+									onChange={(value) => {
+										setInputToken(value)
+										setQuoteInfo(null)
+									}}
+									style={{ width: 150 }}
+									showSearch
+									placeholder="选择代币"
+									optionFilterProp="label"
+									options={mintList.map((m) => ({
+										label: m.symbol,
+										value: m.address,
+									}))}
+									optionRender={(option) => (
+										<div className="flex items-center gap-2">
+											<Image
+												src={
+													mintList.find((m) => m.address === option.value)
+														?.logoURI
+												}
+												alt={option.label as string}
+												width={20}
+												height={20}
+												style={{ borderRadius: '50%' }}
+												preview={false}
+												fallback="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
+											/>
+											<span>{option.label}</span>
+										</div>
+									)}
+								/>
+								<InputNumber
+									value={inputAmount}
+									onChange={(value) => setInputAmount(value)}
+									placeholder={getPlaceholder()}
+									style={{ flex: 1 }}
+									min={0}
+									step={isUsdMode ? 1 : 0.001}
+									precision={isUsdMode ? 2 : 6}
+								/>
+							</div>
+							{!isUsdMode && getTokenInfo(inputToken)?.price && (
+								<div className="text-xs text-gray-400">
+									≈ $
+									{formatNumber(
+										(inputAmount || 0) *
+											Number(getTokenInfo(inputToken)?.price || 0)
+									)}
+								</div>
+							)}
+						</div>
+
+						<div className="flex justify-center">
+							<Button
+								type="text"
+								icon={<SwapOutlined />}
+								onClick={handleSwitchTokens}
+								disabled={!inputToken || !outputToken}
+							/>
+						</div>
+
+						<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
+							<div className="text-xs text-gray-400 mb-2">获得</div>
+							<div className="flex items-center gap-2">
+								<Select
+									value={outputToken}
+									onChange={(value) => {
+										setOutputToken(value)
+										setQuoteInfo(null)
+									}}
+									style={{ width: 150 }}
+									showSearch
+									placeholder="选择代币"
+									optionFilterProp="label"
+									options={mintList.map((m) => ({
+										label: m.symbol,
+										value: m.address,
+									}))}
+									optionRender={(option) => (
+										<div className="flex items-center gap-2">
+											<Image
+												src={
+													mintList.find((m) => m.address === option.value)
+														?.logoURI
+												}
+												alt={option.label as string}
+												width={20}
+												height={20}
+												style={{ borderRadius: '50%' }}
+												preview={false}
+												fallback="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
+											/>
+											<span>{option.label}</span>
+										</div>
+									)}
+								/>
+								<div className="flex-1 text-right">
+									{quoting ? (
+										<Spin size="small" />
+									) : quoteInfo ? (
+										<div>
+											<div className="text-lg font-bold">
+												{formatNumber(quoteInfo.outAmountUi)}
+											</div>
+											{getTokenInfo(outputToken)?.price && (
+												<div className="text-xs text-gray-400">
+													≈ $
+													{formatNumber(
+														quoteInfo.outAmountUi *
+															Number(getTokenInfo(outputToken)?.price || 0)
+													)}
+												</div>
+											)}
+										</div>
+									) : (
+										<span className="text-gray-400">-</span>
+									)}
+								</div>
+							</div>
+						</div>
+
+						{quoteInfo && (
+							<div className="text-xs text-gray-500 space-y-1 p-2 bg-gray-50 dark:bg-gray-800 rounded">
+								<div className="flex justify-between">
+									<span>价格影响:</span>
+									<span
+										style={{
+											color:
+												Number(quoteInfo.priceImpact) > 1
+													? '#ff4d4f'
+													: Number(quoteInfo.priceImpact) > 0.5
+														? '#faad14'
+														: '#52c41a',
+										}}
+									>
+										{(Number(quoteInfo.priceImpact) * 100).toFixed(2)}%
+									</span>
+								</div>
+								{quoteInfo.route && (
+									<div className="flex justify-between">
+										<span>路由:</span>
+										<span className="text-right max-w-[200px] truncate">
+											{quoteInfo.route}
+										</span>
+									</div>
+								)}
+							</div>
+						)}
+
+						<Button
+							type="primary"
+							block
+							size="large"
+							onClick={handleSwap}
+							loading={swapLoading}
+							disabled={
+								!inputToken ||
+								!outputToken ||
+								!inputAmount ||
+								inputAmount <= 0 ||
+								inputToken === outputToken
+							}
+						>
+							{inputToken === outputToken && inputToken
+								? '请选择不同的代币'
+								: '兑换'}
+						</Button>
+					</div>
+				</Spin>
+			</Card>
+
+			<div className="mt-4 text-xs text-gray-400 text-center">
+				<p>使用 Jupiter Ultra API 进行兑换</p>
+				<p>滑点: 2%</p>
+			</div>
+		</main>
+	)
+}
+
+export default function SwapPage() {
+	return <SwapPageContent />
+}