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