From 2f6545e7b02a5ca5714278c94d42eb624a3fc982 Mon Sep 17 00:00:00 2001 From: maho0638 <104829390+maho0638@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:59:29 +0300 Subject: [PATCH] fix(calldata): reject lossy JavaScript inputs --- src/abi/calldata/encoder.ts | 41 ++++++++++++++++++++++++--- tests/calldata-lossy-inputs.test.ts | 44 +++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 tests/calldata-lossy-inputs.test.ts diff --git a/src/abi/calldata/encoder.ts b/src/abi/calldata/encoder.ts index b3395db..eea907f 100644 --- a/src/abi/calldata/encoder.ts +++ b/src/abi/calldata/encoder.ts @@ -6,6 +6,37 @@ function reportError(msg: string, data: CalldataEncodable): never { throw new Error(`invalid calldata input '${data}'`); } +function isWellFormedUnicode(value: string): boolean { + for (let i = 0; i < value.length; i++) { + const codeUnit = value.charCodeAt(i); + + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + if (i + 1 >= value.length) { + return false; + } + const next = value.charCodeAt(i + 1); + if (next < 0xdc00 || next > 0xdfff) { + return false; + } + i++; + continue; + } + + if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + return false; + } + } + + return true; +} + +function encodeUtf8(value: string): Uint8Array { + if (!isWellFormedUnicode(value)) { + throw new Error("invalid calldata string: unpaired UTF-16 surrogate"); + } + return new TextEncoder().encode(value); +} + function writeNum(to: number[], data: bigint) { if (data === 0n) { to.push(0); @@ -50,7 +81,7 @@ function encodeMap(to: number[], arr: Iterable<[string, CalldataEncodable]>) { arr, ([k, v]): [number[], Uint8Array, CalldataEncodable] => [ Array.from(k, x => x.codePointAt(0)!), - new TextEncoder().encode(k), + encodeUtf8(k), v, ], ); @@ -86,8 +117,10 @@ function encodeImpl(to: number[], data: CalldataEncodable) { } switch (typeof data) { case "number": { - if (!Number.isInteger(data)) { - reportError("floats are not supported", data); + if (!Number.isSafeInteger(data)) { + throw new Error( + "calldata numbers must be safe integers; use bigint for exact large integers", + ); } encodeNum(to, BigInt(data)); return; @@ -97,7 +130,7 @@ function encodeImpl(to: number[], data: CalldataEncodable) { return; } case "string": { - const str = new TextEncoder().encode(data); + const str = encodeUtf8(data); encodeNumWithType(to, BigInt(str.length), consts.TYPE_STR); for (const c of str) { to.push(c); diff --git a/tests/calldata-lossy-inputs.test.ts b/tests/calldata-lossy-inputs.test.ts new file mode 100644 index 0000000..48e819c --- /dev/null +++ b/tests/calldata-lossy-inputs.test.ts @@ -0,0 +1,44 @@ +import {describe, expect, it} from "vitest"; +import {calldata} from "@/abi"; +import type {CalldataEncodable} from "@/types/calldata"; + +describe("calldata encoder lossless inputs", () => { + it("rejects unsafe integer numbers and points callers to bigint", () => { + expect(() => calldata.encode(Number.MAX_SAFE_INTEGER + 1)).toThrow( + "numbers must be safe integers; use bigint for exact large integers", + ); + expect(() => calldata.encode(Number.MIN_SAFE_INTEGER - 1)).toThrow( + "numbers must be safe integers; use bigint for exact large integers", + ); + }); + + it("encodes the same large integer exactly when supplied as bigint", () => { + const value = BigInt(Number.MAX_SAFE_INTEGER) + 2n; + expect(calldata.decode(calldata.encode(value))).toBe(value); + }); + + it("rejects unpaired high and low UTF-16 surrogates in string values", () => { + expect(() => calldata.encode("\ud800")).toThrow( + "invalid calldata string: unpaired UTF-16 surrogate", + ); + expect(() => calldata.encode("\udc00")).toThrow( + "invalid calldata string: unpaired UTF-16 surrogate", + ); + }); + + it("rejects map keys that TextEncoder would silently replace", () => { + const value = new Map([ + ["\ud800", null], + ["\ufffd", true], + ]); + + expect(() => calldata.encode(value)).toThrow( + "invalid calldata string: unpaired UTF-16 surrogate", + ); + }); + + it("preserves valid surrogate pairs such as emoji", () => { + const value = "before 😀 after"; + expect(calldata.decode(calldata.encode(value))).toBe(value); + }); +});