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

## Unreleased

### Changed

- The IR and CFG views show each operand of a `hash` instruction, which
now has a list of `values` ([#365]).

## 0.1.0-preview.1 — 2026-10-03

Updated `@ethdebug/bugc` to `0.1.0-preview.1`.
Expand Down Expand Up @@ -34,3 +39,4 @@ First publication.

[#299]: https://github.com/ethdebug/format/pull/299
[#300]: https://github.com/ethdebug/format/pull/300
[#365]: https://github.com/ethdebug/format/pull/365
2 changes: 1 addition & 1 deletion packages/bugc-react/src/components/CfgView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ function CfgViewContent({ ir }: CfgViewProps): JSX.Element {
}
}
case "hash":
return `${inst.dest} = keccak256(${formatValue(inst.value)})`;
return `${inst.dest} = keccak256(${inst.values.map(formatValue).join(", ")})`;
case "cast":
return `${inst.dest} = cast ${formatValue(inst.value)} to ${inst.targetType.kind}`;
case "compute_slot": {
Expand Down
5 changes: 4 additions & 1 deletion packages/bugc-react/src/components/IrView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,10 @@ function InstructionRenderer({

case "hash":
add(`${formatDest(instruction.dest)} = hash `);
addOperand("value", formatValue(instruction.value));
instruction.values.forEach((value, index) => {
if (index > 0) add(", ");
addOperand("value", formatValue(value));
});
break;

case "cast":
Expand Down
4 changes: 0 additions & 4 deletions packages/bugc-react/src/utils/irDebugUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,6 @@ export function extractInstructionDebug(
operands.push({ label: "operand", debug: instruction.operandDebug });
break;

case "hash":
operands.push({ label: "value", debug: instruction.valueDebug });
break;

case "cast":
operands.push({ label: "value", debug: instruction.valueDebug });
break;
Expand Down
11 changes: 11 additions & 0 deletions packages/bugc/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ support. Changes to the specification itself are tracked in the root

### Added

- `keccak256` over value types: `keccak256(a, b, ...)` hashes the 32-byte
words of one or more integers, `address`, `bool` or `bytesN` values, in
order. For integers, `address` and `bool` this equals Solidity's
`keccak256(abi.encode(a, b, ...))`. A `bytesN` narrower than 32 bytes
hashes its word with its bytes at the right end, unlike `abi.encode`;
cast it to `bytes32` to match Solidity. A single dynamic `bytes` or
`string` argument still hashes its data, and must be the only argument.
The optimizer folds a hash of constant words at levels 1 to 3 ([#365]).
- 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 +36,8 @@ support. Changes to the specification itself are tracked in the root

### Changed

- The IR `hash` instruction now has `values`, a list, in place of
`value` and `valueDebug` ([#365]).
- An integer literal operand of an arithmetic or comparison operator now
takes the type of the other operand when its value fits, so with
`x: int8`, `x < 0`, `x == 1` and `-1 < x` compare as `int8`. A literal
Expand Down Expand Up @@ -300,4 +310,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
[#365]: https://github.com/ethdebug/format/pull/365
[#356]: https://github.com/ethdebug/format/pull/356
25 changes: 25 additions & 0 deletions packages/bugc/examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,28 @@ account.isActive = true;
accounts[user].balance = accounts[user].balance + 100;
accounts[user].isActive = true;
```

## Hashing

`keccak256` has two forms. The compiler tells them apart by the type of
the argument:

- **One dynamic `bytes` or `string`** hashes its data:
`keccak256("transfer(address,uint256)")`.
- **One or more value types** (integers, `address`, `bool`, `bytesN`)
hashes their 32-byte words, concatenated in order. This equals
Solidity's `keccak256(abi.encode(a, b, ...))` for integers, `address`
and `bool`:

```bug
// a toy roll: 2 in 3 is a hit
let hit = (keccak256(block.number, msg.sender) as uint256) % 3 != 0;
```

A `bytes` or `string` argument must be the only one.

A `bytesN` narrower than 32 bytes hashes its full word, with its bytes
at the low-order (right) end, as BUG holds it. Solidity's `abi.encode`
puts a `bytesN` value's bytes at the high-order (left) end, so for
`bytes4` and the like the hashes differ from Solidity's. Cast to
`bytes32` first (which puts the bytes at the left end) to match it.
2 changes: 1 addition & 1 deletion packages/bugc/src/evmgen/analysis/liveness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ function getUsedValues(inst: Ir.Instruction): Set<string> {
}
break;
case "hash":
addValue(inst.value);
inst.values.forEach(addValue);
break;
case "cast":
addValue(inst.value);
Expand Down
8 changes: 6 additions & 2 deletions packages/bugc/src/evmgen/analysis/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,10 +248,14 @@ function simulateInstruction(stack: string[], inst: Ir.Instruction): string[] {
// Pop consumed values based on instruction type
switch (inst.kind) {
case "binary":
case "hash":
newStack.pop(); // Two operands
newStack.pop();
break;
case "hash":
for (const _ of inst.values) {
newStack.pop();
}
break;
case "compute_slot":
// Depends on kind
newStack.pop(); // base
Expand Down Expand Up @@ -355,7 +359,7 @@ function getUsedValues(inst: Ir.Instruction): Set<string> {
addValue(inst.object);
break;
case "hash":
addValue(inst.value);
inst.values.forEach(addValue);
break;
case "assert":
addValue(inst.condition);
Expand Down
123 changes: 123 additions & 0 deletions packages/bugc/src/evmgen/behavioral.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, it, expect } from "vitest";
import { keccak256 } from "ethereum-cryptography/keccak";
import { bytesToHex } from "ethereum-cryptography/utils";

import { compile } from "#compiler";
import { executeProgram } from "#test/evm/behavioral";

describe("behavioral tests", () => {
Expand Down Expand Up @@ -1289,6 +1290,128 @@ code {
}
});

describe("keccak256 over words", () => {
// Solidity's keccak256(abi.encode(...)) for value types: each value
// is one 32-byte word, in order
const word = (value: bigint) =>
BigInt.asUintN(256, value).toString(16).padStart(64, "0");
const hashOfWords = (...values: bigint[]) =>
BigInt(
"0x" +
bytesToHex(
keccak256(
Uint8Array.from(Buffer.from(values.map(word).join(""), "hex")),
),
),
);

const sender = 1n; // @ethdebug/evm calls from address 0x00..01

const hashes: Record<string, [string, bigint]> = {
"two uint256 constants": [
`out = keccak256(7, 9) as uint256;`,
hashOfWords(7n, 9n),
],
"a uint256 and an address": [
`let n: uint256 = 42;
out = keccak256(n, msg.sender) as uint256;`,
hashOfWords(42n, sender),
],
"storage values": [
`out = keccak256(a, b) as uint256;`,
hashOfWords(1071n, 462n),
],
"narrow integers and a bool": [
`let x: uint8 = 200;
let t = true;
out = keccak256(x, t) as uint256;`,
hashOfWords(200n, 1n),
],
"a negative int8": [
`let x = (0 as int8) - 3;
out = keccak256(x, a) as uint256;`,
hashOfWords(-3n, 1071n),
],
"a bytes32 and three words": [
`let h: bytes32 =
0x1122334400000000000000000000000000000000000000000000000000000000;
out = keccak256(h, a, b) as uint256;`,
hashOfWords(
0x1122334400000000000000000000000000000000000000000000000000000000n,
1071n,
462n,
),
],
// Not as in Solidity, which left-aligns bytesN in abi.encode
"a bytes4, by its right-aligned word": [
`let s: bytes4 = 0xaabbccdd;
out = keccak256(s, a) as uint256;`,
hashOfWords(0xaabbccddn, 1071n),
],
"a bytes4 cast to bytes32, as Solidity encodes it": [
`let s: bytes4 = 0xaabbccdd;
out = keccak256(s as bytes32, a) as uint256;`,
hashOfWords(0xaabbccddn << 224n, 1071n),
],
"one uint256": [`out = keccak256(a) as uint256;`, hashOfWords(1071n)],
"a roll": [
`if ((keccak256(a, msg.sender) as uint256) % 3 != 0) { out = 1; }`,
hashOfWords(1071n, sender) % 3n !== 0n ? 1n : 0n,
],
};

for (const [name, [body, expected]] of Object.entries(hashes)) {
for (const level of [0, 1, 2, 3] as const) {
it(`should hash ${name} (level ${level})`, async () => {
const source = `name HashWords;
storage {
[0] a: uint256;
[1] b: uint256;
[2] out: uint256;
}
create { a = 1071; b = 462; }
code {
${body}
}`;
const result = await executeProgram(source, {
calldata: "",
optimizationLevel: level,
});

expect(result.callSuccess).toBe(true);
expect(await result.getStorage(2n)).toBe(expected);
});
}
}

for (const level of [1, 2, 3] as const) {
it(`should fold a hash of constant words (level ${level})`, async () => {
const result = await compile({
to: "ir",
source: `name Fold;
storage { [0] out: uint256; }
code { out = keccak256(7, 9) as uint256; }`,
optimizer: { level },
});
if (!result.success) throw new Error("compile failed");

const instructions = [...result.value.ir.main.blocks.values()].flatMap(
(block) => block.instructions,
);
expect(instructions.some(({ kind }) => kind === "hash")).toBe(false);
expect(instructions).toContainEqual(
expect.objectContaining({
kind: "write",
value: expect.objectContaining({
kind: "const",
value: hashOfWords(7n, 9n),
}),
}),
);
});
}
});

describe("modulo", () => {
const program = (expr: string) => `name Modulo;

Expand Down
52 changes: 45 additions & 7 deletions packages/bugc/src/evmgen/generation/instructions/hash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,64 @@ import type * as Ir from "#ir";
import type { Stack } from "#evm";

import { type Transition, pipe, operations } from "#evmgen/operations";
import { Memory } from "#evmgen/analysis";

import { loadValue, storeValueIfNeeded } from "../values/index.js";

const { PUSHn, MSTORE, KECCAK256 } = operations;
const { PUSHn, MSTORE, MLOAD, ADD, KECCAK256 } = operations;

const freeMemoryPointer = BigInt(Memory.regions.FREE_MEMORY_POINTER);

/**
* Generate code for hash operations
* Generate code for hash operations: keccak256 of the values' 32-byte
* words, in order. One or two words go in the scratch space at 0x00;
* more go in the free memory, which they do not allocate.
*/
export function generateHashOp<S extends Stack>(
inst: Ir.Instruction.Hash,
): Transition<S, readonly ["value", ...S]> {
const debug = inst.operationDebug;
const size = BigInt(inst.values.length * 32);

if (inst.values.length <= 2) {
const stores = inst.values.map((value, index) =>
pipe<S>()
.then(loadValue(value, { debug }))
.then(PUSHn(BigInt(index * 32), { debug }), { as: "offset" })
.then(MSTORE({ debug }))
.done(),
);
return pipe<S>()
.then(sequence(stores))
.then(PUSHn(size, { debug }), { as: "size" })
.then(PUSHn(0n, { debug }), { as: "offset" })
.then(KECCAK256({ debug }), { as: "value" })
.then(storeValueIfNeeded(inst.dest, { debug }))
.done();
}

const stores = inst.values.map((value, index) =>
pipe<S>()
.then(loadValue(value, { debug }))
.then(PUSHn(BigInt(index * 32), { debug }), { as: "b" })
.then(PUSHn(freeMemoryPointer, { debug }), { as: "offset" })
.then(MLOAD({ debug }), { as: "a" })
.then(ADD({ debug }), { as: "offset" })
.then(MSTORE({ debug }))
.done(),
);
return pipe<S>()
.then(loadValue(inst.value, { debug }))
.then(PUSHn(0n, { debug }), { as: "offset" })
.then(MSTORE({ debug }))
.then(PUSHn(32n, { debug }), { as: "size" })
.then(PUSHn(0n, { debug }), { as: "offset" })
.then(sequence(stores))
.then(PUSHn(size, { debug }), { as: "size" })
.then(PUSHn(freeMemoryPointer, { debug }), { as: "offset" })
.then(MLOAD({ debug }), { as: "offset" })
.then(KECCAK256({ debug }), { as: "value" })
.then(storeValueIfNeeded(inst.dest, { debug }))
.done();
}

function sequence<S extends Stack>(
steps: Transition<S, S>[],
): Transition<S, S> {
return (state) => steps.reduce((current, step) => step(current), state);
}
2 changes: 1 addition & 1 deletion packages/bugc/src/ir/analysis/formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ export class Formatter {
return `${destWithType(inst.dest)} = env ${inst.op}`;

case "hash":
return `${destWithType(inst.dest)} = hash ${this.formatValue(inst.value)}`;
return `${destWithType(inst.dest)} = hash ${inst.values.map((value) => this.formatValue(value)).join(", ")}`;

case "cast":
return `${destWithType(inst.dest, inst.targetType)} = cast ${this.formatValue(inst.value)} to ${this.formatType(inst.targetType)}`;
Expand Down
4 changes: 2 additions & 2 deletions packages/bugc/src/ir/analysis/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,10 +268,10 @@ export class Validator {
this.tempDefs.add(inst.dest);
}

if (!inst.value) {
if (!inst.values?.length) {
this.error("Hash instruction must have a value");
} else {
this.validateValue(inst.value);
inst.values.forEach((value) => this.validateValue(value));
}
}

Expand Down
8 changes: 6 additions & 2 deletions packages/bugc/src/ir/spec/instruction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,10 +388,14 @@ export namespace Instruction {
dest: string;
}

/**
* keccak256 of the 32-byte words of `values`, in order. A single
* memory reference (dynamic `bytes` or a `string`) instead hashes
* the data it refers to.
*/
export interface Hash extends Instruction.Base {
kind: "hash";
value: Value;
valueDebug?: Instruction.Debug;
values: Value[];
dest: string;
}

Expand Down
Loading
Loading