diff --git a/src/abi/calldata/decoder.ts b/src/abi/calldata/decoder.ts index 6b7680b..6671833 100644 --- a/src/abi/calldata/decoder.ts +++ b/src/abi/calldata/decoder.ts @@ -2,19 +2,72 @@ import type {CalldataEncodable} from "../../types/calldata"; import {CalldataAddress} from "../../types/calldata"; import * as consts from "./consts"; +const fatalUtf8Decoder = new TextDecoder("utf-8", {fatal: true}); + +function readByte(data: Uint8Array, index: {i: number}): number { + if (index.i >= data.length) { + throw new Error("unexpected end of calldata"); + } + return data[index.i++]; +} + +function readBytes( + data: Uint8Array, + index: {i: number}, + length: bigint | number, +): Uint8Array { + const lengthBigInt = typeof length === "bigint" ? length : BigInt(length); + const remaining = BigInt(data.length - index.i); + if (lengthBigInt > remaining) { + throw new Error("unexpected end of calldata"); + } + + const lengthNumber = Number(lengthBigInt); + const ret = data.slice(index.i, index.i + lengthNumber); + index.i += lengthNumber; + return ret; +} + function readULeb128(data: Uint8Array, index: {i: number}): bigint { - let res: bigint = 0n; + let res = 0n; let accum = 0n; - let shouldContinue = true; - while (shouldContinue) { - const byte = data[index.i]; - index.i++; + + while (true) { + const byte = readByte(data, index); const rest = byte & 0x7f; res += BigInt(rest) * (1n << accum); + + if ((byte & 0x80) === 0) { + if (byte === 0 && accum !== 0n) { + throw new Error("most significant ULEB128 octet cannot be zero"); + } + return res; + } + accum += 7n; - shouldContinue = byte >= 128; } - return res; +} + +function compareCodePoints(left: string, right: string): number { + const leftPoints = Array.from(left, (char) => char.codePointAt(0)!); + const rightPoints = Array.from(right, (char) => char.codePointAt(0)!); + + for (let i = 0; i < leftPoints.length && i < rightPoints.length; i++) { + const diff = leftPoints[i] - rightPoints[i]; + if (diff !== 0) { + return diff; + } + } + + return leftPoints.length - rightPoints.length; +} + +function decodeUtf8(data: Uint8Array): string { + try { + return fatalUtf8Decoder.decode(data); + } catch { + throw new Error("invalid UTF-8 in calldata"); + } } function decodeImpl(data: Uint8Array, index: {i: number}): CalldataEncodable { @@ -27,28 +80,22 @@ function decodeImpl(data: Uint8Array, index: {i: number}): CalldataEncodable { case BigInt(consts.SPECIAL_FALSE): return false; case BigInt(consts.SPECIAL_ADDR): { - const res = data.slice(index.i, index.i + 20); - index.i += 20; - return new CalldataAddress(res); + return new CalldataAddress(readBytes(data, index, 20)); } } + const type = Number(cur & 0xffn) & ((1 << consts.BITS_IN_TYPE) - 1); const rest = cur >> BigInt(consts.BITS_IN_TYPE); + switch (type) { - case consts.TYPE_BYTES: { - const ret = data.slice(index.i, index.i + Number(rest)); - index.i += Number(rest); - return ret; - } + case consts.TYPE_BYTES: + return readBytes(data, index, rest); case consts.TYPE_PINT: return rest; case consts.TYPE_NINT: return -1n - rest; - case consts.TYPE_STR: { - const ret = data.slice(index.i, index.i + Number(rest)); - index.i += Number(rest); - return new TextDecoder("utf-8").decode(ret); - } + case consts.TYPE_STR: + return decodeUtf8(readBytes(data, index, rest)); case consts.TYPE_ARR: { const ret = [] as CalldataEncodable[]; let elems = rest; @@ -61,18 +108,31 @@ function decodeImpl(data: Uint8Array, index: {i: number}): CalldataEncodable { case consts.TYPE_MAP: { const ret = new Map(); let elems = rest; + let previousKey: string | undefined; + while (elems > 0) { elems--; - const strLen = Number(readULeb128(data, index)); - const key = data.slice(index.i, index.i + strLen); - index.i += strLen; - const keyStr = new TextDecoder("utf-8").decode(key); + const strLen = readULeb128(data, index); + const keyStr = decodeUtf8(readBytes(data, index, strLen)); + + if ( + previousKey !== undefined && + compareCodePoints(previousKey, keyStr) >= 0 + ) { + throw new Error( + `unordered calldata keys: '${previousKey}' >= '${keyStr}'`, + ); + } + + previousKey = keyStr; ret.set(keyStr, decodeImpl(data, index)); } return ret; } default: - throw new Error(`can't decode type from ${type} rest is ${rest} at pos ${index.i}`); + throw new Error( + `can't decode type from ${type} rest is ${rest} at pos ${index.i}`, + ); } } diff --git a/tests/calldata.test.ts b/tests/calldata.test.ts index 9b798bc..fb4c7e3 100644 --- a/tests/calldata.test.ts +++ b/tests/calldata.test.ts @@ -33,3 +33,80 @@ describe("calldata method-call encoding", () => { expect(decoded.has("kwargs")).toBe(true); }); }); + +describe("calldata decoder canonicality", () => { + it("rejects a truncated ULEB128 instead of reading past the input", () => { + expect(() => calldata.decode(new Uint8Array([0x80]))).toThrow( + "unexpected end of calldata", + ); + }); + + it("rejects overlong ULEB128 encodings", () => { + expect(() => calldata.decode(new Uint8Array([0x80, 0x00]))).toThrow( + "most significant ULEB128 octet cannot be zero", + ); + }); + + it.each([ + ["address", new Uint8Array([0x18])], + ["bytes", new Uint8Array([0x0b])], + ["string", new Uint8Array([0x0c])], + ["array element", new Uint8Array([0x0d])], + ["map entry", new Uint8Array([0x0e])], + ])("rejects truncated %s payloads immediately", (_name, encoded) => { + expect(() => calldata.decode(encoded)).toThrow("unexpected end of calldata"); + }); + + it("rejects invalid UTF-8 strings", () => { + expect(() => calldata.decode(new Uint8Array([0x0c, 0xff]))).toThrow( + "invalid UTF-8 in calldata", + ); + }); + + it("rejects invalid UTF-8 map keys", () => { + expect(() => + calldata.decode(new Uint8Array([0x0e, 0x01, 0xff, 0x00])), + ).toThrow("invalid UTF-8 in calldata"); + }); + + it("rejects unordered map keys", () => { + const encoded = new Uint8Array([ + 0x16, + 0x01, + 0x62, + 0x00, + 0x01, + 0x61, + 0x00, + ]); + + expect(() => calldata.decode(encoded)).toThrow( + "unordered calldata keys: 'b' >= 'a'", + ); + }); + + it("rejects duplicate map keys", () => { + const encoded = new Uint8Array([ + 0x16, + 0x01, + 0x61, + 0x00, + 0x01, + 0x61, + 0x00, + ]); + + expect(() => calldata.decode(encoded)).toThrow( + "unordered calldata keys: 'a' >= 'a'", + ); + }); + + it("still round-trips canonical nested calldata", () => { + const input = new Map([ + ["alpha", [1n, "hello", new Uint8Array([0x00, 0xff])]], + ["emoji", "😀"], + ]); + + expect(calldata.decode(calldata.encode(input))).toEqual(input); + }); +});