diff --git a/lib/stellar/parser.ts b/lib/stellar/parser.ts index be693e7..9fa854f 100644 --- a/lib/stellar/parser.ts +++ b/lib/stellar/parser.ts @@ -39,6 +39,14 @@ export function parseJSON(content: string): PaymentInstruction[] { } return rawInstructions.map((item: Record, index: number) => { + // #714: Reject numeric JSON amounts — JSON.parse applies IEEE-754 rounding + // before validation, silently altering values like 99999999999.9999999 → 1e11 + if (typeof item.amount === 'number') { + throw new Error( + `Row ${index + 1}: amount must be a quoted string, not a number (received ${item.amount})` + ); + } + const instruction: PaymentInstruction = { address: sanitizeValue(String(item.address ?? '')), amount: sanitizeValue(String(item.amount ?? '')), diff --git a/tests/parser.test.ts b/tests/parser.test.ts index a7499f0..f75e795 100644 --- a/tests/parser.test.ts +++ b/tests/parser.test.ts @@ -81,6 +81,35 @@ describe('JSON Parser', () => { const result = parseJSON(json); expect(result[0].amount).toBe('123.456789'); }); + + // #714: Reject numeric JSON amounts to prevent silent IEEE-754 rounding + test('rejects unquoted numeric amount that would round silently', () => { + // Using raw JSON string so the amount is a literal number, not a string + const json = `[{"address":"GBBD47UZM2HN7D7XZIZVG4KVAUC36THN5BES6RMNNOK5TUNXAUCVMAKER","amount":99999999999.9999999,"asset":"XLM"}]`; + expect(() => parseJSON(json)).toThrow(/amount must be a quoted string/); + }); + + test('rejects unquoted small numeric amount that becomes scientific notation', () => { + const json = `[{"address":"GBBD47UZM2HN7D7XZIZVG4KVAUC36THN5BES6RMNNOK5TUNXAUCVMAKER","amount":0.0000001,"asset":"XLM"}]`; + expect(() => parseJSON(json)).toThrow(/amount must be a quoted string/); + }); + + test('rejects unquoted integer amount', () => { + const json = `[{"address":"GBBD47UZM2HN7D7XZIZVG4KVAUC36THN5BES6RMNNOK5TUNXAUCVMAKER","amount":100,"asset":"XLM"}]`; + expect(() => parseJSON(json)).toThrow(/amount must be a quoted string/); + }); + + test('accepts quoted string amount for large value', () => { + const json = `[{"address":"GBBD47UZM2HN7D7XZIZVG4KVAUC36THN5BES6RMNNOK5TUNXAUCVMAKER","amount":"99999999999.9999999","asset":"XLM"}]`; + const result = parseJSON(json); + expect(result[0].amount).toBe('99999999999.9999999'); + }); + + test('accepts quoted string amount for tiny value', () => { + const json = `[{"address":"GBBD47UZM2HN7D7XZIZVG4KVAUC36THN5BES6RMNNOK5TUNXAUCVMAKER","amount":"0.0000001","asset":"XLM"}]`; + const result = parseJSON(json); + expect(result[0].amount).toBe('0.0000001'); + }); }); describe('CSV Parser', () => {