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
26 changes: 24 additions & 2 deletions src/abi/calldata/decoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,24 @@ function readULeb128(data: Uint8Array, index: {i: number}): bigint {
let res: bigint = 0n;
let accum = 0n;
let shouldContinue = true;
let bytesRead = 0;
while (shouldContinue) {
if (index.i >= data.length) {
throw new Error("unexpected end of input in ULEB128");
}
const byte = data[index.i];
index.i++;
bytesRead++;
const rest = byte & 0x7f;
res += BigInt(rest) * (1n << accum);
accum += 7n;
shouldContinue = byte >= 128;
if (shouldContinue && rest === 0n && bytesRead > 1) {
throw new Error("non-canonical ULEB128: overlong encoding");
}
}
if (bytesRead > 1 && res === 0n) {
throw new Error("non-canonical ULEB128: overlong encoding");
}
return res;
}
Expand Down Expand Up @@ -45,9 +56,12 @@ function decodeImpl(data: Uint8Array, index: {i: number}): CalldataEncodable {
case consts.TYPE_NINT:
return -1n - rest;
case consts.TYPE_STR: {
if (index.i + Number(rest) > data.length) {
throw new Error("string extends past end of input");
}
const ret = data.slice(index.i, index.i + Number(rest));
index.i += Number(rest);
return new TextDecoder("utf-8").decode(ret);
return new TextDecoder("utf-8", {fatal: true}).decode(ret);
}
case consts.TYPE_ARR: {
const ret = [] as CalldataEncodable[];
Expand All @@ -61,12 +75,20 @@ function decodeImpl(data: Uint8Array, index: {i: number}): CalldataEncodable {
case consts.TYPE_MAP: {
const ret = new Map<string, CalldataEncodable>();
let elems = rest;
let prevKey: string | null = null;
while (elems > 0) {
elems--;
const strLen = Number(readULeb128(data, index));
if (index.i + strLen > data.length) {
throw new Error("map key extends past end of input");
}
const key = data.slice(index.i, index.i + strLen);
index.i += strLen;
const keyStr = new TextDecoder("utf-8").decode(key);
const keyStr = new TextDecoder("utf-8", {fatal: true}).decode(key);
if (prevKey !== null && keyStr <= prevKey) {
throw new Error("map keys must be in strictly increasing order");
}
prevKey = keyStr;
ret.set(keyStr, decodeImpl(data, index));
}
return ret;
Expand Down
36 changes: 31 additions & 5 deletions src/abi/calldata/encoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@ function reportError(msg: string, data: CalldataEncodable): never {
throw new Error(`invalid calldata input '${data}'`);
}

function hasUnpairedSurrogates(s: string): boolean {
for (let i = 0; i < s.length; i++) {
const code = s.charCodeAt(i);
if (code >= 0xd800 && code <= 0xdbff) {
if (i + 1 >= s.length) return true;
const next = s.charCodeAt(i + 1);
if (next < 0xdc00 || next > 0xdfff) return true;
i++;
} else if (code >= 0xdc00 && code <= 0xdfff) {
return true;
}
}
return false;
}

function writeNum(to: number[], data: bigint) {
if (data === 0n) {
to.push(0);
Expand Down Expand Up @@ -48,11 +63,16 @@ function encodeMap(to: number[], arr: Iterable<[string, CalldataEncodable]>) {
// unicode code points array, utf8 encoded array, item
const newEntries: [number[], Uint8Array, CalldataEncodable][] = Array.from(
arr,
([k, v]): [number[], Uint8Array, CalldataEncodable] => [
Array.from(k, x => x.codePointAt(0)!),
new TextEncoder().encode(k),
v,
],
([k, v]): [number[], Uint8Array, CalldataEncodable] => {
if (hasUnpairedSurrogates(k)) {
throw new Error(`unpaired UTF-16 surrogate in map key '${k}'`);
}
return [
Array.from(k, x => x.codePointAt(0)!),
new TextEncoder().encode(k),
v,
];
},
);
newEntries.sort((v1, v2) => compareString(v1[0], v2[0]));
for (let i = 1; i < newEntries.length; i++) {
Expand Down Expand Up @@ -89,6 +109,9 @@ function encodeImpl(to: number[], data: CalldataEncodable) {
if (!Number.isInteger(data)) {
reportError("floats are not supported", data);
}
if (!Number.isSafeInteger(data)) {
reportError("unsafe integer: use bigint for exact integers", data);
}
encodeNum(to, BigInt(data));
return;
}
Expand All @@ -97,6 +120,9 @@ function encodeImpl(to: number[], data: CalldataEncodable) {
return;
}
case "string": {
if (hasUnpairedSurrogates(data)) {
reportError("unpaired UTF-16 surrogate in string", data);
}
const str = new TextEncoder().encode(data);
encodeNumWithType(to, BigInt(str.length), consts.TYPE_STR);
for (const c of str) {
Expand Down
56 changes: 56 additions & 0 deletions tests/calldata-decoder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import {describe, it, expect} from "vitest";
import {decode} from "../src/abi/calldata/decoder";

describe("calldata decoder", () => {
describe("ULEB128 validation", () => {
it("rejects overlong ULEB128 encoding", () => {
expect(() => decode(new Uint8Array([0x80, 0x00]))).toThrow();
});

it("rejects truncated ULEB128", () => {
expect(() => decode(new Uint8Array([0x80]))).toThrow();
});

it("rejects empty input", () => {
expect(() => decode(new Uint8Array([]))).toThrow();
});
});

describe("map key ordering", () => {
it("rejects unordered map keys", () => {
expect(() =>
decode(new Uint8Array([0x16, 0x01, 0x62, 0x00, 0x01, 0x61, 0x00])),
).toThrow();
});

it("rejects duplicate map keys", () => {
expect(() =>
decode(new Uint8Array([0x16, 0x01, 0x61, 0x00, 0x01, 0x61, 0x00])),
).toThrow();
});
});

describe("UTF-8 validation", () => {
it("rejects invalid UTF-8 in strings", () => {
expect(() => decode(new Uint8Array([0x0c, 0xff]))).toThrow();
});

it("rejects invalid UTF-8 in map keys", () => {
expect(() =>
decode(new Uint8Array([0x16, 0x01, 0xff, 0x00, 0x01, 0x61, 0x00])),
).toThrow();
});
});

describe("bounds checking", () => {
it("rejects string extending past end of input", () => {
expect(() => decode(new Uint8Array([0x0c, 0xff, 0xff, 0xff]))).toThrow();
});

it("rejects map key extending past end of input", () => {
expect(() =>
decode(new Uint8Array([0x16, 0x01, 0xff, 0xff, 0xff, 0x00])),
).toThrow();
});
});
});
49 changes: 49 additions & 0 deletions tests/calldata-encoder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {describe, it, expect} from "vitest";
import {encode} from "../src/abi/calldata/encoder";

describe("calldata encoder", () => {
describe("unsafe integers", () => {
it("rejects numbers outside safe integer range", () => {
expect(() => encode(Number.MAX_SAFE_INTEGER + 1)).toThrow();
expect(() => encode(Number.MIN_SAFE_INTEGER - 1)).toThrow();
});

it("accepts safe integers", () => {
expect(() => encode(Number.MAX_SAFE_INTEGER)).not.toThrow();
expect(() => encode(Number.MIN_SAFE_INTEGER)).not.toThrow();
expect(() => encode(0)).not.toThrow();
expect(() => encode(-1)).not.toThrow();
});

it("accepts bigint for large values", () => {
expect(() => encode(BigInt(Number.MAX_SAFE_INTEGER) + 1n)).not.toThrow();
});
});

describe("unpaired surrogates", () => {
it("rejects lone high surrogate in string", () => {
expect(() => encode("\uD800")).toThrow();
});

it("rejects lone low surrogate in string", () => {
expect(() => encode("\uDC00")).toThrow();
});

it("rejects high surrogate at end of string", () => {
expect(() => encode("hello\uD800")).toThrow();
});

it("rejects unpaired surrogate in map key", () => {
expect(() => encode(new Map([["\uD800", null]]))).toThrow();
});

it("accepts valid surrogate pairs (emoji)", () => {
expect(() => encode("hello 🌍")).not.toThrow();
});

it("accepts normal unicode strings", () => {
expect(() => encode("hello world")).not.toThrow();
expect(() => encode("")).not.toThrow();
});
});
});
Loading