diff --git a/app/src/app/api/launch/meta/route.ts b/app/src/app/api/launch/meta/route.ts index 351a27e..7ba815b 100644 --- a/app/src/app/api/launch/meta/route.ts +++ b/app/src/app/api/launch/meta/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { rateLimited } from "@/lib/launchpad/editServer"; import { MetaConflict, saveMeta, validateMeta } from "@/lib/launchpad/meta"; import { LAUNCHPAD_CONFIGURED } from "@/lib/launchpad/config"; @@ -7,6 +8,10 @@ export const dynamic = "force-dynamic"; /** POST — store off-chain metadata for a launch about to be sent; returns the metadataURI + predicted token. */ export async function POST(req: Request) { if (!LAUNCHPAD_CONFIGURED) return NextResponse.json({ error: "launchpad unconfigured" }, { status: 503 }); + // Unsigned by design (salt secrecy decides conflicts), so the IP bucket is + // the only thing stopping junk-row sprays and predictToken RPC burn. + const ip = (req.headers.get("fly-client-ip") || req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; + if (rateLimited(`meta:ip:${ip}`, 20)) return NextResponse.json({ error: "slow down" }, { status: 429 }); let body: unknown; try { body = await req.json(); diff --git a/app/src/app/api/launch/sync/route.ts b/app/src/app/api/launch/sync/route.ts index 8fc45f2..89f9cb0 100644 --- a/app/src/app/api/launch/sync/route.ts +++ b/app/src/app/api/launch/sync/route.ts @@ -1,11 +1,17 @@ import { NextResponse } from "next/server"; import { isChainKey } from "@/lib/chainPublic"; +import { rateLimited } from "@/lib/launchpad/editServer"; import { applyLaunchTx, pollAll } from "@/lib/launchpad/indexer"; export const dynamic = "force-dynamic"; /** POST /api/launch/sync?chain=base|robinhood&tx=0x… → apply one receipt now. No tx → poll every configured chain. */ export async function POST(req: Request) { + // Unauthenticated and the most expensive route in the app (a tx-less call + // fans out over every configured chain: log ranges, heals and backfills), + // so it gets the same per-IP bucket as the other write endpoints. + const ip = (req.headers.get("fly-client-ip") || req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; + if (rateLimited(`sync:ip:${ip}`, 10)) return NextResponse.json({ error: "slow down" }, { status: 429 }); const u = new URL(req.url); const tx = u.searchParams.get("tx"); const chain = u.searchParams.get("chain") ?? "base"; diff --git a/app/src/app/api/presence/route.ts b/app/src/app/api/presence/route.ts index 5092db3..3f2d29a 100644 --- a/app/src/app/api/presence/route.ts +++ b/app/src/app/api/presence/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { rateLimited } from "@/lib/launchpad/editServer"; import { looksLikeBot, prunePresence, readPulse, recordBeacon, visitorHash } from "@/lib/launchpad/presence"; import { memo } from "@/lib/launchpad/memo"; @@ -16,6 +17,10 @@ export async function POST(req: Request) { const ua = req.headers.get("user-agent") ?? ""; if (looksLikeBot(ua)) return NextResponse.json(await readPulse(), { headers: { "cache-control": "no-store" } }); const ip = (req.headers.get("fly-client-ip") || req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; + // The visitor hash mixes in the UA, which the caller fully controls: without + // a bucket, rotating UAs mints a fresh bb_presence row per request and + // inflates visits. 30/min leaves normal beaconing (~every few seconds) alone. + if (rateLimited(`presence:ip:${ip}`, 30)) return NextResponse.json({ error: "slow down" }, { status: 429 }); const pulse = await recordBeacon(visitorHash(ip, ua)); if (Date.now() - lastPrune > 3_600_000) { lastPrune = Date.now(); diff --git a/app/src/app/api/write-limits.test.ts b/app/src/app/api/write-limits.test.ts new file mode 100644 index 0000000..0f387c4 --- /dev/null +++ b/app/src/app/api/write-limits.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +/** + * Pin the per-IP rate-limit wiring on the unauthenticated write endpoints. + * The limiter behavior itself is unit-tested in editServer.test.ts; these + * assertions fail if anyone drops the check from a route (the same + * source-structural pattern as the preparing-controls test in + * transaction-safety.test.ts, since importing Next routes into node --test + * is not supported). + */ +function source(path: string): string { + return readFileSync(new URL(path, import.meta.url), "utf8"); +} + +for ( + const { file, bucket, mustPrecede } of [ + { file: "./launch/sync/route.ts", bucket: "sync:ip", mustPrecede: "pollAll()" }, + { file: "./launch/meta/route.ts", bucket: "meta:ip", mustPrecede: "req.json()" }, + { file: "./presence/route.ts", bucket: "presence:ip", mustPrecede: "recordBeacon(" }, + ] +) { + test(`${file} rate-limits by IP before doing work`, () => { + const src = source(file); + assert.match(src, /from "@\/lib\/launchpad\/editServer"/, "imports the shared limiter"); + assert.ok(src.includes("rateLimited(`" + bucket + ":${ip}`"), `calls rateLimited with a ${bucket} key`); + assert.match(src, /\{\s*error:\s*"slow down"\s*\},\s*\{\s*status:\s*429\s*\}/, "answers 429 like the sibling write routes"); + assert.ok( + src.indexOf("rateLimited(`" + bucket) < src.indexOf(mustPrecede), + `the limit runs before ${mustPrecede}`, + ); + }); +} + +test("presence still short-circuits bots before counting the bucket", () => { + const src = source("./presence/route.ts"); + assert.ok(src.indexOf("looksLikeBot(ua)") < src.indexOf("rateLimited(`presence:ip"), "bot beacons never touch the limiter or the DB"); +}); + +test("sync still validates tx/chain shapes with 400s", () => { + const src = source("./launch/sync/route.ts"); + assert.match(src, /\{\s*error:\s*"bad tx"\s*\},\s*\{\s*status:\s*400\s*\}/); + assert.match(src, /\{\s*error:\s*"bad chain"\s*\},\s*\{\s*status:\s*400\s*\}/); +}); diff --git a/app/src/components/launchpad/TradePanel.tsx b/app/src/components/launchpad/TradePanel.tsx index 159d057..79f8c4d 100644 --- a/app/src/components/launchpad/TradePanel.tsx +++ b/app/src/components/launchpad/TradePanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { useRouter } from "next/navigation"; import { useAccount, useBalance, useConfig, useReadContract, useSwitchChain } from "wagmi"; import { getPublicClient, getWalletClient } from "wagmi/actions"; @@ -15,6 +15,7 @@ import { fmtCompact, fmtQuoteUnits, fmtUsd, minOut, units, pipsToPct } from "@/l import { encodeV4ExactInSingle, type PoolKey } from "@/lib/launchpad/swap"; import { CHAINS, CHAIN_LABELS, BUILDER_DATA_SUFFIX, explorerTx, type ChainKey } from "@/lib/chainPublic"; import { tradeQuoteKey } from "@/lib/launchpad/token-market"; +import { SLIPPAGE_PRESETS_BPS, formatSlippageBps, getSlippageBps, getSlippageBpsServer, parseSlippageField, setSlippageBps, subscribeSlippage } from "@/lib/launchpad/trade-slippage"; import { friendlyError } from "@/lib/errors"; import { Spinner } from "@/components/Skeleton"; import ConnectWallet from "@/components/ConnectWallet"; @@ -36,7 +37,6 @@ type Phase = | { k: "done"; hash: Hex; side: Side } | { k: "error"; message: string }; -const SLIPPAGE_BPS = 100; // 1% const PERMIT_EXPIRY_S = 30 * 24 * 3600; @@ -54,6 +54,12 @@ export default function TradePanel({ chain, token, symbol, poolKey, quote, ethUs const { switchChainAsync, isPending: switching } = useSwitchChain(); const [side, setSide] = useState("buy"); const [amount, setAmount] = useState(""); + // Stored slippage via an external store: the server snapshot is always the default, so server and + // client render the same markup during hydration (an effect that sets state is rejected by lint). + const getSlippageSnapshot = useMemo(() => () => getSlippageBps(chain), [chain]); + const slippageBps = useSyncExternalStore(subscribeSlippage, getSlippageSnapshot, getSlippageBpsServer); + const [slippageInput, setSlippageInput] = useState(null); + const [slippageError, setSlippageError] = useState(null); const [quote_, setQuote] = useState<{ out: bigint; forKey: string } | null>(null); const [quoting, setQuoting] = useState(false); const [phase, setPhase] = useState({ k: "idle" }); @@ -109,11 +115,19 @@ export default function TradePanel({ chain, token, symbol, poolKey, quote, ethUs // Lock before the first await, including wallet lookup and RPC preflight. transactionLock.current = true; setPhase({ k: "preparing" }); + // Declared here so catch can use it; assigned after preflight so the + // synchronous lock/preparing prefix stays dependency-free for the safety + // harness. Null means preflight failed before the tolerance was read — + // those errors are never slippage reverts, so the default applies. + let tradeSlippageBps: number | null = null; try { if (!onChain) await switchChainAsync({ chainId: CHAIN.id }); const pub = getPublicClient(config, { chainId: CHAIN.id })!; const wallet = await getWalletClient(config, { chainId: CHAIN.id }); - const min = minOut(quote_.out, SLIPPAGE_BPS); + // The closure value is fixed per render, so mid-flight preset picks in a + // newer render cannot change this transaction's tolerance either way. + tradeSlippageBps = slippageBps; + const min = minOut(quote_.out, tradeSlippageBps); // Whatever ERC20 we are paying with (the token on a sell, an ERC20 quote on a buy) goes through Permit2. const payToken: Address | null = side === "sell" ? token : isNative ? null : quote.address; @@ -166,7 +180,7 @@ export default function TradePanel({ chain, token, symbol, poolKey, quote, ethUs onTraded?.(); router.refresh(); } catch (err) { - setPhase({ k: "error", message: friendlyError(err) }); + setPhase({ k: "error", message: friendlyError(err, tradeSlippageBps !== null ? { slippagePct: tradeSlippageBps / 100 } : {}) }); } finally { transactionLock.current = false; } @@ -207,8 +221,48 @@ export default function TradePanel({ chain, token, symbol, poolKey, quote, ethUs
-
Minimum received
{quote_ && quote_.forKey === quoteKey ? (side === "buy" ? `${fmtCompact(Number(minOut(quote_.out, SLIPPAGE_BPS)) / 1e18)} ${symbol}` : fmtQ(minOut(quote_.out, SLIPPAGE_BPS))) : "—"}
-
Slippage tolerance
1%
+
Minimum received
{quote_ && quote_.forKey === quoteKey ? (side === "buy" ? `${fmtCompact(Number(minOut(quote_.out, slippageBps)) / 1e18)} ${symbol}` : fmtQ(minOut(quote_.out, slippageBps))) : "—"}
+
+
+
+ {SLIPPAGE_PRESETS_BPS.map((p) => ( + + ))} + + { + const raw = e.target.value; + setSlippageInput(raw); + const parsed = parseSlippageField(raw); + if (parsed === null) { setSlippageError(raw.trim() === "" ? null : "0.1–20%"); return; } + setSlippageError(null); + setSlippageBps(chain, parsed); + }} + onBlur={() => setSlippageInput(null)} + inputMode="decimal" + autoComplete="off" + aria-label="Custom slippage percent" + aria-describedby={slippageError ? `slippage-err-${chain}` : undefined} + /> + % + +
+
+ {slippageError ? : null}
{!configured ? ( diff --git a/app/src/lib/launchpad/trade-slippage.test.ts b/app/src/lib/launchpad/trade-slippage.test.ts new file mode 100644 index 0000000..ebc6e64 --- /dev/null +++ b/app/src/lib/launchpad/trade-slippage.test.ts @@ -0,0 +1,77 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { clampSlippageBps, formatSlippageBps, getSlippageBps, getSlippageBpsServer, parseSlippageField, parseSlippageInput, SLIPPAGE_DEFAULT_BPS, SLIPPAGE_MAX_BPS, SLIPPAGE_MIN_BPS, SLIPPAGE_PRESETS_BPS, loadSlippageBps, setSlippageBps, slippageStorageKey, subscribeSlippage } from "./trade-slippage.ts"; + +test("presets include the old 1% default", () => { + assert.ok(SLIPPAGE_PRESETS_BPS.includes(SLIPPAGE_DEFAULT_BPS)); + assert.deepEqual([...SLIPPAGE_PRESETS_BPS], [50, 100, 300]); +}); + +test("clampSlippageBps bounds garbage to the default and rounds", () => { + assert.equal(clampSlippageBps(100), 100); + assert.equal(clampSlippageBps(150.4), 150); + assert.equal(clampSlippageBps(1), SLIPPAGE_MIN_BPS, "below 0.1% clamps up"); + assert.equal(clampSlippageBps(0), SLIPPAGE_MIN_BPS, "0% clamps up (exact-out would always revert)"); + assert.equal(clampSlippageBps(-5), SLIPPAGE_MIN_BPS, "negative clamps up"); + assert.equal(clampSlippageBps(99_999), SLIPPAGE_MAX_BPS, "above 20% clamps down"); + for (const bad of [NaN, Infinity, null, undefined, "abc", ""]) assert.equal(clampSlippageBps(bad), SLIPPAGE_DEFAULT_BPS, `${String(bad)} → default`); + assert.equal(clampSlippageBps("300"), 300, "stored strings parse"); +}); + +test("parseSlippageInput takes percent strings, rejects junk", () => { + assert.equal(parseSlippageInput("1"), 100); + assert.equal(parseSlippageInput("0.5"), 50); + assert.equal(parseSlippageInput("0.1"), 10, "minimum is selectable"); + assert.equal(parseSlippageInput("3%"), 300); + assert.equal(parseSlippageInput(" 2.5 % "), 250); + for (const bad of ["", " ", "0", "0.01", "0.09", "-1", "abc", "1%%", "21", "100"]) assert.equal(parseSlippageInput(bad), null, `${JSON.stringify(bad)} rejected`); +}); + +test("parseSlippageField validates raw input: comma is a decimal, minus/letters are rejected (PR #30)", () => { + assert.equal(parseSlippageField("0.5"), 50); + assert.equal(parseSlippageField("0,5"), 50, "decimal comma (mobile keyboards) normalizes to a dot"); + assert.equal(parseSlippageField("0,5%"), 50); + assert.equal(parseSlippageField("1"), 100); + assert.equal(parseSlippageField("-3"), null, "minus must not strip into a valid tolerance"); + assert.equal(parseSlippageField("0,5".replace(".", ",")), 50); + for (const bad of ["0,5x", "a1", "1a", "--3", ""]) { + if (bad === "") { assert.equal(parseSlippageField(bad), null); continue; } + assert.equal(parseSlippageField(bad), null, `${JSON.stringify(bad)} keeps the previous tolerance`); + } + // The old handler stripped to "05" (5%); the field parser must not do that. + assert.notEqual(parseSlippageField("0,5"), 500); +}); + +test("formatSlippageBps trims cleanly", () => { + assert.equal(formatSlippageBps(100), "1%"); + assert.equal(formatSlippageBps(50), "0.5%"); + assert.equal(formatSlippageBps(250), "2.5%"); + assert.equal(formatSlippageBps(33), "0.33%"); +}); + +test("storage key is per-chain; node (no localStorage) loads the default", () => { + assert.equal(slippageStorageKey("base"), "ol:slippage-bps:base"); + assert.notEqual(slippageStorageKey("base"), slippageStorageKey("robinhood")); + assert.equal(loadSlippageBps("base"), SLIPPAGE_DEFAULT_BPS); +}); + +test("bounds constants are sane", () => { + assert.equal(SLIPPAGE_MIN_BPS, 10, "0.1%"); + assert.equal(SLIPPAGE_MAX_BPS, 2000, "20%"); +}); + +test("external store: server snapshot is the default; set notifies subscribers", () => { + assert.equal(getSlippageBpsServer(), SLIPPAGE_DEFAULT_BPS); + let calls = 0; + const unsub = subscribeSlippage(() => { calls += 1; }); + setSlippageBps("base", 300); + assert.equal(getSlippageBps("base"), 300); + assert.equal(calls, 1); + setSlippageBps("base", 999_999); + assert.equal(getSlippageBps("base"), SLIPPAGE_MAX_BPS, "store clamps like the input"); + assert.equal(calls, 2); + unsub(); + setSlippageBps("base", 100); + assert.equal(calls, 2, "unsubscribed"); + assert.equal(getSlippageBps("base"), 100); +}); diff --git a/app/src/lib/launchpad/trade-slippage.ts b/app/src/lib/launchpad/trade-slippage.ts new file mode 100644 index 0000000..599084b --- /dev/null +++ b/app/src/lib/launchpad/trade-slippage.ts @@ -0,0 +1,131 @@ +/** + * User slippage control for token-page trades (pure; unit-tested). + * + * TradePanel hardcoded 1% (`SLIPPAGE_BPS = 100`): on a fresh launch that is + * often too tight (reverts, frontrun failures) and sometimes too loose for a + * large size. This module owns the presets, parsing, clamping and per-chain + * persistence; TradePanel keeps rendering the default on the server and loads + * the stored value in an effect, so hydration never mismatches. + */ + +/** Default when nothing is stored (the old hardcoded 1%). */ +export const SLIPPAGE_DEFAULT_BPS = 100; +/** Quick presets shown under the quote. */ +export const SLIPPAGE_PRESETS_BPS: readonly number[] = [50, 100, 300]; +/** Hard bounds for custom input: 0.1% … 20%. */ +export const SLIPPAGE_MIN_BPS = 10; +export const SLIPPAGE_MAX_BPS = 2000; + +/** Clamp anything to an integer within [MIN, MAX]; garbage → default. */ +export function clampSlippageBps(v: unknown): number { + const n = typeof v === "string" && v.trim() !== "" ? Number(v) : typeof v === "number" ? v : NaN; + if (!Number.isFinite(n)) return SLIPPAGE_DEFAULT_BPS; + return Math.min(SLIPPAGE_MAX_BPS, Math.max(SLIPPAGE_MIN_BPS, Math.round(n))); +} + +/** + * Parse a percent string the user typed ("1", "0.5", "2.5%") into integer bps. + * Null when empty, unusable, or outside 0.1–20% (caller keeps the previous + * value and shows a hint — never silently clamps a below-minimum entry up). + */ +export function parseSlippageInput(raw: string): number | null { + const t = raw.trim().replace(/%$/, "").trim(); + if (!t) return null; + const pct = Number(t); + if (!Number.isFinite(pct) || pct < SLIPPAGE_MIN_BPS / 100 || pct > SLIPPAGE_MAX_BPS / 100) return null; + return clampSlippageBps(Math.round(pct * 100)); +} + +/** + * Validate the raw field value before stripping characters (PR #30). + * + * The input handler used to `replace(/[^0-9.%]/g, "")` first, so pasting + * `0,5` became `05` (5% instead of 0.5%) and `-3` became `3`. This validates + * the raw text: a leading `-` or any letter is rejected (caller keeps the + * previous tolerance), while a decimal comma (common on mobile keyboards) is + * normalized to a dot. Covered by unit tests since parser-only tests bypass + * the input transformation. + */ +export function parseSlippageField(raw: string): number | null { + if (raw.includes("-")) return null; + const normalized = raw.replace(/,/g, "."); + if (/[^0-9.%\s]/.test(normalized)) return null; + return parseSlippageInput(normalized); +} + +/** "1%" / "0.5%" for labels. */ +export function formatSlippageBps(bps: number): string { + const pct = bps / 100; + return `${Number.isInteger(pct) ? pct : pct.toFixed(2).replace(/0+$/, "").replace(/\.$/, "")}%`; +} + +export function slippageStorageKey(chain: string): string { + return `ol:slippage-bps:${chain}`; +} + +/** Stored value, or the default on the server / when storage is blocked. */ +export function loadSlippageBps(chain: string): number { + try { + if (typeof localStorage === "undefined") return SLIPPAGE_DEFAULT_BPS; + return clampSlippageBps(localStorage.getItem(slippageStorageKey(chain))); + } catch { + return SLIPPAGE_DEFAULT_BPS; + } +} + +export function saveSlippageBps(chain: string, bps: number): void { + try { + if (typeof localStorage === "undefined") return; + localStorage.setItem(slippageStorageKey(chain), String(clampSlippageBps(bps))); + } catch { + /* storage blocked: the in-memory state above is what the UI keeps until reload */ + } +} + +const slippageListeners = new Set<() => void>(); +const slippageCache = new Map(); + +function readStoredSlippage(chain: string): number | null { + try { + if (typeof localStorage === "undefined") return null; + const raw = localStorage.getItem(slippageStorageKey(chain)); + if (raw === null || raw.trim() === "") return null; + const n = Number(raw); + if (!Number.isFinite(n)) return null; + return clampSlippageBps(n); + } catch { + return null; + } +} + +/** + * External-store plumbing so components read stored slippage with + * useSyncExternalStore (server snapshot is always the default, so server and + * client render the same markup during hydration; an effect that sets state + * is rejected by the lint rules). + */ +export function subscribeSlippage(cb: () => void): () => void { + slippageListeners.add(cb); + return () => { slippageListeners.delete(cb); }; +} + +/** Client snapshot: cached value, else storage, else the default. */ +export function getSlippageBps(chain: string): number { + const hit = slippageCache.get(chain); + if (hit !== undefined) return hit; + const v = readStoredSlippage(chain) ?? SLIPPAGE_DEFAULT_BPS; + slippageCache.set(chain, v); + return v; +} + +/** Server snapshot: always the default. */ +export function getSlippageBpsServer(): number { + return SLIPPAGE_DEFAULT_BPS; +} + +export function setSlippageBps(chain: string, bps: number): void { + const v = clampSlippageBps(bps); + slippageCache.set(chain, v); + saveSlippageBps(chain, v); + for (const cb of slippageListeners) cb(); +}