Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions solana/jupiter-ultra-swap/.env.example
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
39 changes: 39 additions & 0 deletions solana/jupiter-ultra-swap/.gitignore
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/
107 changes: 107 additions & 0 deletions solana/jupiter-ultra-swap/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# 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:**
- Get your QuickNode RPC endpoint from [QuickNode](https://www.quicknode.com/)
- Get your Jupiter API key from [Jupiter API Portal](https://portal.jup.ag/). The API key is optional but recommended for better rate limits and reliability.
- These API keys are stored server-side and never exposed to the client. They are used in Next.js API routes only.

3. Run the development server:
```bash
npm run dev
```

4. Open [http://localhost:3000](http://localhost:3000) in your browser.

## Tech Stack

- **Framework:** Next.js 14 (App Router)
- **UI:** React + Tailwind CSS
- **Wallets:** Solana Wallet Adapter for React
- **Blockchain:** Solana mainnet via QuickNode RPC
- **Swap API:** Jupiter Ultra API
- **Solana SDK:** @solana/kit v5

## Project Structure

```
├── 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
│ │ └── rpc-endpoint/ # RPC URL 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
│ └── 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

## Notes

- This app uses Solana mainnet only
- All RPC calls go through QuickNode endpoint (proxied via API routes)
- Swaps are executed via Jupiter Ultra API (proxied via API routes)
- API keys are stored server-side and never exposed to the client
- Transaction signing uses Solana Wallet Adapter
- Solana Kit is used for RPC operations

## License

ISC

111 changes: 111 additions & 0 deletions solana/jupiter-ultra-swap/app/api/balances/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
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;

// 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;
}

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) {
return NextResponse.json([], { status: 200 });
}

const response = await fetch(`${JUPITER_ULTRA_BASE}/v1/holdings/${walletAddress}`, {
headers: getHeaders(),
cache: "no-store",
});
Comment on lines +107 to +110

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

The
URL
of this request depends on a
user-provided value
.

Copilot Autofix

AI 15 days ago

Copilot could not generate an autofix suggestion

Copilot could not generate an autofix suggestion for this alert. Try pushing a new commit or if the problem persists contact support.

Copy link

@mikemaccana mikemaccana Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mike I think you can resolve this due your isAddress()validation.


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),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseInt is base16 by default, use Number() or parseInt(string, 10). Number is better though.

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);
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 }
);
}
}

77 changes: 77 additions & 0 deletions solana/jupiter-ultra-swap/app/api/execute/route.ts
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" },

Choose a reason for hiding this comment

The 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 error instanceof Error. This isn't useful advice just an old man grumbling. 😅

{ status: 500 }
);
}
}

Loading