diff --git a/packages/pointers/package.json b/packages/pointers/package.json index 7de1fa7a25..a1ce744dfd 100644 --- a/packages/pointers/package.json +++ b/packages/pointers/package.json @@ -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" diff --git a/packages/pointers/src/decode.test.ts b/packages/pointers/src/decode.test.ts new file mode 100644 index 0000000000..1d2c055930 --- /dev/null +++ b/packages/pointers/src/decode.test.ts @@ -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()); + }); + }); +}); diff --git a/packages/pointers/src/decode.ts b/packages/pointers/src/decode.ts new file mode 100644 index 0000000000..ed6f419294 --- /dev/null +++ b/packages/pointers/src/decode.ts @@ -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`. + * 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`; 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` 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; +} diff --git a/packages/pointers/src/index.ts b/packages/pointers/src/index.ts index 5475fbbf88..80e9161be0 100644 --- a/packages/pointers/src/index.ts +++ b/packages/pointers/src/index.ts @@ -3,3 +3,4 @@ export { Cursor } from "#cursor"; export type { Machine } from "#machine"; export { Data } from "#data"; +export { decodeValue } from "#decode";