-
Notifications
You must be signed in to change notification settings - Fork 130
add Jupiter Ultra Swap #154
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
740621b
eb234c7
e9376aa
186e6c0
55c7350
b367c6b
f6374d4
cc9ed1e
123d65c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| # QuickNode RPC URL | ||
| # Get your QuickNode RPC endpoint from https://www.quicknode.com/ | ||
| QUICKNODE_RPC_URL=your_quicknode_rpc_endpoint_here | ||
|
|
||
| # Jupiter API Key (optional but recommended for better rate limits) | ||
| # Get your Jupiter API key from https://portal.jup.ag/ | ||
| JUPITER_API_KEY=your_jupiter_api_key_here |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. | ||
|
|
||
| # dependencies | ||
| /node_modules | ||
| /.pnp | ||
| .pnp.js | ||
|
|
||
| # testing | ||
| /coverage | ||
|
|
||
| # next.js | ||
| /.next/ | ||
| /out/ | ||
|
|
||
| # production | ||
| /build | ||
|
|
||
| # misc | ||
| .DS_Store | ||
| *.pem | ||
|
|
||
| # debug | ||
| npm-debug.log* | ||
| yarn-debug.log* | ||
| yarn-error.log* | ||
|
|
||
| # local env files | ||
| .env*.local | ||
| .env | ||
|
|
||
| # vercel | ||
| .vercel | ||
|
|
||
| # typescript | ||
| *.tsbuildinfo | ||
| next-env.d.ts | ||
|
|
||
| # cursor | ||
| /.cursor/ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| # Jupiter Ultra Swap Demo | ||
|
|
||
| A single-page Solana swap UI demonstrating token swaps using Jupiter Ultra API, QuickNode RPC, Solana Wallet Adapter, and Solana Kit. | ||
|
|
||
| ## Features | ||
|
|
||
| - Connect Solana wallet (Phantom, Backpack, Solflare) | ||
| - Select From and To tokens | ||
| - Enter swap amount | ||
| - View token balances | ||
| - Execute swaps via Jupiter Ultra | ||
| - View transaction status and explorer links | ||
|
|
||
| ## Setup | ||
|
|
||
| 1. Install dependencies: | ||
|
|
||
| ```bash | ||
| npm install | ||
| ``` | ||
|
|
||
| 2. Create `.env.local` file (copy from `.env.example`): | ||
|
|
||
| ```bash | ||
| cp .env.example .env.local | ||
| ``` | ||
|
|
||
| Then edit `.env.local` and add your API keys: | ||
|
|
||
| ```bash | ||
| QUICKNODE_RPC_URL=your_quicknode_rpc_endpoint_here | ||
| JUPITER_API_KEY=your_jupiter_api_key_here | ||
| ``` | ||
|
|
||
| **Note:** | ||
|
|
||
| - **QUICKNODE_RPC_URL** (required): Get your QuickNode RPC endpoint from [QuickNode](https://www.quicknode.com/) or use the default public URL `https://api.mainnet-beta.solana.com` | ||
| - **JUPITER_API_KEY** (required for full functionality): Get your Jupiter API key from [Jupiter API Portal](https://portal.jup.ag/). | ||
|
|
||
| 3. Run the development server: | ||
|
|
||
| ```bash | ||
| npm run dev | ||
| ``` | ||
|
|
||
| 4. Open [http://localhost:3000](http://localhost:3000) in your browser. | ||
|
|
||
| ## Project Structure | ||
|
|
||
| ``` text | ||
| ├── app/ | ||
| │ ├── api/ # Next.js API routes (server-side) | ||
| │ │ ├── tokens/ # Token list endpoint | ||
| │ │ ├── balances/ # Token balances endpoint | ||
| │ │ ├── quote/ # Swap quote endpoint | ||
| │ │ ├── execute/ # Swap execution endpoint | ||
| │ │ ├── rpc/ # RPC proxy endpoint | ||
| │ ├── layout.tsx # Root layout with WalletProvider | ||
| │ ├── page.tsx # Main swap page | ||
| │ ├── globals.css # Global styles | ||
| │ └── providers/ | ||
| │ └── WalletProvider.tsx | ||
| ├── components/ | ||
| │ ├── SwapCard.tsx # Main card container | ||
| │ ├── WalletButton.tsx # Wallet connect button | ||
| │ ├── TokenSelector.tsx # Token dropdown | ||
| │ ├── TokenInput.tsx # Token input row | ||
| │ ├── SwapButton.tsx # Swap action button | ||
| │ └── StatusMessage.tsx # Status/error messages | ||
| ├── hooks/ | ||
| │ ├── useTokenBalances.ts # Token balance management | ||
| │ ├── useTokenList.ts # Token list fetching | ||
| │ ├── useQuote.ts # Swap quote fetching | ||
| │ └── useSwap.ts # Swap execution logic | ||
| └── lib/ | ||
| ├── jupiter.ts # Jupiter API client (calls API routes) | ||
| ├── solana-client.ts # Solana Kit RPC client | ||
| └── types.ts # TypeScript types | ||
| ``` | ||
|
|
||
| ## Usage | ||
|
|
||
| 1. Connect your Solana wallet | ||
| 2. Select a "From" token (default: SOL) | ||
| 3. Select a "To" token (default: USDC) | ||
| 4. Enter the amount you want to swap | ||
| 5. Click "Swap" and approve the transaction in your wallet | ||
| 6. Wait for confirmation and view the transaction link |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,163 @@ | ||
| import { NextResponse } from "next/server"; | ||
| import { assertIsAddress } from "@solana/kit"; | ||
|
|
||
| const JUPITER_ULTRA_BASE = "https://api.jup.ag/ultra"; | ||
| const JUPITER_API_KEY = process.env.JUPITER_API_KEY; | ||
| const QUICKNODE_RPC_URL = process.env.QUICKNODE_RPC_URL; | ||
|
|
||
| // Validate Solana wallet address using @solana/kit to prevent SSRF attacks | ||
| function isValidWalletAddress(address: string): boolean { | ||
| try { | ||
| assertIsAddress(address); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| // Helper function to create request headers with API key | ||
| function getHeaders(): HeadersInit { | ||
| const headers: HeadersInit = { | ||
| "Accept": "application/json", | ||
| "Content-Type": "application/json", | ||
| }; | ||
|
|
||
| if (JUPITER_API_KEY) { | ||
| headers["x-api-key"] = JUPITER_API_KEY; | ||
| } | ||
|
|
||
| return headers; | ||
| } | ||
|
|
||
| // Fetch SOL balance via RPC when Jupiter API key is not available | ||
| async function fetchSOLBalanceViaRPC(walletAddress: string): Promise<number> { | ||
| if (!QUICKNODE_RPC_URL) { | ||
| // If no RPC URL, return 0 (fallback to public RPC would require different approach) | ||
| return 0; | ||
| } | ||
|
|
||
| try { | ||
| const rpcRequest = { | ||
| jsonrpc: "2.0", | ||
| id: 1, | ||
| method: "getBalance", | ||
| params: [walletAddress], | ||
| }; | ||
|
|
||
| const response = await fetch(QUICKNODE_RPC_URL, { | ||
| method: "POST", | ||
| headers: { | ||
| "Content-Type": "application/json", | ||
| }, | ||
| body: JSON.stringify(rpcRequest), | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| return 0; | ||
| } | ||
|
|
||
| const data = await response.json(); | ||
| return data.result?.value || 0; | ||
| } catch (error) { | ||
| console.error("Error fetching SOL balance via RPC:", error); | ||
| return 0; | ||
| } | ||
| } | ||
|
|
||
| export async function GET(request: Request) { | ||
| try { | ||
| const { searchParams } = new URL(request.url); | ||
| const walletAddress = searchParams.get("walletAddress"); | ||
|
|
||
| if (!walletAddress) { | ||
| return NextResponse.json( | ||
| { error: "walletAddress parameter is required" }, | ||
| { status: 400 } | ||
| ); | ||
| } | ||
|
|
||
| // Validate wallet address to prevent SSRF attacks | ||
| if (!isValidWalletAddress(walletAddress)) { | ||
| return NextResponse.json( | ||
| { error: "Invalid walletAddress parameter" }, | ||
| { status: 400 } | ||
| ); | ||
| } | ||
|
|
||
| // If Jupiter API key is missing, fetch SOL balance via RPC as fallback | ||
| // This allows users to swap SOL even without the API key | ||
| if (!JUPITER_API_KEY) { | ||
| const balances: any[] = []; | ||
|
|
||
| // Fetch SOL balance via RPC | ||
| const solBalanceLamports = await fetchSOLBalanceViaRPC(walletAddress); | ||
|
|
||
| if (solBalanceLamports > 0) { | ||
| balances.push({ | ||
| mint: "So11111111111111111111111111111111111111112", // SOL mint address | ||
| balance: solBalanceLamports, | ||
| decimals: 9, | ||
| }); | ||
| } | ||
|
|
||
| // Note: SPL token balances require Jupiter API key, so we only return SOL balance | ||
| return NextResponse.json(balances, { status: 200 }); | ||
| } | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| const response = await fetch(`${JUPITER_ULTRA_BASE}/v1/holdings/${walletAddress}`, { | ||
| headers: getHeaders(), | ||
| cache: "no-store", | ||
| }); | ||
|
||
|
|
||
| if (!response.ok) { | ||
| return NextResponse.json([], { status: 200 }); | ||
| } | ||
|
|
||
| const data = await response.json(); | ||
| const balances: any[] = []; | ||
|
|
||
| // Add SOL balance (native Solana) | ||
| if (data.amount && parseInt(data.amount) > 0) { | ||
| balances.push({ | ||
| mint: "So11111111111111111111111111111111111111112", // SOL mint address | ||
| balance: parseInt(data.amount), | ||
|
||
| decimals: 9, | ||
| }); | ||
| } | ||
|
|
||
| // Add SPL token balances | ||
| if (data.tokens && typeof data.tokens === "object") { | ||
| for (const [mint, tokenAccounts] of Object.entries(data.tokens)) { | ||
| if (Array.isArray(tokenAccounts) && tokenAccounts.length > 0) { | ||
| // Sum up all token accounts for this mint | ||
| let totalAmount = 0; | ||
| let decimals = 0; | ||
|
|
||
| for (const account of tokenAccounts as any[]) { | ||
| if (account.amount) { | ||
| totalAmount += parseInt(account.amount); | ||
cursor[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| decimals = account.decimals || 0; | ||
| } | ||
| } | ||
|
|
||
| if (totalAmount > 0) { | ||
| balances.push({ | ||
| mint, | ||
| balance: totalAmount, | ||
| decimals, | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return NextResponse.json(balances); | ||
| } catch (error) { | ||
| console.error("Error fetching token balances:", error); | ||
| return NextResponse.json( | ||
| { error: "Failed to fetch token balances" }, | ||
| { status: 500 } | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| import { NextResponse } from "next/server"; | ||
|
|
||
| const JUPITER_ULTRA_BASE = "https://api.jup.ag/ultra"; | ||
| const JUPITER_API_KEY = process.env.JUPITER_API_KEY; | ||
|
|
||
| // Helper function to create request headers with API key | ||
| function getHeaders(): HeadersInit { | ||
| const headers: HeadersInit = { | ||
| "Accept": "application/json", | ||
| "Content-Type": "application/json", | ||
| }; | ||
|
|
||
| if (JUPITER_API_KEY) { | ||
| headers["x-api-key"] = JUPITER_API_KEY; | ||
| } | ||
|
|
||
| return headers; | ||
| } | ||
|
|
||
| // Helper function to parse error messages from API responses | ||
| async function parseErrorResponse(response: Response): Promise<string> { | ||
| const responseText = await response.text(); | ||
| try { | ||
| const errorData = JSON.parse(responseText); | ||
| return errorData.error || errorData.message || `Request failed: ${response.status}`; | ||
| } catch { | ||
| return responseText || `Request failed: ${response.status}`; | ||
| } | ||
| } | ||
|
|
||
| export async function POST(request: Request) { | ||
| try { | ||
| const body = await request.json(); | ||
| const { signedTransaction, requestId } = body; | ||
|
|
||
| if (!signedTransaction) { | ||
| return NextResponse.json( | ||
| { error: "signedTransaction is required" }, | ||
| { status: 400 } | ||
| ); | ||
| } | ||
|
|
||
| if (!JUPITER_API_KEY) { | ||
| return NextResponse.json( | ||
| { error: "Jupiter Ultra API requires an API key." }, | ||
| { status: 500 } | ||
| ); | ||
| } | ||
|
|
||
| const response = await fetch(`${JUPITER_ULTRA_BASE}/v1/execute`, { | ||
| method: "POST", | ||
| headers: getHeaders(), | ||
| body: JSON.stringify({ | ||
| signedTransaction, | ||
| requestId, | ||
| }), | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| const errorMessage = await parseErrorResponse(response); | ||
| return NextResponse.json( | ||
| { error: errorMessage }, | ||
| { status: response.status } | ||
| ); | ||
| } | ||
|
|
||
| const result = await response.json(); | ||
| return NextResponse.json(result); | ||
| } catch (error) { | ||
| console.error("Error executing swap:", error); | ||
| return NextResponse.json( | ||
| { error: error instanceof Error ? error.message : "Failed to execute swap" }, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One of the weird things about JS is that you can throw anything - an array, an object, a string, a number - but in reality, nobody ever does. So now everyone has to write |
||
| { status: 500 } | ||
| ); | ||
| } | ||
| } | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(Nitpick, feel free to ignore) for consistency with the rest of the code, I always use the generics syntax. Eg you have
Promise<number>elsewhere, useArray<any>here.AI can change this in one step!