Skip to content
Open
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
41 changes: 37 additions & 4 deletions src/abi/calldata/encoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
],
);
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down
44 changes: 44 additions & 0 deletions tests/calldata-lossy-inputs.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, CalldataEncodable>([
["\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);
});
});