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
13 changes: 13 additions & 0 deletions packages/bugc/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ support. Changes to the specification itself are tracked in the root

### Added

- `push` on a dynamic array in storage, as in Solidity: `a.push(v)`
writes `v` at index `a.length` and adds one to the length, and
`a.push()` adds a zero element, as for an array of arrays. `a` may be
in a mapping or a struct. A push has no value. On an array in memory
or a fixed-size array, it is a type error ([#366]).
- The `%` operator, with the precedence of `*` and `/`. It compiles to
the EVM's `MOD` (`SMOD` for signed operands), so `x % 0` is `0`, as
`x / 0` is ([#321]).
Expand All @@ -28,6 +33,11 @@ support. Changes to the specification itself are tracked in the root

### Changed

- An index into a dynamic array in storage now reverts with Solidity's
`Panic(0x32)` unless it is less than the array's length, for reads and
writes, as an index into an array in memory does. Before, a write past
the end wrote there and left the length as it was, so the array's
pointer did not show the element ([#366]).
- Storage arrays now have Solidity's layout. A fixed-size array is
inline: its elements start at its own slot, so `[0] a: array<uint256,
3>` takes slots 0 to 2, and a struct member that is a fixed-size array
Expand Down Expand Up @@ -90,6 +100,8 @@ support. Changes to the specification itself are tracked in the root

### Fixed

- `a.length` on a dynamic array in storage now reads the length in the
array's slot. Before, it did not read that slot ([#366]).
- At optimization levels 2 and 3, an inlined body's control flow now has
a `transform: ["inline"]` context: the branch of an `if`, the jumps
between the body's blocks, each block's `JUMPDEST`, and the jump that
Expand Down Expand Up @@ -313,3 +325,4 @@ First publication.
[#352]: https://github.com/ethdebug/format/pull/352
[#356]: https://github.com/ethdebug/format/pull/356
[#364]: https://github.com/ethdebug/format/pull/364
[#366]: https://github.com/ethdebug/format/pull/366
13 changes: 10 additions & 3 deletions packages/bugc/examples/advanced/nested-arrays.bug
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name NestedArrays;
// Example 7: Nested Arrays
//
// Expected IR:
// // First dimension: matrix[i]
// // First dimension: matrix[i], checked against matrix's length
// %outer_base = compute_slot kind="array", base=15
// %inner_array_slot = add %outer_base, %i
//
Expand All @@ -23,8 +23,15 @@ storage {
}

code {
let i = 2;
let j = 5;
// An index must be below the length, so push the rows and elements
// first: matrix[1] gets two elements
matrix.push();
matrix.push();
matrix[1].push(0);
matrix[1].push(0);

let i = 1;
let j = 1;
let value = 999;
matrix[i][j] = value;
let elem = matrix[i][j];
Expand Down
3 changes: 2 additions & 1 deletion packages/bugc/src/evmgen/storage-array-layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,9 @@ code { ps[1].a = 6; ps[1].b = 7; ps[0].b = 8; r = ps[1].b; }`,
[0] d: array<uint16>;
[1] r: uint256;
}
code { d[0] = 3; d[1] = 5; r = d[1] as uint256; }`,
code { d.push(3); d.push(5); r = d[1] as uint256; }`,
slots: [
[0n, 2n],
[hashed(0n), 0x00050003n],
[1n, 5n],
],
Expand Down
172 changes: 172 additions & 0 deletions packages/bugc/src/evmgen/storage-array-push.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/**
* `push` on a dynamic array in storage, as in Solidity: `a.push(v)`
* writes `v` at index `a.length`, then adds one to the length;
* `a.push()` adds a zero element. An index at or past the length
* reverts with Panic(0x32), for reads and writes, as in memory.
*/
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 { compile } from "#compiler";
import { executeProgram } from "#test/evm/behavioral";
import { traceLocals } from "#test/evm/locals";

const levels = [0, 1, 2, 3] as const;

const panic32 = "4e487b71" + 0x32n.toString(16).padStart(64, "0");

function word(value: bigint): Uint8Array {
const bytes = new Uint8Array(32);
for (let i = 0; i < 32; i++) {
bytes[31 - i] = Number((value >> BigInt(8 * i)) & 0xffn);
}
return bytes;
}

/** keccak256 of words, as a dynamic array's first slot */
function hashed(...values: bigint[]): bigint {
const data = new Uint8Array(32 * values.length);
values.forEach((value, i) => data.set(word(value), 32 * i));
return BigInt("0x" + bytesToHex(keccak256(data)));
}

const passes: Record<string, [string, [bigint, bigint][]]> = {
"push values and read them back": [
`storage { [0] h: array<uint256>; [1] r: uint256; }
code { h.push(5); h.push(7); r = h[1] + h.length; }`,
[
[0n, 2n],
[hashed(0n), 5n],
[hashed(0n) + 1n, 7n],
[1n, 9n],
],
],
"push narrow elements into one slot": [
`storage { [0] h: array<uint8>; }
code { h.push(1); h.push(2); }`,
[
[0n, 2n],
[hashed(0n), 0x0201n],
],
],
"push an empty element, then into it": [
`storage { [0] m: array<array<uint256>>; [1] r: uint256; }
code { m.push(); m[0].push(4); r = m[0][0] + m.length + m[0].length; }`,
[
[0n, 1n],
[hashed(0n), 1n],
[hashed(hashed(0n)), 4n],
[1n, 6n],
],
],
"push into a mapping's array": [
`storage { [0] q: mapping<uint256, array<uint256>>; [1] r: uint256; }
code { q[3].push(9); r = q[3][0]; }`,
[
[hashed(3n, 0n), 1n],
[hashed(hashed(3n, 0n)), 9n],
[1n, 9n],
],
],
"push into a struct's array": [
`define { struct S { n: uint8; xs: array<uint256>; }; }
storage { [0] s: S; [2] r: uint256; }
code { s.n = 1; s.xs.push(6); r = s.xs[0]; }`,
[
[0n, 1n],
[1n, 1n],
[hashed(1n), 6n],
[2n, 6n],
],
],
"write an element below the length": [
`storage { [0] h: array<uint256>; }
code { h.push(1); h.push(2); h[1] = 3; }`,
[[hashed(0n) + 1n, 3n]],
],
};

const reverts: Record<string, string> = {
"write at the length": `storage { [0] h: array<uint256>; }
code { h.push(1); h[1] = 2; }`,
"read an empty array": `storage { [0] h: array<uint256>; [1] r: uint256; }
code { r = h[0]; }`,
"write into an empty inner array": `storage { [0] m: array<array<uint256>>; }
code { m.push(); m[0][0] = 1; }`,
};

describe("push on a storage array", () => {
for (const [name, [body, slots]] of Object.entries(passes)) {
for (const level of levels) {
it(`should ${name} (level ${level})`, async () => {
const result = await executeProgram(`name Push;\n${body}`, {
calldata: "",
optimizationLevel: level,
});
expect(result.callSuccess).toBe(true);
for (const [slot, value] of slots) {
expect(await result.getStorage(slot), `slot ${slot}`).toBe(value);
}
});
}
}

for (const [name, body] of Object.entries(reverts)) {
for (const level of levels) {
it(`should revert: ${name} (level ${level})`, async () => {
const result = await executeProgram(`name Push;\n${body}`, {
calldata: "",
optimizationLevel: level,
});
expect(result.callSuccess).toBe(false);
expect(bytesToHex(result.returnValue)).toBe(panic32);
});
}
}

it("gives the pushed elements to the array's pointer", async () => {
const { program, executor, steps } = await traceLocals(
`name Push;\nstorage { [0] h: array<uint16>; }
code { h.push(5); h.push(7); h.push(9); }`,
);
const state = createMachineState(executor, {
traceStep: steps[steps.length - 1],
});
const variable = (
program.context as Format.Program.Context.Variables
).variables.find((v) => v.identifier === "h")!;
const cursor = await dereference(variable.pointer!, { state });
const view = await cursor.view(state);
const read = await Promise.all(
view.regions
.filter((region) => region.name === "element")
.map(async (region) => (await view.read(region)).asUint()),
);
expect(read).toEqual([5n, 7n, 9n]);
});
});

describe("push type errors", () => {
const errors: Record<string, string> = {
"push on a fixed-size array": `storage { [0] f: array<uint256, 3>; }
code { f.push(1); }`,
"push on a memory array": `code { let a: array<uint256> = [1, 2]; a.push(3); }`,
"push a value of the wrong type": `storage { [0] h: array<uint8>; }
code { h.push(true); }`,
"push two values": `storage { [0] h: array<uint256>; }
code { h.push(1, 2); }`,
};
for (const [name, body] of Object.entries(errors)) {
it(`rejects ${name}`, async () => {
const result = await compile({
to: "bytecode",
source: `name Push;\n${body}`,
});
expect(result.success).toBe(false);
});
}
});
8 changes: 8 additions & 0 deletions packages/bugc/src/irgen/generate/expressions/access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,14 @@ const makeBuildMemberAccess = (
return Ir.Value.temp(tempId, resultType);
}

// A dynamic array in storage keeps its length in its slot
if (Type.isArray(objectType)) {
const chain = yield* findStorageAccessChain(expr.object);
if (chain) {
return yield* emitStorageChainLoad(chain, resultType, expr);
}
}

// For dynamic arrays/bytes/strings, emit length instruction
const object = yield* buildExpression(expr.object, { kind: "rvalue" });
yield* Process.Instructions.emit({
Expand Down
2 changes: 2 additions & 0 deletions packages/bugc/src/irgen/generate/expressions/array.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export function* buildArray(
slot,
Ir.Value.constant(BigInt(i), Ir.Type.Scalar.uint256),
expr,
// Each index is below the literal's length
false,
);
yield* Process.Instructions.emit({
kind: "write",
Expand Down
87 changes: 86 additions & 1 deletion packages/bugc/src/irgen/generate/expressions/call.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type * as Ast from "#ast";
import * as Ast from "#ast";
import * as Ir from "#ir";
import { Severity } from "#result";
import { Type } from "#types";
Expand All @@ -7,6 +7,12 @@ import { Error as IrgenError } from "#irgen/errors";
import { Process } from "../process.js";
import type { Context } from "./context.js";
import { fromBugType } from "#irgen/type";
import {
type StorageAccessChain,
findStorageAccessChain,
emitStorageChainLoad,
emitStorageChainStore,
} from "../storage.js";

/**
* Build a call expression
Expand All @@ -21,6 +27,15 @@ export const makeBuildCall = (
expr: Ast.Expression.Call,
_context: Context,
): Process<Ir.Value> {
// `a.push(v)` or `a.push()` on a dynamic array in storage
if (
Ast.Expression.isAccess(expr.callee) &&
Ast.Expression.Access.isMember(expr.callee) &&
expr.callee.property === "push"
) {
return yield* buildPush(expr, expr.callee, buildExpression);
}

// Check if this is a built-in function call
if (
expr.callee.kind === "expression:identifier" &&
Expand Down Expand Up @@ -123,3 +138,73 @@ export const makeBuildCall = (
);
return Ir.Value.constant(0n, Ir.Type.Scalar.uint256);
};

/**
* Push onto a dynamic array in storage, as Solidity does: write the
* value (if any) at index `length`, then store `length + 1`. With no
* value, the new element is zero: nothing can have written past the
* length.
*/
function* buildPush(
expr: Ast.Expression.Call,
callee: Ast.Expression.Access.Member,
buildExpression: (
node: Ast.Expression,
context: Context,
) => Process<Ir.Value>,
): Process<Ir.Value> {
const marker = Ir.Value.constant(0n, Ir.Type.Scalar.uint256);
const value =
expr.arguments.length > 0
? yield* buildExpression(expr.arguments[0], { kind: "rvalue" })
: undefined;

const chain = yield* findStorageAccessChain(callee.object);
if (!chain) {
yield* Process.Errors.report(
new IrgenError(
"push needs a dynamic array in storage",
expr.loc ?? undefined,
Severity.Error,
),
);
return marker;
}
// Each use of the chain gets its own copy of its accesses
const copy = (
extra: StorageAccessChain["accesses"] = [],
): StorageAccessChain => ({
slot: chain.slot,
accesses: [...chain.accesses.map((access) => ({ ...access })), ...extra],
});

const length = yield* emitStorageChainLoad(
copy(),
Ir.Type.Scalar.uint256,
expr,
);
if (value) {
yield* emitStorageChainStore(
copy([{ kind: "index", key: length, unchecked: true }]),
value,
expr,
);
}

const next = yield* Process.Variables.newTemp();
yield* Process.Instructions.emit({
kind: "binary",
op: "add",
left: length,
right: Ir.Value.constant(1n, Ir.Type.Scalar.uint256),
dest: next,
operationDebug: yield* Process.Debug.forAstNode(expr),
} as Ir.Instruction.BinaryOp);
yield* emitStorageChainStore(
copy(),
Ir.Value.temp(next, Ir.Type.Scalar.uint256),
expr,
);

return marker;
}
Loading
Loading