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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ NEXT_PUBLIC_ZORA_API_KEY
COINGECKO_API_KEY
PINATA_JWT
NEYNAR_API_KEY
ZEROX_API_KEY # 0x Swap API (server-only)
# (ZEROX_API_KEY retired: /swap quotes through SwapPro, keyless — docs/integrations/swap.md)

# Optional
USDC_BASE
Expand Down
155 changes: 97 additions & 58 deletions docs/integrations/swap.md
Original file line number Diff line number Diff line change
@@ -1,75 +1,114 @@
# 0x Swap Integration
# SwapPro 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`).
The `/swap` page trades ETH, USDC, GNARS and the other tokens in the picker, on six
chains, through
the [SwapPro HTTP API](https://www.swaps.pro/docs/api). One `GET /quote` routes across
0x, CoW, LI.FI, Relay and more and returns a firm quote with the transaction to sign.
There is no API key. All transaction signing happens through the existing thirdweb
wallet layer (`useWriteAccount`), exactly as before.

## Architecture

```
src/app/swap/
page.tsx server component — metadata + page chrome
SwapWidget.tsx "use client" — token pickers, debounced price, approve, swap
src/app/[locale]/swap/
SwapWidget.tsx "use client" — token pickers, debounced price, approve, swap (unchanged UI)

src/lib/
swappro.ts pure: SwapPro request/response ⇄ the shape the widget reads (unit-tested)
swapproRoute.ts the one handler: reads the query, sets the fee from config, calls SwapPro

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
price/route.ts GET → swapproRoute (kept at its old path so the widget does not change)
quote/route.ts GET → swapproRoute (same call: every SwapPro answer is firm)
```

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.
The routes keep their `/api/0x/*` paths on purpose: the widget's two-step flow (price while
typing, quote on click) is untouched, and the fee recipient is still set server-side from
`src/lib/config.ts` rather than 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)`.
`buyToken`, `sellAmount` (base units), `taker`, `sellDecimals`, `buyDecimals` and
(optionally) `fee=1`.
3. The handler converts base units to human decimals, maps the native sentinel
(`0xeeee…`) to the chain's native symbol, and calls
`https://www.swaps.pro/api/sdk/v1/quote`. The answer comes back in the widget's shape:
`liquidityAvailable`, `buyAmount` / `minBuyAmount` in base units, `issues.allowance`
when an ERC-20 approval is needed, `transaction { to, data, value, gas }`, and `route`
naming the venue SwapPro chose.
4. If `issues.allowance` is present, the widget shows "Approve". SwapPro's approval is for
the exact amount; the widget's existing approve flow (`prepareContractCall` +
`sendTransaction`) is unchanged.
5. "Swap" calls `/api/0x/quote` — the same call — and sends `transaction` via
`prepareTransaction` on the user's thirdweb account.
6. Wrong-network state shows a "Switch to Base" CTA, as before.

## Configuration

