Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
12 changes: 12 additions & 0 deletions solana/titan-swap/.env.local.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# QuickNode Solana RPC endpoint (server-only — never exposed to the browser).
# The browser's wallet adapter talks to the same-origin /api/rpc proxy, which
# forwards to this URL, so the embedded token stays on the server.
QUICKNODE_RPC_URL=https://your-endpoint.solana-mainnet.quiknode.pro/YOUR_TOKEN/

# Titan Gateway add-on base URL (server-only — never exposed to the browser).
# This is the endpoint shown for the Titan Gateway add-on in your QuickNode
# dashboard. With or without a trailing /api/v1 — the proxy normalizes it.
TITAN_GATEWAY_URL=https://your-endpoint.solana-mainnet.quiknode.pro/YOUR_TOKEN/

# Optional bearer token, only if your Gateway URL doesn't already embed auth.
# TITAN_GATEWAY_AUTH=your_titan_jwt
39 changes: 39 additions & 0 deletions solana/titan-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/
115 changes: 115 additions & 0 deletions solana/titan-swap/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# Titan Swap — Meta-Aggregation on Solana

A single-page Solana swap UI built on the **Titan Gateway** meta-aggregation API,
served through **Quicknode**. Instead of hiding routing behind a single number,
this app surfaces what makes meta-aggregation interesting: multiple providers
competing for the best route, the on-chain venues a route touches, and a
self-custodial build → sign → send flow you own end to end.

## What it shows off

- **Provider race** — Titan sources quotes from several providers (its own DART
router plus other aggregators and RFQ venues). The UI shows every competing
quote side by side, the basis points each is behind the leader, and which one
Titan picks as the expected winner.
- **Routing venues** — lights up the on-chain venues the winning route actually
touches, derived by intersecting the route's instruction program ids with the
venue program ids from `GET /api/v1/venues`.
- **Composable instructions** — Titan returns instructions + address lookup
tables, not a sealed transaction. The app builds a v0 transaction itself, the
wallet signs it, and it's submitted and confirmed through Quicknode RPC.
- **Accurate vs. Fast** — a toggle that maps to Titan's `simulate` parameter,
with the round-trip latency shown next to the results.
- **Price vs. swap endpoints** — an unconnected wallet uses the lightweight
`/quote/price` endpoint for an indicative rate; a connected wallet uses
`/quote/swap` for the full provider race and executable instructions.

## Architecture

Both credentials stay on the server. Browser code talks to local Next.js route
handlers: `/api/titan/*` calls the Gateway (decoding MessagePack and normalizing
pubkeys), and `/api/rpc` proxies Solana JSON-RPC to the QuickNode endpoint.
Neither the Gateway auth nor the RPC token ever reaches the client.

```
Browser ──> /api/titan/* ──> Titan Gateway (MessagePack decode, server-only)
└─> /api/rpc ──> QuickNode Solana RPC (token server-only)
```

Because the RPC proxy carries only HTTP JSON-RPC (no WebSocket), the app
confirms transactions by polling `getSignatureStatuses` rather than via a
signature subscription.

## Setup

1. Install dependencies:
```bash
npm install
```

