Skip to content
Merged
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
1 change: 1 addition & 0 deletions docs/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ This is the canonical entry point for project documentation. Everything below sh

- `docs/integrations/pinata.md` — IPFS upload integration
- `docs/integrations/splits.md` — 0xSplits droposal revenue sharing
- `docs/integrations/swap.md` — 0x Swap API v2 integration powering /swap, including affiliate-fee config

## Specs

Expand Down
75 changes: 75 additions & 0 deletions docs/integrations/swap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# 0x Swap Integration

The `/swap` page lets users trade ETH, WETH, USDC, GNARS, and a few other Base ERC-20s
through the [0x Swap API v2](https://0x.org/docs/0x-swap-api/introduction). Routing is
handled by 0x's allowance-holder endpoints, and all transaction signing happens through
the existing thirdweb wallet layer (`useWriteAccount`).

## Architecture

```
src/app/swap/
page.tsx server component — metadata + page chrome
SwapWidget.tsx "use client" — token pickers, debounced price, approve, swap

src/app/api/0x/
price/route.ts GET proxy → api.0x.org/swap/allowance-holder/price
quote/route.ts GET proxy → api.0x.org/swap/allowance-holder/quote
```

The proxies exist so the `0x-api-key` header stays server-side, and so the affiliate-fee
parameters can be injected without exposing the recipient address in the client bundle.

## Flow

1. User picks sell/buy tokens and enters an amount.
2. After 600 ms of idle, `SwapWidget` calls `/api/0x/price` with `chainId`, `sellToken`,
`buyToken`, `sellAmount`, `taker`, and (optionally) `fee=1`.
3. If the response includes `issues.allowance`, the widget shows an "Approve" button.
Approval is signed via `prepareContractCall` + `sendTransaction` against the user's
active thirdweb account and confirmed via `waitForReceipt`.
4. Once approved (or for ETH), the "Swap" button calls `/api/0x/quote` to get a firm
transaction (`{ to, data, value, gas }`), wraps it with `prepareTransaction`, and
sends it via the same thirdweb account.
5. Wrong-network state shows a "Switch to Base" CTA that calls
`wallet.switchChain(thirdwebBase)`.

## Configuration

| Setting | Source | Notes |
| --------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `ZEROX_API_KEY` | env (server-only) | 0x API key. Required — proxy returns `500` without it. |
| Fee recipient | `DAO_ADDRESSES.treasury` | Hardcoded to the canonical Gnars treasury in `src/lib/config.ts`. Override via `NEXT_PUBLIC_TREASURY_ADDRESS` like every other DAO address. |
| Fee rate | `SWAP_FEE_BPS` in `src/lib/config.ts` | Defaults to `50` (0.5%). Edit the constant to change. |

Only `ZEROX_API_KEY` is read from the environment. Everything else ships with the
code so the fee destination and rate are auditable in git rather than hidden in
deploy-time secrets.

## Affiliate fee behaviour

The fee is **opt-in per request**: the client appends `&fee=1` to its proxy call,
the proxy sees this flag and injects three params before forwarding to 0x:

```
swapFeeRecipient = DAO_ADDRESSES.treasury
swapFeeBps = SWAP_FEE_BPS (default 50)
swapFeeToken = <buyToken> (fee is taken on the asset the user receives)
```

Both `/api/0x/price` and `/api/0x/quote` apply identical logic so the indicative
price matches the executed quote. The "Support Gnars treasury (0.5% fee)" checkbox
in `SwapWidget` defaults to **checked** — users can untick it to skip the fee.

## Notes & deviations from the SkateHive reference

- **No multi-chain.** Gnars lives on Base only; the chainId is hardcoded to `8453`
client-side.
- **No Hive / Zora bonding-curve routes.** Standard 0x ERC-20 swaps only.
- **shadcn / Tailwind, not Chakra.** UI is rebuilt with `Card`, `Button`, `Input`,
`Dialog`, `Checkbox`, `Tooltip`, and `sonner` toasts.
- **thirdweb signing, not wagmi writes.** The widget calls `useWriteAccount()` and
uses thirdweb's `prepareContractCall` (approval) + `prepareTransaction` (raw 0x tx)
to keep the SA-vs-EOA view-mode toggle working.
- **Fixed token list.** No dynamic search — we ship ETH, WETH, USDC, GNARS, DEGEN,
HIGHER. Adding tokens is a one-line edit in `SwapWidget.tsx`.
6 changes: 6 additions & 0 deletions env.example
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ PINATA_JWT=your_pinata_jwt_here
# Get your key from https://neynar.com/
NEYNAR_API_KEY=your_neynar_api_key_here

# 0x Swap API Key (server-only — used by /api/0x/* proxy routes for the /swap page)
# Get your key from https://dashboard.0x.org/
# The affiliate fee recipient (DAO treasury) and rate (SWAP_FEE_BPS) live in
# src/lib/config.ts; only the API key is read from the environment.
ZEROX_API_KEY=your_0x_api_key_here

# ===========================================
# Optional / Advanced
# ===========================================
Expand Down
61 changes: 61 additions & 0 deletions src/app/api/0x/price/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { NextResponse, type NextRequest } from "next/server";
import { DAO_ADDRESSES, SWAP_FEE_BPS } from "@/lib/config";

// Server-only API key — never leaked to the client bundle.
const ZEROX_API_KEY = process.env.ZEROX_API_KEY ?? "";

// Fee recipient + rate live in src/lib/config.ts so they ship with the code
// (the DAO treasury is canonical and overridable via NEXT_PUBLIC_TREASURY_ADDRESS
// alongside every other DAO address).
const FEE_RECIPIENT = DAO_ADDRESSES.treasury;
const FEE_BPS = String(SWAP_FEE_BPS);

const ZEROX_HEADERS: HeadersInit = {
"0x-api-key": ZEROX_API_KEY,
"0x-version": "v2",
"Content-Type": "application/json",
};

/**
* GET /api/0x/price — proxy for 0x's allowance-holder/price endpoint.
*
* Forwards every query param through, with two server-side adjustments:
* 1. Strips `fee=1` so it doesn't reach 0x.
* 2. When the client opted in (`fee=1`), injects affiliate fee params
* (`swapFeeRecipient` = DAO treasury, `swapFeeBps` = SWAP_FEE_BPS,
* `swapFeeToken` = buyToken).
*
* Required upstream params: chainId, sellToken, buyToken, sellAmount, taker.
*/
export async function GET(request: NextRequest) {
if (!ZEROX_API_KEY) {
return NextResponse.json(
{ error: "ZEROX_API_KEY is not configured on the server" },
{ status: 500 },
);
}

const params = new URLSearchParams(request.nextUrl.searchParams);
const wantsFee = params.get("fee") === "1";
params.delete("fee");

if (wantsFee) {
const buyToken = params.get("buyToken") ?? "";
if (buyToken) {
params.set("swapFeeRecipient", FEE_RECIPIENT);
params.set("swapFeeBps", FEE_BPS);
params.set("swapFeeToken", buyToken);
}
}

const upstream = `https://api.0x.org/swap/allowance-holder/price?${params.toString()}`;

try {
const res = await fetch(upstream, { headers: ZEROX_HEADERS });
const data = await res.json();
return NextResponse.json(data, { status: res.status });
} catch (err) {
const message = err instanceof Error ? err.message : "Upstream request failed";
return NextResponse.json({ error: message }, { status: 502 });
}
}
56 changes: 56 additions & 0 deletions src/app/api/0x/quote/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { NextResponse, type NextRequest } from "next/server";
import { DAO_ADDRESSES, SWAP_FEE_BPS } from "@/lib/config";

// Server-only API key — never leaked to the client bundle.
const ZEROX_API_KEY = process.env.ZEROX_API_KEY ?? "";

// Fee recipient + rate live in src/lib/config.ts; the recipient is the DAO
// treasury (overridable via NEXT_PUBLIC_TREASURY_ADDRESS).
const FEE_RECIPIENT = DAO_ADDRESSES.treasury;
const FEE_BPS = String(SWAP_FEE_BPS);

const ZEROX_HEADERS: HeadersInit = {
"0x-api-key": ZEROX_API_KEY,
"0x-version": "v2",
"Content-Type": "application/json",
};

/**
* GET /api/0x/quote — proxy for 0x's allowance-holder/quote endpoint.
*
* Mirrors the fee-injection logic in /api/0x/price exactly. Both endpoints
* MUST inject the same fee params (or omit them) so the price preview and
* the firm quote remain consistent.
*/
export async function GET(request: NextRequest) {
if (!ZEROX_API_KEY) {
return NextResponse.json(
{ error: "ZEROX_API_KEY is not configured on the server" },
{ status: 500 },
);
}

const params = new URLSearchParams(request.nextUrl.searchParams);
const wantsFee = params.get("fee") === "1";
params.delete("fee");

if (wantsFee) {
const buyToken = params.get("buyToken") ?? "";
if (buyToken) {
params.set("swapFeeRecipient", FEE_RECIPIENT);
params.set("swapFeeBps", FEE_BPS);
params.set("swapFeeToken", buyToken);
}
}

const upstream = `https://api.0x.org/swap/allowance-holder/quote?${params.toString()}`;

try {
const res = await fetch(upstream, { headers: ZEROX_HEADERS });
const data = await res.json();
return NextResponse.json(data, { status: res.status });
} catch (err) {
const message = err instanceof Error ? err.message : "Upstream request failed";
return NextResponse.json({ error: message }, { status: 502 });
}
}
Loading
Loading