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
16 changes: 11 additions & 5 deletions packages/bugc/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,17 +142,22 @@ support. Changes to the specification itself are tracked in the root
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]).
`int256` divided by `-1` is itself. 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]).
- Arithmetic (`+`, `-`, `*`, `/`, and unary `-`) on an integer type
narrower than 256 bits now wraps to the type's width, as in a Solidity
`unchecked { }` block: an unsigned result keeps its low bits, and a
signed result sign-extends. Before, at every optimization level, a
result could be out of its type's range: `(255 as uint8) + (1 as uint8)`
was 256, and `(127 as int8) + (1 as int8)` was 128. The optimizer folds
constants by the same rule, and now wraps 256-bit results at the word,
where it failed to compile a constant result outside it ([#340]).

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

Expand Down Expand Up @@ -203,3 +208,4 @@ First publication.
[#336]: https://github.com/ethdebug/format/pull/336
[#337]: https://github.com/ethdebug/format/pull/337
[#339]: https://github.com/ethdebug/format/pull/339
[#340]: https://github.com/ethdebug/format/pull/340
213 changes: 213 additions & 0 deletions packages/bugc/src/evmgen/behavioral.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1834,4 +1834,217 @@ code { a[1] = n as int16; o0 = a[1]; }`;
});
}
});

describe("narrow arithmetic", () => {
const word = 2n ** 256n;
const toWord = (n: bigint) => ((n % word) + word) % word;
const bitsOf = (type: string) => BigInt(type.replace(/^u?int/, ""));

// A result keeps the low bits of its type, as a signed value when
// the type is signed
const wrap = (n: bigint, type: string) => {
const size = 2n ** bitsOf(type);
const low = ((n % size) + size) % size;
return type.startsWith("u") || low < size / 2n ? low : low - size;
};

// A literal of a type is a cast of its two's complement
const literal = (n: bigint, type: string) =>
`(${toWord(n) % 2n ** bitsOf(type)} as ${type})`;

const operators = [
["+", (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;

// Operands at the boundaries of each type
const operandsOf = (type: string): (readonly [bigint, bigint])[] => {
const bits = bitsOf(type);
if (type.startsWith("u")) {
const max = 2n ** bits - 1n;
const half = 2n ** (bits / 2n);
return [
[max, 1n],
[0n, 1n],
[half, half],
[max, max],
];
}
const max = 2n ** (bits - 1n) - 1n;
const min = -max - 1n;
return [
[max, 1n],
[min, 1n],
[min, -1n],
[-1n, max],
];
};

// Each pair of operands `x` and `y` comes from storage, from
// calldata, or from literals, which the optimizer folds. Each
// operator and the negation of `x` store a result in its own slot.
const sources = ["storage", "calldata", "literals"] as const;

const program = (
type: string,
pairs: readonly (readonly [bigint, bigint])[],
source: (typeof sources)[number],
) => {
const out = type.startsWith("u") ? "uint256" : "int256";
const width = operators.length + 1;
const operand = (n: bigint, k: number, i: number) => {
switch (source) {
case "storage":
return `s${k}${i}`;
case "calldata": {
const offset = 32 * (2 * k + i);
return `(msg.data[${offset}:${offset + 32}] as bytes32 as uint256 as ${type})`;
}
case "literals":
return literal(n, type);
}
};
const storage = pairs.flatMap((_, k) => [
`[${2 * k}] s${k}0: ${type};`,
`[${2 * k + 1}] s${k}1: ${type};`,
...Array.from(
{ length: width },
(_, j) => `[${100 + k * width + j}] o${k}${j}: ${out};`,
),
]);
const create = pairs.flatMap(([a, b], k) => [
`s${k}0 = ${literal(a, type)};`,
`s${k}1 = ${literal(b, type)};`,
]);
const code = pairs.flatMap(([a, b], k) => {
const [x, y] = [operand(a, k, 0), operand(b, k, 1)];
return [
...operators.map(([op], j) => `o${k}${j} = ${x} ${op} ${y};`),
`o${k}${operators.length} = -${x};`,
];
});
return `name NarrowArithmetic;
storage {
${storage.join("\n ")}
}
create {
${create.join("\n ")}
}
code {
${code.join("\n ")}
}`;
};

const calldataOf = (pairs: readonly (readonly [bigint, bigint])[]) =>
"0x" +
pairs
.flat()
.map((n) => toWord(n).toString(16).padStart(64, "0"))
.join("");

const types = [8, 16, 32, 64, 128, 256].flatMap((bits) => [
`uint${bits}`,
`int${bits}`,
]);

for (const type of types) {
const pairs = operandsOf(type);
const expected = pairs.flatMap(([a, b]) => [
...operators.map(([, apply]) => wrap(apply(a, b), type)),
wrap(-a, type),
]);
for (const source of sources) {
const code = program(type, pairs, source);
for (const level of [0, 1, 2, 3] as const) {
it(`should wrap ${type} arithmetic on ${source} (level ${level})`, async () => {
const result = await executeProgram(code, {
calldata: calldataOf(pairs),
optimizationLevel: level,
});
expect(result.callSuccess).toBe(true);
const actual = [];
for (const j of expected.keys()) {
actual.push(await result.getStorage(100n + BigInt(j)));
}
expect(actual).toEqual(expected.map(toWord));
});
}
}
}

// A wrapped result is the value that later expressions see
const compound = (type: string, values: readonly bigint[]) => {
const out = type.startsWith("u") ? "uint256" : "int256";
const wide = type.replace("8", "16");
return `name NarrowCompound;
storage {
[0] a: ${type};
[1] b: ${type};
[2] c: ${type};
[3] s: ${type};
[4] w: ${wide};
[10] o0: ${out};
[11] o1: ${out};
[12] o2: ${out};
[13] o3: ${out};
[14] o4: ${out};
[15] o5: ${out};
[16] o6: ${out};
[17] o7: ${out};
}
create {
a = ${literal(values[0], type)};
b = ${literal(values[1], type)};
c = ${literal(values[2], type)};
w = ${literal(values[3], wide)};
}
code {
if (a + b < c) { o0 = 1 as ${out}; }
o1 = (a + b) / ${literal(2n, type)};
o2 = (a + b) as ${wide};
s = a + b;
o3 = s;
o4 = a + b - c;
let t = a * b;
o5 = t;
o6 = a + w;
o7 = -(a + b);
}`;
};

const compoundCases = [
// 200 + 100 wraps to 44, which is less than 50
[
"uint8",
[200n, 100n, 50n, 65400n],
[1n, 22n, 44n, 44n, 250n, 32n, 64n, 212n],
],
// 100 + 100 wraps to -56, which is less than -50
[
"int8",
[100n, 100n, -50n, 32700n],
[1n, -28n, -56n, -56n, -6n, 16n, -32736n, 56n],
],
] as const;

for (const [type, values, expected] of compoundCases) {
for (const level of [0, 1, 2, 3] as const) {
it(`should use wrapped ${type} results in later expressions (level ${level})`, async () => {
const result = await executeProgram(compound(type, values), {
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));
});
}
}
});
});
5 changes: 4 additions & 1 deletion packages/bugc/src/ir/utils/signed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ export function foldSigned(
}
}

const toWord = (value: bigint): bigint => ((value % word) + word) % word;
/**
* A value as the EVM holds it: modulo 2^256, so that arithmetic wraps
*/
export const toWord = (value: bigint): bigint => ((value % word) + word) % word;

const toSigned = (value: bigint): bigint => {
const unsigned = toWord(value);
Expand Down
66 changes: 61 additions & 5 deletions packages/bugc/src/irgen/generate/expressions/operator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,16 +91,20 @@ const makeBuildUnaryOperator = (
// Map operator (matching generator.ts logic)
const op = expr.operator === "!" ? "not" : "neg";

const operationDebug = yield* Process.Debug.forAstNode(expr);

// Emit unary operation
yield* Process.Instructions.emit({
kind: "unary",
op,
operand: operandVal,
dest: tempId,
operationDebug: yield* Process.Debug.forAstNode(expr),
operationDebug,
} as Ir.Instruction.UnaryOp);

return Ir.Value.temp(tempId, resultType);
return op === "neg"
? yield* wrap(tempId, resultType, operationDebug)
: Ir.Value.temp(tempId, resultType);
};

/**
Expand Down Expand Up @@ -143,19 +147,71 @@ const makeBuildBinaryOperator = (
// Generate temp for result
const tempId = yield* Process.Variables.newTemp();

const op = mapBinaryOp(expr.operator);
const operationDebug = yield* Process.Debug.forAstNode(expr);

// Emit binary operation
yield* Process.Instructions.emit({
kind: "binary",
op: mapBinaryOp(expr.operator),
op,
left: leftVal,
right: rightVal,
dest: tempId,
operationDebug: yield* Process.Debug.forAstNode(expr),
operationDebug,
} as Ir.Instruction.BinaryOp);

return Ir.Value.temp(tempId, resultType);
return overflows(op, resultType)
? yield* wrap(tempId, resultType, operationDebug)
: Ir.Value.temp(tempId, resultType);
};

/**
* Whether an operation's result can fall outside its type. A quotient
* or remainder of unsigned values is no greater than the dividend, and
* a signed remainder is smaller in magnitude than the divisor. A signed
* quotient overflows only for the least value divided by -1.
*/
function overflows(op: Ir.Instruction.BinaryOp["op"], type: Ir.Type): boolean {
switch (op) {
case "add":
case "sub":
case "mul":
return true;
case "div":
return Ir.Utils.isSigned(type);
default:
return false;
}
}

/**
* Wrap the result of arithmetic in temp `id`, computed in a full word,
* to its type: keep the low bits of an unsigned result, and
* sign-extend a signed one. This is the cast from a word to the type.
* A 256-bit result needs no cast; the EVM wraps it already.
*/
function* wrap(
id: string,
type: Ir.Type,
operationDebug: Ir.Instruction.Debug,
): Process<Ir.Value> {
const word = Ir.Value.temp(id, Ir.Type.Scalar.word);
if (Ir.Utils.castSteps(word.type, type).length === 0) {
return Ir.Value.temp(id, type);
}

const dest = yield* Process.Variables.newTemp();
yield* Process.Instructions.emit({
kind: "cast",
value: word,
targetType: type,
dest,
operationDebug,
} as Ir.Instruction.Cast);

return Ir.Value.temp(dest, type);
}

function mapBinaryOp(op: string): Ir.Instruction.BinaryOp["op"] {
const opMap: Record<string, Ir.Instruction.BinaryOp["op"]> = {
"+": "add",
Expand Down
9 changes: 7 additions & 2 deletions packages/bugc/src/optimizer/steps/constant-folding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,9 +148,14 @@ export class ConstantFoldingStep extends BaseOptimizationStep {
typeof rightValue === "bigint"
? Ir.Utils.foldSigned(inst.op, leftValue, rightValue)
: undefined;
const result =
const evaluated =
signed ?? this.evaluateBinary(inst.op, leftValue, rightValue);
if (result === undefined) return null;
if (evaluated === undefined) return null;

// Arithmetic wraps at the word, as on the EVM. A narrower result
// then wraps to its type by the cast after it.
const result =
typeof evaluated === "bigint" ? Ir.Utils.toWord(evaluated) : evaluated;

return {
kind: "const",
Expand Down
Loading
Loading