Skip to content
Merged
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
4 changes: 4 additions & 0 deletions packages/pointers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
"types": "./src/data.ts",
"default": "./dist/src/data.js"
},
"#decode": {
"types": "./src/decode.ts",
"default": "./dist/src/decode.js"
},
"#evaluate": {
"types": "./src/evaluate.ts",
"default": "./dist/src/evaluate.js"
Expand Down
135 changes: 135 additions & 0 deletions packages/pointers/src/decode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { expect, describe, it } from "vitest";

import { Data } from "./data.js";
import { decodeValue } from "./decode.js";

// Build a right-aligned 32-byte word from a hex value (numbers/addresses are
// stored right-aligned in an EVM word).
const word = (hexNo0x: string): Data =>
Data.fromHex(`0x${hexNo0x.padStart(64, "0")}`);

describe("decodeValue", () => {
describe("uint", () => {
const type = { kind: "uint" as const, bits: 256 };

it("decodes to decimal", () => {
expect(decodeValue(word("02"), type)).toBe("2");
expect(decodeValue(word("ff"), type)).toBe("255");
expect(decodeValue(word("0100"), type)).toBe("256");
});

it("decodes zero", () => {
expect(decodeValue(word("00"), type)).toBe("0");
});

it("decodes a large 256-bit value", () => {
expect(decodeValue(Data.fromHex(`0x${"ff".repeat(32)}`), type)).toBe(
(2n ** 256n - 1n).toString(10),
);
});

it("decodes a tightly-stored uint8", () => {
expect(decodeValue(new Data([0xff]), { kind: "uint", bits: 8 })).toBe(
"255",
);
});
});

describe("int", () => {
const type = { kind: "int" as const, bits: 256 };

it("decodes positive values", () => {
expect(decodeValue(word("05"), type)).toBe("5");
});

it("decodes -1 from a sign-extended full word", () => {
expect(decodeValue(Data.fromHex(`0x${"ff".repeat(32)}`), type)).toBe(
"-1",
);
});

it("decodes a tightly-stored negative int8", () => {
expect(decodeValue(new Data([0xfb]), { kind: "int", bits: 8 })).toBe(
"-5",
);
});

it("decodes int8 minimum", () => {
expect(decodeValue(new Data([0x80]), { kind: "int", bits: 8 })).toBe(
"-128",
);
});

it("decodes zero", () => {
expect(decodeValue(word("00"), type)).toBe("0");
});
});

describe("bool", () => {
const type = { kind: "bool" as const };

it("decodes false and true", () => {
expect(decodeValue(word("00"), type)).toBe("false");
expect(decodeValue(word("01"), type)).toBe("true");
});

it("treats any nonzero as true", () => {
expect(decodeValue(word("05"), type)).toBe("true");
});
});

describe("address", () => {
const type = { kind: "address" as const };

it("decodes to an EIP-55 checksummed address", () => {
// canonical EIP-55 test vector
expect(
decodeValue(word("5aaeb6053f3e94c9b9a09f33669435e7ef1beaed"), type),
).toBe("0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed");
});

it("keeps the low 20 bytes of a wider word", () => {
// leading nonzero bytes above the 20-byte address must be dropped
const padded = Data.fromHex(
`0xdeadbeef${"5aaeb6053f3e94c9b9a09f33669435e7ef1beaed"}`,
);
expect(decodeValue(padded, type)).toBe(
"0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed",
);
});
});

describe("bytesN", () => {
it("decodes left-aligned static bytes4", () => {
const leftAligned = Data.fromHex(`0xdeadbeef${"00".repeat(28)}`);
expect(decodeValue(leftAligned, { kind: "bytes", size: 4 })).toBe(
"0xdeadbeef",
);
});

it("falls back to raw hex for dynamic bytes (no size)", () => {
const data = Data.fromHex("0xdeadbeef");
expect(decodeValue(data, { kind: "bytes" })).toBe("0xdeadbeef");
});
});

describe("fallbacks (deferred / unhandled shapes)", () => {
const data = word("2a");

it("falls back to raw hex for string", () => {
expect(decodeValue(data, { kind: "string" })).toBe(data.toHex());
});

it("falls back to raw hex for an unresolved { id } reference", () => {
expect(decodeValue(data, { id: 5 })).toBe(data.toHex());
});

it("falls back to raw hex for a missing type", () => {
expect(decodeValue(data, undefined)).toBe(data.toHex());
});

it("falls back to raw hex for aggregate kinds", () => {
expect(decodeValue(data, { kind: "array" } as never)).toBe(data.toHex());
});
});
});
125 changes: 125 additions & 0 deletions packages/pointers/src/decode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { keccak256 } from "ethereum-cryptography/keccak";

import { Data } from "#data";

// Type is imported for its shape only; @ethdebug/format is a type-only
// (dev) dependency here, so we discriminate on `kind` structurally rather
// than pulling in the package's runtime type guards.
import type { Type } from "@ethdebug/format";

/**
* Decode a resolved value region's bytes into a readable, human-facing
* string, directed by the variable's static type.
*
* This is the scalar-first slice of the value-rendering `reduce` layer: it
* turns raw bytes + type into a value a person can read (`uint256` -> `2`,
* not `0x00...02`). It is a pure, framework-agnostic, byte-testable function
* — no machine state, no location logic (the pointer layer already erased
* that).
*
* Scalar kinds handled: `uint`, `int`, `bool`, `address`, static `bytes<N>`.
* Everything else — dynamic `bytes`/`string`, arrays, structs, mappings,
* `fixed`/`ufixed`, `enum`, unresolved `{ id }` type references, or a missing
* type — falls back to raw hex so callers never break on an unhandled shape.
*/
export function decodeValue(
data: Data,
type: Type.Specifier | undefined,
): string {
// No type, or a bare `{ id }` reference we can't resolve here -> raw hex.
if (!type || typeof type !== "object" || !("kind" in type)) {
return data.toHex();
}

switch (type.kind) {
case "uint":
return decodeUint(data);

case "bool":
return decodeBool(data);

case "address":
return decodeAddress(data);

case "int":
return decodeInt(data);

case "bytes":
// `size` present -> static `bytes<N>`; absent -> dynamic (deferred).
return "size" in type && typeof type.size === "number"
? decodeBytesN(data, type.size)
: data.toHex();

default:
// string, arrays, structs, mappings, fixed/ufixed, enum, contract, ...
return data.toHex();
}
}

function decodeUint(data: Data): string {
return data.asUint().toString(10);
}

function decodeBool(data: Data): string {
return data.asUint() === 0n ? "false" : "true";
}

/**
* Two's-complement decode over the region's *actual* byte width. Signed
* values are sign-extended to the region width by the compiler, so
* interpreting over `data.length` bytes is correct whether the value is
* stored tightly (`int8` in 1 byte) or in a full sign-extended word
* (`int8` -1 as `0xff..ff`).
*/
function decodeInt(data: Data): string {
const width = data.length;
if (width === 0) {
return "0";
}

const raw = data.asUint();
const bits = BigInt(width * 8);
const signBit = 1n << (bits - 1n);

const value = raw >= signBit ? raw - (1n << bits) : raw;
return value.toString(10);
}

/**
* Address = the low 20 bytes, rendered as an EIP-55 checksummed `0x` string.
* `resizeTo(20)` keeps the least-significant 20 bytes of a wider word (and
* left-pads a narrower one).
*/
function decodeAddress(data: Data): string {
const hex = data.resizeTo(20).toHex().slice(2);
return toChecksumAddress(hex);
}

/**
* Static `bytes<N>` is left-aligned (high-order) in its word, so the value is
* the first `size` bytes of the region.
*/
function decodeBytesN(data: Data, size: number): string {
return Data.fromBytes(data.slice(0, size)).toHex();
}

/**
* EIP-55 checksum: hash the lowercase hex (ASCII, no `0x`), then uppercase
* each hex letter whose corresponding hash nibble is >= 8.
*/
function toChecksumAddress(lowerHex: string): string {
const hash = keccak256(new TextEncoder().encode(lowerHex));

let out = "0x";
for (let i = 0; i < lowerHex.length; i++) {
const char = lowerHex[i];
if (char >= "a" && char <= "f") {
const hashByte = hash[i >> 1];
const nibble = i % 2 === 0 ? hashByte >> 4 : hashByte & 0x0f;
out += nibble >= 8 ? char.toUpperCase() : char;
} else {
out += char;
}
}
return out;
}
1 change: 1 addition & 0 deletions packages/pointers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export { Cursor } from "#cursor";

export type { Machine } from "#machine";
export { Data } from "#data";
export { decodeValue } from "#decode";
Loading