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..30d5d4d1 100644 --- a/docs/integrations/swap.md +++ b/docs/integrations/swap.md @@ -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 = (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. 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..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 @@ -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(); @@ -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); @@ -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(); @@ -976,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/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/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 new file mode 100644 index 00000000..a3023046 --- /dev/null +++ b/src/lib/swappro.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from "vitest"; +import { + buildQuoteUrl, + NATIVE_SENTINEL, + toSwapProToken, + toWidgetError, + toWidgetQuote, + SWAPPRO_CHAINS, + 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(); + }); +}); + +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 new file mode 100644 index 00000000..a01f6559 --- /dev/null +++ b/src/lib/swappro.ts @@ -0,0 +1,227 @@ +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; + }; +} + +/** + * 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; 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. */ +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: reasonOf(e), code: codeOf(e) }; +} + +/** 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..21ecd3f9 --- /dev/null +++ b/src/lib/swapproRoute.ts @@ -0,0 +1,65 @@ +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 }, + ); + } + + // 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({ + 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 }, + ); + } +}