From 9f81506100ac6a4ab4119aa68b77fdba730c93c4 Mon Sep 17 00:00:00 2001 From: Ayush7614 Date: Fri, 11 Sep 2026 14:40:58 +0530 Subject: [PATCH 1/4] feat(trade): user slippage control with per-chain persistence TradePanel hardcoded 1% slippage: too tight on fresh launches (avoidable reverts) with no override. Add 0.5/1/3% presets plus custom 0.1-20% input, persisted per chain, wired into minOut and Minimum received. Default stays 1% so existing behavior is unchanged unless the user picks otherwise. New pure module trade-slippage.ts (presets, parse/clamp/format, external- store with server snapshot so hydration never mismatches) with 7 unit tests. --- app/src/components/launchpad/TradePanel.tsx | 56 ++++++++- app/src/lib/launchpad/trade-slippage.test.ts | 61 ++++++++++ app/src/lib/launchpad/trade-slippage.ts | 113 +++++++++++++++++++ 3 files changed, 225 insertions(+), 5 deletions(-) create mode 100644 app/src/lib/launchpad/trade-slippage.test.ts create mode 100644 app/src/lib/launchpad/trade-slippage.ts diff --git a/app/src/components/launchpad/TradePanel.tsx b/app/src/components/launchpad/TradePanel.tsx index 159d0578..708e55d8 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, parseSlippageInput, 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" }); @@ -113,7 +119,7 @@ export default function TradePanel({ chain, token, symbol, poolKey, quote, ethUs 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); + const min = minOut(quote_.out, slippageBps); // 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; @@ -207,8 +213,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.replace(/[^0-9.%]/g, ""); + setSlippageInput(raw); + const parsed = parseSlippageInput(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 00000000..a16803cd --- /dev/null +++ b/app/src/lib/launchpad/trade-slippage.test.ts @@ -0,0 +1,61 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { clampSlippageBps, formatSlippageBps, getSlippageBps, getSlippageBpsServer, 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("3%"), 300); + assert.equal(parseSlippageInput(" 2.5 % "), 250); + for (const bad of ["", " ", "0", "-1", "abc", "1%%", "21", "100"]) assert.equal(parseSlippageInput(bad), null, `${JSON.stringify(bad)} rejected`); +}); + +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 00000000..1818f3af --- /dev/null +++ b/app/src/lib/launchpad/trade-slippage.ts @@ -0,0 +1,113 @@ +/** + * 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 or unusable (caller keeps the previous value and shows a hint). + */ +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 <= 0 || pct > SLIPPAGE_MAX_BPS / 100) return null; + return clampSlippageBps(Math.round(pct * 100)); +} + +/** "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(); +} From 46e89f5c1079628cde71f2ad94aa3c7b38271d7e Mon Sep 17 00:00:00 2001 From: Ayush7614 Date: Fri, 11 Sep 2026 14:59:42 +0530 Subject: [PATCH 2/4] fix(trade): reject sub-minimum slippage input instead of clamping (CodeRabbit, PR #30) parseSlippageInput("0.01") passed validation then clamped to 10bps, silently changing the selection to 0.1%. Reject anything below SLIPPAGE_MIN_BPS so the field keeps the previous value and shows the 0.1-20% hint. --- app/src/lib/launchpad/trade-slippage.test.ts | 3 ++- app/src/lib/launchpad/trade-slippage.ts | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/lib/launchpad/trade-slippage.test.ts b/app/src/lib/launchpad/trade-slippage.test.ts index a16803cd..81af1387 100644 --- a/app/src/lib/launchpad/trade-slippage.test.ts +++ b/app/src/lib/launchpad/trade-slippage.test.ts @@ -21,9 +21,10 @@ test("clampSlippageBps bounds garbage to the default and rounds", () => { 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", "-1", "abc", "1%%", "21", "100"]) assert.equal(parseSlippageInput(bad), null, `${JSON.stringify(bad)} rejected`); + for (const bad of ["", " ", "0", "0.01", "0.09", "-1", "abc", "1%%", "21", "100"]) assert.equal(parseSlippageInput(bad), null, `${JSON.stringify(bad)} rejected`); }); test("formatSlippageBps trims cleanly", () => { diff --git a/app/src/lib/launchpad/trade-slippage.ts b/app/src/lib/launchpad/trade-slippage.ts index 1818f3af..ba5310ab 100644 --- a/app/src/lib/launchpad/trade-slippage.ts +++ b/app/src/lib/launchpad/trade-slippage.ts @@ -25,13 +25,14 @@ export function clampSlippageBps(v: unknown): number { /** * Parse a percent string the user typed ("1", "0.5", "2.5%") into integer bps. - * Null when empty or unusable (caller keeps the previous value and shows a hint). + * 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 <= 0 || pct > SLIPPAGE_MAX_BPS / 100) return null; + if (!Number.isFinite(pct) || pct < SLIPPAGE_MIN_BPS / 100 || pct > SLIPPAGE_MAX_BPS / 100) return null; return clampSlippageBps(Math.round(pct * 100)); } From 94d8a5178797e4a836736fec1b91a58eadbaeef2 Mon Sep 17 00:00:00 2001 From: Ayush7614 Date: Sat, 12 Sep 2026 17:57:36 +0530 Subject: [PATCH 3/4] fix(trade): validate slippage raw input and report actual tolerance on errors (PR #30) - parseSlippageField rejects minus/letters, normalizes decimal comma to dot - input handler no longer strips chars before parsing (0,5 no longer becomes 5%) - trade captures slippageBps per transaction and passes it to friendlyError --- app/src/components/launchpad/TradePanel.tsx | 13 ++++++++----- app/src/lib/launchpad/trade-slippage.test.ts | 17 ++++++++++++++++- app/src/lib/launchpad/trade-slippage.ts | 17 +++++++++++++++++ 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/app/src/components/launchpad/TradePanel.tsx b/app/src/components/launchpad/TradePanel.tsx index 708e55d8..13a315aa 100644 --- a/app/src/components/launchpad/TradePanel.tsx +++ b/app/src/components/launchpad/TradePanel.tsx @@ -15,7 +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, parseSlippageInput, setSlippageBps, subscribeSlippage } from "@/lib/launchpad/trade-slippage"; +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"; @@ -114,12 +114,15 @@ export default function TradePanel({ chain, token, symbol, poolKey, quote, ethUs if (!address || amountIn === null || !quote_ || quote_.forKey !== quoteKey || busy || insufficient || transactionLock.current) return; // Lock before the first await, including wallet lookup and RPC preflight. transactionLock.current = true; + // Capture the tolerance for this transaction: minOut and the error text + // must agree even if the user picks another preset mid-flight. + const tradeSlippageBps = slippageBps; setPhase({ k: "preparing" }); 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, 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; @@ -172,7 +175,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, { slippagePct: tradeSlippageBps / 100 }) }); } finally { transactionLock.current = false; } @@ -237,9 +240,9 @@ export default function TradePanel({ chain, token, symbol, poolKey, quote, ethUs 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.replace(/[^0-9.%]/g, ""); + const raw = e.target.value; setSlippageInput(raw); - const parsed = parseSlippageInput(raw); + const parsed = parseSlippageField(raw); if (parsed === null) { setSlippageError(raw.trim() === "" ? null : "0.1–20%"); return; } setSlippageError(null); setSlippageBps(chain, parsed); diff --git a/app/src/lib/launchpad/trade-slippage.test.ts b/app/src/lib/launchpad/trade-slippage.test.ts index 81af1387..ebc6e649 100644 --- a/app/src/lib/launchpad/trade-slippage.test.ts +++ b/app/src/lib/launchpad/trade-slippage.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { clampSlippageBps, formatSlippageBps, getSlippageBps, getSlippageBpsServer, parseSlippageInput, SLIPPAGE_DEFAULT_BPS, SLIPPAGE_MAX_BPS, SLIPPAGE_MIN_BPS, SLIPPAGE_PRESETS_BPS, loadSlippageBps, setSlippageBps, slippageStorageKey, subscribeSlippage } from "./trade-slippage.ts"; +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)); @@ -27,6 +27,21 @@ test("parseSlippageInput takes percent strings, rejects junk", () => { 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%"); diff --git a/app/src/lib/launchpad/trade-slippage.ts b/app/src/lib/launchpad/trade-slippage.ts index ba5310ab..599084bc 100644 --- a/app/src/lib/launchpad/trade-slippage.ts +++ b/app/src/lib/launchpad/trade-slippage.ts @@ -36,6 +36,23 @@ export function parseSlippageInput(raw: string): number | 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; From bc5839303195f9b1d4477b6f6ca4b323a8c73aa2 Mon Sep 17 00:00:00 2001 From: Ayush7614 Date: Sat, 12 Sep 2026 18:19:16 +0530 Subject: [PATCH 4/4] fix(trade): keep safety-harness sync prefix dependency-free; slippage fallback on preflight fail (PR #30 CI) - declare tradeSlippageBps outside try, assign after preflight awaits - catch uses it when set, else default (preflight errors are never slippage reverts) --- app/src/components/launchpad/TradePanel.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/app/src/components/launchpad/TradePanel.tsx b/app/src/components/launchpad/TradePanel.tsx index 13a315aa..79f8c4d2 100644 --- a/app/src/components/launchpad/TradePanel.tsx +++ b/app/src/components/launchpad/TradePanel.tsx @@ -114,14 +114,19 @@ export default function TradePanel({ chain, token, symbol, poolKey, quote, ethUs if (!address || amountIn === null || !quote_ || quote_.forKey !== quoteKey || busy || insufficient || transactionLock.current) return; // Lock before the first await, including wallet lookup and RPC preflight. transactionLock.current = true; - // Capture the tolerance for this transaction: minOut and the error text - // must agree even if the user picks another preset mid-flight. - const tradeSlippageBps = slippageBps; 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 }); + // 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. @@ -175,7 +180,7 @@ export default function TradePanel({ chain, token, symbol, poolKey, quote, ethUs onTraded?.(); router.refresh(); } catch (err) { - setPhase({ k: "error", message: friendlyError(err, { slippagePct: tradeSlippageBps / 100 }) }); + setPhase({ k: "error", message: friendlyError(err, tradeSlippageBps !== null ? { slippagePct: tradeSlippageBps / 100 } : {}) }); } finally { transactionLock.current = false; }