diff --git a/docs/INDEX.md b/docs/INDEX.md index 41077b35..9aa9bd2c 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -25,6 +25,7 @@ This is the canonical entry point for project documentation. Everything below sh - `docs/integrations/pinata.md` — IPFS upload integration - `docs/integrations/splits.md` — 0xSplits droposal revenue sharing +- `docs/integrations/swap.md` — 0x Swap API v2 integration powering /swap, including affiliate-fee config ## Specs diff --git a/docs/integrations/swap.md b/docs/integrations/swap.md new file mode 100644 index 00000000..3e10a702 --- /dev/null +++ b/docs/integrations/swap.md @@ -0,0 +1,75 @@ +# 0x Swap Integration + +The `/swap` page lets users trade ETH, WETH, USDC, GNARS, and a few other Base ERC-20s +through the [0x Swap API v2](https://0x.org/docs/0x-swap-api/introduction). Routing is +handled by 0x's allowance-holder endpoints, and all transaction signing happens through +the existing thirdweb wallet layer (`useWriteAccount`). + +## Architecture + +``` +src/app/swap/ + page.tsx server component — metadata + page chrome + SwapWidget.tsx "use client" — token pickers, debounced price, approve, swap + +src/app/api/0x/ + price/route.ts GET proxy → api.0x.org/swap/allowance-holder/price + quote/route.ts GET proxy → api.0x.org/swap/allowance-holder/quote +``` + +The proxies exist so the `0x-api-key` header stays server-side, and so the affiliate-fee +parameters can be injected without exposing the recipient address in the client bundle. + +## Flow + +1. User picks sell/buy tokens and enters an amount. +2. After 600 ms of idle, `SwapWidget` calls `/api/0x/price` with `chainId`, `sellToken`, + `buyToken`, `sellAmount`, `taker`, and (optionally) `fee=1`. +3. If the response includes `issues.allowance`, the widget shows an "Approve" button. + Approval is signed via `prepareContractCall` + `sendTransaction` against the user's + active thirdweb account and confirmed via `waitForReceipt`. +4. Once approved (or for ETH), the "Swap" button calls `/api/0x/quote` to get a firm + transaction (`{ to, data, value, gas }`), wraps it with `prepareTransaction`, and + sends it via the same thirdweb account. +5. Wrong-network state shows a "Switch to Base" CTA that calls + `wallet.switchChain(thirdwebBase)`. + +## Configuration + +| Setting | Source | Notes | +| --------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `ZEROX_API_KEY` | env (server-only) | 0x API key. Required — proxy returns `500` without it. | +| Fee recipient | `DAO_ADDRESSES.treasury` | Hardcoded to the canonical Gnars treasury in `src/lib/config.ts`. Override via `NEXT_PUBLIC_TREASURY_ADDRESS` like every other DAO address. | +| Fee rate | `SWAP_FEE_BPS` in `src/lib/config.ts` | Defaults to `50` (0.5%). Edit the constant to change. | + +Only `ZEROX_API_KEY` is read from the environment. Everything else ships with the +code so the fee destination and rate are auditable in git rather than hidden in +deploy-time secrets. + +## Affiliate fee behaviour + +The fee is **opt-in per request**: the client appends `&fee=1` to its proxy call, +the proxy sees this flag and injects three params before forwarding to 0x: + +``` +swapFeeRecipient = DAO_ADDRESSES.treasury +swapFeeBps = SWAP_FEE_BPS (default 50) +swapFeeToken = (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`. diff --git a/env.example b/env.example index 5448850d..a5309afd 100644 --- a/env.example +++ b/env.example @@ -61,6 +61,12 @@ PINATA_JWT=your_pinata_jwt_here # Get your key from https://neynar.com/ NEYNAR_API_KEY=your_neynar_api_key_here +# 0x Swap API Key (server-only — used by /api/0x/* proxy routes for the /swap page) +# Get your key from https://dashboard.0x.org/ +# The affiliate fee recipient (DAO treasury) and rate (SWAP_FEE_BPS) live in +# src/lib/config.ts; only the API key is read from the environment. +ZEROX_API_KEY=your_0x_api_key_here + # =========================================== # Optional / Advanced # =========================================== diff --git a/src/app/api/0x/price/route.ts b/src/app/api/0x/price/route.ts new file mode 100644 index 00000000..0fc4cc7d --- /dev/null +++ b/src/app/api/0x/price/route.ts @@ -0,0 +1,61 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { DAO_ADDRESSES, SWAP_FEE_BPS } from "@/lib/config"; + +// Server-only API key — never leaked to the client bundle. +const ZEROX_API_KEY = process.env.ZEROX_API_KEY ?? ""; + +// Fee recipient + rate live in src/lib/config.ts so they ship with the code +// (the DAO treasury is canonical and overridable via NEXT_PUBLIC_TREASURY_ADDRESS +// alongside every other DAO address). +const FEE_RECIPIENT = DAO_ADDRESSES.treasury; +const FEE_BPS = String(SWAP_FEE_BPS); + +const ZEROX_HEADERS: HeadersInit = { + "0x-api-key": ZEROX_API_KEY, + "0x-version": "v2", + "Content-Type": "application/json", +}; + +/** + * GET /api/0x/price — proxy for 0x's allowance-holder/price endpoint. + * + * Forwards every query param through, with two server-side adjustments: + * 1. Strips `fee=1` so it doesn't reach 0x. + * 2. When the client opted in (`fee=1`), injects affiliate fee params + * (`swapFeeRecipient` = DAO treasury, `swapFeeBps` = SWAP_FEE_BPS, + * `swapFeeToken` = buyToken). + * + * Required upstream params: chainId, sellToken, buyToken, sellAmount, taker. + */ +export async function GET(request: NextRequest) { + if (!ZEROX_API_KEY) { + return NextResponse.json( + { error: "ZEROX_API_KEY is not configured on the server" }, + { status: 500 }, + ); + } + + const params = new URLSearchParams(request.nextUrl.searchParams); + const wantsFee = params.get("fee") === "1"; + params.delete("fee"); + + if (wantsFee) { + const buyToken = params.get("buyToken") ?? ""; + if (buyToken) { + params.set("swapFeeRecipient", FEE_RECIPIENT); + params.set("swapFeeBps", FEE_BPS); + params.set("swapFeeToken", buyToken); + } + } + + const upstream = `https://api.0x.org/swap/allowance-holder/price?${params.toString()}`; + + try { + const res = await fetch(upstream, { headers: ZEROX_HEADERS }); + const data = await res.json(); + return NextResponse.json(data, { status: res.status }); + } catch (err) { + const message = err instanceof Error ? err.message : "Upstream request failed"; + return NextResponse.json({ error: message }, { status: 502 }); + } +} diff --git a/src/app/api/0x/quote/route.ts b/src/app/api/0x/quote/route.ts new file mode 100644 index 00000000..b5397313 --- /dev/null +++ b/src/app/api/0x/quote/route.ts @@ -0,0 +1,56 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { DAO_ADDRESSES, SWAP_FEE_BPS } from "@/lib/config"; + +// Server-only API key — never leaked to the client bundle. +const ZEROX_API_KEY = process.env.ZEROX_API_KEY ?? ""; + +// Fee recipient + rate live in src/lib/config.ts; the recipient is the DAO +// treasury (overridable via NEXT_PUBLIC_TREASURY_ADDRESS). +const FEE_RECIPIENT = DAO_ADDRESSES.treasury; +const FEE_BPS = String(SWAP_FEE_BPS); + +const ZEROX_HEADERS: HeadersInit = { + "0x-api-key": ZEROX_API_KEY, + "0x-version": "v2", + "Content-Type": "application/json", +}; + +/** + * GET /api/0x/quote — proxy for 0x's allowance-holder/quote endpoint. + * + * Mirrors the fee-injection logic in /api/0x/price exactly. Both endpoints + * MUST inject the same fee params (or omit them) so the price preview and + * the firm quote remain consistent. + */ +export async function GET(request: NextRequest) { + if (!ZEROX_API_KEY) { + return NextResponse.json( + { error: "ZEROX_API_KEY is not configured on the server" }, + { status: 500 }, + ); + } + + const params = new URLSearchParams(request.nextUrl.searchParams); + const wantsFee = params.get("fee") === "1"; + params.delete("fee"); + + if (wantsFee) { + const buyToken = params.get("buyToken") ?? ""; + if (buyToken) { + params.set("swapFeeRecipient", FEE_RECIPIENT); + params.set("swapFeeBps", FEE_BPS); + params.set("swapFeeToken", buyToken); + } + } + + const upstream = `https://api.0x.org/swap/allowance-holder/quote?${params.toString()}`; + + try { + const res = await fetch(upstream, { headers: ZEROX_HEADERS }); + const data = await res.json(); + return NextResponse.json(data, { status: res.status }); + } catch (err) { + const message = err instanceof Error ? err.message : "Upstream request failed"; + return NextResponse.json({ error: message }, { status: 502 }); + } +} diff --git a/src/app/swap/SwapWidget.tsx b/src/app/swap/SwapWidget.tsx new file mode 100644 index 00000000..19eb8df1 --- /dev/null +++ b/src/app/swap/SwapWidget.tsx @@ -0,0 +1,688 @@ +"use client"; + +import * as React from "react"; +import { ArrowDownUp, Check, ChevronDown, Info, Loader2, Search } from "lucide-react"; +import { toast } from "sonner"; +import { prepareContractCall, prepareTransaction, sendTransaction, waitForReceipt } from "thirdweb"; +import { base as thirdwebBase } from "thirdweb/chains"; +import { useActiveWallet, useActiveWalletChain } from "thirdweb/react"; +import { formatUnits, maxUint256, parseUnits, type Address, type Hex } from "viem"; +import { base } from "wagmi/chains"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useUserAddress } from "@/hooks/use-user-address"; +import { useWriteAccount } from "@/hooks/use-write-account"; +import { DAO_ADDRESSES, TREASURY_TOKEN_ALLOWLIST } from "@/lib/config"; +import { getThirdwebClient } from "@/lib/thirdweb"; +import { ensureOnChain, normalizeTxError } from "@/lib/thirdweb-tx"; +import { cn } from "@/lib/utils"; + +// 0x convention for the native asset slot. +const NATIVE_TOKEN = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" as const; + +const erc20ApproveAbi = [ + { + name: "approve", + type: "function", + stateMutability: "nonpayable", + inputs: [ + { name: "spender", type: "address" }, + { name: "amount", type: "uint256" }, + ], + outputs: [{ type: "bool" }], + }, +] as const; + +interface TokenInfo { + symbol: string; + name: string; + address: `0x${string}` | typeof NATIVE_TOKEN; + decimals: number; + logo?: string; +} + +const TOKENS: readonly TokenInfo[] = [ + { + symbol: "ETH", + name: "Ethereum", + address: NATIVE_TOKEN, + decimals: 18, + logo: "https://assets.relay.link/icons/1/light.png", + }, + { + symbol: "WETH", + name: "Wrapped Ether", + address: TREASURY_TOKEN_ALLOWLIST.WETH as `0x${string}`, + decimals: 18, + logo: "https://assets.relay.link/icons/1/light.png", + }, + { + symbol: "USDC", + name: "USD Coin", + address: TREASURY_TOKEN_ALLOWLIST.USDC as `0x${string}`, + decimals: 6, + logo: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/base/assets/0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913/logo.png", + }, + { + symbol: "GNARS", + name: "Gnars", + address: DAO_ADDRESSES.gnarsErc20 as `0x${string}`, + decimals: 18, + logo: "/gnars.webp", + }, + { + symbol: "DEGEN", + name: "Degen", + address: "0x4ed4e862860bed51a9570b96d89af5e1b0efefed", + decimals: 18, + }, + { + symbol: "HIGHER", + name: "Higher", + address: "0x0578d8a44db98b23bf096a382e016e29a5ce0ffe", + decimals: 18, + }, +] as const; + +const POPULAR_SYMBOLS = ["ETH", "USDC", "GNARS", "DEGEN"] as const; + +const DEFAULT_SELL = TOKENS.find((t) => t.symbol === "ETH")!; +const DEFAULT_BUY = TOKENS.find((t) => t.symbol === "GNARS")!; + +interface ZeroExPriceResponse { + liquidityAvailable?: boolean; + buyAmount?: string; + sellAmount?: string; + totalNetworkFee?: string; + allowanceTarget?: string; + issues?: { + allowance?: { spender?: string } | null; + balance?: { token?: string; actual?: string; expected?: string } | null; + }; + zid?: string; +} + +interface ZeroExQuoteResponse extends ZeroExPriceResponse { + transaction?: { + to: string; + data: string; + value: string; + gas?: string; + }; + reason?: string; +} + +function formatTokenAmount(raw: string | undefined, decimals: number): string { + if (!raw) return "—"; + try { + const value = parseFloat(formatUnits(BigInt(raw), decimals)); + if (value === 0) return "0"; + if (value < 0.0001) return value.toExponential(3); + if (value < 1) return value.toFixed(6); + if (value < 1000) return value.toFixed(4); + return value.toLocaleString(undefined, { maximumFractionDigits: 2 }); + } catch { + return "—"; + } +} + +function TokenLogo({ token, size = 24 }: { token: TokenInfo; size?: number }) { + const dim = `${size}px`; + if (token.logo) { + return ( + + {/* eslint-disable-next-line @next/next/no-img-element */} + + + ); + } + return ( + + {token.symbol[0]} + + ); +} + +interface TokenPickerProps { + value: TokenInfo; + exclude?: `0x${string}` | typeof NATIVE_TOKEN; + onSelect: (token: TokenInfo) => void; + label: string; +} + +function TokenPicker({ value, exclude, onSelect, label }: TokenPickerProps) { + const [open, setOpen] = React.useState(false); + const [query, setQuery] = React.useState(""); + + const popular = React.useMemo( + () => + TOKENS.filter((t) => POPULAR_SYMBOLS.includes(t.symbol as (typeof POPULAR_SYMBOLS)[number])), + [], + ); + + const filtered = React.useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return TOKENS; + return TOKENS.filter( + (t) => + t.symbol.toLowerCase().includes(q) || + t.name.toLowerCase().includes(q) || + t.address.toLowerCase().includes(q), + ); + }, [query]); + + const choose = (token: TokenInfo) => { + if (token.address === exclude) return; + onSelect(token); + setQuery(""); + setOpen(false); + }; + + return ( + + + + + + + Select a token + +
+
+ + setQuery(e.target.value)} + placeholder="Search name or paste address" + className="pl-9" + /> +
+ + {!query && popular.length > 0 && ( +
+

Popular

+
+ {popular.map((t) => { + const isSelected = t.address === value.address; + const isExcluded = t.address === exclude; + return ( + + ); + })} +
+
+ )} + +
+ {filtered.length === 0 ? ( +

No tokens found

+ ) : ( + filtered.map((t) => { + const isSelected = t.address === value.address; + const isExcluded = t.address === exclude; + return ( + + ); + }) + )} +
+
+
+
+ ); +} + +export function SwapWidget() { + const { address, isConnected } = useUserAddress(); + const writer = useWriteAccount(); + const activeWallet = useActiveWallet(); + const activeChain = useActiveWalletChain(); + const isWrongNetwork = isConnected && activeChain?.id !== base.id; + + const [sellToken, setSellToken] = React.useState(DEFAULT_SELL); + const [buyToken, setBuyToken] = React.useState(DEFAULT_BUY); + const [sellAmount, setSellAmount] = React.useState(""); + const [supportFee, setSupportFee] = React.useState(true); + + const [price, setPrice] = React.useState(null); + const [isFetching, setIsFetching] = React.useState(false); + const [needsApproval, setNeedsApproval] = React.useState(false); + const [approvalTarget, setApprovalTarget] = React.useState
(null); + + const [isApproving, setIsApproving] = React.useState(false); + const [isSwapping, setIsSwapping] = React.useState(false); + const [isSwitchingChain, setIsSwitchingChain] = React.useState(false); + + // Debounced indicative price fetch. + React.useEffect(() => { + const numeric = Number(sellAmount); + if (!sellAmount || Number.isNaN(numeric) || numeric <= 0 || !address) { + setPrice(null); + setNeedsApproval(false); + setApprovalTarget(null); + return; + } + + let cancelled = false; + const timeout = setTimeout(async () => { + try { + setIsFetching(true); + const rawAmount = parseUnits(sellAmount, sellToken.decimals).toString(); + const params = new URLSearchParams({ + chainId: String(base.id), + sellToken: sellToken.address, + buyToken: buyToken.address, + sellAmount: rawAmount, + taker: address, + }); + if (supportFee) params.set("fee", "1"); + + const res = await fetch(`/api/0x/price?${params.toString()}`); + const data: ZeroExPriceResponse = await res.json(); + if (cancelled) return; + + setPrice(data); + const spender = data?.issues?.allowance?.spender as Address | undefined; + const requiresApproval = + sellToken.address !== NATIVE_TOKEN && + Boolean(data?.issues?.allowance) && + Boolean(spender); + setNeedsApproval(requiresApproval); + setApprovalTarget(requiresApproval && spender ? spender : null); + } catch (err) { + if (cancelled) return; + console.error("[swap] price fetch failed", err); + setPrice(null); + } finally { + if (!cancelled) setIsFetching(false); + } + }, 600); + + return () => { + cancelled = true; + clearTimeout(timeout); + }; + }, [sellAmount, sellToken, buyToken, address, supportFee]); + + const flip = () => { + setSellToken(buyToken); + setBuyToken(sellToken); + setSellAmount(""); + setPrice(null); + setNeedsApproval(false); + setApprovalTarget(null); + }; + + const handleSwitchChain = async () => { + if (!activeWallet || isSwitchingChain) return; + setIsSwitchingChain(true); + try { + await activeWallet.switchChain(thirdwebBase); + toast.success("Switched to Base"); + } catch (err) { + const { message } = normalizeTxError(err); + toast.error("Failed to switch network", { description: message }); + } finally { + setIsSwitchingChain(false); + } + }; + + const handleApprove = async () => { + const client = getThirdwebClient(); + if (!client || !writer || !approvalTarget) { + toast.error("Cannot approve", { description: "Connect a wallet on Base." }); + return; + } + setIsApproving(true); + try { + await ensureOnChain(writer.wallet, thirdwebBase); + const tx = prepareContractCall({ + contract: { + client, + chain: thirdwebBase, + address: sellToken.address as Address, + abi: erc20ApproveAbi, + }, + method: "approve", + params: [approvalTarget, maxUint256], + }); + const result = await sendTransaction({ account: writer.account, transaction: tx }); + const txHash = result.transactionHash as Hex; + toast.success(`Approval submitted`, { + description: `${txHash.slice(0, 10)}…${txHash.slice(-4)}`, + }); + await waitForReceipt({ client, chain: thirdwebBase, transactionHash: txHash }); + setNeedsApproval(false); + setApprovalTarget(null); + toast.success(`${sellToken.symbol} approved`); + } catch (err) { + const { category, message } = normalizeTxError(err); + if (category === "user-rejected") { + toast.error("Approval cancelled"); + } else { + toast.error("Approval failed", { description: message }); + } + } finally { + setIsApproving(false); + } + }; + + const handleSwap = async () => { + const client = getThirdwebClient(); + if (!client || !writer || !address) { + toast.error("Connect a wallet first"); + return; + } + if (!price?.liquidityAvailable) { + toast.error("No liquidity for this pair"); + return; + } + + setIsSwapping(true); + try { + await ensureOnChain(writer.wallet, thirdwebBase); + + const rawAmount = parseUnits(sellAmount, sellToken.decimals).toString(); + const params = new URLSearchParams({ + chainId: String(base.id), + sellToken: sellToken.address, + buyToken: buyToken.address, + sellAmount: rawAmount, + taker: address, + }); + if (supportFee) params.set("fee", "1"); + + const res = await fetch(`/api/0x/quote?${params.toString()}`); + const quote: ZeroExQuoteResponse = await res.json(); + + if (!quote?.transaction) { + toast.error("Quote unavailable", { + description: quote?.reason ?? "0x returned no transaction", + }); + return; + } + + const tx = prepareTransaction({ + chain: thirdwebBase, + client, + to: quote.transaction.to as Address, + data: quote.transaction.data as Hex, + value: BigInt(quote.transaction.value ?? "0"), + gas: quote.transaction.gas ? BigInt(quote.transaction.gas) : undefined, + }); + + const result = await sendTransaction({ account: writer.account, transaction: tx }); + const txHash = result.transactionHash as Hex; + toast.success("Swap submitted", { + description: `${txHash.slice(0, 10)}…${txHash.slice(-4)}`, + }); + + await waitForReceipt({ client, chain: thirdwebBase, transactionHash: txHash }); + toast.success("Swap confirmed", { + description: `Received ~${formatTokenAmount(quote.buyAmount, buyToken.decimals)} ${buyToken.symbol}`, + }); + + setSellAmount(""); + setPrice(null); + } catch (err) { + const { category, message } = normalizeTxError(err); + if (category === "user-rejected") { + toast.error("Swap cancelled"); + } else { + toast.error("Swap failed", { description: message }); + } + } finally { + setIsSwapping(false); + } + }; + + const buyDisplay = isFetching + ? "…" + : price?.liquidityAvailable + ? formatTokenAmount(price.buyAmount, buyToken.decimals) + : "—"; + + const networkFeeEth = price?.totalNetworkFee + ? parseFloat(formatUnits(BigInt(price.totalNetworkFee), 18)).toFixed(6) + : null; + + const isLoading = isFetching || isApproving || isSwapping; + const hasAmount = sellAmount.length > 0 && Number(sellAmount) > 0; + const insufficientBalance = Boolean(price?.issues?.balance); + const canSwap = + isConnected && + !isWrongNetwork && + hasAmount && + !needsApproval && + !insufficientBalance && + Boolean(price?.liquidityAvailable) && + !isLoading; + + return ( + + + {/* Sell */} +
+

You pay

+
+ setSellAmount(e.target.value)} + className="h-12 border-0 bg-transparent px-0 text-2xl font-bold shadow-none focus-visible:ring-0 focus-visible:ring-offset-0" + /> + { + setSellToken(t); + setSellAmount(""); + setPrice(null); + }} + label="Sell token" + /> +
+
+ + {/* Flip */} +
+ +
+ + {/* Buy */} +
+

