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
110 changes: 85 additions & 25 deletions src/abi/calldata/decoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand All @@ -61,18 +108,31 @@ function decodeImpl(data: Uint8Array, index: {i: number}): CalldataEncodable {
case consts.TYPE_MAP: {
const ret = new Map<string, CalldataEncodable>();
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}`,
);
}
}

Expand Down
77 changes: 77 additions & 0 deletions tests/calldata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, CalldataEncodable>([
["alpha", [1n, "hello", new Uint8Array([0x00, 0xff])]],
["emoji", "😀"],
]);

expect(calldata.decode(calldata.encode(input))).toEqual(input);
});
});