Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/bugc/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
26 changes: 26 additions & 0 deletions packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
201 changes: 201 additions & 0 deletions packages/bugc/src/evmgen/generation/instructions/storage.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -405,6 +408,10 @@ function generateStorageWrite<S extends Stack>(
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<S>()
Expand Down Expand Up @@ -478,3 +485,197 @@ function generateStorageWrite<S extends Stack>(
.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<Stack>) => State<Stack>;
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 <mask>
* 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 <mask> 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
*
* `<mask>` 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<S extends Stack>(
inst: Ir.Instruction.Write,
debug: Ir.Instruction.Debug,
): Transition<S, S> {
const raw = operations as unknown as Record<string, Op>;
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<Stack>): State<Stack> => {
const id = state.nextId;
const long = `$bytes_long_${id}`;
const loop = `$bytes_loop_${id}`;
const end = `$bytes_end_${id}`;

const loaded = pipe<Stack>()
.then(loadValue(inst.value!, { debug }), { as: "value" })
.then(loadValue(inst.slot!, { debug }), { as: "key" })
.done()({ ...state, nextId: id + 1 }) as State<Stack>;

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<State<Stack>>((current, step) => step(current), loaded);
}) as unknown as Transition<S, S>;
}
111 changes: 111 additions & 0 deletions packages/bugc/src/evmgen/string-storage.test.ts
Original file line number Diff line number Diff line change
@@ -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<uint256, string>; }
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)));
});
}
}
});
5 changes: 4 additions & 1 deletion packages/bugc/src/irgen/generate/expressions/access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading