diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index 6999866af2..86df3cc522 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -132,6 +132,16 @@ support. Changes to the specification itself are tracked in the root - `a.length` on a dynamic array in storage now reads the length in the array's slot. Before, it did not read that slot ([#366]). +- The pointer for a storage `string` or `bytes` value, as a variable, a + struct member or an array element, now reads its data, in the shape of + solc's `t_string_storage` template. A one-byte `length-flag` region (the + slot's low byte) chooses the form: when it is even, a `data` region in + the slot, of length `length-flag / 2`; else a `long-length` region (the + slot) and a `data` region from keccak256(slot), of length + `(long-length - 1) / 2`. A member's regions have names qualified by its + name, such as `name-data`. Before, the pointer gave only the slot, so a + debugger had to know Solidity's encoding to read the value + ([#369]). - At optimization levels 2 and 3, an inlined body's control flow now has a `transform: ["inline"]` context: the branch of an `if`, the jumps between the body's blocks, each block's `JUMPDEST`, and the jump that @@ -423,3 +433,4 @@ First publication. [#365]: https://github.com/ethdebug/format/pull/365 [#366]: https://github.com/ethdebug/format/pull/366 [#367]: https://github.com/ethdebug/format/pull/367 +[#369]: https://github.com/ethdebug/format/pull/369 diff --git a/packages/bugc/src/irgen/debug/variables.test.ts b/packages/bugc/src/irgen/debug/variables.test.ts new file mode 100644 index 0000000000..2ace01a38c --- /dev/null +++ b/packages/bugc/src/irgen/debug/variables.test.ts @@ -0,0 +1,137 @@ +/** + * A storage string or `bytes` has Solidity's encoding: up to 31 bytes + * in its slot, left-aligned, with length * 2 in the low byte; else + * length * 2 + 1 in the slot and the data from keccak256(slot). Its + * pointer must say so, so that a debugger reads the exact bytes from + * the pointer alone. + */ +import { describe, it, expect } from "vitest"; +import { bytesToHex, hexToBytes } from "ethereum-cryptography/utils"; +import { createMachineState } from "@ethdebug/evm"; +import { dereference } from "@ethdebug/pointers"; +import type * as Format from "@ethdebug/format"; + +import { parse } from "#parser"; +import { checkProgram } from "#typechecker"; +import { Type } from "#types"; +import { traceLocals } from "#test/evm/locals"; + +import { mappingAccess } from "./pointers.js"; +import { generateStoragePointer } from "./variables.js"; + +const lengths = [0, 5, 31, 32, 70]; +const levels = [0, 1, 2, 3] as const; + +const alphabet = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; +const text = (length: number) => + Array.from({ length }, (_, i) => alphabet[i % alphabet.length]).join(""); + +const hex = (length: number) => + Array.from({ length }, (_, i) => (i + 1).toString(16).padStart(2, "0")).join( + "", + ); + +const source = (length: number) => `name StringPointers; +define { struct S { id: uint256; name: string; tag: bytes; }; } +storage { + [0] s: string; + [1] b: bytes; + [2] p: S; + [5] a: array; + [7] u: mapping; +} +code { + let m: string = "${text(length)}"; + let n: bytes = 0x${hex(70)}; + let c: bytes = ${length === 70 ? "n" : `n[0:${length}]`}; + s = m; + b = c; + p.id = 1; + p.name = m; + p.tag = c; + a[1] = m; + u[7].id = 2; + u[7].name = m; + u[7].tag = c; +}`; + +/** The value type of storage variable `name`'s mapping */ +function mappingValueType(source: string, name: string): Type { + const parsed = parse(source); + if (!parsed.success) throw new Error("parse failed"); + const checked = checkProgram(parsed.value); + if (!checked.success) throw new Error("typecheck failed"); + const declaration = parsed.value.storage!.find((d) => d.name === name)!; + const type = checked.value.types.get(declaration.id)!; + if (!Type.isMapping(type)) throw new Error(`${name} is not a mapping`); + return type.value; +} + +/** Read each region named `name`, in order, as hex */ +async function readRegions( + pointer: Format.Pointer, + state: ReturnType, + name: string, +): Promise { + const cursor = await dereference(pointer, { state }); + const view = await cursor.view(state); + return Promise.all( + view.regions + .filter((region) => region.name === name) + .map(async (region) => bytesToHex(await view.read(region))), + ); +} + +describe("storage string and bytes pointers", () => { + for (const level of levels) { + for (const length of lengths) { + it(`read ${length} bytes (level ${level})`, async () => { + const program = source(length); + const run = await traceLocals(program, level); + const state = createMachineState(run.executor, { + traceStep: run.steps[run.steps.length - 1], + }); + const pointerOf = (identifier: string) => + ( + run.program.context as Format.Program.Context.Variables + ).variables.find((v) => v.identifier === identifier)!.pointer!; + + const string = bytesToHex(new TextEncoder().encode(text(length))); + const bytes = bytesToHex(hexToBytes(hex(length))); + + expect(await readRegions(pointerOf("s"), state, "data")).toEqual([ + string, + ]); + expect(await readRegions(pointerOf("b"), state, "data")).toEqual([ + bytes, + ]); + expect(await readRegions(pointerOf("p"), state, "id")).toEqual([ + "0".repeat(63) + "1", + ]); + expect(await readRegions(pointerOf("p"), state, "name-data")).toEqual([ + string, + ]); + expect(await readRegions(pointerOf("p"), state, "tag-data")).toEqual([ + bytes, + ]); + expect(await readRegions(pointerOf("a"), state, "data")).toEqual([ + "", + string, + ]); + + // A mapping's pointer names only its slot; its entry `u[7]` + // has the pointer of its value type at the entry's slot + const entry = generateStoragePointer( + mappingAccess(7, 7), + mappingValueType(program, "u"), + )!; + expect(await readRegions(entry, state, "id")).toEqual([ + "0".repeat(63) + "2", + ]); + expect(await readRegions(entry, state, "name-data")).toEqual([string]); + expect(await readRegions(entry, state, "tag-data")).toEqual([bytes]); + }); + } + } +}); diff --git a/packages/bugc/src/irgen/debug/variables.ts b/packages/bugc/src/irgen/debug/variables.ts index 7859fa2c4b..531f68f7b1 100644 --- a/packages/bugc/src/irgen/debug/variables.ts +++ b/packages/bugc/src/irgen/debug/variables.ts @@ -41,9 +41,7 @@ function getTypeSize(bugType: Type): number { case "bool": return 1; case "bytes": - return bugType.size || 32; // Dynamic bytes default to full slot - case "string": - return 32; // Dynamic string default to full slot + return bugType.size || 32; default: return 32; } @@ -69,7 +67,7 @@ function plus( * Instead, every region name inside it is qualified by the member's path: * `prefix` is that path (e.g. "ceo-" for the members of field `ceo`). */ -function generateStoragePointer( +export function generateStoragePointer( baseSlot: Format.Pointer.Expression, bugType: Type, byteOffset: number = 0, @@ -196,6 +194,14 @@ function generateStoragePointer( return pointer; } + if ( + Type.Elementary.isString(bugType) || + (Type.Elementary.isBytes(bugType) && + Type.Elementary.Bytes.isDynamic(bugType)) + ) { + return generateStorageBytesPointer(baseSlot, prefix); + } + // For elementary types, generate pointer with offset and length const size = getTypeSize(bugType); const pointer: Format.Pointer = { @@ -217,6 +223,60 @@ function generateStoragePointer( return pointer; } +/** + * A pointer for a storage string or dynamic `bytes`, by Solidity's + * encoding, in the shape solc gives its `t_string_storage` template. + * The slot's low byte is even for a short value (up to 31 bytes): the + * slot holds the data, left-aligned, and length * 2 in that byte. Else + * the slot holds length * 2 + 1, and the data starts at + * keccak256(slot), in one region across as many slots as it needs. + */ +function generateStorageBytesPointer( + slot: Format.Pointer.Expression, + prefix: string, +): Format.Pointer { + const flag = `${prefix}length-flag`; + const longLength = `${prefix}long-length`; + const data = `${prefix}data`; + return { + group: [ + { + name: flag, + location: "storage", + slot, + offset: { $difference: ["$wordsize", 1] }, + length: 1, + }, + { + if: { $remainder: [{ $sum: [{ $read: flag }, 1] }, 2] }, + then: { + define: { length: { $quotient: [{ $read: flag }, 2] } }, + in: { name: data, location: "storage", slot, length: "length" }, + }, + else: { + group: [ + { name: longLength, location: "storage", slot }, + { + define: { + length: { + $quotient: [{ $difference: [{ $read: longLength }, 1] }, 2], + }, + start: { $keccak256: [{ $wordsized: slot }] }, + }, + in: { + name: data, + location: "storage", + slot: "start", + length: "length", + }, + }, + ], + }, + }, + ], + }; +} + /** * Collect all variables with determinable locations from current state *