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, "expression"> = { } const funcType = symbol.type; + nodeTypes.set(node.callee.id, funcType); // Check argument count if (node.arguments.length !== funcType.parameters.length) { diff --git a/packages/bugc/src/typechecker/slice.test.ts b/packages/bugc/src/typechecker/slice.test.ts index 28f65734b..3d437be57 100644 --- a/packages/bugc/src/typechecker/slice.test.ts +++ b/packages/bugc/src/typechecker/slice.test.ts @@ -99,9 +99,9 @@ describe("Slice type checking", () => { }); }); - test("allows bytes calldata only as the type of a let or a cast", () => { + test("allows bytes calldata only as a let's, a parameter's or a cast's type", () => { const placement = - "`bytes calldata` can only be the type of a `let` or a cast"; + "`bytes calldata` can only be the type of a `let`, a parameter or a cast"; const errors = (source: string) => { const result = parse(source); if (!result.success) throw new Error("Parse failed"); @@ -114,12 +114,13 @@ describe("Slice type checking", () => { errors(` name Test; define { - function f(b: bytes calldata) -> uint256 { return b.length; }; + function f(b: array) -> uint256 { return 0; }; + function g() -> bytes calldata { return msg.data; }; struct S { b: bytes calldata; }; } code {} `), - ).toEqual([placement, placement]); + ).toEqual([placement, placement, placement]); expect( errors(` @@ -216,19 +217,64 @@ describe("String calldata type checking", () => { }); }); + test("allows a parameter of bytes or a string in calldata", () => { + const result = parse(` + name Test; + define { + function f(s: string calldata, b: bytes calldata) -> uint256 { + let t = s; + let c = b[0:1]; + return s.length + b.length + t.length + c.length; + }; + function g(s: string calldata) -> uint256 { + return f(s, s as bytes calldata); + }; + } + storage { [0] r: uint256; } + code { + let a = msg.data[4:36]; + r = g(a as string calldata) + f(a as string calldata, a); + } + `); + if (!result.success) throw new Error("Parse failed"); + expect(checkProgram(result.value).success).toBe(true); + }); + test("rejects a string in calldata mixed with bytes or memory", () => { const placement = - "`string calldata` can only be the type of a `let` or a cast"; + "`string calldata` can only be the type of a `let`, a parameter or a cast"; expect( typeErrors(` name Test; define { - function f(s: string calldata) -> uint256 { return s.length; }; + function f() -> string calldata { return msg.data as string calldata; }; } code {} `), ).toEqual([placement]); + // A string in memory or bytes in calldata is not a `string + // calldata` argument; a string in calldata copies to a `string` + // parameter + expect( + typeErrors(` + name Test; + define { + function g(s: string calldata) -> uint256 { return s.length; }; + function h(s: string) -> uint256 { return s.length; }; + } + code { + let m = "hello"; + let n = g(m); + let c = g(msg.data); + let x = h(msg.data as string calldata); + } + `), + ).toEqual([ + "Argument 1 type mismatch: expected string calldata, got string", + "Argument 1 type mismatch: expected string calldata, got bytes calldata", + ]); + expect( typeErrors(` name Test; 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 103e2c7eb..7cdd86d6a 100644 --- a/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx +++ b/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx @@ -149,14 +149,14 @@ let copy: bytes = text; // a copy in memory ``` The type says where the bytes are: `bytes` is always in memory, as in -a parameter, a return type or a struct field, and `bytes calldata` can -only be the type of a `let` or of a cast. Calldata is read only: +a return type or a struct field, and `bytes calldata` can only be the +type of a `let`, a parameter or a cast. Calldata is read only: `text[0] = 1` is a type error, and so is assigning bytes in memory to a `bytes calldata` local or casting them to `bytes calldata`. Bytes in calldata are copied to memory (with `CALLDATACOPY`) wherever a value must be in memory: in a `let` typed `bytes`, an assignment to a `bytes` -local, a cast to `bytes` or `string`, a function argument or return -value, and the argument of `keccak256`. Assigning to a `bytes` or +local, a cast to `bytes` or `string`, an argument to a `bytes` +parameter, a return value, and the argument of `keccak256`. Assigning to a `bytes` or `string` storage variable stores the bytes as Solidity does, through such a copy. @@ -173,8 +173,20 @@ motd = name; // stored as Solidity stores a string As a `string` in memory, a `string calldata` has no index or slice, and bytes do not assign to it without a cast. It too can only be the -type of a `let` or of a cast. Bytes or a string in calldata cannot be -compared with `==` or `!=`, or be a mapping key. +type of a `let`, a parameter or a cast. Bytes or a string in calldata +cannot be compared with `==` or `!=`, or be a mapping key. + +A parameter of bytes or a string in calldata takes its argument's word, +with no copy, both in a real call and when the optimizer inlines the +function: + +```bug +function join(name: string calldata) { + players[msg.sender].name = name; +}; +// ... +join(name); // passes the word; copies nothing +``` A value of bytes in calldata is one word: the offset of the first byte in the high 128 bits, and the length in the low 128 bits. A local's