You receive

+
+
+ {isFetching ? ( + + ) : ( + buyDisplay + )} +
+ { + setBuyToken(t); + setSellAmount(""); + setPrice(null); + }} + label="Buy token" + /> +
+
+ + {/* Quote info */} + {price && !isFetching && ( +
+ {networkFeeEth && price.liquidityAvailable && ( +
+ Estimated network fee + {networkFeeEth} ETH +
+ )} + {insufficientBalance && ( +

Insufficient {sellToken.symbol} balance

+ )} + {price.liquidityAvailable === false && ( +

No liquidity available for this pair

+ )} +
+ )} + + {/* CTA */} + {!isConnected ? ( +
+ Connect your wallet to swap +
+ ) : isWrongNetwork ? ( + + ) : needsApproval ? ( + + ) : ( + + )} + + {/* Fee opt-in */} +
+ setSupportFee(v === true)} + /> + +
+ +
+ Powered by 0x · Best price across 150+ DEXes + + Base + +
+
+
+ ); +} diff --git a/src/app/swap/page.tsx b/src/app/swap/page.tsx new file mode 100644 index 00000000..5e54c2d1 --- /dev/null +++ b/src/app/swap/page.tsx @@ -0,0 +1,39 @@ +import type { Metadata } from "next"; +import { SwapWidget } from "./SwapWidget"; + +const description = + "Swap ETH, USDC, WETH, and the GNARS token on Base with best execution across 150+ DEXes via the 0x Protocol."; + +export const metadata: Metadata = { + title: "Swap — Gnars DAO", + description, + alternates: { + canonical: "/swap", + }, + openGraph: { + title: "Swap — Gnars DAO", + description, + }, + twitter: { + card: "summary_large_image", + title: "Swap — Gnars DAO", + description, + }, +}; + +export default function SwapPage() { + return ( +
+
+
+

Swap

+

+ Trade tokens on Base with best execution across 150+ DEXes. +

+
+ + +
+
+ ); +} diff --git a/src/components/layout/DaoHeader.tsx b/src/components/layout/DaoHeader.tsx index 326ea75a..76191900 100644 --- a/src/components/layout/DaoHeader.tsx +++ b/src/components/layout/DaoHeader.tsx @@ -24,6 +24,7 @@ import Image from "next/image"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { + ArrowLeftRight, BookOpen, Coins, Gavel, @@ -41,9 +42,9 @@ import { Wallet, } from "lucide-react"; import { toast } from "sonner"; -import { base } from "wagmi/chains"; import { base as thirdwebBase } from "thirdweb/chains"; import { useActiveWallet, useActiveWalletChain } from "thirdweb/react"; +import { base } from "wagmi/chains"; import { DelegationModal } from "@/components/layout/DelegationModal"; import { ThemeToggle } from "@/components/layout/ThemeToggle"; import { Badge } from "@/components/ui/badge"; @@ -111,9 +112,22 @@ const navigationItems = [ ], }, { - title: "Treasury", - href: "/treasury", - icon: Wallet, + title: "Money", + items: [ + { + title: "Treasury", + href: "/treasury", + icon: Wallet, + description: "ETH, ERC-20s, NFTs, and onchain analytics for the DAO treasury", + }, + { + title: "Swap", + href: "/swap", + icon: ArrowLeftRight, + description: "Trade tokens on Base via 0x — best price across 150+ DEXes", + badge: "NEW!", + }, + ], }, { title: "Community", diff --git a/src/lib/config.ts b/src/lib/config.ts index 644135c8..c2903657 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -9,11 +9,16 @@ export const CHAIN = { // Core Builder DAO addresses — override via env vars to deploy for a different DAO export const DAO_ADDRESSES = { - token: (process.env.NEXT_PUBLIC_TOKEN_ADDRESS || "0x880fb3cf5c6cc2d7dfc13a993e839a9411200c17") as `0x${string}`, - auction: (process.env.NEXT_PUBLIC_AUCTION_ADDRESS || "0x494eaa55ecf6310658b8fc004b0888dcb698097f") as `0x${string}`, - governor: (process.env.NEXT_PUBLIC_GOVERNOR_ADDRESS || "0x3dd4e53a232b7b715c9ae455f4e732465ed71b4c") as `0x${string}`, - treasury: (process.env.NEXT_PUBLIC_TREASURY_ADDRESS || "0x72ad986ebac0246d2b3c565ab2a1ce3a14ce6f88") as `0x${string}`, - metadata: (process.env.NEXT_PUBLIC_METADATA_ADDRESS || "0xdc9799d424ebfdcf5310f3bad3ddcce3931d4b58") as `0x${string}`, + token: (process.env.NEXT_PUBLIC_TOKEN_ADDRESS || + "0x880fb3cf5c6cc2d7dfc13a993e839a9411200c17") as `0x${string}`, + auction: (process.env.NEXT_PUBLIC_AUCTION_ADDRESS || + "0x494eaa55ecf6310658b8fc004b0888dcb698097f") as `0x${string}`, + governor: (process.env.NEXT_PUBLIC_GOVERNOR_ADDRESS || + "0x3dd4e53a232b7b715c9ae455f4e732465ed71b4c") as `0x${string}`, + treasury: (process.env.NEXT_PUBLIC_TREASURY_ADDRESS || + "0x72ad986ebac0246d2b3c565ab2a1ce3a14ce6f88") as `0x${string}`, + metadata: (process.env.NEXT_PUBLIC_METADATA_ADDRESS || + "0xdc9799d424ebfdcf5310f3bad3ddcce3931d4b58") as `0x${string}`, gnarsErc20: "0x0cf0c3b75d522290d7d12c74d7f1f0cc47ccb23b", // $GNARS ERC20 token } as const; @@ -33,7 +38,7 @@ export const GNARS_ZORA_HANDLE = "gnars" as const; // Use for known community members whose wallets are fragmented across profiles. export const GNARS_CREATOR_ALLOWLIST: readonly string[] = [ "skatehacker", // vlad — NFTs on skateboard/maconhinha.base.eth wallets - "nogenta", // nogenta — 9 NFTs, may fall outside top-200 subgraph scan + "nogenta", // nogenta — 9 NFTs, may fall outside top-200 subgraph scan ] as const; // Zora Factory contract on Base @@ -50,24 +55,27 @@ 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 paid to the DAO treasury when the user +// keeps the "Support Gnars treasury" checkbox checked. Fee is taken on the +// bought token. The recipient is always `DAO_ADDRESSES.treasury`; this just +// controls the rate. +export const SWAP_FEE_BPS = 50 as const; // 0.5% 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`, - + // Legacy Gnars subgraph on Ethereum mainnet (The Graph Studio) ethMainnet: "https://api.studio.thegraph.com/query/84885/gnars-mainnet/v1.0.0", } as const; - export const GNARS_ADDRESSES_ETH = { token: "0x558bfff0d583416f7c4e380625c7865821b8e95c", governor: "0xd10e3dee203579fcee90ed7d0bdd8086f7e53beb", treasury: "0x4d3a210f40f83286dc5e4d3fe285dcfef30cce52", } as const; -export const DAO_DESCRIPTION = - "Nounish Open Source Action Sports Brand experiment"; +export const DAO_DESCRIPTION = "Nounish Open Source Action Sports Brand experiment"; export const HOMEPAGE_DESCRIPTIONS = [ "Nounish Open Source Action Sports Brand experiment", @@ -75,7 +83,7 @@ export const HOMEPAGE_DESCRIPTIONS = [ "Building the future of shredding", "Empowering athletes through collective governance", "Has funded 15 skatable sculptures around the world", - "é foda pra caralho!" + "é foda pra caralho!", ] as const; // Token contracts we care about for treasury display