Skip to content
Merged
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
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);
});
131 changes: 131 additions & 0 deletions app/src/lib/launchpad/trade-slippage.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>();

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();
}
Loading