From 6d40a8be5b5ff303ec1b8d1657551d9def40a61b Mon Sep 17 00:00:00 2001 From: Ayush7614 Date: Tue, 15 Sep 2026 17:44:24 +0530 Subject: [PATCH 1/4] fix(ui): reject scientific-notation amounts, guard tiny-negative USD and share-card numbers --- app/src/components/launchpad/LaunchForm.tsx | 5 +-- app/src/components/launchpad/TradePanel.tsx | 3 +- app/src/lib/launchpad/creator.test.ts | 2 +- app/src/lib/launchpad/decimal-input.test.ts | 28 +++++++++++++++++ app/src/lib/launchpad/decimal-input.ts | 28 +++++++++++++++++ app/src/lib/launchpad/market-format.test.ts | 4 +++ app/src/lib/launchpad/market-format.ts | 2 +- app/src/lib/launchpad/ogcard.test.ts | 35 +++++++++++++++++++++ app/src/lib/launchpad/ogcard.ts | 8 +++-- 9 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 app/src/lib/launchpad/decimal-input.test.ts create mode 100644 app/src/lib/launchpad/decimal-input.ts create mode 100644 app/src/lib/launchpad/ogcard.test.ts diff --git a/app/src/components/launchpad/LaunchForm.tsx b/app/src/components/launchpad/LaunchForm.tsx index 975cb1ee..711137f3 100644 --- a/app/src/components/launchpad/LaunchForm.tsx +++ b/app/src/components/launchpad/LaunchForm.tsx @@ -17,6 +17,7 @@ import { DEAD, DEFAULT_SUPPLY, FEE_PRESETS, GAS_RESERVE_WEI, MAX_RECIPIENTS, STO import { bpsToPct, buildRecipients, describeShares, emptyRow, isBurnAddress, type Recipient, type RecipientRow } from "@/lib/launchpad/recipients"; import { capChipLabel, capDisplay, capEntry, capPick, capPresets, capToQuote } from "@/lib/launchpad/market-cap"; import { uppercaseInPlace } from "@/lib/launchpad/symbol-input"; +import { sanitizeDecimalInput } from "@/lib/launchpad/decimal-input"; import { fdvForStartTick, fmtCompact, fmtQuoteUnits, fmtUsd, initialBuyPreview, minOut, startTickForFdv, tickToTokensPerQuote, units } from "@/lib/launchpad/math"; import { BUY_PRESETS, defaultFirstBuy, gasReserveInQuote, suggestFirstBuy } from "@/lib/launchpad/first-buy"; import { getFirstBuyDeclined, getFirstBuyDeclinedServer, setFirstBuyDeclined, subscribeFirstBuyDeclined } from "@/lib/launchpad/first-buy-session"; @@ -682,7 +683,7 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = D setCustomMcap(e.target.value.replace(/[^0-9.]/g, ""))} + onChange={(e) => setCustomMcap(sanitizeDecimalInput(e.target.value))} placeholder="custom" inputMode="decimal" aria-label={`custom starting market cap in ${entry.unit === "usd" ? "USD" : quote.symbol}`} @@ -777,7 +778,7 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = D { const v = e.target.value.replace(/[^0-9.]/g, ""); if (v) chooseFirstBuy(v); else declineFirstBuy(); }} + onChange={(e) => { const v = sanitizeDecimalInput(e.target.value); if (v) chooseFirstBuy(v); else declineFirstBuy(); }} placeholder="none" inputMode="decimal" aria-label={`first buy amount in ${quote.symbol}`} diff --git a/app/src/components/launchpad/TradePanel.tsx b/app/src/components/launchpad/TradePanel.tsx index 1d47602b..fa25dee9 100644 --- a/app/src/components/launchpad/TradePanel.tsx +++ b/app/src/components/launchpad/TradePanel.tsx @@ -13,6 +13,7 @@ import { ERC20_MIN_ABI, PERMIT2_ABI, UNIVERSAL_ROUTER_ABI, V4_QUOTER_ABI } from import { BUY_PRESETS, NATIVE, SWAP_GAS_RESERVE_WEI, launchpad, quoteUsdOf, sharesGasBalance, type Quote } from "@/lib/launchpad/config"; import { gasReserveInQuote } from "@/lib/launchpad/first-buy"; import { fmtCompact, fmtQuoteUnits, fmtUsd, minOut, units, pipsToPct } from "@/lib/launchpad/math"; +import { sanitizeDecimalInput } from "@/lib/launchpad/decimal-input"; 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"; @@ -209,7 +210,7 @@ export default function TradePanel({ chain, token, symbol, poolKey, quote, ethUs {balance !== undefined ? : }
- setAmount(e.target.value.replace(/[^0-9.]/g, ""))} placeholder="0.0" inputMode="decimal" autoComplete="off" aria-label={side === "buy" ? `${quote.symbol} amount` : `${symbol} amount`} /> + setAmount(sanitizeDecimalInput(e.target.value))} placeholder="0.0" inputMode="decimal" autoComplete="off" aria-label={side === "buy" ? `${quote.symbol} amount` : `${symbol} amount`} /> {side === "buy" ? quote.symbol : symbol}
diff --git a/app/src/lib/launchpad/creator.test.ts b/app/src/lib/launchpad/creator.test.ts index e1f22de8..fb0f182e 100644 --- a/app/src/lib/launchpad/creator.test.ts +++ b/app/src/lib/launchpad/creator.test.ts @@ -101,7 +101,7 @@ test("share card shaping", () => { assert.equal(feeLabel(0, []), "0% fee"); assert.equal(feeLabel(30000, [{ payout: "0x1", bps: 10000 }]), "3% fee → beneficiary"); assert.equal(feeLabel(10000, [{ payout: "0x1", bps: 6000 }, { payout: "0x2", bps: 4000 }]), "1% fee → beneficiaries"); - assert.equal(ageLabel("2026-09-06T11:59:30Z", now), "1m old"); + assert.equal(ageLabel("2026-09-06T11:59:30Z", now), "30s old"); assert.equal(shapeCard({ name: "X", symbol: "X", chain: "base", fdv_usd: null, fdv_quote: 2.5, quote_key: "eth", quote_symbol: "ETH", change_from_launch: -0.5, lp_fee: 0, recipients: [], block_time: "2026-09-01T00:00:00Z" }, now).mcap, "2.50 ETH"); }); diff --git a/app/src/lib/launchpad/decimal-input.test.ts b/app/src/lib/launchpad/decimal-input.test.ts new file mode 100644 index 00000000..e7d1e517 --- /dev/null +++ b/app/src/lib/launchpad/decimal-input.test.ts @@ -0,0 +1,28 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { sanitizeDecimalInput } from "./decimal-input.ts"; + +test("plain decimals pass through", () => { + assert.equal(sanitizeDecimalInput(""), ""); + assert.equal(sanitizeDecimalInput("0"), "0"); + assert.equal(sanitizeDecimalInput("1.5"), "1.5"); + assert.equal(sanitizeDecimalInput(".5"), ".5"); + assert.equal(sanitizeDecimalInput("007"), "007"); +}); + +test("scientific notation is rejected, never corrupted into a tradable size", () => { + assert.equal(sanitizeDecimalInput("1e-7"), "", "must not become 17 (~1e8x the intent)"); + assert.equal(sanitizeDecimalInput("1E21"), "", "must not become 121"); + assert.equal(sanitizeDecimalInput("2.5e3"), ""); + assert.equal(sanitizeDecimalInput("e"), ""); +}); + +test("grouping, currency and whitespace are stripped; only the first dot survives", () => { + assert.equal(sanitizeDecimalInput("1,234.5"), "1234.5"); + assert.equal(sanitizeDecimalInput(" 3.5 "), "3.5"); + assert.equal(sanitizeDecimalInput("$12.25"), "12.25"); + assert.equal(sanitizeDecimalInput("1..2"), "1.2"); + assert.equal(sanitizeDecimalInput("1.2.3"), "1.23", "extra dots are dropped, digits kept"); + assert.equal(sanitizeDecimalInput("abc1.5"), "1.5"); + assert.equal(sanitizeDecimalInput("12 USD"), "12"); +}); diff --git a/app/src/lib/launchpad/decimal-input.ts b/app/src/lib/launchpad/decimal-input.ts new file mode 100644 index 00000000..cd13df1b --- /dev/null +++ b/app/src/lib/launchpad/decimal-input.ts @@ -0,0 +1,28 @@ +/** + * Decimal amount field sanitizer (pure; unit-tested). + * + * Trade and launch amount inputs previously used + * `value.replace(/[^0-9.]/g, "")`, which silently corrupts pasted values: + * "1e-7" becomes "17" (the exponent letters are stripped and the digits + * join), so parseUnits succeeds on a value ~1e8x the intended size with no + * error shown. "1..2" collapses only downstream when parseUnits throws. + * + * This helper rejects scientific notation outright (returns "") instead of + * corrupting it, strips grouping/currency/whitespace characters, and keeps at + * most one decimal point. Callers stay controlled inputs; an empty result + * disables the submit path (amount parses to null) instead of trading a + * wrong size. + */ +export function sanitizeDecimalInput(raw: string): string { + if (/[eE]/.test(raw)) return ""; + let out = ""; + let dot = false; + for (const ch of raw) { + if (ch >= "0" && ch <= "9") out += ch; + else if (ch === "." && !dot) { + dot = true; + out += ch; + } + } + return out; +} diff --git a/app/src/lib/launchpad/market-format.test.ts b/app/src/lib/launchpad/market-format.test.ts index b551baad..70ac87ea 100644 --- a/app/src/lib/launchpad/market-format.test.ts +++ b/app/src/lib/launchpad/market-format.test.ts @@ -8,6 +8,10 @@ test("ledger money fits its column without rounding dust to zero", () => { assert.equal(marketUsd(0.1486467461), "$0.15"); assert.equal(marketUsd(25_123), "$25.1K"); assert.equal(marketUsd(Number.NaN), "—"); + assert.equal(marketUsd(-0.0049), ">-$0.01", "tiny negatives keep their sign instead of formatting as -$0"); + assert.equal(marketUsd(-0.15), "-$0.15"); + assert.equal(marketUsd(Infinity), "—"); + assert.equal(marketUsd(-Infinity), "—"); }); test("rounded zero changes are neutral; extreme values stay bounded", () => { diff --git a/app/src/lib/launchpad/market-format.ts b/app/src/lib/launchpad/market-format.ts index f995b628..06c5af53 100644 --- a/app/src/lib/launchpad/market-format.ts +++ b/app/src/lib/launchpad/market-format.ts @@ -2,7 +2,7 @@ export function marketUsd(value: number): string { if (!Number.isFinite(value)) return "—"; if (value === 0) return "$0"; - if (value > 0 && value < 0.01) return "<$0.01"; + if (value !== 0 && Math.abs(value) < 0.01) return value > 0 ? "<$0.01" : ">-$0.01"; return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", notation: Math.abs(value) >= 1_000 ? "compact" : "standard", maximumFractionDigits: Math.abs(value) >= 1_000 ? 1 : 2, minimumFractionDigits: 0 }).format(value); } diff --git a/app/src/lib/launchpad/ogcard.test.ts b/app/src/lib/launchpad/ogcard.test.ts new file mode 100644 index 00000000..48f40993 --- /dev/null +++ b/app/src/lib/launchpad/ogcard.test.ts @@ -0,0 +1,35 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { ageLabel, shapeCard, type CardInput } from "./ogcard.ts"; + +const input = (over: Partial = {}): CardInput => ({ + name: "Test", + symbol: "TEST", + chain: "base", + fdv_usd: 12345, + fdv_quote: 12345, + quote_key: "eth", + quote_symbol: "ETH", + change_from_launch: 0, + lp_fee: 0, + recipients: [], + block_time: "2026-09-06T09:30:00Z", + ...over, +}); + +test("ageLabel counts seconds under a minute and rejects garbage dates", () => { + const now = Date.parse("2026-09-06T12:00:00Z"); + assert.equal(ageLabel("2026-09-06T11:59:55Z", now), "5s old"); + assert.equal(ageLabel("2026-09-06T11:59:30Z", now), "30s old"); + assert.equal(ageLabel("2026-09-06T11:00:00Z", now), "1h old"); + assert.equal(ageLabel("2026-09-06T09:30:00Z", now), "2h old"); + assert.equal(ageLabel("garbage", now), "—", "invalid dates never render NaN"); + assert.equal(ageLabel("2026-09-06T09:30:00Z", Number.NaN), "—"); +}); + +test("shapeCard never renders NaN/Infinity market caps", () => { + const now = Date.parse("2026-09-06T12:00:00Z"); + assert.equal(shapeCard(input({ fdv_usd: Number.NaN }), now).mcap, "$—"); + assert.equal(shapeCard(input({ fdv_usd: Infinity }), now).mcap, "$—"); + assert.equal(shapeCard(input({ fdv_usd: null, fdv_quote: Number.NaN, quote_symbol: "ETH" }), now).mcap, "— ETH"); +}); diff --git a/app/src/lib/launchpad/ogcard.ts b/app/src/lib/launchpad/ogcard.ts index a83fd57f..db6b2f87 100644 --- a/app/src/lib/launchpad/ogcard.ts +++ b/app/src/lib/launchpad/ogcard.ts @@ -7,6 +7,7 @@ export type Card = { title: string; symbol: string; chainLabel: string; mcap: st const DEAD = "0x000000000000000000000000000000000000dead"; function compact(n: number): string { + if (!Number.isFinite(n)) return "—"; const a = Math.abs(n); if (a >= 1e9) return `${(n / 1e9).toFixed(2)}B`; if (a >= 1e6) return `${(n / 1e6).toFixed(2)}M`; @@ -22,8 +23,11 @@ export function feeLabel(lpFee: number, recipients: { payout: string; bps: numbe } export function ageLabel(iso: string, now: number): string { - const s = Math.max(0, Math.floor((now - new Date(iso).getTime()) / 1000)); - if (s < 3600) return `${Math.max(1, Math.floor(s / 60))}m old`; + const t = new Date(iso).getTime(); + if (!Number.isFinite(t) || !Number.isFinite(now)) return "—"; + const s = Math.max(0, Math.floor((now - t) / 1000)); + if (s < 60) return `${s}s old`; + if (s < 3600) return `${Math.floor(s / 60)}m old`; if (s < 86400) return `${Math.floor(s / 3600)}h old`; return `${Math.floor(s / 86400)}d old`; } From fbacdc0044e202e43203487f5533867b8322e88f Mon Sep 17 00:00:00 2001 From: Ayush7614 Date: Tue, 15 Sep 2026 17:58:44 +0530 Subject: [PATCH 2/4] fix(ui): clear mcap preset on rejected input, neutral share-card change (CodeRabbit PR47) --- app/src/app/t/[chain]/[token]/opengraph-image.tsx | 2 +- app/src/components/launchpad/LaunchForm.tsx | 8 ++++++-- app/src/components/launchpad/launch-form.test.ts | 8 ++++++++ app/src/lib/launchpad/decimal-input.test.ts | 9 ++++++++- app/src/lib/launchpad/decimal-input.ts | 15 +++++++++++++++ app/src/lib/launchpad/ogcard.test.ts | 15 +++++++++++++++ app/src/lib/launchpad/ogcard.ts | 7 ++++--- 7 files changed, 57 insertions(+), 7 deletions(-) diff --git a/app/src/app/t/[chain]/[token]/opengraph-image.tsx b/app/src/app/t/[chain]/[token]/opengraph-image.tsx index 8e30139b..ebac4409 100644 --- a/app/src/app/t/[chain]/[token]/opengraph-image.tsx +++ b/app/src/app/t/[chain]/[token]/opengraph-image.tsx @@ -112,7 +112,7 @@ export default async function TokenOg({ params }: { params: Promise<{ chain: str
{card.mcap} - {card.change} + {card.change} market cap · since launch
diff --git a/app/src/components/launchpad/LaunchForm.tsx b/app/src/components/launchpad/LaunchForm.tsx index 711137f3..b5c05fad 100644 --- a/app/src/components/launchpad/LaunchForm.tsx +++ b/app/src/components/launchpad/LaunchForm.tsx @@ -17,7 +17,7 @@ import { DEAD, DEFAULT_SUPPLY, FEE_PRESETS, GAS_RESERVE_WEI, MAX_RECIPIENTS, STO import { bpsToPct, buildRecipients, describeShares, emptyRow, isBurnAddress, type Recipient, type RecipientRow } from "@/lib/launchpad/recipients"; import { capChipLabel, capDisplay, capEntry, capPick, capPresets, capToQuote } from "@/lib/launchpad/market-cap"; import { uppercaseInPlace } from "@/lib/launchpad/symbol-input"; -import { sanitizeDecimalInput } from "@/lib/launchpad/decimal-input"; +import { sanitizeDecimalInput, resolveCustomMcapInput } from "@/lib/launchpad/decimal-input"; import { fdvForStartTick, fmtCompact, fmtQuoteUnits, fmtUsd, initialBuyPreview, minOut, startTickForFdv, tickToTokensPerQuote, units } from "@/lib/launchpad/math"; import { BUY_PRESETS, defaultFirstBuy, gasReserveInQuote, suggestFirstBuy } from "@/lib/launchpad/first-buy"; import { getFirstBuyDeclined, getFirstBuyDeclinedServer, setFirstBuyDeclined, subscribeFirstBuyDeclined } from "@/lib/launchpad/first-buy-session"; @@ -683,7 +683,11 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = D setCustomMcap(sanitizeDecimalInput(e.target.value))} + onChange={(e) => { + const next = resolveCustomMcapInput(e.target.value); + if (next.clearPick) setMcapPick(null); + setCustomMcap(next.value); + }} placeholder="custom" inputMode="decimal" aria-label={`custom starting market cap in ${entry.unit === "usd" ? "USD" : quote.symbol}`} diff --git a/app/src/components/launchpad/launch-form.test.ts b/app/src/components/launchpad/launch-form.test.ts index d23bc5d4..a6a2a77f 100644 --- a/app/src/components/launchpad/launch-form.test.ts +++ b/app/src/components/launchpad/launch-form.test.ts @@ -153,3 +153,11 @@ test("beneficiary split: recipients come from the tested helper, and the summary assert.match(source, /describeShares\(recipients, shortAddr\)/); assert.match(source, /split \$\{recipients\.length \|\| rows\.length\} ways/); }); + +test("custom market-cap entry clears the preset pick when sanitization rejects the value", () => { + // With a preset active, typing 1e-7 sanitizes to "" — the preset must clear, + // otherwise mcapEntered falls back to the old preset cap while the field shows empty. + assert.match(source, /resolveCustomMcapInput\(e\.target\.value\)/); + assert.match(source, /if \(next\.clearPick\) setMcapPick\(null\)/); + assert.match(source, /setCustomMcap\(next\.value\)/); +}); diff --git a/app/src/lib/launchpad/decimal-input.test.ts b/app/src/lib/launchpad/decimal-input.test.ts index e7d1e517..2d4818be 100644 --- a/app/src/lib/launchpad/decimal-input.test.ts +++ b/app/src/lib/launchpad/decimal-input.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { sanitizeDecimalInput } from "./decimal-input.ts"; +import { resolveCustomMcapInput, sanitizeDecimalInput } from "./decimal-input.ts"; test("plain decimals pass through", () => { assert.equal(sanitizeDecimalInput(""), ""); @@ -26,3 +26,10 @@ test("grouping, currency and whitespace are stripped; only the first dot survive assert.equal(sanitizeDecimalInput("abc1.5"), "1.5"); assert.equal(sanitizeDecimalInput("12 USD"), "12"); }); + +test("resolveCustomMcapInput clears the preset pick when the entry sanitizes to empty", () => { + assert.deepEqual(resolveCustomMcapInput("1e-7"), { value: "", clearPick: true }, "rejected entry must not fall back to the old preset cap"); + assert.deepEqual(resolveCustomMcapInput("25000"), { value: "25000", clearPick: false }); + assert.deepEqual(resolveCustomMcapInput(""), { value: "", clearPick: false }, "clearing the field is not a rejection"); + assert.deepEqual(resolveCustomMcapInput(" "), { value: "", clearPick: false }); +}); diff --git a/app/src/lib/launchpad/decimal-input.ts b/app/src/lib/launchpad/decimal-input.ts index cd13df1b..3bcb5a9e 100644 --- a/app/src/lib/launchpad/decimal-input.ts +++ b/app/src/lib/launchpad/decimal-input.ts @@ -26,3 +26,18 @@ export function sanitizeDecimalInput(raw: string): string { } return out; } + +/** + * Custom market-cap field state transition (pure; unit-tested). + * + * The launch form falls back to the selected preset whenever the custom field + * is empty (`customMcap.trim() ? Number(customMcap) : pickedPreset`). Without + * this, typing a value that sanitizes to empty (e.g. "1e-7" with a preset + * active) leaves the old preset selected: the field shows empty while the + * launch proceeds at the preset cap. Returns the sanitized value plus whether + * the caller must clear the preset pick. + */ +export function resolveCustomMcapInput(raw: string): { value: string; clearPick: boolean } { + const value = sanitizeDecimalInput(raw); + return { value, clearPick: raw.trim() !== "" && value === "" }; +} diff --git a/app/src/lib/launchpad/ogcard.test.ts b/app/src/lib/launchpad/ogcard.test.ts index 48f40993..0afcbe76 100644 --- a/app/src/lib/launchpad/ogcard.test.ts +++ b/app/src/lib/launchpad/ogcard.test.ts @@ -33,3 +33,18 @@ test("shapeCard never renders NaN/Infinity market caps", () => { assert.equal(shapeCard(input({ fdv_usd: Infinity }), now).mcap, "$—"); assert.equal(shapeCard(input({ fdv_usd: null, fdv_quote: Number.NaN, quote_symbol: "ETH" }), now).mcap, "— ETH"); }); + +test("shapeCard renders a neutral dash for non-finite change, not NaN%/+—%", () => { + const now = Date.parse("2026-09-06T12:00:00Z"); + assert.deepEqual( + { change: shapeCard(input({ change_from_launch: Number.NaN }), now).change, up: shapeCard(input({ change_from_launch: Number.NaN }), now).up }, + { change: "—", up: null }, + ); + assert.deepEqual( + { change: shapeCard(input({ change_from_launch: Infinity }), now).change, up: shapeCard(input({ change_from_launch: Infinity }), now).up }, + { change: "—", up: null }, + ); + const finite = shapeCard(input({ change_from_launch: 0.234 }), now); + assert.equal(finite.change, "+23%"); + assert.equal(finite.up, true); +}); diff --git a/app/src/lib/launchpad/ogcard.ts b/app/src/lib/launchpad/ogcard.ts index db6b2f87..1150fc80 100644 --- a/app/src/lib/launchpad/ogcard.ts +++ b/app/src/lib/launchpad/ogcard.ts @@ -2,7 +2,7 @@ import { CHAIN_LABELS, type ChainKey } from "../chainKeys.ts"; export type CardInput = { name: string; symbol: string; chain: ChainKey; fdv_usd: number | null; fdv_quote: number; quote_key: string; quote_symbol: string; change_from_launch: number; lp_fee: number; recipients: { payout: string; bps: number }[]; block_time: string }; -export type Card = { title: string; symbol: string; chainLabel: string; mcap: string; change: string; up: boolean; fee: string; age: string; quote: { symbol: string; ticker: string; kind: "stock" | "gitlawb" } | null }; +export type Card = { title: string; symbol: string; chainLabel: string; mcap: string; change: string; up: boolean | null; fee: string; age: string; quote: { symbol: string; ticker: string; kind: "stock" | "gitlawb" } | null }; const DEAD = "0x000000000000000000000000000000000000dead"; @@ -34,13 +34,14 @@ export function ageLabel(iso: string, now: number): string { export function shapeCard(l: CardInput, now: number): Card { const pct = l.change_from_launch * 100; + const finitePct = Number.isFinite(pct); return { title: l.name.slice(0, 28), symbol: l.symbol.slice(0, 12), chainLabel: CHAIN_LABELS[l.chain], mcap: l.fdv_usd !== null ? `$${compact(l.fdv_usd)}` : `${compact(l.fdv_quote)} ${l.quote_symbol}`, - change: `${pct >= 0 ? "+" : ""}${Math.abs(pct) >= 1000 ? compact(pct) : pct.toFixed(Math.abs(pct) >= 10 ? 0 : 1)}%`, - up: pct >= 0, + change: finitePct ? `${pct >= 0 ? "+" : ""}${Math.abs(pct) >= 1000 ? compact(pct) : pct.toFixed(Math.abs(pct) >= 10 ? 0 : 1)}%` : "—", + up: finitePct ? pct >= 0 : null, fee: feeLabel(l.lp_fee, l.recipients), age: ageLabel(l.block_time, now), quote: quotePillOf(l.quote_key, l.quote_symbol), From d541c8be9156dfb84deb7cacf771baeed070b558 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Fri, 25 Sep 2026 10:04:46 +0800 Subject: [PATCH 3/4] fix(ui): keep "0.5 ETH" pastes; drop the unreachable marketUsd change - sanitizeDecimalInput rejected any value containing e/E, so pasting "0.5 ETH" cleared the field. Only an exponent (e right after a digit or dot: 1e-7, 2.5E3, 1.e5) is rejected now; a unit after a space is not. - marketUsd only formats volume and market caps, which are never negative, so the tiny-negative branch could not be reached; restored to main. --- app/src/lib/launchpad/decimal-input.test.ts | 3 +++ app/src/lib/launchpad/decimal-input.ts | 5 +++-- app/src/lib/launchpad/market-format.test.ts | 4 ---- app/src/lib/launchpad/market-format.ts | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/src/lib/launchpad/decimal-input.test.ts b/app/src/lib/launchpad/decimal-input.test.ts index 2d4818be..65a57299 100644 --- a/app/src/lib/launchpad/decimal-input.test.ts +++ b/app/src/lib/launchpad/decimal-input.test.ts @@ -25,6 +25,9 @@ test("grouping, currency and whitespace are stripped; only the first dot survive assert.equal(sanitizeDecimalInput("1.2.3"), "1.23", "extra dots are dropped, digits kept"); assert.equal(sanitizeDecimalInput("abc1.5"), "1.5"); assert.equal(sanitizeDecimalInput("12 USD"), "12"); + // "ETH" carries an E: a unit after a space is not an exponent, so the amount survives + assert.equal(sanitizeDecimalInput("0.5 ETH"), "0.5"); + assert.equal(sanitizeDecimalInput("1.e5"), "", "an exponent right after the dot is still rejected"); }); test("resolveCustomMcapInput clears the preset pick when the entry sanitizes to empty", () => { diff --git a/app/src/lib/launchpad/decimal-input.ts b/app/src/lib/launchpad/decimal-input.ts index 3bcb5a9e..30dc5449 100644 --- a/app/src/lib/launchpad/decimal-input.ts +++ b/app/src/lib/launchpad/decimal-input.ts @@ -9,12 +9,13 @@ * * This helper rejects scientific notation outright (returns "") instead of * corrupting it, strips grouping/currency/whitespace characters, and keeps at - * most one decimal point. Callers stay controlled inputs; an empty result + * most one decimal point. An exponent is an "e" right after a digit or dot + * ("1e-7", "2.5E3", "1.e5"); a unit after a space ("0.5 ETH") is not one. Callers stay controlled inputs; an empty result * disables the submit path (amount parses to null) instead of trading a * wrong size. */ export function sanitizeDecimalInput(raw: string): string { - if (/[eE]/.test(raw)) return ""; + if (/[\d.][eE]/.test(raw)) return ""; let out = ""; let dot = false; for (const ch of raw) { diff --git a/app/src/lib/launchpad/market-format.test.ts b/app/src/lib/launchpad/market-format.test.ts index 70ac87ea..b551baad 100644 --- a/app/src/lib/launchpad/market-format.test.ts +++ b/app/src/lib/launchpad/market-format.test.ts @@ -8,10 +8,6 @@ test("ledger money fits its column without rounding dust to zero", () => { assert.equal(marketUsd(0.1486467461), "$0.15"); assert.equal(marketUsd(25_123), "$25.1K"); assert.equal(marketUsd(Number.NaN), "—"); - assert.equal(marketUsd(-0.0049), ">-$0.01", "tiny negatives keep their sign instead of formatting as -$0"); - assert.equal(marketUsd(-0.15), "-$0.15"); - assert.equal(marketUsd(Infinity), "—"); - assert.equal(marketUsd(-Infinity), "—"); }); test("rounded zero changes are neutral; extreme values stay bounded", () => { diff --git a/app/src/lib/launchpad/market-format.ts b/app/src/lib/launchpad/market-format.ts index 06c5af53..f995b628 100644 --- a/app/src/lib/launchpad/market-format.ts +++ b/app/src/lib/launchpad/market-format.ts @@ -2,7 +2,7 @@ export function marketUsd(value: number): string { if (!Number.isFinite(value)) return "—"; if (value === 0) return "$0"; - if (value !== 0 && Math.abs(value) < 0.01) return value > 0 ? "<$0.01" : ">-$0.01"; + if (value > 0 && value < 0.01) return "<$0.01"; return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", notation: Math.abs(value) >= 1_000 ? "compact" : "standard", maximumFractionDigits: Math.abs(value) >= 1_000 ? 1 : 2, minimumFractionDigits: 0 }).format(value); } From 25bf84a3c384ecbaac3fdad1fe82876a62950373 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Fri, 25 Sep 2026 10:23:02 +0800 Subject: [PATCH 4/4] fix(ui): spaced exponents, rejected first buys, unknown caps (CodeRabbit PR47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sanitizeDecimalInput looks for the exponent with spaces and grouping removed, and treats an e after a digit as one unless it starts a word: "1 e-7" and "1e" are rejected, "0.5 ETH" and "1.5eth" still parse. - first buy: a rejected entry is ignored (the field keeps its amount, and the launch buys what it shows) instead of declining the first buy. Only clearing the field declines. resolveFirstBuyInput carries the rule. - share card: a non-finite market cap renders as one dash, not "$—" or "— ETH". --- app/src/components/launchpad/LaunchForm.tsx | 4 ++-- .../components/launchpad/launch-form.test.ts | 5 +++++ app/src/lib/launchpad/decimal-input.test.ts | 15 ++++++++++++++- app/src/lib/launchpad/decimal-input.ts | 19 ++++++++++++++++--- app/src/lib/launchpad/ogcard.test.ts | 8 +++++--- app/src/lib/launchpad/ogcard.ts | 3 ++- 6 files changed, 44 insertions(+), 10 deletions(-) diff --git a/app/src/components/launchpad/LaunchForm.tsx b/app/src/components/launchpad/LaunchForm.tsx index b5c05fad..59993c7e 100644 --- a/app/src/components/launchpad/LaunchForm.tsx +++ b/app/src/components/launchpad/LaunchForm.tsx @@ -17,7 +17,7 @@ import { DEAD, DEFAULT_SUPPLY, FEE_PRESETS, GAS_RESERVE_WEI, MAX_RECIPIENTS, STO import { bpsToPct, buildRecipients, describeShares, emptyRow, isBurnAddress, type Recipient, type RecipientRow } from "@/lib/launchpad/recipients"; import { capChipLabel, capDisplay, capEntry, capPick, capPresets, capToQuote } from "@/lib/launchpad/market-cap"; import { uppercaseInPlace } from "@/lib/launchpad/symbol-input"; -import { sanitizeDecimalInput, resolveCustomMcapInput } from "@/lib/launchpad/decimal-input"; +import { resolveCustomMcapInput, resolveFirstBuyInput } from "@/lib/launchpad/decimal-input"; import { fdvForStartTick, fmtCompact, fmtQuoteUnits, fmtUsd, initialBuyPreview, minOut, startTickForFdv, tickToTokensPerQuote, units } from "@/lib/launchpad/math"; import { BUY_PRESETS, defaultFirstBuy, gasReserveInQuote, suggestFirstBuy } from "@/lib/launchpad/first-buy"; import { getFirstBuyDeclined, getFirstBuyDeclinedServer, setFirstBuyDeclined, subscribeFirstBuyDeclined } from "@/lib/launchpad/first-buy-session"; @@ -782,7 +782,7 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = D { const v = sanitizeDecimalInput(e.target.value); if (v) chooseFirstBuy(v); else declineFirstBuy(); }} + onChange={(e) => { const next = resolveFirstBuyInput(e.target.value); if (next.kind === "choose") chooseFirstBuy(next.value); else if (next.kind === "decline") declineFirstBuy(); }} placeholder="none" inputMode="decimal" aria-label={`first buy amount in ${quote.symbol}`} diff --git a/app/src/components/launchpad/launch-form.test.ts b/app/src/components/launchpad/launch-form.test.ts index a6a2a77f..51d22d46 100644 --- a/app/src/components/launchpad/launch-form.test.ts +++ b/app/src/components/launchpad/launch-form.test.ts @@ -161,3 +161,8 @@ test("custom market-cap entry clears the preset pick when sanitization rejects t assert.match(source, /if \(next\.clearPick\) setMcapPick\(null\)/); assert.match(source, /setCustomMcap\(next\.value\)/); }); + +test("a rejected first-buy entry is ignored, never read as declining the first buy", () => { + assert.match(source, /const next = resolveFirstBuyInput\(e\.target\.value\); if \(next\.kind === "choose"\) chooseFirstBuy\(next\.value\); else if \(next\.kind === "decline"\) declineFirstBuy\(\);/); + assert.doesNotMatch(source, /sanitizeDecimalInput\(e\.target\.value\); if \(v\) chooseFirstBuy\(v\); else declineFirstBuy\(\)/); +}); diff --git a/app/src/lib/launchpad/decimal-input.test.ts b/app/src/lib/launchpad/decimal-input.test.ts index 65a57299..00bd4b57 100644 --- a/app/src/lib/launchpad/decimal-input.test.ts +++ b/app/src/lib/launchpad/decimal-input.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { resolveCustomMcapInput, sanitizeDecimalInput } from "./decimal-input.ts"; +import { resolveCustomMcapInput, resolveFirstBuyInput, sanitizeDecimalInput } from "./decimal-input.ts"; test("plain decimals pass through", () => { assert.equal(sanitizeDecimalInput(""), ""); @@ -15,6 +15,9 @@ test("scientific notation is rejected, never corrupted into a tradable size", () assert.equal(sanitizeDecimalInput("1E21"), "", "must not become 121"); assert.equal(sanitizeDecimalInput("2.5e3"), ""); assert.equal(sanitizeDecimalInput("e"), ""); + assert.equal(sanitizeDecimalInput("1 e-7"), "", "a space before the exponent must not make it 17"); + assert.equal(sanitizeDecimalInput("1,000e3"), "", "grouping before the exponent too"); + assert.equal(sanitizeDecimalInput("1e"), "", "an exponent being typed is rejected, not silently dropped"); }); test("grouping, currency and whitespace are stripped; only the first dot survives", () => { @@ -27,6 +30,7 @@ test("grouping, currency and whitespace are stripped; only the first dot survive assert.equal(sanitizeDecimalInput("12 USD"), "12"); // "ETH" carries an E: a unit after a space is not an exponent, so the amount survives assert.equal(sanitizeDecimalInput("0.5 ETH"), "0.5"); + assert.equal(sanitizeDecimalInput("1.5eth"), "1.5", "a unit word right after the number is not an exponent"); assert.equal(sanitizeDecimalInput("1.e5"), "", "an exponent right after the dot is still rejected"); }); @@ -36,3 +40,12 @@ test("resolveCustomMcapInput clears the preset pick when the entry sanitizes to assert.deepEqual(resolveCustomMcapInput(""), { value: "", clearPick: false }, "clearing the field is not a rejection"); assert.deepEqual(resolveCustomMcapInput(" "), { value: "", clearPick: false }); }); + +test("resolveFirstBuyInput: a rejected entry keeps the shown amount; only clearing declines", () => { + assert.deepEqual(resolveFirstBuyInput("0.05"), { kind: "choose", value: "0.05" }); + assert.deepEqual(resolveFirstBuyInput("0.5 ETH"), { kind: "choose", value: "0.5" }); + assert.deepEqual(resolveFirstBuyInput("1e-7"), { kind: "ignore" }, "never launch without the buy because a paste was rejected"); + assert.deepEqual(resolveFirstBuyInput("1 e-7"), { kind: "ignore" }); + assert.deepEqual(resolveFirstBuyInput(""), { kind: "decline" }); + assert.deepEqual(resolveFirstBuyInput(" "), { kind: "decline" }); +}); diff --git a/app/src/lib/launchpad/decimal-input.ts b/app/src/lib/launchpad/decimal-input.ts index 30dc5449..f0481cdd 100644 --- a/app/src/lib/launchpad/decimal-input.ts +++ b/app/src/lib/launchpad/decimal-input.ts @@ -9,13 +9,14 @@ * * This helper rejects scientific notation outright (returns "") instead of * corrupting it, strips grouping/currency/whitespace characters, and keeps at - * most one decimal point. An exponent is an "e" right after a digit or dot - * ("1e-7", "2.5E3", "1.e5"); a unit after a space ("0.5 ETH") is not one. Callers stay controlled inputs; an empty result + * most one decimal point. An exponent is an "e" after a digit or dot that + * does not start a word, spaces and grouping ignored ("1e-7", "2.5E3", + * "1.e5", "1 e-7", "1e"); a unit ("0.5 ETH", "1.5eth") is not one. Callers stay controlled inputs; an empty result * disables the submit path (amount parses to null) instead of trading a * wrong size. */ export function sanitizeDecimalInput(raw: string): string { - if (/[\d.][eE]/.test(raw)) return ""; + if (/[\d.][eE](?![a-zA-Z])/.test(raw.replace(/[\s,_]/g, ""))) return ""; let out = ""; let dot = false; for (const ch of raw) { @@ -42,3 +43,15 @@ export function resolveCustomMcapInput(raw: string): { value: string; clearPick: const value = sanitizeDecimalInput(raw); return { value, clearPick: raw.trim() !== "" && value === "" }; } + +/** + * First-buy field state transition (pure; unit-tested). A rejected entry + * (e.g. "1e-7") is ignored, so the field keeps the amount it showed and the + * launch buys exactly that; it must never read as "no first buy", which would + * launch with no buy at all. Only clearing the field declines. + */ +export function resolveFirstBuyInput(raw: string): { kind: "choose"; value: string } | { kind: "decline" } | { kind: "ignore" } { + const value = sanitizeDecimalInput(raw); + if (value) return { kind: "choose", value }; + return raw.trim() === "" ? { kind: "decline" } : { kind: "ignore" }; +} diff --git a/app/src/lib/launchpad/ogcard.test.ts b/app/src/lib/launchpad/ogcard.test.ts index 0afcbe76..281372b3 100644 --- a/app/src/lib/launchpad/ogcard.test.ts +++ b/app/src/lib/launchpad/ogcard.test.ts @@ -29,9 +29,11 @@ test("ageLabel counts seconds under a minute and rejects garbage dates", () => { test("shapeCard never renders NaN/Infinity market caps", () => { const now = Date.parse("2026-09-06T12:00:00Z"); - assert.equal(shapeCard(input({ fdv_usd: Number.NaN }), now).mcap, "$—"); - assert.equal(shapeCard(input({ fdv_usd: Infinity }), now).mcap, "$—"); - assert.equal(shapeCard(input({ fdv_usd: null, fdv_quote: Number.NaN, quote_symbol: "ETH" }), now).mcap, "— ETH"); + assert.equal(shapeCard(input({ fdv_usd: Number.NaN }), now).mcap, "—"); + assert.equal(shapeCard(input({ fdv_usd: Infinity }), now).mcap, "—"); + assert.equal(shapeCard(input({ fdv_usd: null, fdv_quote: Number.NaN, quote_symbol: "ETH" }), now).mcap, "—"); + assert.equal(shapeCard(input({ fdv_usd: 12345 }), now).mcap, "$12.3K", "finite caps keep their format"); + assert.equal(shapeCard(input({ fdv_usd: null, fdv_quote: 2.5, quote_symbol: "ETH" }), now).mcap, "2.50 ETH"); }); test("shapeCard renders a neutral dash for non-finite change, not NaN%/+—%", () => { diff --git a/app/src/lib/launchpad/ogcard.ts b/app/src/lib/launchpad/ogcard.ts index 1150fc80..e60d7cc4 100644 --- a/app/src/lib/launchpad/ogcard.ts +++ b/app/src/lib/launchpad/ogcard.ts @@ -39,7 +39,8 @@ export function shapeCard(l: CardInput, now: number): Card { title: l.name.slice(0, 28), symbol: l.symbol.slice(0, 12), chainLabel: CHAIN_LABELS[l.chain], - mcap: l.fdv_usd !== null ? `$${compact(l.fdv_usd)}` : `${compact(l.fdv_quote)} ${l.quote_symbol}`, + // an unknown cap is one dash, not "$—" or "— ETH" + mcap: l.fdv_usd !== null ? (Number.isFinite(l.fdv_usd) ? `$${compact(l.fdv_usd)}` : "—") : Number.isFinite(l.fdv_quote) ? `${compact(l.fdv_quote)} ${l.quote_symbol}` : "—", change: finitePct ? `${pct >= 0 ? "+" : ""}${Math.abs(pct) >= 1000 ? compact(pct) : pct.toFixed(Math.abs(pct) >= 10 ? 0 : 1)}%` : "—", up: finitePct ? pct >= 0 : null, fee: feeLabel(l.lp_fee, l.recipients),