From 6233f42a5fa254a71eb0c355ead45a541377ada9 Mon Sep 17 00:00:00 2001 From: sktbrd Date: Thu, 3 Sep 2026 15:54:38 -0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(swap):=20quote=20through=20SwapPro=20i?= =?UTF-8?q?nstead=20of=200x=20=E2=80=94=20keyless,=20every=20venue=20at=20?= =?UTF-8?q?once?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /swap page keeps its UI and its two-step flow; behind /api/0x/price and /api/0x/quote a single SwapPro /quote now routes across 0x, CoW, LI.FI, Relay and more and answers with a firm quote and the transaction to sign. No API key: ZEROX_API_KEY is retired. src/lib/swappro.ts is the pure translation between SwapPro's answer (human decimals, approval, tx, provider) and the shape the widget already reads (liquidityAvailable, base-unit amounts, issues.allowance, transaction), with unit tests. The widget passes sellDecimals/buyDecimals so the proxy can convert both ways; nothing else in it changes. The affiliate fee is still opt-in per request and still set from config server-side, sent as SwapPro's partner + partnerFeeBps. How it is collected depends on the venue SwapPro picks — on-chain per swap on CoW, settled off-chain on 0x and LI.FI, not at all on Relay — and every quote reports it in a partnerFee block. docs/integrations/swap.md spells this out; it is the one thing the DAO should decide on before merging. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018aKwE1cu2NvM7Gj7ykdcgD --- CLAUDE.md | 2 +- docs/integrations/swap.md | 128 +++++++++-------- env.example | 2 +- src/app/[locale]/swap/SwapWidget.tsx | 5 + src/app/api/0x/price/route.ts | 61 +------- src/app/api/0x/quote/route.ts | 57 +------- src/lib/swappro.test.ts | 130 +++++++++++++++++ src/lib/swappro.ts | 202 +++++++++++++++++++++++++++ src/lib/swapproRoute.ts | 61 ++++++++ 9 files changed, 478 insertions(+), 170 deletions(-) create mode 100644 src/lib/swappro.test.ts create mode 100644 src/lib/swappro.ts create mode 100644 src/lib/swapproRoute.ts diff --git a/CLAUDE.md b/CLAUDE.md index 7e9d04a6..8932565f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docs/integrations/swap.md b/docs/integrations/swap.md index e7e30e4e..5e6cf5b9 100644 --- a/docs/integrations/swap.md +++ b/docs/integrations/swap.md @@ -1,75 +1,87 @@ -# 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 Base tokens in the picker 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 = (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). What changes is _how_ it is collected, and it depends on the venue +SwapPro picks for the quote: + +- **CoW** — a genuine on-chain volume fee to the partner address, per swap. +- **0x and LI.FI** — the partner share rides inside SwapPro's own fee and is settled to + the partner address from SwapPro's request log, not on chain per swap. +- **Relay and other same-chain venues** — cannot carry a partner fee at all. + +Every quote returns a `partnerFee` block saying what was requested, what was collected +and whether it was `paidToPartner`, and the handler passes it through verbatim. With the +direct 0x integration the fee landed in the split contract on every swap; with SwapPro +it lands on some venues per swap and is settled off-chain on others. SwapPro also takes +its own 30 bps on same-chain EVM routes. This is a treasury-policy decision for the DAO, +not a code one, and it is the reason this PR is a proposal rather than a drop-in. + +## 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 + +SwapPro routes Base, Ethereum, Arbitrum, BNB Chain, Avalanche and Robinhood Chain. Optimism +is in the site's chain list but not routed by SwapPro yet; the handler answers +`liquidityAvailable: false` with `code: UNSUPPORTED_CHAIN` for it. diff --git a/env.example b/env.example index 2f8a70c3..57968159 100644 --- a/env.example +++ b/env.example @@ -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 diff --git a/src/app/[locale]/swap/SwapWidget.tsx b/src/app/[locale]/swap/SwapWidget.tsx index c4b21381..8548882e 100644 --- a/src/app/[locale]/swap/SwapWidget.tsx +++ b/src/app/[locale]/swap/SwapWidget.tsx @@ -499,6 +499,9 @@ 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"); @@ -632,6 +635,8 @@ export function SwapWidget() { buyToken: buyToken.address, sellAmount: rawAmount, taker: address, + sellDecimals: String(sellToken.decimals), + buyDecimals: String(buyToken.decimals), }); if (supportFee) params.set("fee", "1"); diff --git a/src/app/api/0x/price/route.ts b/src/app/api/0x/price/route.ts index 405de465..b2285c0e 100644 --- a/src/app/api/0x/price/route.ts +++ b/src/app/api/0x/price/route.ts @@ -1,60 +1,7 @@ -import { NextResponse, type NextRequest } from "next/server"; -import { getSwapFeeRecipient, SWAP_FEE_BPS } from "@/lib/config"; +import type { NextRequest } from "next/server"; +import { answerSwapProQuote } from "@/lib/swapproRoute"; -// Server-only API key — never leaked to the client bundle. -const ZEROX_API_KEY = process.env.ZEROX_API_KEY ?? ""; - -// Fee recipient is chain-aware: Base → DAO treasury, others → multichain -// custody address. See `getSwapFeeRecipient` in src/lib/config.ts. -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. - */ +/** GET /api/0x/price — an indicative quote from SwapPro, in the shape the widget reads. See src/lib/swapproRoute.ts. */ 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") ?? ""; - const chainId = Number(params.get("chainId") ?? "0"); - if (buyToken && Number.isFinite(chainId) && chainId > 0) { - params.set("swapFeeRecipient", getSwapFeeRecipient(chainId)); - 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 }); - } + return answerSwapProQuote(request); } diff --git a/src/app/api/0x/quote/route.ts b/src/app/api/0x/quote/route.ts index 734fd9ea..a1b41d07 100644 --- a/src/app/api/0x/quote/route.ts +++ b/src/app/api/0x/quote/route.ts @@ -1,56 +1,7 @@ -import { NextResponse, type NextRequest } from "next/server"; -import { getSwapFeeRecipient, SWAP_FEE_BPS } from "@/lib/config"; +import type { NextRequest } from "next/server"; +import { answerSwapProQuote } from "@/lib/swapproRoute"; -// Server-only API key — never leaked to the client bundle. -const ZEROX_API_KEY = process.env.ZEROX_API_KEY ?? ""; - -// Fee recipient is chain-aware: Base → DAO treasury, others → multichain -// custody address. See `getSwapFeeRecipient` in src/lib/config.ts. -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. - */ +/** GET /api/0x/quote — the firm quote with the transaction to sign. Same call as /price; see src/lib/swapproRoute.ts. */ 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") ?? ""; - const chainId = Number(params.get("chainId") ?? "0"); - if (buyToken && Number.isFinite(chainId) && chainId > 0) { - params.set("swapFeeRecipient", getSwapFeeRecipient(chainId)); - 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 }); - } + return answerSwapProQuote(request); } diff --git a/src/lib/swappro.test.ts b/src/lib/swappro.test.ts new file mode 100644 index 00000000..8c2670aa --- /dev/null +++ b/src/lib/swappro.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { + buildQuoteUrl, + NATIVE_SENTINEL, + toSwapProToken, + toWidgetError, + toWidgetQuote, + type SwapProQuote, +} from "./swappro"; + +const USDC = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"; +const TAKER = "0x21c9a94AF76B59b171b32fD125A4edF0e9A2Ad3e"; + +// Captured from https://www.swaps.pro/api/sdk/v1/quote on 2026-09-03 (calldata shortened). +const QUOTE: SwapProQuote = { + provider: "0x", + sellChain: "BASE", + buyChain: "BASE", + sellToken: { caip: "eip155:8453/slip44:60", symbol: "ETH" }, + buyToken: { caip: `eip155:8453/erc20:${USDC}`, symbol: "USDC" }, + sellAmount: "0.1", + buyAmount: "246.927321", + minBuyAmount: "244.458047", + rate: 2469.27321, + tx: { + chainId: 8453, + to: "0x0000000000001ff3684f28c67538d4d072c22734", + data: "0x2213bc0b000000000000000000000000", + value: "0x16345785d8a0000", + gasLimit: "0x2f8d0", + }, + expiresAt: "2026-09-03T12:00:00.000Z", + partner: "gnars", + partnerFee: { + requestedBps: 0, + collectedBps: 0, + collected: false, + note: "No partner fee was requested.", + }, +}; + +describe("token mapping", () => { + it("sends the native sentinel as the chain's native symbol and addresses as themselves", () => { + expect(toSwapProToken(8453, NATIVE_SENTINEL)).toBe("ETH"); + expect(toSwapProToken(8453, NATIVE_SENTINEL.toUpperCase().replace("0X", "0x"))).toBe("ETH"); + expect(toSwapProToken(8453, USDC)).toBe(USDC); + expect(toSwapProToken(10, USDC)).toBeNull(); + }); +}); + +describe("quote URL", () => { + it("converts base units to human decimals and carries the affiliate fee as partner + bps", () => { + const url = buildQuoteUrl({ + chainId: 8453, + sellToken: NATIVE_SENTINEL, + buyToken: USDC, + sellAmount: "100000000000000000", + sellDecimals: 18, + buyDecimals: 6, + taker: TAKER, + fee: { recipient: "0x1111111111111111111111111111111111111111", bps: 50 }, + }); + expect(url).toContain("sellChain=8453"); + expect(url).toContain("sellToken=ETH"); + expect(url).toContain(`buyToken=${USDC}`); + expect(url).toContain("amount=0.1"); + expect(url).toContain(`address=${TAKER}`); + expect(url).toContain("partner=0x1111111111111111111111111111111111111111"); + expect(url).toContain("partnerFeeBps=50"); + }); + + it("names gnars as the partner without a fee, and refuses a chain SwapPro does not route", () => { + const base = { + sellToken: NATIVE_SENTINEL, + buyToken: USDC, + sellAmount: "1", + sellDecimals: 18, + buyDecimals: 6, + taker: TAKER, + }; + expect(buildQuoteUrl({ chainId: 8453, ...base })).toContain("partner=gnars"); + expect(buildQuoteUrl({ chainId: 8453, ...base })).not.toContain("partnerFeeBps"); + expect(buildQuoteUrl({ chainId: 10, ...base })).toBeNull(); + }); +}); + +describe("widget shape", () => { + it("returns base units, the enforced floor, the transaction, and the venue", () => { + const w = toWidgetQuote(QUOTE, 18, 6); + expect(w.liquidityAvailable).toBe(true); + expect(w.sellAmount).toBe("100000000000000000"); + expect(w.buyAmount).toBe("246927321"); + expect(w.minBuyAmount).toBe("244458047"); + expect(w.transaction).toEqual({ + to: "0x0000000000001ff3684f28c67538d4d072c22734", + data: "0x2213bc0b000000000000000000000000", + value: "100000000000000000", + gas: "194768", + }); + expect(w.route).toBe("0x"); + expect(w.issues?.allowance).toBeNull(); + }); + + it("surfaces an exact-amount approval as issues.allowance so the Approve button appears", () => { + const w = toWidgetQuote( + { + ...QUOTE, + approval: { + chainId: 8453, + token: USDC, + spender: "0x0000000000001ff3684f28c67538d4d072c22734", + amountWei: "100000000", + }, + }, + 6, + 18, + ); + expect(w.issues?.allowance).toEqual({ + spender: "0x0000000000001ff3684f28c67538d4d072c22734", + amount: "100000000", + }); + }); + + it("turns a SwapPro error into a no-liquidity answer with the reason", () => { + const w = toWidgetError({ error: "No route for this pair at this size", code: "NO_ROUTE" }); + expect(w.liquidityAvailable).toBe(false); + expect(w.reason).toContain("No route"); + expect(w.transaction).toBeUndefined(); + }); +}); diff --git a/src/lib/swappro.ts b/src/lib/swappro.ts new file mode 100644 index 00000000..78c7af62 --- /dev/null +++ b/src/lib/swappro.ts @@ -0,0 +1,202 @@ +import { formatUnits, parseUnits } from "viem"; + +/** + * SwapPro quotes, in the shape the swap widget already reads. + * + * The widget was written against 0x's allowance-holder responses + * (`liquidityAvailable`, `buyAmount` in base units, `issues.allowance.spender`, + * `transaction { to, data, value, gas }`). SwapPro's `/quote` answers with one + * routed quote across 0x, CoW, LI.FI, Relay and more — no API key — but in + * human decimals and with its own field names. This module is the translation, + * kept pure so it can be unit-tested without a network. + * + * https://www.swaps.pro/docs/api/quote + */ + +export const SWAPPRO_BASE_URL = "https://www.swaps.pro/api/sdk/v1"; + +/** 0x's convention for the native asset, which the widget's token list uses. */ +export const NATIVE_SENTINEL = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; + +/** Chains SwapPro routes, keyed by EIP-155 id, with the symbol its native asset resolves under. */ +export const SWAPPRO_CHAINS: Record = { + 1: { id: "ETH", native: "ETH" }, + 8453: { id: "BASE", native: "ETH" }, + 42161: { id: "ARB", native: "ETH" }, + 56: { id: "BSC", native: "BNB" }, + 43114: { id: "AVAX", native: "AVAX" }, + 4663: { id: "RHD", native: "ETH" }, +}; + +export interface SwapProQuote { + provider: string; + sellChain: string; + buyChain: string; + sellToken: { caip: string; symbol: string }; + buyToken: { caip: string; symbol: string }; + /** Human decimals, e.g. "0.1". */ + sellAmount: string; + buyAmount: string; + minBuyAmount?: string; + rate: number; + tx?: { chainId: number; to: string; data?: string; value?: string; gasLimit?: string }; + approval?: { chainId: number; token: string; spender: string; amountWei: string }; + expiresAt: string; + partner?: string; + partnerFeeBps?: number; + partnerFee?: { + requestedBps: number; + collectedBps: number; + collected: boolean; + paidToPartner?: boolean; + note: string; + }; +} + +export interface SwapProError { + error: string; + code: string; +} + +/** What the widget reads. Superset of the 0x fields it touches. */ +export interface WidgetQuote { + liquidityAvailable: boolean; + sellAmount?: string; + buyAmount?: string; + minBuyAmount?: string; + issues?: { + allowance?: { spender: string; amount: string } | null; + balance?: null; + }; + transaction?: { to: string; data: string; value: string; gas?: string }; + /** Which venue SwapPro chose: 0x, cow, lifi, relay, … */ + route?: string; + /** SwapPro's own accounting of the affiliate fee, verbatim. */ + partnerFee?: SwapProQuote["partnerFee"]; + expiresAt?: string; + reason?: string; + code?: string; +} + +export interface QuoteRequest { + chainId: number; + /** Token address, or the native sentinel. */ + sellToken: string; + buyToken: string; + /** Base units, as the widget sends them. */ + sellAmount: string; + sellDecimals: number; + buyDecimals: number; + taker: string; + /** Affiliate fee opt-in: the recipient address and the bps. */ + fee?: { recipient: string; bps: number } | null; +} + +/** The SwapPro token parameter for a widget token: symbol for the native asset, address otherwise. */ +export function toSwapProToken(chainId: number, token: string): string | null { + const chain = SWAPPRO_CHAINS[chainId]; + if (!chain) return null; + return token.toLowerCase() === NATIVE_SENTINEL ? chain.native : token; +} + +/** The query string for SwapPro's /quote. Null when the chain is not one SwapPro routes. */ +export function buildQuoteUrl(req: QuoteRequest, base: string = SWAPPRO_BASE_URL): string | null { + const chain = SWAPPRO_CHAINS[req.chainId]; + const sell = toSwapProToken(req.chainId, req.sellToken); + const buy = toSwapProToken(req.chainId, req.buyToken); + if (!chain || !sell || !buy) return null; + const params = new URLSearchParams({ + sellChain: String(req.chainId), + sellToken: sell, + buyChain: String(req.chainId), + buyToken: buy, + amount: formatUnits(BigInt(req.sellAmount), req.sellDecimals), + address: req.taker, + partner: req.fee?.recipient ?? "gnars", + }); + if (req.fee && req.fee.bps > 0) params.set("partnerFeeBps", String(req.fee.bps)); + return `${base}/quote?${params.toString()}`; +} + +const toBaseUnits = (human: string | undefined, decimals: number): string | undefined => { + if (human == null) return undefined; + try { + return parseUnits(human, decimals).toString(); + } catch { + return undefined; + } +}; + +const toDecimalString = (hexOrDec: string | undefined): string => { + if (!hexOrDec) return "0"; + try { + return BigInt(hexOrDec).toString(); + } catch { + return "0"; + } +}; + +/** A SwapPro quote in the widget's shape. */ +export function toWidgetQuote( + q: SwapProQuote, + sellDecimals: number, + buyDecimals: number, +): WidgetQuote { + return { + liquidityAvailable: true, + sellAmount: toBaseUnits(q.sellAmount, sellDecimals), + buyAmount: toBaseUnits(q.buyAmount, buyDecimals), + minBuyAmount: toBaseUnits(q.minBuyAmount, buyDecimals), + issues: { + allowance: q.approval ? { spender: q.approval.spender, amount: q.approval.amountWei } : null, + balance: null, + }, + transaction: q.tx?.data + ? { + to: q.tx.to, + data: q.tx.data, + value: toDecimalString(q.tx.value), + gas: q.tx.gasLimit ? toDecimalString(q.tx.gasLimit) : undefined, + } + : undefined, + route: q.provider, + partnerFee: q.partnerFee, + expiresAt: q.expiresAt, + }; +} + +/** A SwapPro error in the widget's shape: no liquidity, with the reason it gave. */ +export function toWidgetError(e: SwapProError): WidgetQuote { + return { liquidityAvailable: false, reason: e.error, code: e.code }; +} + +/** One call to SwapPro, translated. Never throws on an API answer; throws only when there is none. */ +export async function fetchWidgetQuote( + req: QuoteRequest, + base: string = SWAPPRO_BASE_URL, +): Promise<{ status: number; body: WidgetQuote }> { + const url = buildQuoteUrl(req, base); + if (!url) { + return { + status: 400, + body: { + liquidityAvailable: false, + reason: `SwapPro does not route chain ${req.chainId} yet`, + code: "UNSUPPORTED_CHAIN", + }, + }; + } + const res = await fetch(url, { + headers: { accept: "application/json" }, + signal: AbortSignal.timeout(45_000), + }); + const data = (await res.json()) as SwapProQuote | SwapProError; + if (!res.ok || "error" in data) { + // A NO_ROUTE answer is a quote result, not a failure: the widget shows "no liquidity". + return { + status: res.status === 502 ? 200 : res.status, + body: toWidgetError(data as SwapProError), + }; + } + return { status: 200, body: toWidgetQuote(data, req.sellDecimals, req.buyDecimals) }; +} diff --git a/src/lib/swapproRoute.ts b/src/lib/swapproRoute.ts new file mode 100644 index 00000000..211191b7 --- /dev/null +++ b/src/lib/swapproRoute.ts @@ -0,0 +1,61 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { getSwapFeeRecipient, SWAP_FEE_BPS } from "@/lib/config"; +import { fetchWidgetQuote } from "@/lib/swappro"; + +/** + * The one handler behind /api/0x/price and /api/0x/quote. + * + * SwapPro has a single /quote: every answer is firm and carries the + * transaction when a same-chain route exists, so the indicative price and + * the executed quote are the same call and cannot disagree. Both routes keep + * their old paths so the widget does not change. There is no API key; the + * route stays a proxy so the fee recipient is set here, from config, and not + * in the client bundle. + * + * Params: chainId, sellToken, buyToken, sellAmount (base units), taker, + * sellDecimals, buyDecimals, and `fee=1` to opt in to the affiliate fee. + */ +export async function answerSwapProQuote(request: NextRequest) { + const q = request.nextUrl.searchParams; + const chainId = Number(q.get("chainId") ?? "0"); + const sellToken = q.get("sellToken") ?? ""; + const buyToken = q.get("buyToken") ?? ""; + const sellAmount = q.get("sellAmount") ?? ""; + const taker = q.get("taker") ?? ""; + const sellDecimals = Number(q.get("sellDecimals") ?? "18"); + const buyDecimals = Number(q.get("buyDecimals") ?? "18"); + + if (!chainId || !sellToken || !buyToken || !/^\d+$/.test(sellAmount) || !taker) { + return NextResponse.json( + { + liquidityAvailable: false, + reason: "chainId, sellToken, buyToken, sellAmount and taker are required", + code: "BAD_REQUEST", + }, + { status: 400 }, + ); + } + + const fee = + q.get("fee") === "1" ? { recipient: getSwapFeeRecipient(chainId), bps: SWAP_FEE_BPS } : null; + + try { + const { status, body } = await fetchWidgetQuote({ + chainId, + sellToken, + buyToken, + sellAmount, + sellDecimals, + buyDecimals, + taker, + fee, + }); + return NextResponse.json(body, { status }); + } catch (err) { + const message = err instanceof Error ? err.message : "SwapPro did not answer"; + return NextResponse.json( + { liquidityAvailable: false, reason: message, code: "UPSTREAM_UNAVAILABLE" }, + { status: 502 }, + ); + } +} From 257d0541c2956db47591fcf30ba0cf7fc046e4d7 Mon Sep 17 00:00:00 2001 From: sktbrd Date: Thu, 3 Sep 2026 16:47:35 -0300 Subject: [PATCH 2/2] feat(swap): every chain SwapPro routes, and a fee that only asks where it can be paid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker offered Optimism, which SwapPro does not route — every quote on it came back UNSUPPORTED_CHAIN. It did not offer BNB Chain, Avalanche or Robinhood Chain, which SwapPro does route. chains.test.ts now fails when the picker and the router disagree, so the next chain SwapPro adds is a failing test rather than silence. Token addresses and decimals come from SwapPro's /tokens registry and were read back from chain before being written down: BNB Chain's USDT and USDC are 18 decimals, not Ethereum's six, and Avalanche's Tether is USDt. The treasury fee is now gated on the chain. eth_getCode on 2026-09-03 found the treasury's split holding 89 bytes on Base and zero on the other five, so asking for 50 bps there would take it from the user and park it at an address with nothing behind it. A chain missing from GNARS_SWAP_PAYOUT asks for no fee and does not show the checkbox — the swap simply costs less. The old rule sent every non-Base fee to the SOPA x COINMASTERSGUILD split, under a checkbox that says "Support Gnars treasury". And SwapPro's error body is about to become an object with the message inside it. toWidgetError read `error` as a string, which would have put "[object Object]" on the screen without throwing; it now reads both shapes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013nXkNtBMRY446o2pHHo9H8 --- docs/integrations/swap.md | 59 +++++++--- src/app/[locale]/swap/SwapWidget.tsx | 17 ++- src/app/[locale]/swap/chains.test.ts | 65 +++++++++++ src/app/[locale]/swap/chains.ts | 160 +++++++++++++++++++++++---- src/lib/config.ts | 56 ++++++++-- src/lib/swappro.test.ts | 63 +++++++++++ src/lib/swappro.ts | 31 +++++- src/lib/swapproRoute.ts | 8 +- 8 files changed, 398 insertions(+), 61 deletions(-) create mode 100644 src/app/[locale]/swap/chains.test.ts diff --git a/docs/integrations/swap.md b/docs/integrations/swap.md index 5e6cf5b9..30d5d4d1 100644 --- a/docs/integrations/swap.md +++ b/docs/integrations/swap.md @@ -1,6 +1,7 @@ # SwapPro Swap Integration -The `/swap` page trades ETH, USDC, GNARS and the other Base tokens in the picker through +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 @@ -56,20 +57,36 @@ typing, quote on click) is untouched, and the fee recipient is still set server- ## Affiliate fee behaviour — read this before merging The fee is still **opt-in per request** (`fee=1`, the "Support Gnars treasury" checkbox, -default checked). What changes is _how_ it is collected, and it depends on the venue -SwapPro picks for the quote: +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: -- **CoW** — a genuine on-chain volume fee to the partner address, per swap. -- **0x and LI.FI** — the partner share rides inside SwapPro's own fee and is settled to - the partner address from SwapPro's request log, not on chain per swap. -- **Relay and other same-chain venues** — cannot carry a partner fee at all. +- **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 -and whether it was `paidToPartner`, and the handler passes it through verbatim. With the -direct 0x integration the fee landed in the split contract on every swap; with SwapPro -it lands on some venues per swap and is settled off-chain on others. SwapPro also takes -its own 30 bps on same-chain EVM routes. This is a treasury-policy decision for the DAO, -not a code one, and it is the reason this PR is a proposal rather than a drop-in. +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 @@ -82,6 +99,16 @@ not a code one, and it is the reason this PR is a proposal rather than a drop-in ## Chains -SwapPro routes Base, Ethereum, Arbitrum, BNB Chain, Avalanche and Robinhood Chain. Optimism -is in the site's chain list but not routed by SwapPro yet; the handler answers -`liquidityAvailable: false` with `code: UNSUPPORTED_CHAIN` for it. +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. diff --git a/src/app/[locale]/swap/SwapWidget.tsx b/src/app/[locale]/swap/SwapWidget.tsx index 8548882e..2514944e 100644 --- a/src/app/[locale]/swap/SwapWidget.tsx +++ b/src/app/[locale]/swap/SwapWidget.tsx @@ -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"; @@ -411,6 +411,11 @@ export function SwapWidget() { const [buyToken, setBuyToken] = React.useState(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(null); // Distinct from `price === null`, which also means "nothing typed yet". Without @@ -503,7 +508,7 @@ export function SwapWidget() { 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(); @@ -545,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); @@ -638,7 +643,7 @@ export function SwapWidget() { 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(); @@ -981,8 +986,8 @@ export function SwapWidget() {
- {/* Fee opt-in */} -
+ {/* Fee opt-in — only where the treasury can actually be paid. */} +
{ + 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); + } + }); +}); diff --git a/src/app/[locale]/swap/chains.ts b/src/app/[locale]/swap/chains.ts index 9d68ed23..77e818c6 100644 --- a/src/app/[locale]/swap/chains.ts +++ b/src/app/[locale]/swap/chains.ts @@ -1,8 +1,10 @@ import { arbitrum as thirdwebArbitrum, + avalanche as thirdwebAvalanche, base as thirdwebBase, + bsc as thirdwebBsc, + defineChain, ethereum as thirdwebEthereum, - optimism as thirdwebOptimism, type Chain as ThirdwebChain, } from "thirdweb/chains"; import { DAO_ADDRESSES, TREASURY_TOKEN_ALLOWLIST } from "@/lib/config"; @@ -40,6 +42,46 @@ const ETH_NATIVE: SwapToken = { logo: ETH_LOGO, }; +const BNB_NATIVE: SwapToken = { + symbol: "BNB", + name: "BNB", + address: NATIVE_TOKEN, + decimals: 18, + logo: "https://assets.relay.link/icons/56/light.png", +}; + +const AVAX_NATIVE: SwapToken = { + symbol: "AVAX", + name: "Avalanche", + address: NATIVE_TOKEN, + decimals: 18, + logo: "https://assets.relay.link/icons/43114/light.png", +}; + +/** + * Robinhood Chain, defined here because thirdweb ships no definition for it. + * + * The RPC is given explicitly and is NOT the one in the chain's own registry + * entry: that host does not resolve from a browser, so a wallet balance read + * would fail on the client while working in every server-side test. + */ +const thirdwebRobinhood = defineChain({ + id: 4663, + name: "Robinhood Chain", + nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, + rpc: "https://robinhood-rpc.publicnode.com", + blockExplorers: [{ name: "Robinhood", url: "https://explorer.chain.robinhood.com" }], +}); + +/** + * The chains the picker offers — exactly the ones SwapPro routes. + * + * This list and `SWAPPRO_CHAINS` in src/lib/swappro.ts must hold the same ids, + * and chains.test.ts fails when they drift. It is not bookkeeping: Optimism + * sat here for months and every quote on it came back UNSUPPORTED_CHAIN, so + * the picker offered a chain that could never fill an order. A chain SwapPro + * adds shows up as a failing test rather than as nothing at all. + */ export const SWAP_CHAINS: readonly SwapChain[] = [ { id: 8453, @@ -138,65 +180,135 @@ export const SWAP_CHAINS: readonly SwapChain[] = [ ], }, { - id: 10, - name: "Optimism", - shortName: "OP", - thirdwebChain: thirdwebOptimism, + id: 42161, + name: "Arbitrum", + shortName: "ARB", + thirdwebChain: thirdwebArbitrum, defaults: { sell: "ETH", buy: "USDC" }, tokens: [ ETH_NATIVE, { symbol: "WETH", name: "Wrapped Ether", - address: "0x4200000000000000000000000000000000000006", + address: "0x82af49447d8a07e3bd95bd0d56f35241523fbab1", decimals: 18, logo: ETH_LOGO, }, { symbol: "USDC", name: "USD Coin", - address: "0x0b2c639c533813f4aa9d7837caf62653d097ff85", + address: "0xaf88d065e77c8cc2239327c5edb3a432268e5831", decimals: 6, logo: USDC_LOGO, }, { - symbol: "OP", - name: "Optimism", - address: "0x4200000000000000000000000000000000000042", + symbol: "ARB", + name: "Arbitrum", + address: "0x912ce59144191c1204e64559fe8253a0e49e6548", decimals: 18, }, ], }, { - id: 42161, - name: "Arbitrum", - shortName: "ARB", - thirdwebChain: thirdwebArbitrum, - defaults: { sell: "ETH", buy: "USDC" }, + id: 56, + name: "BNB Chain", + shortName: "BNB", + thirdwebChain: thirdwebBsc, + defaults: { sell: "BNB", buy: "USDT" }, tokens: [ - ETH_NATIVE, + BNB_NATIVE, + // BNB Chain stables are 18 decimals, not 6. Read from chain on + // 2026-09-03 rather than copied from the Ethereum list, which is the + // mistake that turns 35 USDT into 35 trillion. { - symbol: "WETH", - name: "Wrapped Ether", - address: "0x82af49447d8a07e3bd95bd0d56f35241523fbab1", + symbol: "USDT", + name: "Tether", + address: "0x55d398326f99059ff775485246999027b3197955", decimals: 18, - logo: ETH_LOGO, }, { symbol: "USDC", name: "USD Coin", - address: "0xaf88d065e77c8cc2239327c5edb3a432268e5831", + address: "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d", + decimals: 18, + logo: USDC_LOGO, + }, + { + symbol: "WBNB", + name: "Wrapped BNB", + address: "0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c", + decimals: 18, + }, + ], + }, + { + id: 43114, + name: "Avalanche", + shortName: "AVAX", + thirdwebChain: thirdwebAvalanche, + defaults: { sell: "AVAX", buy: "USDC" }, + tokens: [ + AVAX_NATIVE, + { + symbol: "USDC", + name: "USD Coin", + address: "0xb97ef9ef8734c71904d8002f8b6bc66dd9c48a6e", decimals: 6, logo: USDC_LOGO, }, + // Avalanche's Tether is "USDt", not "USDT" — the symbol is the token's + // own, so the picker matches what a block explorer shows. { - symbol: "ARB", - name: "Arbitrum", - address: "0x912ce59144191c1204e64559fe8253a0e49e6548", + symbol: "USDt", + name: "Tether", + address: "0x9702230a8ea53601f5cd2dc00fdbc13d4df4a8c7", + decimals: 6, + }, + { + symbol: "WAVAX", + name: "Wrapped AVAX", + address: "0xb31f66aa3c1e785363f0875a1b74e27b85fd66c7", decimals: 18, }, ], }, + { + id: 4663, + name: "Robinhood Chain", + shortName: "RHD", + thirdwebChain: thirdwebRobinhood, + defaults: { sell: "ETH", buy: "USDG" }, + // Tokenised equities, which is the whole reason this chain is here: a + // shredder can turn ETH into NVDA without leaving the page. + tokens: [ + ETH_NATIVE, + { + symbol: "USDG", + name: "Global Dollar", + address: "0x5fc5360d0400a0fd4f2af552add042d716f1d168", + decimals: 6, + }, + { + symbol: "NVDA", + name: "NVIDIA", + address: "0xd0601ce157db5bdc3162bbac2a2c8af5320d9eec", + decimals: 18, + }, + { + symbol: "TSLA", + name: "Tesla", + address: "0x322f0929c4625ed5bad873c95208d54e1c003b2d", + decimals: 18, + }, + { + symbol: "WETH", + name: "Wrapped Ether", + address: "0x0bd7d308f8e1639fab988df18a8011f41eacad73", + decimals: 18, + logo: ETH_LOGO, + }, + ], + }, ] as const; export const DEFAULT_SWAP_CHAIN = SWAP_CHAINS[0]; // Base diff --git a/src/lib/config.ts b/src/lib/config.ts index 15fea559..63f9f46c 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -112,28 +112,64 @@ export const DROPOSAL_TARGET = { // Default mint limit per address for droposals (effectively unlimited) export const DROPOSAL_DEFAULT_MINT_LIMIT = 1000000 as const; -// /swap (0x Swap API) — affiliate fee taken on the bought token when the -// user keeps the "Support Gnars treasury" checkbox checked. -// Recipient depends on chain: Base swaps land in the Gnars split contract; -// other (multichain / EVM batch) swaps land in the SOPA × COINMASTERSGUILD -// split contract, which divides the 0x fee between the two parties on-chain. +// /swap — the affiliate fee taken on the bought token when the user keeps the +// "Support Gnars treasury" checkbox checked. Quotes come from SwapPro, which +// adds this on top of its own 30 bps and pays it out through a 0xSplits +// contract derived from the payout address below. See docs/integrations/swap.md. export const SWAP_FEE_BPS = 50 as const; // 0.5% export const SWAP_FEE_RECIPIENT_BASE = "0x15E69fD67DcC17E061Ceeb93DaC791e0f5aF0Eae" as `0x${string}`; /** - * The SOPA × COINMASTERSGUILD split contract — receives the 0x affiliate - * fee for every non-Base (EVM batch / multichain) swap and splits it between - * the two parties on-chain. Replaces the old multichain custody wallet. + * The SOPA x COINMASTERSGUILD split. Kept as a named address because the + * treasury pages still attribute historical inflows to it; it is NOT a + * /swap payout address. It used to be the recipient for every non-Base swap, + * which meant a checkbox reading "Support Gnars treasury" funded somebody + * else on four chains out of five. See GNARS_SWAP_PAYOUT below. */ export const SWAP_FEE_SPLIT_RECIPIENT = "0xa642b91ff941fb68919d1877e9937f3e369dfd68" as `0x${string}`; -export function getSwapFeeRecipient(chainId: number): `0x${string}` { - return chainId === CHAIN.id ? SWAP_FEE_RECIPIENT_BASE : SWAP_FEE_SPLIT_RECIPIENT; +/** + * Where the treasury's cut is paid, per chain — and nowhere else. + * + * SwapPro derives a 0xSplits contract from (payout address, bps) and pays the + * whole affiliate fee into it, which then divides on-chain between SwapPro and + * us. The derivation is deterministic, so both sides compute the same address + * without registering anything — but the SPLIT and the PAYOUT ADDRESS both + * have to exist as code on the chain the swap settles on. + * + * Measured on 2026-09-03 with eth_getCode: 0x15E69f... holds 89 bytes on Base + * and ZERO on Ethereum, Arbitrum, BNB Chain, Avalanche and Robinhood Chain. + * Paying it on those chains would park the money at an address with nothing + * behind it, so this map has one entry and a chain that is missing from it + * asks for NO partner fee at all — the user then pays SwapPro's 30 bps and + * nothing else, instead of 80 bps of which ours goes nowhere. + * + * TO ADD A CHAIN: deploy the same split (same recipients, same shares, same + * immutable owner) at the same address on that chain — 0xSplits is + * deterministic, so the address carries over — then add the id here. Robinhood + * Chain (4663) is the exception: 0xSplits has no factory there yet, so an EOA + * or a Safe is the only option. + */ +export const GNARS_SWAP_PAYOUT: Readonly> = { + 8453: SWAP_FEE_RECIPIENT_BASE, +}; + +/** + * The payout address for a chain, or null when the treasury cannot be paid + * there. Null is a real answer and the caller must not substitute one: an + * address the treasury does not control is worse than no fee. + */ +export function getSwapFeeRecipient(chainId: number): `0x${string}` | null { + return GNARS_SWAP_PAYOUT[chainId] ?? null; } +/** Can the treasury actually be paid on this chain? Drives the fee checkbox. */ +export const chainPaysTreasury = (chainId: number): boolean => + getSwapFeeRecipient(chainId) !== null; + export const SUBGRAPH = { // Official Nouns Builder Subgraph URL for Gnars on Base (Goldsky public) url: `https://api.goldsky.com/api/public/${process.env.NEXT_PUBLIC_GOLDSKY_PROJECT_ID || "project_cm33ek8kjx6pz010i2c3w8z25"}/subgraphs/nouns-builder-base-mainnet/latest/gn`, diff --git a/src/lib/swappro.test.ts b/src/lib/swappro.test.ts index 8c2670aa..a3023046 100644 --- a/src/lib/swappro.test.ts +++ b/src/lib/swappro.test.ts @@ -5,6 +5,7 @@ import { toSwapProToken, toWidgetError, toWidgetQuote, + SWAPPRO_CHAINS, type SwapProQuote, } from "./swappro"; @@ -128,3 +129,65 @@ describe("widget shape", () => { expect(w.transaction).toBeUndefined(); }); }); + +describe("error shapes", () => { + it("reads the v1 string error", () => { + const w = toWidgetError({ error: "No route for this pair", code: "NO_ROUTE" }); + expect(w.liquidityAvailable).toBe(false); + expect(w.reason).toBe("No route for this pair"); + expect(w.code).toBe("NO_ROUTE"); + }); + + it("reads the standard object error without printing [object Object]", () => { + const w = toWidgetError({ + error: { code: "INSUFFICIENT_LIQUIDITY", message: "Not enough liquidity", retryable: true }, + message: "Not enough liquidity", + }); + expect(w.reason).toBe("Not enough liquidity"); + expect(w.code).toBe("INSUFFICIENT_LIQUIDITY"); + expect(w.reason).not.toContain("object Object"); + }); + + it("falls back to the transitional top-level message when the object carries none", () => { + const w = toWidgetError({ error: { code: "RATE_LIMITED" }, message: "Too many requests" }); + expect(w.reason).toBe("Too many requests"); + expect(w.code).toBe("RATE_LIMITED"); + }); + + it("never leaves the widget without a sentence", () => { + const w = toWidgetError({ error: { retryable: false } }); + expect(w.reason).toBeTruthy(); + expect(w.code).toBe("UPSTREAM_UNAVAILABLE"); + }); +}); + +describe("every SwapPro chain builds a quote URL", () => { + it("routes the six EVM chains and refuses the rest", () => { + for (const chainId of Object.keys(SWAPPRO_CHAINS).map(Number)) { + const url = buildQuoteUrl({ + chainId, + sellToken: NATIVE_SENTINEL, + buyToken: "0x0000000000000000000000000000000000000001", + sellAmount: "1000000000000000000", + sellDecimals: 18, + buyDecimals: 18, + taker: "0x21c9a94AF76B59b171b32fD125A4edF0e9A2Ad3e", + }); + expect(url, `chain ${chainId}`).toContain(`sellChain=${chainId}`); + // The native asset goes by the symbol SwapPro resolves, never the 0x sentinel. + expect(url).toContain(`sellToken=${SWAPPRO_CHAINS[chainId].native}`); + expect(url).not.toContain(NATIVE_SENTINEL); + } + expect( + buildQuoteUrl({ + chainId: 10, + sellToken: NATIVE_SENTINEL, + buyToken: "0x0000000000000000000000000000000000000001", + sellAmount: "1", + sellDecimals: 18, + buyDecimals: 18, + taker: "0x21c9a94AF76B59b171b32fD125A4edF0e9A2Ad3e", + }), + ).toBeNull(); + }); +}); diff --git a/src/lib/swappro.ts b/src/lib/swappro.ts index 78c7af62..a01f6559 100644 --- a/src/lib/swappro.ts +++ b/src/lib/swappro.ts @@ -53,9 +53,34 @@ export interface SwapProQuote { }; } +/** + * SwapPro's error body, in BOTH shapes it can arrive in. + * + * v1 answered `{ error: "…", code: "…" }` with the message as a plain string. + * The standard shape being rolled out answers + * `{ error: { code, message, retryable }, message: "…" }`, keeping the + * top-level `message` for one release. Reading `error` as a string against the + * new body would print "[object Object]" on the screen and nothing would throw, + * so both are read and `reasonOf` decides. + */ export interface SwapProError { - error: string; - code: string; + error: string | { code?: string; message?: string; retryable?: boolean }; + /** The transitional top-level message on the new shape. */ + message?: string; + code?: string; +} + +/** The sentence to show, whichever error shape came back. */ +export function reasonOf(e: SwapProError): string { + if (typeof e.error === "string" && e.error) return e.error; + if (e.error && typeof e.error === "object" && e.error.message) return e.error.message; + return e.message ?? "SwapPro did not say why"; +} + +/** The machine-readable code, whichever error shape came back. */ +export function codeOf(e: SwapProError): string { + if (e.error && typeof e.error === "object" && e.error.code) return e.error.code; + return e.code ?? "UPSTREAM_UNAVAILABLE"; } /** What the widget reads. Superset of the 0x fields it touches. */ @@ -167,7 +192,7 @@ export function toWidgetQuote( /** A SwapPro error in the widget's shape: no liquidity, with the reason it gave. */ export function toWidgetError(e: SwapProError): WidgetQuote { - return { liquidityAvailable: false, reason: e.error, code: e.code }; + return { liquidityAvailable: false, reason: reasonOf(e), code: codeOf(e) }; } /** One call to SwapPro, translated. Never throws on an API answer; throws only when there is none. */ diff --git a/src/lib/swapproRoute.ts b/src/lib/swapproRoute.ts index 211191b7..21ecd3f9 100644 --- a/src/lib/swapproRoute.ts +++ b/src/lib/swapproRoute.ts @@ -36,8 +36,12 @@ export async function answerSwapProQuote(request: NextRequest) { ); } - const fee = - q.get("fee") === "1" ? { recipient: getSwapFeeRecipient(chainId), bps: SWAP_FEE_BPS } : null; + // The fee is opt-in AND chain-gated. `getSwapFeeRecipient` returns null on a + // chain where the treasury has no address that can receive — asking for a fee + // there would collect 50 bps from the user and park it at an address with no + // code behind it. No recipient, no fee: the swap simply costs less. + const recipient = getSwapFeeRecipient(chainId); + const fee = q.get("fee") === "1" && recipient ? { recipient, bps: SWAP_FEE_BPS } : null; try { const { status, body } = await fetchWidgetQuote({