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 975cb1e..59993c7 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 { 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"; @@ -682,7 +683,11 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = D setCustomMcap(e.target.value.replace(/[^0-9.]/g, ""))} + 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}`} @@ -777,7 +782,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 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/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/components/launchpad/launch-form.test.ts b/app/src/components/launchpad/launch-form.test.ts index d23bc5d..51d22d4 100644 --- a/app/src/components/launchpad/launch-form.test.ts +++ b/app/src/components/launchpad/launch-form.test.ts @@ -153,3 +153,16 @@ 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\)/); +}); + +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/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..00bd4b5 --- /dev/null +++ b/app/src/lib/launchpad/decimal-input.test.ts @@ -0,0 +1,51 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { resolveCustomMcapInput, resolveFirstBuyInput, 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"), ""); + 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", () => { + 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"); + // "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"); +}); + +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 }); +}); + +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 new file mode 100644 index 0000000..f0481cd --- /dev/null +++ b/app/src/lib/launchpad/decimal-input.ts @@ -0,0 +1,57 @@ +/** + * 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. 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](?![a-zA-Z])/.test(raw.replace(/[\s,_]/g, ""))) 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; +} + +/** + * 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 === "" }; +} + +/** + * 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 new file mode 100644 index 0000000..281372b --- /dev/null +++ b/app/src/lib/launchpad/ogcard.test.ts @@ -0,0 +1,52 @@ +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, "—"); + 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%/+—%", () => { + 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 a83fd57..e60d7cc 100644 --- a/app/src/lib/launchpad/ogcard.ts +++ b/app/src/lib/launchpad/ogcard.ts @@ -2,11 +2,12 @@ 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"; 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,21 +23,26 @@ 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`; } 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, + // 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), age: ageLabel(l.block_time, now), quote: quotePillOf(l.quote_key, l.quote_symbol),