Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions packages/bugc/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
66 changes: 66 additions & 0 deletions packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
169 changes: 169 additions & 0 deletions packages/bugc/src/evmgen/string-calldata.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<address, Player>;
[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),
);
});
});
27 changes: 21 additions & 6 deletions packages/bugc/src/irgen/generate/expressions/call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
}

Expand Down
8 changes: 5 additions & 3 deletions packages/bugc/src/typechecker/declarations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/bugc/src/typechecker/expressions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,7 @@ export const expressionChecker: Pick<Visitor<Report, Context>, "expression"> = {
}

const funcType = symbol.type;
nodeTypes.set(node.callee.id, funcType);

// Check argument count
if (node.arguments.length !== funcType.parameters.length) {
Expand Down
Loading
Loading