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 975cb1e..711137f 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 1d47602..fa25dee 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 e1f22de..fb0f182 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 0000000..e7d1e51 --- /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 0000000..cd13df1 --- /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 b551baa..70ac87e 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 f995b62..06c5af5 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 0000000..48f4099 --- /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 a83fd57..db6b2f8 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 8e30139..ebac440 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 711137f..b5c05fa 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 d23bc5d..a6a2a77 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 e7d1e51..2d4818b 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 cd13df1..3bcb5a9 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 48f4099..0afcbe7 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 db6b2f8..1150fc8 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 2d4818b..65a5729 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 3bcb5a9..30dc544 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 70ac87e..b551baa 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 06c5af5..f995b62 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 b5c05fa..59993c7 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 a6a2a77..51d22d4 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 65a5729..00bd4b5 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 30dc544..f0481cd 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 0afcbe7..281372b 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 1150fc8..e60d7cc 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),