diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index f7b5d6634e..2411e70298 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -89,6 +89,12 @@ support. Changes to the specification itself are tracked in the root has the branch's context: its source range, its variables (the storage variables too) and its transforms. Before, it had no context, so a debugger listed no variables while it ran ([#356]). +- Assigning a memory `string` or `bytes` to storage, as in `s = m;`, now + stores its bytes as Solidity does: up to 31 bytes in the slot, with + twice the length in the low byte; else twice the length plus one in the + slot, and the data from `keccak256(slot)`. A slice's bytes past its + length are stored as zero. Before, the slot held the value's memory + address ([#355]). - A call to a function with no return type now compiles as a statement, as in `bump();`, at every optimization level. The function's `return` context has no `data`. Before, IR generation failed with "Cannot convert @@ -300,4 +306,5 @@ First publication. [#349]: https://github.com/ethdebug/format/pull/349 [#351]: https://github.com/ethdebug/format/pull/351 [#352]: https://github.com/ethdebug/format/pull/352 +[#355]: https://github.com/ethdebug/format/pull/355 [#356]: https://github.com/ethdebug/format/pull/356 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 f2ab39d7f7..b6bd550d22 100644 --- a/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts +++ b/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts @@ -982,6 +982,32 @@ code { }, }, }, + { + // Storing a string copies its bytes in a loop, which holds + // words of its own on the stack + name: "strings stored to storage", + source: `name StringStore; +storage { [0] r: uint256; [1] s: string; [2] t: string; } +create { r = 1; } +code { + let n = r; + let short = "hello"; + let long = "hello world, this is longer than thirty-two bytes"; + s = short; + t = long; + if (n > 0) { r = n + short.length + long.length; } +}`, + locals: { + n: { values: [1n] }, + short: { shape: { kind: "bytes" }, values: [textBytes("hello")] }, + long: { + shape: { kind: "bytes" }, + values: [ + textBytes("hello world, this is longer than thirty-two bytes"), + ], + }, + }, + }, { name: "bytes literal", source: `name BytesLit; diff --git a/packages/bugc/src/evmgen/generation/instructions/storage.ts b/packages/bugc/src/evmgen/generation/instructions/storage.ts index c65c44dc5a..6ada855ae3 100644 --- a/packages/bugc/src/evmgen/generation/instructions/storage.ts +++ b/packages/bugc/src/evmgen/generation/instructions/storage.ts @@ -1,7 +1,10 @@ import * as Ir from "#ir"; import type { Stack } from "#evm"; +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 { loadValue, storeValueIfNeeded } from "../values/index.js"; import { generateCastSteps } from "./cast.js"; @@ -405,6 +408,10 @@ function generateStorageWrite( const offset = inst.offset?.kind === "const" ? inst.offset.value : 0n; const length = inst.length?.kind === "const" ? inst.length.value : 32n; + if (isMemoryBytes(inst.value!.type)) { + return generateBytesStorageWrite(inst, debug); + } + if (offset === 0n && length === 32n) { // Full slot write - simple SSTORE return pipe() @@ -478,3 +485,197 @@ function generateStorageWrite( .done() ); } + +/** + * Whether a value is a reference to a string or `bytes` in memory: a + * length word, then the data + */ +function isMemoryBytes(type: Ir.Type): boolean { + return ( + type.kind === "ref" && + type.location === "memory" && + type.origin !== "synthetic" && + BugType.isElementary(type.origin) && + (BugType.Elementary.isString(type.origin) || + (BugType.Elementary.isBytes(type.origin) && + type.origin.size === undefined)) + ); +} + +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. + */ +function generateBytesStorageWrite( + inst: Ir.Instruction.Write, + debug: Ir.Instruction.Debug, +): Transition { + const raw = operations as unknown as Record; + const op = + (name: string): Step => + (state) => + raw[name]({ debug })(state); + const push = + (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) => { + const index = state.instructions.length; + const pushed = (operations.PUSH2([0, 0], { debug }) as unknown as Step)( + state, + ); + return { + ...pushed, + patches: [...pushed.patches, { index, target: label }], + }; + }; + const mark = + (label: string): Step => + (state) => { + const marked = { + ...state, + blockOffsets: { + ...state.blockOffsets, + [label]: calculateSize(state.instructions), + }, + }; + return op("JUMPDEST")(marked); + }; + + const mask: Step[] = [ + op("DUP1"), + push(3n), + op("SHL"), + push(256n), + op("SUB"), + op("SWAP1"), + push(32n), + op("SWAP1"), + op("LT"), + op("MUL"), + op("DUP1"), + op("SWAP2"), + op("SWAP1"), + op("SHR"), + op("SWAP1"), + op("SHL"), + ]; + + return ((state: State): State => { + const id = state.nextId; + const long = `$bytes_long_${id}`; + const loop = `$bytes_loop_${id}`; + const end = `$bytes_end_${id}`; + + const loaded = pipe() + .then(loadValue(inst.value!, { debug }), { as: "value" }) + .then(loadValue(inst.slot!, { debug }), { as: "key" }) + .done()({ ...state, nextId: id + 1 }) as State; + + const steps: Step[] = [ + op("DUP2"), + op("MLOAD"), + push(31n), + op("DUP2"), + op("GT"), + target(long), + op("JUMPI"), + + op("DUP3"), + push(32n), + op("ADD"), + op("MLOAD"), + op("DUP2"), + ...mask, + op("DUP2"), + op("DUP1"), + op("ADD"), + op("OR"), + op("DUP3"), + op("SSTORE"), + target(end), + op("JUMP"), + + mark(long), + op("DUP1"), + op("DUP1"), + op("ADD"), + push(1n), + op("ADD"), + op("DUP3"), + op("SSTORE"), + op("DUP2"), + push(0n), + op("MSTORE"), + push(32n), + push(0n), + op("KECCAK256"), + op("DUP4"), + push(32n), + op("ADD"), + op("DUP3"), + op("DUP2"), + op("ADD"), + + mark(loop), + op("DUP2"), + op("MLOAD"), + op("DUP3"), + op("DUP3"), + op("SUB"), + ...mask, + op("DUP4"), + op("SSTORE"), + op("SWAP2"), + push(1n), + op("ADD"), + op("SWAP2"), + op("SWAP1"), + push(32n), + op("ADD"), + op("SWAP1"), + op("DUP2"), + op("DUP2"), + op("GT"), + target(loop), + op("JUMPI"), + op("POP"), + op("POP"), + op("POP"), + + mark(end), + op("POP"), + op("POP"), + op("POP"), + ]; + + return steps.reduce>((current, step) => step(current), loaded); + }) as unknown as Transition; +} diff --git a/packages/bugc/src/evmgen/string-storage.test.ts b/packages/bugc/src/evmgen/string-storage.test.ts new file mode 100644 index 0000000000..8bbf36a1ae --- /dev/null +++ b/packages/bugc/src/evmgen/string-storage.test.ts @@ -0,0 +1,111 @@ +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 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) { + it(`stores ${length} bytes (level ${level})`, async () => { + const value = text(length); + const source = `name StringToStorage; +storage { [0] s: string; [1] t: string; [2] u: mapping; } +code { + let m: string = "${value}"; + s = m; + t = "${value}"; + u[7] = m; +}`; + + const result = await executeProgram(source, { + calldata: "", + optimizationLevel: level, + }); + + expect(result.callSuccess).toBe(true); + const data = new TextEncoder().encode(value); + await expectEncoded(result, 0n, data); + await expectEncoded(result, 1n, data); + await expectEncoded(result, mappingSlot(2n, 7n), data); + }); + } + } +}); + +describe("assigning memory bytes to storage", () => { + // Slices of a 70-byte literal. Memory past the end of a slice's + // data may hold other bytes; those must not reach storage. + for (const level of levels) { + for (const length of lengths) { + it(`stores ${length} bytes (level ${level})`, async () => { + const source = `name BytesToStorage; +storage { [0] b: bytes; } +code { + let m: bytes = 0x${hex(70)}; + ${length === 70 ? "b = m;" : `b = m[0:${length}];`} +}`; + + const result = await executeProgram(source, { + calldata: "", + optimizationLevel: level, + }); + + expect(result.callSuccess).toBe(true); + await expectEncoded(result, 0n, hexToBytes(hex(length))); + }); + } + } +}); diff --git a/packages/bugc/src/irgen/generate/expressions/access.ts b/packages/bugc/src/irgen/generate/expressions/access.ts index ab6ac3772d..82aa47edf8 100644 --- a/packages/bugc/src/irgen/generate/expressions/access.ts +++ b/packages/bugc/src/irgen/generate/expressions/access.ts @@ -212,7 +212,10 @@ const makeBuildSliceAccess = ( yield* emitBoundsCheck("le", end, objectLength, expr); // Slicing bytes returns dynamic bytes (memory reference) - const resultType: Ir.Type = Ir.Type.Ref.memory(); + const resultType: Ir.Type = Ir.Type.ref( + "memory", + Type.Elementary.bytes(), + ); // Calculate the length of the slice const lengthTemp = yield* Process.Variables.newTemp();