From 0a93acd0762e8f947a1bae351d5a2ce1a6135148 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Tue, 6 Oct 2026 02:34:11 -0400 Subject: [PATCH] bugc: wrap narrow arithmetic to its type's width Arithmetic on an integer type narrower than 256 bits computed in a full word and kept the full result, so `(255 as uint8) + (1 as uint8)` was 256 and `(127 as int8) + (1 as int8)` was 128, out of the type's range. BUG has no overflow checks, so wrap the result to the type's width, as a Solidity `unchecked { }` block does: after `+`, `-`, `*`, unary `-`, and signed `/`, cast the result from a word to its type. An unsigned result keeps its low bits and a signed result sign-extends, as a cast does. Unsigned `/` and `%`, and signed `%`, cannot leave the type's range, so they need no cast. The optimizer folds the cast as it folds any other. Folded arithmetic now wraps at the word, as the EVM does, where a constant result past 2^256 or below zero failed to compile. --- packages/bugc/CHANGELOG.md | 16 +- packages/bugc/src/evmgen/behavioral.test.ts | 213 ++++++++++++++++++ packages/bugc/src/ir/utils/signed.ts | 5 +- .../irgen/generate/expressions/operator.ts | 66 +++++- .../src/optimizer/steps/constant-folding.ts | 9 +- .../compiler/case-study-bug.mdx | 21 +- 6 files changed, 314 insertions(+), 16 deletions(-) diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index 42f47af3ff..1caacd17bb 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -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` 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 @@ -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 diff --git a/packages/bugc/src/evmgen/behavioral.test.ts b/packages/bugc/src/evmgen/behavioral.test.ts index 92993727ac..db4d8a0d4b 100644 --- a/packages/bugc/src/evmgen/behavioral.test.ts +++ b/packages/bugc/src/evmgen/behavioral.test.ts @@ -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)); + }); + } + } + }); }); diff --git a/packages/bugc/src/ir/utils/signed.ts b/packages/bugc/src/ir/utils/signed.ts index 7346955675..9fd3fa5641 100644 --- a/packages/bugc/src/ir/utils/signed.ts +++ b/packages/bugc/src/ir/utils/signed.ts @@ -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); diff --git a/packages/bugc/src/irgen/generate/expressions/operator.ts b/packages/bugc/src/irgen/generate/expressions/operator.ts index bc062b7d41..10545359ba 100644 --- a/packages/bugc/src/irgen/generate/expressions/operator.ts +++ b/packages/bugc/src/irgen/generate/expressions/operator.ts @@ -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); }; /** @@ -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 { + 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 = { "+": "add", diff --git a/packages/bugc/src/optimizer/steps/constant-folding.ts b/packages/bugc/src/optimizer/steps/constant-folding.ts index b31a89a8f0..5035f77a21 100644 --- a/packages/bugc/src/optimizer/steps/constant-folding.ts +++ b/packages/bugc/src/optimizer/steps/constant-folding.ts @@ -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", diff --git a/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx b/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx index a2756e9d21..0e9c79d057 100644 --- a/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx +++ b/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx @@ -87,9 +87,24 @@ for a signed `x`, and `x < 128` is an error for an `int8`. (7 as int8) % (-2 as int8); // 1 ``` -A result narrower than a word does not wrap to its type yet: -`(-128 as int8) / (-1 as int8)` is 128, not -128, and the same goes for a -narrow `+`, `-` or `*` that overflows. A follow-up change makes them wrap. +### Arithmetic + +Arithmetic wraps, as in a Solidity `unchecked { }` block; BUG has no +overflow checks. A result narrower than 256 bits wraps to the width of +its type, as a cast to that type does: an unsigned result keeps its low +bits, and a signed result then sign-extends. An operation on two integer +types has the wider type, and a literal takes the other operand's type, +so `x + 1` with `x: uint8` is a `uint8` and wraps at 8 bits. + +```bug +(255 as uint8) + (1 as uint8); // 0 +(0 as uint8) - (1 as uint8); // 255 +(16 as uint8) * (16 as uint8); // 0 +(127 as int8) + (1 as int8); // -128 +(-128 as int8) - (1 as int8); // 127 +(-128 as int8) / (-1 as int8); // -128 +-(-128 as int8); // -128 +``` ## Compilation pipeline