diff --git a/app/src/lib/launchpad/decimal-input.test.ts b/app/src/lib/launchpad/decimal-input.test.ts index 00bd4b5..67dd7e5 100644 --- a/app/src/lib/launchpad/decimal-input.test.ts +++ b/app/src/lib/launchpad/decimal-input.test.ts @@ -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 @@ -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" }); +}); diff --git a/app/src/lib/launchpad/decimal-input.ts b/app/src/lib/launchpad/decimal-input.ts index f0481cd..4297b54 100644 --- a/app/src/lib/launchpad/decimal-input.ts +++ b/app/src/lib/launchpad/decimal-input.ts @@ -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, ""); } /**