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