diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index 86df3cc522..da72031605 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -142,6 +142,16 @@ support. Changes to the specification itself are tracked in the root 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]). +- The pointer for a storage mapping now gives a template for its entry, + in the shape of solc's `t_mapping` templates, around the region at the + mapping's slot. The template expects `slot` (the mapping's slot) and + `key`, and points at keccak256(key . slot), each a word: a value type + there is a region named `value`, and a struct's members are regions + named `value-`, such as `value-score` or `value-name-data`. The + template is named `entry` for a mapping variable, `-entry` for a + mapping member `name`, and `value-entry` for a mapping in an entry. + Before, the pointer gave only the mapping's slot, so a debugger could + not find an entry or its members from it ([#371]). - 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 @@ -434,3 +444,4 @@ First publication. [#366]: https://github.com/ethdebug/format/pull/366 [#367]: https://github.com/ethdebug/format/pull/367 [#369]: https://github.com/ethdebug/format/pull/369 +[#371]: https://github.com/ethdebug/format/pull/371 diff --git a/packages/bugc/examples/intermediate/mappings.bug b/packages/bugc/examples/intermediate/mappings.bug index 689bee598f..9ff5a84d70 100644 --- a/packages/bugc/examples/intermediate/mappings.bug +++ b/packages/bugc/examples/intermediate/mappings.bug @@ -22,7 +22,16 @@ code { after: deploy variables: balances: - pointer: { location: storage, slot: 0 } + pointer: + templates: + entry: + expect: [slot, key] + for: + define: + slot: + $keccak256: [{ $wordsized: key }, { $wordsized: slot }] + in: { location: storage, slot: slot, name: value } + in: { location: storage, slot: 0 } */ // Read from mapping diff --git a/packages/bugc/src/irgen/debug/variables.test.ts b/packages/bugc/src/irgen/debug/variables.test.ts index 2ace01a38c..35cffa32be 100644 --- a/packages/bugc/src/irgen/debug/variables.test.ts +++ b/packages/bugc/src/irgen/debug/variables.test.ts @@ -7,18 +7,13 @@ */ import { describe, it, expect } from "vitest"; import { bytesToHex, hexToBytes } from "ethereum-cryptography/utils"; -import { createMachineState } from "@ethdebug/evm"; +import { Executor, createMachineState } from "@ethdebug/evm"; import { dereference } from "@ethdebug/pointers"; -import type * as Format from "@ethdebug/format"; +import * as Format from "@ethdebug/format"; -import { parse } from "#parser"; -import { checkProgram } from "#typechecker"; -import { Type } from "#types"; +import { compile } from "#compiler"; 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; @@ -56,16 +51,22 @@ code { 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; +/** + * The pointer to an entry of a mapping, from the entry template that a + * pointer defines (with the mapping's slot and the key) + */ +function entryOf( + pointer: Format.Pointer, + template: string, + define: Record, +): Format.Pointer { + if (!Format.Pointer.Collection.isTemplates(pointer)) { + throw new Error("pointer defines no templates"); + } + return { + templates: pointer.templates, + in: { define, in: { template } }, + }; } /** Read each region named `name`, in order, as hex */ @@ -120,18 +121,113 @@ describe("storage string and bytes pointers", () => { 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([ + // A mapping's entry `u[7]` comes from its entry template + const entry = entryOf(pointerOf("u"), "entry", { slot: 7, key: 7 }); + expect(await readRegions(entry, state, "value-id")).toEqual([ "0".repeat(63) + "2", ]); - expect(await readRegions(entry, state, "name-data")).toEqual([string]); - expect(await readRegions(entry, state, "tag-data")).toEqual([bytes]); + expect(await readRegions(entry, state, "value-name-data")).toEqual([ + string, + ]); + expect(await readRegions(entry, state, "value-tag-data")).toEqual([ + bytes, + ]); }); } } }); + +/** + * A mapping's entry is at keccak256(key . slot). Its pointer must give a + * template for the entry, so that a debugger finds `players[k]` and its + * members, for any key, from the pointer alone. + */ +describe("storage mapping pointers", () => { + const program = `name MappingPointers; +define { struct Player { score: uint64; combo: uint32; name: string; }; } +storage { + [0] other: uint256; + [4] players: mapping; + [5] plays: mapping; + [6] grid: mapping>; +} +code { + let who = msg.data[0:32] as bytes32 as address; + let len = msg.data[32:64] as bytes32 as uint256; + players[who].score = len as uint64; + players[who].combo = 3; + players[who].name = msg.data[64:64 + len] as string; + plays[who] = plays[who] + 1; + grid[who][len] = 7; +}`; + + const players = [ + { key: "0x" + "a11ce".padStart(40, "0"), name: "alice" }, + { key: "0x" + "b0b".padStart(40, "0"), name: "bob" }, + { + key: "0x" + "ca201".padStart(40, "0"), + name: "carol, the unstoppable combo queen", + }, + ]; + + const word = (value: bigint | number) => + BigInt(value).toString(16).padStart(64, "0"); + + for (const level of levels) { + it(`reads alice's, bob's and carol's entries (level ${level})`, async () => { + const result = await compile({ + to: "bytecode", + source: program, + optimizer: { level }, + }); + if (!result.success) throw new Error("compile failed"); + const { bytecode } = result.value; + const executor = new Executor(); + await executor.deploy(bytesToHex(bytecode.create ?? bytecode.runtime)); + for (const { key, name } of players) { + const text = new TextEncoder().encode(name); + const data = word(BigInt(key)) + word(text.length) + bytesToHex(text); + const run = await executor.execute({ data }); + expect(run.success).toBe(true); + } + const state = createMachineState(executor); + const pointerOf = (identifier: string) => + ( + bytecode.runtimeProgram.context as Format.Program.Context.Variables + ).variables.find((v) => v.identifier === identifier)!.pointer!; + + for (const { key, name } of players) { + const text = new TextEncoder().encode(name); + const player = entryOf(pointerOf("players"), "entry", { + slot: 4, + key, + }); + expect(await readRegions(player, state, "value-score")).toEqual([ + word(text.length).slice(-16), + ]); + expect(await readRegions(player, state, "value-combo")).toEqual([ + "00000003", + ]); + expect(await readRegions(player, state, "value-name-data")).toEqual([ + bytesToHex(text), + ]); + + const plays = entryOf(pointerOf("plays"), "entry", { slot: 5, key }); + expect(await readRegions(plays, state, "value")).toEqual([word(1)]); + + // `grid[key]` is a mapping: its slot is the inner mapping's, + // and the inner template gives `grid[key][len]` + const grid = entryOf(pointerOf("grid"), "entry", { slot: 6, key }); + const cursor = await dereference(grid, { state }); + const [inner] = (await cursor.view(state)).regions; + expect(inner.name).toEqual("value"); + if (!("slot" in inner)) throw new Error("not a storage region"); + const cell = entryOf(pointerOf("grid"), "value-entry", { + slot: inner.slot.toHex(), + key: text.length, + }); + expect(await readRegions(cell, state, "value")).toEqual(["07"]); + } + }); + } +}); diff --git a/packages/bugc/src/irgen/debug/variables.ts b/packages/bugc/src/irgen/debug/variables.ts index 531f68f7b1..502f2a3582 100644 --- a/packages/bugc/src/irgen/debug/variables.ts +++ b/packages/bugc/src/irgen/debug/variables.ts @@ -66,6 +66,10 @@ function plus( * collection (a nested struct or an array) cannot be named as a whole. * 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`). + * + * A mapping's entries depend on a key, so each mapping in the pointer + * gets a template for its entry (see `generateMappingPointer`). The + * templates go in one `templates` collection around the whole pointer. */ export function generateStoragePointer( baseSlot: Format.Pointer.Expression, @@ -73,7 +77,22 @@ export function generateStoragePointer( byteOffset: number = 0, prefix: string = "", depth: number = 0, + entries?: Entries, ): Format.Pointer | undefined { + if (!entries) { + const templates: Format.Pointer.Templates = {}; + const pointer = generateStoragePointer( + baseSlot, + bugType, + byteOffset, + prefix, + depth, + { templates, scope: "" }, + ); + if (!pointer || Object.keys(templates).length === 0) return pointer; + return { templates, in: pointer }; + } + // For structs, generate a group pointer with each field if (Type.isStruct(bugType)) { const group: Format.Pointer[] = []; @@ -92,6 +111,7 @@ export function generateStoragePointer( fieldOffset, `${prefix}${fieldName}-`, depth, + entries, ); if (!fieldPointer) continue; @@ -149,6 +169,7 @@ export function generateStoragePointer( 0, prefix, depth + 1, + entries, ); if (elementPointer && Format.Pointer.isRegion(elementPointer)) { elementPointer = { ...elementPointer, name: `${prefix}element` }; @@ -181,17 +202,8 @@ export function generateStoragePointer( }; } - // For mappings, we can't represent them without keys - // Just return the base slot pointer with offset/length if (Type.isMapping(bugType)) { - const pointer: Format.Pointer = { - location: "storage", - slot: baseSlot, - }; - if (byteOffset > 0) { - pointer.offset = byteOffset; - } - return pointer; + return generateMappingPointer(baseSlot, bugType, prefix, entries); } if ( @@ -223,6 +235,54 @@ export function generateStoragePointer( return pointer; } +/** + * The entry templates of a pointer, and the path of the template being + * built (empty outside any template), which keeps template names unique + */ +interface Entries { + templates: Format.Pointer.Templates; + scope: string; +} + +/** + * A pointer for a storage mapping: a region at its slot, plus (in + * `entries`) a template for its entry, in the shape solc gives its + * `t_mapping` templates. The template expects `slot` (the mapping's + * slot) and `key`; the entry is at keccak256(key . slot), each a word. + * A value type there is a region named `value`; a struct's members are + * named `value-`, and so on, as at a variable's own slot. + * + * The template is named `entry` for a mapping variable, else after the + * mapping's region: `-entry` for the mapping member named `name`, + * or `value-entry` for a mapping in an entry (named inside the outer + * entry's template, so `value-value-entry` for a mapping two deep). + */ +function generateMappingPointer( + slot: Format.Pointer.Expression, + mapping: Type.Mapping, + prefix: string, + { templates, scope }: Entries, +): Format.Pointer { + const value = generateStoragePointer("slot", mapping.value, 0, "value-", 0, { + templates, + scope: `${scope}${prefix}`, + }); + if (value) { + templates[`${scope}${prefix}entry`] = { + expect: ["slot", "key"], + for: { + define: { + slot: { $keccak256: [{ $wordsized: "key" }, { $wordsized: "slot" }] }, + }, + in: Format.Pointer.isRegion(value) + ? { ...value, name: "value" } + : value, + }, + }; + } + return { location: "storage", slot }; +} + /** * A pointer for a storage string or dynamic `bytes`, by Solidity's * encoding, in the shape solc gives its `t_string_storage` template.