diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index edf0b1d97..c52fc18c4 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -9,6 +9,12 @@ support. Changes to the specification itself are tracked in the root ### Added +- A function parameter may be `string calldata` or `bytes calldata`: + `function join(name: string calldata) { ... }`. The argument passes + as its word (offset and length) and is not copied to memory, both in + a real call and when the function is inlined. Its argument must be a + string or bytes in calldata, as for a `let` of that type. The + parameter's pointer is that of a calldata `let` ([#383]). - `string calldata`, a string in calldata, as `bytes calldata` is bytes in calldata: `let name: string calldata = msg.data[a:b] as string calldata;` refers to the calldata and copies nothing. Bytes and a string in @@ -17,8 +23,9 @@ support. Changes to the specification itself are tracked in the root to a `string` in memory or storage (`let m: string = name;`, `name as string`, `players[msg.sender].name = name;`). As `string` is, it is not indexed or sliced, and as `bytes calldata` is, it is - only the type of a `let` or a cast. A local's pointer is that of - bytes in calldata, and its type is `{ "kind": "string" }` ([#379]). + only the type of a `let`, a parameter or a cast. A local's pointer is + that of bytes in calldata, and its type is `{ "kind": "string" }` + ([#379]). - A struct copied from storage to memory may have `string` and `bytes` fields: each field's word holds the address of its own copy. A struct in memory assigned to storage (`players[msg.sender] = player;`) is @@ -531,3 +538,4 @@ First publication. [#377]: https://github.com/ethdebug/format/pull/377 [#378]: https://github.com/ethdebug/format/pull/378 [#379]: https://github.com/ethdebug/format/pull/379 +[#383]: https://github.com/ethdebug/format/pull/383 diff --git a/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts b/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts index 554f90ec3..34b4fd5e0 100644 --- a/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts +++ b/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts @@ -541,6 +541,65 @@ code { }; } +/** + * A `string calldata` or `bytes calldata` parameter refers to the + * calldata as a local of its type does. `record` is a leaf, so it is + * inlined at O2 and O3; `join` has two returns, so it stays a real + * call, and passes its parameter on to `record`. + */ +function parameterProgram( + text: string, + callee: "record" | "join", + type: "string" | "bytes" = "string", +) { + const pass = (name: string) => + type === "bytes" ? `${name} as bytes calldata` : name; + return { + name: `a ${type} calldata parameter of ${text.length} bytes (${callee})`, + source: `name Join; +define { + function record(who: ${type} calldata) -> uint256 { + motd = who as string; + return who.length; + }; + function join(player: string calldata) -> uint256 { + let k = record(${pass("player")}); + if (k > 0) { return k; } + return 0; + }; +} +storage { [0] motd: string; [1] r: uint256; } +code { + let offset = msg.data[4:36] as bytes32 as uint256; + let n = msg.data[4 + offset:36 + offset] as bytes32 as uint256; + let name: string calldata = + msg.data[36 + offset:36 + offset + n] as string calldata; + r = ${callee}(${callee === "record" ? pass("name") : "name"}); +}`, + calldata: setMotd(text), + locals: { + offset: { values: [32n] }, + n: { values: [BigInt(text.length)] }, + name: { shape: { kind: "calldata" }, values: [textBytes(text)] }, + who: { + shape: { kind: "calldata" }, + values: [textBytes(text)], + everyLevel: true, + }, + ...(callee === "join" + ? { + player: { + shape: { kind: "calldata" }, + values: [textBytes(text)], + everyLevel: true, + }, + k: { values: [BigInt(text.length)] }, + } + : {}), + }, + } satisfies LocalsProgram; +} + const programs: LocalsProgram[] = [ { name: "straight line", @@ -1199,6 +1258,13 @@ code { ...["", "a".repeat(31), "b".repeat(32), "c".repeat(33), motd].map( namedProgram, ), + ...["", "c".repeat(33), motd].flatMap((text) => + (["record", "join"] as const).flatMap((callee) => + (["string", "bytes"] as const).map((type) => + parameterProgram(text, callee, type), + ), + ), + ), { // A slice longer than a word: its pointer reads every byte name: "a long bytes slice", diff --git a/packages/bugc/src/evmgen/string-calldata.test.ts b/packages/bugc/src/evmgen/string-calldata.test.ts index e86036c82..332287b1e 100644 --- a/packages/bugc/src/evmgen/string-calldata.test.ts +++ b/packages/bugc/src/evmgen/string-calldata.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect } from "vitest"; import { keccak256 } from "ethereum-cryptography/keccak"; +import { compile } from "#compiler"; + import { executeProgram } from "#test/evm/behavioral"; import { expectEncoded, @@ -107,3 +109,170 @@ code { }); } }); + +/** + * A `string calldata` or `bytes calldata` parameter: the argument + * passes as its word of offset and length, with no copy. `record` is a + * leaf, so it is inlined at O2 and O3; `join` has two returns, so it + * stays a real call, and passes its parameter on. + */ +const parameterSource = (callee: "record" | "join") => `name Join; +define { + struct Player { score: uint256; name: string; }; + function record(name: string calldata, raw: bytes calldata) -> uint256 { + players[msg.sender].name = name; + if (raw.length > 0) { first = raw[0]; } + return name.length; + }; + function join(name: string calldata) -> uint256 { + let n = record(name, name as bytes calldata); + motd = name; + if (n > 0) { return n; } + return 0; + }; +} +storage { + [0] motd: string; [1] len: uint256; [2] players: mapping
; + [3] first: uint256; +} +code { + let offset = msg.data[4:36] as bytes32 as uint256; + let n = msg.data[4 + offset:36 + offset] as bytes32 as uint256; + let name: string calldata = + msg.data[36 + offset:36 + offset + n] as string calldata; + len = ${callee === "join" ? "join(name)" : "record(name, name as bytes calldata)"}; +}`; + +describe("calldata parameters", () => { + it("inline at O2 into one call, not the other", async () => { + const functions = async (callee: "record" | "join") => { + const result = await compile({ + to: "ir", + source: parameterSource(callee), + optimizer: { level: 2 }, + }); + if (!result.success) throw new Error("compile failed"); + return [...result.value.ir.functions.keys()].sort(); + }; + // `record` is inlined everywhere; `join` is never inlined + expect(await functions("record")).toEqual(["join"]); + expect(await functions("join")).toEqual(["join"]); + }); + + it("are not copied to memory", async () => { + const result = await compile({ + to: "ir", + source: parameterSource("join"), + optimizer: { level: 0 }, + }); + if (!result.success) throw new Error("compile failed"); + const { functions } = result.value.ir; + for (const fn of [functions.get("join")!, functions.get("record")!]) { + for (const parameter of fn.parameters) { + expect(parameter.type).toMatchObject({ + kind: "ref", + location: "calldata", + }); + } + } + // The only copy from calldata is `motd = name` and the struct field + const copies = [result.value.ir.main, ...functions.values()] + .flatMap((fn) => [...fn.blocks.values()]) + .flatMap((block) => block.instructions) + .filter((inst) => inst.kind === "copy"); + expect(copies).toHaveLength(2); + }); +}); + +describe.each(levels)("calldata parameters at O%i", (level) => { + for (const callee of ["record", "join"] as const) { + it.each(lengths)(`pass %i bytes to ${callee}`, async (length) => { + const name = text(length); + const result = await executeProgram(parameterSource(callee), { + calldata: setName(name), + optimizationLevel: level, + }); + expect(result.callSuccess).toBe(true); + expect(await result.getStorage(1n)).toBe(BigInt(length)); + await expectEncoded(result, mappingSlot(2n, sender) + 1n, name); + if (callee === "join") await expectEncoded(result, 0n, name); + expect(await result.getStorage(3n)).toBe( + length > 0 ? BigInt(name.charCodeAt(0)) : 0n, + ); + }); + } +}); + +/** + * Flows from a calldata parameter in its function: a copy to a + * `string` local, `keccak256`, a `string` return value, an argument to + * a `string` parameter, and a tail-recursive function's parameter + */ +const flowsSource = `name Flows; +define { + function copied(p: string calldata) -> uint256 { + let t: string = p; + first = t; + return t.length; + }; + function hashed(p: string calldata) -> bytes32 { return keccak256(p); }; + function returned(p: string calldata) -> string { return p; }; + function stored(m: string) -> uint256 { second = m; return m.length; }; + function passed(p: string calldata) -> uint256 { return stored(p); }; + function sum(p: bytes calldata, i: uint256, total: uint256) -> uint256 { + if (i == p.length) { return total; } + return sum(p, i + 1, total + p[i]); + }; +} +storage { + [0] first: string; [1] second: string; [2] third: string; + [3] h: bytes32; [4] copiedLength: uint256; [5] passedLength: uint256; + [6] total: uint256; +} +code { + let offset = msg.data[4:36] as bytes32 as uint256; + let n = msg.data[4 + offset:36 + offset] as bytes32 as uint256; + let name: string calldata = + msg.data[36 + offset:36 + offset + n] as string calldata; + copiedLength = copied(name); + h = hashed(name); + third = returned(name); + passedLength = passed(name); + total = sum(name as bytes calldata, 0, 0); +}`; + +describe.each(levels)("flows from a calldata parameter at O%i", (level) => { + it("make sum a tail call loop at O2 and O3", async () => { + const result = await compile({ + to: "ir", + source: flowsSource, + optimizer: { level }, + }); + if (!result.success) throw new Error("compile failed"); + const sum = result.value.ir.functions.get("sum")!; + const tailCall = [...sum.blocks.values()].some( + ({ terminator }) => terminator.kind === "jump" && terminator.tailCall, + ); + expect(tailCall).toBe(level >= 2); + }); + + it.each(lengths)("copy, hash, return and pass %i bytes", async (length) => { + const name = text(length); + const result = await executeProgram(flowsSource, { + calldata: setName(name), + optimizationLevel: level, + }); + expect(result.callSuccess).toBe(true); + await expectEncoded(result, 0n, name); + await expectEncoded(result, 1n, name); + await expectEncoded(result, 2n, name); + expect(await result.getStorage(3n)).toBe( + BigInt("0x" + Buffer.from(keccak256(Buffer.from(name))).toString("hex")), + ); + expect(await result.getStorage(4n)).toBe(BigInt(length)); + expect(await result.getStorage(5n)).toBe(BigInt(length)); + expect(await result.getStorage(6n)).toBe( + [...Buffer.from(name)].reduce((a, b) => a + BigInt(b), 0n), + ); + }); +}); diff --git a/packages/bugc/src/irgen/generate/expressions/call.ts b/packages/bugc/src/irgen/generate/expressions/call.ts index 2a8d59e0d..0dcef1d0e 100644 --- a/packages/bugc/src/irgen/generate/expressions/call.ts +++ b/packages/bugc/src/irgen/generate/expressions/call.ts @@ -98,14 +98,29 @@ export const makeBuildCall = ( return Ir.Value.constant(0n, Ir.Type.Scalar.uint256); } - // Evaluate arguments; a parameter's bytes are in memory + // Evaluate arguments. A parameter's bytes are in memory, unless + // the parameter is bytes or a string in calldata: then its + // argument is too, and passes as its word of offset and length. + const calleeType = yield* Process.Types.nodeType(expr.callee); + if (!calleeType || !Type.isFunction(calleeType)) { + yield* Process.Errors.report( + new IrgenError( + `No function type for ${functionName}`, + expr.loc ?? undefined, + Severity.Error, + ), + ); + return Ir.Value.constant(0n, Ir.Type.Scalar.uint256); + } + const { parameters } = calleeType; const argValues: Ir.Value[] = []; - for (const arg of expr.arguments) { + for (const [index, arg] of expr.arguments.entries()) { + const value = yield* buildExpression(arg, { kind: "rvalue" }); + const parameter = parameters[index]; argValues.push( - yield* emitInMemory( - yield* buildExpression(arg, { kind: "rvalue" }), - arg, - ), + parameter && Type.Elementary.isCalldata(parameter) + ? value + : yield* emitInMemory(value, arg), ); } diff --git a/packages/bugc/src/typechecker/declarations.ts b/packages/bugc/src/typechecker/declarations.ts index 3b15425b8..c22cb312d 100644 --- a/packages/bugc/src/typechecker/declarations.ts +++ b/packages/bugc/src/typechecker/declarations.ts @@ -104,7 +104,9 @@ function buildFunctionSignature( // Resolve parameter types const parameterTypes: Type[] = []; for (const param of decl.parameters) { - checkNoCalldata(param.type); + // A parameter may be bytes or a string in calldata, as a whole + const calldata = calldataTypeNode(param.type); + if (calldata && calldata !== param.type) throw calldataTypeError(calldata); const paramType = resolveType(param.type, structTypes); parameterTypes.push(paramType); } @@ -142,14 +144,14 @@ export function calldataTypeNode( /** * The error for `bytes calldata` or `string calldata` where it cannot - * be: it is only the whole type of a `let` or of a cast + * be: it is only the whole type of a `let`, a parameter or a cast */ export function calldataTypeError( typeNode: Ast.Type.Elementary.Bytes | Ast.Type.Elementary.String, ): TypeError { const name = Ast.Type.Elementary.isBytes(typeNode) ? "bytes" : "string"; return new TypeError( - `\`${name} calldata\` can only be the type of a \`let\` or a cast`, + `\`${name} calldata\` can only be the type of a \`let\`, a parameter or a cast`, typeNode.loc || undefined, undefined, undefined, diff --git a/packages/bugc/src/typechecker/expressions.ts b/packages/bugc/src/typechecker/expressions.ts index 90709682e..b78e890d6 100644 --- a/packages/bugc/src/typechecker/expressions.ts +++ b/packages/bugc/src/typechecker/expressions.ts @@ -716,6 +716,7 @@ export const expressionChecker: Pick