| Setting | Source | Notes |
| --------------- | -------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `ZEROX_API_KEY` | env (server-only) | 0x API key. Required — proxy returns `500` without it. |
| Fee recipient | `getSwapFeeRecipient` in `src/lib/config.ts` | Base swaps route fees to the Gnars split contract. Non-Base swaps route to the cross-chain fee recipient. |
| 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 = getSwapFeeRecipient(chainId)
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`.
| Setting | Source | Notes |
| ------------- | -------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| API key | none | SwapPro is CORS-open and keyless. `ZEROX_API_KEY` is no longer read. |
| Fee recipient | `getSwapFeeRecipient` in `src/lib/config.ts` | Sent as SwapPro's `partner`. An EVM address as partner is the opt-in to being paid the partner share. |
| Fee rate | `SWAP_FEE_BPS` in `src/lib/config.ts` | Sent as `partnerFeeBps`. SwapPro caps it at 100 bps (200 with a Pro Pass). |
| Rate limit | SwapPro | 60 quotes a minute per IP with no credential; the proxy shares the site's server IP. |

## Affiliate fee behaviour — read this before merging

The fee is still **opt-in per request** (`fee=1`, the "Support Gnars treasury" checkbox,
default checked), and it is now also **gated on the chain**. What changes is _how_ it is
collected, and it depends on the venue SwapPro picks for the quote:

- **0x and CoW** — collected on chain, on top of SwapPro's own 30 bps, and paid to a
0xSplits contract derived from `(payout address, bps)` that divides it between the
treasury and SwapPro with no invoice and nobody to trust.
- **LI.FI** — collected, but LI.FI registers one fee wallet per integrator rather than
accepting one per request, so it lands in SwapPro's wallet and the response says so
(`paidToPartner: false`).
- **Relay and other Pioneer venues** — cannot carry a partner fee at all.

Every quote returns a `partnerFee` block saying what was requested, what was collected,
whether it was `paidToPartner` and where it landed. The handler passes it through verbatim.

### The chain gate

`GNARS_SWAP_PAYOUT` in `src/lib/config.ts` lists the chains where the treasury has an
address that can actually receive. **A chain missing from that map asks for no fee at
all** and the checkbox is not shown there.

That is not caution, it is measurement: `eth_getCode` on 2026-09-03 found the treasury's
split holding 89 bytes on Base and **zero** on Ethereum, Arbitrum, BNB Chain, Avalanche
and Robinhood Chain. Requesting 50 bps on those chains would take the money from the user
and park it at an address with nothing behind it. Without the gate the user pays 80 bps
and the treasury receives none of it.

To earn on another chain: deploy the same split there (0xSplits derives the address from
the configuration, so it carries over unchanged), then add the chain id to the map.
Robinhood Chain is the exception — 0xSplits has no factory on 4663, so an EOA or a Safe
is the only option there.

## What the user gains

- Every quote is priced across every venue at once, not just 0x.
- `minBuyAmount` is the floor the transaction enforces on chain; the wallet receives at
least that or the transaction reverts.
- No API key to rotate, no per-request 0x pricing.
- Cross-chain and Bitcoin-native routes (THORChain) are one parameter away when the DAO
wants them: the same endpoint takes a different `sellChain`.

## Chains

The picker offers exactly the six chains SwapPro routes: Base, Ethereum, Arbitrum,
BNB Chain, Avalanche and Robinhood Chain.

Optimism was removed. It sat in the picker and every quote on it came back
`liquidityAvailable: false` with `code: UNSUPPORTED_CHAIN` — a chain offered that could
never fill an order. `chains.test.ts` now fails when `SWAP_CHAINS` and `SWAPPRO_CHAINS`
disagree, so a chain SwapPro adds shows up as a failing test rather than as silence, and
one it drops cannot linger.

Token addresses and decimals come from SwapPro's own `/tokens` registry and were read back
from chain before being written down. Two that are not guessable: **BNB Chain's USDT and
USDC carry 18 decimals**, not Ethereum's six, and Avalanche's Tether is `USDt`. Robinhood
Chain carries tokenised equities (NVDA, TSLA), which is the reason it is worth offering.
2 changes: 1 addition & 1 deletion env.example
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ NEYNAR_API_KEY=your_neynar_api_key_here
# The affiliate fee recipients (Gnars split on Base, cross-chain wallet elsewhere)
# 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
# ZEROX_API_KEY is no longer read: /swap quotes through SwapPro (keyless). See docs/integrations/swap.md

# ===========================================
# Rounds (community contests) — Postgres
Expand Down
22 changes: 16 additions & 6 deletions src/app/[locale]/swap/SwapWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { useUserAddress } from "@/hooks/use-user-address";
import { useWriteAccount } from "@/hooks/use-write-account";
import { Link } from "@/i18n/navigation";
import { prepareContractCall, prepareTransaction } from "@/lib/builder-code";
import { DAO_ADDRESSES } from "@/lib/config";
import { chainPaysTreasury, DAO_ADDRESSES } from "@/lib/config";
import { ipfsToHttp } from "@/lib/ipfs";
import { getThirdwebClient } from "@/lib/thirdweb";
import { ensureOnChain, normalizeTxError } from "@/lib/thirdweb-tx";
Expand Down Expand Up @@ -411,6 +411,11 @@ export function SwapWidget() {
const [buyToken, setBuyToken] = React.useState<SwapToken>(initialPair.buy);
const [sellAmount, setSellAmount] = React.useState("");
const [supportFee, setSupportFee] = React.useState(true);
// The treasury can only be paid where it has an address that can receive.
// On any other chain the checkbox would collect 0.5% and send it nowhere,
// so it is not shown and no fee is requested. See GNARS_SWAP_PAYOUT.
const canPayTreasury = chainPaysTreasury(chain.id);
const feeRequested = supportFee && canPayTreasury;

const [price, setPrice] = React.useState<ZeroExPriceResponse | null>(null);
// Distinct from `price === null`, which also means "nothing typed yet". Without
Expand Down Expand Up @@ -499,8 +504,11 @@ export function SwapWidget() {
buyToken: buyToken.address,
sellAmount: rawAmount,
taker,
// SwapPro quotes in human decimals; the proxy converts both ways.
sellDecimals: String(sellToken.decimals),
buyDecimals: String(buyToken.decimals),
});
if (supportFee) params.set("fee", "1");
if (feeRequested) params.set("fee", "1");

const res = await fetch(`/api/0x/price?${params.toString()}`);
const data: ZeroExPriceResponse & { error?: string } = await res.json();
Expand Down Expand Up @@ -542,7 +550,7 @@ export function SwapWidget() {
cancelled = true;
clearTimeout(timeout);
};
}, [sellAmount, sellToken, buyToken, address, supportFee, chain.id]);
}, [sellAmount, sellToken, buyToken, address, feeRequested, chain.id]);

const flip = () => {
setSellToken(buyToken);
Expand Down Expand Up @@ -632,8 +640,10 @@ export function SwapWidget() {
buyToken: buyToken.address,
sellAmount: rawAmount,
taker: address,
sellDecimals: String(sellToken.decimals),
buyDecimals: String(buyToken.decimals),
});
if (supportFee) params.set("fee", "1");
if (feeRequested) params.set("fee", "1");

const res = await fetch(`/api/0x/quote?${params.toString()}`);
const quote: ZeroExQuoteResponse = await res.json();
Expand Down Expand Up @@ -976,8 +986,8 @@ export function SwapWidget() {

<div className="hidden h-px flex-1 bg-border @2xl:block" />

{/* Fee opt-in */}
<div className="flex items-center gap-2">
{/* Fee opt-in — only where the treasury can actually be paid. */}
<div className={cn("flex items-center gap-2", !canPayTreasury && "hidden")}>
<Checkbox
id="support-treasury-fee"
checked={supportFee}
Expand Down
65 changes: 65 additions & 0 deletions src/app/[locale]/swap/chains.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { SWAPPRO_CHAINS } from "@/lib/swappro";
import { GNARS_SWAP_PAYOUT } from "@/lib/config";
import { getDefaultPair, NATIVE_TOKEN, SWAP_CHAINS } from "./chains";

/**
* The picker and the router have to agree.
*
* Every failure below was once shipped: Optimism in the picker that SwapPro
* cannot route, a default pair naming a token the chain's list does not carry,
* and a stablecoin given Ethereum's six decimals on a chain that uses
* eighteen. None of them is caught by types.
*/
describe("swap chains", () => {
it("offers exactly the chains SwapPro routes", () => {
const offered = SWAP_CHAINS.map((c) => c.id).sort((a, b) => a - b);
const routed = Object.keys(SWAPPRO_CHAINS)
.map(Number)
.sort((a, b) => a - b);
expect(offered).toEqual(routed);
});

it("names the native asset the way SwapPro resolves it", () => {
for (const chain of SWAP_CHAINS) {
const native = chain.tokens.find((t) => t.address === NATIVE_TOKEN);
expect(native, `${chain.name} has no native token`).toBeDefined();
expect(native?.symbol).toBe(SWAPPRO_CHAINS[chain.id].native);
expect(native?.decimals).toBe(18);
}
});

it("resolves both default symbols to real entries in the chain's own list", () => {
for (const chain of SWAP_CHAINS) {
const { sell, buy } = getDefaultPair(chain);
expect(sell.symbol, `${chain.name} default sell`).toBe(chain.defaults.sell);
expect(buy.symbol, `${chain.name} default buy`).toBe(chain.defaults.buy);
expect(sell.address).not.toBe(buy.address);
}
});

it("carries no duplicate symbol or address inside one chain", () => {
for (const chain of SWAP_CHAINS) {
const symbols = chain.tokens.map((t) => t.symbol);
const addresses = chain.tokens.map((t) => t.address.toLowerCase());
expect(new Set(symbols).size, `${chain.name} symbols`).toBe(symbols.length);
expect(new Set(addresses).size, `${chain.name} addresses`).toBe(addresses.length);
}
});

it("gives BNB Chain's stables eighteen decimals, not Ethereum's six", () => {
const bnb = SWAP_CHAINS.find((c) => c.id === 56);
for (const symbol of ["USDT", "USDC"]) {
expect(bnb?.tokens.find((t) => t.symbol === symbol)?.decimals, symbol).toBe(18);
}
});

it("only claims a treasury payout on a chain the treasury can be paid on", () => {
for (const id of Object.keys(GNARS_SWAP_PAYOUT).map(Number)) {
expect(
SWAP_CHAINS.some((c) => c.id === id),
`payout configured for chain ${id}, which the picker does not offer`,
).toBe(true);
}
});
});
Loading