Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions app/src/lib/launchpad/decimal-input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,7 @@ test("grouping, currency and whitespace are stripped; only the first dot survive
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("1,234,567.89"), "1234567.89");
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
Expand All @@ -49,3 +48,35 @@ test("resolveFirstBuyInput: a rejected entry keeps the shown amount; only cleari
assert.deepEqual(resolveFirstBuyInput(""), { kind: "decline" });
assert.deepEqual(resolveFirstBuyInput(" "), { kind: "decline" });
});

test("ambiguous separators are rejected, never rewritten into a different valid amount", () => {
// more than one dot: dropping the extras once turned a de-DE million into 1 (a $1 launch cap)
assert.equal(sanitizeDecimalInput("1.000.000"), "");
assert.equal(sanitizeDecimalInput("1.234.567"), "");
assert.equal(sanitizeDecimalInput("1..2"), "");
assert.equal(sanitizeDecimalInput("1.2.3"), "");
// a decimal comma: stripping it made 0,05 into 5 (100x) and 1,5 into 15
assert.equal(sanitizeDecimalInput("0,05"), "");
assert.equal(sanitizeDecimalInput("1,5"), "");
assert.equal(sanitizeDecimalInput("1.000,50"), "");
assert.equal(sanitizeDecimalInput("1,0000"), "", "grouping is exactly three digits");
assert.equal(sanitizeDecimalInput("12,"), "", "a trailing comma is not grouping either");
// well-formed grouping only: a 1-3 digit lead (not 0), then groups of exactly three, none after the dot
assert.equal(sanitizeDecimalInput("0,123"), "", "a decimal comma with three decimals, not 123 (1,000x)");
assert.equal(sanitizeDecimalInput("1234,567"), "", "a four-digit lead is not grouping");
assert.equal(sanitizeDecimalInput("01,234"), "");
assert.equal(sanitizeDecimalInput(",500"), "");
assert.equal(sanitizeDecimalInput("1,234.567,000"), "", "no comma after the decimal point");
assert.equal(sanitizeDecimalInput("1,23,456"), "", "every group after the lead is exactly three digits");
// real grouping still reads
assert.equal(sanitizeDecimalInput("12,000"), "12000");
assert.equal(sanitizeDecimalInput("1,234,567"), "1234567");
assert.equal(sanitizeDecimalInput("$1,234.50 USD"), "1234.50");
assert.equal(sanitizeDecimalInput("999,999.99"), "999999.99");
assert.equal(sanitizeDecimalInput("100000"), "100000", "ungrouped numbers are untouched");
});

test("the launch form's rejected entries stay visible states, not a different cap or buy", () => {
assert.deepEqual(resolveCustomMcapInput("1.000.000"), { value: "", clearPick: true });
assert.deepEqual(resolveFirstBuyInput("0,05"), { kind: "ignore" });
});
39 changes: 22 additions & 17 deletions app/src/lib/launchpad/decimal-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,33 @@
* `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.
* error shown.
*
* 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.
* The rule: never guess. Anything whose meaning is ambiguous returns "" (the
* caller then rejects it) instead of being rewritten into a different, valid
* amount:
* - scientific notation: 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;
* - more than one dot ("1.000.000" is a million in de-DE, 1 if the extra
* dots were dropped: a $1 launch cap);
* - commas that are not a well-formed thousands grouping: the integer part
* must be 1-3 digits (not starting with 0) then groups of exactly three,
* with no comma after the dot ("0,05" and "0,123" are decimals in many
* locales, 5 and 123 if the comma were stripped; "1234,567" is not
* grouping). "12,000", "1,234,567" and "1,234.5" still read.
* Otherwise grouping commas, currency and whitespace are stripped. 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;
}
if ((raw.match(/\./g) ?? []).length > 1) return "";
if (raw.includes(",")) {
const [int, frac = ""] = raw.replace(/[^0-9.,]/g, "").split(".");
if (!/^[1-9]\d{0,2}(,\d{3})+$/.test(int) || frac.includes(",")) return "";
}
return out;
return raw.replace(/[^0-9.]/g, "");
}

/**
Expand Down
Loading