Skip to content
Merged
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
35 changes: 34 additions & 1 deletion packages/bugc/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ support. Changes to the specification itself are tracked in the root
### Added

- The `%` operator, with the precedence of `*` and `/`. It compiles to
the EVM's unsigned `MOD`, so `x % 0` is `0`, as `x / 0` is ([#321]).
the EVM's `MOD` (`SMOD` for signed operands), so `x % 0` is `0`, as
`x / 0` is ([#321]).
- Each instruction has a `variables` context that lists the local
variables in scope (the innermost declaration of each name, which hides
a storage variable of that name), with a type, and a pointer to the
Expand All @@ -24,6 +25,16 @@ support. Changes to the specification itself are tracked in the root

### Changed

- 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
that type cannot hold, such as `x < 128`, is a type error. Before,
every integer literal was a `uint256`, so `x == 1` was a type error
while `x < 1` compiled ([#339]).
- Arithmetic and comparison operators now reject operands of mixed
signedness, as Solidity does: cast one operand to the other's type.
Before, a comparison such as `u > x`, with `u: uint256` and `x: int8`,
compiled ([#339]).
- A cast now converts its value as Solidity does. A cast to a narrower
integer type or to `address` keeps the low bits, and a cast to a signed
type then sign-extends: `300 as uint8` is 44, `200 as int8` is -56, and
Expand Down Expand Up @@ -121,6 +132,27 @@ support. Changes to the specification itself are tracked in the root
- A local initialized with a string literal (`let s = "hello";`) now
holds the string. Before, it held an empty allocation, so `s.length`
was `0` ([#328]).
- Comparisons (`<`, `<=`, `>`, `>=`) of signed integers are now signed.
Before, at every optimization level, they compiled to the EVM's
unsigned `LT` and `GT`, so a negative value compared greater than any
positive one: `(-56 as int8) < (0 as int8)` was false. The optimizer
folds constant comparisons by the same rule ([#339]).
- `/` and `%` on signed integers now compile to the EVM's `SDIV` and
`SMOD`, so division truncates toward zero and a remainder has the sign
of the dividend, as in Solidity. Before, at every optimization level,
they were unsigned, so `(-7 as int8) / (2 as int8)` was a large
positive number. Arithmetic does not check for overflow, so the least
`int256` divided by `-1` is itself. A result narrower than a word does
not wrap to its type yet: `(-128 as int8) / (-1 as int8)` is 128, and
the same goes for a narrow `+`, `-` or `*` that overflows; a follow-up
change makes them wrap. The optimizer folds constants by the same rules
([#339]).
- A write to a mapping value or array element narrower than a slot now
writes only that value's bytes. Before, at every optimization level,
`m[5] = -2 as int16` with `m: mapping<uint256, int16>` stored a full
word of `ff` bytes ending in `fffe`, where Solidity stores `fffe`.
bugc gives each array element its own slot, without Solidity's
packing, so only a mapping value's layout matches Solidity's ([#339]).

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

Expand Down Expand Up @@ -170,3 +202,4 @@ First publication.
[#335]: https://github.com/ethdebug/format/pull/335
[#336]: https://github.com/ethdebug/format/pull/336
[#337]: https://github.com/ethdebug/format/pull/337
[#339]: https://github.com/ethdebug/format/pull/339
2 changes: 1 addition & 1 deletion packages/bugc/src/evm/spec/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ export const makeOperations = <U, I>(controls: State.Controls<U, I>) => {
}),
),
...mapInstruction(
{ opcode: 0x13, mnemonic: "SGT" },
{ opcode: 0x13, mnemonic: "SGT" } as const,
makeOperationForInstruction({
consumes: ["a", "b"] as const,
produces: ["a > b"] as const,
Expand Down
285 changes: 285 additions & 0 deletions packages/bugc/src/evmgen/behavioral.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1549,4 +1549,289 @@ code {
});
}
});

describe("signed operators", () => {
const word = 2n ** 256n;
const toWord = (n: bigint) => ((n % word) + word) % word;
// A negative literal is a cast of its two's complement, which the
// optimizer folds (it does not fold negation)
const literal = (n: bigint, type: string) => {
const bits = BigInt(type.replace(/^u?int/, ""));
return `(${n < 0n ? 2n ** bits + n : n} as ${type})`;
};

// Store the result of each statement on operands `a` and `b` of
// `type`, read at runtime from int256 storage, or given as
// literals, which the optimizer folds
const program = (
type: string,
[a, b]: readonly [bigint, bigint],
statements: readonly ((
a: string,
b: string,
out: string,
type: string,
) => string)[],
folded: boolean,
) => {
const out = type.startsWith("u") ? "uint256" : "int256";
const outputs = statements.map((_, j) => `[${10 + j}] o${j}: ${out};`);
const [x, y] = folded ? [literal(a, type), literal(b, type)] : ["x", "y"];
return `name SignedOperators;
storage {
[0] a: int256;
[1] b: int256;
${outputs.join("\n ")}
}
create {
a = ${literal(a, "int256")};
b = ${literal(b, "int256")};
}
code {
let x = a as ${type};
let y = b as ${type};
${statements.map((statement, j) => statement(x, y, `o${j}`, out)).join("\n ")}
}`;
};

const check = async (
source: string,
level: 0 | 1 | 2 | 3,
expected: readonly bigint[],
) => {
const result = await executeProgram(source, {
calldata: "",
optimizationLevel: level,
});
expect(result.callSuccess).toBe(true);
const actual = [];
for (const j of expected.keys()) {
actual.push(await result.getStorage(10n + BigInt(j)));
}
expect(actual).toEqual(expected.map(toWord));
};

const min = -(2n ** 255n);

describe("comparisons", () => {
const comparisons = [
["<", (a: bigint, b: bigint) => a < b],
["<=", (a: bigint, b: bigint) => a <= b],
[">", (a: bigint, b: bigint) => a > b],
[">=", (a: bigint, b: bigint) => a >= b],
["==", (a: bigint, b: bigint) => a === b],
["!=", (a: bigint, b: bigint) => a !== b],
] as const;

// Each comparison stores 1 when it holds
const statements = comparisons.map(
([op]) =>
(a: string, b: string, out: string, type: string) =>
`if (${a} ${op} ${b}) { ${out} = 1 as ${type}; }`,
);

const signed = [
[-3n, -2n],
[-2n, -3n],
[-5n, 4n],
[4n, -5n],
[-7n, -7n],
[6n, 6n],
] as const;

const cases = [
...["int8", "int16", "int128", "int256"].flatMap((type) =>
signed.map((operands) => [type, operands] as const),
),
["int256", [min, -min - 1n]],
["uint256", [2n ** 255n, 1n]],
["uint256", [1n, 2n ** 255n]],
] as const;

for (const [type, operands] of cases) {
const [a, b] = operands;
const expected = comparisons.map(([, compare]) =>
compare(a, b) ? 1n : 0n,
);
for (const folded of [false, true]) {
const source = program(type, operands, statements, folded);
for (const level of [0, 1, 2, 3] as const) {
it(`should compare ${type} ${operands.join(" and ")}${folded ? " as literals" : ""} (level ${level})`, async () => {
await check(source, level, expected);
});
}
}
}
});

describe("literal operands", () => {
// A literal takes the type of the other operand
const statements = [
(x: string, _: string, out: string) =>
`if (${x} < 0) { ${out} = 1 as int256; }`,
(x: string, _: string, out: string) =>
`if (${x} == 1) { ${out} = 1 as int256; }`,
(x: string, _: string, out: string) =>
`if (-1 < ${x}) { ${out} = 1 as int256; }`,
(x: string, _: string, out: string) =>
`if (0 >= ${x}) { ${out} = 1 as int256; }`,
(x: string, _: string, out: string) => `${out} = ${x} / -2;`,
];
const expected = (x: bigint) => [
x < 0n ? 1n : 0n,
x === 1n ? 1n : 0n,
-1n < x ? 1n : 0n,
0n >= x ? 1n : 0n,
x / -2n,
];

for (const x of [-3n, -1n, 0n, 1n, 5n]) {
for (const type of ["int8", "int256"]) {
const source = program(type, [x, 0n], statements, false);
for (const level of [0, 1, 2, 3] as const) {
it(`should compare ${type} ${x} with literals (level ${level})`, async () => {
await check(source, level, expected(x));
});
}
}
}

it("should reject a literal too large for the other operand", async () => {
await expect(
executeProgram(`name TooLarge;
storage { [0] n: int8; }
code { if (n < 128) { n = 1 as int8; } }`),
).rejects.toThrow("Literal 128 does not fit in int8");
});

it("should reject mixed signedness", async () => {
await expect(
executeProgram(`name Mixed;
storage { [0] u: uint256; [1] o: uint256; }
code { let x = 1 as int8; if (u > x) { o = 1; } }`),
).rejects.toThrow("cannot mix signed and unsigned operands");
});
});

describe("division and remainder", () => {
// Division truncates toward zero, and the remainder has the sign
// of the dividend, as in Solidity (and as bigint does). BUG
// arithmetic does not check for overflow, so the minimum int256
// divided by -1 wraps to itself.
const statements = [
(a: string, b: string, out: string) => `${out} = ${a} / ${b};`,
(a: string, b: string, out: string) => `${out} = ${a} % ${b};`,
];

const signed = [
[7n, 2n],
[-7n, 2n],
[7n, -2n],
[-7n, -2n],
[-6n, 3n],
[5n, -7n],
] as const;

const cases = [
...["int8", "int16", "int128", "int256"].flatMap((type) =>
signed.map((operands) => [type, operands, null] as const),
),
["int256", [min, -1n], [min, 0n]],
["uint256", [word - 2n, 2n], null],
["uint256", [word - 7n, 2n ** 255n], null],
] as const;

for (const [type, [a, b], wrapped] of cases) {
const expected = wrapped ?? [a / b, a % b];
for (const folded of [false, true]) {
const source = program(type, [a, b], statements, folded);
for (const level of [0, 1, 2, 3] as const) {
it(`should divide ${type} ${a} by ${b}${folded ? " as literals" : ""} (level ${level})`, async () => {
await check(source, level, expected);
});
}
}
}
});
});

describe("narrow mapping values and array elements", () => {
const neg = (n: bigint) => 2n ** 256n - n;
const pad = (n: bigint) => n.toString(16).padStart(64, "0");
const hash = (hex: string) =>
BigInt("0x" + bytesToHex(keccak256(Buffer.from(hex, "hex"))));

const mappings = `name NarrowMappings;
storage {
[0] m8: mapping<uint256, int8>;
[1] m16: mapping<uint256, int16>;
[2] m128: mapping<uint256, int128>;
[3] n: int256;
[10] o0: int256;
[11] o1: int256;
[12] o2: int256;
[13] o3: int256;
[14] o4: int256;
[15] o5: int256;
}
create { n = -2 as int256; }
code {
m8[5] = n as int8;
m16[5] = n as int16;
m128[5] = n as int128;
m8[6] = -3 as int8;
m16[6] = -3 as int16;
m128[6] = -3 as int128;
o0 = m8[5];
o1 = m16[5];
o2 = m128[5];
o3 = m8[6];
o4 = m16[6];
o5 = m128[6];
}`;

// bugc gives each array element its own slot
const arrays = `name NarrowArrays;
storage { [0] a: array<int16, 3>; [1] n: int256; [10] o0: int256; }
create { n = -2 as int256; }
code { a[1] = n as int16; o0 = a[1]; }`;

for (const level of [0, 1, 2, 3] as const) {
it(`should write only a mapping value's bytes (level ${level})`, async () => {
const result = await executeProgram(mappings, {
calldata: "",
optimizationLevel: level,
});
expect(result.callSuccess).toBe(true);
for (const [key, value] of [
[5n, 2n],
[6n, 3n],
] as const) {
for (const [slot, bytes] of [
[0n, 1n],
[1n, 2n],
[2n, 16n],
] as const) {
expect(await result.getStorage(hash(pad(key) + pad(slot)))).toBe(
2n ** (8n * bytes) - value,
);
}
}
const expected = [2n, 2n, 2n, 3n, 3n, 3n].map(neg);
for (const [j, value] of expected.entries()) {
expect(await result.getStorage(10n + BigInt(j))).toBe(value);
}
});

it(`should write only an array element's bytes (level ${level})`, async () => {
const result = await executeProgram(arrays, {
calldata: "",
optimizationLevel: level,
});
expect(result.callSuccess).toBe(true);
expect(await result.getStorage(hash(pad(0n)) + 1n)).toBe(0xfffen);
expect(await result.getStorage(10n)).toBe(neg(2n));
});
}
});
});
Loading
Loading