Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/src/app/api/launch/meta/route.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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();
Expand Down
6 changes: 6 additions & 0 deletions app/src/app/api/launch/sync/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
5 changes: 5 additions & 0 deletions app/src/app/api/presence/route.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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();
Expand Down
45 changes: 45 additions & 0 deletions app/src/app/api/write-limits.test.ts
Original file line number Diff line number Diff line change
@@ -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*\}/);
});
66 changes: 60 additions & 6 deletions app/src/components/launchpad/TradePanel.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand All @@ -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;


Expand All @@ -54,6 +54,12 @@ export default function TradePanel({ chain, token, symbol, poolKey, quote, ethUs
const { switchChainAsync, isPending: switching } = useSwitchChain();
const [side, setSide] = useState<Side>("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<string | null>(null);
const [slippageError, setSlippageError] = useState<string | null>(null);
const [quote_, setQuote] = useState<{ out: bigint; forKey: string } | null>(null);
const [quoting, setQuoting] = useState(false);
const [phase, setPhase] = useState<Phase>({ k: "idle" });
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -207,8 +221,48 @@ export default function TradePanel({ chain, token, symbol, poolKey, quote, ethUs
</div>
</div>
<dl className="space-y-2 text-[11px]">
<div className="flex justify-between gap-3"><dt className="text-muted">Minimum received</dt><dd className="text-right font-mono text-body tnum">{quote_ && quote_.forKey === quoteKey ? (side === "buy" ? `${fmtCompact(Number(minOut(quote_.out, SLIPPAGE_BPS)) / 1e18)} ${symbol}` : fmtQ(minOut(quote_.out, SLIPPAGE_BPS))) : "—"}</dd></div>
<div className="flex justify-between gap-3"><dt className="text-muted">Slippage tolerance</dt><dd className="font-mono text-body tnum">1%</dd></div>
<div className="flex justify-between gap-3"><dt className="text-muted">Minimum received</dt><dd className="text-right font-mono text-body tnum">{quote_ && quote_.forKey === quoteKey ? (side === "buy" ? `${fmtCompact(Number(minOut(quote_.out, slippageBps)) / 1e18)} ${symbol}` : fmtQ(minOut(quote_.out, slippageBps))) : "—"}</dd></div>
<div className="flex items-center justify-between gap-3">
<dt className="text-muted"><label htmlFor={`slippage-${chain}-${token}`}>Slippage tolerance</label></dt>
<dd className="flex items-center gap-1.5">
{SLIPPAGE_PRESETS_BPS.map((p) => (
<button
key={p}
type="button"
disabled={busy}
aria-pressed={slippageBps === p}
aria-label={`Slippage ${formatSlippageBps(p)}`}
onClick={() => { setSlippageBps(chain, p); setSlippageInput(null); setSlippageError(null); }}
className={`min-h-7 rounded-md border px-2 font-mono text-[11px] tnum disabled:opacity-40 ${slippageBps === p ? "border-line-strong bg-paper text-ink" : "border-line text-muted hover:border-line-strong"}`}
>
{formatSlippageBps(p)}
</button>
))}
<span className="relative">
<input
id={`slippage-${chain}-${token}`}
disabled={busy}
className="h-7 w-16 rounded-md border border-line bg-transparent px-1.5 pr-5 text-right font-mono text-[11px] text-ink tnum outline-offset-2 placeholder:text-faint disabled:opacity-40"
value={slippageInput ?? String(slippageBps / 100)}
onChange={(e) => {
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}
/>
<span className="pointer-events-none absolute right-1.5 top-1/2 -translate-y-1/2 text-[10px] text-muted">%</span>
</span>
</dd>
</div>
{slippageError ? <p id={`slippage-err-${chain}`} role="alert" className="text-right text-[10px] text-down-ink">Use 0.1–20%.</p> : null}
</dl>

{!configured ? (
Expand Down
77 changes: 77 additions & 0 deletions app/src/lib/launchpad/trade-slippage.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading
Loading