diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index 2381fbea44..50805812b8 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -12,8 +12,33 @@ support. Changes to the specification itself are tracked in the root - 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]). +### Changed + +- 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 + `(-1 as int256) as uint8` is 255. A cast from a signed type to a wider + type sign-extends. A cast to a narrower `bytesN` keeps the leading + bytes, and a cast to a wider one pads with zero bytes at the end: + `0x11223344 as bytes8` is `0x1122334400000000`. Before, a cast emitted + no code, so the value kept all its bits. The optimizer folds a cast of + a constant by the same rules ([#334]). +- A cast from dynamic `bytes` to an integer or `address` is now a type + error, as in Solidity: cast to `bytesN` first, as in + `msg.data[4:36] as bytes32 as uint256` ([#334]). + ### Fixed +- A cast from dynamic `bytes` to a fixed-size type now reads the bytes. + `msg.data[0:4] as bytes4` is the first four bytes of calldata; bytes + past the slice's length are zero. Before, the cast gave the slice's + memory address ([#334]). +- A slice of `msg.data` now copies from calldata. Before, it copied from + memory ([#334]). +- Each expression in a chain of postfix operations (casts, calls, slices, + members and indexes, as in `v as int8 as int256`) now has its own source + location. Before, every one but the last had location 0:0, so their + types overwrote each other ([#334]). - At optimization level 3, block merging now renames the incoming block of every phi that named a merged block. Before, a `for` loop that carries a local across iterations (and so any loop that calls an internal function @@ -106,5 +131,6 @@ First publication. [#330]: https://github.com/ethdebug/format/pull/330 [#332]: https://github.com/ethdebug/format/pull/332 [#333]: https://github.com/ethdebug/format/pull/333 +[#334]: https://github.com/ethdebug/format/pull/334 [#335]: https://github.com/ethdebug/format/pull/335 [#336]: https://github.com/ethdebug/format/pull/336 diff --git a/packages/bugc/examples/advanced/token-registry.bug b/packages/bugc/examples/advanced/token-registry.bug index 205d6682de..a788fb36bb 100644 --- a/packages/bugc/examples/advanced/token-registry.bug +++ b/packages/bugc/examples/advanced/token-registry.bug @@ -78,12 +78,12 @@ define { // Decode address from calldata (bytes 4-35) function decodeAddress(offset: uint256) -> address { - return msg.data[offset:offset+32] as address; + return msg.data[offset:offset+32] as bytes32 as address; }; // Decode uint256 from calldata (32 bytes) function decodeUint256(offset: uint256) -> uint256 { - return msg.data[offset:offset+32] as uint256; + return msg.data[offset:offset+32] as bytes32 as uint256; }; } diff --git a/packages/bugc/src/evmgen/behavioral.test.ts b/packages/bugc/src/evmgen/behavioral.test.ts index 1d39b4c09f..d304190af3 100644 --- a/packages/bugc/src/evmgen/behavioral.test.ts +++ b/packages/bugc/src/evmgen/behavioral.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect } from "vitest"; +import { keccak256 } from "ethereum-cryptography/keccak"; +import { bytesToHex } from "ethereum-cryptography/utils"; + import { executeProgram } from "#test/evm/behavioral"; describe("behavioral tests", () => { @@ -910,6 +913,329 @@ code { }); }); + describe("casts", () => { + const word = 2n ** 256n; + + // Each case casts a value of type `from` and stores the result in a + // `uint256`. The value comes once as a constant, which the optimizer + // may fold, and once from storage, which it cannot. + const casts: Record< + string, + [string, string, (x: string) => string, bigint] + > = { + "uint256 300 to uint8": ["uint256", "300", (x) => `${x} as uint8`, 44n], + "uint256 255 to uint8": ["uint256", "255", (x) => `${x} as uint8`, 255n], + "uint256 256 to uint8": ["uint256", "256", (x) => `${x} as uint8`, 0n], + "uint256 0 to uint8": ["uint256", "0", (x) => `${x} as uint8`, 0n], + "uint256 max to uint8": ["uint256", "-1", (x) => `${x} as uint8`, 255n], + "uint16 to uint8": [ + "uint16", + "0x1234 as uint16", + (x) => `${x} as uint8`, + 0x34n, + ], + "uint8 to uint16": [ + "uint8", + "200 as uint8", + (x) => `${x} as uint16`, + 200n, + ], + "uint256 200 to int8": [ + "uint256", + "200", + (x) => `(${x} as int8) as int256`, + word - 56n, + ], + "uint256 127 to int8": [ + "uint256", + "127", + (x) => `(${x} as int8) as int256`, + 127n, + ], + "uint256 128 to int8": [ + "uint256", + "128", + (x) => `(${x} as int8) as int256`, + word - 128n, + ], + "int256 -1 to uint8": [ + "int256", + "-1 as int256", + (x) => `${x} as uint8`, + 255n, + ], + "int256 -200 to int8": [ + "int256", + "-200 as int256", + (x) => `(${x} as int8) as int256`, + 56n, + ], + "int16 min to int8": [ + "int16", + "-32768 as int16", + (x) => `(${x} as int8) as int256`, + 0n, + ], + "int8 -56 to int16": [ + "int8", + "-56 as int8", + (x) => `(${x} as int16) as int256`, + word - 56n, + ], + "int8 -56 to int256": [ + "int8", + "-56 as int8", + (x) => `${x} as int256`, + word - 56n, + ], + "int8 -1 to uint8": ["int8", "-1 as int8", (x) => `${x} as uint8`, 255n], + "uint8 255 to int8": [ + "uint8", + "255 as uint8", + (x) => `(${x} as int8) as int256`, + word - 1n, + ], + "int8 -56 to uint16": [ + "int8", + "-56 as int8", + (x) => `${x} as uint16`, + 0xffc8n, + ], + "int8 -56 to uint256": [ + "int8", + "-56 as int8", + (x) => `${x} as uint256`, + word - 56n, + ], + "int256 -56 to uint256": [ + "int256", + "-56 as int256", + (x) => `${x} as uint256`, + word - 56n, + ], + "uint256 max to int256": [ + "uint256", + "-1", + (x) => `${x} as int256`, + word - 1n, + ], + "uint256 max to address": [ + "uint256", + "-1", + (x) => `${x} as address`, + 2n ** 160n - 1n, + ], + "address to uint8": [ + "address", + "0x1234 as address", + (x) => `${x} as uint8`, + 0x34n, + ], + "bytes32 to address": [ + "bytes32", + "0xffffffffffffffffffffffff00112233445566778899aabbccddeeff00112233", + (x) => `${x} as address`, + 0x00112233445566778899aabbccddeeff00112233n, + ], + "bytes32 to bytes4": [ + "bytes32", + "0x1122334400000000000000000000000000000000000000000000000000000055", + (x) => `${x} as bytes4`, + 0x11223344n, + ], + "bytes8 to bytes4": [ + "bytes8", + "0x1122334455667788", + (x) => `${x} as bytes4`, + 0x11223344n, + ], + "bytes4 to bytes8": [ + "bytes4", + "0x11223344", + (x) => `${x} as bytes8`, + 0x1122334400000000n, + ], + "bytes4 to bytes4": [ + "bytes4", + "0x11223344", + (x) => `${x} as bytes4`, + 0x11223344n, + ], + "bytes4 to int16": [ + "bytes4", + "0x1234ff38", + (x) => `(${x} as int16) as int256`, + word - 200n, + ], + }; + + for (const [name, [from, input, cast, expected]] of Object.entries(casts)) { + const sources = { + constant: `name Cast; +storage { [0] v: ${from}; [1] out: uint256; } +code { out = (${cast(`(${input})`)}) as uint256; }`, + "storage value": `name Cast; +storage { [0] v: ${from}; [1] out: uint256; } +create { v = ${input}; } +code { out = (${cast("v")}) as uint256; }`, + }; + + for (const [kind, source] of Object.entries(sources)) { + for (const level of [0, 1, 2, 3] as const) { + it(`should cast a ${kind}: ${name} (level ${level})`, async () => { + const result = await executeProgram(source, { + calldata: "", + optimizationLevel: level, + }); + + expect(result.callSuccess).toBe(true); + expect(await result.getStorage(1n)).toBe(expected); + }); + } + } + } + + // The leading four bytes of the hash of the word 0x00..01 + const hashPrefix = BigInt( + "0x" + bytesToHex(keccak256(new Uint8Array(32).fill(1, 31))).slice(0, 8), + ); + + const calldata = "0xaabbccdd" + "00".repeat(12) + "11".repeat(20); + + // Casts of values that are not stored: comparisons, hashes and + // slices of calldata and memory. The call sends `calldata`. + const others: Record = { + "a narrowed bytes32 compared with a literal": [ + `let h: bytes32 = + 0x1122334400000000000000000000000000000000000000000000000000000000; + let b = h as bytes4; + if (b == 0x11223344) { out = 1; }`, + 1n, + ], + "a hash narrowed to bytes4": [ + `out = (keccak256(0x01) as bytes4) as uint256;`, + hashPrefix, + ], + "a calldata slice to bytes4": [ + `out = (msg.data[0:4] as bytes4) as uint256;`, + 0xaabbccddn, + ], + "a calldata slice compared as bytes4": [ + `let sel = msg.data[0:4] as bytes4; + if (sel == 0xaabbccdd) { out = 1; }`, + 1n, + ], + "a short calldata slice to bytes4": [ + `out = (msg.data[0:2] as bytes4) as uint256;`, + 0xaabb0000n, + ], + "a calldata slice to uint256": [ + `out = msg.data[4:36] as bytes32 as uint256;`, + 0x1111111111111111111111111111111111111111n, + ], + "a calldata slice to address": [ + `out = msg.data[4:36] as bytes32 as address as uint256;`, + 0x1111111111111111111111111111111111111111n, + ], + "a memory slice to bytes4": [ + `let d = msg.data[0:8]; + out = (d[1:5] as bytes4) as uint256;`, + 0xbbccdd00n, + ], + "a short memory slice to bytes4": [ + `let d = msg.data[0:8]; + out = (d[1:3] as bytes4) as uint256;`, + 0xbbcc0000n, + ], + "msg.data to bytes4": [ + `out = (msg.data as bytes4) as uint256;`, + 0xaabbccddn, + ], + }; + + for (const [name, [body, expected]] of Object.entries(others)) { + for (const level of [0, 1, 2, 3] as const) { + it(`should cast ${name} (level ${level})`, async () => { + const source = `name Cast; +storage { [0] n: uint256; [1] out: uint256; } +create { n = 7; } +code { + ${body} +}`; + const result = await executeProgram(source, { + calldata, + optimizationLevel: level, + }); + + expect(result.callSuccess).toBe(true); + expect(await result.getStorage(1n)).toBe(expected); + }); + } + } + + // Chains of casts, calls and slices, mostly without parentheses. + // Each node of a chain has its own type, so each cast must use it. + const functions = `define { + function f() -> uint256 { return 300; }; + function g() -> int8 { return 200 as int8; }; + function h() -> bytes4 { return 0x11223344; }; + function sel(o: uint256) -> bytes4 { return msg.data[o:o+4] as bytes4; }; +}`; + const chains: Record = { + "casts of a storage value": [ + `b = v as int8 as int256 as uint256;`, + [0n, word - 56n], + ], + "casts of a slice and a call": [ + `a = (msg.data[0:4] as bytes4) as uint256; + b = f() as uint8;`, + [0xaabbccddn, 44n], + ], + "casts of calls": [ + `a = g() as int256 as uint256; + b = (h() as bytes8) as uint256;`, + [word - 56n, 0x1122334400000000n], + ], + "three calls that cast a slice": [ + `a = sel(0) as uint256; + b = sel(4) as uint256; + c = sel(32) as uint256;`, + [0xaabbccddn, 0n, 0x11111111n], + ], + "many chains": [ + `a = msg.data[0:4] as bytes4 as uint256; + b = f() as uint8 as uint256 + (g() as int256 as uint256); + c = h() as bytes8 as uint256 + (v as int8 as int16 as uint16 as uint256);`, + [0xaabbccddn, 44n - 56n + word, 0x1122334400000000n + 0xffc8n], + ], + }; + + for (const [name, [body, expected]] of Object.entries(chains)) { + for (const level of [0, 1, 2, 3] as const) { + it(`should cast in chains: ${name} (level ${level})`, async () => { + const source = `name Chains; +${functions} +storage { + [0] v: uint256; [1] a: uint256; [2] b: uint256; [3] c: uint256; +} +create { v = 200; } +code { + ${body} +}`; + const result = await executeProgram(source, { + calldata, + optimizationLevel: level, + }); + + expect(result.callSuccess).toBe(true); + for (const [index, value] of expected.entries()) { + expect(await result.getStorage(BigInt(index + 1))).toBe(value); + } + }); + } + } + }); + describe("modulo", () => { const program = (expr: string) => `name Modulo; diff --git a/packages/bugc/src/evmgen/generation/instructions/cast.ts b/packages/bugc/src/evmgen/generation/instructions/cast.ts index 3fb3a97bd3..65a529592d 100644 --- a/packages/bugc/src/evmgen/generation/instructions/cast.ts +++ b/packages/bugc/src/evmgen/generation/instructions/cast.ts @@ -1,22 +1,105 @@ -import type * as Ir from "#ir"; +import * as Ir from "#ir"; import type { Stack } from "#evm"; -import { type Transition, pipe } from "#evmgen/operations"; +import { type Transition, operations, pipe, rebrand } from "#evmgen/operations"; import { loadValue, storeValueIfNeeded } from "../values/index.js"; +const { ADD, AND, DUP1, MLOAD, NOT, PUSHn, SHL, SHR, SIGNEXTEND, SWAP1 } = + operations; + /** - * Generate code for cast instructions - * Cast is a no-op at the EVM level since types are checked at compile time + * Generate code for cast instructions: truncate, sign-extend or shift + * the value as `Ir.Utils.castSteps` says for its source and target + * types. Most widening casts emit no code. */ export function generateCast( inst: Ir.Instruction.Cast, ): Transition { const debug = inst.operationDebug; + const steps = Ir.Utils.castSteps(inst.value.type, inst.targetType); + + const cast = steps + .map((step) => generateStep(step, debug)) + .reduce>( + (previous, next) => (state) => next(previous(state)), + (state) => state, + ); - // Just load the value and store it with the new type annotation return pipe() .then(loadValue(inst.value, { debug }), { as: "value" }) + .then(cast) .then(storeValueIfNeeded(inst.dest, { debug })) .done(); } + +function generateStep( + step: Ir.Utils.CastStep, + debug: Ir.Instruction.Debug | undefined, +): Transition { + switch (step.op) { + case "and": + return pipe() + .then(rebrand<"value", "b">({ 1: "b" })) + .then(PUSHn((1n << BigInt(step.bytes * 8)) - 1n, { debug }), { + as: "a", + }) + .then(AND({ debug }), { as: "value" }) + .done(); + case "signextend": + return pipe() + .then(rebrand<"value", "x">({ 1: "x" })) + .then(PUSHn(BigInt(step.bytes - 1), { debug }), { as: "b" }) + .then(SIGNEXTEND({ debug }), { as: "value" }) + .done(); + case "shr": + return pipe() + .then(PUSHn(BigInt(step.bytes * 8), { debug }), { as: "shift" }) + .then(SHR({ debug }), { as: "value" }) + .done(); + case "shl": + return pipe() + .then(PUSHn(BigInt(step.bytes * 8), { debug }), { as: "shift" }) + .then(SHL({ debug }), { as: "value" }) + .done(); + case "load": + return generateLoad(debug); + } +} + +/** + * Replace a reference to dynamic `bytes` in memory with its first 32 + * bytes, zeroing the bytes past its length: + * `mload(ref + 32) & ~(~0 >> (8 * length))` + */ +function generateLoad( + debug: Ir.Instruction.Debug | undefined, +): Transition { + return ( + pipe() + // The first 32 bytes + .then(rebrand<"value", "b">({ 1: "b" })) + .then(DUP1({ debug })) + .then(PUSHn(32n, { debug }), { as: "a" }) + .then(ADD({ debug }), { as: "offset" }) + .then(MLOAD({ debug }), { as: "data" }) + + // The length, in bits + .then(SWAP1({ debug })) + .then(rebrand<"b", "offset">({ 1: "offset" })) + .then(MLOAD({ debug }), { as: "value" }) + .then(PUSHn(3n, { debug }), { as: "shift" }) + .then(SHL({ debug }), { as: "shift" }) + + // The mask that keeps `length` leading bytes + .then(PUSHn(0n, { debug }), { as: "a" }) + .then(NOT({ debug }), { as: "value" }) + .then(SWAP1({ debug })) + .then(SHR({ debug }), { as: "a" }) + .then(NOT({ debug }), { as: "a" }) + + .then(rebrand<"a", "a", "data", "b">({ 1: "a", 2: "b" })) + .then(AND({ debug }), { as: "value" }) + .done() + ); +} diff --git a/packages/bugc/src/ir/utils/cast.ts b/packages/bugc/src/ir/utils/cast.ts new file mode 100644 index 0000000000..8e56830276 --- /dev/null +++ b/packages/bugc/src/ir/utils/cast.ts @@ -0,0 +1,134 @@ +import { Type as BugType } from "#types"; + +import { Type } from "../spec/type.js"; + +/** + * One step of a cast's code. Every scalar sits right-aligned in its + * word, `bytesN` included: a value of `n` bytes is the word's low + * `n` bytes, and the first byte of a `bytesN` is the highest of them. + * + * - `load`: replace a reference to dynamic `bytes` in memory with its + * first 32 bytes, as a `bytes32` (bytes past the length are zero) + * - `and`: keep the low `bytes` bytes + * - `signextend`: sign-extend from the low `bytes` bytes + * - `shr` / `shl`: shift by `bytes` bytes + */ +export type CastStep = + | { op: "load" } + | { op: "and" | "signextend" | "shr" | "shl"; bytes: number }; + +type Kind = "unsigned" | "signed" | "bytes"; + +interface Shape { + kind: Kind; + size: number; +} + +const shapeOf = (type: Type.Scalar): Shape => { + const { origin, size } = type; + if (origin !== "synthetic" && BugType.isElementary(origin)) { + if (BugType.Elementary.isInt(origin)) { + return { kind: "signed", size }; + } + if (BugType.Elementary.isBytes(origin)) { + return { kind: "bytes", size }; + } + } + return { kind: "unsigned", size }; +}; + +/** + * The steps that cast a value of type `from` to type `to`: + * + * - to `bytesN`: keep the leading bytes (narrowing), or pad with zero + * bytes at the end (widening) + * - to an unsigned integer or `address`: keep the low bits + * - to a signed integer: keep the low bits, then sign-extend + * - from a signed integer to a wider type: sign-extend first + * - from dynamic `bytes`: load its first 32 bytes as a `bytes32`, then + * cast that + * + * No steps means the cast emits no code. + */ +export function castSteps(from: Type, to: Type): CastStep[] { + if (!Type.isScalar(to)) { + return []; + } + + if (!Type.isScalar(from)) { + const loaded = Type.scalar(32, BugType.Elementary.bytes(32)); + return Type.isRef(from) && from.location === "memory" + ? [{ op: "load" }, ...castSteps(loaded, to)] + : []; + } + + const source = shapeOf(from); + const target = shapeOf(to); + const steps: CastStep[] = []; + + if (target.kind === "bytes") { + if (source.size > target.size) { + steps.push({ op: "shr", bytes: source.size - target.size }); + } else if (source.size < target.size) { + steps.push({ op: "shl", bytes: target.size - source.size }); + } + return steps; + } + + // A signed value keeps its sign in a wider type + if (source.kind === "signed" && source.size < target.size) { + steps.push({ op: "signextend", bytes: source.size }); + } + + if (target.size < 32) { + if (target.kind === "signed" && source.size >= target.size) { + steps.push({ op: "signextend", bytes: target.size }); + } else if ( + target.kind !== "signed" && + (source.kind === "signed" || source.size > target.size) + ) { + steps.push({ op: "and", bytes: target.size }); + } + } + + return steps; +} + +const word = 2n ** 256n; + +/** + * Apply a cast's steps to a constant. Returns undefined for a cast + * that loads from memory. + */ +export function foldCast( + value: bigint, + steps: readonly CastStep[], +): bigint | undefined { + let result = ((value % word) + word) % word; + + for (const step of steps) { + if (step.op === "load") { + return undefined; + } + + const bits = BigInt(step.bytes * 8); + switch (step.op) { + case "and": + result &= (1n << bits) - 1n; + break; + case "signextend": { + const low = result & ((1n << bits) - 1n); + result = low >> (bits - 1n) ? word - (1n << bits) + low : low; + break; + } + case "shr": + result >>= bits; + break; + case "shl": + result = (result << bits) % word; + break; + } + } + + return result; +} diff --git a/packages/bugc/src/ir/utils/index.ts b/packages/bugc/src/ir/utils/index.ts index 8144839cb5..2f538a5122 100644 --- a/packages/bugc/src/ir/utils/index.ts +++ b/packages/bugc/src/ir/utils/index.ts @@ -1 +1,2 @@ export * from "./debug.js"; +export * from "./cast.js"; diff --git a/packages/bugc/src/irgen/generate/expressions/access.ts b/packages/bugc/src/irgen/generate/expressions/access.ts index a568d6fca9..2167724552 100644 --- a/packages/bugc/src/irgen/generate/expressions/access.ts +++ b/packages/bugc/src/irgen/generate/expressions/access.ts @@ -245,38 +245,51 @@ const makeBuildSliceAccess = ( operationDebug: yield* Process.Debug.forAstNode(expr), } as Ir.Instruction.Write); - // Compute source offset (skip length prefix + start offset) - const sourceOffsetTemp = yield* Process.Variables.newTemp(); - yield* Process.Instructions.emit({ - kind: "binary", - op: "add", - left: object, - right: Ir.Value.constant(32n, Ir.Type.Scalar.uint256), - dest: sourceOffsetTemp, - operationDebug: yield* Process.Debug.forAstNode(expr), - } as Ir.Instruction); + // Read the slice data from the source: `msg.data` is calldata + // from offset 0; other bytes are in memory after a length word. + // Either read copies one word. + const dataTemp = yield* Process.Variables.newTemp(); + if (Ast.Expression.Special.isMsgData(expr.object)) { + yield* Process.Instructions.emit({ + kind: "read", + location: "calldata", + offset: start, + length: Ir.Value.constant(32n, Ir.Type.Scalar.uint256), + type: resultType, + dest: dataTemp, + operationDebug: yield* Process.Debug.forAstNode(expr), + } as Ir.Instruction.Read); + } else { + const sourceOffsetTemp = yield* Process.Variables.newTemp(); + yield* Process.Instructions.emit({ + kind: "binary", + op: "add", + left: object, + right: Ir.Value.constant(32n, Ir.Type.Scalar.uint256), + dest: sourceOffsetTemp, + operationDebug: yield* Process.Debug.forAstNode(expr), + } as Ir.Instruction); - const adjustedSourceTemp = yield* Process.Variables.newTemp(); - yield* Process.Instructions.emit({ - kind: "binary", - op: "add", - left: Ir.Value.temp(sourceOffsetTemp, Ir.Type.Scalar.uint256), - right: start, - dest: adjustedSourceTemp, - operationDebug: yield* Process.Debug.forAstNode(expr), - } as Ir.Instruction); + const adjustedSourceTemp = yield* Process.Variables.newTemp(); + yield* Process.Instructions.emit({ + kind: "binary", + op: "add", + left: Ir.Value.temp(sourceOffsetTemp, Ir.Type.Scalar.uint256), + right: start, + dest: adjustedSourceTemp, + operationDebug: yield* Process.Debug.forAstNode(expr), + } as Ir.Instruction); - // Read the slice data from source - const dataTemp = yield* Process.Variables.newTemp(); - yield* Process.Instructions.emit({ - kind: "read", - location: "memory", - offset: Ir.Value.temp(adjustedSourceTemp, Ir.Type.Scalar.uint256), - length, - type: resultType, - dest: dataTemp, - operationDebug: yield* Process.Debug.forAstNode(expr), - } as Ir.Instruction.Read); + yield* Process.Instructions.emit({ + kind: "read", + location: "memory", + offset: Ir.Value.temp(adjustedSourceTemp, Ir.Type.Scalar.uint256), + length, + type: resultType, + dest: dataTemp, + operationDebug: yield* Process.Debug.forAstNode(expr), + } as Ir.Instruction.Read); + } // Calculate destination offset (skip length prefix) const destDataOffsetTemp = yield* Process.Variables.newTemp(); diff --git a/packages/bugc/src/irgen/generate/expressions/cast.ts b/packages/bugc/src/irgen/generate/expressions/cast.ts index 9ae727482e..388f30e86b 100644 --- a/packages/bugc/src/irgen/generate/expressions/cast.ts +++ b/packages/bugc/src/irgen/generate/expressions/cast.ts @@ -1,6 +1,7 @@ -import type * as Ast from "#ast"; +import * as Ast from "#ast"; import * as Ir from "#ir"; import { Severity } from "#result"; +import { Type } from "#types"; import { Error as IrgenError } from "#irgen/errors"; import { fromBugType } from "#irgen/type"; @@ -9,23 +10,51 @@ import { Process } from "../process.js"; import type { Context } from "./context.js"; /** - * Build a cast expression + * Build a cast expression. Code generation turns the cast into code + * by its source and target types (see `Ir.Utils.castSteps`). */ export const makeBuildCast = ( buildExpression: ( node: Ast.Expression, context: Context, ) => Process, -) => - function* buildCast( +) => { + /** + * Evaluate the expression being cast, typed as the type checker + * typed it, so the cast knows its source type + */ + function* buildOperand( + operand: Ast.Expression, + targetType: Ir.Type, + ): Process { + // `msg.data` is calldata at offset 0: cast its first 32 bytes + if ( + Ast.Expression.Special.isMsgData(operand) && + Ir.Type.isScalar(targetType) + ) { + const type = fromBugType(Type.Elementary.bytes(32)); + const dest = yield* Process.Variables.newTemp(); + yield* Process.Instructions.emit({ + kind: "read", + location: "calldata", + offset: Ir.Value.constant(0n, Ir.Type.Scalar.uint256), + length: Ir.Value.constant(32n, Ir.Type.Scalar.uint256), + type, + dest, + operationDebug: yield* Process.Debug.forAstNode(operand), + } as Ir.Instruction.Read); + return Ir.Value.temp(dest, type); + } + + const value = yield* buildExpression(operand, { kind: "rvalue" }); + const sourceType = yield* Process.Types.nodeType(operand); + return sourceType ? { ...value, type: fromBugType(sourceType) } : value; + } + + return function* buildCast( expr: Ast.Expression.Cast, _context: Context, ): Process { - // Evaluate the expression being cast - const exprValue = yield* buildExpression(expr.expression, { - kind: "rvalue", - }); - // Get the target type from the type checker const targetType = yield* Process.Types.nodeType(expr); @@ -37,18 +66,17 @@ export const makeBuildCast = ( Severity.Error, ), ); - return exprValue; // Return the original value + // Return the value being cast as it is + return yield* buildExpression(expr.expression, { kind: "rvalue" }); } const targetIrType = fromBugType(targetType); - - // For now, we'll generate a cast instruction that will be handled during bytecode generation - // In many cases, the cast is a no-op at the IR level (e.g., uint256 to address) + const value = yield* buildOperand(expr.expression, targetIrType); const resultTemp = yield* Process.Variables.newTemp(); yield* Process.Instructions.emit({ kind: "cast", - value: exprValue, + value, targetType: targetIrType, dest: resultTemp, operationDebug: yield* Process.Debug.forAstNode(expr), @@ -56,3 +84,4 @@ export const makeBuildCast = ( return Ir.Value.temp(resultTemp, targetIrType); }; +}; diff --git a/packages/bugc/src/optimizer/steps/constant-folding.ts b/packages/bugc/src/optimizer/steps/constant-folding.ts index 725c7f5306..8c957fdfd8 100644 --- a/packages/bugc/src/optimizer/steps/constant-folding.ts +++ b/packages/bugc/src/optimizer/steps/constant-folding.ts @@ -74,6 +74,22 @@ export class ConstantFoldingStep extends BaseOptimizationStep { } else { newInstructions.push(inst); } + } else if (inst.kind === "cast") { + const folded = this.foldCast(inst, constants); + if (folded) { + newInstructions.push(folded); + constants.set(folded.dest, folded.value); + + context.trackTransformation({ + type: "replace", + pass: this.name, + original: Ir.Utils.extractContexts(inst), + result: Ir.Utils.extractContexts(folded), + reason: `Evaluated cast of constant`, + }); + } else { + newInstructions.push(inst); + } } else if (inst.kind === "length" && this.canFoldLength(inst)) { // Try to fold length operation const folded = this.foldLength(inst); @@ -141,6 +157,33 @@ export class ConstantFoldingStep extends BaseOptimizationStep { }; } + /** + * Fold a cast of an integer constant, by the same steps code + * generation emits + */ + private foldCast( + inst: Ir.Instruction.Cast, + constants: Map, + ): Ir.Instruction.Const | undefined { + const value = this.getConstantValue(inst.value, constants); + if (typeof value !== "bigint") return undefined; + + const steps = Ir.Utils.castSteps(inst.value.type, inst.targetType); + const result = Ir.Utils.foldCast(value, steps); + if (result === undefined) return undefined; + + return { + kind: "const", + value: result, + type: inst.targetType, + dest: inst.dest, + operationDebug: Ir.Utils.addTransform( + Ir.Utils.preserveDebug(inst), + "fold", + ), + }; + } + private getConstantValue( value: Ir.Value, constants: Map, diff --git a/packages/bugc/src/parser/parser.test.ts b/packages/bugc/src/parser/parser.test.ts index ad5975b098..ab324ce711 100644 --- a/packages/bugc/src/parser/parser.test.ts +++ b/packages/bugc/src/parser/parser.test.ts @@ -685,4 +685,66 @@ code {}`; } }); }); + + describe("Postfix chains", () => { + // The nodes of a postfix chain, outermost first + const chain = (expression: Ast.Expression): Ast.Expression[] => { + const inner = + "object" in expression + ? expression.object + : "expression" in expression + ? expression.expression + : "callee" in expression + ? expression.callee + : undefined; + return [expression, ...(inner ? chain(inner as Ast.Expression) : [])]; + }; + + const valueOf = (source: string): Ast.Expression => { + const result = parse(source); + if (!result.success) throw new Error("Parse failed"); + const statement = result.value.body!.items[0] as Ast.Statement.Assign; + return statement.value; + }; + + const programs = { + casts: ["v as int8 as int256 as uint256", 4], + "a slice, cast": ["msg.data[0:4] as bytes4 as uint256", 4], + "a call, cast": ["f() as uint8", 3], + "members and indexes": ["s.a[1].b[0:2] as bytes2", 6], + } as const; + + for (const [name, [expression, length]] of Object.entries(programs)) { + it(`should locate each node of ${name}`, () => { + const prefix = "name Test;\ncode { x = "; + const source = `${prefix}${expression}; }`; + const nodes = chain(valueOf(source)); + + expect(nodes).toHaveLength(length); + expect(new Set(nodes.map(({ id }) => id)).size).toBe(length); + + // Each node spans from the base's start; outer nodes span more + const spans = nodes.map(({ loc }) => ({ + offset: Number(loc!.offset), + length: Number(loc!.length), + })); + for (const [index, { offset, length }] of spans.entries()) { + expect(offset).toBe(prefix.length); + if (index > 0) { + expect(length).toBeLessThan(spans[index - 1].length); + } + } + const { offset, length: outer } = spans[0]; + expect(source.slice(offset, offset + outer).trim()).toBe(expression); + }); + } + + it("should locate an intermediate cast at its own text", () => { + const source = "name Test;\ncode { x = v as int8 as int256; }"; + const [, inner] = chain(valueOf(source)); + const offset = Number(inner.loc!.offset); + const length = Number(inner.loc!.length); + expect(source.slice(offset, offset + length).trim()).toBe("v as int8"); + }); + }); }); diff --git a/packages/bugc/src/parser/parser.ts b/packages/bugc/src/parser/parser.ts index 37aed38291..cf6e5aabfd 100644 --- a/packages/bugc/src/parser/parser.ts +++ b/packages/bugc/src/parser/parser.ts @@ -641,49 +641,36 @@ const postfixExpression = P.lazy(() => { const suffix = P.alt(memberSuffix, indexSuffix, callSuffix, castSuffix); - // Split into two parsers: inner for creating located intermediate expressions, - // outer for the main parsing logic - const innerPostfix = P.seq(primaryExpression, suffix.many()).map( - ([base, suffixes]) => { - return suffixes.reduce((obj, suffix) => { - // Use located for each intermediate expression - if (suffix.type === "member") { - return located( - P.succeed( - Ast.Expression.Access.member(PENDING_ID, obj, suffix.property), - ), - ).tryParse(""); - } else if (suffix.type === "slice") { - return located( - P.succeed( - Ast.Expression.Access.slice( - PENDING_ID, - obj, - suffix.property, - suffix.end, - ), - ), - ).tryParse(""); - } else if (suffix.type === "call") { - return located( - P.succeed(Ast.Expression.call(PENDING_ID, obj, suffix.arguments)), - ).tryParse(""); - } else if (suffix.type === "cast") { - return located( - P.succeed(Ast.Expression.cast(PENDING_ID, obj, suffix.targetType)), - ).tryParse(""); - } else { - return located( - P.succeed( - Ast.Expression.Access.index(PENDING_ID, obj, suffix.property), - ), - ).tryParse(""); - } - }, base); - }, + // Each postfix expression spans from the start of its base to the end + // of its last suffix, so each gets its own location and id + const chain = P.seq( + P.index, + primaryExpression, + P.seq(suffix, P.index).many(), + ).map(([start, base, suffixes]) => + suffixes.reduce((obj, [suffix, end]) => { + const loc = { + offset: start.offset, + length: end.offset - start.offset, + }; + const id = `${loc.offset}_${loc.length}` as Ast.Id; + + const node = + suffix.type === "member" + ? Ast.Expression.Access.member(id, obj, suffix.property) + : suffix.type === "slice" + ? Ast.Expression.Access.slice(id, obj, suffix.property, suffix.end) + : suffix.type === "call" + ? Ast.Expression.call(id, obj, suffix.arguments) + : suffix.type === "cast" + ? Ast.Expression.cast(id, obj, suffix.targetType) + : Ast.Expression.Access.index(id, obj, suffix.property); + + return { ...node, id, loc }; + }, base), ); - return located(innerPostfix); + return located(chain); }); // Unary expressions diff --git a/packages/bugc/src/typechecker/checker.test.ts b/packages/bugc/src/typechecker/checker.test.ts index 074ab26d2a..97ddbcc8cd 100644 --- a/packages/bugc/src/typechecker/checker.test.ts +++ b/packages/bugc/src/typechecker/checker.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { parse } from "#parser"; import { Result, Severity } from "#result"; -import type { Types } from "#types"; +import { Type, type Types } from "#types"; import { checkProgram } from "./checker.js"; import type { Error as BugTypeError } from "./errors.js"; @@ -422,4 +422,60 @@ describe("checkProgram", () => { expect(Result.hasMessages(result)).toBe(false); }); }); + + describe("Postfix chains", () => { + it("should give each node of a chain its own type", () => { + const result = check(` + name Test; + define { + function f() -> uint256 { return 300; }; + } + storage { [0] v: uint256; [1] a: uint256; [2] b: uint256; } + code { + a = v as int8 as int256 as uint256; + b = f() as uint8 as uint256; + } + `); + + expect(result.success).toBe(true); + if (!result.success) return; + + const formatted = new Set( + [...result.value.values()].map((type) => Type.format(type)), + ); + for (const name of ["int8", "int256", "uint8", "uint256"]) { + expect(formatted).toContain(name); + } + + // No two chain nodes share an id, so no type is overwritten + const ids = [...result.value.keys()]; + expect(ids).not.toContain("0_0"); + }); + }); + + describe("Casts from dynamic bytes", () => { + const program = (expression: string) => ` + name Test; + storage { [0] a: uint256; } + code { let x = ${expression}; } + `; + + for (const target of ["uint32", "int8", "uint256", "address"]) { + it(`should reject bytes to ${target}`, () => { + const result = check(program(`msg.data[0:4] as ${target}`)); + + expect(result.success).toBe(false); + expect(result).toHaveMessage({ + severity: Severity.Error, + message: `Cannot cast from bytes to ${target}: cast to a fixed-size bytes type first`, + }); + }); + } + + it("should allow bytes to an integer via bytesN", () => { + const result = check(program("msg.data[0:4] as bytes4 as uint32")); + + expect(result.success).toBe(true); + }); + }); }); diff --git a/packages/bugc/src/typechecker/expressions.ts b/packages/bugc/src/typechecker/expressions.ts index fbb161c203..cbe8329894 100644 --- a/packages/bugc/src/typechecker/expressions.ts +++ b/packages/bugc/src/typechecker/expressions.ts @@ -772,6 +772,27 @@ export const expressionChecker: Pick, "expression"> = { return { symbols, nodeTypes, bindings, errors }; } + // Dynamic bytes cast to an integer or address only via bytesN, + // as in Solidity + if ( + isDynamicBytes(exprResult.type) && + Type.isElementary(targetTypeResult.type) && + (Type.Elementary.isNumeric(targetTypeResult.type) || + Type.Elementary.isAddress(targetTypeResult.type)) + ) { + const target = Type.format(targetTypeResult.type); + const error = new TypeError( + `Cannot cast from bytes to ${target}: cast to a fixed-size ` + + `bytes type first (for example, \`x as bytes32 as ${target}\`)`, + node.loc || undefined, + target, + Type.format(exprResult.type), + ErrorCode.INVALID_TYPE_CAST, + ); + errors.push(error); + return { symbols, nodeTypes, bindings, errors }; + } + // Check if the cast is valid if (!isValidCast(exprResult.type, targetTypeResult.type)) { const error = new TypeError( @@ -1030,6 +1051,11 @@ export const expressionChecker: Pick, "expression"> = { }, }; +const isDynamicBytes = (type: Type): boolean => + Type.isElementary(type) && + Type.Elementary.isBytes(type) && + type.size === undefined; + /** * Helper function to check if a cast is valid between two types */ 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 a66d5bd055..26e6a315c9 100644 --- a/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx +++ b/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx @@ -49,6 +49,26 @@ code { } ``` +### Casts + +`x as T` converts `x` to type `T` as Solidity does. A cast to a narrower +integer type or to `address` keeps the low bits; a cast to a signed type +then sign-extends from the new width. A cast to a narrower `bytesN` keeps +the leading bytes, and a cast to a wider one pads with zero bytes at the +end. A cast from dynamic `bytes` to `bytesN` uses its first 32 bytes +(zero past its length) as a `bytes32`. As in Solidity, dynamic `bytes` +casts to an integer or `address` only through `bytesN`. + +```bug +300 as uint8; // 44 +200 as int8; // -56 +(-1 as int256) as uint8; // 255 +0x11223344 as bytes8; // 0x1122334400000000 +0x1122334455667788 as bytes4; // 0x11223344 +msg.data[0:4] as bytes4; // the function selector +msg.data[4:36] as bytes32 as address; // an ABI-encoded address +``` + ## Compilation pipeline BUG uses a multi-stage compilation pipeline: