-
Notifications
You must be signed in to change notification settings - Fork 16
fix(ui): reject scientific-notation amounts, guard tiny-negative USD and share-card numbers #47
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
kevincodex1
merged 4 commits into
Twigpine:main
from
Ayush7614:fix/ui-amount-format-guards
Sep 25, 2026
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
6d40a8b
fix(ui): reject scientific-notation amounts, guard tiny-negative USD …
Ayush7614 fbacdc0
fix(ui): clear mcap preset on rejected input, neutral share-card chan…
Ayush7614 d541c8b
fix(ui): keep "0.5 ETH" pastes; drop the unreachable marketUsd change
kevincodex1 25bf84a
fix(ui): spaced exponents, rejected first buys, unknown caps (CodeRab…
kevincodex1 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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> = {}): 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); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Render an unknown market cap as
—.When
fdv_usdis non-finite,compactreturns—, butshapeCarddisplays$—. A non-finitefdv_quotesimilarly displays—with a quote symbol. Check finiteness before adding currency text so the complete market-cap label is—.🤖 Prompt for AI Agents