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),