2. Create `.env.local` (see `.env.local.example`). Both are server-only:
```bash
QUICKNODE_RPC_URL=your_quicknode_solana_rpc_url
TITAN_GATEWAY_URL=your_titan_gateway_addon_url
# TITAN_GATEWAY_AUTH=optional_bearer_token
```
Enable the [Titan Gateway add-on](https://marketplace.quicknode.com/add-on/titan-gateway)
on your Quicknode endpoint to get the Gateway URL.

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

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

## Tech stack

- **Framework:** Next.js 16 (App Router, server route handlers)
- **UI:** React 19 + Tailwind CSS v4 (Quicknode dark design tokens)
- **Wallets:** Solana Wallet Adapter (Phantom, Solflare)
- **Chain:** Solana mainnet via Quicknode RPC
- **Swap API:** Titan Gateway meta-aggregation (`@msgpack/msgpack` decode)
- **Transactions:** `@solana/web3.js` v0 transaction assembly

## Project structure

```
├── app/
│ ├── api/titan/ # Server proxy: info, providers, venues, price, swap
│ ├── api/rpc/ # Server proxy: Solana JSON-RPC (hides QuickNode token)
│ ├── layout.tsx
│ ├── page.tsx # Main swap UI
│ ├── globals.css # Tailwind v4 + Quicknode dark design tokens
│ └── providers/WalletProvider.tsx
├── components/
│ ├── ProviderRace.tsx # Competing-provider visualization
│ ├── VenueSplit.tsx # Venues touched by the winning route
│ ├── SimulationToggle.tsx # Accurate vs. Fast (simulate param)
│ ├── SlippageControl.tsx
│ ├── TokenInput.tsx / TokenSelector.tsx / SwapButton.tsx / StatusMessage.tsx
│ └── SwapCard.tsx / WalletButton.tsx
├── hooks/
│ ├── useQuote.ts # /quote/swap (connected) or /quote/price
│ ├── useSwap.ts # build → sign → send → confirm
│ ├── useTokenBalances.ts # balances via Quicknode RPC
│ ├── useTokenList.ts
│ └── useTitanMeta.ts # providers / venues / info
└── lib/
├── titan-server.ts # server-only Gateway client (MessagePack)
├── titan.ts # client fetchers for /api/titan/*
├── build-swap-tx.ts # instructions + ALTs -> VersionedTransaction
├── tokens.ts # token metadata registry
└── types.ts
```

## Notes

- Solana mainnet only.
- Titan Gateway supports exact-in swaps; output is always estimated.
- No priority-fee or tip injection — the focus is Titan's routing, not
transaction-landing strategy.

## License

ISC
41 changes: 41 additions & 0 deletions solana/titan-swap/app/api/rpc/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { NextResponse } from "next/server";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

/**
* Server-side proxy for Solana JSON-RPC.
*
* The browser's wallet-adapter Connection points at this same-origin route, so
* the QuickNode endpoint (which embeds an auth token) never reaches the client.
* Only HTTP JSON-RPC is forwarded — the app confirms transactions by polling
* getSignatureStatuses rather than via a WebSocket subscription.
*/
const RPC_URL = process.env.QUICKNODE_RPC_URL;

export async function POST(req: Request) {
if (!RPC_URL) {
return NextResponse.json(
{ error: "QUICKNODE_RPC_URL is not set. Add it to .env.local." },
{ status: 502 }
);
}

const body = await req.text();
try {
const res = await fetch(RPC_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
cache: "no-store",
});
const text = await res.text();
return new NextResponse(text, {
status: res.status,
headers: { "Content-Type": "application/json" },
});
} catch (err) {
const message = err instanceof Error ? err.message : "RPC request failed";
return NextResponse.json({ error: message }, { status: 502 });
}
}
14 changes: 14 additions & 0 deletions solana/titan-swap/app/api/titan/info/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { fetchInfo } from "@/lib/titan-server";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

export async function GET() {
try {
return NextResponse.json(await fetchInfo());
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to load info";
return NextResponse.json({ error: message }, { status: 502 });
}
}
33 changes: 33 additions & 0 deletions solana/titan-swap/app/api/titan/price/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { fetchPrice } from "@/lib/titan-server";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

export async function GET(req: Request) {
const sp = new URL(req.url).searchParams;
const inputMint = sp.get("inputMint");
const outputMint = sp.get("outputMint");
const amount = sp.get("amount");

if (!inputMint || !outputMint || !amount) {
return NextResponse.json(
{ error: "inputMint, outputMint and amount are required" },
{ status: 400 }
);
}

try {
const slippageBps = sp.get("slippageBps");
const price = await fetchPrice({
inputMint,
outputMint,
amount,
slippageBps: slippageBps ? Number(slippageBps) : undefined,
});
return NextResponse.json(price);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to fetch price";
return NextResponse.json({ error: message }, { status: 502 });
}
}
14 changes: 14 additions & 0 deletions solana/titan-swap/app/api/titan/providers/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { fetchProviders } from "@/lib/titan-server";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

export async function GET() {
try {
return NextResponse.json(await fetchProviders());
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to load providers";
return NextResponse.json({ error: message }, { status: 502 });
}
}
37 changes: 37 additions & 0 deletions solana/titan-swap/app/api/titan/swap/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { fetchSwap } from "@/lib/titan-server";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

export async function GET(req: Request) {
const sp = new URL(req.url).searchParams;
const inputMint = sp.get("inputMint");
const outputMint = sp.get("outputMint");
const amount = sp.get("amount");
const userPublicKey = sp.get("userPublicKey");

if (!inputMint || !outputMint || !amount || !userPublicKey) {
return NextResponse.json(
{ error: "inputMint, outputMint, amount and userPublicKey are required" },
{ status: 400 }
);
}

try {
const slippageBps = sp.get("slippageBps");
const simulate = sp.get("simulate");
const swap = await fetchSwap({
inputMint,
outputMint,
amount,
userPublicKey,
slippageBps: slippageBps ? Number(slippageBps) : undefined,
simulate: simulate != null ? simulate === "true" : undefined,
});
return NextResponse.json(swap);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to fetch swap";
return NextResponse.json({ error: message }, { status: 502 });
}
}
14 changes: 14 additions & 0 deletions solana/titan-swap/app/api/titan/venues/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { fetchVenues } from "@/lib/titan-server";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

export async function GET() {
try {
return NextResponse.json(await fetchVenues());
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to load venues";
return NextResponse.json({ error: message }, { status: 502 });
}
}
Loading