From d806ead6e085f6ef06cecbccfffee9c4d6432972 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Fri, 9 Oct 2026 02:12:55 -0400 Subject: [PATCH 1/5] bugc: add string calldata references `string calldata` is a string in calldata, held as `bytes calldata` is: one word with the offset and length. A cast converts bytes and a string in calldata to each other; a string in calldata copies to a string in memory or storage. A local's pointer is that of bytes in calldata, typed as a string. --- packages/bugc/CHANGELOG.md | 10 +++ packages/bugc/src/ast/spec.ts | 10 ++- .../debug/local-variables.soundness.test.ts | 68 ++++++++++++++++ .../bugc/src/evmgen/string-calldata.test.ts | 75 ++++++++++++++++++ packages/bugc/src/irgen/generate/calldata.ts | 13 +++- .../src/irgen/generate/expressions/access.ts | 4 +- .../src/irgen/generate/statements/assign.ts | 5 +- packages/bugc/src/irgen/type.ts | 4 +- packages/bugc/src/parser/parser.ts | 25 +++--- packages/bugc/src/typechecker/assignable.ts | 10 +++ packages/bugc/src/typechecker/declarations.ts | 32 +++++--- packages/bugc/src/typechecker/expressions.ts | 18 +++-- packages/bugc/src/typechecker/slice.test.ts | 78 +++++++++++++++++++ packages/bugc/src/typechecker/type-nodes.ts | 4 +- packages/bugc/src/types/spec.ts | 41 +++++++++- .../compiler/case-study-bug.mdx | 18 ++++- 16 files changed, 372 insertions(+), 43 deletions(-) create mode 100644 packages/bugc/src/evmgen/string-calldata.test.ts diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index c23d95f286..faaeba6361 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -9,6 +9,16 @@ support. Changes to the specification itself are tracked in the root ### Added +- `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 + calldata convert to each other with a cast (`as string calldata`, + `as bytes calldata`). A `string calldata` has `.length`, and copies + 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" }`. - 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 diff --git a/packages/bugc/src/ast/spec.ts b/packages/bugc/src/ast/spec.ts index a0ef836af8..f9cf2dda25 100644 --- a/packages/bugc/src/ast/spec.ts +++ b/packages/bugc/src/ast/spec.ts @@ -489,8 +489,10 @@ export namespace Type { }; } + /** `string`, or `string calldata` (`location`) */ export interface String extends Elementary.Base { kind: "type:elementary:string"; + location?: "calldata"; } export const isString = (type: Type.Base): type is Type.Elementary.String => @@ -499,8 +501,14 @@ export namespace Type { export function string( id: Id, loc?: SourceLocation, + location?: "calldata", ): Type.Elementary.String { - return { id, kind: "type:elementary:string", loc: loc ?? null }; + return { + id, + kind: "type:elementary:string", + ...(location ? { location } : {}), + loc: loc ?? null, + }; } export interface Fixed extends Elementary.Base { 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 b56311cfcb..90ce23d395 100644 --- a/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts +++ b/packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts @@ -476,6 +476,37 @@ code { }; } +/** + * A `string calldata` local refers to the calldata as `bytes calldata` + * does; its pointer reads exactly the string's bytes + */ +function namedProgram(text: string): LocalsProgram { + return { + name: `a string calldata local of ${text.length} bytes`, + source: `name SetName; +storage { [0] motd: string; [1] r: uint256; } +create { r = 1; } +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; + motd = name; + if (r > 0) { r = name.length; } +}`, + calldata: setMotd(text), + locals: { + offset: { values: [32n] }, + n: { values: [BigInt(text.length)] }, + name: { + shape: { kind: "calldata" }, + values: [textBytes(text)], + everyLevel: true, + }, + }, + }; +} + const programs: LocalsProgram[] = [ { name: "straight line", @@ -1109,6 +1140,9 @@ code { locals: { b: { shape: { kind: "calldata" }, values: ["0x"] } }, }, ...annotations.map(setMotdProgram), + ...["", "a".repeat(31), "b".repeat(32), "c".repeat(33), motd].map( + namedProgram, + ), { // A slice longer than a word: its pointer reads every byte name: "a long bytes slice", @@ -1216,6 +1250,40 @@ describe.each(levels)("locals hold the program's values at O%i", (level) => { ); }); +describe.each(levels)("a string calldata local at O%i", (level) => { + it("has a string type and the pointer of bytes in calldata", async () => { + const pointerOf = async (type: string) => { + const { program } = await traceLocals( + `name SetName; +storage { [0] motd: string; } +code { + let name: ${type} = msg.data[4:msg.data.length] as ${type}; + motd = name as string; +}`, + level, + setMotd(motd), + ); + const entries = program.instructions + .flatMap(({ context }) => localsOf(context)) + .filter((entry) => entry.identifier === "name" && entry.pointer); + expect(entries.length).toBeGreaterThan(0); + return entries; + }; + + const strings = await pointerOf("string calldata"); + const bytes = await pointerOf("bytes calldata"); + for (const entry of strings) { + expect(entry.type).toEqual({ kind: "string" }); + } + for (const entry of bytes) { + expect(entry.type).toEqual({ kind: "bytes" }); + } + expect(strings.map((entry) => entry.pointer)).toEqual( + bytes.map((entry) => entry.pointer), + ); + }); +}); + describe("variables before a function's first statement", () => { // Contexts are postconditions: the step after an instruction shows // its context. Every step lists the storage variable; from the diff --git a/packages/bugc/src/evmgen/string-calldata.test.ts b/packages/bugc/src/evmgen/string-calldata.test.ts new file mode 100644 index 0000000000..3f079acc93 --- /dev/null +++ b/packages/bugc/src/evmgen/string-calldata.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from "vitest"; +import { keccak256 } from "ethereum-cryptography/keccak"; + +import { executeProgram } from "#test/evm/behavioral"; +import { expectEncoded, mappingSlot, text } from "#test/evm/storage"; + +const levels = [0, 1, 2, 3] as const; + +/** The sender of every transaction the executor sends */ +const sender = 1n; + +const word = (n: bigint) => n.toString(16).padStart(64, "0"); + +/** Calldata for `setName(string)`, ABI-encoded, without the 0x */ +function setName(name: string): string { + const data = Buffer.from(name).toString("hex"); + const padded = data.padEnd(Math.ceil(data.length / 64) * 64, "0"); + return "deadbeef" + word(32n) + word(BigInt(name.length)) + padded; +} + +/** + * A `string calldata` local from the ABI offset and length words of a + * string argument, copied to storage (a variable and a struct field) + * and to memory + */ +const source = `name Names; +define { struct Player { score: uint256; name: string; }; } +storage { + [0] motd: string; [1] len: uint256; [2] players: mapping; + [3] copy: string; [4] raw: bytes; [5] first: uint256; [6] h: bytes32; + [7] other: string; +} +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; + let alias = name; + len = alias.length; + motd = name; + players[msg.sender].name = name; + let m = name as string; + copy = m; + let s: string = name; + other = s; + raw = name as bytes; + let b = name as bytes calldata; + if (b.length > 0) { first = b[0]; } + h = keccak256(name); +}`; + +const lengths = [0, 31, 32, 33, 65]; + +describe.each(levels)("a string in calldata at O%i", (level) => { + it.each(lengths)("copies %i bytes to storage and memory", async (length) => { + const name = text(length); + const result = await executeProgram(source, { + calldata: setName(name), + optimizationLevel: level, + }); + expect(result.callSuccess).toBe(true); + expect(await result.getStorage(1n)).toBe(BigInt(length)); + await expectEncoded(result, 0n, name); + await expectEncoded(result, mappingSlot(2n, sender) + 1n, name); + await expectEncoded(result, 3n, name); + await expectEncoded(result, 4n, name); + await expectEncoded(result, 7n, name); + expect(await result.getStorage(5n)).toBe( + length > 0 ? BigInt(name.charCodeAt(0)) : 0n, + ); + expect(await result.getStorage(6n)).toBe( + BigInt("0x" + Buffer.from(keccak256(Buffer.from(name))).toString("hex")), + ); + }); +}); diff --git a/packages/bugc/src/irgen/generate/calldata.ts b/packages/bugc/src/irgen/generate/calldata.ts index cd9785896e..e495ba44ae 100644 --- a/packages/bugc/src/irgen/generate/calldata.ts +++ b/packages/bugc/src/irgen/generate/calldata.ts @@ -127,10 +127,19 @@ export function* emitCopyToMemory( return Ir.Value.temp(base, Ir.Type.ref("memory", origin)); } -/** A value, or, if it is bytes in calldata, a copy of them in memory */ +/** + * A value, or, if it is bytes or a string in calldata, a copy of them + * in memory, typed `bytes` or `string` + */ export function* emitInMemory( value: Ir.Value, node: Ast.Node, ): Process { - return isCalldata(value) ? yield* emitCopyToMemory(value, node) : value; + if (!isCalldata(value)) return value; + const { origin } = value.type; + return yield* emitCopyToMemory( + value, + node, + origin === "synthetic" ? undefined : Type.Elementary.inMemory(origin), + ); } diff --git a/packages/bugc/src/irgen/generate/expressions/access.ts b/packages/bugc/src/irgen/generate/expressions/access.ts index 7db343fba1..918b6b703a 100644 --- a/packages/bugc/src/irgen/generate/expressions/access.ts +++ b/packages/bugc/src/irgen/generate/expressions/access.ts @@ -96,9 +96,9 @@ const makeBuildMemberAccess = ( (Type.Elementary.isBytes(objectType) || Type.Elementary.isString(objectType)))) ) { - // Bytes in calldata: the length is in their word + // Bytes or a string in calldata: the length is in their word if ( - Type.Elementary.Bytes.isCalldata(objectType) && + Type.Elementary.isCalldata(objectType) && !Ast.Expression.Special.isMsgData(expr.object) ) { const word = yield* buildExpression(expr.object, { diff --git a/packages/bugc/src/irgen/generate/statements/assign.ts b/packages/bugc/src/irgen/generate/statements/assign.ts index 82b5859751..17c0ff0954 100644 --- a/packages/bugc/src/irgen/generate/statements/assign.ts +++ b/packages/bugc/src/irgen/generate/statements/assign.ts @@ -63,10 +63,11 @@ function* buildLValue( } // Evaluate the value expression with the appropriate context. Bytes - // in calldata copy to memory, unless the target is in calldata too. + // or a string in calldata copy to memory, unless the target is in + // calldata too. let value = yield* buildExpression(valueExpr, context); const targetType = yield* Process.Types.nodeType(target); - if (!targetType || !Type.Elementary.Bytes.isCalldata(targetType)) { + if (!targetType || !Type.Elementary.isCalldata(targetType)) { value = yield* emitInMemory(value, valueExpr); } diff --git a/packages/bugc/src/irgen/type.ts b/packages/bugc/src/irgen/type.ts index 1f01e4c25c..8dd57913c5 100644 --- a/packages/bugc/src/irgen/type.ts +++ b/packages/bugc/src/irgen/type.ts @@ -57,8 +57,8 @@ export function fromBugType(type: BugType): Ir.Type { return Ir.Type.ref(type.location ?? "memory", type); } case "string": - // Strings are always memory references - return Ir.Type.ref("memory", type); + // Strings are memory references, or calldata ones + return Ir.Type.ref(type.location ?? "memory", type); default: assertExhausted(type); } diff --git a/packages/bugc/src/parser/parser.ts b/packages/bugc/src/parser/parser.ts index 2b86b0b8c6..f8fd21fdd2 100644 --- a/packages/bugc/src/parser/parser.ts +++ b/packages/bugc/src/parser/parser.ts @@ -376,21 +376,24 @@ let statement: P.Parser; * Type Parsers */ -// Elementary types with location. Dynamic bytes may be followed by -// `calldata`: bytes in calldata +// Elementary types with location. Dynamic bytes and string may be +// followed by `calldata`: bytes or a string in calldata const elementaryType = located( Lang.elementaryType.chain((name: string) => - name === "bytes" + name === "bytes" || name === "string" ? Lang.keyword("calldata") .atMost(1) - .map((calldata) => - Ast.Type.Elementary.bytes( - PENDING_ID, - undefined, - undefined, - calldata.length > 0 ? "calldata" : undefined, - ), - ) + .map((calldata) => { + const location = calldata.length > 0 ? "calldata" : undefined; + return name === "bytes" + ? Ast.Type.Elementary.bytes( + PENDING_ID, + undefined, + undefined, + location, + ) + : Ast.Type.Elementary.string(PENDING_ID, undefined, location); + }) : P.succeed(elementaryTypeOf(name)), ), ); diff --git a/packages/bugc/src/typechecker/assignable.ts b/packages/bugc/src/typechecker/assignable.ts index 37e670022f..64fef65c55 100644 --- a/packages/bugc/src/typechecker/assignable.ts +++ b/packages/bugc/src/typechecker/assignable.ts @@ -22,6 +22,16 @@ export function isAssignable(target: Type, value: Type): boolean { return true; } + // A string in calldata copies to a string in memory or storage + if ( + Type.Elementary.String.isCalldata(value) && + Type.isElementary(target) && + Type.Elementary.isString(target) && + target.location === undefined + ) { + return true; + } + // Numeric types can be implicitly converted (with range checks) if ( Type.isElementary(target) && diff --git a/packages/bugc/src/typechecker/declarations.ts b/packages/bugc/src/typechecker/declarations.ts index 447e67e921..3419a286b1 100644 --- a/packages/bugc/src/typechecker/declarations.ts +++ b/packages/bugc/src/typechecker/declarations.ts @@ -118,10 +118,13 @@ function buildFunctionSignature( return Type.function_(parameterTypes, returnType, decl.name); } -/** The `bytes calldata` in a type node, if any */ -export function calldataTypeNode(typeNode: Ast.Type): Ast.Type | undefined { +/** The `bytes calldata` or `string calldata` in a type node, if any */ +export function calldataTypeNode( + typeNode: Ast.Type, +): Ast.Type.Elementary.Bytes | Ast.Type.Elementary.String | undefined { if (Ast.Type.isElementary(typeNode)) { - return Ast.Type.Elementary.isBytes(typeNode) && + return (Ast.Type.Elementary.isBytes(typeNode) || + Ast.Type.Elementary.isString(typeNode)) && typeNode.location === "calldata" ? typeNode : undefined; @@ -138,12 +141,15 @@ export function calldataTypeNode(typeNode: Ast.Type): Ast.Type | undefined { } /** - * The error for `bytes calldata` where it cannot be: it is only the - * whole type of a `let` or of a cast + * 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 */ -export function calldataTypeError(typeNode: Ast.Type): TypeError { +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( - "`bytes calldata` can only be the type of a `let` or a cast", + `\`${name} calldata\` can only be the type of a \`let\` or a cast`, typeNode.loc || undefined, undefined, undefined, @@ -151,7 +157,7 @@ export function calldataTypeError(typeNode: Ast.Type): TypeError { ); } -/** Throw unless a type node has no `bytes calldata` */ +/** Throw unless a type node has no `bytes calldata` or `string calldata` */ function checkNoCalldata(typeNode: Ast.Type): void { const found = calldataTypeNode(typeNode); if (found) throw calldataTypeError(found); @@ -163,6 +169,12 @@ export const dynamicBytes = (typeNode: Ast.Type.Elementary.Bytes): Type => ? Type.Elementary.calldataBytes() : Type.Elementary.bytes(); +/** `string`, or `string calldata` */ +export const string = (typeNode: Ast.Type.Elementary.String): Type => + typeNode.location === "calldata" + ? Type.Elementary.calldataString() + : Type.Elementary.string(); + /** * Resolves an AST type node to a Type object and records bindings */ @@ -229,7 +241,7 @@ export function resolveTypeWithBindings( return { type: Type.Elementary.bool(), bindings }; } if (Ast.Type.Elementary.isString(typeNode)) { - return { type: Type.Elementary.string(), bindings }; + return { type: string(typeNode), bindings }; } return { type: Type.failure(`Unknown elementary type: ${typeNode.kind}`), @@ -352,7 +364,7 @@ export function resolveType( return Type.Elementary.bool(); } if (Ast.Type.Elementary.isString(typeNode)) { - return Type.Elementary.string(); + return string(typeNode); } return Type.failure(`Unknown elementary type: ${typeNode.kind}`); } diff --git a/packages/bugc/src/typechecker/expressions.ts b/packages/bugc/src/typechecker/expressions.ts index 5ce19aed73..55063697da 100644 --- a/packages/bugc/src/typechecker/expressions.ts +++ b/packages/bugc/src/typechecker/expressions.ts @@ -806,22 +806,23 @@ export const expressionChecker: Pick, "expression"> = { return { symbols, nodeTypes, bindings, errors }; } - // `bytes calldata` is only the whole target type, and only bytes - // in calldata cast to it + // `bytes calldata` and `string calldata` are only the whole + // target type, and only bytes or a string in calldata cast to them const calldata = calldataTypeNode(node.targetType); if (calldata && calldata !== node.targetType) { errors.push(calldataTypeError(calldata)); return { symbols, nodeTypes, bindings, errors }; } if ( - Type.Elementary.Bytes.isCalldata(targetTypeResult.type) && - !Type.Elementary.Bytes.isCalldata(exprResult.type) + Type.Elementary.isCalldata(targetTypeResult.type) && + !Type.Elementary.isCalldata(exprResult.type) ) { + const target = Type.format(targetTypeResult.type); errors.push( new TypeError( - `Cannot cast from ${Type.format(exprResult.type)} to bytes calldata`, + `Cannot cast from ${Type.format(exprResult.type)} to ${target}`, node.loc || undefined, - "bytes calldata", + target, Type.format(exprResult.type), ErrorCode.INVALID_TYPE_CAST, ), @@ -1145,6 +1146,11 @@ function isValidCast(fromType: Type, toType: Type): boolean { return true; } + // Allow casting between strings (a string in calldata to memory) + if (Type.Elementary.isString(fromType) && Type.Elementary.isString(toType)) { + return true; + } + // Allow casting from string to bytes (for slicing without UTF-8 concerns) if (Type.Elementary.isString(fromType) && Type.Elementary.isBytes(toType)) { return true; diff --git a/packages/bugc/src/typechecker/slice.test.ts b/packages/bugc/src/typechecker/slice.test.ts index ebd44e53fb..745f9bb842 100644 --- a/packages/bugc/src/typechecker/slice.test.ts +++ b/packages/bugc/src/typechecker/slice.test.ts @@ -131,6 +131,84 @@ describe("Slice type checking", () => { ]); }); + /** The messages of a program's type errors */ + const typeErrors = (source: string) => { + const result = parse(source); + if (!result.success) throw new Error("Parse failed"); + const typeResult = checkProgram(result.value); + expect(typeResult.success).toBe(false); + return (typeResult.messages[Severity.Error] ?? []).map((m) => m.message); + }; + + test("types a string in calldata", () => { + expect( + letTypes(` + name Test; + storage { [0] motd: string; } + code { + let a: string calldata = msg.data[4:36] as string calldata; + let b = a; + let c = a as string; + let d: string = a; + let e = a as bytes calldata; + let f = e as string calldata; + let g = a as bytes; + let n = a.length; + motd = a; + } + `), + ).toEqual({ + a: "string calldata", + b: "string calldata", + c: "string", + d: "string", + e: "bytes calldata", + f: "string calldata", + g: "bytes", + n: "uint256", + }); + }); + + 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"; + expect( + typeErrors(` + name Test; + define { + function f(s: string calldata) -> uint256 { return s.length; }; + } + code {} + `), + ).toEqual([placement]); + + expect( + typeErrors(` + name Test; + storage { [0] raw: bytes; } + code { + let a: string calldata = msg.data[4:36]; + let b: string calldata = msg.data[4:36] as string calldata; + let m = "hello"; + let c: string calldata = m; + let d = m as string calldata; + let e: bytes calldata = b; + raw = b; + let x = b[0]; + let y = b[0:1]; + } + `), + ).toEqual([ + "Type mismatch: expected string calldata, got bytes calldata", + "Type mismatch: expected string calldata, got string", + "Cannot cast from string to string calldata", + "Type mismatch: expected bytes calldata, got string calldata", + "Type mismatch: expected bytes, got string calldata", + "Cannot index string calldata", + "Cannot slice string calldata - only bytes types can be sliced", + ]); + }); + test("rejects slice of non-bytes type", () => { const result = parse(` name Test; diff --git a/packages/bugc/src/typechecker/type-nodes.ts b/packages/bugc/src/typechecker/type-nodes.ts index 27015b75f9..e5e65ce84e 100644 --- a/packages/bugc/src/typechecker/type-nodes.ts +++ b/packages/bugc/src/typechecker/type-nodes.ts @@ -3,7 +3,7 @@ import { Type } from "#types"; import type { Visitor } from "#ast"; import type { Context, Report } from "./context.js"; import { Error as TypeError, ErrorCode, ErrorMessages } from "./errors.js"; -import { dynamicBytes } from "./declarations.js"; +import { dynamicBytes, string } from "./declarations.js"; /** * Type checker for type AST nodes. @@ -53,7 +53,7 @@ export const typeNodeChecker: Pick, "type"> = { } else if (Ast.Type.Elementary.isBool(node)) { type = Type.Elementary.bool(); } else if (Ast.Type.Elementary.isString(node)) { - type = Type.Elementary.string(); + type = string(node); } else { type = Type.failure(`Unknown elementary type: ${node.kind}`); } diff --git a/packages/bugc/src/types/spec.ts b/packages/bugc/src/types/spec.ts index 81d4539cd6..4822e87b16 100644 --- a/packages/bugc/src/types/spec.ts +++ b/packages/bugc/src/types/spec.ts @@ -249,23 +249,56 @@ export namespace Type { }${type.location === "calldata" ? " calldata" : ""}`; } + /** + * A string: in memory, or, with `location: "calldata"`, a slice of + * the calldata, as bytes in calldata are + */ export interface String { kind: "string"; + location?: "calldata"; } export const string = (): Type.Elementary.String => ({ kind: "string", }); + /** A string in calldata */ + export const calldataString = (): Type.Elementary.String => ({ + kind: "string", + location: "calldata", + }); + export namespace String { + export const isCalldata = (type: Type): boolean => + Type.isElementary(type) && + Type.Elementary.isString(type) && + type.location === "calldata"; + export const equals = ( - _a: Type.Elementary.String, - _b: Type.Elementary.String, - ): boolean => true; + a: Type.Elementary.String, + b: Type.Elementary.String, + ): boolean => a.location === b.location; - export const format = (_type: Type.Elementary.String): string => `string`; + export const format = (type: Type.Elementary.String): string => + `string${type.location === "calldata" ? " calldata" : ""}`; } + /** Dynamic bytes or a string in calldata */ + export const isCalldata = (type: Type): boolean => + Type.Elementary.Bytes.isCalldata(type) || + Type.Elementary.String.isCalldata(type); + + /** + * The type in memory of a copy of `type`: `bytes` or `string` + * without their calldata location; any other type as it is + */ + export const inMemory = (type: Type): Type => + Type.Elementary.Bytes.isCalldata(type) + ? Type.Elementary.bytes() + : Type.Elementary.String.isCalldata(type) + ? Type.Elementary.string() + : type; + const makeIsKind = (kind: K) => (type: Type.Base): type is Type.Base & { kind: K } => 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 c03116f216..ffa19dc20b 100644 --- a/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx +++ b/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx @@ -160,11 +160,27 @@ value, and the argument of `keccak256`. Assigning to a `bytes` or `string` storage variable stores the bytes as Solidity does, through such a copy. +A string in calldata is `string calldata`. It refers to the calldata +as `bytes calldata` does, and a cast converts one to the other: + +```bug +let name: string calldata = text as string calldata; +name.length; // n +let m: string = name; // a copy in memory +motd = name; // stored as Solidity stores a string +(name as bytes calldata)[0]; // to index or slice it +``` + +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. + 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 pointer names that word and then the bytes in calldata. The region `text-data` reads exactly the bytes, even when, as above, the offset -and length are computed while the program runs: +and length are computed while the program runs. A `string calldata` +local has the same pointer, and the type `{ "kind": "string" }`: Date: Fri, 9 Oct 2026 02:13:20 -0400 Subject: [PATCH 2/5] bugc: link the changelog entry to #379 --- packages/bugc/CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index faaeba6361..d1e513d927 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -18,7 +18,7 @@ support. Changes to the specification itself are tracked in the root `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" }`. + 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 @@ -519,3 +519,4 @@ First publication. [#370]: https://github.com/ethdebug/format/pull/370 [#371]: https://github.com/ethdebug/format/pull/371 [#377]: https://github.com/ethdebug/format/pull/377 +[#379]: https://github.com/ethdebug/format/pull/379 From cbf56a80c7ad16e4f8a905b93d23d2f195400714 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Fri, 9 Oct 2026 02:37:51 -0400 Subject: [PATCH 3/5] bugc: copy a pushed calldata value to memory push of bytes or a string in calldata onto an array in storage stored the calldata word (offset and length) instead of the bytes. --- .../bugc/src/evmgen/string-calldata.test.ts | 36 ++++++++++++++++++- .../src/irgen/generate/expressions/call.ts | 6 +++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/bugc/src/evmgen/string-calldata.test.ts b/packages/bugc/src/evmgen/string-calldata.test.ts index 3f079acc93..e86036c821 100644 --- a/packages/bugc/src/evmgen/string-calldata.test.ts +++ b/packages/bugc/src/evmgen/string-calldata.test.ts @@ -2,7 +2,13 @@ import { describe, it, expect } from "vitest"; import { keccak256 } from "ethereum-cryptography/keccak"; import { executeProgram } from "#test/evm/behavioral"; -import { expectEncoded, mappingSlot, text } from "#test/evm/storage"; +import { + expectEncoded, + hash, + mappingSlot, + text, + word as storageWord, +} from "#test/evm/storage"; const levels = [0, 1, 2, 3] as const; @@ -73,3 +79,31 @@ describe.each(levels)("a string in calldata at O%i", (level) => { ); }); }); + +describe.each(levels)("a push from calldata at O%i", (level) => { + for (const [type, memory] of [ + ["string calldata", "string"], + ["bytes calldata", "bytes"], + ]) { + it.each(lengths)(`pushes ${type} of %i bytes`, async (length) => { + const name = text(length); + const result = await executeProgram( + `name Push; +storage { [0] items: array<${memory}>; } +code { + let offset = msg.data[4:36] as bytes32 as uint256; + let n = msg.data[4 + offset:36 + offset] as bytes32 as uint256; + let c: ${type} = msg.data[36 + offset:36 + offset + n] as ${type}; + items.push(c); + items.push(c); +}`, + { calldata: setName(name), optimizationLevel: level }, + ); + expect(result.callSuccess).toBe(true); + expect(await result.getStorage(0n)).toBe(2n); + const base = hash(storageWord(0n)); + await expectEncoded(result, base, name); + await expectEncoded(result, base + 1n, name); + }); + } +}); diff --git a/packages/bugc/src/irgen/generate/expressions/call.ts b/packages/bugc/src/irgen/generate/expressions/call.ts index 83f9886cf5..2a8d59e0dc 100644 --- a/packages/bugc/src/irgen/generate/expressions/call.ts +++ b/packages/bugc/src/irgen/generate/expressions/call.ts @@ -167,9 +167,13 @@ function* buildPush( ) => Process, ): Process { const marker = Ir.Value.constant(0n, Ir.Type.Scalar.uint256); + // A pushed value's bytes are in memory const value = expr.arguments.length > 0 - ? yield* buildExpression(expr.arguments[0], { kind: "rvalue" }) + ? yield* emitInMemory( + yield* buildExpression(expr.arguments[0], { kind: "rvalue" }), + expr.arguments[0], + ) : undefined; const chain = yield* findStorageAccessChain(callee.object); From 4d8f513bd56f98467f33e6948ff800a9bbd3ac28 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Fri, 9 Oct 2026 02:37:54 -0400 Subject: [PATCH 4/5] bugc: reject comparing or keying a mapping by calldata ==, != and a mapping key with bytes or a string in calldata used the calldata word (offset and length), not the bytes; they are now type errors. --- packages/bugc/CHANGELOG.md | 6 + packages/bugc/src/typechecker/expressions.ts | 33 +++- packages/bugc/src/typechecker/slice.test.ts | 158 +++++++++++------- .../compiler/case-study-bug.mdx | 3 +- 4 files changed, 132 insertions(+), 68 deletions(-) diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index d1e513d927..1b265637af 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -33,6 +33,9 @@ support. Changes to the specification itself are tracked in the root ### Changed +- `==`, `!=` and a mapping key with bytes or a string in calldata are + now type errors. Before, they compared or hashed the calldata + reference's word (offset and length), not the bytes ([#379]). - A struct member of a struct in storage now takes all the slots its struct needs, as in Solidity, and the member after it starts at the next free slot. Before, it took one slot, so the members after a @@ -46,6 +49,9 @@ support. Changes to the specification itself are tracked in the root ### Fixed +- `push` of bytes in calldata onto an `array` in storage now + stores the bytes. Before, it stored the calldata reference's word + (offset and length) ([#379]). - Reading a `string` or `bytes` from storage as a whole, as in `let m: string = motd;`, now copies it to memory, decoded from its storage encoding. This also corrects an index (`data[3]`), a slice diff --git a/packages/bugc/src/typechecker/expressions.ts b/packages/bugc/src/typechecker/expressions.ts index 55063697da..90709682ed 100644 --- a/packages/bugc/src/typechecker/expressions.ts +++ b/packages/bugc/src/typechecker/expressions.ts @@ -250,7 +250,25 @@ export const expressionChecker: Pick, "expression"> = { case "==": case "!=": - if (!isAssignable(leftType, rightType)) { + // Bytes or a string in calldata is a word of offset and + // length, which compares with no other value's + if ( + Type.Elementary.isCalldata(leftType) || + Type.Elementary.isCalldata(rightType) + ) { + const type = Type.Elementary.isCalldata(leftType) + ? leftType + : rightType; + errors.push( + new TypeError( + `Cannot compare ${Type.format(type)}`, + node.loc || undefined, + undefined, + undefined, + ErrorCode.INVALID_OPERATION, + ), + ); + } else if (!isAssignable(leftType, rightType)) { const error = new TypeError( `Cannot compare ${Type.format(leftType)} with ${Type.format(rightType)}`, node.loc || undefined, @@ -506,7 +524,18 @@ export const expressionChecker: Pick, "expression"> = { } resultType = objectType.element; } else if (Type.isMapping(objectType)) { - if (!isAssignable(objectType.key, indexType)) { + // A key in calldata would hash its word of offset and length + if (Type.Elementary.isCalldata(indexType)) { + errors.push( + new TypeError( + `Cannot use ${Type.format(indexType)} as a mapping key`, + indexExpr.loc || undefined, + undefined, + undefined, + ErrorCode.TYPE_MISMATCH, + ), + ); + } else if (!isAssignable(objectType.key, indexType)) { const error = new TypeError( `Invalid mapping key: expected ${Type.format(objectType.key)}, got ${Type.format(indexType)}`, indexExpr.loc || undefined, diff --git a/packages/bugc/src/typechecker/slice.test.ts b/packages/bugc/src/typechecker/slice.test.ts index 745f9bb842..28f65734bf 100644 --- a/packages/bugc/src/typechecker/slice.test.ts +++ b/packages/bugc/src/typechecker/slice.test.ts @@ -9,6 +9,32 @@ import { checkProgram } from "./checker.js"; import "#test/matchers"; +/** The messages of a program's type errors */ +const typeErrors = (source: string) => { + const result = parse(source); + if (!result.success) throw new Error("Parse failed"); + const typeResult = checkProgram(result.value); + expect(typeResult.success).toBe(false); + return (typeResult.messages[Severity.Error] ?? []).map((m) => m.message); +}; + +/** The type of each `let` in a program's `code` block, by name */ +function letTypes(source: string): Record { + const result = parse(source); + if (!result.success) throw new Error("Parse failed"); + const typeResult = checkProgram(result.value); + if (!typeResult.success) throw new Error("Type check failed"); + const { types } = typeResult.value; + const lets: Record = {}; + for (const item of result.value.body?.items ?? []) { + if (Ast.isStatement(item) && Ast.Statement.isDeclare(item)) { + const decl = item.declaration; + lets[decl.name] = Type.format(types.get(decl.id)!); + } + } + return lets; +} + describe("Slice type checking", () => { test("validates slice of msg.data", () => { const result = parse(` @@ -42,23 +68,6 @@ describe("Slice type checking", () => { } }); - /** The type of each `let` in a program's `code` block, by name */ - function letTypes(source: string): Record { - const result = parse(source); - if (!result.success) throw new Error("Parse failed"); - const typeResult = checkProgram(result.value); - if (!typeResult.success) throw new Error("Type check failed"); - const { types } = typeResult.value; - const lets: Record = {}; - for (const item of result.value.body?.items ?? []) { - if (Ast.isStatement(item) && Ast.Statement.isDeclare(item)) { - const decl = item.declaration; - lets[decl.name] = Type.format(types.get(decl.id)!); - } - } - return lets; - } - test("keeps a slice of calldata in calldata, unless typed bytes", () => { expect( letTypes(` @@ -131,15 +140,53 @@ describe("Slice type checking", () => { ]); }); - /** The messages of a program's type errors */ - const typeErrors = (source: string) => { - const result = parse(source); + test("rejects slice of non-bytes type", () => { + const result = parse(` + name Test; + storage { + [0] numbers: array; + } + code { + let slice = numbers[0:4]; + } + `); + + expect(result.success).toBe(true); if (!result.success) throw new Error("Parse failed"); + const typeResult = checkProgram(result.value); expect(typeResult.success).toBe(false); - return (typeResult.messages[Severity.Error] ?? []).map((m) => m.message); - }; + expect(typeResult).toHaveMessage({ + severity: Severity.Error, + message: "Cannot slice", + }); + }); + + test("validates slice indices are numeric", () => { + const result = parse(` + name Test; + code { + let slice = msg.data["start":"end"]; + } + `); + + expect(result.success).toBe(true); + if (!result.success) throw new Error("Parse failed"); + const typeResult = checkProgram(result.value); + expect(typeResult.success).toBe(false); + expect(typeResult).toHaveMessage({ + severity: Severity.Error, + message: "Slice start index must be numeric", + }); + expect(typeResult).toHaveMessage({ + severity: Severity.Error, + message: "Slice end index must be numeric", + }); + }); +}); + +describe("String calldata type checking", () => { test("types a string in calldata", () => { expect( letTypes(` @@ -209,48 +256,29 @@ describe("Slice type checking", () => { ]); }); - test("rejects slice of non-bytes type", () => { - const result = parse(` - name Test; - storage { - [0] numbers: array; - } - code { - let slice = numbers[0:4]; - } - `); - - expect(result.success).toBe(true); - if (!result.success) throw new Error("Parse failed"); - - const typeResult = checkProgram(result.value); - expect(typeResult.success).toBe(false); - expect(typeResult).toHaveMessage({ - severity: Severity.Error, - message: "Cannot slice", - }); - }); - - test("validates slice indices are numeric", () => { - const result = parse(` - name Test; - code { - let slice = msg.data["start":"end"]; - } - `); - - expect(result.success).toBe(true); - if (!result.success) throw new Error("Parse failed"); - - const typeResult = checkProgram(result.value); - expect(typeResult.success).toBe(false); - expect(typeResult).toHaveMessage({ - severity: Severity.Error, - message: "Slice start index must be numeric", - }); - expect(typeResult).toHaveMessage({ - severity: Severity.Error, - message: "Slice end index must be numeric", - }); + test("rejects comparing or keying a mapping by calldata", () => { + for (const type of ["string calldata", "bytes calldata"]) { + const memory = type === "string calldata" ? "string" : "bytes"; + expect( + typeErrors(` + name Test; + storage { [0] m: mapping<${memory}, uint256>; [1] r: bool; } + code { + let c: ${type} = msg.data[4:36] as ${type}; + let x: ${memory} = c; + r = x == c; + r = c != x; + m[c] = 1; + let y = m[c]; + m[x] = 2; + } + `), + ).toEqual([ + `Cannot compare ${type}`, + `Cannot compare ${type}`, + `Cannot use ${type} as a mapping key`, + `Cannot use ${type} as a mapping key`, + ]); + } }); }); 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 ffa19dc20b..103e2c7eb5 100644 --- a/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx +++ b/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx @@ -173,7 +173,8 @@ 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. +type of a `let` or of a cast. Bytes or a string in calldata cannot be +compared with `==` or `!=`, or be a mapping key. 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 From 084812e975c561d70b4d3014f40435fdc7e0332c Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Fri, 9 Oct 2026 02:37:59 -0400 Subject: [PATCH 5/5] bugc: skip errors after a misplaced calldata type; rename a helper --- packages/bugc/src/typechecker/blocks.ts | 13 ++++++++----- packages/bugc/src/typechecker/declarations.ts | 6 +++--- packages/bugc/src/typechecker/type-nodes.ts | 4 ++-- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/bugc/src/typechecker/blocks.ts b/packages/bugc/src/typechecker/blocks.ts index 2bb520010e..335ecae8b0 100644 --- a/packages/bugc/src/typechecker/blocks.ts +++ b/packages/bugc/src/typechecker/blocks.ts @@ -380,11 +380,11 @@ export const blockChecker: Pick< // Determine the variable's type let type: Type; if (node.type) { - // `bytes calldata` is only the whole type + // `bytes calldata` and `string calldata` are only the whole + // type const calldata = calldataTypeNode(node.type); - if (calldata && calldata !== node.type) { - errors.push(calldataTypeError(calldata)); - } + const misplaced = calldata !== undefined && calldata !== node.type; + if (misplaced) errors.push(calldataTypeError(calldata)); // If a type is explicitly declared, use it and record bindings const typeResult = resolveTypeWithBindings( @@ -392,7 +392,10 @@ export const blockChecker: Pick< context.structs, bindings, ); - type = typeResult.type; + // (a misplaced calldata type fails, with no further errors) + type = misplaced + ? Type.failure("misplaced calldata type") + : typeResult.type; bindings = typeResult.bindings; // Check that the initializer is compatible with the declared type diff --git a/packages/bugc/src/typechecker/declarations.ts b/packages/bugc/src/typechecker/declarations.ts index 3419a286b1..3b15425b8e 100644 --- a/packages/bugc/src/typechecker/declarations.ts +++ b/packages/bugc/src/typechecker/declarations.ts @@ -170,7 +170,7 @@ export const dynamicBytes = (typeNode: Ast.Type.Elementary.Bytes): Type => : Type.Elementary.bytes(); /** `string`, or `string calldata` */ -export const string = (typeNode: Ast.Type.Elementary.String): Type => +export const elementaryString = (typeNode: Ast.Type.Elementary.String): Type => typeNode.location === "calldata" ? Type.Elementary.calldataString() : Type.Elementary.string(); @@ -241,7 +241,7 @@ export function resolveTypeWithBindings( return { type: Type.Elementary.bool(), bindings }; } if (Ast.Type.Elementary.isString(typeNode)) { - return { type: string(typeNode), bindings }; + return { type: elementaryString(typeNode), bindings }; } return { type: Type.failure(`Unknown elementary type: ${typeNode.kind}`), @@ -364,7 +364,7 @@ export function resolveType( return Type.Elementary.bool(); } if (Ast.Type.Elementary.isString(typeNode)) { - return string(typeNode); + return elementaryString(typeNode); } return Type.failure(`Unknown elementary type: ${typeNode.kind}`); } diff --git a/packages/bugc/src/typechecker/type-nodes.ts b/packages/bugc/src/typechecker/type-nodes.ts index e5e65ce84e..a48f942c34 100644 --- a/packages/bugc/src/typechecker/type-nodes.ts +++ b/packages/bugc/src/typechecker/type-nodes.ts @@ -3,7 +3,7 @@ import { Type } from "#types"; import type { Visitor } from "#ast"; import type { Context, Report } from "./context.js"; import { Error as TypeError, ErrorCode, ErrorMessages } from "./errors.js"; -import { dynamicBytes, string } from "./declarations.js"; +import { dynamicBytes, elementaryString } from "./declarations.js"; /** * Type checker for type AST nodes. @@ -53,7 +53,7 @@ export const typeNodeChecker: Pick, "type"> = { } else if (Ast.Type.Elementary.isBool(node)) { type = Type.Elementary.bool(); } else if (Ast.Type.Elementary.isString(node)) { - type = string(node); + type = elementaryString(node); } else { type = Type.failure(`Unknown elementary type: ${node.kind}`); }