From c0ab7fad5385a0ee21141173f3c70226c4303977 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Tue, 6 Oct 2026 22:09:35 -0400 Subject: [PATCH] bugc: lay storage arrays out as Solidity does --- packages/bugc/CHANGELOG.md | 12 ++ .../bugc/examples/intermediate/arrays.bug | 18 +- packages/bugc/src/evmgen/behavioral.test.ts | 5 +- .../bugc/src/evmgen/constructor-array.test.ts | 4 +- .../evmgen/generation/instructions/storage.ts | 40 +++- .../src/evmgen/storage-array-layout.test.ts | 180 ++++++++++++++++ .../src/evmgen/storage-verification.test.ts | 150 ++------------ packages/bugc/src/irgen/debug/variables.ts | 192 +++++++----------- .../src/irgen/generate/expressions/array.ts | 79 ++++--- packages/bugc/src/irgen/generate/storage.ts | 182 ++++++++++------- packages/bugc/src/irgen/generator.test.ts | 5 +- packages/bugc/src/typechecker/layout.ts | 15 +- packages/bugc/src/types/index.ts | 3 + packages/bugc/src/types/storage.ts | 54 +++++ .../compiler/case-study-bug.mdx | 32 +++ 15 files changed, 576 insertions(+), 395 deletions(-) create mode 100644 packages/bugc/src/evmgen/storage-array-layout.test.ts create mode 100644 packages/bugc/src/types/storage.ts diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index f7b5d6634e..9a26307f00 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -28,6 +28,17 @@ support. Changes to the specification itself are tracked in the root ### Changed +- Storage arrays now have Solidity's layout. A fixed-size array is + inline: its elements start at its own slot, so `[0] a: array` takes slots 0 to 2, and a struct member that is a fixed-size array + takes all its slots. A dynamic array still keeps its length in its + slot and its elements from `keccak256(slot)`. In either, elements + narrower than 16 bytes now share a slot, as many as fit, from its + low-order end: an `array` takes two slots. Each storage + variable's pointer describes the same layout. Before, every array's + elements started at `keccak256(slot)`, one element to a slot, while + the pointer of a fixed-size array described it inline, and the pointer + of an array of narrow elements described them packed ([#364]). - An integer literal operand of an arithmetic or comparison operator now takes the type of the other operand when its value fits, so with `x: int8`, `x < 0`, `x == 1` and `-1 < x` compare as `int8`. A literal @@ -301,3 +312,4 @@ First publication. [#351]: https://github.com/ethdebug/format/pull/351 [#352]: https://github.com/ethdebug/format/pull/352 [#356]: https://github.com/ethdebug/format/pull/356 +[#364]: https://github.com/ethdebug/format/pull/364 diff --git a/packages/bugc/examples/intermediate/arrays.bug b/packages/bugc/examples/intermediate/arrays.bug index a70820d0e8..262fe83365 100644 --- a/packages/bugc/examples/intermediate/arrays.bug +++ b/packages/bugc/examples/intermediate/arrays.bug @@ -2,11 +2,11 @@ name ArraysAndLoops; storage { [0] numbers: array; - [1] sum: uint256; - [2] max: uint256; - [3] count: uint256; - [4] found: bool; - [5] searchValue: uint256; + [10] sum: uint256; + [11] max: uint256; + [12] count: uint256; + [13] found: bool; + [14] searchValue: uint256; } code { @@ -21,7 +21,7 @@ code { after: deploy variables: sum: - pointer: { location: storage, slot: 1 } + pointer: { location: storage, slot: 10 } value: 0 */ for (let i = 0; i < 10; i = i + 1) { @@ -78,21 +78,21 @@ code { fails: stack overflow during call - needs investigation variables: sum: - pointer: { location: storage, slot: 1 } + pointer: { location: storage, slot: 10 } value: 285 */ /*@test final-max fails: stack overflow during call - needs investigation variables: max: - pointer: { location: storage, slot: 2 } + pointer: { location: storage, slot: 11 } value: 81 */ /*@test final-count fails: stack overflow during call - needs investigation variables: count: - pointer: { location: storage, slot: 3 } + pointer: { location: storage, slot: 12 } value: 8 */ diff --git a/packages/bugc/src/evmgen/behavioral.test.ts b/packages/bugc/src/evmgen/behavioral.test.ts index 66913f8e72..3a51525183 100644 --- a/packages/bugc/src/evmgen/behavioral.test.ts +++ b/packages/bugc/src/evmgen/behavioral.test.ts @@ -1843,7 +1843,8 @@ code { o5 = m128[6]; }`; - // bugc gives each array element its own slot + // A fixed-size array is inline, and its narrow elements share a + // slot, from the low-order end (a[1] is its bytes 2 and 3) const arrays = `name NarrowArrays; storage { [0] a: array; [1] n: int256; [10] o0: int256; } create { n = -2 as int256; } @@ -1882,7 +1883,7 @@ code { a[1] = n as int16; o0 = a[1]; }`; optimizationLevel: level, }); expect(result.callSuccess).toBe(true); - expect(await result.getStorage(hash(pad(0n)) + 1n)).toBe(0xfffen); + expect(await result.getStorage(0n)).toBe(0xfffe0000n); expect(await result.getStorage(10n)).toBe(neg(2n)); }); } diff --git a/packages/bugc/src/evmgen/constructor-array.test.ts b/packages/bugc/src/evmgen/constructor-array.test.ts index af236f8f87..0f71ece5e1 100644 --- a/packages/bugc/src/evmgen/constructor-array.test.ts +++ b/packages/bugc/src/evmgen/constructor-array.test.ts @@ -51,11 +51,11 @@ code {} const createFunc = module.create!; const entry = createFunc.blocks.get("entry")!; - // Check that we have compute_slot instructions for array access + // A fixed-size array is inline: no keccak256 of its slot const computeSlotInstructions = entry.instructions.filter( (i) => i.kind === "compute_slot" && i.slotKind === "array", ); - expect(computeSlotInstructions.length).toBe(3); + expect(computeSlotInstructions.length).toBe(0); // Instructions are verified by checking write instructions below diff --git a/packages/bugc/src/evmgen/generation/instructions/storage.ts b/packages/bugc/src/evmgen/generation/instructions/storage.ts index c65c44dc5a..521bf47aca 100644 --- a/packages/bugc/src/evmgen/generation/instructions/storage.ts +++ b/packages/bugc/src/evmgen/generation/instructions/storage.ts @@ -108,6 +108,32 @@ function signExtend( ); } +/** Whether a storage offset is the constant 0 (or absent) */ +function isZero(offset: Ir.Value | undefined): boolean { + return !offset || (offset.kind === "const" && BigInt(offset.value) === 0n); +} + +/** + * Push a storage offset in bits: `offset * 8`. A storage array's + * element that shares its slot has an offset known only at run time. + */ +function bitOffset( + offset: Ir.Value | undefined, + debug: Ir.Instruction.Debug, +): Transition { + if (!offset || offset.kind === "const") { + const bytes = offset ? BigInt(offset.value as bigint) : 0n; + return pipe() + .then(PUSHn(bytes * 8n, { debug }), { as: "shift" }) + .done(); + } + return pipe() + .then(loadValue(offset, { debug }), { as: "value" }) + .then(PUSHn(3n, { debug }), { as: "shift" }) + .then(SHL({ debug }), { as: "shift" }) + .done(); +} + /** * Storage read: SLOAD with optional partial-slot extraction */ @@ -115,10 +141,9 @@ function generateStorageRead( inst: Ir.Instruction.Read, debug: Ir.Instruction.Debug, ): Transition { - const offset = inst.offset?.kind === "const" ? inst.offset.value : 0n; const length = inst.length?.kind === "const" ? inst.length.value : 32n; - if (offset === 0n && length === 32n) { + if (isZero(inst.offset) && length === 32n) { // Full slot read - simple SLOAD return pipe() .then(loadValue(inst.slot!, { debug }), { as: "key" }) @@ -136,7 +161,7 @@ function generateStorageRead( // Shift right by offset * 8 bits: storage writes count `offset` // from the low-order end of the slot - .then(PUSHn(BigInt(offset) * 8n, { debug }), { as: "shift" }) + .then(bitOffset(inst.offset, debug), { as: "shift" }) .then(SHR({ debug }), { as: "shiftedValue" }) .then(PUSHn(1n, { debug }), { as: "b" }) @@ -402,10 +427,9 @@ function generateStorageWrite( inst: Ir.Instruction.Write, debug: Ir.Instruction.Debug, ): Transition { - const offset = inst.offset?.kind === "const" ? inst.offset.value : 0n; const length = inst.length?.kind === "const" ? inst.length.value : 32n; - if (offset === 0n && length === 32n) { + if (isZero(inst.offset) && length === 32n) { // Full slot write - simple SSTORE return pipe() .then(loadValue(inst.value!, { debug }), { as: "value" }) @@ -430,9 +454,7 @@ function generateStorageWrite( .then(SUB({ debug }), { as: "lengthMask" }) // Shift mask to offset position - .then(PUSHn(BigInt(offset) * 8n, { debug }), { - as: "bitOffset", - }) + .then(bitOffset(inst.offset, debug), { as: "bitOffset" }) .then( rebrand<"bitOffset", "shift", "lengthMask", "value">({ 1: "shift", @@ -460,7 +482,7 @@ function generateStorageWrite( as: "a", }) .then(AND({ debug }), { as: "value" }) - .then(PUSHn(BigInt(offset) * 8n, { debug }), { as: "shift" }) + .then(bitOffset(inst.offset, debug), { as: "shift" }) .then(SHL({ debug }), { as: "shiftedValue" }) .then( diff --git a/packages/bugc/src/evmgen/storage-array-layout.test.ts b/packages/bugc/src/evmgen/storage-array-layout.test.ts new file mode 100644 index 0000000000..58da6be919 --- /dev/null +++ b/packages/bugc/src/evmgen/storage-array-layout.test.ts @@ -0,0 +1,180 @@ +/** + * Storage arrays have Solidity's layout. A fixed-size array is inline: + * its elements start at its own slot. A dynamic array keeps its length + * in its slot and its elements from keccak256(slot). Either way, an + * element narrower than 16 bytes shares a slot with its neighbors, as + * many as fit, from the low-order end; any other element starts a slot + * of its own and takes as many slots as its type needs. An array, as a + * struct member, starts a slot, and the next member starts the slot + * after it. Each storage variable's pointer must read the values the + * program wrote. + */ +import { describe, it, expect } from "vitest"; +import { keccak256 } from "ethereum-cryptography/keccak"; +import { bytesToHex } from "ethereum-cryptography/utils"; +import { createMachineState } from "@ethdebug/evm"; +import { dereference } from "@ethdebug/pointers"; +import type * as Format from "@ethdebug/format"; + +import { executeProgram } from "#test/evm/behavioral"; +import { traceLocals } from "#test/evm/locals"; + +const levels = [0, 1, 2, 3] as const; + +/** keccak256 of a slot number, as a dynamic array's first slot */ +function hashed(slot: bigint): bigint { + const word = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + word[31 - i] = Number((slot >> BigInt(8 * i)) & 0xffn); + } + return BigInt("0x" + bytesToHex(keccak256(word))); +} + +interface Case { + source: string; + /** Expected words, by slot */ + slots: [bigint, bigint][]; + /** Expected values, in order, of each variable's regions, by name */ + pointers?: Record>; +} + +const cases: Record = { + "a fixed-size array is inline": { + source: `storage { + [0] f: array; + [3] r: uint256; +} +code { f[0] = 10; f[2] = 30; r = f[0] + f[2]; }`, + slots: [ + [0n, 10n], + [1n, 0n], + [2n, 30n], + [3n, 40n], + ], + pointers: { f: { element: [10n, 0n, 30n] } }, + }, + "narrow elements share a slot, from the low-order end": { + source: `storage { + [0] a: array; + [2] r: uint256; +} +code { a[0] = 1; a[1] = 2; a[33] = 7; r = (a[1] as uint256) + (a[33] as uint256); }`, + slots: [ + [0n, 0x0201n], + [1n, 0x0700n], + [2n, 9n], + ], + pointers: { + a: { + element: Array.from({ length: 40 }, (_, i) => + i === 0 ? 1n : i === 1 ? 2n : i === 33 ? 7n : 0n, + ), + }, + }, + }, + "a fixed-size array of fixed-size arrays": { + source: `storage { + [0] m: array, 2>; + [6] r: uint256; +} +code { m[0][1] = 4; m[1][2] = 5; r = m[1][2]; }`, + slots: [ + [1n, 4n], + [5n, 5n], + [6n, 5n], + ], + pointers: { m: { element: [0n, 4n, 0n, 0n, 0n, 5n] } }, + }, + "an array in a struct takes its slots": { + source: `define { + struct S { x: uint8; arr: array; y: uint8; }; +} +storage { + [0] s: S; + [4] r: uint256; +} +code { s.x = 1; s.arr[1] = 2; s.y = 3; r = s.arr[1] + (s.y as uint256); }`, + slots: [ + [0n, 1n], + [1n, 0n], + [2n, 2n], + [3n, 3n], + [4n, 5n], + ], + pointers: { s: { x: [1n], "arr-element": [0n, 2n], y: [3n] } }, + }, + "a fixed-size array of structs": { + source: `define { + struct P { a: uint256; b: uint256; }; +} +storage { + [0] ps: array; + [4] r: uint256; +} +code { ps[1].a = 6; ps[1].b = 7; ps[0].b = 8; r = ps[1].b; }`, + slots: [ + [1n, 8n], + [2n, 6n], + [3n, 7n], + [4n, 7n], + ], + pointers: { ps: { a: [0n, 6n], b: [8n, 7n] } }, + }, + "a dynamic array's narrow elements share a slot": { + source: `storage { + [0] d: array; + [1] r: uint256; +} +code { d[0] = 3; d[1] = 5; r = d[1] as uint256; }`, + slots: [ + [hashed(0n), 0x00050003n], + [1n, 5n], + ], + }, +}; + +describe("storage array layout", () => { + for (const [name, { source, slots }] of Object.entries(cases)) { + for (const level of levels) { + it(`${name} (level ${level})`, async () => { + const result = await executeProgram(`name Layout;\n${source}`, { + calldata: "", + optimizationLevel: level, + }); + expect(result.callSuccess).toBe(true); + for (const [slot, value] of slots) { + expect(await result.getStorage(slot), `slot ${slot}`).toBe(value); + } + }); + } + } +}); + +describe("storage array pointers", () => { + for (const [name, { source, pointers }] of Object.entries(cases)) { + if (!pointers) continue; + it(name, async () => { + const { program, executor, steps } = await traceLocals( + `name Layout;\n${source}`, + ); + const state = createMachineState(executor, { + traceStep: steps[steps.length - 1], + }); + for (const [identifier, regions] of Object.entries(pointers)) { + const variable = ( + program.context as Format.Program.Context.Variables + ).variables.find((v) => v.identifier === identifier)!; + const cursor = await dereference(variable.pointer!, { state }); + const view = await cursor.view(state); + for (const [regionName, values] of Object.entries(regions)) { + const read = await Promise.all( + view.regions + .filter((region) => region.name === regionName) + .map(async (region) => (await view.read(region)).asUint()), + ); + expect(read, `${identifier}: ${regionName}`).toEqual(values); + } + } + }); + } +}); diff --git a/packages/bugc/src/evmgen/storage-verification.test.ts b/packages/bugc/src/evmgen/storage-verification.test.ts index 304e90f961..34ea8175af 100644 --- a/packages/bugc/src/evmgen/storage-verification.test.ts +++ b/packages/bugc/src/evmgen/storage-verification.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; -import { compile } from "#compiler"; +import { executeProgram } from "#test/evm/behavioral"; -describe.skip("Storage verification (requires local Ethereum node)", () => { +describe("Storage verification", () => { it("should store array values correctly in constructor", async () => { const source = ` name ConstructorArray; @@ -19,73 +19,15 @@ describe.skip("Storage verification (requires local Ethereum node)", () => { code {} `; - const result = await compile({ to: "bytecode", source }); - expect(result.success).toBe(true); + const result = await executeProgram(source); - if (!result.success) return; - - const { create: creationBytecode } = result.value.bytecode; - expect(creationBytecode).toBeDefined(); - - // Deploy and check storage - const deployResponse = await fetch("http://localhost:8545", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "eth_sendTransaction", - params: [ - { - from: "0xa0e20c11276b8ab803a7034d0945797afb4d060b", - data: "0x" + Buffer.from(creationBytecode!).toString("hex"), - gas: "0x100000", - }, - ], - id: 1, - }), - }); - - const deployResult = await deployResponse.json(); - const txHash = deployResult.result; - - // Get receipt to find contract address - const receiptResponse = await fetch("http://localhost:8545", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "eth_getTransactionReceipt", - params: [txHash], - id: 2, - }), - }); - - const receipt = await receiptResponse.json(); - const contractAddress = receipt.result.contractAddress; - - // Check storage slots - const expectedValues = [ - { slot: "0x0", value: 1005 }, - { slot: "0x1", value: 1006 }, - { slot: "0x2", value: 1007 }, - ]; - - for (const { slot, value } of expectedValues) { - const storageResponse = await fetch("http://localhost:8545", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "eth_getStorageAt", - params: [contractAddress, slot, "latest"], - id: 3, - }), - }); - - const storageResult = await storageResponse.json(); - const storedValue = BigInt(storageResult.result); - - expect(storedValue).toBe(BigInt(value)); + // A fixed-size array is inline: its elements start at its slot + for (const [slot, value] of [ + [0n, 1005n], + [1n, 1006n], + [2n, 1007n], + ]) { + expect(await result.getStorage(slot)).toBe(value); } }); @@ -108,72 +50,14 @@ describe.skip("Storage verification (requires local Ethereum node)", () => { code {} `; - const result = await compile({ to: "bytecode", source }); - expect(result.success).toBe(true); - - if (!result.success) return; - - const { create: creationBytecode } = result.value.bytecode; - - // Deploy - const deployResponse = await fetch("http://localhost:8545", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "eth_sendTransaction", - params: [ - { - from: "0xa0e20c11276b8ab803a7034d0945797afb4d060b", - data: "0x" + Buffer.from(creationBytecode!).toString("hex"), - gas: "0x100000", - }, - ], - id: 1, - }), - }); - - const deployResult = await deployResponse.json(); - const txHash = deployResult.result; - - // Get receipt - const receiptResponse = await fetch("http://localhost:8545", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "eth_getTransactionReceipt", - params: [txHash], - id: 2, - }), - }); - - const receipt = await receiptResponse.json(); - const contractAddress = receipt.result.contractAddress; - - // Check storage slots - const expectedValues = [ - { slot: "0x0", value: 100 }, - { slot: "0x1", value: 200 }, - { slot: "0x2", value: 300 }, - ]; - - for (const { slot, value } of expectedValues) { - const storageResponse = await fetch("http://localhost:8545", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "eth_getStorageAt", - params: [contractAddress, slot, "latest"], - id: 3, - }), - }); - - const storageResult = await storageResponse.json(); - const storedValue = BigInt(storageResult.result); + const result = await executeProgram(source); - expect(storedValue).toBe(BigInt(value)); + for (const [slot, value] of [ + [0n, 100n], + [1n, 200n], + [2n, 300n], + ]) { + expect(await result.getStorage(slot)).toBe(value); } }); }); diff --git a/packages/bugc/src/irgen/debug/variables.ts b/packages/bugc/src/irgen/debug/variables.ts index 59162be362..7859fa2c4b 100644 --- a/packages/bugc/src/irgen/debug/variables.ts +++ b/packages/bugc/src/irgen/debug/variables.ts @@ -7,7 +7,7 @@ import * as Format from "@ethdebug/format"; import type { State } from "../generate/state.js"; import { generatePointer, type VariableLocation } from "./pointers.js"; -import { Type } from "#types"; +import { Storage, Type } from "#types"; import { convertBugType } from "./types.js"; /** @@ -52,6 +52,15 @@ function getTypeSize(bugType: Type): number { return 32; } +/** A slot plus a number of slots */ +function plus( + slot: Format.Pointer.Expression, + slots: number, +): Format.Pointer.Expression { + if (slots === 0) return slot; + return typeof slot === "number" ? slot + slots : { $sum: [slot, slots] }; +} + /** * Generate a sophisticated pointer for a storage variable based on its type * @@ -61,10 +70,11 @@ function getTypeSize(bugType: Type): number { * `prefix` is that path (e.g. "ceo-" for the members of field `ceo`). */ function generateStoragePointer( - baseSlot: number, + baseSlot: Format.Pointer.Expression, bugType: Type, byteOffset: number = 0, prefix: string = "", + depth: number = 0, ): Format.Pointer | undefined { // For structs, generate a group pointer with each field if (Type.isStruct(bugType)) { @@ -75,7 +85,7 @@ function generateStoragePointer( if (!layout) continue; const absoluteOffset = byteOffset + layout.byteOffset; - const fieldSlot = baseSlot + Math.floor(absoluteOffset / 32); + const fieldSlot = plus(baseSlot, Math.floor(absoluteOffset / 32)); const fieldOffset = absoluteOffset % 32; const fieldPointer = generateStoragePointer( @@ -83,6 +93,7 @@ function generateStoragePointer( fieldType, fieldOffset, `${prefix}${fieldName}-`, + depth, ); if (!fieldPointer) continue; @@ -100,127 +111,76 @@ function generateStoragePointer( return { group }; } - // For arrays, generate a list pointer + // For arrays, generate a list pointer, by Solidity's layout (see + // `Storage`): a fixed-size array's elements start at its slot, a + // dynamic array's at keccak256 of its slot, where its length is if (Type.isArray(bugType)) { - const elementType = bugType.element; - const elementSize = getTypeSize(elementType); - - // Check if this is a fixed-size or dynamic array - if (bugType.size !== undefined) { - // Fixed-size array: elements stored sequentially from base slot - // For fixed arrays, elements are at baseSlot + floor((i * elementSize) / 32) - // with offset (i * elementSize) % 32 - const elementSlotExpression: Format.Pointer.Expression = - elementSize >= 32 - ? // Full slots: baseSlot + i * (elementSize / 32) - { - $sum: [baseSlot, { $product: ["i", elementSize / 32] }], - } - : // Packed elements: baseSlot + floor((i * elementSize) / 32) - { - $sum: [ - baseSlot, - { - $quotient: [{ $product: ["i", elementSize] }, 32], - }, - ], - }; - - const elementPointer: Format.Pointer = { + const element = bugType.element; + // Each nested list needs its own index variable + const index = depth === 0 ? "i" : `i${depth}`; + const first: Format.Pointer.Expression = + bugType.size === undefined + ? { $keccak256: [{ $wordsized: baseSlot }] } + : baseSlot; + + let elementPointer: Format.Pointer | undefined; + const perSlot = Storage.elementsPerSlot(element); + if (perSlot > 1) { + // Elements that share a slot, from its low-order end; a pointer's + // `offset` counts from the high-order end + const size = Storage.size(element)!; + elementPointer = { name: `${prefix}element`, location: "storage", - slot: elementSlotExpression, - }; - - // Add offset for packed elements - if (elementSize < 32) { - elementPointer.offset = { - $remainder: [{ $product: ["i", elementSize] }, 32], - }; - elementPointer.length = elementSize; - } - - // Recursively handle complex element types - const refinedPointer = - Type.isStruct(elementType) || Type.isArray(elementType) - ? generateStoragePointer(0, elementType, 0, prefix) - : elementPointer; - - return { - list: { - count: bugType.size, - each: "i", - is: refinedPointer || elementPointer, + slot: { $sum: [first, { $quotient: [index, perSlot] }] }, + offset: { + $difference: [ + 32 - size, + { $product: [{ $remainder: [index, perSlot] }, size] }, + ], }, + length: size, }; } else { - // Dynamic array: length at base slot, elements at keccak256(slot) + index - // Elements start at keccak256(baseSlot) - // Note: baseSlot must be wordsized for proper 32-byte keccak256 input - const elementSlotExpression: Format.Pointer.Expression = - elementSize >= 32 - ? // Full slots: keccak256(baseSlot) + i * (elementSize / 32) - { - $sum: [ - { $keccak256: [{ $wordsized: baseSlot }] }, - { $product: ["i", elementSize / 32] }, - ], - } - : // Packed elements: keccak256(baseSlot) + floor((i * elementSize) / 32) - { - $sum: [ - { $keccak256: [{ $wordsized: baseSlot }] }, - { - $quotient: [{ $product: ["i", elementSize] }, 32], - }, - ], - }; - - const elementPointer: Format.Pointer = { - name: `${prefix}element`, - location: "storage", - slot: elementSlotExpression, - }; - - // Add offset for packed elements - if (elementSize < 32) { - elementPointer.offset = { - $remainder: [{ $product: ["i", elementSize] }, 32], - }; - elementPointer.length = elementSize; - } - - // Recursively handle complex element types - const refinedPointer = - Type.isStruct(elementType) || Type.isArray(elementType) - ? generateStoragePointer(0, elementType, 0, prefix) - : elementPointer; - - // For dynamic arrays, we use a group to declare both the length region - // and the list of elements - // Note: "array-length" avoids conflict with Array.prototype.length - const lengthRegion: Format.Pointer = { - name: `${prefix}array-length`, - location: "storage", - slot: baseSlot, - }; - if (byteOffset > 0) { - lengthRegion.offset = byteOffset; + const stride = Storage.slots(element); + elementPointer = generateStoragePointer( + { + $sum: [first, stride === 1 ? index : { $product: [index, stride] }], + }, + element, + 0, + prefix, + depth + 1, + ); + if (elementPointer && Format.Pointer.isRegion(elementPointer)) { + elementPointer = { ...elementPointer, name: `${prefix}element` }; } - - return { - group: [ - lengthRegion, - { - list: { - count: { $read: `${prefix}array-length` }, - each: "i", - is: refinedPointer || elementPointer, - }, - }, - ], - }; } + if (!elementPointer) return undefined; + + const list: Format.Pointer = { + list: { + count: + bugType.size === undefined + ? { $read: `${prefix}array-length` } + : bugType.size, + each: index, + is: elementPointer, + }, + }; + if (bugType.size !== undefined) return list; + + // Note: "array-length" avoids conflict with Array.prototype.length + return { + group: [ + { + name: `${prefix}array-length`, + location: "storage", + slot: baseSlot, + }, + list, + ], + }; } // For mappings, we can't represent them without keys diff --git a/packages/bugc/src/irgen/generate/expressions/array.ts b/packages/bugc/src/irgen/generate/expressions/array.ts index f29b911297..ab885519fe 100644 --- a/packages/bugc/src/irgen/generate/expressions/array.ts +++ b/packages/bugc/src/irgen/generate/expressions/array.ts @@ -1,9 +1,10 @@ import type * as Ast from "#ast"; import * as Ir from "#ir"; -import { Type } from "#types"; +import { Storage, Type } from "#types"; import { Process } from "../process.js"; import type { Context } from "./context.js"; import { buildExpression } from "./expression.js"; +import { emitArrayElement } from "../storage.js"; /** * Build IR for an array expression. @@ -18,60 +19,52 @@ export function* buildArray( ): Process { switch (context.kind) { case "lvalue-storage": { - // Storage array assignment - expand to individual storage writes - // First, store the array length at the base slot - const lengthValue = Ir.Value.constant( - BigInt(expr.elements.length), + // Storage array assignment - expand to individual storage writes. + // A dynamic array keeps its length in its slot; a fixed-size + // array's elements start there + const slot = Ir.Value.constant( + BigInt(context.slot), Ir.Type.Scalar.uint256, ); - yield* Process.Instructions.emit({ - kind: "write", - location: "storage", - slot: Ir.Value.constant(BigInt(context.slot), Ir.Type.Scalar.uint256), - offset: Ir.Value.constant(0n, Ir.Type.Scalar.uint256), - length: Ir.Value.constant(32n, Ir.Type.Scalar.uint256), - value: lengthValue, - operationDebug: yield* Process.Debug.forAstNode(expr), - } as Ir.Instruction.Write); + if (!Type.isArray(context.type) || context.type.size === undefined) { + yield* Process.Instructions.emit({ + kind: "write", + location: "storage", + slot, + offset: Ir.Value.constant(0n, Ir.Type.Scalar.uint256), + length: Ir.Value.constant(32n, Ir.Type.Scalar.uint256), + value: Ir.Value.constant( + BigInt(expr.elements.length), + Ir.Type.Scalar.uint256, + ), + operationDebug: yield* Process.Debug.forAstNode(expr), + } as Ir.Instruction.Write); + } + const arrayType: Type.Array = Type.isArray(context.type) + ? context.type + : Type.array(Type.Elementary.uint(256)); // Then write each element for (let i = 0; i < expr.elements.length; i++) { - // Generate the value for this element const elementValue = yield* buildExpression(expr.elements[i], { kind: "rvalue", }); - - // Generate the index value - const indexValue = Ir.Value.constant(BigInt(i), Ir.Type.Scalar.uint256); - - // Compute the first slot for the array - const firstSlotTemp = yield* Process.Variables.newTemp(); - yield* Process.Instructions.emit( - Ir.Instruction.ComputeSlot.array( - Ir.Value.constant(BigInt(context.slot), Ir.Type.Scalar.uint256), - firstSlotTemp, - yield* Process.Debug.forAstNode(expr), - ), + const element = yield* emitArrayElement( + arrayType, + slot, + Ir.Value.constant(BigInt(i), Ir.Type.Scalar.uint256), + expr, ); - - // Add the index to get the actual element slot - const slotTemp = yield* Process.Variables.newTemp(); - yield* Process.Instructions.emit({ - kind: "binary", - op: "add", - left: Ir.Value.temp(firstSlotTemp, Ir.Type.Scalar.uint256), - right: indexValue, - dest: slotTemp, - operationDebug: yield* Process.Debug.forAstNode(expr), - } as Ir.Instruction.BinaryOp); - - // Write to storage yield* Process.Instructions.emit({ kind: "write", location: "storage", - slot: Ir.Value.temp(slotTemp, Ir.Type.Scalar.uint256), - offset: Ir.Value.constant(0n, Ir.Type.Scalar.uint256), - length: Ir.Value.constant(32n, Ir.Type.Scalar.uint256), + slot: element.slot, + offset: + element.offset ?? Ir.Value.constant(0n, Ir.Type.Scalar.uint256), + length: Ir.Value.constant( + BigInt(Storage.size(arrayType.element) ?? 32), + Ir.Type.Scalar.uint256, + ), value: elementValue, operationDebug: yield* Process.Debug.forAstNode(expr.elements[i]), } as Ir.Instruction.Write); diff --git a/packages/bugc/src/irgen/generate/storage.ts b/packages/bugc/src/irgen/generate/storage.ts index 83cea74561..2508628c4b 100644 --- a/packages/bugc/src/irgen/generate/storage.ts +++ b/packages/bugc/src/irgen/generate/storage.ts @@ -1,7 +1,7 @@ import * as Ast from "#ast"; import * as Ir from "#ir"; import { Severity } from "#result"; -import { Type } from "#types"; +import { Storage, Type } from "#types"; import { Error as IrgenError } from "#irgen/errors"; import { fromBugType } from "#irgen/type"; @@ -152,6 +152,82 @@ export function* emitStorageVariableStore( } as Ir.Instruction.Write); } +/** + * Emit the slot of element `index` of the storage array at `slot` (see + * `Storage`), and, for an element that shares its slot, its byte offset + * from the slot's low-order end. A fixed-size array reverts on an index + * out of bounds. (A dynamic array in storage is not checked.) + */ +export function* emitArrayElement( + array: Type.Array, + slot: Ir.Value, + index: Ir.Value, + node: Ast.Node | undefined, +): Process<{ slot: Ir.Value; offset?: Ir.Value }> { + if (array.size !== undefined) { + yield* emitBoundsCheck( + "lt", + index, + Ir.Value.constant(BigInt(array.size), Ir.Type.Scalar.uint256), + node, + ); + } + + const debug = node ? yield* Process.Debug.forAstNode(node) : {}; + const binary = function* ( + op: "add" | "mul" | "div" | "mod", + left: Ir.Value, + right: Ir.Value | number, + ): Process { + const dest = yield* Process.Variables.newTemp(); + yield* Process.Instructions.emit({ + kind: "binary", + op, + left, + right: + typeof right === "number" + ? Ir.Value.constant(BigInt(right), Ir.Type.Scalar.uint256) + : right, + dest, + operationDebug: debug, + } as Ir.Instruction.BinaryOp); + return Ir.Value.temp(dest, Ir.Type.Scalar.uint256); + }; + + // A fixed-size array's elements start at its slot; a dynamic array's + // at keccak256 of its slot + let first = slot; + if (array.size === undefined) { + const dest = yield* Process.Variables.newTemp(); + yield* Process.Instructions.emit({ + kind: "compute_slot", + slotKind: "array", + base: slot, + dest, + operationDebug: debug, + } as Ir.Instruction.ComputeSlot); + first = Ir.Value.temp(dest, Ir.Type.Scalar.uint256); + } + + const perSlot = Storage.elementsPerSlot(array.element); + if (perSlot > 1) { + const position = yield* binary("mod", index, perSlot); + return { + slot: yield* binary("add", first, yield* binary("div", index, perSlot)), + offset: yield* binary("mul", position, Storage.size(array.element)!), + }; + } + + const stride = Storage.slots(array.element); + return { + slot: yield* binary( + "add", + first, + stride === 1 ? index : yield* binary("mul", index, stride), + ), + }; +} + /** * Emit a storage chain load */ @@ -170,9 +246,12 @@ export function* emitStorageChainLoad( // Track the Bug type for semantic information let currentOrigin = bugType; + // An array element's byte offset in its slot, if it shares the slot + let elementOffset: Ir.Value | undefined; // Process each access in the chain for (const access of chain.accesses) { + elementOffset = undefined; if (access.kind === "index" && access.key) { // For mapping/array access const tempId = yield* Process.Variables.newTemp(); @@ -193,41 +272,16 @@ export function* emitStorageChainLoad( // Update to the value type currentOrigin = currentOrigin.value; } else if (currentOrigin && Type.isArray(currentOrigin)) { - // A fixed-size array reverts on an index out of bounds. (A - // dynamic array in storage grows when written past its end.) - if (currentOrigin.size !== undefined) { - yield* emitBoundsCheck( - "lt", - access.key, - Ir.Value.constant( - BigInt(currentOrigin.size), - Ir.Type.Scalar.uint256, - ), - node, - ); - } - - // Array access - first compute the array's first slot (hash of base) - const firstSlotTempId = yield* Process.Variables.newTemp(); - yield* Process.Instructions.emit({ - kind: "compute_slot", - slotKind: "array", - base: currentSlot, - dest: firstSlotTempId, - operationDebug: node ? yield* Process.Debug.forAstNode(node) : {}, - } as Ir.Instruction.ComputeSlot); - - // Then add the index to get the actual element slot - yield* Process.Instructions.emit({ - kind: "binary", - op: "add", - left: Ir.Value.temp(firstSlotTempId, Ir.Type.Scalar.uint256), - right: access.key, - dest: tempId, - operationDebug: node ? yield* Process.Debug.forAstNode(node) : {}, - } as Ir.Instruction.BinaryOp); - // Update to the element type + const element = yield* emitArrayElement( + currentOrigin, + currentSlot, + access.key, + node, + ); + elementOffset = element.offset; + currentSlot = element.slot; currentOrigin = currentOrigin.element; + continue; } currentSlot = Ir.Value.temp(tempId, Ir.Type.Scalar.uint256); @@ -289,13 +343,19 @@ export function* emitStorageChainLoad( } } + if (elementOffset && currentOrigin) { + fieldSize = Storage.size(currentOrigin) ?? 32; + } + // Generate the final read instruction using new unified format const loadTempId = yield* Process.Variables.newTemp(); yield* Process.Instructions.emit({ kind: "read", location: "storage", slot: currentSlot, - offset: Ir.Value.constant(BigInt(byteOffset), Ir.Type.Scalar.uint256), + offset: + elementOffset ?? + Ir.Value.constant(BigInt(byteOffset), Ir.Type.Scalar.uint256), length: Ir.Value.constant(BigInt(fieldSize), Ir.Type.Scalar.uint256), type: valueType, dest: loadTempId, @@ -328,9 +388,12 @@ export function* emitStorageChainStore( Ir.Type.Scalar.uint256, ); let currentOrigin = bugType; + // An array element's byte offset in its slot, if it shares the slot + let elementOffset: Ir.Value | undefined; // Process each access in the chain for (const access of chain.accesses) { + elementOffset = undefined; if (access.kind === "index" && access.key) { // For mapping/array access if (currentOrigin && Type.isMapping(currentOrigin)) { @@ -349,41 +412,14 @@ export function* emitStorageChainStore( currentSlot = Ir.Value.temp(slotTemp, Ir.Type.Scalar.uint256); currentOrigin = currentOrigin.value; } else if (currentOrigin && Type.isArray(currentOrigin)) { - // A fixed-size array reverts on an index out of bounds. (A - // dynamic array in storage grows when written past its end.) - if (currentOrigin.size !== undefined) { - yield* emitBoundsCheck( - "lt", - access.key, - Ir.Value.constant( - BigInt(currentOrigin.size), - Ir.Type.Scalar.uint256, - ), - node, - ); - } - - // Array access - first compute the array's first slot (hash of base) - const firstSlotTemp = yield* Process.Variables.newTemp(); - yield* Process.Instructions.emit({ - kind: "compute_slot", - slotKind: "array", - base: currentSlot, - dest: firstSlotTemp, - operationDebug: node ? yield* Process.Debug.forAstNode(node) : {}, - }); - - // Then add the index to get the actual element slot - const slotTemp = yield* Process.Variables.newTemp(); - yield* Process.Instructions.emit({ - kind: "binary", - op: "add", - left: Ir.Value.temp(firstSlotTemp, Ir.Type.Scalar.uint256), - right: access.key, - dest: slotTemp, - operationDebug: node ? yield* Process.Debug.forAstNode(node) : {}, - }); - currentSlot = Ir.Value.temp(slotTemp, Ir.Type.Scalar.uint256); + const element = yield* emitArrayElement( + currentOrigin, + currentSlot, + access.key, + node, + ); + elementOffset = element.offset; + currentSlot = element.slot; currentOrigin = currentOrigin.element; } } else if (access.kind === "member" && access.fieldName) { @@ -458,7 +494,9 @@ export function* emitStorageChainStore( kind: "write", location: "storage", slot: currentSlot, - offset: Ir.Value.constant(BigInt(byteOffset), Ir.Type.Scalar.uint256), + offset: + elementOffset ?? + Ir.Value.constant(BigInt(byteOffset), Ir.Type.Scalar.uint256), length: Ir.Value.constant(BigInt(actualFieldSize), Ir.Type.Scalar.uint256), value, operationDebug: node ? yield* Process.Debug.forAstNode(node) : {}, diff --git a/packages/bugc/src/irgen/generator.test.ts b/packages/bugc/src/irgen/generator.test.ts index bd70abb377..6da87e3dce 100644 --- a/packages/bugc/src/irgen/generator.test.ts +++ b/packages/bugc/src/irgen/generator.test.ts @@ -730,8 +730,9 @@ describe("generateModule", () => { } expect(hasMappingSlot).toBe(true); - // Both fixed and dynamic arrays now use compute_slot with kind="array" for proper storage layout - expect(hasArraySlot).toBe(true); + // A fixed-size array is inline: its elements start at its own + // slot, with no keccak256 of it + expect(hasArraySlot).toBe(false); // We now generate "add" instructions to compute array element slots expect(hasBinaryAdd).toBe(true); expect(hasStorageDynamic).toBe(true); diff --git a/packages/bugc/src/typechecker/layout.ts b/packages/bugc/src/typechecker/layout.ts index 428e44b7a7..7719f85b3a 100644 --- a/packages/bugc/src/typechecker/layout.ts +++ b/packages/bugc/src/typechecker/layout.ts @@ -1,4 +1,4 @@ -import { Type } from "../types/index.js"; +import { Storage, Type } from "../types/index.js"; /** * Compute storage layout for a struct's fields. @@ -25,13 +25,14 @@ export function computeStructLayout( currentSlotUsed = 0; } - layout.set(fieldName, { - byteOffset: currentSlotOffset, - size: SLOT_SIZE, // Dynamic types use full slot for reference - }); + // A fixed-size array is inline, so it takes all its slots + const size = Type.isArray(fieldType) + ? Storage.slots(fieldType) * SLOT_SIZE + : SLOT_SIZE; + layout.set(fieldName, { byteOffset: currentSlotOffset, size }); - // Move to next slot - currentSlotOffset += SLOT_SIZE; + // Move to the slot after it + currentSlotOffset += size; currentSlotUsed = 0; } else { // For non-dynamic types, try to pack them diff --git a/packages/bugc/src/types/index.ts b/packages/bugc/src/types/index.ts index 9a1e43df9d..ad4bf0205e 100644 --- a/packages/bugc/src/types/index.ts +++ b/packages/bugc/src/types/index.ts @@ -16,5 +16,8 @@ export { mergeBindings, } from "./spec.js"; +// Storage layout +export * as Storage from "./storage.js"; + // Export analysis tools export * as Analysis from "./analysis/index.js"; diff --git a/packages/bugc/src/types/storage.ts b/packages/bugc/src/types/storage.ts new file mode 100644 index 0000000000..a7bcfd9e7a --- /dev/null +++ b/packages/bugc/src/types/storage.ts @@ -0,0 +1,54 @@ +/** + * Storage layout, as Solidity lays storage out. + * + * A value narrower than a slot takes only its own bytes, and values + * that fit share a slot, from its low-order end. An array, a struct, a + * mapping, a string or dynamic bytes starts a slot of its own, and what + * follows it starts the next slot. A fixed-size array is inline: its + * elements start at its own slot. A dynamic array keeps its length in + * its slot, and its elements start at keccak256(slot). + */ +import { Type } from "./spec.js"; + +/** The bytes a value of an elementary type takes, if it has a size */ +export function size(type: Type): number | undefined { + if (!Type.isElementary(type)) return undefined; + switch (type.kind) { + case "uint": + case "int": + return Math.ceil(type.bits / 8); + case "address": + return 20; + case "bool": + return 1; + case "bytes": + return type.size; + default: + return undefined; + } +} + +/** How many elements of an array of `element` share each slot */ +export function elementsPerSlot(element: Type): number { + const bytes = size(element); + return bytes === undefined ? 1 : Math.floor(32 / bytes); +} + +/** How many slots a value of the type takes */ +export function slots(type: Type): number { + if (Type.isArray(type)) { + if (type.size === undefined) return 1; + const perSlot = elementsPerSlot(type.element); + return perSlot > 1 + ? Math.ceil(type.size / perSlot) + : type.size * slots(type.element); + } + if (Type.isStruct(type)) { + let end = 0; + for (const { byteOffset, size } of type.layout.values()) { + end = Math.max(end, byteOffset + size); + } + return Math.max(1, Math.ceil(end / 32)); + } + return 1; +} diff --git a/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx b/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx index 749054fec2..519ec7a507 100644 --- a/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx +++ b/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx @@ -230,6 +230,38 @@ For structs, BUG generates group pointers with field offsets: }`} +### Storage arrays + +BUG lays storage arrays out as Solidity does. A fixed-size array is +inline: its elements start at its own slot, and a struct member or the +next storage variable must leave room for all of its slots. A dynamic +array keeps its length in its slot, and its elements start at +`keccak256(slot)`. Either way, elements narrower than 16 bytes share a +slot, as many as fit, from its low-order end; any other element starts +a slot of its own. + + + {`{ + "list": { + "count": 40, + "each": "i", + "is": { + "name": "element", + "location": "storage", + "slot": { "$sum": [0, { "$quotient": ["i", 32] }] }, + "offset": { + "$difference": [31, { "$product": [{ "$remainder": ["i", 32] }, 1] }] + }, + "length": 1 + } + } +}`} + + ### Dynamic arrays For dynamic arrays, BUG generates pointers with keccak256 expressions: