From 92a8a42db4dc58d89d78cd0bdde9dde54e5e1788 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Fri, 9 Oct 2026 01:08:21 -0400 Subject: [PATCH 1/6] bugc: give a nested struct in storage all its slots --- packages/bugc/src/typechecker/layout.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/bugc/src/typechecker/layout.ts b/packages/bugc/src/typechecker/layout.ts index 7719f85b3a..1b457fc90f 100644 --- a/packages/bugc/src/typechecker/layout.ts +++ b/packages/bugc/src/typechecker/layout.ts @@ -25,10 +25,12 @@ export function computeStructLayout( currentSlotUsed = 0; } - // A fixed-size array is inline, so it takes all its slots - const size = Type.isArray(fieldType) - ? Storage.slots(fieldType) * SLOT_SIZE - : SLOT_SIZE; + // A fixed-size array or a struct is inline, so it takes all its + // slots + const size = + Type.isArray(fieldType) || Type.isStruct(fieldType) + ? Storage.slots(fieldType) * SLOT_SIZE + : SLOT_SIZE; layout.set(fieldName, { byteOffset: currentSlotOffset, size }); // Move to the slot after it From 2840b9025704befc2f567b84cfaf69d25110a6f3 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Fri, 9 Oct 2026 01:08:24 -0400 Subject: [PATCH 2/6] bugc: copy structs and strings between storage and memory --- .../evmgen/generation/instructions/storage.ts | 230 ++++++++++++-- .../bugc/src/evmgen/struct-copies.test.ts | 233 ++++++++++++++ .../irgen/generate/expressions/identifier.ts | 12 +- packages/bugc/src/irgen/generate/storage.ts | 284 ++++++++++++++---- .../bugc/src/irgen/generator.error.test.ts | 55 +++- .../src/optimizer/steps/read-write-merging.ts | 6 + 6 files changed, 731 insertions(+), 89 deletions(-) create mode 100644 packages/bugc/src/evmgen/struct-copies.test.ts diff --git a/packages/bugc/src/evmgen/generation/instructions/storage.ts b/packages/bugc/src/evmgen/generation/instructions/storage.ts index 0b01ddc99d..53462201f3 100644 --- a/packages/bugc/src/evmgen/generation/instructions/storage.ts +++ b/packages/bugc/src/evmgen/generation/instructions/storage.ts @@ -5,6 +5,7 @@ import { Type as BugType } from "#types"; import type { State } from "#evmgen/state"; import { type Transition, rebrand, pipe, operations } from "#evmgen/operations"; import { calculateSize } from "#evmgen/serialize"; +import { Memory } from "#evmgen/analysis"; import { loadValue, storeValueIfNeeded } from "../values/index.js"; import { generateCastSteps } from "./cast.js"; @@ -146,6 +147,10 @@ function generateStorageRead( ): Transition { const length = inst.length?.kind === "const" ? inst.length.value : 32n; + if (isMemoryBytes(inst.type)) { + return generateBytesStorageRead(inst, debug); + } + if (isZero(inst.offset) && length === 32n) { // Full slot read - simple SLOAD return pipe() @@ -528,33 +533,11 @@ type Step = (state: State) => State; type Op = (options?: { debug: Ir.Instruction.Debug }) => Step; /** - * Store a string or `bytes` from memory as Solidity encodes it in - * storage. Up to 31 bytes go in the slot itself, left-aligned, with - * length * 2 in the low byte. Longer data puts length * 2 + 1 in the - * slot and the data in the slots from keccak256(slot). The bytes - * after the end of the data, in its last word, are stored as zero. - * - * [slot, ptr] DUP2 MLOAD - * [len, ...] PUSH1 31 DUP2 GT PUSH2 long JUMPI - * DUP3 PUSH1 32 ADD MLOAD DUP2 - * DUP2 DUP1 ADD OR DUP3 SSTORE PUSH2 end JUMP - * long: JUMPDEST DUP1 DUP1 ADD PUSH1 1 ADD DUP3 SSTORE - * DUP2 PUSH0 MSTORE PUSH1 32 PUSH0 KECCAK256 - * DUP4 PUSH1 32 ADD DUP3 DUP2 ADD - * loop: JUMPDEST [end, src, dataSlot, len, ...] - * DUP2 MLOAD DUP3 DUP3 SUB DUP4 SSTORE - * SWAP2 PUSH1 1 ADD SWAP2 SWAP1 PUSH1 32 ADD SWAP1 - * DUP2 DUP2 GT PUSH2 loop JUMPI POP POP POP - * end: JUMPDEST POP POP POP - * - * `` takes [k, word] to the word with all but its first k bytes - * cleared, when k < 32: it shifts the word right, then left, by - * (k < 32) * (256 - 8k) bits. + * Untyped steps, for routines with loops (whose stack the typed + * `pipe` cannot follow): an op by name, a push, a push of a jump + * target patched to the offset of a label, and a label's JUMPDEST */ -function generateBytesStorageWrite( - inst: Ir.Instruction.Write, - debug: Ir.Instruction.Debug, -): Transition { +function rawSteps(debug: Ir.Instruction.Debug) { const raw = operations as unknown as Record; const op = (name: string): Step => @@ -564,8 +547,6 @@ function generateBytesStorageWrite( (value: bigint): Step => (state) => (operations.PUSHn(value, { debug }) as unknown as Step)(state); - - // Push a jump target, patched to the offset of `label` const target = (label: string): Step => (state) => { @@ -594,6 +575,39 @@ function generateBytesStorageWrite( // [n, ...] to [(n + 31) >> 5, ...]: the words n bytes need const words: Step[] = [push(31n), op("ADD"), push(5n), op("SHR")]; + return { op, push, target, mark, words }; +} + +/** + * Store a string or `bytes` from memory as Solidity encodes it in + * storage. Up to 31 bytes go in the slot itself, left-aligned, with + * length * 2 in the low byte. Longer data puts length * 2 + 1 in the + * slot and the data in the slots from keccak256(slot). The bytes + * after the end of the data, in its last word, are stored as zero. + * + * [slot, ptr] DUP2 MLOAD + * [len, ...] PUSH1 31 DUP2 GT PUSH2 long JUMPI + * DUP3 PUSH1 32 ADD MLOAD DUP2 + * DUP2 DUP1 ADD OR DUP3 SSTORE PUSH2 end JUMP + * long: JUMPDEST DUP1 DUP1 ADD PUSH1 1 ADD DUP3 SSTORE + * DUP2 PUSH0 MSTORE PUSH1 32 PUSH0 KECCAK256 + * DUP4 PUSH1 32 ADD DUP3 DUP2 ADD + * loop: JUMPDEST [end, src, dataSlot, len, ...] + * DUP2 MLOAD DUP3 DUP3 SUB DUP4 SSTORE + * SWAP2 PUSH1 1 ADD SWAP2 SWAP1 PUSH1 32 ADD SWAP1 + * DUP2 DUP2 GT PUSH2 loop JUMPI POP POP POP + * end: JUMPDEST POP POP POP + * + * `` takes [k, word] to the word with all but its first k bytes + * cleared, when k < 32: it shifts the word right, then left, by + * (k < 32) * (256 - 8k) bits. + */ +function generateBytesStorageWrite( + inst: Ir.Instruction.Write, + debug: Ir.Instruction.Debug, +): Transition { + const { op, push, target, mark, words } = rawSteps(debug); + const mask: Step[] = [ op("DUP1"), push(3n), @@ -769,3 +783,163 @@ function generateBytesStorageWrite( return steps.reduce>((current, step) => step(current), loaded); }) as unknown as Transition; } + +/** + * Copy a string or `bytes` from storage, as Solidity encodes it there + * (see `generateBytesStorageWrite`), into new memory: a length word, + * then the data, in whole words. Leaves the copy's address. + * + * [slot] DUP1 SLOAD DUP1 PUSH1 1 AND PUSH2 long JUMPI + * [word, slot] DUP1 PUSH1 0xff AND PUSH1 1 SHR PUSH1 64 + * SWAP1 DUP2 MSTORE SWAP1 PUSH1 0xff NOT AND + * DUP2 PUSH1 32 ADD MSTORE PUSH2 end JUMP + * long: JUMPDEST PUSH1 1 SHR DUP1 PUSH1 5 SHL + * PUSH1 32 ADD DUP2 DUP2 MSTORE + * DUP4 PUSH0 MSTORE PUSH1 32 PUSH0 KECCAK256 + * DUP2 PUSH1 32 ADD DUP4 PUSH1 5 SHL DUP2 ADD + * loop: JUMPDEST [end, dst, dataSlot, ptr, len, slot] + * DUP1 DUP3 LT ISZERO PUSH2 done JUMPI + * DUP3 SLOAD DUP3 MSTORE + * SWAP2 PUSH1 1 ADD SWAP2 SWAP1 PUSH1 32 ADD SWAP1 + * PUSH2 loop JUMP + * done: JUMPDEST POP POP POP SWAP1 POP + * end: JUMPDEST SWAP1 POP [ptr, slot] to [ptr] + * + * `` takes [size] to [ptr], moving the free memory pointer past + * `size` bytes. The data's last word holds zero past its end, as the + * write stores it. + */ +function generateBytesStorageRead( + inst: Ir.Instruction.Read, + debug: Ir.Instruction.Debug, +): Transition { + const { op, push, target, mark, words } = rawSteps(debug); + const fmp = BigInt(Memory.regions.FREE_MEMORY_POINTER); + + // [size, ...] to [ptr, ...] + const alloc: Step[] = [ + push(fmp), + op("MLOAD"), + op("SWAP1"), + op("DUP2"), + op("ADD"), + push(fmp), + op("MSTORE"), + ]; + + return ((state: State): State => { + const id = state.nextId; + const long = `$bytes_read_long_${id}`; + const loop = `$bytes_read_loop_${id}`; + const done = `$bytes_read_done_${id}`; + const end = `$bytes_read_end_${id}`; + + const loaded = pipe() + .then(loadValue(inst.slot!, { debug }), { as: "key" }) + .done()({ ...state, nextId: id + 1 }) as State; + + const steps: Step[] = [ + op("DUP1"), + op("SLOAD"), + op("DUP1"), + push(1n), + op("AND"), + target(long), + op("JUMPI"), + + // Short: the length is the low byte / 2, the data the rest + op("DUP1"), + push(0xffn), + op("AND"), + push(1n), + op("SHR"), + push(64n), + ...alloc, + op("SWAP1"), + op("DUP2"), + op("MSTORE"), + op("SWAP1"), + push(0xffn), + op("NOT"), + op("AND"), + op("DUP2"), + push(32n), + op("ADD"), + op("MSTORE"), + target(end), + op("JUMP"), + + // Long: the length is the word / 2; the data from keccak256(slot) + mark(long), + push(1n), + op("SHR"), + op("DUP1"), + ...words, + push(5n), + op("SHL"), + push(32n), + op("ADD"), + ...alloc, + op("DUP2"), + op("DUP2"), + op("MSTORE"), + op("DUP3"), + push(0n), + op("MSTORE"), + push(32n), + push(0n), + op("KECCAK256"), + op("DUP2"), + push(32n), + op("ADD"), + op("DUP4"), + ...words, + push(5n), + op("SHL"), + op("DUP2"), + op("ADD"), + + mark(loop), + op("DUP1"), + op("DUP3"), + op("LT"), + op("ISZERO"), + target(done), + op("JUMPI"), + op("DUP3"), + op("SLOAD"), + op("DUP3"), + op("MSTORE"), + op("SWAP2"), + push(1n), + op("ADD"), + op("SWAP2"), + op("SWAP1"), + push(32n), + op("ADD"), + op("SWAP1"), + target(loop), + op("JUMP"), + + mark(done), + op("POP"), + op("POP"), + op("POP"), + op("SWAP1"), + op("POP"), + + // Both ways reach here with [ptr, slot] + mark(end), + op("SWAP1"), + op("POP"), + ]; + + const copied = steps.reduce>( + (current, step) => step(current), + loaded, + ); + return (storeValueIfNeeded(inst.dest, { debug }) as unknown as Step)( + copied, + ); + }) as unknown as Transition; +} diff --git a/packages/bugc/src/evmgen/struct-copies.test.ts b/packages/bugc/src/evmgen/struct-copies.test.ts new file mode 100644 index 0000000000..0b47736118 --- /dev/null +++ b/packages/bugc/src/evmgen/struct-copies.test.ts @@ -0,0 +1,233 @@ +import { describe, it, expect } from "vitest"; +import { keccak256 } from "ethereum-cryptography/keccak"; +import { bytesToHex, hexToBytes } from "ethereum-cryptography/utils"; + +import { + executeProgram, + type ExecuteProgramResult, +} from "#test/evm/behavioral"; + +const levels = [0, 1, 2, 3] as const; + +const alphabet = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; +const text = (length: number, from: number = 0) => + Array.from({ length }, (_, i) => alphabet[(i + from) % alphabet.length]).join( + "", + ); + +const word = (n: bigint) => hexToBytes(n.toString(16).padStart(64, "0")); +const hash = (bytes: Uint8Array) => BigInt("0x" + bytesToHex(keccak256(bytes))); + +/** The slot of a mapping's value: keccak256(key . slot) */ +const mappingSlot = (slot: bigint, key: bigint) => + hash(new Uint8Array([...word(key), ...word(slot)])); + +/** The sender of every transaction the executor sends */ +const sender = 1n; + +/** + * Check that storage at `slot` holds `data` as Solidity encodes a + * string: up to 31 bytes in the slot itself, left-aligned, with + * length * 2 in the low byte; else length * 2 + 1 in the slot and the + * data from keccak256(slot). + */ +async function expectEncoded( + result: ExecuteProgramResult, + slot: bigint, + value: string, +) { + const data = new TextEncoder().encode(value); + const length = BigInt(data.length); + const padded = new Uint8Array(Math.ceil(data.length / 32) * 32); + padded.set(data); + const dataWord = (i: number) => + BigInt("0x" + (bytesToHex(padded.slice(i * 32, i * 32 + 32)) || "0")); + + if (length < 32n) { + expect(await result.getStorage(slot)).toBe(dataWord(0) | (length * 2n)); + return; + } + + expect(await result.getStorage(slot)).toBe(length * 2n + 1n); + const base = hash(word(slot)); + for (let i = 0; i < padded.length / 32; i++) { + expect(await result.getStorage(base + BigInt(i))).toBe(dataWord(i)); + } +} + +/** + * The words of storage a `Player` at `base` may use: its own slots, and + * the first data slots of a string in any of them + */ +async function playerStorage( + result: ExecuteProgramResult, + base: bigint, +): Promise { + const slots: bigint[] = []; + for (let i = 0n; i < 7n; i++) { + slots.push(base + i); + const data = hash(word(base + i)); + for (let j = 0n; j < 4n; j++) slots.push(data + j); + } + return Promise.all(slots.map((slot) => result.getStorage(slot))); +} + +const define = `define { + struct Stats { best: uint32; label: string; }; + struct Player { + score: uint64; + combo: uint32; + plays: uint32; + name: string; + lastBlock: uint64; + stats: Stats; + tag: string; + hits: uint8; + }; +} +storage { + [0] total: uint128; + [4] players: mapping; +}`; + +/** Set every field of the sender's `Player`, with a name of `name` */ +const create = (name: string) => `create { + players[msg.sender].score = 100; + players[msg.sender].combo = 3; + players[msg.sender].plays = 7; + players[msg.sender].name = "${name}"; + players[msg.sender].lastBlock = 9; + players[msg.sender].stats.best = 5; + players[msg.sender].stats.label = "${text(40, 7)}"; + players[msg.sender].tag = "${text(5, 3)}"; + players[msg.sender].hits = 2; +}`; + +describe("copying a struct from storage to memory and back", () => { + // The name's length before and after. A name that needs fewer data + // words than before has the words it no longer uses cleared. + const changes: Array<[number, number]> = [ + [70, 70], + [70, 5], + [5, 70], + [40, 32], + [0, 31], + [31, 0], + ]; + + for (const level of levels) { + for (const [from, to] of changes) { + it(`writes back every field, name ${from} to ${to} bytes (level ${level})`, async () => { + const before = text(from); + const after = from === to ? before : text(to, 11); + const rename = from === to ? "" : `player.name = "${after}";`; + + const roundTrip = await executeProgram( + `name RoundTrip; +${define} +${create(before)} +code { + let player: Player = players[msg.sender]; + player.plays = player.plays + 1; + player.score = player.score + 10; + player.stats.best = player.stats.best + player.combo; + ${rename} + players[msg.sender] = player; +}`, + { calldata: "", optimizationLevel: level }, + ); + expect(roundTrip.callSuccess).toBe(true); + + // The same changes, written field by field + const direct = await executeProgram( + `name Direct; +${define} +${create(before)} +code { + players[msg.sender].plays = 8; + players[msg.sender].score = 110; + players[msg.sender].stats.best = 8; + players[msg.sender].name = "${after}"; +}`, + { calldata: "", optimizationLevel: level }, + ); + expect(direct.callSuccess).toBe(true); + + const base = mappingSlot(4n, sender); + expect(await playerStorage(roundTrip, base)).toEqual( + await playerStorage(direct, base), + ); + + // score (8 bytes), combo (4), plays (4), from the low-order end + expect(await roundTrip.getStorage(base)).toBe( + 110n | (3n << 64n) | (8n << 96n), + ); + await expectEncoded(roundTrip, base + 1n, after); + expect(await roundTrip.getStorage(base + 2n)).toBe(9n); + expect(await roundTrip.getStorage(base + 3n)).toBe(8n); + await expectEncoded(roundTrip, base + 4n, text(40, 7)); + await expectEncoded(roundTrip, base + 5n, text(5, 3)); + expect(await roundTrip.getStorage(base + 6n)).toBe(2n); + }); + } + } +}); + +describe("copying a struct between storage variables", () => { + for (const level of levels) { + it(`copies through a local (level ${level})`, async () => { + const result = await executeProgram( + `name Copy; +define { struct Entry { id: uint32; note: string; owner: address; }; } +storage { [0] a: Entry; [3] b: Entry; } +create { + a.id = 4; + a.note = "${text(50)}"; + a.owner = 0x00000000000000000000000000000000000000aa; + b.note = "${text(90, 5)}"; +} +code { + let e: Entry = a; + e.id = e.id + 1; + b = e; +}`, + { calldata: "", optimizationLevel: level }, + ); + expect(result.callSuccess).toBe(true); + + expect(await result.getStorage(3n)).toBe(5n); + await expectEncoded(result, 4n, text(50)); + expect(await result.getStorage(5n)).toBe(0xaan); + // b's old note took three data words; the third is cleared + expect(await result.getStorage(hash(word(4n)) + 2n)).toBe(0n); + // a is unchanged + expect(await result.getStorage(0n)).toBe(4n); + await expectEncoded(result, 1n, text(50)); + }); + } +}); + +describe("copying a storage string to memory", () => { + for (const level of levels) { + for (const length of [0, 5, 31, 32, 70]) { + it(`copies ${length} bytes (level ${level})`, async () => { + const value = text(length); + const result = await executeProgram( + `name StringCopy; +storage { [1] motd: string; [2] other: string; [3] size: uint256; } +create { motd = "${value}"; } +code { + let m: string = motd; + size = m.length; + other = m; +}`, + { calldata: "", optimizationLevel: level }, + ); + expect(result.callSuccess).toBe(true); + expect(await result.getStorage(3n)).toBe(BigInt(length)); + await expectEncoded(result, 2n, value); + }); + } + } +}); diff --git a/packages/bugc/src/irgen/generate/expressions/identifier.ts b/packages/bugc/src/irgen/generate/expressions/identifier.ts index 129de58c5b..ae5edd134b 100644 --- a/packages/bugc/src/irgen/generate/expressions/identifier.ts +++ b/packages/bugc/src/irgen/generate/expressions/identifier.ts @@ -8,7 +8,7 @@ import { fromBugType } from "#irgen/type"; import { Type } from "#types"; import { Process } from "../process.js"; -import { emitStorageStructCopy } from "../storage.js"; +import { emitStorageCopyToMemory } from "../storage.js"; /** * Build an identifier expression @@ -33,9 +33,13 @@ export function* buildIdentifier( ? fromBugType(storageType) : Ir.Type.Scalar.uint256; - // A struct is a copy in memory, not the slot's word - if (storageType && Type.isStruct(storageType)) { - return yield* emitStorageStructCopy( + // A struct is a copy in memory, not the slot's word; an array + // cannot be copied + if ( + storageType && + (Type.isStruct(storageType) || Type.isArray(storageType)) + ) { + return yield* emitStorageCopyToMemory( Ir.Value.constant(BigInt(storageSlot.slot), Ir.Type.Scalar.uint256), storageType, expr, diff --git a/packages/bugc/src/irgen/generate/storage.ts b/packages/bugc/src/irgen/generate/storage.ts index e1ab2cac1f..331220ce15 100644 --- a/packages/bugc/src/irgen/generate/storage.ts +++ b/packages/bugc/src/irgen/generate/storage.ts @@ -141,6 +141,9 @@ export function* emitStorageVariableStore( node: Ast.Node | undefined, ): Process { const type = yield* Process.Types.nodeType(slot.declaration); + const base = Ir.Value.constant(BigInt(slot.slot), Ir.Type.Scalar.uint256); + if (type && (yield* emitStorageCopyFromMemory(base, type, value, node))) + return; const size = type ? getFieldSize(fromBugType(type)) : 32; yield* Process.Instructions.emit({ @@ -343,9 +346,14 @@ export function* emitStorageChainLoad( } } - // A struct is a copy in memory, not the slot's word - if (currentOrigin && Type.isStruct(currentOrigin)) { - return yield* emitStorageStructCopy(currentSlot, currentOrigin, node); + // A struct is a copy in memory, not the slot's word; an array cannot + // be copied (its length is a word, though) + if ( + currentOrigin && + (Type.isStruct(currentOrigin) || + (Type.isArray(currentOrigin) && valueType.kind === "ref")) + ) { + return yield* emitStorageCopyToMemory(currentSlot, currentOrigin, node); } // Check if the last access was a struct field to get packed field info @@ -388,12 +396,111 @@ export function* emitStorageChainLoad( return Ir.Value.temp(loadTempId, valueType); } +/** + * Whether a type is a string or dynamic `bytes`: in storage, encoded + * as Solidity encodes them; in memory, a length word, then the data + */ +function isDynamicBytes(type: Type): boolean { + return ( + Type.isElementary(type) && + (Type.Elementary.isString(type) || + (Type.Elementary.isBytes(type) && type.size === undefined)) + ); +} + +/** + * Report a value that bugc cannot copy between storage and memory + */ +function* reportUncopyable( + type: Type, + direction: "to memory" | "to storage", + node: Ast.Node | undefined, + within?: { struct: Type.Struct; field: string }, +): Process { + const path = + direction === "to memory" + ? "from storage to memory" + : "from memory to storage"; + const message = within + ? `Cannot copy struct ${within.struct.name} ${path}: field ` + + `${within.field} is ${Type.format(type)}; only value, string, ` + + `bytes, and struct fields can be copied` + : `Cannot copy ${Type.format(type)} ${path}`; + yield* Process.Errors.report( + new IrgenError(message, node?.loc ?? undefined, Severity.Error), + ); +} + +/** + * Copy a struct or array from storage at `slot` to memory: a struct is + * copied field by field (its address in memory is the value); an array + * cannot be copied, which is an error. + */ +export function* emitStorageCopyToMemory( + slot: Ir.Value, + type: Type.Struct | Type.Array, + node: Ast.Node | undefined, +): Process { + if (Type.isStruct(type)) { + return yield* emitStorageStructCopy(slot, type, node); + } + yield* reportUncopyable(type, "to memory", node); + return Ir.Value.constant(0n, Ir.Type.Scalar.uint256); +} + +/** + * The slot of a struct's field, for a struct at `slot` + */ +function* emitFieldSlot( + slot: Ir.Value, + byteOffset: number, + debug: Ir.Instruction.Debug, +): Process { + if (byteOffset < 32) return slot; + const temp = yield* Process.Variables.newTemp(); + yield* Process.Instructions.emit({ + kind: "compute_slot", + slotKind: "field", + base: slot, + fieldOffset: byteOffset, + dest: temp, + operationDebug: debug, + } as Ir.Instruction.ComputeSlot); + return Ir.Value.temp(temp, Ir.Type.Scalar.uint256); +} + +/** + * The address of the word of a struct's `index`th field, for a struct + * in memory at `address` + */ +function* emitFieldWord( + address: Ir.Value, + name: string, + index: number, + debug: Ir.Instruction.Debug, +): Process { + if (index === 0) return address; + const temp = yield* Process.Variables.newTemp(); + yield* Process.Instructions.emit( + Ir.Instruction.ComputeOffset.field( + "memory", + address, + name, + index * 32, + temp, + debug, + ), + ); + return Ir.Value.temp(temp, Ir.Type.Scalar.uint256); +} + /** * Copy a struct from storage, starting at `slot`, into new memory, and * return its address. A struct in memory has one word per field, in - * order; a struct field is the address of its own copy. + * order: a value type's value, or the address of a struct, string, or + * `bytes` field's own copy. */ -export function* emitStorageStructCopy( +function* emitStorageStructCopy( slot: Ir.Value, struct: Type.Struct, node: Ast.Node | undefined, @@ -417,77 +524,45 @@ export function* emitStorageStructCopy( if (!layout) { throw new Error(`Field ${name} not found in struct ${struct.name}`); } - - // The field's slot - let fieldSlot = slot; - if (layout.byteOffset >= 32) { - const temp = yield* Process.Variables.newTemp(); - yield* Process.Instructions.emit({ - kind: "compute_slot", - slotKind: "field", - base: slot, - fieldOffset: layout.byteOffset, - dest: temp, - operationDebug: debug, - } as Ir.Instruction.ComputeSlot); - fieldSlot = Ir.Value.temp(temp, uint256); - } + const fieldSlot = yield* emitFieldSlot(slot, layout.byteOffset, debug); let value: Ir.Value; if (Type.isStruct(fieldType)) { value = yield* emitStorageStructCopy(fieldSlot, fieldType, node); - } else if ( - Type.isElementary(fieldType) && - !(Type.Elementary.isBytes(fieldType) && fieldType.size === undefined) && - !Type.Elementary.isString(fieldType) - ) { + } else if (Type.isElementary(fieldType)) { + // A string or `bytes` reads as a copy in memory; a value type, + // from its bytes in the slot const irType = fromBugType(fieldType); + const length = isDynamicBytes(fieldType) ? 32 : getFieldSize(irType); const temp = yield* Process.Variables.newTemp(); yield* Process.Instructions.emit({ kind: "read", location: "storage", slot: fieldSlot, offset: constant(BigInt(layout.byteOffset % 32)), - length: constant(BigInt(getFieldSize(irType))), + length: constant(BigInt(length)), type: irType, dest: temp, operationDebug: debug, } as Ir.Instruction.Read); value = Ir.Value.temp(temp, irType); } else { - yield* Process.Errors.report( - new IrgenError( - `Cannot copy struct ${struct.name} from storage: its field ` + - `${name} is a ${Type.format(fieldType)}, and only value ` + - `and struct fields can be copied to memory`, - node?.loc ?? undefined, - Severity.Error, - ), - ); + yield* reportUncopyable(fieldType, "to memory", node, { + struct, + field: name, + }); value = constant(0n); } - // The field's word in memory - let offset = Ir.Value.temp(address, uint256); - if (index > 0) { - const temp = yield* Process.Variables.newTemp(); - yield* Process.Instructions.emit( - Ir.Instruction.ComputeOffset.field( - "memory", - offset, - name, - index * 32, - temp, - debug, - ), - ); - offset = Ir.Value.temp(temp, uint256); - } - yield* Process.Instructions.emit({ kind: "write", location: "memory", - offset, + offset: yield* emitFieldWord( + Ir.Value.temp(address, uint256), + name, + index, + debug, + ), length: constant(32n), value, operationDebug: debug, @@ -496,7 +571,101 @@ export function* emitStorageStructCopy( index++; } - return Ir.Value.temp(address, Ir.Type.Ref.memory()); + return Ir.Value.temp(address, Ir.Type.ref("memory", struct)); +} + +/** + * Copy a struct from memory, at `address`, to storage, starting at + * `slot`: each field to its bytes in its slot, as the struct's layout + * places it. A struct, string, or `bytes` field's word holds the + * address of its data, which is copied in turn (a string or `bytes` + * as Solidity encodes it). + */ +function* emitStorageStructStore( + slot: Ir.Value, + struct: Type.Struct, + address: Ir.Value, + node: Ast.Node | undefined, +): Process { + const debug = node ? yield* Process.Debug.forAstNode(node) : {}; + const uint256 = Ir.Type.Scalar.uint256; + const constant = (value: bigint) => Ir.Value.constant(value, uint256); + + let index = 0; + for (const [name, fieldType] of struct.fields) { + const layout = struct.layout.get(name); + if (!layout) { + throw new Error(`Field ${name} not found in struct ${struct.name}`); + } + + if (!Type.isStruct(fieldType) && !Type.isElementary(fieldType)) { + yield* reportUncopyable(fieldType, "to storage", node, { + struct, + field: name, + }); + index++; + continue; + } + + // The field's word in memory + const irType = fromBugType(fieldType); + const word = yield* emitFieldWord(address, name, index, debug); + const temp = yield* Process.Variables.newTemp(); + yield* Process.Instructions.emit({ + kind: "read", + location: "memory", + offset: word, + length: constant(32n), + type: irType, + dest: temp, + operationDebug: debug, + } as Ir.Instruction.Read); + const value = Ir.Value.temp(temp, irType); + + const fieldSlot = yield* emitFieldSlot(slot, layout.byteOffset, debug); + if (Type.isStruct(fieldType)) { + yield* emitStorageStructStore(fieldSlot, fieldType, value, node); + } else { + yield* Process.Instructions.emit({ + kind: "write", + location: "storage", + slot: fieldSlot, + offset: constant(BigInt(layout.byteOffset % 32)), + length: constant( + BigInt(isDynamicBytes(fieldType) ? 32 : getFieldSize(irType)), + ), + value, + operationDebug: debug, + } as Ir.Instruction.Write); + } + + index++; + } +} + +/** + * Copy a value in memory to storage at `slot`, if `type` is one stored + * by copying its data: a struct is copied field by field; + * an array cannot be copied, which is an error. Returns whether the + * value was handled here (a string or `bytes` is not: a write of one + * copies it). + */ +function* emitStorageCopyFromMemory( + slot: Ir.Value, + type: Type, + value: Ir.Value, + node: Ast.Node | undefined, +): Process { + if (value.type.kind !== "ref") return false; + if (Type.isStruct(type)) { + yield* emitStorageStructStore(slot, type, value, node); + return true; + } + if (Type.isArray(type)) { + yield* reportUncopyable(type, "to storage", node); + return true; + } + return false; } /** @@ -599,6 +768,13 @@ export function* emitStorageChainStore( } } + if ( + currentOrigin && + (yield* emitStorageCopyFromMemory(currentSlot, currentOrigin, value, node)) + ) { + return; + } + // Check if the last access was a struct field to handle packed fields let byteOffset = 0; let fieldSize: number | undefined; diff --git a/packages/bugc/src/irgen/generator.error.test.ts b/packages/bugc/src/irgen/generator.error.test.ts index 3e9778d4ac..4f43739df4 100644 --- a/packages/bugc/src/irgen/generator.error.test.ts +++ b/packages/bugc/src/irgen/generator.error.test.ts @@ -133,9 +133,58 @@ describe("generateModule error handling", () => { severity: Severity.Error, }).map((d) => d.message); expect(messages).toContainEqual( - expect.stringContaining( - "Cannot copy struct Bag from storage: its field items", - ), + "Cannot copy struct Bag from storage to memory: field items is " + + "array; only value, string, bytes, and struct fields " + + "can be copied", + ); + }); + + it("should reject an array copy from storage", async () => { + const source = ` + name Test; + storage { + [0] items: array; + } + code { + let m: array = items; + } + `; + + const result = await compileTest(source); + + expect(result.success).toBe(false); + const messages = Result.findMessages(result, { + severity: Severity.Error, + }).map((d) => d.message); + expect(messages).toContainEqual( + "Cannot copy array from storage to memory", + ); + }); + + it("should reject an array copy to storage", async () => { + const source = ` + name Test; + define { + struct Bag { + items: array; + }; + } + storage { + [0] bag: Bag; + } + code { + bag.items = [1, 2]; + } + `; + + const result = await compileTest(source); + + expect(result.success).toBe(false); + const messages = Result.findMessages(result, { + severity: Severity.Error, + }).map((d) => d.message); + expect(messages).toContainEqual( + "Cannot copy array from memory to storage", ); }); }); diff --git a/packages/bugc/src/optimizer/steps/read-write-merging.ts b/packages/bugc/src/optimizer/steps/read-write-merging.ts index 140342d1a6..5bd8eff5df 100644 --- a/packages/bugc/src/optimizer/steps/read-write-merging.ts +++ b/packages/bugc/src/optimizer/steps/read-write-merging.ts @@ -174,6 +174,12 @@ export class ReadWriteMergingStep extends BaseOptimizationStep { // Must be same location type if (first.location !== second.location) return false; + // A write of a reference (a string or `bytes` in memory) copies its + // data, not the word + if (first.value?.type.kind === "ref" || second.value?.type.kind === "ref") { + return false; + } + // For storage/transient: must have same slot if (first.location === "storage" || first.location === "transient") { if (!first.slot || !second.slot) return false; From 01201f46f2c4123c401f7487a9bdb7db84bac6e1 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Fri, 9 Oct 2026 01:08:28 -0400 Subject: [PATCH 3/6] bugc: point to a struct local's fields --- .../debug/local-variables.soundness.test.ts | 59 ++++++++++++++ .../bugc/src/evmgen/debug/local-variables.ts | 44 ++++++++++- packages/bugc/test/evm/locals.ts | 77 ++++++++++++------- 3 files changed, 151 insertions(+), 29 deletions(-) diff --git a/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts b/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts index 039ab2239d..b56311cfcb 100644 --- a/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts +++ b/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts @@ -370,6 +370,24 @@ code { let v = calls + 5; total = outer(v); total = total + v; }`; const words: Shape = { kind: "array", element: { kind: "scalar", size: 32 } }; +/** The layout of the struct programs' `Player` local */ +const player: Shape = { + kind: "struct", + fields: { + score: { kind: "scalar", size: 8 }, + plays: { kind: "scalar", size: 4 }, + name: { kind: "bytes" }, + tag: { kind: "bytes" }, + }, +}; +const longName = "a name longer than thirty-two bytes, in two words"; +const playerValue = (plays: bigint, name: string) => ({ + score: 100n, + plays, + name: textBytes(name), + tag: textBytes("hi"), +}); + /** A scalar program for each sub-word kind, `ad` passed as `arg` */ const kinds = ( [ @@ -1118,6 +1136,47 @@ code { }, }, }, + { + // A struct copied from storage, changed, and written back: its + // pointer is a group of its fields' words, a string field's with + // the string's length and data + name: "a struct copied from storage", + source: `name StructLocal; +define { + struct Player { score: uint64; plays: uint32; name: string; tag: string; }; +} +storage { [0] r: uint256; [1] players: mapping; } +create { + players[msg.sender].score = 100; + players[msg.sender].plays = 7; + players[msg.sender].name = "${longName}"; + players[msg.sender].tag = "hi"; +} +code { + let player: Player = players[msg.sender]; + player.plays = player.plays + 1; + player.name = "renamed"; + players[msg.sender] = player; + r = player.score + player.plays; +}`, + locals: { + player: { + shape: player, + everyLevel: true, + values: [ + playerValue(7n, longName), + { + value: playerValue(8n, longName), + after: "player.plays = player.plays + 1", + }, + { + value: playerValue(8n, "renamed"), + after: 'player.name = "renamed"', + }, + ], + }, + }, + }, { name: "a uint8 cast", source: `name U8; diff --git a/packages/bugc/src/evmgen/debug/local-variables.ts b/packages/bugc/src/evmgen/debug/local-variables.ts index 2b7eca419b..53a0da2db1 100644 --- a/packages/bugc/src/evmgen/debug/local-variables.ts +++ b/packages/bugc/src/evmgen/debug/local-variables.ts @@ -122,10 +122,15 @@ function stackPointer( * word; a reference element's word holds the address of its data, * described the same way. * + * - struct: one word per field, in order, from `base`. A scalar field + * is right-aligned in its word; a reference field's word holds the + * address of its data, described the same way. + * * Regions are named after the local: `x-length`, `x-data`, - * `x-element`, and for a nested reference `x-element-length` and so - * on. Fixed-size arrays and structs are left out: bugc cannot build - * one in memory yet (no struct literal; array literals are dynamic). + * `x-element`, `x-`, and for a nested reference + * `x-element-length`, `x--data` and so on. Fixed-size arrays + * are left out: bugc cannot build one in memory yet (array literals + * are dynamic). */ function dataPointers( base: Format.Pointer.Expression, @@ -157,6 +162,39 @@ function dataPointers( ]; } + if (BugType.isStruct(type)) { + const fields: Format.Pointer[] = []; + let index = 0; + for (const [field, fieldType] of type.fields) { + const region = `${name}-${field}`; + const word = 32 * index++; + const offset = (within: number): Format.Pointer.Expression => + word + within === 0 ? base : { "~sum": [base, word + within] }; + const irType = fromBugType(fieldType); + if (Ir.Type.isScalar(irType)) { + fields.push({ + name: region, + location: "memory", + offset: offset(32 - irType.size), + length: irType.size, + }); + continue; + } + const nested = dataPointers( + { "~read": region }, + fieldType, + region, + depth, + ); + if (!nested) return undefined; + fields.push( + { name: region, location: "memory", offset: offset(0), length: 32 }, + ...nested, + ); + } + return fields; + } + if (!BugType.isArray(type) || type.size !== undefined) return undefined; const index = `i${depth}`; diff --git a/packages/bugc/test/evm/locals.ts b/packages/bugc/test/evm/locals.ts index 004d6f1ba6..237886f4d1 100644 --- a/packages/bugc/test/evm/locals.ts +++ b/packages/bugc/test/evm/locals.ts @@ -82,17 +82,37 @@ export function localsOf(context: unknown): Array> { export type Shape = | { kind: "bytes" } | { kind: "calldata" } - | { kind: "array"; element: Shape | { kind: "scalar"; size: number } }; + | { kind: "array"; element: Shape | Scalar } + | { kind: "struct"; fields: Record }; + +/** A scalar element or field, of `size` bytes */ +export type Scalar = { kind: "scalar"; size: number }; /** A reference's value as its regions read it: hex for bytes and - * strings, an array of bigints (or nested arrays) for arrays. */ -export type RefValue = string | bigint | RefValue[]; + * strings, an array of bigints (or nested arrays) for arrays, and an + * object by field for structs. */ +export type RefValue = + | string + | bigint + | RefValue[] + | { [field: string]: RefValue }; /** A value a local takes, and (optionally) the source text of the * statement that gives it: the value may be read only once that * statement has run (as many times as the value is listed after it). */ export type Value = RefValue | { value: RefValue; after: string }; +/** Whether a value names the statement that gives it */ +function isTimed(v: Value): v is { value: RefValue; after: string } { + return ( + typeof v === "object" && + !Array.isArray(v) && + "after" in v && + typeof v.after === "string" && + "value" in v + ); +} + export interface Local { /** The values the local takes, in execution order */ values: Value[]; @@ -161,9 +181,7 @@ export async function check( const statements = [ ...new Set( Object.values(locals).flatMap((local) => - local.values.flatMap((v) => - typeof v === "object" && v !== null && "after" in v ? [v.after] : [], - ), + local.values.flatMap((v) => (isTimed(v) ? [v.after] : [])), ), ), ].map((text) => { @@ -182,10 +200,7 @@ export async function check( const valuesOf = (local: Local) => { const seen = new Map(); return local.values.map((v) => { - const { value, after } = - typeof v === "object" && v !== null && "after" in v - ? v - : { value: v, after: undefined }; + const { value, after } = isTimed(v) ? v : { value: v, after: undefined }; const run = after === undefined ? 0 @@ -362,7 +377,8 @@ async function readScalar( * A reference-typed local's value at a step, by its layout: the * local's word holds the base address, the length word is at the base, * each element is in its word (a scalar right-aligned), and the data - * follows the length word, as long as the length says. Bytes in + * follows the length word, as long as the length says. A struct has no + * length word: each field is in its word from the base. Bytes in * calldata are the calldata from the offset in the word's high 16 * bytes, as long as its low 16 bytes say. */ @@ -383,33 +399,42 @@ async function readReference( return (await regions.read(`${name}-data`, offset, length)).toHex(); } + /** A scalar's value, or a reference's, whose word is at `word` */ + const decodeWord = async ( + shape: Shape | Scalar, + prefix: string, + word: bigint, + ): Promise => { + if (shape.kind === "scalar") { + const size = BigInt(shape.size); + return (await regions.read(prefix, word + 32n - size, size)).asUint(); + } + const address = (await regions.read(prefix, word, 32n)).asUint(); + return decode(shape, prefix, address); + }; + const decode = async ( shape: Shape, prefix: string, base: bigint, ): Promise => { + if (shape.kind === "struct") { + const value: { [field: string]: RefValue } = {}; + let word = base; + for (const [field, fieldShape] of Object.entries(shape.fields)) { + value[field] = await decodeWord(fieldShape, `${prefix}-${field}`, word); + word += 32n; + } + return value; + } const length = (await regions.read(`${prefix}-length`, base, 32n)).asUint(); if (shape.kind !== "array") { return (await regions.read(`${prefix}-data`, base + 32n, length)).toHex(); } - const element = shape.element; const values: RefValue[] = []; for (let i = 0n; i < length; i++) { const word = base + 32n + 32n * i; - if (element.kind === "scalar") { - const size = BigInt(element.size); - const value = await regions.read( - `${prefix}-element`, - word + 32n - size, - size, - ); - values.push(value.asUint()); - } else { - const address = ( - await regions.read(`${prefix}-element`, word, 32n) - ).asUint(); - values.push(await decode(element, `${prefix}-element`, address)); - } + values.push(await decodeWord(shape.element, `${prefix}-element`, word)); } return values; }; From 43ea97cd5da6596377fb64bd8bae9f43413720d4 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Fri, 9 Oct 2026 01:08:33 -0400 Subject: [PATCH 4/6] bugc: add changelog entries for struct and string copies --- packages/bugc/CHANGELOG.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index dbc81f8380..cccf9f0c88 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -7,6 +7,36 @@ support. Changes to the specification itself are tracked in the root ## Unreleased +### Added + +- A struct copied from storage to memory may have `string` and `bytes` + fields: each field's word holds the address of its own copy. A struct + in memory assigned to storage (`players[msg.sender] = player;`) is + copied field by field, each to its bytes in its slot, and a `string` + or `bytes` field as Solidity encodes it, clearing the data words the + old value used past the new one's ([#PR]). +- A struct local's pointer is a group of its fields: each field's bytes + in its word in memory and, for a `string` or `bytes` field, the + field's word, then the length and data it refers to. Regions are + named after the local and the field (`player-score`, `player-name`, + `player-name-length`, `player-name-data`) ([#PR]). + +### Fixed + +- Reading a `string` or `bytes` from storage as a whole, as in + `let m: string = motd;`, now copies it to memory, decoded from its + storage encoding. Before, the local held the slot's word as if it + were a memory address ([#PR]). +- Assigning a struct in memory to storage now writes each of its + fields. Before, it wrote the struct's memory address to the first + slot ([#PR]). +- A struct field of a struct in storage now takes all the slots the + field's struct needs, as in Solidity. Before, it took one slot, and + the fields after it shared the slots of its fields ([#PR]). +- Copying an array between storage and memory, which bugc does not + support, is now an error. Before, it compiled to a copy of the slot's + word ([#PR]). + ## 0.1.0-preview.2 — 2026-10-07 ### Added From 5043a0f654a25d859319174147f33a4cad7865dd Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Fri, 9 Oct 2026 01:08:55 -0400 Subject: [PATCH 5/6] bugc: link the changelog entries to their PR --- packages/bugc/CHANGELOG.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index cccf9f0c88..50d1e1377f 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -14,28 +14,28 @@ support. Changes to the specification itself are tracked in the root in memory assigned to storage (`players[msg.sender] = player;`) is copied field by field, each to its bytes in its slot, and a `string` or `bytes` field as Solidity encodes it, clearing the data words the - old value used past the new one's ([#PR]). + old value used past the new one's ([#377]). - A struct local's pointer is a group of its fields: each field's bytes in its word in memory and, for a `string` or `bytes` field, the field's word, then the length and data it refers to. Regions are named after the local and the field (`player-score`, `player-name`, - `player-name-length`, `player-name-data`) ([#PR]). + `player-name-length`, `player-name-data`) ([#377]). ### Fixed - Reading a `string` or `bytes` from storage as a whole, as in `let m: string = motd;`, now copies it to memory, decoded from its storage encoding. Before, the local held the slot's word as if it - were a memory address ([#PR]). + were a memory address ([#377]). - Assigning a struct in memory to storage now writes each of its fields. Before, it wrote the struct's memory address to the first - slot ([#PR]). + slot ([#377]). - A struct field of a struct in storage now takes all the slots the field's struct needs, as in Solidity. Before, it took one slot, and - the fields after it shared the slots of its fields ([#PR]). + the fields after it shared the slots of its fields ([#377]). - Copying an array between storage and memory, which bugc does not support, is now an error. Before, it compiled to a copy of the slot's - word ([#PR]). + word ([#377]). ## 0.1.0-preview.2 — 2026-10-07 @@ -490,3 +490,4 @@ First publication. [#369]: https://github.com/ethdebug/format/pull/369 [#370]: https://github.com/ethdebug/format/pull/370 [#371]: https://github.com/ethdebug/format/pull/371 +[#377]: https://github.com/ethdebug/format/pull/377 From e85aa9c7aeb606ace8080352657796ba6f1a0592 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Fri, 9 Oct 2026 01:45:48 -0400 Subject: [PATCH 6/6] bugc: list the nested struct layout change as a change; address review --- packages/bugc/CHANGELOG.md | 34 +++++++--- .../bugc/examples/advanced/nested-structs.bug | 8 +-- .../evmgen/generation/instructions/storage.ts | 4 ++ .../bugc/src/evmgen/string-storage.test.ts | 60 +++-------------- .../bugc/src/evmgen/struct-copies.test.ts | 56 +++------------- packages/bugc/src/irgen/generate/storage.ts | 24 +++---- .../bugc/src/irgen/generator.error.test.ts | 10 +-- packages/bugc/src/typechecker/layout.test.ts | 31 +++++++++ packages/bugc/test/evm/storage.ts | 66 +++++++++++++++++++ .../compiler/case-study-bug.mdx | 3 +- 10 files changed, 165 insertions(+), 131 deletions(-) create mode 100644 packages/bugc/src/typechecker/layout.test.ts create mode 100644 packages/bugc/test/evm/storage.ts diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index 50d1e1377f..c23d95f286 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -21,21 +21,39 @@ support. Changes to the specification itself are tracked in the root named after the local and the field (`player-score`, `player-name`, `player-name-length`, `player-name-data`) ([#377]). +### Changed + +- A struct member of a struct in storage now takes all the slots its + struct needs, as in Solidity, and the member after it starts at the + next free slot. Before, it took one slot, so the members after a + struct of two or more slots shared slots with it. This moves those + members, and data stored by a contract that an earlier bugc compiled + is not where the new build reads it. For example, with + `struct Inner { a: uint256; b: uint8; }` and + `struct Outer { x: uint8; inner: Inner; y: uint16; }`, `y` is now in + slot 3 of an `Outer`, after `inner` in slots 1 and 2. Before, `y` was + in slot 2 ([#377]). + ### Fixed - Reading a `string` or `bytes` from storage as a whole, as in `let m: string = motd;`, now copies it to memory, decoded from its - storage encoding. Before, the local held the slot's word as if it - were a memory address ([#377]). + storage encoding. This also corrects an index (`data[3]`), a slice + (`data[2:40]`), `.length` of a struct member (`p.s.length`), and + `keccak256(data)` of a `string` or `bytes` in storage. Before, the + value was the slot's word, used as if it were a memory address, so + these reverted or read the wrong bytes ([#377]). - Assigning a struct in memory to storage now writes each of its fields. Before, it wrote the struct's memory address to the first slot ([#377]). -- A struct field of a struct in storage now takes all the slots the - field's struct needs, as in Solidity. Before, it took one slot, and - the fields after it shared the slots of its fields ([#377]). -- Copying an array between storage and memory, which bugc does not - support, is now an error. Before, it compiled to a copy of the slot's - word ([#377]). +- Copying an array to or from storage, which bugc does not support, is + now an error: an array read from storage as a whole, as in + `let m: array = items;`, an array in memory assigned to a + struct member or mapping value in storage, as in + `bag.items = [1, 2];`, and a struct with an array field copied either + way. Before, these compiled to a copy of the slot's word, or wrote + the memory address to the slot. An array literal assigned to a storage array variable + (`items = [1, 2];`) still compiles ([#377]). ## 0.1.0-preview.2 — 2026-10-07 diff --git a/packages/bugc/examples/advanced/nested-structs.bug b/packages/bugc/examples/advanced/nested-structs.bug index 2a5e7f1df3..1cb16f0890 100644 --- a/packages/bugc/examples/advanced/nested-structs.bug +++ b/packages/bugc/examples/advanced/nested-structs.bug @@ -3,11 +3,11 @@ name NestedStructsStorage; // Example 9: Nested Structs in Storage // // Expected IR: -// // CEO salary is at slot 4 (base slot 2 + struct layout) -// write location="storage", slot=4, offset=0, length=32, value=1000000 +// // CEO salary is at slot 5 (base slot 2 + struct layout) +// write location="storage", slot=5, offset=0, length=32, value=1000000 // -// // CEO address is at slot 3, first 20 bytes -// %ceo_addr = read location="storage", slot=3, offset=0, length=20 +// // CEO address is at slot 4, first 20 bytes +// %ceo_addr = read location="storage", slot=4, offset=0, length=20 // // EVM Strategy: Calculate nested struct slot offsets based on layout // diff --git a/packages/bugc/src/evmgen/generation/instructions/storage.ts b/packages/bugc/src/evmgen/generation/instructions/storage.ts index 53462201f3..1142974335 100644 --- a/packages/bugc/src/evmgen/generation/instructions/storage.ts +++ b/packages/bugc/src/evmgen/generation/instructions/storage.ts @@ -808,6 +808,10 @@ function generateBytesStorageWrite( * `` takes [size] to [ptr], moving the free memory pointer past * `size` bytes. The data's last word holds zero past its end, as the * write stores it. + * + * Unlike Solidity, this does not check the encoding (Solidity reverts + * with `Panic(0x22)` on a bad one): a corrupt long length allocates as + * much memory as it says, and runs out of gas. */ function generateBytesStorageRead( inst: Ir.Instruction.Read, diff --git a/packages/bugc/src/evmgen/string-storage.test.ts b/packages/bugc/src/evmgen/string-storage.test.ts index 33fbee8755..230c5dbc5e 100644 --- a/packages/bugc/src/evmgen/string-storage.test.ts +++ b/packages/bugc/src/evmgen/string-storage.test.ts @@ -1,61 +1,19 @@ import { describe, it, expect } from "vitest"; -import { keccak256 } from "ethereum-cryptography/keccak"; -import { bytesToHex, hexToBytes } from "ethereum-cryptography/utils"; +import { hexToBytes } from "ethereum-cryptography/utils"; +import { executeProgram } from "#test/evm/behavioral"; import { - executeProgram, - type ExecuteProgramResult, -} from "#test/evm/behavioral"; + expectEncoded, + hash, + hex, + mappingSlot, + text, + word, +} from "#test/evm/storage"; 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 word = (n: bigint) => hexToBytes(n.toString(16).padStart(64, "0")); -const hash = (bytes: Uint8Array) => BigInt("0x" + bytesToHex(keccak256(bytes))); - -/** The slot of a mapping's value: keccak256(key . slot) */ -const mappingSlot = (slot: bigint, key: bigint) => - hash(new Uint8Array([...word(key), ...word(slot)])); - -/** - * Check that storage at `slot` holds `data` as Solidity encodes a - * string or `bytes`: up to 31 bytes in the slot itself, left-aligned, - * with length * 2 in the low byte; else length * 2 + 1 in the slot and - * the data from keccak256(slot). - */ -async function expectEncoded( - result: ExecuteProgramResult, - slot: bigint, - data: Uint8Array, -) { - const length = BigInt(data.length); - const padded = new Uint8Array(Math.ceil(data.length / 32) * 32); - padded.set(data); - const dataWord = (i: number) => - BigInt("0x" + (bytesToHex(padded.slice(i * 32, i * 32 + 32)) || "0")); - - if (length < 32n) { - expect(await result.getStorage(slot)).toBe(dataWord(0) | (length * 2n)); - return; - } - - expect(await result.getStorage(slot)).toBe(length * 2n + 1n); - const base = hash(word(slot)); - for (let i = 0; i < padded.length / 32; i++) { - expect(await result.getStorage(base + BigInt(i))).toBe(dataWord(i)); - } -} - describe("assigning a memory string to storage", () => { for (const level of levels) { for (const length of lengths) { diff --git a/packages/bugc/src/evmgen/struct-copies.test.ts b/packages/bugc/src/evmgen/struct-copies.test.ts index 0b47736118..91011ba177 100644 --- a/packages/bugc/src/evmgen/struct-copies.test.ts +++ b/packages/bugc/src/evmgen/struct-copies.test.ts @@ -1,61 +1,21 @@ import { describe, it, expect } from "vitest"; -import { keccak256 } from "ethereum-cryptography/keccak"; -import { bytesToHex, hexToBytes } from "ethereum-cryptography/utils"; - import { executeProgram, type ExecuteProgramResult, } from "#test/evm/behavioral"; +import { + expectEncoded, + hash, + mappingSlot, + text, + word, +} from "#test/evm/storage"; const levels = [0, 1, 2, 3] as const; -const alphabet = - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; -const text = (length: number, from: number = 0) => - Array.from({ length }, (_, i) => alphabet[(i + from) % alphabet.length]).join( - "", - ); - -const word = (n: bigint) => hexToBytes(n.toString(16).padStart(64, "0")); -const hash = (bytes: Uint8Array) => BigInt("0x" + bytesToHex(keccak256(bytes))); - -/** The slot of a mapping's value: keccak256(key . slot) */ -const mappingSlot = (slot: bigint, key: bigint) => - hash(new Uint8Array([...word(key), ...word(slot)])); - /** The sender of every transaction the executor sends */ const sender = 1n; -/** - * Check that storage at `slot` holds `data` as Solidity encodes a - * string: up to 31 bytes in the slot itself, left-aligned, with - * length * 2 in the low byte; else length * 2 + 1 in the slot and the - * data from keccak256(slot). - */ -async function expectEncoded( - result: ExecuteProgramResult, - slot: bigint, - value: string, -) { - const data = new TextEncoder().encode(value); - const length = BigInt(data.length); - const padded = new Uint8Array(Math.ceil(data.length / 32) * 32); - padded.set(data); - const dataWord = (i: number) => - BigInt("0x" + (bytesToHex(padded.slice(i * 32, i * 32 + 32)) || "0")); - - if (length < 32n) { - expect(await result.getStorage(slot)).toBe(dataWord(0) | (length * 2n)); - return; - } - - expect(await result.getStorage(slot)).toBe(length * 2n + 1n); - const base = hash(word(slot)); - for (let i = 0; i < padded.length / 32; i++) { - expect(await result.getStorage(base + BigInt(i))).toBe(dataWord(i)); - } -} - /** * The words of storage a `Player` at `base` may use: its own slots, and * the first data slots of a string in any of them @@ -118,7 +78,7 @@ describe("copying a struct from storage to memory and back", () => { for (const level of levels) { for (const [from, to] of changes) { - it(`writes back every field, name ${from} to ${to} bytes (level ${level})`, async () => { + it(`writes back a name of ${from}, then ${to} bytes (O${level})`, async () => { const before = text(from); const after = from === to ? before : text(to, 11); const rename = from === to ? "" : `player.name = "${after}";`; diff --git a/packages/bugc/src/irgen/generate/storage.ts b/packages/bugc/src/irgen/generate/storage.ts index 331220ce15..6e499afd6b 100644 --- a/packages/bugc/src/irgen/generate/storage.ts +++ b/packages/bugc/src/irgen/generate/storage.ts @@ -409,23 +409,19 @@ function isDynamicBytes(type: Type): boolean { } /** - * Report a value that bugc cannot copy between storage and memory + * Report a value that bugc cannot copy to or from storage: an array, or + * a struct with an array or mapping field (`within`) */ function* reportUncopyable( type: Type, - direction: "to memory" | "to storage", node: Ast.Node | undefined, within?: { struct: Type.Struct; field: string }, ): Process { - const path = - direction === "to memory" - ? "from storage to memory" - : "from memory to storage"; const message = within - ? `Cannot copy struct ${within.struct.name} ${path}: field ` + - `${within.field} is ${Type.format(type)}; only value, string, ` + - `bytes, and struct fields can be copied` - : `Cannot copy ${Type.format(type)} ${path}`; + ? `bugc cannot copy struct ${within.struct.name} to or from ` + + `storage: field ${within.field} is ${Type.format(type)}, and bugc ` + + `can copy only value, string, bytes, and struct fields` + : `bugc cannot copy ${Type.format(type)} to or from storage`; yield* Process.Errors.report( new IrgenError(message, node?.loc ?? undefined, Severity.Error), ); @@ -444,7 +440,7 @@ export function* emitStorageCopyToMemory( if (Type.isStruct(type)) { return yield* emitStorageStructCopy(slot, type, node); } - yield* reportUncopyable(type, "to memory", node); + yield* reportUncopyable(type, node); return Ir.Value.constant(0n, Ir.Type.Scalar.uint256); } @@ -547,7 +543,7 @@ function* emitStorageStructCopy( } as Ir.Instruction.Read); value = Ir.Value.temp(temp, irType); } else { - yield* reportUncopyable(fieldType, "to memory", node, { + yield* reportUncopyable(fieldType, node, { struct, field: name, }); @@ -599,7 +595,7 @@ function* emitStorageStructStore( } if (!Type.isStruct(fieldType) && !Type.isElementary(fieldType)) { - yield* reportUncopyable(fieldType, "to storage", node, { + yield* reportUncopyable(fieldType, node, { struct, field: name, }); @@ -662,7 +658,7 @@ function* emitStorageCopyFromMemory( return true; } if (Type.isArray(type)) { - yield* reportUncopyable(type, "to storage", node); + yield* reportUncopyable(type, node); return true; } return false; diff --git a/packages/bugc/src/irgen/generator.error.test.ts b/packages/bugc/src/irgen/generator.error.test.ts index 4f43739df4..b55154fd8d 100644 --- a/packages/bugc/src/irgen/generator.error.test.ts +++ b/packages/bugc/src/irgen/generator.error.test.ts @@ -133,9 +133,9 @@ describe("generateModule error handling", () => { severity: Severity.Error, }).map((d) => d.message); expect(messages).toContainEqual( - "Cannot copy struct Bag from storage to memory: field items is " + - "array; only value, string, bytes, and struct fields " + - "can be copied", + "bugc cannot copy struct Bag to or from storage: field items is " + + "array, and bugc can copy only value, string, bytes, " + + "and struct fields", ); }); @@ -157,7 +157,7 @@ describe("generateModule error handling", () => { severity: Severity.Error, }).map((d) => d.message); expect(messages).toContainEqual( - "Cannot copy array from storage to memory", + "bugc cannot copy array to or from storage", ); }); @@ -184,7 +184,7 @@ describe("generateModule error handling", () => { severity: Severity.Error, }).map((d) => d.message); expect(messages).toContainEqual( - "Cannot copy array from memory to storage", + "bugc cannot copy array to or from storage", ); }); }); diff --git a/packages/bugc/src/typechecker/layout.test.ts b/packages/bugc/src/typechecker/layout.test.ts new file mode 100644 index 0000000000..7c9c12e684 --- /dev/null +++ b/packages/bugc/src/typechecker/layout.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; + +import { Type } from "#types"; + +import { computeStructLayout } from "./layout.js"; + +const uint = (bits: number) => Type.Elementary.uint(bits); + +describe("computeStructLayout", () => { + it("gives a struct member all its slots, as Solidity does", () => { + const fields = new Map([ + ["a", uint(256)], + ["b", uint(8)], + ]); + const inner = Type.struct("Inner", fields, computeStructLayout(fields)); + const layout = computeStructLayout( + new Map([ + ["x", uint(8)], + ["inner", inner], + ["y", uint(16)], + ]), + ); + + // x in slot 0; inner in slots 1 and 2; y in slot 3 + expect(Object.fromEntries(layout)).toEqual({ + x: { byteOffset: 0, size: 1 }, + inner: { byteOffset: 32, size: 64 }, + y: { byteOffset: 96, size: 2 }, + }); + }); +}); diff --git a/packages/bugc/test/evm/storage.ts b/packages/bugc/test/evm/storage.ts new file mode 100644 index 0000000000..83e316eade --- /dev/null +++ b/packages/bugc/test/evm/storage.ts @@ -0,0 +1,66 @@ +/** + * Support for tests of values in storage: test data, slots, and + * Solidity's encoding of a string or `bytes` + */ +import { expect } from "vitest"; +import { keccak256 } from "ethereum-cryptography/keccak"; +import { bytesToHex, hexToBytes } from "ethereum-cryptography/utils"; + +import type { ExecuteProgramResult } from "./behavioral.js"; + +const alphabet = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + +/** `length` characters of test text, from the `from`th of the alphabet */ +export const text = (length: number, from: number = 0) => + Array.from({ length }, (_, i) => alphabet[(i + from) % alphabet.length]).join( + "", + ); + +/** `length` bytes of test data (1, 2, 3, ...), as hex without 0x */ +export const hex = (length: number) => + Array.from({ length }, (_, i) => (i + 1).toString(16).padStart(2, "0")).join( + "", + ); + +/** A number as a 32-byte word */ +export const word = (n: bigint) => hexToBytes(n.toString(16).padStart(64, "0")); + +/** keccak256 of bytes, as a number */ +export const hash = (bytes: Uint8Array) => + BigInt("0x" + bytesToHex(keccak256(bytes))); + +/** The slot of a mapping's value: keccak256(key . slot) */ +export const mappingSlot = (slot: bigint, key: bigint) => + hash(new Uint8Array([...word(key), ...word(slot)])); + +/** + * Check that storage at `slot` holds `data` (text, or bytes) as + * Solidity encodes a string or `bytes`: up to 31 bytes in the slot + * itself, left-aligned, with length * 2 in the low byte; else + * length * 2 + 1 in the slot and the data from keccak256(slot). + */ +export async function expectEncoded( + result: ExecuteProgramResult, + slot: bigint, + data: Uint8Array | string, +) { + const bytes = + typeof data === "string" ? new TextEncoder().encode(data) : data; + const length = BigInt(bytes.length); + const padded = new Uint8Array(Math.ceil(bytes.length / 32) * 32); + padded.set(bytes); + const dataWord = (i: number) => + BigInt("0x" + (bytesToHex(padded.slice(i * 32, i * 32 + 32)) || "0")); + + if (length < 32n) { + expect(await result.getStorage(slot)).toBe(dataWord(0) | (length * 2n)); + return; + } + + expect(await result.getStorage(slot)).toBe(length * 2n + 1n); + const base = hash(word(slot)); + for (let i = 0; i < padded.length / 32; i++) { + expect(await result.getStorage(base + BigInt(i))).toBe(dataWord(i)); + } +} 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 b5b4eeb67a..c03116f216 100644 --- a/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx +++ b/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx @@ -303,7 +303,8 @@ For structs, BUG generates group pointers with field offsets: 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 +next storage variable must leave room for all of its slots. A struct +member that is a struct is inline too, and takes 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