From 66a22f7252fb6303cd1b216b1453c59fcc113820 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 29 May 2025 15:41:48 +0100 Subject: [PATCH 01/39] Draft function call context schemas --- schemas/program/context.schema.yaml | 12 + schemas/program/context/invoke.schema.yaml | 250 +++++++++++++++++++++ schemas/program/context/return.schema.yaml | 69 ++++++ schemas/program/context/revert.schema.yaml | 58 +++++ 4 files changed, 389 insertions(+) create mode 100644 schemas/program/context/invoke.schema.yaml create mode 100644 schemas/program/context/return.schema.yaml create mode 100644 schemas/program/context/revert.schema.yaml diff --git a/schemas/program/context.schema.yaml b/schemas/program/context.schema.yaml index ba5a4ee87..3361594b9 100644 --- a/schemas/program/context.schema.yaml +++ b/schemas/program/context.schema.yaml @@ -70,6 +70,18 @@ allOf: intermediary representation) to associate a context with a particular compiler step. $ref: "schema:ethdebug/format/program/context/frame" + - if: + required: ["invoke"] + then: + $ref: "schema:ethdebug/format/program/context/invoke" + - if: + required: ["return"] + then: + $ref: "schema:ethdebug/format/program/context/return" + - if: + required: ["revert"] + then: + $ref: "schema:ethdebug/format/program/context/revert" unevaluatedProperties: false diff --git a/schemas/program/context/invoke.schema.yaml b/schemas/program/context/invoke.schema.yaml new file mode 100644 index 000000000..820c55f98 --- /dev/null +++ b/schemas/program/context/invoke.schema.yaml @@ -0,0 +1,250 @@ +$schema: "https://json-schema.org/draft/2020-12/schema" +$id: "schema:ethdebug/format/program/context/invoke" + +title: ethdebug/format/program/context/invoke +description: | + Schema for representing function invocation context at a specific point in + program execution. + + This context captures information about function calls, including both + internal function calls (via JUMP) and external contract calls (via CALL, + DELEGATECALL, STATICCALL, etc.). The schema distinguishes between these + different invocation types through the use of `internal` and `external` + boolean properties. + +type: object +properties: + invoke: + type: object + title: Function invocation + description: | + Represents a function invocation, either internal (via JUMP) or external + (via CALL opcodes). The schema enforces that exactly one of `internal` + or `external` must be true. + + For internal calls, only `target` and `arguments` are valid. + For external calls, `gas`, `value`, `input`, `salt`, `delegate`, + `static`, `create`, and `create2` may be used as appropriate. + + properties: + target: + type: object + title: Invocation target + description: | + Pointer to the target of the invocation. For internal calls, this + typically points to a code location. For external calls, this points + to the address being called. + properties: + pointer: + $ref: "schema:ethdebug/format/pointer" + required: + - pointer + additionalProperties: false + + internal: + type: boolean + description: | + Indicates this is an internal function call (JUMP/JUMPI). + + external: + type: boolean + description: | + Indicates this is an external contract call (CALL/DELEGATECALL/etc). + + arguments: + type: object + title: Function arguments + description: | + Pointer to the arguments for an internal function call. + Only valid for internal calls. + properties: + pointer: + $ref: "schema:ethdebug/format/pointer" + required: + - pointer + additionalProperties: false + + gas: + type: object + title: Gas allocation + description: | + Pointer to the gas allocated for an external call. + Only valid for external calls. + properties: + pointer: + $ref: "schema:ethdebug/format/pointer" + required: + - pointer + additionalProperties: false + + value: + type: object + title: ETH value + description: | + Pointer to the amount of ETH being sent with an external call. + Only valid for external calls. + properties: + pointer: + $ref: "schema:ethdebug/format/pointer" + required: + - pointer + additionalProperties: false + + input: + type: object + title: Call input data + description: | + Pointer to the input data for an external call. + Only valid for external calls. + properties: + pointer: + $ref: "schema:ethdebug/format/pointer" + required: + - pointer + additionalProperties: false + + salt: + type: object + title: CREATE2 salt + description: | + Pointer to the salt value for CREATE2. + Only valid when create2 is true. + properties: + pointer: + $ref: "schema:ethdebug/format/pointer" + required: + - pointer + additionalProperties: false + + delegate: + type: boolean + description: | + Indicates this external call is a DELEGATECALL. + Only valid when external is true. + + static: + type: boolean + description: | + Indicates this external call is a STATICCALL. + Only valid when external is true. + + create: + type: boolean + description: | + Indicates this external call creates a new contract (CREATE). + Only valid when external is true. + + create2: + type: boolean + description: | + Indicates this external call creates a new contract (CREATE2). + Only valid when external is true. + + required: + - target + + oneOf: + - properties: + internal: + const: true + required: + - internal + - properties: + external: + const: true + required: + - external + +required: + - invoke + +additionalProperties: false + +examples: + - invoke: + target: + pointer: + location: code + offset: 291 + length: 2 + internal: true + arguments: + pointer: + location: stack + slot: 0 + length: 3 + + - invoke: + target: + pointer: + location: stack + slot: 0 + external: true + value: + pointer: + location: stack + slot: 1 + gas: + pointer: + location: stack + slot: 2 + input: + pointer: + location: memory + offset: 128 + length: 68 + + - invoke: + target: + pointer: + location: stack + slot: 0 + external: true + delegate: true + gas: + pointer: + location: stack + slot: 1 + input: + pointer: + location: calldata + offset: 4 + length: 68 + + - invoke: + target: + pointer: + location: stack + slot: 0 + external: true + create2: true + salt: + pointer: + location: stack + slot: 1 + value: + pointer: + location: stack + slot: 2 + input: + pointer: + location: memory + offset: 0 + length: 200 + + - invoke: + target: + pointer: + location: stack + slot: 0 + external: true + static: true + gas: + pointer: + location: stack + slot: 1 + input: + pointer: + location: calldata + offset: 4 + length: 36 diff --git a/schemas/program/context/return.schema.yaml b/schemas/program/context/return.schema.yaml new file mode 100644 index 000000000..091893132 --- /dev/null +++ b/schemas/program/context/return.schema.yaml @@ -0,0 +1,69 @@ +$schema: "https://json-schema.org/draft/2020-12/schema" +$id: "schema:ethdebug/format/program/context/return" + +title: ethdebug/format/program/context/return +description: | + Schema for representing function return context at a specific point in + program execution. + + This context captures information about successful function returns, + including the return data and, for external calls, the success status. + +type: object +properties: + return: + type: object + properties: + data: + type: object + title: Return data + description: | + Pointer to the data being returned from the function. + properties: + pointer: + $ref: "schema:ethdebug/format/pointer" + required: + - pointer + + success: + type: object + title: Call success status + description: | + Pointer to the success status of an external call. + Typically points to a boolean value on the stack. + properties: + pointer: + $ref: "schema:ethdebug/format/pointer" + required: + - pointer + +required: + - return + +additionalProperties: false + +examples: + - return: + data: + pointer: + location: memory + offset: 0x40 + length: 0x20 + + - return: + data: + pointer: + location: memory + offset: 0x40 + length: 0x20 + success: + pointer: + location: stack + slot: 0 + + - return: + data: + pointer: + location: returndata + offset: 0 + length: 32 diff --git a/schemas/program/context/revert.schema.yaml b/schemas/program/context/revert.schema.yaml new file mode 100644 index 000000000..032b10c1c --- /dev/null +++ b/schemas/program/context/revert.schema.yaml @@ -0,0 +1,58 @@ +$schema: "https://json-schema.org/draft/2020-12/schema" +$id: "schema:ethdebug/format/program/context/revert" + +title: ethdebug/format/program/context/revert +description: | + Schema for representing function revert context at a specific point in + program execution. + + This context captures information about function reverts, including + revert reason data or panic codes. + +type: object +properties: + revert: + type: object + properties: + reason: + type: object + title: Revert reason + description: | + Pointer to the revert reason data. This typically contains an + ABI-encoded error message or custom error data. + properties: + pointer: + $ref: "schema:ethdebug/format/pointer" + required: + - pointer + + panic: + type: integer + title: Panic code + description: | + Numeric panic code for built-in assertion failures. + Languages may define their own panic code conventions + (e.g., Solidity uses codes like 0x11 for arithmetic overflow). + +required: + - revert + +additionalProperties: false + +examples: + - revert: + reason: + pointer: + location: memory + offset: 0x40 + length: 0x60 + + - revert: + panic: 0x11 + + - revert: + reason: + pointer: + location: returndata + offset: 0 + length: 100 From fc1503e9506e0f2bbad16e0a2286f8bdaffe2d5a Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Sat, 31 May 2025 14:19:56 +0100 Subject: [PATCH 02/39] Organize schema a bit --- schemas/program/context/invoke.schema.yaml | 74 +++++++++++----------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/schemas/program/context/invoke.schema.yaml b/schemas/program/context/invoke.schema.yaml index 820c55f98..70c703d7c 100644 --- a/schemas/program/context/invoke.schema.yaml +++ b/schemas/program/context/invoke.schema.yaml @@ -33,7 +33,7 @@ properties: description: | Pointer to the target of the invocation. For internal calls, this typically points to a code location. For external calls, this points - to the address being called. + to the address and/or selector being called. properties: pointer: $ref: "schema:ethdebug/format/pointer" @@ -41,15 +41,26 @@ properties: - pointer additionalProperties: false + oneOf: + - $ref: "#/$defs/InternalFunctionInvocation" + - $ref: "#/$defs/ExternalFunctionInvocation" + + required: + - target + +required: + - invoke + +additionalProperties: false + +$defs: + InternalFunctionInvocation: + type: object + properties: internal: - type: boolean description: | Indicates this is an internal function call (JUMP/JUMPI). - - external: - type: boolean - description: | - Indicates this is an external contract call (CALL/DELEGATECALL/etc). + const: true arguments: type: object @@ -63,13 +74,21 @@ properties: required: - pointer additionalProperties: false + required: [internal] + + ExternalFunctionInvocation: + type: object + properties: + external: + description: | + Indicates this is an external contract call (CALL/DELEGATECALL/etc). + const: true gas: type: object title: Gas allocation description: | - Pointer to the gas allocated for an external call. - Only valid for external calls. + Pointer to the gas allocated for an external call properties: pointer: $ref: "schema:ethdebug/format/pointer" @@ -82,7 +101,6 @@ properties: title: ETH value description: | Pointer to the amount of ETH being sent with an external call. - Only valid for external calls. properties: pointer: $ref: "schema:ethdebug/format/pointer" @@ -90,12 +108,12 @@ properties: - pointer additionalProperties: false - input: + salt: type: object - title: Call input data + title: CREATE2 salt description: | - Pointer to the input data for an external call. - Only valid for external calls. + Pointer to the salt value for CREATE2. + Only valid when create2 is true. properties: pointer: $ref: "schema:ethdebug/format/pointer" @@ -103,12 +121,12 @@ properties: - pointer additionalProperties: false - salt: + input: type: object - title: CREATE2 salt + title: Call input data description: | - Pointer to the salt value for CREATE2. - Only valid when create2 is true. + Pointer to the input data for an external call. + Only valid for external calls. properties: pointer: $ref: "schema:ethdebug/format/pointer" @@ -140,25 +158,7 @@ properties: Indicates this external call creates a new contract (CREATE2). Only valid when external is true. - required: - - target - - oneOf: - - properties: - internal: - const: true - required: - - internal - - properties: - external: - const: true - required: - - external - -required: - - invoke - -additionalProperties: false + required: [external] examples: - invoke: From 89cb2b36b61e4af8054b42f6746b47aa5f8facfc Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Sat, 31 May 2025 15:00:35 +0100 Subject: [PATCH 03/39] Disable unevaluated properties --- schemas/program/context/invoke.schema.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/schemas/program/context/invoke.schema.yaml b/schemas/program/context/invoke.schema.yaml index 70c703d7c..31d8060c0 100644 --- a/schemas/program/context/invoke.schema.yaml +++ b/schemas/program/context/invoke.schema.yaml @@ -45,6 +45,8 @@ properties: - $ref: "#/$defs/InternalFunctionInvocation" - $ref: "#/$defs/ExternalFunctionInvocation" + unevaluatedProperties: false + required: - target From 241fc7f22462cce0a2bad0d28b0c6e76b4d262a3 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Sat, 31 May 2025 15:00:58 +0100 Subject: [PATCH 04/39] Allow additionalProperties at top-level --- schemas/program/context/invoke.schema.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/schemas/program/context/invoke.schema.yaml b/schemas/program/context/invoke.schema.yaml index 31d8060c0..e0ca257c4 100644 --- a/schemas/program/context/invoke.schema.yaml +++ b/schemas/program/context/invoke.schema.yaml @@ -53,8 +53,6 @@ properties: required: - invoke -additionalProperties: false - $defs: InternalFunctionInvocation: type: object From 16141b9b8db3239a28f44ea1ad041f19633ba60a Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Tue, 3 Mar 2026 20:30:35 -0500 Subject: [PATCH 05/39] Add function identity fields to invoke/return/revert contexts Introduce a shared context/function schema defining identifier, declaration, and type properties (mirroring Variable's pattern) and compose it into invoke, return, and revert via allOf. Switch return and revert from additionalProperties to unevaluatedProperties to support allOf composition. --- packages/format/src/schemas/examples.test.ts | 1 + packages/web/spec/program/context/invoke.mdx | 38 ++++ packages/web/spec/program/context/return.mdx | 11 + packages/web/spec/program/context/revert.mdx | 11 + packages/web/src/schemas.ts | 25 ++- .../components/UnnecessaryComposition.tsx | 1 + schemas/program/context.schema.yaml | 9 + schemas/program/context/function.schema.yaml | 29 +++ schemas/program/context/invoke.schema.yaml | 211 ++++++++++-------- schemas/program/context/return.schema.yaml | 31 ++- schemas/program/context/revert.schema.yaml | 15 +- 11 files changed, 273 insertions(+), 109 deletions(-) create mode 100644 packages/web/spec/program/context/invoke.mdx create mode 100644 packages/web/spec/program/context/return.mdx create mode 100644 packages/web/spec/program/context/revert.mdx create mode 100644 schemas/program/context/function.schema.yaml diff --git a/packages/format/src/schemas/examples.test.ts b/packages/format/src/schemas/examples.test.ts index 0b77fbd01..7f4da0d56 100644 --- a/packages/format/src/schemas/examples.test.ts +++ b/packages/format/src/schemas/examples.test.ts @@ -13,6 +13,7 @@ const idsOfSchemasAllowedToOmitExamples = new Set([ "schema:ethdebug/format/type/elementary", "schema:ethdebug/format/pointer/region", "schema:ethdebug/format/pointer/collection", + "schema:ethdebug/format/program/context/function", ]); describe("Examples", () => { diff --git a/packages/web/spec/program/context/invoke.mdx b/packages/web/spec/program/context/invoke.mdx new file mode 100644 index 000000000..f67e610fb --- /dev/null +++ b/packages/web/spec/program/context/invoke.mdx @@ -0,0 +1,38 @@ +--- +sidebar_position: 8 +--- + +import SchemaViewer from "@site/src/components/SchemaViewer"; + +# Invocation contexts + + + +## Internal function invocation + +An internal function invocation represents a call within the same +contract via JUMP/JUMPI. The target points to a code location and +arguments are passed on the stack. + + + +## External function invocation + +An external function invocation represents a call to another contract +via CALL, DELEGATECALL, STATICCALL, CREATE, or CREATE2. The type of +call may be indicated by setting exactly one of `delegate`, `static`, +`create`, or `create2` to `true`. If none of these flags is present, +the invocation represents a regular CALL. + +For CREATE and CREATE2 operations, the `target` field is forbidden +since the creation bytecode is specified via `input` instead. + + diff --git a/packages/web/spec/program/context/return.mdx b/packages/web/spec/program/context/return.mdx new file mode 100644 index 000000000..80601939b --- /dev/null +++ b/packages/web/spec/program/context/return.mdx @@ -0,0 +1,11 @@ +--- +sidebar_position: 9 +--- + +import SchemaViewer from "@site/src/components/SchemaViewer"; + +# Return contexts + + diff --git a/packages/web/spec/program/context/revert.mdx b/packages/web/spec/program/context/revert.mdx new file mode 100644 index 000000000..eb606b07c --- /dev/null +++ b/packages/web/spec/program/context/revert.mdx @@ -0,0 +1,11 @@ +--- +sidebar_position: 10 +--- + +import SchemaViewer from "@site/src/components/SchemaViewer"; + +# Revert contexts + + diff --git a/packages/web/src/schemas.ts b/packages/web/src/schemas.ts index 619c35b04..f25d0e26e 100644 --- a/packages/web/src/schemas.ts +++ b/packages/web/src/schemas.ts @@ -224,13 +224,36 @@ const programSchemaIndex: SchemaIndex = { href: "/spec/program/context", }, - ...["name", "code", "variables", "remark", "pick", "gather", "frame"] + ...[ + "name", + "code", + "variables", + "remark", + "pick", + "gather", + "frame", + "invoke", + "return", + "revert", + ] .map((name) => ({ [`schema:ethdebug/format/program/context/${name}`]: { href: `/spec/program/context/${name}`, }, })) .reduce((a, b) => ({ ...a, ...b }), {}), + + "schema:ethdebug/format/program/context/invoke#/$defs/InternalFunctionInvocation": + { + title: "Internal function invocation schema", + href: "/spec/program/context/invoke#internal-function-invocation", + }, + + "schema:ethdebug/format/program/context/invoke#/$defs/ExternalFunctionInvocation": + { + title: "External function invocation schema", + href: "/spec/program/context/invoke#external-function-invocation", + }, }; const infoSchemaIndex: SchemaIndex = { diff --git a/packages/web/src/theme/JSONSchemaViewer/components/UnnecessaryComposition.tsx b/packages/web/src/theme/JSONSchemaViewer/components/UnnecessaryComposition.tsx index 8640d9a47..d468c6a15 100644 --- a/packages/web/src/theme/JSONSchemaViewer/components/UnnecessaryComposition.tsx +++ b/packages/web/src/theme/JSONSchemaViewer/components/UnnecessaryComposition.tsx @@ -2,6 +2,7 @@ import React from "react"; import Link from "@docusaurus/Link"; import CreateNodes from "@theme/JSONSchemaViewer/components/CreateNodes"; import { SchemaHierarchyComponent } from "@theme-original/JSONSchemaViewer/contexts"; +import { Collapsible } from "@theme/JSONSchemaViewer/components"; import { GenerateFriendlyName, QualifierMessages, diff --git a/schemas/program/context.schema.yaml b/schemas/program/context.schema.yaml index 3361594b9..ef1686928 100644 --- a/schemas/program/context.schema.yaml +++ b/schemas/program/context.schema.yaml @@ -73,14 +73,23 @@ allOf: - if: required: ["invoke"] then: + description: | + Function invocation context, representing an internal function call + (via JUMP) or an external contract call (via CALL opcodes). $ref: "schema:ethdebug/format/program/context/invoke" - if: required: ["return"] then: + description: | + Function return context, representing the data returned from a + function and, for external calls, the success status. $ref: "schema:ethdebug/format/program/context/return" - if: required: ["revert"] then: + description: | + Function revert context, representing revert reason data or a + panic code for built-in assertion failures. $ref: "schema:ethdebug/format/program/context/revert" unevaluatedProperties: false diff --git a/schemas/program/context/function.schema.yaml b/schemas/program/context/function.schema.yaml new file mode 100644 index 000000000..d28e2d6fa --- /dev/null +++ b/schemas/program/context/function.schema.yaml @@ -0,0 +1,29 @@ +$schema: "https://json-schema.org/draft/2020-12/schema" +$id: "schema:ethdebug/format/program/context/function" + +title: ethdebug/format/program/context/function +description: | + Common properties for identifying the function associated with + an invoke, return, or revert context. These properties mirror + the Variable schema's identity fields (identifier, declaration, + type). + +type: object +properties: + identifier: + type: string + description: | + The function's name in the source language. + + declaration: + description: | + Source range where the function is declared. + $ref: "schema:ethdebug/format/materials/source-range" + + type: + description: | + The function's type, specified either as a full + ethdebug/format/type representation or a type reference. + oneOf: + - $ref: "schema:ethdebug/format/type" + - $ref: "schema:ethdebug/format/type/reference" diff --git a/schemas/program/context/invoke.schema.yaml b/schemas/program/context/invoke.schema.yaml index e0ca257c4..f61521594 100644 --- a/schemas/program/context/invoke.schema.yaml +++ b/schemas/program/context/invoke.schema.yaml @@ -3,14 +3,12 @@ $id: "schema:ethdebug/format/program/context/invoke" title: ethdebug/format/program/context/invoke description: | - Schema for representing function invocation context at a specific point in - program execution. + Schema for representing function invocation context at a specific + point in program execution. - This context captures information about function calls, including both - internal function calls (via JUMP) and external contract calls (via CALL, - DELEGATECALL, STATICCALL, etc.). The schema distinguishes between these - different invocation types through the use of `internal` and `external` - boolean properties. + This context captures information about function calls, including + both internal function calls (via JUMP) and external contract calls + (via CALL, DELEGATECALL, STATICCALL, etc.). type: object properties: @@ -18,22 +16,39 @@ properties: type: object title: Function invocation description: | - Represents a function invocation, either internal (via JUMP) or external - (via CALL opcodes). The schema enforces that exactly one of `internal` - or `external` must be true. + Represents a function invocation, either internal (via JUMP) or + external (via CALL opcodes). - For internal calls, only `target` and `arguments` are valid. - For external calls, `gas`, `value`, `input`, `salt`, `delegate`, - `static`, `create`, and `create2` may be used as appropriate. + allOf: + - $ref: "schema:ethdebug/format/program/context/function" + - oneOf: + - required: [internal] + - required: [external] + - if: + required: [internal] + then: + $ref: "#/$defs/InternalFunctionInvocation" + - if: + required: [external] + then: + $ref: "#/$defs/ExternalFunctionInvocation" + unevaluatedProperties: false + +required: + - invoke + +$defs: + InternalFunctionInvocation: + title: Internal function invocation + type: object properties: target: type: object title: Invocation target description: | - Pointer to the target of the invocation. For internal calls, this - typically points to a code location. For external calls, this points - to the address and/or selector being called. + Pointer to the target of the invocation. For internal + calls, this typically points to a code location. properties: pointer: $ref: "schema:ethdebug/format/pointer" @@ -41,22 +56,6 @@ properties: - pointer additionalProperties: false - oneOf: - - $ref: "#/$defs/InternalFunctionInvocation" - - $ref: "#/$defs/ExternalFunctionInvocation" - - unevaluatedProperties: false - - required: - - target - -required: - - invoke - -$defs: - InternalFunctionInvocation: - type: object - properties: internal: description: | Indicates this is an internal function call (JUMP/JUMPI). @@ -67,28 +66,51 @@ $defs: title: Function arguments description: | Pointer to the arguments for an internal function call. - Only valid for internal calls. properties: pointer: $ref: "schema:ethdebug/format/pointer" required: - pointer additionalProperties: false - required: [internal] + required: [internal, target] ExternalFunctionInvocation: + title: External function invocation + description: | + Represents an external contract call. The type of call may be + indicated by setting exactly one of `delegate`, `static`, + `create`, or `create2` to `true`. If none of these flags is + present, the invocation represents a regular CALL. type: object properties: + target: + type: object + title: Invocation target + description: | + Pointer to the target of the invocation. For external + calls, this points to the address and/or selector + being called. + + Not used for contract creation operations + (CREATE/CREATE2), where the creation bytecode is + specified via input instead. + properties: + pointer: + $ref: "schema:ethdebug/format/pointer" + required: + - pointer + additionalProperties: false + external: description: | - Indicates this is an external contract call (CALL/DELEGATECALL/etc). + Indicates this is an external contract call. const: true gas: type: object title: Gas allocation description: | - Pointer to the gas allocated for an external call + Pointer to the gas allocated for the external call. properties: pointer: $ref: "schema:ethdebug/format/pointer" @@ -100,7 +122,7 @@ $defs: type: object title: ETH value description: | - Pointer to the amount of ETH being sent with an external call. + Pointer to the amount of ETH being sent with the call. properties: pointer: $ref: "schema:ethdebug/format/pointer" @@ -113,7 +135,6 @@ $defs: title: CREATE2 salt description: | Pointer to the salt value for CREATE2. - Only valid when create2 is true. properties: pointer: $ref: "schema:ethdebug/format/pointer" @@ -125,8 +146,7 @@ $defs: type: object title: Call input data description: | - Pointer to the input data for an external call. - Only valid for external calls. + Pointer to the input data for the external call. properties: pointer: $ref: "schema:ethdebug/format/pointer" @@ -135,116 +155,123 @@ $defs: additionalProperties: false delegate: - type: boolean description: | Indicates this external call is a DELEGATECALL. - Only valid when external is true. + const: true static: - type: boolean description: | Indicates this external call is a STATICCALL. - Only valid when external is true. + const: true create: - type: boolean description: | - Indicates this external call creates a new contract (CREATE). - Only valid when external is true. + Indicates this external call creates a new contract + (CREATE). + const: true create2: - type: boolean description: | - Indicates this external call creates a new contract (CREATE2). - Only valid when external is true. + Indicates this external call creates a new contract + (CREATE2). + const: true + + allOf: + - if: + anyOf: + - required: [create] + - required: [create2] + then: + properties: + target: false + else: + required: [target] required: [external] examples: - invoke: + identifier: "transfer" + declaration: + source: + id: 1 + range: + offset: 256 + length: 80 + type: + id: 42 target: pointer: location: code - offset: 291 - length: 2 + offset: "0x100" + length: 1 internal: true arguments: pointer: - location: stack - slot: 0 - length: 3 + group: + - name: "arg0" + location: stack + slot: 0 + - name: "arg1" + location: stack + slot: 1 - invoke: target: pointer: location: stack - slot: 0 + slot: 1 external: true - value: + gas: pointer: location: stack - slot: 1 - gas: + slot: 0 + value: pointer: location: stack slot: 2 input: pointer: - location: memory - offset: 128 - length: 68 + group: + - name: "selector" + location: memory + offset: "0x80" + length: 4 + - name: "arguments" + location: memory + offset: "0x84" + length: "0x40" - invoke: target: pointer: location: stack - slot: 0 + slot: 1 external: true delegate: true gas: pointer: location: stack - slot: 1 + slot: 0 input: pointer: - location: calldata - offset: 4 - length: 68 + location: memory + offset: "0x80" + length: "0x24" - invoke: - target: - pointer: - location: stack - slot: 0 external: true create2: true - salt: - pointer: - location: stack - slot: 1 value: - pointer: - location: stack - slot: 2 - input: - pointer: - location: memory - offset: 0 - length: 200 - - - invoke: - target: pointer: location: stack slot: 0 - external: true - static: true - gas: + salt: pointer: location: stack slot: 1 input: pointer: - location: calldata - offset: 4 - length: 36 + location: memory + offset: "0x80" + length: "0x200" diff --git a/schemas/program/context/return.schema.yaml b/schemas/program/context/return.schema.yaml index 091893132..f373216e7 100644 --- a/schemas/program/context/return.schema.yaml +++ b/schemas/program/context/return.schema.yaml @@ -13,6 +13,8 @@ type: object properties: return: type: object + allOf: + - $ref: "schema:ethdebug/format/program/context/function" properties: data: type: object @@ -37,25 +39,35 @@ properties: required: - pointer + required: + - data + + unevaluatedProperties: false + required: - return -additionalProperties: false - examples: - return: + identifier: "transfer" + declaration: + source: + id: 1 + range: + offset: 256 + length: 80 data: pointer: location: memory - offset: 0x40 - length: 0x20 + offset: "0x80" + length: "0x20" - return: data: pointer: - location: memory - offset: 0x40 - length: 0x20 + location: returndata + offset: 0 + length: "0x20" success: pointer: location: stack @@ -64,6 +76,5 @@ examples: - return: data: pointer: - location: returndata - offset: 0 - length: 32 + location: stack + slot: 0 diff --git a/schemas/program/context/revert.schema.yaml b/schemas/program/context/revert.schema.yaml index 032b10c1c..b863b6c0e 100644 --- a/schemas/program/context/revert.schema.yaml +++ b/schemas/program/context/revert.schema.yaml @@ -13,6 +13,8 @@ type: object properties: revert: type: object + allOf: + - $ref: "schema:ethdebug/format/program/context/function" properties: reason: type: object @@ -34,25 +36,26 @@ properties: Languages may define their own panic code conventions (e.g., Solidity uses codes like 0x11 for arithmetic overflow). + unevaluatedProperties: false + required: - revert -additionalProperties: false - examples: - revert: + identifier: "transfer" reason: pointer: location: memory - offset: 0x40 - length: 0x60 + offset: "0x80" + length: "0x64" - revert: - panic: 0x11 + panic: 17 - revert: reason: pointer: location: returndata offset: 0 - length: 100 + length: "0x64" From 56857246cf1484f9902a7a6e1dc1458b6c98f43c Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Tue, 3 Mar 2026 20:54:52 -0500 Subject: [PATCH 06/39] Fix Playground crash on page refresh in dev server Wrap the Monaco-based Playground component in BrowserOnly with React.lazy so it only loads client-side, preventing SSR-related initialization errors that caused [object Object] crashes on refresh. --- packages/web/src/components/SchemaViewer.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/web/src/components/SchemaViewer.tsx b/packages/web/src/components/SchemaViewer.tsx index db803ddef..93dd9627b 100644 --- a/packages/web/src/components/SchemaViewer.tsx +++ b/packages/web/src/components/SchemaViewer.tsx @@ -1,16 +1,18 @@ -import React from "react"; +import React, { Suspense } from "react"; import type { URL } from "url"; import type { JSONSchema } from "json-schema-typed/draft-2020-12"; import JSONSchemaViewer from "@theme/JSONSchemaViewer"; import CodeBlock from "@theme/CodeBlock"; import Tabs from "@theme/Tabs"; import TabItem from "@theme/TabItem"; +import BrowserOnly from "@docusaurus/BrowserOnly"; import { type DescribeSchemaOptions, describeSchema } from "@ethdebug/format"; import { schemaIndex } from "@site/src/schemas"; import { SchemaContext, internalIdKey } from "@site/src/contexts/SchemaContext"; import ReactMarkdown from "react-markdown"; import SchemaListing from "./SchemaListing"; -import Playground from "./Playground"; + +const Playground = React.lazy(() => import("./Playground")); export interface SchemaViewerProps extends DescribeSchemaOptions {} @@ -83,7 +85,13 @@ export default function SchemaViewer(props: SchemaViewerProps): JSX.Element { - + Loading playground...}> + {() => ( + Loading playground...}> + + + )} + ); From fb11c3e97732be6b092951e185e1e488054f2e83 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Wed, 4 Mar 2026 00:23:02 -0500 Subject: [PATCH 07/39] Nest invoke/return/revert under function/ category Move function call lifecycle contexts (invoke, return, revert) into a function/ subcategory and extract shared identity fields (identifier, declaration, type) into a base function schema that each context extends via $ref. --- .../program/context/function/_category_.json | 4 + .../program/context/function/function.mdx | 14 ++ .../spec/program/context/function/invoke.mdx | 44 +++++ .../program/context/{ => function}/return.mdx | 4 +- .../program/context/{ => function}/revert.mdx | 4 +- packages/web/spec/program/context/invoke.mdx | 38 ---- packages/web/src/schemas.ts | 48 ++++-- schemas/program/context.schema.yaml | 6 +- schemas/program/context/function.schema.yaml | 29 +++- .../context/{ => function}/invoke.schema.yaml | 162 ++++++++++-------- .../context/{ => function}/return.schema.yaml | 17 +- .../context/{ => function}/revert.schema.yaml | 24 +-- 12 files changed, 234 insertions(+), 160 deletions(-) create mode 100644 packages/web/spec/program/context/function/_category_.json create mode 100644 packages/web/spec/program/context/function/function.mdx create mode 100644 packages/web/spec/program/context/function/invoke.mdx rename packages/web/spec/program/context/{ => function}/return.mdx (52%) rename packages/web/spec/program/context/{ => function}/revert.mdx (52%) delete mode 100644 packages/web/spec/program/context/invoke.mdx rename schemas/program/context/{ => function}/invoke.schema.yaml (66%) rename schemas/program/context/{ => function}/return.schema.yaml (77%) rename schemas/program/context/{ => function}/revert.schema.yaml (65%) diff --git a/packages/web/spec/program/context/function/_category_.json b/packages/web/spec/program/context/function/_category_.json new file mode 100644 index 000000000..8c013b27b --- /dev/null +++ b/packages/web/spec/program/context/function/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Function contexts", + "link": null +} diff --git a/packages/web/spec/program/context/function/function.mdx b/packages/web/spec/program/context/function/function.mdx new file mode 100644 index 000000000..0b00b3a6d --- /dev/null +++ b/packages/web/spec/program/context/function/function.mdx @@ -0,0 +1,14 @@ +--- +sidebar_position: 0 +--- + +import SchemaViewer from "@site/src/components/SchemaViewer"; + +# Function identity + +Function contexts (invoke, return, revert) share a common set of +identity fields for the function being called. + + diff --git a/packages/web/spec/program/context/function/invoke.mdx b/packages/web/spec/program/context/function/invoke.mdx new file mode 100644 index 000000000..553e88f03 --- /dev/null +++ b/packages/web/spec/program/context/function/invoke.mdx @@ -0,0 +1,44 @@ +--- +sidebar_position: 1 +--- + +import SchemaViewer from "@site/src/components/SchemaViewer"; + +# Invocation contexts + + + +## Internal call + +An internal call represents a function call within the same contract +via JUMP/JUMPI. The target points to a code location and arguments +are passed on the stack. + + + +## External call + +An external call represents a call to another contract via CALL, +DELEGATECALL, or STATICCALL. The type of call may be indicated by +setting `delegate` or `static` to `true`. If neither flag is present, +the invocation represents a regular CALL. + + + +## Contract creation + +A contract creation represents a CREATE or CREATE2 operation. The +presence of `salt` implies CREATE2. + + diff --git a/packages/web/spec/program/context/return.mdx b/packages/web/spec/program/context/function/return.mdx similarity index 52% rename from packages/web/spec/program/context/return.mdx rename to packages/web/spec/program/context/function/return.mdx index 80601939b..44a0f1591 100644 --- a/packages/web/spec/program/context/return.mdx +++ b/packages/web/spec/program/context/function/return.mdx @@ -1,5 +1,5 @@ --- -sidebar_position: 9 +sidebar_position: 2 --- import SchemaViewer from "@site/src/components/SchemaViewer"; @@ -7,5 +7,5 @@ import SchemaViewer from "@site/src/components/SchemaViewer"; # Return contexts diff --git a/packages/web/spec/program/context/revert.mdx b/packages/web/spec/program/context/function/revert.mdx similarity index 52% rename from packages/web/spec/program/context/revert.mdx rename to packages/web/spec/program/context/function/revert.mdx index eb606b07c..4b18b518b 100644 --- a/packages/web/spec/program/context/revert.mdx +++ b/packages/web/spec/program/context/function/revert.mdx @@ -1,5 +1,5 @@ --- -sidebar_position: 10 +sidebar_position: 3 --- import SchemaViewer from "@site/src/components/SchemaViewer"; @@ -7,5 +7,5 @@ import SchemaViewer from "@site/src/components/SchemaViewer"; # Revert contexts diff --git a/packages/web/spec/program/context/invoke.mdx b/packages/web/spec/program/context/invoke.mdx deleted file mode 100644 index f67e610fb..000000000 --- a/packages/web/spec/program/context/invoke.mdx +++ /dev/null @@ -1,38 +0,0 @@ ---- -sidebar_position: 8 ---- - -import SchemaViewer from "@site/src/components/SchemaViewer"; - -# Invocation contexts - - - -## Internal function invocation - -An internal function invocation represents a call within the same -contract via JUMP/JUMPI. The target points to a code location and -arguments are passed on the stack. - - - -## External function invocation - -An external function invocation represents a call to another contract -via CALL, DELEGATECALL, STATICCALL, CREATE, or CREATE2. The type of -call may be indicated by setting exactly one of `delegate`, `static`, -`create`, or `create2` to `true`. If none of these flags is present, -the invocation represents a regular CALL. - -For CREATE and CREATE2 operations, the `target` field is forbidden -since the creation bytecode is specified via `input` instead. - - diff --git a/packages/web/src/schemas.ts b/packages/web/src/schemas.ts index f25d0e26e..84ba8c701 100644 --- a/packages/web/src/schemas.ts +++ b/packages/web/src/schemas.ts @@ -14,6 +14,10 @@ const typeSchemaIndex: SchemaIndex = { title: "Base type wrapper schema", href: "/spec/type/base#base-type-wrapper-schema", }, + "schema:ethdebug/format/type/specifier": { + title: "Type specifier schema", + href: "/spec/type/concepts#type-specifier-schema", + }, "schema:ethdebug/format/type/wrapper": { title: "Type wrapper schema", href: "/spec/type/concepts#type-wrapper-schema", @@ -224,18 +228,7 @@ const programSchemaIndex: SchemaIndex = { href: "/spec/program/context", }, - ...[ - "name", - "code", - "variables", - "remark", - "pick", - "gather", - "frame", - "invoke", - "return", - "revert", - ] + ...["name", "code", "variables", "remark", "pick", "gather", "frame"] .map((name) => ({ [`schema:ethdebug/format/program/context/${name}`]: { href: `/spec/program/context/${name}`, @@ -243,16 +236,35 @@ const programSchemaIndex: SchemaIndex = { })) .reduce((a, b) => ({ ...a, ...b }), {}), - "schema:ethdebug/format/program/context/invoke#/$defs/InternalFunctionInvocation": + "schema:ethdebug/format/program/context/function": { + title: "Function identity schema", + href: "/spec/program/context/function", + }, + + ...["invoke", "return", "revert"] + .map((name) => ({ + [`schema:ethdebug/format/program/context/function/${name}`]: { + href: `/spec/program/context/function/${name}`, + }, + })) + .reduce((a, b) => ({ ...a, ...b }), {}), + + "schema:ethdebug/format/program/context/function/invoke#/$defs/InternalCall": + { + title: "Internal call schema", + href: "/spec/program/context/function/invoke#internal-call", + }, + + "schema:ethdebug/format/program/context/function/invoke#/$defs/ExternalCall": { - title: "Internal function invocation schema", - href: "/spec/program/context/invoke#internal-function-invocation", + title: "External call schema", + href: "/spec/program/context/function/invoke#external-call", }, - "schema:ethdebug/format/program/context/invoke#/$defs/ExternalFunctionInvocation": + "schema:ethdebug/format/program/context/function/invoke#/$defs/ContractCreation": { - title: "External function invocation schema", - href: "/spec/program/context/invoke#external-function-invocation", + title: "Contract creation schema", + href: "/spec/program/context/function/invoke#contract-creation", }, }; diff --git a/schemas/program/context.schema.yaml b/schemas/program/context.schema.yaml index ef1686928..2be1016d4 100644 --- a/schemas/program/context.schema.yaml +++ b/schemas/program/context.schema.yaml @@ -76,21 +76,21 @@ allOf: description: | Function invocation context, representing an internal function call (via JUMP) or an external contract call (via CALL opcodes). - $ref: "schema:ethdebug/format/program/context/invoke" + $ref: "schema:ethdebug/format/program/context/function/invoke" - if: required: ["return"] then: description: | Function return context, representing the data returned from a function and, for external calls, the success status. - $ref: "schema:ethdebug/format/program/context/return" + $ref: "schema:ethdebug/format/program/context/function/return" - if: required: ["revert"] then: description: | Function revert context, representing revert reason data or a panic code for built-in assertion failures. - $ref: "schema:ethdebug/format/program/context/revert" + $ref: "schema:ethdebug/format/program/context/function/revert" unevaluatedProperties: false diff --git a/schemas/program/context/function.schema.yaml b/schemas/program/context/function.schema.yaml index d28e2d6fa..375cde728 100644 --- a/schemas/program/context/function.schema.yaml +++ b/schemas/program/context/function.schema.yaml @@ -3,15 +3,19 @@ $id: "schema:ethdebug/format/program/context/function" title: ethdebug/format/program/context/function description: | - Common properties for identifying the function associated with - an invoke, return, or revert context. These properties mirror - the Variable schema's identity fields (identifier, declaration, - type). + Properties for identifying a function in the source language. + Used by function context schemas (invoke, return, revert) to + associate a compile-time function identity with runtime + execution events. + + All properties are optional so that compilers may provide as + much or as little information as is available. type: object properties: identifier: type: string + minLength: 1 description: | The function's name in the source language. @@ -24,6 +28,17 @@ properties: description: | The function's type, specified either as a full ethdebug/format/type representation or a type reference. - oneOf: - - $ref: "schema:ethdebug/format/type" - - $ref: "schema:ethdebug/format/type/reference" + $ref: "schema:ethdebug/format/type/specifier" + +examples: + - identifier: "transfer" + declaration: + source: + id: 1 + range: + offset: 256 + length: 80 + type: + id: 42 + - identifier: "balanceOf" + - {} diff --git a/schemas/program/context/invoke.schema.yaml b/schemas/program/context/function/invoke.schema.yaml similarity index 66% rename from schemas/program/context/invoke.schema.yaml rename to schemas/program/context/function/invoke.schema.yaml index f61521594..d8e5cbeff 100644 --- a/schemas/program/context/invoke.schema.yaml +++ b/schemas/program/context/function/invoke.schema.yaml @@ -1,14 +1,14 @@ $schema: "https://json-schema.org/draft/2020-12/schema" -$id: "schema:ethdebug/format/program/context/invoke" +$id: "schema:ethdebug/format/program/context/function/invoke" -title: ethdebug/format/program/context/invoke +title: ethdebug/format/program/context/function/invoke description: | - Schema for representing function invocation context at a specific - point in program execution. + Context for a function being invoked. Covers internal calls + (JUMP), external contract calls (CALL, DELEGATECALL, STATICCALL), + and contract creation (CREATE, CREATE2). - This context captures information about function calls, including - both internal function calls (via JUMP) and external contract calls - (via CALL, DELEGATECALL, STATICCALL, etc.). + Extends the function identity schema with variant-specific + fields such as call targets, gas, value, and input data. type: object properties: @@ -16,22 +16,29 @@ properties: type: object title: Function invocation description: | - Represents a function invocation, either internal (via JUMP) or - external (via CALL opcodes). + Represents a function invocation: an internal call (via JUMP), + an external call (via CALL opcodes), or a contract creation + (via CREATE/CREATE2). + + $ref: "schema:ethdebug/format/program/context/function" allOf: - - $ref: "schema:ethdebug/format/program/context/function" - oneOf: - required: [internal] - - required: [external] + - required: [call] + - required: [create] - if: required: [internal] then: - $ref: "#/$defs/InternalFunctionInvocation" + $ref: "#/$defs/InternalCall" + - if: + required: [call] + then: + $ref: "#/$defs/ExternalCall" - if: - required: [external] + required: [create] then: - $ref: "#/$defs/ExternalFunctionInvocation" + $ref: "#/$defs/ContractCreation" unevaluatedProperties: false @@ -39,10 +46,18 @@ required: - invoke $defs: - InternalFunctionInvocation: - title: Internal function invocation + InternalCall: + title: Internal call + description: | + Represents an internal function call within the same contract + (via JUMP/JUMPI). type: object properties: + internal: + description: | + Indicates this is an internal function call (JUMP/JUMPI). + const: true + target: type: object title: Invocation target @@ -56,11 +71,6 @@ $defs: - pointer additionalProperties: false - internal: - description: | - Indicates this is an internal function call (JUMP/JUMPI). - const: true - arguments: type: object title: Function arguments @@ -72,17 +82,23 @@ $defs: required: - pointer additionalProperties: false + required: [internal, target] - ExternalFunctionInvocation: - title: External function invocation + ExternalCall: + title: External call description: | - Represents an external contract call. The type of call may be - indicated by setting exactly one of `delegate`, `static`, - `create`, or `create2` to `true`. If none of these flags is - present, the invocation represents a regular CALL. + Represents an external contract call via CALL, DELEGATECALL, + or STATICCALL. The type of call may be indicated by setting + `delegate` or `static` to `true`. If neither flag is present, + the invocation represents a regular CALL. type: object properties: + call: + description: | + Indicates this is an external contract call. + const: true + target: type: object title: Invocation target @@ -90,10 +106,6 @@ $defs: Pointer to the target of the invocation. For external calls, this points to the address and/or selector being called. - - Not used for contract creation operations - (CREATE/CREATE2), where the creation bytecode is - specified via input instead. properties: pointer: $ref: "schema:ethdebug/format/pointer" @@ -101,11 +113,6 @@ $defs: - pointer additionalProperties: false - external: - description: | - Indicates this is an external contract call. - const: true - gas: type: object title: Gas allocation @@ -130,18 +137,6 @@ $defs: - pointer additionalProperties: false - salt: - type: object - title: CREATE2 salt - description: | - Pointer to the salt value for CREATE2. - properties: - pointer: - $ref: "schema:ethdebug/format/pointer" - required: - - pointer - additionalProperties: false - input: type: object title: Call input data @@ -164,30 +159,60 @@ $defs: Indicates this external call is a STATICCALL. const: true + required: [call, target] + + ContractCreation: + title: Contract creation + description: | + Represents a contract creation operation via CREATE or + CREATE2. The presence of `salt` implies CREATE2. + type: object + properties: create: description: | - Indicates this external call creates a new contract - (CREATE). + Indicates this is a contract creation operation + (CREATE or CREATE2). const: true - create2: + value: + type: object + title: ETH value description: | - Indicates this external call creates a new contract - (CREATE2). - const: true + Pointer to the amount of ETH being sent with the + creation. + properties: + pointer: + $ref: "schema:ethdebug/format/pointer" + required: + - pointer + additionalProperties: false - allOf: - - if: - anyOf: - - required: [create] - - required: [create2] - then: - properties: - target: false - else: - required: [target] + salt: + type: object + title: CREATE2 salt + description: | + Pointer to the salt value for CREATE2. Its presence + implies this is a CREATE2 operation. + properties: + pointer: + $ref: "schema:ethdebug/format/pointer" + required: + - pointer + additionalProperties: false + + input: + type: object + title: Creation bytecode + description: | + Pointer to the creation bytecode for the new contract. + properties: + pointer: + $ref: "schema:ethdebug/format/pointer" + required: + - pointer + additionalProperties: false - required: [external] + required: [create] examples: - invoke: @@ -221,7 +246,7 @@ examples: pointer: location: stack slot: 1 - external: true + call: true gas: pointer: location: stack @@ -247,7 +272,7 @@ examples: pointer: location: stack slot: 1 - external: true + call: true delegate: true gas: pointer: @@ -260,8 +285,7 @@ examples: length: "0x24" - invoke: - external: true - create2: true + create: true value: pointer: location: stack diff --git a/schemas/program/context/return.schema.yaml b/schemas/program/context/function/return.schema.yaml similarity index 77% rename from schemas/program/context/return.schema.yaml rename to schemas/program/context/function/return.schema.yaml index f373216e7..35cf256c6 100644 --- a/schemas/program/context/return.schema.yaml +++ b/schemas/program/context/function/return.schema.yaml @@ -1,20 +1,19 @@ $schema: "https://json-schema.org/draft/2020-12/schema" -$id: "schema:ethdebug/format/program/context/return" +$id: "schema:ethdebug/format/program/context/function/return" -title: ethdebug/format/program/context/return +title: ethdebug/format/program/context/function/return description: | - Schema for representing function return context at a specific point in - program execution. - - This context captures information about successful function returns, - including the return data and, for external calls, the success status. + Context for a function returning successfully. Extends the + function identity schema with the return data pointer and, + for external calls, the success status. type: object properties: return: type: object - allOf: - - $ref: "schema:ethdebug/format/program/context/function" + + $ref: "schema:ethdebug/format/program/context/function" + properties: data: type: object diff --git a/schemas/program/context/revert.schema.yaml b/schemas/program/context/function/revert.schema.yaml similarity index 65% rename from schemas/program/context/revert.schema.yaml rename to schemas/program/context/function/revert.schema.yaml index b863b6c0e..03a7afc6a 100644 --- a/schemas/program/context/revert.schema.yaml +++ b/schemas/program/context/function/revert.schema.yaml @@ -1,27 +1,26 @@ $schema: "https://json-schema.org/draft/2020-12/schema" -$id: "schema:ethdebug/format/program/context/revert" +$id: "schema:ethdebug/format/program/context/function/revert" -title: ethdebug/format/program/context/revert +title: ethdebug/format/program/context/function/revert description: | - Schema for representing function revert context at a specific point in - program execution. - - This context captures information about function reverts, including - revert reason data or panic codes. + Context for a function that reverts. Extends the function + identity schema with optional revert reason data and/or a + numeric panic code. type: object properties: revert: type: object - allOf: - - $ref: "schema:ethdebug/format/program/context/function" + + $ref: "schema:ethdebug/format/program/context/function" + properties: reason: type: object title: Revert reason description: | - Pointer to the revert reason data. This typically contains an - ABI-encoded error message or custom error data. + Pointer to the revert reason data. This typically contains + an ABI-encoded error message or custom error data. properties: pointer: $ref: "schema:ethdebug/format/pointer" @@ -34,7 +33,8 @@ properties: description: | Numeric panic code for built-in assertion failures. Languages may define their own panic code conventions - (e.g., Solidity uses codes like 0x11 for arithmetic overflow). + (e.g., Solidity uses codes like 0x11 for arithmetic + overflow). unevaluatedProperties: false From ebeff7982a1b308801336f53fc0686763c260d42 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Wed, 4 Mar 2026 00:37:38 -0500 Subject: [PATCH 08/39] Rename invoke discriminant fields: internal->jump, call->message Reduces ambiguity: `call` could mean any function call, and `internal` vs `create` doesn't clearly convey the mechanism. The new names (jump/message/create) map directly to the three EVM invocation mechanisms. Also enforces mutual exclusivity of delegate/static on external calls via `not: required: [delegate, static]`. --- .../context/function/invoke.schema.yaml | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/schemas/program/context/function/invoke.schema.yaml b/schemas/program/context/function/invoke.schema.yaml index d8e5cbeff..8bab859d7 100644 --- a/schemas/program/context/function/invoke.schema.yaml +++ b/schemas/program/context/function/invoke.schema.yaml @@ -24,15 +24,15 @@ properties: allOf: - oneOf: - - required: [internal] - - required: [call] + - required: [jump] + - required: [message] - required: [create] - if: - required: [internal] + required: [jump] then: $ref: "#/$defs/InternalCall" - if: - required: [call] + required: [message] then: $ref: "#/$defs/ExternalCall" - if: @@ -53,7 +53,7 @@ $defs: (via JUMP/JUMPI). type: object properties: - internal: + jump: description: | Indicates this is an internal function call (JUMP/JUMPI). const: true @@ -83,7 +83,7 @@ $defs: - pointer additionalProperties: false - required: [internal, target] + required: [jump, target] ExternalCall: title: External call @@ -94,9 +94,10 @@ $defs: the invocation represents a regular CALL. type: object properties: - call: + message: description: | - Indicates this is an external contract call. + Indicates this is an external message call (CALL, + DELEGATECALL, or STATICCALL). const: true target: @@ -159,7 +160,11 @@ $defs: Indicates this external call is a STATICCALL. const: true - required: [call, target] + not: + description: Only one of `delegate` and `static` can be set at a time. + required: [delegate, static] + + required: [message, target] ContractCreation: title: Contract creation @@ -230,7 +235,7 @@ examples: location: code offset: "0x100" length: 1 - internal: true + jump: true arguments: pointer: group: @@ -246,7 +251,7 @@ examples: pointer: location: stack slot: 1 - call: true + message: true gas: pointer: location: stack @@ -272,7 +277,7 @@ examples: pointer: location: stack slot: 1 - call: true + message: true delegate: true gas: pointer: From 50d9c30d0b7068d7482b824068d8140f475888bc Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Wed, 4 Mar 2026 00:40:52 -0500 Subject: [PATCH 09/39] Rewrite function context descriptions Frame descriptions from the perspective of contexts marking instructions rather than "representing" things. A context indicates association with a function lifecycle event; it does not represent the event itself. --- schemas/program/context.schema.yaml | 10 +++--- schemas/program/context/function.schema.yaml | 10 +++--- .../context/function/invoke.schema.yaml | 35 ++++++++++--------- .../context/function/return.schema.yaml | 7 ++-- .../context/function/revert.schema.yaml | 7 ++-- 5 files changed, 36 insertions(+), 33 deletions(-) diff --git a/schemas/program/context.schema.yaml b/schemas/program/context.schema.yaml index 2be1016d4..a57fce654 100644 --- a/schemas/program/context.schema.yaml +++ b/schemas/program/context.schema.yaml @@ -74,22 +74,20 @@ allOf: required: ["invoke"] then: description: | - Function invocation context, representing an internal function call - (via JUMP) or an external contract call (via CALL opcodes). + Indicates association with a function invocation (internal call, + external message call, or contract creation). $ref: "schema:ethdebug/format/program/context/function/invoke" - if: required: ["return"] then: description: | - Function return context, representing the data returned from a - function and, for external calls, the success status. + Indicates association with a successful function return. $ref: "schema:ethdebug/format/program/context/function/return" - if: required: ["revert"] then: description: | - Function revert context, representing revert reason data or a - panic code for built-in assertion failures. + Indicates association with a function revert. $ref: "schema:ethdebug/format/program/context/function/revert" unevaluatedProperties: false diff --git a/schemas/program/context/function.schema.yaml b/schemas/program/context/function.schema.yaml index 375cde728..f16d9b9aa 100644 --- a/schemas/program/context/function.schema.yaml +++ b/schemas/program/context/function.schema.yaml @@ -3,13 +3,13 @@ $id: "schema:ethdebug/format/program/context/function" title: ethdebug/format/program/context/function description: | - Properties for identifying a function in the source language. - Used by function context schemas (invoke, return, revert) to - associate a compile-time function identity with runtime - execution events. + Properties for identifying a source-language function. Function + context schemas (invoke, return, revert) extend this schema so + that each context can optionally indicate which function it + pertains to. All properties are optional so that compilers may provide as - much or as little information as is available. + much or as little detail as is available. type: object properties: diff --git a/schemas/program/context/function/invoke.schema.yaml b/schemas/program/context/function/invoke.schema.yaml index 8bab859d7..1bf401926 100644 --- a/schemas/program/context/function/invoke.schema.yaml +++ b/schemas/program/context/function/invoke.schema.yaml @@ -3,12 +3,14 @@ $id: "schema:ethdebug/format/program/context/function/invoke" title: ethdebug/format/program/context/function/invoke description: | - Context for a function being invoked. Covers internal calls - (JUMP), external contract calls (CALL, DELEGATECALL, STATICCALL), - and contract creation (CREATE, CREATE2). + This context indicates that the marked instruction is + associated with a function invocation. The invocation is one + of three kinds: an internal call via JUMP, an external message + call (CALL / DELEGATECALL / STATICCALL), or a contract + creation (CREATE / CREATE2). - Extends the function identity schema with variant-specific - fields such as call targets, gas, value, and input data. + Extends the function identity schema with kind-specific fields + such as call targets, gas, value, and input data. type: object properties: @@ -16,9 +18,10 @@ properties: type: object title: Function invocation description: | - Represents a function invocation: an internal call (via JUMP), - an external call (via CALL opcodes), or a contract creation - (via CREATE/CREATE2). + Describes the function invocation associated with this + context. Must indicate exactly one invocation kind: `jump` + for an internal call, `message` for an external call, or + `create` for a contract creation. $ref: "schema:ethdebug/format/program/context/function" @@ -49,8 +52,8 @@ $defs: InternalCall: title: Internal call description: | - Represents an internal function call within the same contract - (via JUMP/JUMPI). + An internal function call within the same contract, entered + via JUMP/JUMPI. type: object properties: jump: @@ -88,10 +91,10 @@ $defs: ExternalCall: title: External call description: | - Represents an external contract call via CALL, DELEGATECALL, - or STATICCALL. The type of call may be indicated by setting - `delegate` or `static` to `true`. If neither flag is present, - the invocation represents a regular CALL. + An external message call to another contract via CALL, + DELEGATECALL, or STATICCALL. Set `delegate` or `static` to + `true` to indicate the call variant; if neither is present + the call is a regular CALL. type: object properties: message: @@ -169,8 +172,8 @@ $defs: ContractCreation: title: Contract creation description: | - Represents a contract creation operation via CREATE or - CREATE2. The presence of `salt` implies CREATE2. + A contract creation via CREATE or CREATE2. The presence + of `salt` distinguishes CREATE2 from CREATE. type: object properties: create: diff --git a/schemas/program/context/function/return.schema.yaml b/schemas/program/context/function/return.schema.yaml index 35cf256c6..6885cb1f2 100644 --- a/schemas/program/context/function/return.schema.yaml +++ b/schemas/program/context/function/return.schema.yaml @@ -3,9 +3,10 @@ $id: "schema:ethdebug/format/program/context/function/return" title: ethdebug/format/program/context/function/return description: | - Context for a function returning successfully. Extends the - function identity schema with the return data pointer and, - for external calls, the success status. + This context indicates that the marked instruction is + associated with a successful function return. Extends the + function identity schema with a pointer to the return data + and, for external calls, the success status. type: object properties: diff --git a/schemas/program/context/function/revert.schema.yaml b/schemas/program/context/function/revert.schema.yaml index 03a7afc6a..483805f25 100644 --- a/schemas/program/context/function/revert.schema.yaml +++ b/schemas/program/context/function/revert.schema.yaml @@ -3,9 +3,10 @@ $id: "schema:ethdebug/format/program/context/function/revert" title: ethdebug/format/program/context/function/revert description: | - Context for a function that reverts. Extends the function - identity schema with optional revert reason data and/or a - numeric panic code. + This context indicates that the marked instruction is + associated with a function revert. Extends the function + identity schema with an optional pointer to revert reason + data and/or a numeric panic code. type: object properties: From 565e1165273f5cb91a86e9dd5401552a0f5a5a5c Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Wed, 4 Mar 2026 01:09:40 -0500 Subject: [PATCH 10/39] Rewrite function context examples with realistic scenarios Each example now describes a concrete EVM execution scenario: which instruction the context marks, what the stack and memory layout looks like, and why each pointer points where it does. --- schemas/program/context/function.schema.yaml | 16 +++- .../context/function/invoke.schema.yaml | 92 +++++++++++++++---- .../context/function/return.schema.yaml | 37 ++++++-- .../context/function/revert.schema.yaml | 24 +++++ 4 files changed, 142 insertions(+), 27 deletions(-) diff --git a/schemas/program/context/function.schema.yaml b/schemas/program/context/function.schema.yaml index f16d9b9aa..52da86c23 100644 --- a/schemas/program/context/function.schema.yaml +++ b/schemas/program/context/function.schema.yaml @@ -31,14 +31,22 @@ properties: $ref: "schema:ethdebug/format/type/specifier" examples: + # All three identity fields provided: the compiler knows the + # function name, where it was declared, and its type. - identifier: "transfer" declaration: source: - id: 1 + id: 0 range: - offset: 256 - length: 80 + offset: 128 + length: 95 type: - id: 42 + id: 7 + + # Only the function name is known. - identifier: "balanceOf" + + # No identity information. The compiler knows that a function + # context applies but cannot attribute it to a specific + # function (e.g., an indirect call through a function pointer). - {} diff --git a/schemas/program/context/function/invoke.schema.yaml b/schemas/program/context/function/invoke.schema.yaml index 1bf401926..e1779a733 100644 --- a/schemas/program/context/function/invoke.schema.yaml +++ b/schemas/program/context/function/invoke.schema.yaml @@ -223,38 +223,68 @@ $defs: required: [create] examples: + # ----------------------------------------------------------- + # Internal call: transfer(address, uint256) + # ----------------------------------------------------------- + # This context would mark the JUMP instruction that enters + # the function. Before the jump, the compiler has arranged + # the stack as follows (top first): + # + # slot 0: jump destination (entry PC of `transfer`) + # slot 1: return label + # slot 2: first argument (`to`) + # slot 3: second argument (`amount`) + # + # The `target` pointer reads the jump destination from the + # stack; `arguments` uses a group to name each argument's + # stack position. - invoke: identifier: "transfer" declaration: source: - id: 1 + id: 0 range: - offset: 256 - length: 80 + offset: 128 + length: 95 type: - id: 42 + id: 7 + jump: true target: pointer: - location: code - offset: "0x100" - length: 1 - jump: true + location: stack + slot: 0 arguments: pointer: group: - - name: "arg0" + - name: "to" location: stack - slot: 0 - - name: "arg1" + slot: 2 + - name: "amount" location: stack - slot: 1 + slot: 3 + # ----------------------------------------------------------- + # External CALL: token.balanceOf(account) + # ----------------------------------------------------------- + # This context would mark the CALL instruction. The EVM + # expects the stack to contain (top first): + # + # slot 0: gas to forward + # slot 1: target contract address + # slot 2: value (0 — balanceOf is non-payable) + # + # The ABI-encoded calldata has already been written to + # memory at 0x80: + # + # 0x80..0x83: function selector (4 bytes) + # 0x84..0xa3: abi-encoded `account` (32 bytes) - invoke: + identifier: "balanceOf" + message: true target: pointer: location: stack slot: 1 - message: true gas: pointer: location: stack @@ -273,15 +303,31 @@ examples: - name: "arguments" location: memory offset: "0x84" - length: "0x40" + length: "0x20" + # ----------------------------------------------------------- + # DELEGATECALL: proxy forwarding calldata + # ----------------------------------------------------------- + # This context would mark a DELEGATECALL instruction in a + # proxy contract. The call executes the implementation's + # code within the proxy's storage context. + # + # DELEGATECALL takes no value parameter. Stack layout + # (top first): + # + # slot 0: gas + # slot 1: implementation address + # + # The original calldata has been copied into memory: + # + # 0x80..0xe3: forwarded calldata (100 bytes) - invoke: + message: true + delegate: true target: pointer: location: stack slot: 1 - message: true - delegate: true gas: pointer: location: stack @@ -290,8 +336,20 @@ examples: pointer: location: memory offset: "0x80" - length: "0x24" + length: "0x64" + # ----------------------------------------------------------- + # CREATE2: deploying a child contract + # ----------------------------------------------------------- + # This context would mark the CREATE2 instruction. Stack + # layout (top first): + # + # slot 0: value (ETH to send to the new contract) + # slot 1: salt (for deterministic address derivation) + # + # The init code has been placed in memory: + # + # 0x80..0x027f: creation bytecode (512 bytes) - invoke: create: true value: diff --git a/schemas/program/context/function/return.schema.yaml b/schemas/program/context/function/return.schema.yaml index 6885cb1f2..dd274f67d 100644 --- a/schemas/program/context/function/return.schema.yaml +++ b/schemas/program/context/function/return.schema.yaml @@ -48,20 +48,37 @@ required: - return examples: + # ----------------------------------------------------------- + # Internal return: transfer(address, uint256) returns (bool) + # ----------------------------------------------------------- + # This context would mark the JUMP instruction that returns + # control to the caller. The function has left its return + # value on the stack: + # + # slot 0: return value (`bool success`) - return: identifier: "transfer" declaration: source: - id: 1 + id: 0 range: - offset: 256 - length: 80 + offset: 128 + length: 95 data: pointer: - location: memory - offset: "0x80" - length: "0x20" + location: stack + slot: 0 + # ----------------------------------------------------------- + # External call return: processing result of a CALL + # ----------------------------------------------------------- + # This context would mark an instruction after a CALL that + # completed successfully. The EVM places a success flag on + # the stack, and the callee's return data is accessible via + # the returndata buffer: + # + # stack slot 0: success flag (1 = success) + # returndata 0x00..0x1f: ABI-encoded return value (32 bytes) - return: data: pointer: @@ -73,6 +90,14 @@ examples: location: stack slot: 0 + # ----------------------------------------------------------- + # Minimal return: only the data pointer + # ----------------------------------------------------------- + # When the compiler cannot attribute the return to a named + # function, the context may contain only the return data. + # Here, a single stack value is being returned. + # + # slot 0: return value - return: data: pointer: diff --git a/schemas/program/context/function/revert.schema.yaml b/schemas/program/context/function/revert.schema.yaml index 483805f25..9aecc9026 100644 --- a/schemas/program/context/function/revert.schema.yaml +++ b/schemas/program/context/function/revert.schema.yaml @@ -43,6 +43,15 @@ required: - revert examples: + # ----------------------------------------------------------- + # Revert with reason: require() failure in transfer + # ----------------------------------------------------------- + # This context would mark the REVERT instruction after a + # failed require(). The compiler has written the ABI-encoded + # Error(string) revert reason into memory: + # + # 0x80..0xe3: ABI-encoded Error(string) (100 bytes) + # selector 0x08c379a0 + offset + length + data - revert: identifier: "transfer" reason: @@ -51,9 +60,24 @@ examples: offset: "0x80" length: "0x64" + # ----------------------------------------------------------- + # Panic: arithmetic overflow (code 0x11) + # ----------------------------------------------------------- + # A built-in safety check detected an arithmetic overflow. + # The panic code alone identifies the failure; no pointer to + # revert data is needed since the compiler inserts the check + # itself. - revert: panic: 17 + # ----------------------------------------------------------- + # External call revert: processing a failed CALL + # ----------------------------------------------------------- + # This context would mark an instruction after a CALL that + # reverted. The callee's revert reason is accessible via the + # returndata buffer: + # + # returndata 0x00..0x63: ABI-encoded revert reason - revert: reason: pointer: From ddee0bc5cc11496c90cd4019184092e2cad1caed Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Wed, 4 Mar 2026 01:10:14 -0500 Subject: [PATCH 11/39] Add type specifier schema and use it across the format Introduce schema:ethdebug/format/type/specifier to formalize the "type or type reference" pattern. Update type wrapper, variables, and doc pages to use the new schema instead of inline oneOf/if-then discrimination. Remove the now-unnecessary allow-list entry for the function context schema. --- packages/format/src/schemas/examples.test.ts | 1 - packages/web/spec/type/concepts.mdx | 41 ++++++++++++------- schemas/program/context/variables.schema.yaml | 10 ++--- schemas/type/specifier.schema.yaml | 20 +++++++++ schemas/type/wrapper.schema.yaml | 9 +--- 5 files changed, 51 insertions(+), 30 deletions(-) create mode 100644 schemas/type/specifier.schema.yaml diff --git a/packages/format/src/schemas/examples.test.ts b/packages/format/src/schemas/examples.test.ts index 7f4da0d56..0b77fbd01 100644 --- a/packages/format/src/schemas/examples.test.ts +++ b/packages/format/src/schemas/examples.test.ts @@ -13,7 +13,6 @@ const idsOfSchemasAllowedToOmitExamples = new Set([ "schema:ethdebug/format/type/elementary", "schema:ethdebug/format/pointer/region", "schema:ethdebug/format/pointer/collection", - "schema:ethdebug/format/program/context/function", ]); describe("Examples", () => { diff --git a/packages/web/spec/type/concepts.mdx b/packages/web/spec/type/concepts.mdx index e87a516f4..554baa560 100644 --- a/packages/web/spec/type/concepts.mdx +++ b/packages/web/spec/type/concepts.mdx @@ -158,18 +158,24 @@ possibly includes other fields alongside `"type"`. -## Type wrappers and type references +## Type specifiers, wrappers, and references -This schema defines the concept of a type wrapper and the related concept of a -type reference. +This schema defines three related concepts for working with types +indirectly: **type specifiers**, **type wrappers**, and +**type references**. -Type wrappers serve to encapsulate a type representation alongside other fields -in the same object, and to facilitate discriminating which polymorphic form is -used for a particular complex type. +A **type specifier** is either a complete type representation or a +reference to a known type by ID. Type specifiers appear wherever a +type or reference to a type is needed—as the value of a type +wrapper's `"type"` field, as properties on variable and function +context schemas, etc. -Type wrappers are any object of the form -`{ "type": , ...otherProperties }`, where `` is either a complete -type representation or a reference to another type by ID. +A **type wrapper** is any object of the form +`{ "type": , ...otherProperties }`, where `` +is a type specifier. Type wrappers serve to encapsulate a type +specifier alongside other fields in the same object, and to +facilitate discriminating which polymorphic form is used for a +particular complex type.
Example type wrapper with complete type representation @@ -198,9 +204,17 @@ type representation or a reference to another type by ID.
-Note that **ethdebug/format/type** places no restriction on IDs other than -that they must be either a number or a string. Other components of this format -at-large may impose restrictions, however. +A **type reference** is the simplest form of type specifier: an +object containing only an `"id"` field. Note that +**ethdebug/format/type** places no restriction on IDs other than +that they must be either a number or a string. Other components +of this format at-large may impose restrictions, however. + +### Type specifier schema + + ### Type wrapper schema @@ -208,9 +222,6 @@ at-large may impose restrictions, however. ### Type reference schema -A type reference is an object containing the single `"id"` field. This field -must be a string or a number. - ## Sometimes types are defined in code diff --git a/schemas/program/context/variables.schema.yaml b/schemas/program/context/variables.schema.yaml index a64b43725..9987a1856 100644 --- a/schemas/program/context/variables.schema.yaml +++ b/schemas/program/context/variables.schema.yaml @@ -58,12 +58,10 @@ $defs: type: description: | - The variable's static type, if it exists. This **must** be specified - either as a full **ethdebug/format/type** representation, or an - `{ "id": "..." }` type reference object. - oneOf: - - $ref: "schema:ethdebug/format/type" - - $ref: "schema:ethdebug/format/type/reference" + The variable's static type, if it exists. This **must** be + specified either as a full **ethdebug/format/type** + representation, or an `{ "id": "..." }` type reference. + $ref: "schema:ethdebug/format/type/specifier" pointer: description: | diff --git a/schemas/type/specifier.schema.yaml b/schemas/type/specifier.schema.yaml new file mode 100644 index 000000000..8781c890e --- /dev/null +++ b/schemas/type/specifier.schema.yaml @@ -0,0 +1,20 @@ +$schema: "https://json-schema.org/draft/2020-12/schema" +$id: "schema:ethdebug/format/type/specifier" + +title: ethdebug/format/type/specifier +description: | + A type specifier: either a complete type representation or a + reference to a known type by ID. This schema discriminates + between the two forms based on the presence of an `id` field. + +if: + required: [id] +then: + $ref: "schema:ethdebug/format/type/reference" +else: + $ref: "schema:ethdebug/format/type" + +examples: + - kind: uint + bits: 256 + - id: 42 diff --git a/schemas/type/wrapper.schema.yaml b/schemas/type/wrapper.schema.yaml index eefca6a56..8e9a277f0 100644 --- a/schemas/type/wrapper.schema.yaml +++ b/schemas/type/wrapper.schema.yaml @@ -9,14 +9,7 @@ description: type: object properties: type: - # Discriminate between reference and type based on presence of `id` - if: - required: - - id - then: - $ref: "schema:ethdebug/format/type/reference" - else: - $ref: "schema:ethdebug/format/type" + $ref: "schema:ethdebug/format/type/specifier" required: - type From addf9d5997abe388a2b807ba927fc41176077a13 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Wed, 4 Mar 2026 01:15:39 -0500 Subject: [PATCH 12/39] Format --- packages/web/spec/type/concepts.mdx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/web/spec/type/concepts.mdx b/packages/web/spec/type/concepts.mdx index 554baa560..e42bcbf7f 100644 --- a/packages/web/spec/type/concepts.mdx +++ b/packages/web/spec/type/concepts.mdx @@ -212,9 +212,7 @@ of this format at-large may impose restrictions, however. ### Type specifier schema - + ### Type wrapper schema From a2e3dde21fcad15a28146daa91ab63708a9d7a01 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Wed, 11 Mar 2026 03:40:04 -0400 Subject: [PATCH 13/39] format: add TypeScript types for function call contexts (#186) Add Type.Reference, Type.Specifier, and type guards for the new function call context schemas (invoke, return, revert). - Type.Reference: { id: string | number } for type references - Type.Specifier: Type | Reference union (matches type/specifier schema) - Context.Function.Identity: shared function identity fields - Context.Invoke: internal calls, external calls, contract creation - Context.Return: function return with data and optional success - Context.Revert: function revert with optional reason/panic - Update Variable.type to accept Type.Specifier (was Type only) - Update Type.Wrapper to use Type.Specifier --- .../format/src/types/program/context.test.ts | 16 ++ packages/format/src/types/program/context.ts | 172 +++++++++++++++++- packages/format/src/types/type/index.test.ts | 15 ++ packages/format/src/types/type/index.ts | 20 +- 4 files changed, 216 insertions(+), 7 deletions(-) diff --git a/packages/format/src/types/program/context.test.ts b/packages/format/src/types/program/context.test.ts index 47185c7ca..4470a322d 100644 --- a/packages/format/src/types/program/context.test.ts +++ b/packages/format/src/types/program/context.test.ts @@ -30,4 +30,20 @@ testSchemaGuards("ethdebug/format/program/context", [ schema: "schema:ethdebug/format/program/context/frame", guard: Context.isFrame, }, + { + schema: "schema:ethdebug/format/program/context/function", + guard: Context.Function.isIdentity, + }, + { + schema: "schema:ethdebug/format/program/context/function/invoke", + guard: Context.isInvoke, + }, + { + schema: "schema:ethdebug/format/program/context/function/return", + guard: Context.isReturn, + }, + { + schema: "schema:ethdebug/format/program/context/function/revert", + guard: Context.isRevert, + }, ] as const); diff --git a/packages/format/src/types/program/context.ts b/packages/format/src/types/program/context.ts index 6a552734a..9388fea7b 100644 --- a/packages/format/src/types/program/context.ts +++ b/packages/format/src/types/program/context.ts @@ -1,5 +1,5 @@ import { Materials } from "#types/materials"; -import { Type, isType } from "#types/type"; +import { Type } from "#types/type"; import { Pointer, isPointer } from "#types/pointer"; export type Context = @@ -8,7 +8,10 @@ export type Context = | Context.Remark | Context.Pick | Context.Gather - | Context.Frame; + | Context.Frame + | Context.Invoke + | Context.Return + | Context.Revert; export const isContext = (value: unknown): value is Context => [ @@ -18,6 +21,9 @@ export const isContext = (value: unknown): value is Context => Context.isPick, Context.isFrame, Context.isGather, + Context.isInvoke, + Context.isReturn, + Context.isRevert, ].some((guard) => guard(value)); export namespace Context { @@ -47,7 +53,7 @@ export namespace Context { export interface Variable { identifier?: string; declaration?: Materials.SourceRange; - type?: Type; + type?: Type.Specifier; pointer?: Pointer; } @@ -66,7 +72,7 @@ export namespace Context { (!("identifier" in value) || typeof value.identifier === "string") && (!("declaration" in value) || Materials.isSourceRange(value.declaration)) && - (!("type" in value) || isType(value.type)) && + (!("type" in value) || Type.isSpecifier(value.type)) && (!("pointer" in value) || isPointer(value.pointer)); } @@ -111,4 +117,162 @@ export namespace Context { !!value && "frame" in value && typeof value.frame === "string"; + + export namespace Function { + export interface Identity { + identifier?: string; + declaration?: Materials.SourceRange; + type?: Type.Specifier; + } + + export const isIdentity = (value: unknown): value is Identity => + typeof value === "object" && + !!value && + (!("identifier" in value) || typeof value.identifier === "string") && + (!("declaration" in value) || + Materials.isSourceRange(value.declaration)) && + (!("type" in value) || Type.isSpecifier(value.type)); + + export interface PointerRef { + pointer: Pointer; + } + + export const isPointerRef = (value: unknown): value is PointerRef => + typeof value === "object" && + !!value && + "pointer" in value && + isPointer(value.pointer); + } + + export interface Invoke { + invoke: Invoke.Invocation; + } + + export const isInvoke = (value: unknown): value is Invoke => + typeof value === "object" && + !!value && + "invoke" in value && + Invoke.isInvocation(value.invoke); + + export namespace Invoke { + export type Invocation = Function.Identity & + ( + | Invocation.InternalCall + | Invocation.ExternalCall + | Invocation.ContractCreation + ); + + export const isInvocation = (value: unknown): value is Invocation => + Function.isIdentity(value) && + (Invocation.isInternalCall(value) || + Invocation.isExternalCall(value) || + Invocation.isContractCreation(value)); + + export namespace Invocation { + export interface InternalCall extends Function.Identity { + jump: true; + target: Function.PointerRef; + arguments?: Function.PointerRef; + } + + export const isInternalCall = (value: unknown): value is InternalCall => + typeof value === "object" && + !!value && + "jump" in value && + value.jump === true && + "target" in value && + Function.isPointerRef(value.target) && + (!("arguments" in value) || Function.isPointerRef(value.arguments)); + + export interface ExternalCall extends Function.Identity { + message: true; + target: Function.PointerRef; + gas?: Function.PointerRef; + value?: Function.PointerRef; + input?: Function.PointerRef; + delegate?: true; + static?: true; + } + + export const isExternalCall = (value: unknown): value is ExternalCall => + typeof value === "object" && + !!value && + "message" in value && + value.message === true && + "target" in value && + Function.isPointerRef(value.target) && + (!("gas" in value) || Function.isPointerRef(value.gas)) && + (!("value" in value) || Function.isPointerRef(value.value)) && + (!("input" in value) || Function.isPointerRef(value.input)) && + (!("delegate" in value) || value.delegate === true) && + (!("static" in value) || value.static === true); + + export interface ContractCreation extends Function.Identity { + create: true; + value?: Function.PointerRef; + salt?: Function.PointerRef; + input?: Function.PointerRef; + } + + export const isContractCreation = ( + value: unknown, + ): value is ContractCreation => + typeof value === "object" && + !!value && + "create" in value && + value.create === true && + (!("value" in value) || Function.isPointerRef(value.value)) && + (!("salt" in value) || Function.isPointerRef(value.salt)) && + (!("input" in value) || Function.isPointerRef(value.input)); + } + } + + export interface Return { + return: Return.Info; + } + + export const isReturn = (value: unknown): value is Return => + typeof value === "object" && + !!value && + "return" in value && + Return.isInfo(value.return); + + export namespace Return { + export interface Info extends Function.Identity { + data: Function.PointerRef; + success?: Function.PointerRef; + } + + export const isInfo = (value: unknown): value is Info => + Function.isIdentity(value) && + typeof value === "object" && + !!value && + "data" in value && + Function.isPointerRef(value.data) && + (!("success" in value) || Function.isPointerRef(value.success)); + } + + export interface Revert { + revert: Revert.Info; + } + + export const isRevert = (value: unknown): value is Revert => + typeof value === "object" && + !!value && + "revert" in value && + Revert.isInfo(value.revert); + + export namespace Revert { + export interface Info extends Function.Identity { + reason?: Function.PointerRef; + panic?: number; + } + + export const isInfo = (value: unknown): value is Info => + Function.isIdentity(value) && + typeof value === "object" && + !!value && + (!("reason" in value) || Function.isPointerRef(value.reason)) && + (!("panic" in value) || typeof value.panic === "number"); + } } diff --git a/packages/format/src/types/type/index.test.ts b/packages/format/src/types/type/index.test.ts index 61a5349c3..cc461540a 100644 --- a/packages/format/src/types/type/index.test.ts +++ b/packages/format/src/types/type/index.test.ts @@ -80,4 +80,19 @@ testSchemaGuards("ethdebug/format/type", [ schema: "schema:ethdebug/format/type/complex/struct", guard: Type.Complex.isStruct, }, + + // type reference and specifier + + { + schema: "schema:ethdebug/format/type/reference", + guard: Type.isReference, + }, + { + schema: "schema:ethdebug/format/type/specifier", + guard: Type.isSpecifier, + }, + { + schema: "schema:ethdebug/format/type/wrapper", + guard: Type.isWrapper, + }, ] as const); diff --git a/packages/format/src/types/type/index.ts b/packages/format/src/types/type/index.ts index c53d0230b..066f3a6d5 100644 --- a/packages/format/src/types/type/index.ts +++ b/packages/format/src/types/type/index.ts @@ -32,16 +32,30 @@ export namespace Type { (typeof value.contains === "object" && Object.values(value.contains).every(Type.isWrapper))); + export interface Reference { + id: string | number; + } + + export const isReference = (value: unknown): value is Reference => + typeof value === "object" && + !!value && + "id" in value && + (typeof value.id === "string" || typeof value.id === "number"); + + export type Specifier = Type | Reference; + + export const isSpecifier = (value: unknown): value is Specifier => + isType(value) || isReference(value); + export interface Wrapper { - type: Type | { id: any }; + type: Specifier; } export const isWrapper = (value: unknown): value is Wrapper => typeof value === "object" && !!value && "type" in value && - (isType(value.type) || - (typeof value.type === "object" && !!value.type && "id" in value.type)); + isSpecifier(value.type); export type Elementary = | Elementary.Uint From c50c8d9da17860a80d584094f08c20e87c674135 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Wed, 11 Mar 2026 03:54:11 -0400 Subject: [PATCH 14/39] bugc: emit invoke/return contexts for internal function calls (#185) Add debug context annotations for function call boundaries using the typed Format.Program.Context.Invoke and Context.Return interfaces: - Invoke context on caller's JUMP instruction with target pointer and argument group pointers (stack slots) - Return context on continuation JUMPDEST with data pointer to return value at stack slot 0 - Invoke context on callee entry JUMPDEST with target and argument pointers Includes tests verifying context emission for single-arg, multi-arg, nested, and void function call scenarios. --- .../bugc/src/evmgen/call-contexts.test.ts | 358 ++++++++++++++++++ packages/bugc/src/evmgen/generation/block.ts | 20 +- .../generation/control-flow/terminator.ts | 37 +- .../bugc/src/evmgen/generation/function.ts | 33 +- 4 files changed, 439 insertions(+), 9 deletions(-) create mode 100644 packages/bugc/src/evmgen/call-contexts.test.ts diff --git a/packages/bugc/src/evmgen/call-contexts.test.ts b/packages/bugc/src/evmgen/call-contexts.test.ts new file mode 100644 index 000000000..c0a66d549 --- /dev/null +++ b/packages/bugc/src/evmgen/call-contexts.test.ts @@ -0,0 +1,358 @@ +import { describe, it, expect } from "vitest"; + +import { compile } from "#compiler"; +import type * as Format from "@ethdebug/format"; + +/** + * Compile a BUG source and return the runtime program + */ +async function compileProgram(source: string): Promise { + const result = await compile({ + to: "bytecode", + source, + }); + + if (!result.success) { + const errors = result.messages.error ?? []; + const msgs = errors + .map((e: { message?: string }) => e.message ?? String(e)) + .join("\n"); + throw new Error(`Compilation failed:\n${msgs}`); + } + + return result.value.bytecode.runtimeProgram; +} + +/** + * Find instructions matching a predicate + */ +function findInstructions( + program: Format.Program, + predicate: (instr: Format.Program.Instruction) => boolean, +): Format.Program.Instruction[] { + return program.instructions.filter(predicate); +} + +describe("function call debug contexts", () => { + const source = `name CallContextTest; + +define { + function add(a: uint256, b: uint256) -> uint256 { + return a + b; + }; +} + +storage { + [0] result: uint256; +} + +create { + result = 0; +} + +code { + result = add(10, 20); +}`; + + it("should emit invoke context on caller JUMP", async () => { + const program = await compileProgram(source); + + // Find JUMP instructions with invoke context + const invokeJumps = findInstructions( + program, + (instr) => + instr.operation?.mnemonic === "JUMP" && + !!(instr.context as Record)?.invoke, + ); + + expect(invokeJumps.length).toBeGreaterThanOrEqual(1); + + const ctx = (invokeJumps[0].context as Record)!; + const invoke = ctx.invoke as Record; + + expect(invoke.jump).toBe(true); + expect(invoke.identifier).toBe("add"); + + // Should have target pointer + const target = invoke.target as Record; + expect(target.pointer).toBeDefined(); + + // Should have argument pointers + const args = invoke.arguments as Record; + const pointer = args.pointer as Record; + const group = pointer.group as Array>; + + expect(group).toHaveLength(2); + // First arg (a) is deepest on stack + expect(group[0]).toEqual({ + location: "stack", + slot: 1, + }); + // Second arg (b) is on top + expect(group[1]).toEqual({ + location: "stack", + slot: 0, + }); + }); + + it("should emit return context on continuation JUMPDEST", async () => { + const program = await compileProgram(source); + + // Find JUMPDEST instructions with return context + const returnJumpdests = findInstructions( + program, + (instr) => + instr.operation?.mnemonic === "JUMPDEST" && + !!(instr.context as Record)?.return, + ); + + expect(returnJumpdests.length).toBeGreaterThanOrEqual(1); + + const ctx = (returnJumpdests[0].context as Record)!; + const ret = ctx.return as Record; + + expect(ret.identifier).toBe("add"); + + // Should have data pointer to return value at + // TOS (stack slot 0) + const data = ret.data as Record; + const pointer = data.pointer as Record; + expect(pointer).toEqual({ + location: "stack", + slot: 0, + }); + }); + + it("should emit invoke context on callee entry JUMPDEST", async () => { + const program = await compileProgram(source); + + // Find JUMPDEST instructions with invoke context + // (the callee entry point, not the continuation) + const invokeJumpdests = findInstructions( + program, + (instr) => + instr.operation?.mnemonic === "JUMPDEST" && + !!(instr.context as Record)?.invoke, + ); + + expect(invokeJumpdests.length).toBeGreaterThanOrEqual(1); + + const ctx = (invokeJumpdests[0].context as Record)!; + const invoke = ctx.invoke as Record; + + expect(invoke.jump).toBe(true); + expect(invoke.identifier).toBe("add"); + + // Should have argument pointers matching + // function parameters + const args = invoke.arguments as Record; + const pointer = args.pointer as Record; + const group = pointer.group as Array>; + + expect(group).toHaveLength(2); + }); + + it("should emit contexts in correct instruction order", async () => { + const program = await compileProgram(source); + + // The caller JUMP should come before the + // continuation JUMPDEST + const invokeJump = findInstructions( + program, + (instr) => + instr.operation?.mnemonic === "JUMP" && + !!(instr.context as Record)?.invoke, + )[0]; + + const returnJumpdest = findInstructions( + program, + (instr) => + instr.operation?.mnemonic === "JUMPDEST" && + !!(instr.context as Record)?.return, + )[0]; + + expect(invokeJump).toBeDefined(); + expect(returnJumpdest).toBeDefined(); + + // Invoke JUMP offset should be less than + // return JUMPDEST offset (caller comes first + // in bytecode) + expect(Number(invokeJump.offset)).toBeLessThan( + Number(returnJumpdest.offset), + ); + }); + + describe("void function calls", () => { + const voidSource = `name VoidCallTest; + +define { + function setVal( + s: uint256, v: uint256 + ) -> uint256 { + return v; + }; +} + +storage { + [0] result: uint256; +} + +create { + result = 0; +} + +code { + result = setVal(0, 42); +}`; + + it( + "should emit return context without data pointer " + "for void functions", + async () => { + // This tests that when a function returns a + // value, the return context includes data. + // (All our test functions return values, so + // data should always be present here.) + const program = await compileProgram(voidSource); + + const returnJumpdests = findInstructions( + program, + (instr) => + instr.operation?.mnemonic === "JUMPDEST" && + !!(instr.context as Record)?.return, + ); + + expect(returnJumpdests.length).toBeGreaterThanOrEqual(1); + + const ctx = (returnJumpdests[0].context as Record)!; + const ret = ctx.return as Record; + expect(ret.identifier).toBe("setVal"); + // Since setVal returns a value, data should + // be present + expect(ret.data).toBeDefined(); + }, + ); + }); + + describe("nested function calls", () => { + const nestedSource = `name NestedCallTest; + +define { + function add( + a: uint256, b: uint256 + ) -> uint256 { + return a + b; + }; + function addThree( + x: uint256, y: uint256, z: uint256 + ) -> uint256 { + let sum1 = add(x, y); + let sum2 = add(sum1, z); + return sum2; + }; +} + +storage { + [0] result: uint256; +} + +create { + result = 0; +} + +code { + result = addThree(1, 2, 3); +}`; + + it("should emit invoke/return contexts for " + "nested calls", async () => { + const program = await compileProgram(nestedSource); + + // Should have invoke contexts for: + // 1. main -> addThree + // 2. addThree -> add (first call) + // 3. addThree -> add (second call) + // Plus callee entry JUMPDESTs + const invokeJumps = findInstructions( + program, + (instr) => + instr.operation?.mnemonic === "JUMP" && + !!(instr.context as Record)?.invoke, + ); + + // At least 3 invoke JUMPs (main->addThree, + // addThree->add x2) + expect(invokeJumps.length).toBeGreaterThanOrEqual(3); + + // Check we have invokes for both functions + const invokeIds = invokeJumps.map( + (instr) => + ( + (instr.context as Record).invoke as Record< + string, + unknown + > + ).identifier, + ); + expect(invokeIds).toContain("addThree"); + expect(invokeIds).toContain("add"); + + // Should have return contexts for all + // continuation points + const returnJumpdests = findInstructions( + program, + (instr) => + instr.operation?.mnemonic === "JUMPDEST" && + !!(instr.context as Record)?.return, + ); + + expect(returnJumpdests.length).toBeGreaterThanOrEqual(3); + }); + }); + + describe("single-arg function", () => { + const singleArgSource = `name SingleArgTest; + +define { + function double(x: uint256) -> uint256 { + return x + x; + }; +} + +storage { + [0] result: uint256; +} + +create { + result = 0; +} + +code { + result = double(7); +}`; + + it("should emit single-element argument group", async () => { + const program = await compileProgram(singleArgSource); + + const invokeJumps = findInstructions( + program, + (instr) => + instr.operation?.mnemonic === "JUMP" && + !!(instr.context as Record)?.invoke, + ); + + expect(invokeJumps.length).toBeGreaterThanOrEqual(1); + + const ctx = (invokeJumps[0].context as Record)!; + const invoke = ctx.invoke as Record; + const args = invoke.arguments as Record; + const pointer = args.pointer as Record; + const group = pointer.group as Array>; + + // Single arg at stack slot 0 + expect(group).toHaveLength(1); + expect(group[0]).toEqual({ + location: "stack", + slot: 0, + }); + }); + }); +}); diff --git a/packages/bugc/src/evmgen/generation/block.ts b/packages/bugc/src/evmgen/generation/block.ts index e425212fb..6b0c2189d 100644 --- a/packages/bugc/src/evmgen/generation/block.ts +++ b/packages/bugc/src/evmgen/generation/block.ts @@ -2,6 +2,7 @@ * Block-level code generation */ +import type * as Format from "@ethdebug/format"; import * as Ir from "#ir"; import type { Stack } from "#evm"; @@ -69,11 +70,24 @@ export function generate( // Add JUMPDEST with continuation annotation if applicable if (isContinuation) { - const continuationDebug = { - context: { - remark: `call-continuation: resume after call to ${calledFunction}`, + // Return context describes state after JUMPDEST + // executes: TOS is the return value (if any). + // data pointer is required by the schema; for + // void returns, slot 0 is still valid (empty). + const returnCtx: Format.Program.Context.Return = { + return: { + identifier: calledFunction, + data: { + pointer: { + location: "stack" as const, + slot: 0, + }, + }, }, }; + const continuationDebug = { + context: returnCtx as Format.Program.Context, + }; result = result.then(JUMPDEST({ debug: continuationDebug })); } else { result = result.then(JUMPDEST()); diff --git a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts index 44c4792da..fe39eddfc 100644 --- a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts +++ b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts @@ -1,3 +1,4 @@ +import type * as Format from "@ethdebug/format"; import type * as Ir from "#ir"; import type { Stack } from "#evm"; import type { State } from "#evmgen/state"; @@ -225,13 +226,45 @@ export function generateCallTerminator( currentState = loadValue(arg, { debug: argsDebug })(currentState); } - // Push function address and jump + // Push function address and jump. + // The JUMP gets an invoke context: after JUMP executes, + // the function has been entered with args on the stack. const funcAddrPatchIndex = currentState.instructions.length; const invocationDebug = { context: { remark: `call-invocation: jump to function ${funcName}`, }, }; + + // Build argument pointers: after the JUMP, the callee + // sees args on the stack in order (first arg deepest). + const argPointers = args.map((_arg, i) => ({ + location: "stack" as const, + slot: args.length - 1 - i, + })); + + // Invoke context describes state after JUMP executes: + // the callee has been entered with args on the stack. + // target points to the function address at stack slot 0 + // (consumed by JUMP, but describes the call target). + const invoke: Format.Program.Context.Invoke = { + invoke: { + jump: true as const, + identifier: funcName, + target: { + pointer: { location: "stack" as const, slot: 0 }, + }, + ...(argPointers.length > 0 && { + arguments: { + pointer: { + group: argPointers, + }, + }, + }), + }, + }; + const invokeContext = { context: invoke as Format.Program.Context }; + currentState = { ...currentState, instructions: [ @@ -242,7 +275,7 @@ export function generateCallTerminator( immediates: [0, 0], debug: invocationDebug, }, - { mnemonic: "JUMP", opcode: 0x56 }, + { mnemonic: "JUMP", opcode: 0x56, debug: invokeContext }, ], patches: [ ...currentState.patches, diff --git a/packages/bugc/src/evmgen/generation/function.ts b/packages/bugc/src/evmgen/generation/function.ts index 59e068bf8..70b31d8aa 100644 --- a/packages/bugc/src/evmgen/generation/function.ts +++ b/packages/bugc/src/evmgen/generation/function.ts @@ -2,6 +2,7 @@ * Function-level code generation */ +import type * as Format from "@ethdebug/format"; import * as Ir from "#ir"; import type * as Evm from "#evm"; import type { Stack } from "#evm"; @@ -27,12 +28,36 @@ function generatePrologue( return ((state: State): State => { let currentState = state; - // Add JUMPDEST with function entry annotation - const entryDebug = { - context: { - remark: `function-entry: ${func.name || "anonymous"}`, + // Add JUMPDEST with function entry annotation. + // After this JUMPDEST executes, the callee's args are + // on the stack (first arg deepest). + const argPointers = params.map((_p, i) => ({ + location: "stack" as const, + slot: params.length - 1 - i, + })); + + const entryInvoke: Format.Program.Context.Invoke = { + invoke: { + jump: true as const, + identifier: func.name || "anonymous", + target: { + pointer: { + location: "stack" as const, + slot: 0, + }, + }, + ...(argPointers.length > 0 && { + arguments: { + pointer: { + group: argPointers, + }, + }, + }), }, }; + const entryDebug = { + context: entryInvoke as Format.Program.Context, + }; currentState = { ...currentState, instructions: [ From 940e0cc28a9398a6bdd493634e46faeef80eb780 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Wed, 11 Mar 2026 04:28:14 -0400 Subject: [PATCH 15/39] bugc: add source maps for call setup instructions (#188) The call setup sequence (POP cleanup, PUSH return address, MSTORE, push arguments, PUSH function address) was using remark-only debug contexts, leaving these instructions unmapped in tracing output. Now threads the call terminator's operationDebug (which carries the source code range for the call expression) through all setup instructions, matching how other instruction generators use operationDebug. --- .../generation/control-flow/terminator.ts | 41 +++++++------------ 1 file changed, 15 insertions(+), 26 deletions(-) diff --git a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts index fe39eddfc..1f418c9b4 100644 --- a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts +++ b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts @@ -159,6 +159,10 @@ export function generateCallTerminator( return ((state: State): State => { let currentState: State = state as State; + // All call setup instructions map back to the call + // expression source location via operationDebug. + const debug = term.operationDebug; + // Clean the stack before setting up the call. // Values produced by block instructions that are only // used as call arguments will have been DUP'd by @@ -166,17 +170,12 @@ export function generateCallTerminator( // block terminator, all current stack values are dead // after the call — POP them so the function receives a // clean stack with only its arguments. - const cleanupDebug = { - context: { - remark: `call-preparation: clean stack for ${funcName}`, - }, - }; while (currentState.stack.length > 0) { currentState = { ...currentState, instructions: [ ...currentState.instructions, - { mnemonic: "POP", opcode: 0x50, debug: cleanupDebug }, + { mnemonic: "POP", opcode: 0x50, debug }, ], stack: currentState.stack.slice(1), brands: currentState.brands.slice(1) as Stack, @@ -186,11 +185,6 @@ export function generateCallTerminator( const returnPcPatchIndex = currentState.instructions.length; // Store return PC to memory at 0x60 - const returnPcDebug = { - context: { - remark: `call-preparation: store return address for ${funcName}`, - }, - }; currentState = { ...currentState, instructions: [ @@ -199,10 +193,15 @@ export function generateCallTerminator( mnemonic: "PUSH2", opcode: 0x61, immediates: [0, 0], - debug: returnPcDebug, + debug, }, - { mnemonic: "PUSH1", opcode: 0x60, immediates: [0x60] }, - { mnemonic: "MSTORE", opcode: 0x52 }, + { + mnemonic: "PUSH1", + opcode: 0x60, + immediates: [0x60], + debug, + }, + { mnemonic: "MSTORE", opcode: 0x52, debug }, ], patches: [ ...currentState.patches, @@ -217,24 +216,14 @@ export function generateCallTerminator( // Push arguments using loadValue. // Stack is clean, so loadValue will reload from memory // (for temps) or re-push (for consts). - const argsDebug = { - context: { - remark: `call-arguments: push ${args.length} argument(s) for ${funcName}`, - }, - }; for (const arg of args) { - currentState = loadValue(arg, { debug: argsDebug })(currentState); + currentState = loadValue(arg, { debug })(currentState); } // Push function address and jump. // The JUMP gets an invoke context: after JUMP executes, // the function has been entered with args on the stack. const funcAddrPatchIndex = currentState.instructions.length; - const invocationDebug = { - context: { - remark: `call-invocation: jump to function ${funcName}`, - }, - }; // Build argument pointers: after the JUMP, the callee // sees args on the stack in order (first arg deepest). @@ -273,7 +262,7 @@ export function generateCallTerminator( mnemonic: "PUSH2", opcode: 0x61, immediates: [0, 0], - debug: invocationDebug, + debug, }, { mnemonic: "JUMP", opcode: 0x56, debug: invokeContext }, ], From 7802c8b6a212f23cf1fe109f164c36810313e40b Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Wed, 11 Mar 2026 04:35:54 -0400 Subject: [PATCH 16/39] Add function call tracing documentation (#187) * Add function call tracing documentation Add invoke/return/revert context documentation across concept, reference, and spec pages: - concepts/programs.mdx: new "Function call contexts" section explaining the three context types with a SchemaExample - tracing.mdx: walkthrough of tracing through an internal function call (Adder contract), plus external call and revert examples - Spec pages: added intro prose to function.mdx, return.mdx, revert.mdx * Make function call tracing example interactive Replace the static BUG code block with an interactive TraceExample component that lets readers compile and step through the Adder contract, seeing invoke/return contexts at function boundaries. Static SchemaExample blocks are kept for the narrative walkthrough and for external call/revert examples (which BUG can't demonstrate). * Fix BUG source indentation in tracing examples Add 2-space indentation inside block bodies (storage, create, code, if) to match the canonical style used in .bug example files. * Revert BUG indentation to match prettier formatting Prettier strips indentation inside template literal strings in MDX JSX props. Revert to the unindented style that prettier enforces. --- packages/web/docs/concepts/programs.mdx | 49 +++++ .../docs/core-schemas/programs/tracing.mdx | 172 ++++++++++++++++++ .../program/context/function/function.mdx | 6 +- .../spec/program/context/function/return.mdx | 5 + .../spec/program/context/function/revert.mdx | 5 + 5 files changed, 236 insertions(+), 1 deletion(-) diff --git a/packages/web/docs/concepts/programs.mdx b/packages/web/docs/concepts/programs.mdx index ec4b3abe4..93c844c27 100644 --- a/packages/web/docs/concepts/programs.mdx +++ b/packages/web/docs/concepts/programs.mdx @@ -157,6 +157,55 @@ Contexts can be composed using: This composition enables describing complex scenarios like conditional variable assignments or function inlining. +## Function call contexts + +Programs answer "what function are we in?" through three context types +that track function boundaries during execution: + +- **invoke** — marks an instruction that enters a function. Indicates + the invocation kind (internal jump, external message call, or + contract creation) and provides pointers to call arguments, target + address, gas, and value as appropriate. +- **return** — marks an instruction associated with a successful + return from a function. Provides a pointer to the return data. +- **revert** — marks an instruction associated with a failed call. + May include a pointer to revert reason data or a numeric panic + code. + +All three extend a common **function identity** schema with optional +fields for the function's name, declaration source range, and type. +This lets compilers provide as much or as little attribution as +available — from a fully identified `transfer` call down to an +anonymous indirect invocation through a function pointer. + + + {`{ + "invoke": { + "identifier": "transfer", + "jump": true, + "target": { + "pointer": { "location": "stack", "slot": 0 } + }, + "arguments": { + "pointer": { + "group": [ + { "name": "to", "location": "stack", "slot": 2 }, + { "name": "amount", "location": "stack", "slot": 3 } + ] + } + } + } +}`} + + +A debugger uses these contexts to reconstruct call stacks, show +function names in stepping UI, and display argument/return values +alongside source code. + ## What tracing enables By following contexts through execution, debuggers can provide: diff --git a/packages/web/docs/core-schemas/programs/tracing.mdx b/packages/web/docs/core-schemas/programs/tracing.mdx index 7d1fadc86..00a069225 100644 --- a/packages/web/docs/core-schemas/programs/tracing.mdx +++ b/packages/web/docs/core-schemas/programs/tracing.mdx @@ -78,6 +78,178 @@ This example shows working with multiple storage locations: source={multipleStorageSlots} /> +## Tracing through a function call + +The examples above trace simple straight-line code. Real programs +make function calls. **invoke** and **return** contexts let a +debugger follow execution across function boundaries. + +Click **"Try it"** on the example below, then step through the +trace. Watch for **invoke** contexts on the JUMP into `add` and +**return** contexts on the JUMP back to the caller: + + uint256 { +return a + b; +}; +} + +storage { +[0] result: uint256; +} + +create { +result = 0; +} + +code { +result = add(3, 4); +}`} +/> + +As you step through, three phases are visible: + +### Before the call — setting up arguments + +At the call site, the compiler pushes arguments onto the stack and +prepares the jump. The JUMP instruction carries an **invoke** +context identifying the function, its target, and the argument +locations: + + + {`{ + "invoke": { + "identifier": "add", + "jump": true, + "target": { + "pointer": { "location": "stack", "slot": 0 } + }, + "arguments": { + "pointer": { + "group": [ + { "name": "a", "location": "stack", "slot": 2 }, + { "name": "b", "location": "stack", "slot": 3 } + ] + } + } + } +}`} + + +The debugger now knows it's entering `add` with arguments at stack +slots 2 and 3. A trace viewer can show `add(3, 4)` in the call +stack. + +### Inside the function — normal tracing + +Inside `add`, instructions carry their own `code` and `variables` +contexts as usual. The debugger shows the source range within the +function body, and parameters `a` and `b` appear as in-scope +variables. + +### Returning — the result + +When `add` finishes, the JUMP back to the caller carries a +**return** context with a pointer to the result: + + + {`{ + "return": { + "identifier": "add", + "data": { + "pointer": { "location": "stack", "slot": 0 } + } + } +}`} + + +The debugger pops `add` from the call stack and can display the +return value (7). + +### External calls and reverts + +The same pattern applies to external message calls, but with +additional fields. An external CALL instruction carries gas, value, +and input data pointers: + + + {`{ + "invoke": { + "identifier": "balanceOf", + "message": true, + "target": { + "pointer": { "location": "stack", "slot": 1 } + }, + "gas": { + "pointer": { "location": "stack", "slot": 0 } + }, + "input": { + "pointer": { + "group": [ + { "name": "selector", "location": "memory", + "offset": "0x80", "length": 4 }, + { "name": "arguments", "location": "memory", + "offset": "0x84", "length": "0x20" } + ] + } + } + } +}`} + + +If the call reverts, a **revert** context captures the reason: + + + {`{ + "revert": { + "identifier": "transfer", + "reason": { + "pointer": { + "location": "memory", + "offset": "0x80", + "length": "0x64" + } + } + } +}`} + + +For built-in assertion failures, the compiler can provide a panic +code instead of (or alongside) a reason pointer: + + + {`{ + "revert": { + "panic": 17 + } +}`} + + ## Trace data structure A trace step captures the EVM state at a single point: diff --git a/packages/web/spec/program/context/function/function.mdx b/packages/web/spec/program/context/function/function.mdx index 0b00b3a6d..69dcad9d9 100644 --- a/packages/web/spec/program/context/function/function.mdx +++ b/packages/web/spec/program/context/function/function.mdx @@ -7,7 +7,11 @@ import SchemaViewer from "@site/src/components/SchemaViewer"; # Function identity Function contexts (invoke, return, revert) share a common set of -identity fields for the function being called. +identity fields for the function being called. All fields are +optional, allowing compilers to provide as much or as little +detail as available — from a fully named function with source +location and type, down to an empty object for an anonymous +indirect call. diff --git a/packages/web/spec/program/context/function/revert.mdx b/packages/web/spec/program/context/function/revert.mdx index 4b18b518b..98d980988 100644 --- a/packages/web/spec/program/context/function/revert.mdx +++ b/packages/web/spec/program/context/function/revert.mdx @@ -6,6 +6,11 @@ import SchemaViewer from "@site/src/components/SchemaViewer"; # Revert contexts +A revert context marks an instruction associated with a function +revert. It extends the function identity schema with an optional +pointer to revert reason data and/or a numeric panic code for +built-in assertion failures. + From c8a793aad8f749eabc5cd355e11dc42821f05fd3 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Wed, 1 Apr 2026 16:15:19 -0400 Subject: [PATCH 17/39] docs: move 4th BUG example into tracing-examples.ts Move the "Function call and return" (Adder) example into tracing-examples.ts with proper indentation, matching the pattern established for the other three examples. --- .../core-schemas/programs/tracing-examples.ts | 20 ++++++++++++++++++ .../docs/core-schemas/programs/tracing.mdx | 21 ++----------------- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/packages/web/docs/core-schemas/programs/tracing-examples.ts b/packages/web/docs/core-schemas/programs/tracing-examples.ts index 19cd1b936..63b1a4b6f 100644 --- a/packages/web/docs/core-schemas/programs/tracing-examples.ts +++ b/packages/web/docs/core-schemas/programs/tracing-examples.ts @@ -51,3 +51,23 @@ code { a = a + 1; b = b + 1; }`; + +export const functionCallAndReturn = `name Adder; + +define { + function add(a: uint256, b: uint256) -> uint256 { + return a + b; + }; +} + +storage { + [0] result: uint256; +} + +create { + result = 0; +} + +code { + result = add(3, 4); +}`; diff --git a/packages/web/docs/core-schemas/programs/tracing.mdx b/packages/web/docs/core-schemas/programs/tracing.mdx index 00a069225..b476c9579 100644 --- a/packages/web/docs/core-schemas/programs/tracing.mdx +++ b/packages/web/docs/core-schemas/programs/tracing.mdx @@ -9,6 +9,7 @@ import { counterIncrement, thresholdCheck, multipleStorageSlots, + functionCallAndReturn, } from "./tracing-examples"; # Tracing execution @@ -91,25 +92,7 @@ trace. Watch for **invoke** contexts on the JUMP into `add` and uint256 { -return a + b; -}; -} - -storage { -[0] result: uint256; -} - -create { -result = 0; -} - -code { -result = add(3, 4); -}`} + source={functionCallAndReturn} /> As you step through, three phases are visible: From 058aa8c52a1c60d8fab8f3acfdd3162b2c49c70b Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Wed, 1 Apr 2026 16:35:03 -0400 Subject: [PATCH 18/39] docs: add invoke.mdx prose intro and cross-link spec pages (#192) Add a descriptive introduction to the invocation contexts spec page, matching the pattern of function.mdx, return.mdx, and revert.mdx. Add cross-links between the function context spec pages and the tracing documentation. --- packages/web/docs/core-schemas/programs/tracing.mdx | 2 ++ .../web/spec/program/context/function/function.mdx | 6 +++++- .../web/spec/program/context/function/invoke.mdx | 13 +++++++++++++ .../web/spec/program/context/function/return.mdx | 3 ++- .../web/spec/program/context/function/revert.mdx | 3 ++- 5 files changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/web/docs/core-schemas/programs/tracing.mdx b/packages/web/docs/core-schemas/programs/tracing.mdx index b476c9579..2cb4f0bdd 100644 --- a/packages/web/docs/core-schemas/programs/tracing.mdx +++ b/packages/web/docs/core-schemas/programs/tracing.mdx @@ -351,6 +351,8 @@ function decodeValue(bytes: Data, type: Type): string { ## Learn more +- [Function context schemas](/spec/program/context/function) for the + invoke, return, and revert specifications - [Instructions documentation](./instructions) for understanding instruction records - [Variables documentation](./variables) for variable structure and lifetime diff --git a/packages/web/spec/program/context/function/function.mdx b/packages/web/spec/program/context/function/function.mdx index 69dcad9d9..e2a8450f0 100644 --- a/packages/web/spec/program/context/function/function.mdx +++ b/packages/web/spec/program/context/function/function.mdx @@ -11,7 +11,11 @@ identity fields for the function being called. All fields are optional, allowing compilers to provide as much or as little detail as available — from a fully named function with source location and type, down to an empty object for an anonymous -indirect call. +indirect call. See +[invoke](/spec/program/context/function/invoke), +[return](/spec/program/context/function/return), and +[revert](/spec/program/context/function/revert) for the +individual context schemas. diff --git a/packages/web/spec/program/context/function/return.mdx b/packages/web/spec/program/context/function/return.mdx index 0010d3fef..daf7547f5 100644 --- a/packages/web/spec/program/context/function/return.mdx +++ b/packages/web/spec/program/context/function/return.mdx @@ -9,7 +9,8 @@ import SchemaViewer from "@site/src/components/SchemaViewer"; A return context marks an instruction associated with a successful function return. It extends the function identity schema with a pointer to the return data and, for external calls, the success -status. +status. It extends the +[function identity](/spec/program/context/function) schema. Date: Wed, 1 Apr 2026 16:37:39 -0400 Subject: [PATCH 19/39] bugc: use format type guards in call context tests (#194) Validate that compiler output satisfies @ethdebug/format type guards (Context.isInvoke, Context.isReturn, Invocation.isInternalCall) instead of casting through Record. Adds a typed findInstructionsWithContext helper that filters by mnemonic and type guard, providing proper type narrowing. --- .../bugc/src/evmgen/call-contexts.test.ts | 159 ++++++++---------- 1 file changed, 74 insertions(+), 85 deletions(-) diff --git a/packages/bugc/src/evmgen/call-contexts.test.ts b/packages/bugc/src/evmgen/call-contexts.test.ts index c0a66d549..3a784a9f3 100644 --- a/packages/bugc/src/evmgen/call-contexts.test.ts +++ b/packages/bugc/src/evmgen/call-contexts.test.ts @@ -2,6 +2,12 @@ import { describe, it, expect } from "vitest"; import { compile } from "#compiler"; import type * as Format from "@ethdebug/format"; +import { Program } from "@ethdebug/format"; + +const { Context } = Program; +const { Invocation } = Context.Invoke; + +type InternalCall = Format.Program.Context.Invoke.Invocation.InternalCall; /** * Compile a BUG source and return the runtime program @@ -24,13 +30,21 @@ async function compileProgram(source: string): Promise { } /** - * Find instructions matching a predicate + * Find instructions with a given mnemonic whose context + * satisfies a type guard */ -function findInstructions( +function findInstructionsWithContext( program: Format.Program, - predicate: (instr: Format.Program.Instruction) => boolean, -): Format.Program.Instruction[] { - return program.instructions.filter(predicate); + mnemonic: string, + guard: (value: unknown) => value is C, +): (Format.Program.Instruction & { context: C })[] { + return program.instructions.filter( + ( + instr, + ): instr is Format.Program.Instruction & { + context: C; + } => instr.operation?.mnemonic === mnemonic && guard(instr.context), + ); } describe("function call debug contexts", () => { @@ -57,30 +71,27 @@ code { it("should emit invoke context on caller JUMP", async () => { const program = await compileProgram(source); - // Find JUMP instructions with invoke context - const invokeJumps = findInstructions( + const invokeJumps = findInstructionsWithContext( program, - (instr) => - instr.operation?.mnemonic === "JUMP" && - !!(instr.context as Record)?.invoke, + "JUMP", + Context.isInvoke, ); expect(invokeJumps.length).toBeGreaterThanOrEqual(1); - const ctx = (invokeJumps[0].context as Record)!; - const invoke = ctx.invoke as Record; + const { invoke } = invokeJumps[0].context; + expect(Invocation.isInternalCall(invoke)).toBe(true); - expect(invoke.jump).toBe(true); - expect(invoke.identifier).toBe("add"); + const call = invoke as InternalCall; + expect(call.jump).toBe(true); + expect(call.identifier).toBe("add"); // Should have target pointer - const target = invoke.target as Record; - expect(target.pointer).toBeDefined(); + expect(call.target.pointer).toBeDefined(); // Should have argument pointers - const args = invoke.arguments as Record; - const pointer = args.pointer as Record; - const group = pointer.group as Array>; + expect(call.arguments).toBeDefined(); + const group = (call.arguments!.pointer as { group: unknown[] }).group; expect(group).toHaveLength(2); // First arg (a) is deepest on stack @@ -98,26 +109,21 @@ code { it("should emit return context on continuation JUMPDEST", async () => { const program = await compileProgram(source); - // Find JUMPDEST instructions with return context - const returnJumpdests = findInstructions( + const returnJumpdests = findInstructionsWithContext( program, - (instr) => - instr.operation?.mnemonic === "JUMPDEST" && - !!(instr.context as Record)?.return, + "JUMPDEST", + Context.isReturn, ); expect(returnJumpdests.length).toBeGreaterThanOrEqual(1); - const ctx = (returnJumpdests[0].context as Record)!; - const ret = ctx.return as Record; + const { return: ret } = returnJumpdests[0].context; expect(ret.identifier).toBe("add"); // Should have data pointer to return value at // TOS (stack slot 0) - const data = ret.data as Record; - const pointer = data.pointer as Record; - expect(pointer).toEqual({ + expect(ret.data.pointer).toEqual({ location: "stack", slot: 0, }); @@ -126,28 +132,26 @@ code { it("should emit invoke context on callee entry JUMPDEST", async () => { const program = await compileProgram(source); - // Find JUMPDEST instructions with invoke context - // (the callee entry point, not the continuation) - const invokeJumpdests = findInstructions( + // The callee entry point, not the continuation + const invokeJumpdests = findInstructionsWithContext( program, - (instr) => - instr.operation?.mnemonic === "JUMPDEST" && - !!(instr.context as Record)?.invoke, + "JUMPDEST", + Context.isInvoke, ); expect(invokeJumpdests.length).toBeGreaterThanOrEqual(1); - const ctx = (invokeJumpdests[0].context as Record)!; - const invoke = ctx.invoke as Record; + const { invoke } = invokeJumpdests[0].context; + expect(Invocation.isInternalCall(invoke)).toBe(true); - expect(invoke.jump).toBe(true); - expect(invoke.identifier).toBe("add"); + const call = invoke as InternalCall; + expect(call.jump).toBe(true); + expect(call.identifier).toBe("add"); // Should have argument pointers matching // function parameters - const args = invoke.arguments as Record; - const pointer = args.pointer as Record; - const group = pointer.group as Array>; + expect(call.arguments).toBeDefined(); + const group = (call.arguments!.pointer as { group: unknown[] }).group; expect(group).toHaveLength(2); }); @@ -157,18 +161,16 @@ code { // The caller JUMP should come before the // continuation JUMPDEST - const invokeJump = findInstructions( + const invokeJump = findInstructionsWithContext( program, - (instr) => - instr.operation?.mnemonic === "JUMP" && - !!(instr.context as Record)?.invoke, + "JUMP", + Context.isInvoke, )[0]; - const returnJumpdest = findInstructions( + const returnJumpdest = findInstructionsWithContext( program, - (instr) => - instr.operation?.mnemonic === "JUMPDEST" && - !!(instr.context as Record)?.return, + "JUMPDEST", + Context.isReturn, )[0]; expect(invokeJump).toBeDefined(); @@ -206,25 +208,20 @@ code { }`; it( - "should emit return context without data pointer " + "for void functions", + "should emit return context with data pointer " + + "for value-returning functions", async () => { - // This tests that when a function returns a - // value, the return context includes data. - // (All our test functions return values, so - // data should always be present here.) const program = await compileProgram(voidSource); - const returnJumpdests = findInstructions( + const returnJumpdests = findInstructionsWithContext( program, - (instr) => - instr.operation?.mnemonic === "JUMPDEST" && - !!(instr.context as Record)?.return, + "JUMPDEST", + Context.isReturn, ); expect(returnJumpdests.length).toBeGreaterThanOrEqual(1); - const ctx = (returnJumpdests[0].context as Record)!; - const ret = ctx.return as Record; + const { return: ret } = returnJumpdests[0].context; expect(ret.identifier).toBe("setVal"); // Since setVal returns a value, data should // be present @@ -271,11 +268,10 @@ code { // 2. addThree -> add (first call) // 3. addThree -> add (second call) // Plus callee entry JUMPDESTs - const invokeJumps = findInstructions( + const invokeJumps = findInstructionsWithContext( program, - (instr) => - instr.operation?.mnemonic === "JUMP" && - !!(instr.context as Record)?.invoke, + "JUMP", + Context.isInvoke, ); // At least 3 invoke JUMPs (main->addThree, @@ -284,24 +280,17 @@ code { // Check we have invokes for both functions const invokeIds = invokeJumps.map( - (instr) => - ( - (instr.context as Record).invoke as Record< - string, - unknown - > - ).identifier, + (instr) => instr.context.invoke.identifier, ); expect(invokeIds).toContain("addThree"); expect(invokeIds).toContain("add"); // Should have return contexts for all // continuation points - const returnJumpdests = findInstructions( + const returnJumpdests = findInstructionsWithContext( program, - (instr) => - instr.operation?.mnemonic === "JUMPDEST" && - !!(instr.context as Record)?.return, + "JUMPDEST", + Context.isReturn, ); expect(returnJumpdests.length).toBeGreaterThanOrEqual(3); @@ -332,20 +321,20 @@ code { it("should emit single-element argument group", async () => { const program = await compileProgram(singleArgSource); - const invokeJumps = findInstructions( + const invokeJumps = findInstructionsWithContext( program, - (instr) => - instr.operation?.mnemonic === "JUMP" && - !!(instr.context as Record)?.invoke, + "JUMP", + Context.isInvoke, ); expect(invokeJumps.length).toBeGreaterThanOrEqual(1); - const ctx = (invokeJumps[0].context as Record)!; - const invoke = ctx.invoke as Record; - const args = invoke.arguments as Record; - const pointer = args.pointer as Record; - const group = pointer.group as Array>; + const { invoke } = invokeJumps[0].context; + expect(Invocation.isInternalCall(invoke)).toBe(true); + + const call = invoke as InternalCall; + expect(call.arguments).toBeDefined(); + const group = (call.arguments!.pointer as { group: unknown[] }).group; // Single arg at stack slot 0 expect(group).toHaveLength(1); From a2cfb355340173948fecfd0c60d5476bb1a994de Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Wed, 1 Apr 2026 16:40:27 -0400 Subject: [PATCH 20/39] bugc: add declaration source ranges and param names to invoke/return contexts (#196) The invoke and return contexts emitted by bugc now include declaration source ranges (pointing to the function definition) and named argument pointers (using parameter names from the function signature). This enriches debug info so debuggers can link call stack entries to source declarations and display meaningful parameter names. Changes: - Add loc/sourceId fields to Ir.Function, populated during IR generation from AST function declarations - Thread module.functions map through EVM codegen so call terminators can look up target function metadata - Build declaration source ranges on invoke contexts (caller JUMP, callee entry JUMPDEST) and return contexts (continuation JUMPDEST) - Add parameter names to argument group pointers - Update call-contexts tests for new fields --- .../bugc/src/evmgen/call-contexts.test.ts | 17 ++++++++++++++ packages/bugc/src/evmgen/generation/block.ts | 14 +++++++++++- .../generation/control-flow/terminator.ts | 22 +++++++++++++++++-- .../bugc/src/evmgen/generation/function.ts | 19 ++++++++++++++-- packages/bugc/src/evmgen/generation/module.ts | 2 ++ packages/bugc/src/ir/spec/function.ts | 4 ++++ packages/bugc/src/irgen/generate/module.ts | 3 +++ 7 files changed, 76 insertions(+), 5 deletions(-) diff --git a/packages/bugc/src/evmgen/call-contexts.test.ts b/packages/bugc/src/evmgen/call-contexts.test.ts index 3a784a9f3..8789a3623 100644 --- a/packages/bugc/src/evmgen/call-contexts.test.ts +++ b/packages/bugc/src/evmgen/call-contexts.test.ts @@ -86,6 +86,15 @@ code { expect(call.jump).toBe(true); expect(call.identifier).toBe("add"); + // Should have declaration source range + const decl = invoke.declaration as Record; + expect(decl).toBeDefined(); + expect(decl.source).toEqual({ id: "0" }); + expect(decl.range).toBeDefined(); + const range = decl.range as Record; + expect(typeof range.offset).toBe("number"); + expect(typeof range.length).toBe("number"); + // Should have target pointer expect(call.target.pointer).toBeDefined(); @@ -96,11 +105,13 @@ code { expect(group).toHaveLength(2); // First arg (a) is deepest on stack expect(group[0]).toEqual({ + name: "a", location: "stack", slot: 1, }); // Second arg (b) is on top expect(group[1]).toEqual({ + name: "b", location: "stack", slot: 0, }); @@ -121,6 +132,11 @@ code { expect(ret.identifier).toBe("add"); + // Should have declaration source range + const retDecl = ret.declaration as Record; + expect(retDecl).toBeDefined(); + expect(retDecl.source).toEqual({ id: "0" }); + // Should have data pointer to return value at // TOS (stack slot 0) expect(ret.data.pointer).toEqual({ @@ -339,6 +355,7 @@ code { // Single arg at stack slot 0 expect(group).toHaveLength(1); expect(group[0]).toEqual({ + name: "x", location: "stack", slot: 0, }); diff --git a/packages/bugc/src/evmgen/generation/block.ts b/packages/bugc/src/evmgen/generation/block.ts index 6b0c2189d..50afbbd99 100644 --- a/packages/bugc/src/evmgen/generation/block.ts +++ b/packages/bugc/src/evmgen/generation/block.ts @@ -29,6 +29,7 @@ export function generate( isFirstBlock: boolean = false, isUserFunction: boolean = false, func?: Ir.Function, + functions?: Map, ): Transition { const { JUMPDEST } = operations; @@ -74,9 +75,18 @@ export function generate( // executes: TOS is the return value (if any). // data pointer is required by the schema; for // void returns, slot 0 is still valid (empty). + const calledFunc = functions?.get(calledFunction); + const declaration = + calledFunc?.loc && calledFunc?.sourceId + ? { + source: { id: calledFunc.sourceId }, + range: calledFunc.loc, + } + : undefined; const returnCtx: Format.Program.Context.Return = { return: { identifier: calledFunction, + ...(declaration ? { declaration } : {}), data: { pointer: { location: "stack" as const, @@ -142,7 +152,9 @@ export function generate( // Process terminator // Handle call terminators specially (they cross function boundaries) if (block.terminator.kind === "call") { - result = result.then(generateCallTerminator(block.terminator)); + result = result.then( + generateCallTerminator(block.terminator, functions), + ); } else { result = result.then( generateTerminator(block.terminator, isLastBlock, isUserFunction), diff --git a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts index 1f418c9b4..377bd1413 100644 --- a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts +++ b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts @@ -147,14 +147,17 @@ export function generateTerminator( } /** - * Generate code for a call terminator - handled specially since it crosses function boundaries + * Generate code for a call terminator - handled specially + * since it crosses function boundaries */ export function generateCallTerminator( term: Extract, + functions?: Map, ): Transition { const funcName = term.function; const args = term.arguments; const cont = term.continuation; + const targetFunc = functions?.get(funcName); return ((state: State): State => { let currentState: State = state as State; @@ -227,11 +230,22 @@ export function generateCallTerminator( // Build argument pointers: after the JUMP, the callee // sees args on the stack in order (first arg deepest). + const params = targetFunc?.parameters; const argPointers = args.map((_arg, i) => ({ + ...(params?.[i]?.name ? { name: params[i].name } : {}), location: "stack" as const, slot: args.length - 1 - i, })); + // Build declaration source range if available + const declaration = + targetFunc?.loc && targetFunc?.sourceId + ? { + source: { id: targetFunc.sourceId }, + range: targetFunc.loc, + } + : undefined; + // Invoke context describes state after JUMP executes: // the callee has been entered with args on the stack. // target points to the function address at stack slot 0 @@ -240,8 +254,12 @@ export function generateCallTerminator( invoke: { jump: true as const, identifier: funcName, + ...(declaration ? { declaration } : {}), target: { - pointer: { location: "stack" as const, slot: 0 }, + pointer: { + location: "stack" as const, + slot: 0, + }, }, ...(argPointers.length > 0 && { arguments: { diff --git a/packages/bugc/src/evmgen/generation/function.ts b/packages/bugc/src/evmgen/generation/function.ts index 70b31d8aa..99142e94d 100644 --- a/packages/bugc/src/evmgen/generation/function.ts +++ b/packages/bugc/src/evmgen/generation/function.ts @@ -31,15 +31,26 @@ function generatePrologue( // Add JUMPDEST with function entry annotation. // After this JUMPDEST executes, the callee's args are // on the stack (first arg deepest). - const argPointers = params.map((_p, i) => ({ + const argPointers = params.map((p, i) => ({ + ...(p.name ? { name: p.name } : {}), location: "stack" as const, slot: params.length - 1 - i, })); + // Build declaration source range if available + const declaration = + func.loc && func.sourceId + ? { + source: { id: func.sourceId }, + range: func.loc, + } + : undefined; + const entryInvoke: Format.Program.Context.Invoke = { invoke: { jump: true as const, identifier: func.name || "anonymous", + ...(declaration ? { declaration } : {}), target: { pointer: { location: "stack" as const, @@ -157,7 +168,10 @@ export function generate( func: Ir.Function, memory: Memory.Function.Info, layout: Layout.Function.Info, - options: { isUserFunction?: boolean } = {}, + options: { + isUserFunction?: boolean; + functions?: Map; + } = {}, ): { instructions: Evm.Instruction[]; bytecode: number[]; @@ -205,6 +219,7 @@ export function generate( isFirstBlock, options.isUserFunction || false, func, + options.functions, )(state); }, stateAfterPrologue, diff --git a/packages/bugc/src/evmgen/generation/module.ts b/packages/bugc/src/evmgen/generation/module.ts index 92c306f8a..1b309924f 100644 --- a/packages/bugc/src/evmgen/generation/module.ts +++ b/packages/bugc/src/evmgen/generation/module.ts @@ -32,6 +32,7 @@ export function generate( module.main, memory.main, blocks.main, + { functions: module.functions }, ); // Collect all warnings @@ -53,6 +54,7 @@ export function generate( if (funcMemory && funcLayout) { const funcResult = Function.generate(func, funcMemory, funcLayout, { isUserFunction: true, + functions: module.functions, }); functionResults.push({ name, diff --git a/packages/bugc/src/ir/spec/function.ts b/packages/bugc/src/ir/spec/function.ts index df58c6ac8..93d72d8c8 100644 --- a/packages/bugc/src/ir/spec/function.ts +++ b/packages/bugc/src/ir/spec/function.ts @@ -17,6 +17,10 @@ export interface Function { blocks: Map; /** SSA variable metadata mapping temp IDs to original variables */ ssaVariables?: Map; + /** Source location of the function declaration */ + loc?: Ast.SourceLocation; + /** Source ID for debug info (inherited from module) */ + sourceId?: string; } export namespace Function { diff --git a/packages/bugc/src/irgen/generate/module.ts b/packages/bugc/src/irgen/generate/module.ts index dcc41e778..dd3fd0ea3 100644 --- a/packages/bugc/src/irgen/generate/module.ts +++ b/packages/bugc/src/irgen/generate/module.ts @@ -72,6 +72,9 @@ export function* buildModule( buildFunction(funcDecl.name, parameters, funcDecl.body), ); if (func) { + const moduleState = yield* Process.Modules.current(); + func.loc = funcDecl.loc ?? undefined; + func.sourceId = moduleState.sourceId; yield* Process.Functions.addToModule(funcDecl.name, func); } } From 67edefe8dc769277b20a26cde79649b0e79e3836 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Wed, 11 Mar 2026 05:10:14 -0400 Subject: [PATCH 21/39] bugc: add debug contexts to all unmapped bytecodes (#190) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * bugc: add debug contexts to all remaining unmapped bytecodes Thread remark/code contexts through all compiler-generated instructions that previously lacked debug info: - Free memory pointer initialization (remark) - Return value spill after call continuation (call expr source range) - STOP guard between main and user functions (remark) - Function prologue MSTORE for param storage (thread existing remark) - Function prologue return PC save sequence (thread existing remark) - Deployment wrapper CODECOPY+RETURN (remark) All 82 instructions across runtime and create programs now carry debug contexts (previously 22 were unmapped). * bugc: add code contexts with source ranges to compiler-generated instructions Add source location info (loc, sourceId) to Ir.Function so EVM codegen can build code contexts for compiler-generated instructions. Instructions that map to a source location now use gather contexts combining both a remark (for debugger tooling) and a code context (for source highlighting): - Free memory pointer init → code block / create block range - Function prologue (param stores, return PC save) → function decl range - STOP guard → code block range Deployment wrapper remains remark-only (no corresponding source). Return value spill already had correct source mapping (call expr). --- packages/bugc/src/evmgen/generation/block.ts | 71 ++++++++++++++---- .../bugc/src/evmgen/generation/function.ts | 73 +++++++++++++++---- packages/bugc/src/evmgen/generation/module.ts | 50 +++++++++++-- packages/bugc/src/irgen/generate/function.ts | 4 + 4 files changed, 161 insertions(+), 37 deletions(-) diff --git a/packages/bugc/src/evmgen/generation/block.ts b/packages/bugc/src/evmgen/generation/block.ts index 50afbbd99..da19fae46 100644 --- a/packages/bugc/src/evmgen/generation/block.ts +++ b/packages/bugc/src/evmgen/generation/block.ts @@ -2,6 +2,7 @@ * Block-level code generation */ +import type * as Ast from "#ast"; import type * as Format from "@ethdebug/format"; import * as Ir from "#ir"; import type { Stack } from "#evm"; @@ -48,9 +49,13 @@ export function generate( // Initialize memory for first block if (isFirstBlock) { - // Always initialize the free memory pointer for consistency - // This ensures dynamic allocations start after static ones - result = result.then(initializeMemory(state.memory.nextStaticOffset)); + const sourceInfo = + func?.sourceId && func?.loc + ? { sourceId: func.sourceId, loc: func.loc } + : undefined; + result = result.then( + initializeMemory(state.memory.nextStaticOffset, sourceInfo), + ); } // Set JUMPDEST for non-first blocks @@ -114,6 +119,7 @@ export function generate( predBlock.terminator.dest ) { const destId = predBlock.terminator.dest; + const spillDebug = predBlock.terminator.operationDebug; result = result.then(annotateTop(destId)).then((s) => { const allocation = s.memory.allocations[destId]; if (!allocation) return s; @@ -122,7 +128,11 @@ export function generate( ...s, instructions: [ ...s.instructions, - { mnemonic: "DUP1" as const, opcode: 0x80 }, + { + mnemonic: "DUP1" as const, + opcode: 0x80, + debug: spillDebug, + }, { mnemonic: "PUSH2" as const, opcode: 0x61, @@ -130,8 +140,13 @@ export function generate( (allocation.offset >> 8) & 0xff, allocation.offset & 0xff, ], + debug: spillDebug, + }, + { + mnemonic: "MSTORE" as const, + opcode: 0x52, + debug: spillDebug, }, - { mnemonic: "MSTORE" as const, opcode: 0x52 }, ], }; }); @@ -217,21 +232,45 @@ function generatePhi( /** * Initialize the free memory pointer at runtime - * Sets the value at 0x40 to the next available memory location after static allocations + * Sets the value at 0x40 to the next available memory location + * after static allocations */ function initializeMemory( nextStaticOffset: number, + sourceInfo?: { sourceId: string; loc: Ast.SourceLocation }, ): Transition { const { PUSHn, MSTORE } = operations; - return ( - pipe() - // Push the static offset value (the value to store) - .then(PUSHn(BigInt(nextStaticOffset)), { as: "value" }) - // Push the free memory pointer location (0x40) (the offset) - .then(PUSHn(BigInt(Memory.regions.FREE_MEMORY_POINTER)), { as: "offset" }) - // Store the initial free pointer (expects [value, offset] on stack) - .then(MSTORE()) - .done() - ); + const debug = sourceInfo + ? { + context: { + gather: [ + { remark: "initialize free memory pointer" }, + { + code: { + source: { id: sourceInfo.sourceId }, + range: sourceInfo.loc, + }, + }, + ], + } as Format.Program.Context, + } + : { + context: { + remark: "initialize free memory pointer", + } as Format.Program.Context, + }; + + return pipe() + .then(PUSHn(BigInt(nextStaticOffset), { debug }), { + as: "value", + }) + .then( + PUSHn(BigInt(Memory.regions.FREE_MEMORY_POINTER), { + debug, + }), + { as: "offset" }, + ) + .then(MSTORE({ debug })) + .done(); } diff --git a/packages/bugc/src/evmgen/generation/function.ts b/packages/bugc/src/evmgen/generation/function.ts index 99142e94d..fa73325cf 100644 --- a/packages/bugc/src/evmgen/generation/function.ts +++ b/packages/bugc/src/evmgen/generation/function.ts @@ -82,11 +82,28 @@ function generatePrologue( // Return PC is already in memory at 0x60 (stored by caller) // Pop and store each arg from argN down to arg0 - const prologueDebug = { - context: { - remark: `prologue: store ${params.length} parameter(s) to memory`, - }, - }; + const prologueDebug = + func.sourceId && func.loc + ? { + context: { + gather: [ + { + remark: `prologue: store ${params.length} parameter(s) to memory`, + }, + { + code: { + source: { id: func.sourceId }, + range: func.loc, + }, + }, + ], + } as Format.Program.Context, + } + : { + context: { + remark: `prologue: store ${params.length} parameter(s) to memory`, + } as Format.Program.Context, + }; for (let i = params.length - 1; i >= 0; i--) { const param = params[i]; @@ -115,7 +132,11 @@ function generatePrologue( ...currentState, instructions: [ ...currentState.instructions, - { mnemonic: "MSTORE", opcode: 0x52 }, + { + mnemonic: "MSTORE", + opcode: 0x52, + debug: prologueDebug, + }, ], }; } @@ -124,11 +145,28 @@ function generatePrologue( // so nested function calls don't clobber it. const savedPcOffset = currentState.memory.savedReturnPcOffset; if (savedPcOffset !== undefined) { - const savePcDebug = { - context: { - remark: `prologue: save return PC to 0x${savedPcOffset.toString(16)}`, - }, - }; + const savePcDebug = + func.sourceId && func.loc + ? { + context: { + gather: [ + { + remark: `prologue: save return PC to 0x${savedPcOffset.toString(16)}`, + }, + { + code: { + source: { id: func.sourceId }, + range: func.loc, + }, + }, + ], + } as Format.Program.Context, + } + : { + context: { + remark: `prologue: save return PC to 0x${savedPcOffset.toString(16)}`, + } as Format.Program.Context, + }; const highByte = (savedPcOffset >> 8) & 0xff; const lowByte = savedPcOffset & 0xff; currentState = { @@ -141,13 +179,22 @@ function generatePrologue( immediates: [0x60], debug: savePcDebug, }, - { mnemonic: "MLOAD", opcode: 0x51 }, + { + mnemonic: "MLOAD", + opcode: 0x51, + debug: savePcDebug, + }, { mnemonic: "PUSH2", opcode: 0x61, immediates: [highByte, lowByte], + debug: savePcDebug, + }, + { + mnemonic: "MSTORE", + opcode: 0x52, + debug: savePcDebug, }, - { mnemonic: "MSTORE", opcode: 0x52 }, ], }; } diff --git a/packages/bugc/src/evmgen/generation/module.ts b/packages/bugc/src/evmgen/generation/module.ts index 1b309924f..b5b5e014b 100644 --- a/packages/bugc/src/evmgen/generation/module.ts +++ b/packages/bugc/src/evmgen/generation/module.ts @@ -107,9 +107,37 @@ export function generate( // Insert STOP between main and user functions to prevent // fall-through when the main function's last block omits // STOP (the isLastBlock optimization). + const stopGuardDebug = + module.main.sourceId && module.main.loc + ? { + context: { + gather: [ + { + remark: "guard: prevent fall-through into functions", + }, + { + code: { + source: { id: module.main.sourceId }, + range: module.main.loc, + }, + }, + ], + }, + } + : { + context: { + remark: "guard: prevent fall-through into functions", + }, + }; const stopGuard: Evm.Instruction[] = patchedFunctions.length > 0 - ? [{ mnemonic: "STOP" as const, opcode: 0x00 }] + ? [ + { + mnemonic: "STOP" as const, + opcode: 0x00, + debug: stopGuardDebug, + }, + ] : []; const stopGuardBytes: number[] = patchedFunctions.length > 0 ? [0x00] : []; @@ -245,13 +273,19 @@ function buildDeploymentInstructions( function deploymentTransition(runtimeOffset: bigint, runtimeLength: bigint) { const { PUSHn, CODECOPY, RETURN } = operations; + const debug = { + context: { + remark: "deployment: copy runtime bytecode and return", + }, + }; + return pipe() - .then(PUSHn(runtimeLength), { as: "size" }) - .then(PUSHn(runtimeOffset), { as: "offset" }) - .then(PUSHn(0n), { as: "destOffset" }) - .then(CODECOPY()) - .then(PUSHn(runtimeLength), { as: "size" }) - .then(PUSHn(0n), { as: "offset" }) - .then(RETURN()) + .then(PUSHn(runtimeLength, { debug }), { as: "size" }) + .then(PUSHn(runtimeOffset, { debug }), { as: "offset" }) + .then(PUSHn(0n, { debug }), { as: "destOffset" }) + .then(CODECOPY({ debug })) + .then(PUSHn(runtimeLength, { debug }), { as: "size" }) + .then(PUSHn(0n, { debug }), { as: "offset" }) + .then(RETURN({ debug })) .done(); } diff --git a/packages/bugc/src/irgen/generate/function.ts b/packages/bugc/src/irgen/generate/function.ts index 7e39aacfd..59e152768 100644 --- a/packages/bugc/src/irgen/generate/function.ts +++ b/packages/bugc/src/irgen/generate/function.ts @@ -46,12 +46,16 @@ export function* buildFunction( // Collect SSA variable metadata const ssaVariables = yield* Process.Functions.collectSsaMetadata(); + const module_ = yield* Process.Modules.current(); + const function_: Ir.Function = { name, parameters: params, entry: "entry", blocks, ssaVariables: ssaVariables.size > 0 ? ssaVariables : undefined, + loc: body.loc ?? undefined, + sourceId: module_.sourceId, }; return function_; From 83005d6aec09ec79e91014cc615989b01ea09275 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Wed, 11 Mar 2026 05:21:17 -0400 Subject: [PATCH 22/39] Add call stack breadcrumb and call info panel (#189) * Add call stack breadcrumb and call info panel components Surface invoke/return/revert context information in the trace viewer: a breadcrumb showing the current call stack, and a panel showing call details with async-resolved pointer ref values. New components: CallStackDisplay, CallInfoPanel New utilities: extractCallInfoFromInstruction, buildCallStack, buildPcToInstructionMap New types: CallInfo, CallFrame, ResolvedCallInfo, ResolvedPointerRef * Integrate CallStackDisplay and CallInfoPanel into TraceViewer The components were exported from programs-react but never rendered in the web package's TraceViewer. Add them to the layout: call stack breadcrumb in the header, call info panel at the top of the right sidebar. * Add call stack breadcrumb and call info banner to TraceDrawer The deploy preview uses TraceDrawer (not TraceViewer) for the interactive trace playground. Add call context display directly to TraceDrawer: a breadcrumb bar showing nested call frames with clickable navigation, and a colored banner showing invoke/return/revert status at the current step. * Show always-visible call stack and fix duplicate frame bug - Call stack bar now always visible with "(top level)" empty state so users know the feature exists - Fix duplicate call stack frames: compiler emits invoke context on both the caller JUMP and callee entry JUMPDEST, so skip push if top frame already matches the same call - Applied fix to both TraceDrawer and programs-react utility --- .../bugc/src/evmgen/call-contexts.test.ts | 17 +- .../src/components/CallInfoPanel.css | 85 ++++++++ .../src/components/CallInfoPanel.tsx | 131 ++++++++++++ .../src/components/CallStackDisplay.css | 46 +++++ .../src/components/CallStackDisplay.tsx | 62 ++++++ .../src/components/TraceContext.tsx | 127 ++++++++++++ .../programs-react/src/components/index.ts | 9 + packages/programs-react/src/index.ts | 12 ++ packages/programs-react/src/utils/index.ts | 4 + .../programs-react/src/utils/mockTrace.ts | 189 ++++++++++++++++++ .../theme/ProgramExample/CallInfoPanel.css | 85 ++++++++ .../theme/ProgramExample/CallStackDisplay.css | 46 +++++ .../src/theme/ProgramExample/TraceDrawer.css | 77 +++++++ .../src/theme/ProgramExample/TraceDrawer.tsx | 170 ++++++++++++++++ .../src/theme/ProgramExample/TraceViewer.tsx | 7 + .../web/src/theme/ProgramExample/index.ts | 10 + 16 files changed, 1067 insertions(+), 10 deletions(-) create mode 100644 packages/programs-react/src/components/CallInfoPanel.css create mode 100644 packages/programs-react/src/components/CallInfoPanel.tsx create mode 100644 packages/programs-react/src/components/CallStackDisplay.css create mode 100644 packages/programs-react/src/components/CallStackDisplay.tsx create mode 100644 packages/web/src/theme/ProgramExample/CallInfoPanel.css create mode 100644 packages/web/src/theme/ProgramExample/CallStackDisplay.css diff --git a/packages/bugc/src/evmgen/call-contexts.test.ts b/packages/bugc/src/evmgen/call-contexts.test.ts index 8789a3623..7ab7478e0 100644 --- a/packages/bugc/src/evmgen/call-contexts.test.ts +++ b/packages/bugc/src/evmgen/call-contexts.test.ts @@ -87,13 +87,11 @@ code { expect(call.identifier).toBe("add"); // Should have declaration source range - const decl = invoke.declaration as Record; - expect(decl).toBeDefined(); - expect(decl.source).toEqual({ id: "0" }); - expect(decl.range).toBeDefined(); - const range = decl.range as Record; - expect(typeof range.offset).toBe("number"); - expect(typeof range.length).toBe("number"); + expect(invoke.declaration).toBeDefined(); + expect(invoke.declaration!.source).toEqual({ id: "0" }); + expect(invoke.declaration!.range).toBeDefined(); + expect(typeof invoke.declaration!.range!.offset).toBe("number"); + expect(typeof invoke.declaration!.range!.length).toBe("number"); // Should have target pointer expect(call.target.pointer).toBeDefined(); @@ -133,9 +131,8 @@ code { expect(ret.identifier).toBe("add"); // Should have declaration source range - const retDecl = ret.declaration as Record; - expect(retDecl).toBeDefined(); - expect(retDecl.source).toEqual({ id: "0" }); + expect(ret.declaration).toBeDefined(); + expect(ret.declaration!.source).toEqual({ id: "0" }); // Should have data pointer to return value at // TOS (stack slot 0) diff --git a/packages/programs-react/src/components/CallInfoPanel.css b/packages/programs-react/src/components/CallInfoPanel.css new file mode 100644 index 000000000..1adfe5dcc --- /dev/null +++ b/packages/programs-react/src/components/CallInfoPanel.css @@ -0,0 +1,85 @@ +.call-info-panel { + font-size: 0.9em; +} + +.call-info-banner { + padding: 6px 10px; + border-radius: 4px; + font-weight: 500; + margin-bottom: 6px; +} + +.call-info-banner-invoke { + background: var(--programs-invoke-bg, #e8f4fd); + color: var(--programs-invoke-text, #0969da); + border-left: 3px solid var(--programs-invoke-accent, #0969da); +} + +.call-info-banner-return { + background: var(--programs-return-bg, #dafbe1); + color: var(--programs-return-text, #1a7f37); + border-left: 3px solid var(--programs-return-accent, #1a7f37); +} + +.call-info-banner-revert { + background: var(--programs-revert-bg, #ffebe9); + color: var(--programs-revert-text, #cf222e); + border-left: 3px solid var(--programs-revert-accent, #cf222e); +} + +.call-info-refs { + display: flex; + flex-direction: column; + gap: 4px; + padding: 4px 0; +} + +.call-info-ref { + display: flex; + align-items: baseline; + gap: 6px; + padding: 2px 0; +} + +.call-info-ref-label { + font-weight: 500; + color: var(--programs-text-muted, #888); + min-width: 80px; + text-align: right; + flex-shrink: 0; +} + +.call-info-ref-resolved { + font-family: monospace; + font-size: 0.9em; + word-break: break-all; +} + +.call-info-ref-error { + color: var(--programs-error, #cf222e); + font-style: italic; +} + +.call-info-ref-pending { + color: var(--programs-text-muted, #888); + font-style: italic; +} + +.call-info-ref-pointer { + margin-top: 2px; +} + +.call-info-ref-pointer summary { + cursor: pointer; + color: var(--programs-text-muted, #888); + font-size: 0.85em; +} + +.call-info-ref-pointer-json { + font-size: 0.8em; + padding: 4px 8px; + background: var(--programs-bg-code, #f6f8fa); + border-radius: 3px; + overflow-x: auto; + max-height: 200px; +} diff --git a/packages/programs-react/src/components/CallInfoPanel.tsx b/packages/programs-react/src/components/CallInfoPanel.tsx new file mode 100644 index 000000000..b18e58d78 --- /dev/null +++ b/packages/programs-react/src/components/CallInfoPanel.tsx @@ -0,0 +1,131 @@ +/** + * Panel showing call context info for the current instruction. + * + * Displays a banner for invoke/return/revert events and + * lists resolved pointer ref values (arguments, return + * data, etc.). + */ + +import React from "react"; +import { + useTraceContext, + type ResolvedCallInfo, + type ResolvedPointerRef, +} from "./TraceContext.js"; + +// CSS is expected to be imported by the consuming application +// import "./CallInfoPanel.css"; + +export interface CallInfoPanelProps { + /** Whether to show raw pointer JSON */ + showPointers?: boolean; + /** Custom class name */ + className?: string; +} + +function formatBanner(info: ResolvedCallInfo): string { + const name = info.identifier || "(anonymous)"; + + if (info.kind === "invoke") { + const prefix = + info.callType === "external" + ? "Calling (external)" + : info.callType === "create" + ? "Creating" + : "Calling"; + return `${prefix} ${name}()`; + } + + if (info.kind === "return") { + return `Returned from ${name}()`; + } + + // revert + if (info.panic !== undefined) { + return `Reverted: panic 0x${info.panic.toString(16)}`; + } + return `Reverted in ${name}()`; +} + +function bannerClassName(kind: ResolvedCallInfo["kind"]): string { + if (kind === "invoke") { + return "call-info-banner-invoke"; + } + if (kind === "return") { + return "call-info-banner-return"; + } + return "call-info-banner-revert"; +} + +/** + * Shows call context info when the current instruction + * has an invoke, return, or revert context. + */ +export function CallInfoPanel({ + showPointers = false, + className = "", +}: CallInfoPanelProps): JSX.Element | null { + const { currentCallInfo } = useTraceContext(); + + if (!currentCallInfo) { + return null; + } + + return ( +
+
+ {formatBanner(currentCallInfo)} +
+ + {currentCallInfo.pointerRefs.length > 0 && ( +
+ {currentCallInfo.pointerRefs.map((ref) => ( + + ))} +
+ )} +
+ ); +} + +interface PointerRefItemProps { + ref_: ResolvedPointerRef; + showPointer: boolean; +} + +function PointerRefItem({ + ref_, + showPointer, +}: PointerRefItemProps): JSX.Element { + return ( +
+ {ref_.label}: + + {ref_.error ? ( + + Error: {ref_.error} + + ) : ref_.value !== undefined ? ( + {ref_.value} + ) : ( + (resolving...) + )} + + + {showPointer && !!ref_.pointer && ( +
+ Pointer +
+            {JSON.stringify(ref_.pointer, null, 2)}
+          
+
+ )} +
+ ); +} diff --git a/packages/programs-react/src/components/CallStackDisplay.css b/packages/programs-react/src/components/CallStackDisplay.css new file mode 100644 index 000000000..5d928c292 --- /dev/null +++ b/packages/programs-react/src/components/CallStackDisplay.css @@ -0,0 +1,46 @@ +.call-stack { + font-size: 0.85em; + padding: 4px 8px; +} + +.call-stack-empty { + color: var(--programs-text-muted, #888); +} + +.call-stack-breadcrumb { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 2px; +} + +.call-stack-separator { + color: var(--programs-text-muted, #888); + user-select: none; +} + +.call-stack-frame { + display: inline-flex; + align-items: center; + background: none; + border: 1px solid transparent; + border-radius: 3px; + padding: 1px 4px; + cursor: pointer; + font-family: inherit; + font-size: inherit; + color: var(--programs-link, #0366d6); +} + +.call-stack-frame:hover { + background: var(--programs-bg-hover, rgba(0, 0, 0, 0.05)); + border-color: var(--programs-border, #ddd); +} + +.call-stack-name { + font-weight: 500; +} + +.call-stack-parens { + color: var(--programs-text-muted, #888); +} diff --git a/packages/programs-react/src/components/CallStackDisplay.tsx b/packages/programs-react/src/components/CallStackDisplay.tsx new file mode 100644 index 000000000..fbc55c994 --- /dev/null +++ b/packages/programs-react/src/components/CallStackDisplay.tsx @@ -0,0 +1,62 @@ +/** + * Displays the current call stack as a breadcrumb trail. + */ + +import React from "react"; +import { useTraceContext } from "./TraceContext.js"; + +// CSS is expected to be imported by the consuming application +// import "./CallStackDisplay.css"; + +export interface CallStackDisplayProps { + /** Custom class name */ + className?: string; +} + +/** + * Renders the call stack as a breadcrumb. + * + * Shows function names separated by arrows, e.g.: + * main() -> transfer() -> _update() + */ +export function CallStackDisplay({ + className = "", +}: CallStackDisplayProps): JSX.Element { + const { callStack, jumpToStep } = useTraceContext(); + + if (callStack.length === 0) { + return ( +
+ (top level) +
+ ); + } + + return ( +
+
+ {callStack.map((frame, index) => ( + + {index > 0 && ( + {" -> "} + )} + + + ))} +
+
+ ); +} diff --git a/packages/programs-react/src/components/TraceContext.tsx b/packages/programs-react/src/components/TraceContext.tsx index c21874722..4a4ef9d4c 100644 --- a/packages/programs-react/src/components/TraceContext.tsx +++ b/packages/programs-react/src/components/TraceContext.tsx @@ -14,8 +14,12 @@ import type { Pointer, Program } from "@ethdebug/format"; import { dereference, Data } from "@ethdebug/pointers"; import { type TraceStep, + type CallInfo, + type CallFrame, extractVariablesFromInstruction, + extractCallInfoFromInstruction, buildPcToInstructionMap, + buildCallStack, } from "#utils/mockTrace"; import { traceStepToMachineState } from "#utils/traceState"; @@ -35,6 +39,36 @@ export interface ResolvedVariable { error?: string; } +/** + * A resolved pointer ref with its label and value. + */ +export interface ResolvedPointerRef { + /** Label for this pointer (e.g., "target", "arguments") */ + label: string; + /** The raw pointer */ + pointer: unknown; + /** Resolved hex value */ + value?: string; + /** Error if resolution failed */ + error?: string; +} + +/** + * Call info with resolved pointer values. + */ +export interface ResolvedCallInfo { + /** The kind of call event */ + kind: "invoke" | "return" | "revert"; + /** Function name */ + identifier?: string; + /** Call variant for invoke contexts */ + callType?: "internal" | "external" | "create"; + /** Panic code for revert contexts */ + panic?: number; + /** Resolved pointer refs */ + pointerRefs: ResolvedPointerRef[]; +} + /** * State provided by the Trace context. */ @@ -53,6 +87,10 @@ export interface TraceState { currentInstruction: Program.Instruction | undefined; /** Variables in scope at current step */ currentVariables: ResolvedVariable[]; + /** Call stack at current step */ + callStack: CallFrame[]; + /** Call info for current instruction (if any) */ + currentCallInfo: ResolvedCallInfo | undefined; /** Whether we're at the first step */ isAtStart: boolean; /** Whether we're at the last step */ @@ -241,6 +279,93 @@ export function TraceProvider({ }; }, [extractedVars, currentStep, shouldResolve, templates]); + // Build call stack by scanning instructions up to current step + const callStack = useMemo( + () => buildCallStack(trace, pcToInstruction, currentStepIndex), + [trace, pcToInstruction, currentStepIndex], + ); + + // Extract call info for current instruction (synchronous) + const extractedCallInfo = useMemo((): CallInfo | undefined => { + if (!currentInstruction) { + return undefined; + } + return extractCallInfoFromInstruction(currentInstruction); + }, [currentInstruction]); + + // Async call info pointer resolution + const [currentCallInfo, setCurrentCallInfo] = useState< + ResolvedCallInfo | undefined + >(undefined); + + useEffect(() => { + if (!extractedCallInfo) { + setCurrentCallInfo(undefined); + return; + } + + // Immediately show call info without resolved values + const initial: ResolvedCallInfo = { + kind: extractedCallInfo.kind, + identifier: extractedCallInfo.identifier, + callType: extractedCallInfo.callType, + panic: extractedCallInfo.panic, + pointerRefs: extractedCallInfo.pointerRefs.map((ref) => ({ + label: ref.label, + pointer: ref.pointer, + value: undefined, + error: undefined, + })), + }; + setCurrentCallInfo(initial); + + if (!shouldResolve || !currentStep) { + return; + } + + let cancelled = false; + const resolved = [...initial.pointerRefs]; + + const promises = extractedCallInfo.pointerRefs.map(async (ref, index) => { + try { + const value = await resolveVariableValue( + ref.pointer as Pointer, + currentStep, + templates, + ); + if (!cancelled) { + resolved[index] = { + ...resolved[index], + value, + }; + setCurrentCallInfo({ + ...initial, + pointerRefs: [...resolved], + }); + } + } catch (err) { + if (!cancelled) { + resolved[index] = { + ...resolved[index], + error: err instanceof Error ? err.message : String(err), + }; + setCurrentCallInfo({ + ...initial, + pointerRefs: [...resolved], + }); + } + } + }); + + Promise.all(promises).catch(() => { + // Individual errors already handled + }); + + return () => { + cancelled = true; + }; + }, [extractedCallInfo, currentStep, shouldResolve, templates]); + const stepForward = useCallback(() => { setCurrentStepIndex((prev) => Math.min(prev + 1, trace.length - 1)); }, [trace.length]); @@ -272,6 +397,8 @@ export function TraceProvider({ currentStep, currentInstruction, currentVariables, + callStack, + currentCallInfo, isAtStart: currentStepIndex === 0, isAtEnd: currentStepIndex >= trace.length - 1, stepForward, diff --git a/packages/programs-react/src/components/index.ts b/packages/programs-react/src/components/index.ts index 886c22d62..222acf051 100644 --- a/packages/programs-react/src/components/index.ts +++ b/packages/programs-react/src/components/index.ts @@ -22,6 +22,8 @@ export { type TraceState, type TraceProviderProps, type ResolvedVariable, + type ResolvedCallInfo, + type ResolvedPointerRef, } from "./TraceContext.js"; export { @@ -37,3 +39,10 @@ export { type VariableInspectorProps, type StackInspectorProps, } from "./VariableInspector.js"; + +export { + CallStackDisplay, + type CallStackDisplayProps, +} from "./CallStackDisplay.js"; + +export { CallInfoPanel, type CallInfoPanelProps } from "./CallInfoPanel.js"; diff --git a/packages/programs-react/src/index.ts b/packages/programs-react/src/index.ts index 599e8ff1c..2fa17ad36 100644 --- a/packages/programs-react/src/index.ts +++ b/packages/programs-react/src/index.ts @@ -26,13 +26,19 @@ export { TraceProgress, VariableInspector, StackInspector, + CallStackDisplay, + CallInfoPanel, type TraceState, type TraceProviderProps, type ResolvedVariable, + type ResolvedCallInfo, + type ResolvedPointerRef, type TraceControlsProps, type TraceProgressProps, type VariableInspectorProps, type StackInspectorProps, + type CallStackDisplayProps, + type CallInfoPanelProps, } from "#components/index"; // Shiki utilities @@ -51,7 +57,11 @@ export { createMockTrace, findInstructionAtPc, extractVariablesFromInstruction, + extractCallInfoFromInstruction, buildPcToInstructionMap, + buildCallStack, + type CallInfo, + type CallFrame, type DynamicInstruction, type DynamicContext, type ContextThunk, @@ -67,3 +77,5 @@ export { // import "@ethdebug/programs-react/components/SourceContents.css"; // import "@ethdebug/programs-react/components/TraceControls.css"; // import "@ethdebug/programs-react/components/VariableInspector.css"; +// import "@ethdebug/programs-react/components/CallStackDisplay.css"; +// import "@ethdebug/programs-react/components/CallInfoPanel.css"; diff --git a/packages/programs-react/src/utils/index.ts b/packages/programs-react/src/utils/index.ts index 5f750e9a0..a79f07b08 100644 --- a/packages/programs-react/src/utils/index.ts +++ b/packages/programs-react/src/utils/index.ts @@ -17,9 +17,13 @@ export { createMockTrace, findInstructionAtPc, extractVariablesFromInstruction, + extractCallInfoFromInstruction, buildPcToInstructionMap, + buildCallStack, type TraceStep, type MockTraceSpec, + type CallInfo, + type CallFrame, } from "./mockTrace.js"; export { traceStepToMachineState } from "./traceState.js"; diff --git a/packages/programs-react/src/utils/mockTrace.ts b/packages/programs-react/src/utils/mockTrace.ts index 6adef9e05..f3234e735 100644 --- a/packages/programs-react/src/utils/mockTrace.ts +++ b/packages/programs-react/src/utils/mockTrace.ts @@ -100,6 +100,195 @@ function extractVariablesFromContext( return []; } +/** + * Info about a function call context on an instruction. + */ +export interface CallInfo { + /** The kind of call event */ + kind: "invoke" | "return" | "revert"; + /** Function name (from Function.Identity) */ + identifier?: string; + /** Call variant for invoke contexts */ + callType?: "internal" | "external" | "create"; + /** Panic code for revert contexts */ + panic?: number; + /** Named pointer refs to resolve */ + pointerRefs: Array<{ + label: string; + pointer: unknown; + }>; +} + +/** + * Extract call info (invoke/return/revert) from an + * instruction's context tree. + */ +export function extractCallInfoFromInstruction( + instruction: Program.Instruction, +): CallInfo | undefined { + if (!instruction.context) { + return undefined; + } + return extractCallInfoFromContext(instruction.context); +} + +function extractCallInfoFromContext( + context: Program.Context, +): CallInfo | undefined { + // Use unknown intermediate to avoid strict type checks + // on the context union — we discriminate by key presence + const ctx = context as unknown as Record; + + if ("invoke" in ctx) { + const inv = ctx.invoke as Record; + const pointerRefs: CallInfo["pointerRefs"] = []; + + let callType: CallInfo["callType"]; + if ("jump" in inv) { + callType = "internal"; + collectPointerRef(pointerRefs, "target", inv.target); + collectPointerRef(pointerRefs, "arguments", inv.arguments); + } else if ("message" in inv) { + callType = "external"; + collectPointerRef(pointerRefs, "target", inv.target); + collectPointerRef(pointerRefs, "gas", inv.gas); + collectPointerRef(pointerRefs, "value", inv.value); + collectPointerRef(pointerRefs, "input", inv.input); + } else if ("create" in inv) { + callType = "create"; + collectPointerRef(pointerRefs, "value", inv.value); + collectPointerRef(pointerRefs, "salt", inv.salt); + collectPointerRef(pointerRefs, "input", inv.input); + } + + return { + kind: "invoke", + identifier: inv.identifier as string | undefined, + callType, + pointerRefs, + }; + } + + if ("return" in ctx) { + const ret = ctx.return as Record; + const pointerRefs: CallInfo["pointerRefs"] = []; + collectPointerRef(pointerRefs, "data", ret.data); + collectPointerRef(pointerRefs, "success", ret.success); + + return { + kind: "return", + identifier: ret.identifier as string | undefined, + pointerRefs, + }; + } + + if ("revert" in ctx) { + const rev = ctx.revert as Record; + const pointerRefs: CallInfo["pointerRefs"] = []; + collectPointerRef(pointerRefs, "reason", rev.reason); + + return { + kind: "revert", + identifier: rev.identifier as string | undefined, + panic: rev.panic as number | undefined, + pointerRefs, + }; + } + + // Walk gather/pick to find call info + if ("gather" in ctx && Array.isArray(ctx.gather)) { + for (const sub of ctx.gather as Program.Context[]) { + const info = extractCallInfoFromContext(sub); + if (info) { + return info; + } + } + } + + if ("pick" in ctx && Array.isArray(ctx.pick)) { + for (const sub of ctx.pick as Program.Context[]) { + const info = extractCallInfoFromContext(sub); + if (info) { + return info; + } + } + } + + return undefined; +} + +function collectPointerRef( + refs: CallInfo["pointerRefs"], + label: string, + value: unknown, +): void { + if (value && typeof value === "object" && "pointer" in value) { + refs.push({ label, pointer: (value as { pointer: unknown }).pointer }); + } +} + +/** + * A frame in the call stack. + */ +export interface CallFrame { + /** Function name */ + identifier?: string; + /** The step index where this call was invoked */ + stepIndex: number; + /** The call type */ + callType?: "internal" | "external" | "create"; +} + +/** + * Build a call stack by scanning instructions from + * step 0 to the given step index. + */ +export function buildCallStack( + trace: TraceStep[], + pcToInstruction: Map, + upToStep: number, +): CallFrame[] { + const stack: CallFrame[] = []; + + for (let i = 0; i <= upToStep && i < trace.length; i++) { + const step = trace[i]; + const instruction = pcToInstruction.get(step.pc); + if (!instruction) { + continue; + } + + const callInfo = extractCallInfoFromInstruction(instruction); + if (!callInfo) { + continue; + } + + if (callInfo.kind === "invoke") { + // The compiler emits invoke on both the caller JUMP and + // callee entry JUMPDEST. Skip if the top frame already + // matches this call. + const top = stack[stack.length - 1]; + if ( + !top || + top.identifier !== callInfo.identifier || + top.callType !== callInfo.callType + ) { + stack.push({ + identifier: callInfo.identifier, + stepIndex: i, + callType: callInfo.callType, + }); + } + } else if (callInfo.kind === "return" || callInfo.kind === "revert") { + // Pop the matching frame + if (stack.length > 0) { + stack.pop(); + } + } + } + + return stack; +} + /** * Build a map of PC to instruction for quick lookup. */ diff --git a/packages/web/src/theme/ProgramExample/CallInfoPanel.css b/packages/web/src/theme/ProgramExample/CallInfoPanel.css new file mode 100644 index 000000000..1adfe5dcc --- /dev/null +++ b/packages/web/src/theme/ProgramExample/CallInfoPanel.css @@ -0,0 +1,85 @@ +.call-info-panel { + font-size: 0.9em; +} + +.call-info-banner { + padding: 6px 10px; + border-radius: 4px; + font-weight: 500; + margin-bottom: 6px; +} + +.call-info-banner-invoke { + background: var(--programs-invoke-bg, #e8f4fd); + color: var(--programs-invoke-text, #0969da); + border-left: 3px solid var(--programs-invoke-accent, #0969da); +} + +.call-info-banner-return { + background: var(--programs-return-bg, #dafbe1); + color: var(--programs-return-text, #1a7f37); + border-left: 3px solid var(--programs-return-accent, #1a7f37); +} + +.call-info-banner-revert { + background: var(--programs-revert-bg, #ffebe9); + color: var(--programs-revert-text, #cf222e); + border-left: 3px solid var(--programs-revert-accent, #cf222e); +} + +.call-info-refs { + display: flex; + flex-direction: column; + gap: 4px; + padding: 4px 0; +} + +.call-info-ref { + display: flex; + align-items: baseline; + gap: 6px; + padding: 2px 0; +} + +.call-info-ref-label { + font-weight: 500; + color: var(--programs-text-muted, #888); + min-width: 80px; + text-align: right; + flex-shrink: 0; +} + +.call-info-ref-resolved { + font-family: monospace; + font-size: 0.9em; + word-break: break-all; +} + +.call-info-ref-error { + color: var(--programs-error, #cf222e); + font-style: italic; +} + +.call-info-ref-pending { + color: var(--programs-text-muted, #888); + font-style: italic; +} + +.call-info-ref-pointer { + margin-top: 2px; +} + +.call-info-ref-pointer summary { + cursor: pointer; + color: var(--programs-text-muted, #888); + font-size: 0.85em; +} + +.call-info-ref-pointer-json { + font-size: 0.8em; + padding: 4px 8px; + background: var(--programs-bg-code, #f6f8fa); + border-radius: 3px; + overflow-x: auto; + max-height: 200px; +} diff --git a/packages/web/src/theme/ProgramExample/CallStackDisplay.css b/packages/web/src/theme/ProgramExample/CallStackDisplay.css new file mode 100644 index 000000000..5d928c292 --- /dev/null +++ b/packages/web/src/theme/ProgramExample/CallStackDisplay.css @@ -0,0 +1,46 @@ +.call-stack { + font-size: 0.85em; + padding: 4px 8px; +} + +.call-stack-empty { + color: var(--programs-text-muted, #888); +} + +.call-stack-breadcrumb { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 2px; +} + +.call-stack-separator { + color: var(--programs-text-muted, #888); + user-select: none; +} + +.call-stack-frame { + display: inline-flex; + align-items: center; + background: none; + border: 1px solid transparent; + border-radius: 3px; + padding: 1px 4px; + cursor: pointer; + font-family: inherit; + font-size: inherit; + color: var(--programs-link, #0366d6); +} + +.call-stack-frame:hover { + background: var(--programs-bg-hover, rgba(0, 0, 0, 0.05)); + border-color: var(--programs-border, #ddd); +} + +.call-stack-name { + font-weight: 500; +} + +.call-stack-parens { + color: var(--programs-text-muted, #888); +} diff --git a/packages/web/src/theme/ProgramExample/TraceDrawer.css b/packages/web/src/theme/ProgramExample/TraceDrawer.css index 4da55e7be..9f36d24c0 100644 --- a/packages/web/src/theme/ProgramExample/TraceDrawer.css +++ b/packages/web/src/theme/ProgramExample/TraceDrawer.css @@ -127,6 +127,83 @@ text-align: center; } +/* Call stack breadcrumb */ +.call-stack-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 2px; + padding: 4px 12px; + font-size: 12px; + background: var(--ifm-background-surface-color); + border-bottom: 1px solid var(--ifm-color-emphasis-200); + flex-shrink: 0; +} + +.call-stack-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ifm-color-content-secondary); + margin-right: 4px; +} + +.call-stack-toplevel { + color: var(--ifm-color-content-secondary); + font-style: italic; +} + +.call-stack-sep { + color: var(--ifm-color-content-secondary); + padding: 0 2px; + user-select: none; +} + +.call-stack-frame-btn { + background: none; + border: 1px solid transparent; + border-radius: 3px; + padding: 1px 4px; + cursor: pointer; + font-family: var(--ifm-font-family-monospace); + font-size: 12px; + font-weight: 500; + color: var(--ifm-color-primary); +} + +.call-stack-frame-btn:hover { + background: var(--ifm-color-emphasis-100); + border-color: var(--ifm-color-emphasis-300); +} + +/* Call info banner */ +.call-info-bar { + padding: 4px 12px; + font-size: 12px; + font-weight: 500; + flex-shrink: 0; + border-bottom: 1px solid var(--ifm-color-emphasis-200); +} + +.call-info-invoke { + background: var(--ifm-color-info-contrast-background); + color: var(--ifm-color-info-darkest); + border-left: 3px solid var(--ifm-color-info); +} + +.call-info-return { + background: var(--ifm-color-success-contrast-background); + color: var(--ifm-color-success-darkest); + border-left: 3px solid var(--ifm-color-success); +} + +.call-info-revert { + background: var(--ifm-color-danger-contrast-background); + color: var(--ifm-color-danger-darkest); + border-left: 3px solid var(--ifm-color-danger); +} + /* Trace panels */ .trace-panels { display: grid; diff --git a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx index 72c69418b..0cae58678 100644 --- a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx +++ b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx @@ -85,6 +85,62 @@ function TraceDrawerContent(): JSX.Element { return extractVariables(instruction.debug.context); }, [trace, currentStep, pcToInstruction]); + // Extract call info from current instruction context + const currentCallInfo = useMemo(() => { + if (trace.length === 0 || currentStep >= trace.length) { + return undefined; + } + + const step = trace[currentStep]; + const instruction = pcToInstruction.get(step.pc); + if (!instruction?.debug?.context) return undefined; + + return extractCallInfo(instruction.debug.context); + }, [trace, currentStep, pcToInstruction]); + + // Build call stack by scanning invoke/return/revert up to + // current step + const callStack = useMemo(() => { + const frames: Array<{ + identifier?: string; + stepIndex: number; + callType?: string; + }> = []; + + for (let i = 0; i <= currentStep && i < trace.length; i++) { + const step = trace[i]; + const instruction = pcToInstruction.get(step.pc); + if (!instruction?.debug?.context) continue; + + const info = extractCallInfo(instruction.debug.context); + if (!info) continue; + + if (info.kind === "invoke") { + // The compiler emits invoke on both the caller + // JUMP and callee entry JUMPDEST. Skip if the + // top frame already matches this call. + const top = frames[frames.length - 1]; + if ( + !top || + top.identifier !== info.identifier || + top.callType !== info.callType + ) { + frames.push({ + identifier: info.identifier, + stepIndex: i, + callType: info.callType, + }); + } + } else if (info.kind === "return" || info.kind === "revert") { + if (frames.length > 0) { + frames.pop(); + } + } + } + + return frames; + }, [trace, currentStep, pcToInstruction]); + // Compile source and run trace in one shot. // Takes source directly to avoid stale-state issues. const compileAndTrace = useCallback(async (sourceCode: string) => { @@ -298,6 +354,36 @@ function TraceDrawerContent(): JSX.Element { +
+ Call Stack: + {callStack.length === 0 ? ( + (top level) + ) : ( + callStack.map((frame, i) => ( + + {i > 0 && ( + + )} + + + )) + )} +
+ + {currentCallInfo && ( +
+ {formatCallBanner(currentCallInfo)} +
+ )} +
Instructions
@@ -468,6 +554,90 @@ function VariablesDisplay({ variables }: VariablesDisplayProps): JSX.Element { ); } +/** + * Info about a call context (invoke/return/revert). + */ +interface CallInfoResult { + kind: "invoke" | "return" | "revert"; + identifier?: string; + callType?: string; +} + +/** + * Extract call info from an ethdebug format context object. + */ +function extractCallInfo(context: unknown): CallInfoResult | undefined { + if (!context || typeof context !== "object") { + return undefined; + } + + const ctx = context as Record; + + if ("invoke" in ctx && ctx.invoke) { + const inv = ctx.invoke as Record; + let callType: string | undefined; + if ("jump" in inv) callType = "internal"; + else if ("message" in inv) callType = "external"; + else if ("create" in inv) callType = "create"; + + return { + kind: "invoke", + identifier: inv.identifier as string | undefined, + callType, + }; + } + + if ("return" in ctx && ctx.return) { + const ret = ctx.return as Record; + return { + kind: "return", + identifier: ret.identifier as string | undefined, + }; + } + + if ("revert" in ctx && ctx.revert) { + const rev = ctx.revert as Record; + return { + kind: "revert", + identifier: rev.identifier as string | undefined, + }; + } + + // Walk gather/pick + if ("gather" in ctx && Array.isArray(ctx.gather)) { + for (const sub of ctx.gather) { + const info = extractCallInfo(sub); + if (info) return info; + } + } + + if ("pick" in ctx && Array.isArray(ctx.pick)) { + for (const sub of ctx.pick) { + const info = extractCallInfo(sub); + if (info) return info; + } + } + + return undefined; +} + +/** + * Format a call info banner string. + */ +function formatCallBanner(info: CallInfoResult): string { + const name = info.identifier || "(anonymous)"; + switch (info.kind) { + case "invoke": { + const prefix = info.callType === "create" ? "Creating" : "Calling"; + return `${prefix} ${name}()`; + } + case "return": + return `Returned from ${name}()`; + case "revert": + return `Reverted in ${name}()`; + } +} + /** * Extract variables from an ethdebug format context object. */ diff --git a/packages/web/src/theme/ProgramExample/TraceViewer.tsx b/packages/web/src/theme/ProgramExample/TraceViewer.tsx index 59ee577f0..c2348beb7 100644 --- a/packages/web/src/theme/ProgramExample/TraceViewer.tsx +++ b/packages/web/src/theme/ProgramExample/TraceViewer.tsx @@ -12,6 +12,8 @@ import { TraceProgress, VariableInspector, StackInspector, + CallStackDisplay, + CallInfoPanel, useTraceContext, type TraceStep, } from "@ethdebug/programs-react"; @@ -20,6 +22,8 @@ import { import "./TraceViewer.css"; import "./TraceControls.css"; import "./VariableInspector.css"; +import "./CallStackDisplay.css"; +import "./CallInfoPanel.css"; export interface TraceViewerProps { /** The execution trace */ @@ -97,6 +101,7 @@ function TraceViewerContent({
+
@@ -118,6 +123,8 @@ function TraceViewerContent({
+ + {showVariables && (

Variables

diff --git a/packages/web/src/theme/ProgramExample/index.ts b/packages/web/src/theme/ProgramExample/index.ts index 47a0e1113..c930d1b4d 100644 --- a/packages/web/src/theme/ProgramExample/index.ts +++ b/packages/web/src/theme/ProgramExample/index.ts @@ -17,13 +17,19 @@ export { TraceProgress, VariableInspector, StackInspector, + CallStackDisplay, + CallInfoPanel, type TraceState, type TraceProviderProps, type ResolvedVariable, + type ResolvedCallInfo, + type ResolvedPointerRef, type TraceControlsProps, type TraceProgressProps, type VariableInspectorProps, type StackInspectorProps, + type CallStackDisplayProps, + type CallInfoPanelProps, } from "@ethdebug/programs-react"; // Also re-export utilities for convenience @@ -33,12 +39,16 @@ export { createMockTrace, findInstructionAtPc, extractVariablesFromInstruction, + extractCallInfoFromInstruction, buildPcToInstructionMap, + buildCallStack, type DynamicInstruction, type DynamicContext, type ContextThunk, type TraceStep, type MockTraceSpec, + type CallInfo, + type CallFrame, } from "@ethdebug/programs-react"; // Local Docusaurus-specific components From e98969f9401a0d9a2fb4c63a1280f70db20e6889 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Wed, 1 Apr 2026 16:51:40 -0400 Subject: [PATCH 23/39] docs: expand return and revert spec pages (#193) Add subsections to return.mdx covering internal returns, external call returns, the success field, and data pointer. Add subsections to revert.mdx covering reason-based reverts, panic codes, and field optionality. Both pages now match the depth of invoke.mdx. --- .../spec/program/context/function/return.mdx | 50 +++++++++++++++++-- .../spec/program/context/function/revert.mdx | 49 ++++++++++++++++-- 2 files changed, 91 insertions(+), 8 deletions(-) diff --git a/packages/web/spec/program/context/function/return.mdx b/packages/web/spec/program/context/function/return.mdx index daf7547f5..e03668a1b 100644 --- a/packages/web/spec/program/context/function/return.mdx +++ b/packages/web/spec/program/context/function/return.mdx @@ -7,11 +7,53 @@ import SchemaViewer from "@site/src/components/SchemaViewer"; # Return contexts A return context marks an instruction associated with a successful -function return. It extends the function identity schema with a -pointer to the return data and, for external calls, the success -status. It extends the -[function identity](/spec/program/context/function) schema. +function return. It extends the +[function identity](/spec/program/context/function) schema with +a pointer to the return data and, for external calls, the +success status. + +## Return data + +The `data` field is required and contains a pointer to the value +being returned. For internal calls this typically points to a +stack location; for external calls it points into the +`returndata` buffer. + +## Internal return + +An internal return marks the JUMP instruction that transfers +control back to the caller within the same contract. The +function leaves its return value on the stack, so `data` points +to the relevant stack slot. + +Internal returns do not use the `success` field—internal calls +either return normally or revert, with no separate success flag. + +## External call return + +An external call return marks an instruction after a CALL, +DELEGATECALL, or STATICCALL that completed successfully. The +EVM places a success flag on the stack (1 for success, 0 for +failure), and the callee's output is accessible via the +returndata buffer. + +The `success` field is specific to external call returns. It +contains a pointer to the boolean success value on the stack, +letting the debugger distinguish successful returns from +reverts at the EVM level. + +## Field optionality + +The `data` pointer is always required—every return context must +indicate where the return value can be found. The `success` +field is optional and only meaningful for external calls. + +Function identity fields (`identifier`, `declaration`, `type`) +are all optional. A compiler may omit them when it cannot +attribute the return to a specific named function, for instance +when returning from a fallback function or an anonymous code +path. diff --git a/packages/web/spec/program/context/function/revert.mdx b/packages/web/spec/program/context/function/revert.mdx index ced210bd8..564620b7a 100644 --- a/packages/web/spec/program/context/function/revert.mdx +++ b/packages/web/spec/program/context/function/revert.mdx @@ -7,11 +7,52 @@ import SchemaViewer from "@site/src/components/SchemaViewer"; # Revert contexts A revert context marks an instruction associated with a function -revert. It extends the function identity schema with an optional -pointer to revert reason data and/or a numeric panic code for -built-in assertion failures. It extends the -[function identity](/spec/program/context/function) schema. +revert. It extends the +[function identity](/spec/program/context/function) schema with +an optional pointer to revert reason data and/or a numeric +panic code for built-in assertion failures. + +## Reason-based revert + +The `reason` field contains a pointer to the revert reason data +in memory or, for external calls, in the returndata buffer. +This typically holds an ABI-encoded error: a `require()` failure +produces an `Error(string)` payload, while custom errors produce +their own ABI-encoded selector and arguments. + +For an internal revert, `reason` points into memory where the +compiler has written the encoded error. For an external call +that reverted, `reason` points into the `returndata` buffer +containing the callee's revert output. + +## Panic codes + +The `panic` field carries a numeric code for built-in assertion +failures that the compiler inserts automatically. These do not +originate from explicit `revert` or `require` statements—they +guard against conditions like arithmetic overflow or +out-of-bounds array access. + +Languages define their own panic code conventions. For example, +Solidity uses `0x01` for failed `assert()`, `0x11` for +arithmetic overflow, and `0x32` for out-of-bounds indexing. + +A revert context may include `panic` alone (when no reason +pointer is needed), `reason` alone, or both. + +## Field optionality + +Both `reason` and `panic` are optional. A revert context is +valid with either, both, or neither—a bare `revert: {}` is +permitted when the compiler knows a revert occurred but has no +further detail. + +Function identity fields (`identifier`, `declaration`, `type`) +are also optional. A compiler may omit them when the revert +cannot be attributed to a specific function, such as a +top-level `require` guard or an unattributed external call +failure. From 38ea8d9b6aa83ddb4cd6859557a15ba91ee07238 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Wed, 1 Apr 2026 16:53:01 -0400 Subject: [PATCH 24/39] bugc-react: surface invoke/return/revert contexts in BytecodeView (#195) Add context-aware rendering for function call debug contexts in the bytecode disassembly view. Instructions with invoke, return, or revert contexts now show colored badges and inline labels instead of the generic info icon, with colored left borders to make call boundaries visually scannable. - Add classifyContext/summarizeContext utils for extracting human-readable info from debug context objects - Replace generic info icon with arrow/return/x badges for invoke/return/revert instructions - Show inline context labels (e.g. "invoke add", "return add") - Add structured tooltip headers before raw JSON - CSS for context badges, labels, and row highlighting --- .../src/components/BytecodeView.css | 79 ++++++++++ .../src/components/BytecodeView.tsx | 79 +++++++++- packages/bugc-react/src/index.ts | 4 + packages/bugc-react/src/utils/debugUtils.ts | 136 ++++++++++++++++++ packages/bugc-react/src/utils/index.ts | 4 + 5 files changed, 296 insertions(+), 6 deletions(-) diff --git a/packages/bugc-react/src/components/BytecodeView.css b/packages/bugc-react/src/components/BytecodeView.css index b66318e6e..21f11820e 100644 --- a/packages/bugc-react/src/components/BytecodeView.css +++ b/packages/bugc-react/src/components/BytecodeView.css @@ -113,3 +113,82 @@ .opcode-line .immediates { color: var(--bugc-syntax-number); } + +/* Context badges for invoke/return/revert */ +.context-badge { + cursor: pointer; + padding: 0.0625rem 0.25rem; + border-radius: 3px; + font-size: 0.75rem; + font-weight: 600; + user-select: none; + display: inline-block; + min-width: 1.2rem; + text-align: center; + transition: all 0.15s ease; +} + +.context-badge-invoke { + color: var(--bugc-accent-blue); + background-color: var(--bugc-accent-blue-bg); +} + +.context-badge-invoke:hover { + background-color: var(--bugc-accent-blue); + color: var(--bugc-bg-primary); +} + +.context-badge-return { + color: var(--bugc-accent-green); + background-color: var(--bugc-accent-green-bg); +} + +.context-badge-return:hover { + background-color: var(--bugc-accent-green); + color: var(--bugc-bg-primary); +} + +.context-badge-revert { + color: var(--bugc-accent-red); + background-color: rgba(207, 34, 46, 0.1); +} + +.context-badge-revert:hover { + background-color: var(--bugc-accent-red); + color: var(--bugc-bg-primary); +} + +/* Inline context labels */ +.context-label { + font-size: 0.75rem; + font-style: italic; + margin-left: 0.5rem; +} + +.context-label-invoke { + color: var(--bugc-accent-blue); +} + +.context-label-return { + color: var(--bugc-accent-green); +} + +.context-label-revert { + color: var(--bugc-accent-red); +} + +/* Highlight rows with call contexts */ +.opcode-line.context-invoke { + border-left: 2px solid var(--bugc-accent-blue); + padding-left: calc(0.5rem - 2px); +} + +.opcode-line.context-return { + border-left: 2px solid var(--bugc-accent-green); + padding-left: calc(0.5rem - 2px); +} + +.opcode-line.context-revert { + border-left: 2px solid var(--bugc-accent-red); + padding-left: calc(0.5rem - 2px); +} diff --git a/packages/bugc-react/src/components/BytecodeView.tsx b/packages/bugc-react/src/components/BytecodeView.tsx index 441b0f5e0..2027097ef 100644 --- a/packages/bugc-react/src/components/BytecodeView.tsx +++ b/packages/bugc-react/src/components/BytecodeView.tsx @@ -5,10 +5,28 @@ import React from "react"; import type { Evm } from "@ethdebug/bugc"; import type { BytecodeOutput, SourceRange } from "#types"; -import { extractSourceRange } from "#utils/debugUtils"; +import { + extractSourceRange, + classifyContext, + summarizeContext, + type ContextKind, +} from "#utils/debugUtils"; import { useEthdebugTooltip } from "#hooks/useEthdebugTooltip"; import { EthdebugTooltip } from "./EthdebugTooltip.js"; +function contextBadgeLabel(kind: ContextKind): string { + switch (kind) { + case "invoke": + return "\u279c"; // arrow right + case "return": + return "\u21b5"; // return arrow + case "revert": + return "\u2717"; // x mark + default: + return "\u2139"; // info + } +} + /** * Props for BytecodeView component. */ @@ -47,12 +65,35 @@ function InstructionsView({ onOpcodeHover?.([]); }; + const formatTooltipContent = (instruction: Evm.Instruction): string => { + const ctx = instruction.debug?.context; + if (!ctx) return ""; + + const summary = summarizeContext(ctx); + const lines: string[] = []; + + if ( + summary.kind === "invoke" || + summary.kind === "return" || + summary.kind === "revert" + ) { + lines.push(summary.label); + if (summary.details) { + lines.push(` (${summary.details})`); + } + lines.push(""); + } + + lines.push(JSON.stringify(ctx, null, 2)); + return lines.join("\n"); + }; + const handleDebugIconMouseEnter = ( e: React.MouseEvent, instruction: Evm.Instruction, ) => { if (instruction.debug?.context) { - showTooltip(e, JSON.stringify(instruction.debug.context, null, 2)); + showTooltip(e, formatTooltipContent(instruction)); } }; @@ -61,7 +102,7 @@ function InstructionsView({ instruction: Evm.Instruction, ) => { if (instruction.debug?.context) { - pinTooltip(e, JSON.stringify(instruction.debug.context, null, 2)); + pinTooltip(e, formatTooltipContent(instruction)); } }; @@ -73,22 +114,43 @@ function InstructionsView({ const sourceRanges = extractSourceRange(instruction.debug?.context); const hasDebugInfo = !!instruction.debug?.context; + const kind: ContextKind = hasDebugInfo + ? classifyContext(instruction.debug?.context) + : "other"; + const isCallContext = + kind === "invoke" || kind === "return" || kind === "revert"; return (
handleOpcodeMouseEnter(sourceRanges)} onMouseLeave={handleOpcodeMouseLeave} > - {hasDebugInfo ? ( + {isCallContext ? ( + handleDebugIconMouseEnter(e, instruction)} + onMouseLeave={hideTooltip} + onClick={(e) => handleDebugIconClick(e, instruction)} + title={summarizeContext(instruction.debug?.context).label} + > + {contextBadgeLabel(kind)} + + ) : hasDebugInfo ? ( handleDebugIconMouseEnter(e, instruction)} onMouseLeave={hideTooltip} onClick={(e) => handleDebugIconClick(e, instruction)} > - ℹ + {"\u2139"} ) : ( @@ -103,6 +165,11 @@ function InstructionsView({ .join("")} )} + {isCallContext && ( + + {summarizeContext(instruction.debug?.context).label} + + )}
); })} diff --git a/packages/bugc-react/src/index.ts b/packages/bugc-react/src/index.ts index b4151f575..a199e8d79 100644 --- a/packages/bugc-react/src/index.ts +++ b/packages/bugc-react/src/index.ts @@ -50,6 +50,10 @@ export { extractSourceRange, formatDebugContext, hasSourceRange, + classifyContext, + summarizeContext, + type ContextKind, + type ContextSummary, // IR debug utilities extractInstructionDebug, extractTerminatorDebug, diff --git a/packages/bugc-react/src/utils/debugUtils.ts b/packages/bugc-react/src/utils/debugUtils.ts index bf7485558..5cc919da5 100644 --- a/packages/bugc-react/src/utils/debugUtils.ts +++ b/packages/bugc-react/src/utils/debugUtils.ts @@ -93,3 +93,139 @@ export function formatDebugContext(context: unknown): string { export function hasSourceRange(context: unknown): boolean { return extractSourceRange(context).length > 0; } + +/** + * Kinds of function call contexts. + */ +export type ContextKind = + | "invoke" + | "return" + | "revert" + | "remark" + | "code" + | "other"; + +/** + * Classify a debug context by its top-level discriminant. + */ +export function classifyContext(context: unknown): ContextKind { + if (!context || typeof context !== "object") { + return "other"; + } + + const ctx = context as Record; + + if ("invoke" in ctx) return "invoke"; + if ("return" in ctx) return "return"; + if ("revert" in ctx) return "revert"; + if ("remark" in ctx) return "remark"; + if ("code" in ctx) return "code"; + + // Check inside gather — a gather of contexts inherits + // the kind of its function-call child if present + if ("gather" in ctx && Array.isArray(ctx.gather)) { + for (const item of ctx.gather) { + const kind = classifyContext(item); + if (kind === "invoke" || kind === "return" || kind === "revert") { + return kind; + } + } + } + + return "other"; +} + +/** + * Summary of a function call context for display. + */ +export interface ContextSummary { + kind: ContextKind; + label: string; + functionName?: string; + details?: string; +} + +/** + * Extract a human-readable summary from a debug context. + */ +export function summarizeContext(context: unknown): ContextSummary { + const kind = classifyContext(context); + const ctx = context as Record; + + switch (kind) { + case "invoke": { + const invoke = findNestedField(ctx, "invoke") as + | Record + | undefined; + const name = (invoke?.identifier as string) ?? "unknown"; + const callType = invoke?.jump + ? "internal" + : invoke?.message + ? "external" + : invoke?.create + ? "create" + : ""; + return { + kind, + label: `invoke ${name}`, + functionName: name, + details: callType ? `${callType} call` : undefined, + }; + } + + case "return": { + const ret = findNestedField(ctx, "return") as + | Record + | undefined; + const name = (ret?.identifier as string) ?? "unknown"; + return { + kind, + label: `return ${name}`, + functionName: name, + }; + } + + case "revert": { + const rev = findNestedField(ctx, "revert") as + | Record + | undefined; + const name = (rev?.identifier as string) ?? "unknown"; + const panic = rev?.panic as number | undefined; + return { + kind, + label: `revert ${name}`, + functionName: name, + details: panic !== undefined ? `panic(${panic})` : undefined, + }; + } + + case "remark": + return { + kind, + label: ctx.remark as string, + }; + + case "code": + return { kind, label: "source mapping" }; + + default: + return { kind, label: "debug info" }; + } +} + +/** + * Find a field by name, searching inside gather arrays. + */ +function findNestedField(ctx: Record, field: string): unknown { + if (field in ctx) return ctx[field]; + + if ("gather" in ctx && Array.isArray(ctx.gather)) { + for (const item of ctx.gather) { + if (item && typeof item === "object" && field in item) { + return (item as Record)[field]; + } + } + } + + return undefined; +} diff --git a/packages/bugc-react/src/utils/index.ts b/packages/bugc-react/src/utils/index.ts index 335e2e091..812ee5956 100644 --- a/packages/bugc-react/src/utils/index.ts +++ b/packages/bugc-react/src/utils/index.ts @@ -6,6 +6,10 @@ export { extractSourceRange, formatDebugContext, hasSourceRange, + classifyContext, + summarizeContext, + type ContextKind, + type ContextSummary, } from "./debugUtils.js"; export { From 9c5d1a01c892117abced65b09b4b5d299a7daeaa Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Wed, 1 Apr 2026 17:02:29 -0400 Subject: [PATCH 25/39] bugc: map return epilogue instructions to source location (#197) The function-return PUSH/MLOAD/JUMP sequence previously had remark-only debug context. Now uses the IR return terminator's operationDebug, which carries the source location of the return statement. This lets debuggers map the epilogue back to the BUG source. --- .../bugc/src/evmgen/call-contexts.test.ts | 31 +++++++++++++++++++ .../generation/control-flow/terminator.ts | 20 ++++-------- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/packages/bugc/src/evmgen/call-contexts.test.ts b/packages/bugc/src/evmgen/call-contexts.test.ts index 7ab7478e0..244509e91 100644 --- a/packages/bugc/src/evmgen/call-contexts.test.ts +++ b/packages/bugc/src/evmgen/call-contexts.test.ts @@ -358,4 +358,35 @@ code { }); }); }); + + describe("return epilogue source maps", () => { + it( + "should map return epilogue instructions " + "to source location", + async () => { + const program = await compileProgram(source); + + // The return epilogue is PUSH/MLOAD/JUMP inside + // user functions. Find JUMP instructions that are + // NOT invoke contexts — these are return jumps. + const returnJumps = program.instructions.filter( + (instr) => + instr.operation?.mnemonic === "JUMP" && + !Context.isInvoke(instr.context), + ); + + // Should have at least one return JUMP (from add) + expect(returnJumps.length).toBeGreaterThanOrEqual(1); + + // The return JUMP should have a code context with + // source location (not just a remark) + const returnJump = returnJumps[0]; + expect(returnJump.context).toBeDefined(); + const ctx = returnJump.context as Record; + expect(ctx.code).toBeDefined(); + const code = ctx.code as Record; + expect(code.source).toEqual({ id: "0" }); + expect(code.range).toBeDefined(); + }, + ); + }); }); diff --git a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts index 377bd1413..310d74c7f 100644 --- a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts +++ b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts @@ -26,24 +26,16 @@ export function generateTerminator( if (isUserFunction) { // Load return PC from the saved slot (not 0x60, // which may have been overwritten by nested calls). + // Use operationDebug from the IR return terminator + // so the epilogue maps back to the return statement. + const debug = term.operationDebug; return pipe() .peek((state, builder) => { const pcOffset = state.memory.savedReturnPcOffset ?? 0x60; - const returnDebug = { - context: { - remark: term.value - ? "function-return: return with value" - : "function-return: void return", - }, - }; return builder - .then(PUSHn(BigInt(pcOffset), { debug: returnDebug }), { - as: "offset", - }) - .then(MLOAD({ debug: returnDebug }), { - as: "counter", - }) - .then(JUMP({ debug: returnDebug })); + .then(PUSHn(BigInt(pcOffset), { debug }), { as: "offset" }) + .then(MLOAD({ debug }), { as: "counter" }) + .then(JUMP({ debug })); }) .done() as unknown as Transition; } From ff8ebcbf4626716d3f73e63aaafe98c8d2c90e7a Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Wed, 1 Apr 2026 17:10:49 -0400 Subject: [PATCH 26/39] ui: named arguments, click-to-source, and call stack params (#198) Show named argument parameters throughout the UI: - BytecodeView labels show "invoke add(a, b)" instead of "invoke add" when argument names are available in the invoke context's pointer group entries - Call stack breadcrumbs show "add(a, b)" instead of "add()" in both programs-react CallStackDisplay and web TraceDrawer - CallInfoPanel banner shows "Calling add(a, b)" Add click-to-source for context badges: - BytecodeView accepts onDeclarationClick callback - Clicking an invoke/return/revert badge with a declaration field fires the callback with sourceId, offset, and length - Falls back to pinning the tooltip if no declaration Extract declaration and argument names from contexts: - summarizeContext now returns argumentNames and declaration - New formatCallSignature utility for consistent formatting - New DeclarationRange type for click-to-source data - CallFrame and CallInfo types carry argumentNames --- .../src/components/BytecodeView.tsx | 25 +++- packages/bugc-react/src/index.ts | 2 + packages/bugc-react/src/utils/debugUtils.ts | 108 +++++++++++++++++- packages/bugc-react/src/utils/index.ts | 2 + .../src/components/CallInfoPanel.tsx | 5 +- .../src/components/CallStackDisplay.tsx | 4 +- .../src/components/TraceContext.tsx | 3 + .../programs-react/src/utils/mockTrace.ts | 93 +++++++++++++++ .../src/theme/ProgramExample/TraceDrawer.tsx | 42 ++++++- 9 files changed, 276 insertions(+), 8 deletions(-) diff --git a/packages/bugc-react/src/components/BytecodeView.tsx b/packages/bugc-react/src/components/BytecodeView.tsx index 2027097ef..cf6b42ca7 100644 --- a/packages/bugc-react/src/components/BytecodeView.tsx +++ b/packages/bugc-react/src/components/BytecodeView.tsx @@ -10,6 +10,7 @@ import { classifyContext, summarizeContext, type ContextKind, + type DeclarationRange, } from "#utils/debugUtils"; import { useEthdebugTooltip } from "#hooks/useEthdebugTooltip"; import { EthdebugTooltip } from "./EthdebugTooltip.js"; @@ -35,16 +36,20 @@ export interface BytecodeViewProps { bytecode: BytecodeOutput; /** Callback when hovering over an opcode with source ranges */ onOpcodeHover?: (ranges: SourceRange[]) => void; + /** Callback when clicking a context badge with a declaration */ + onDeclarationClick?: (decl: DeclarationRange) => void; } interface InstructionsViewProps { instructions: Evm.Instruction[]; onOpcodeHover?: (ranges: SourceRange[]) => void; + onDeclarationClick?: (decl: DeclarationRange) => void; } function InstructionsView({ instructions, onOpcodeHover, + onDeclarationClick, }: InstructionsViewProps): JSX.Element { const { tooltip, @@ -106,6 +111,21 @@ function InstructionsView({ } }; + const handleBadgeClick = ( + e: React.MouseEvent, + instruction: Evm.Instruction, + ) => { + const ctx = instruction.debug?.context; + if (!ctx) return; + + const summary = summarizeContext(ctx); + if (summary.declaration && onDeclarationClick) { + onDeclarationClick(summary.declaration); + } else { + pinTooltip(e, formatTooltipContent(instruction)); + } + }; + return (
{instructions.map((instruction, idx) => { @@ -138,7 +158,7 @@ function InstructionsView({ className={`context-badge context-badge-${kind}`} onMouseEnter={(e) => handleDebugIconMouseEnter(e, instruction)} onMouseLeave={hideTooltip} - onClick={(e) => handleDebugIconClick(e, instruction)} + onClick={(e) => handleBadgeClick(e, instruction)} title={summarizeContext(instruction.debug?.context).label} > {contextBadgeLabel(kind)} @@ -202,6 +222,7 @@ function InstructionsView({ export function BytecodeView({ bytecode, onOpcodeHover, + onDeclarationClick, }: BytecodeViewProps): JSX.Element { const runtimeHex = Array.from(bytecode.runtime) .map((b) => b.toString(16).padStart(2, "0")) @@ -236,6 +257,7 @@ export function BytecodeView({ )}
@@ -263,6 +285,7 @@ export function BytecodeView({
diff --git a/packages/bugc-react/src/index.ts b/packages/bugc-react/src/index.ts index a199e8d79..91c68799b 100644 --- a/packages/bugc-react/src/index.ts +++ b/packages/bugc-react/src/index.ts @@ -52,8 +52,10 @@ export { hasSourceRange, classifyContext, summarizeContext, + formatCallSignature, type ContextKind, type ContextSummary, + type DeclarationRange, // IR debug utilities extractInstructionDebug, extractTerminatorDebug, diff --git a/packages/bugc-react/src/utils/debugUtils.ts b/packages/bugc-react/src/utils/debugUtils.ts index 5cc919da5..2b7c36e4b 100644 --- a/packages/bugc-react/src/utils/debugUtils.ts +++ b/packages/bugc-react/src/utils/debugUtils.ts @@ -135,6 +135,15 @@ export function classifyContext(context: unknown): ContextKind { return "other"; } +/** + * A declaration source range for click-to-source. + */ +export interface DeclarationRange { + sourceId: string; + offset: number; + length: number; +} + /** * Summary of a function call context for display. */ @@ -142,7 +151,9 @@ export interface ContextSummary { kind: ContextKind; label: string; functionName?: string; + argumentNames?: string[]; details?: string; + declaration?: DeclarationRange; } /** @@ -165,11 +176,16 @@ export function summarizeContext(context: unknown): ContextSummary { : invoke?.create ? "create" : ""; + const argNames = extractArgumentNames(invoke); + const declaration = extractDeclaration(invoke); + const paramList = argNames.length > 0 ? `(${argNames.join(", ")})` : "()"; return { kind, - label: `invoke ${name}`, + label: `invoke ${name}${paramList}`, functionName: name, + argumentNames: argNames.length > 0 ? argNames : undefined, details: callType ? `${callType} call` : undefined, + declaration, }; } @@ -178,10 +194,12 @@ export function summarizeContext(context: unknown): ContextSummary { | Record | undefined; const name = (ret?.identifier as string) ?? "unknown"; + const declaration = extractDeclaration(ret); return { kind, - label: `return ${name}`, + label: `return ${name}()`, functionName: name, + declaration, }; } @@ -191,11 +209,13 @@ export function summarizeContext(context: unknown): ContextSummary { | undefined; const name = (rev?.identifier as string) ?? "unknown"; const panic = rev?.panic as number | undefined; + const declaration = extractDeclaration(rev); return { kind, - label: `revert ${name}`, + label: `revert ${name}()`, functionName: name, details: panic !== undefined ? `panic(${panic})` : undefined, + declaration, }; } @@ -213,6 +233,88 @@ export function summarizeContext(context: unknown): ContextSummary { } } +/** + * Format a function call signature with param names. + * + * Returns "name(a, b)" if names are available, + * "name()" otherwise. + */ +export function formatCallSignature( + identifier: string | undefined, + argNames?: string[], +): string { + const name = identifier || "(anonymous)"; + if (argNames && argNames.length > 0) { + return `${name}(${argNames.join(", ")})`; + } + return `${name}()`; +} + +/** + * Extract argument names from an invoke context's + * arguments pointer group. + */ +function extractArgumentNames( + invoke: Record | undefined, +): string[] { + if (!invoke) return []; + + const args = invoke.arguments as Record | undefined; + if (!args) return []; + + const pointer = args.pointer as Record | undefined; + if (!pointer) return []; + + const group = pointer.group as Array> | undefined; + if (!Array.isArray(group)) return []; + + const names: string[] = []; + let hasAnyName = false; + for (const entry of group) { + const name = entry.name as string | undefined; + if (name) { + names.push(name); + hasAnyName = true; + } else { + names.push("_"); + } + } + + return hasAnyName ? names : []; +} + +/** + * Extract a declaration source range from a function + * identity object (invoke.invoke, return.return, etc.). + */ +function extractDeclaration( + identity: Record | undefined, +): DeclarationRange | undefined { + if (!identity) return undefined; + + const decl = identity.declaration as Record | undefined; + if (!decl) return undefined; + + const source = decl.source as Record | undefined; + const range = decl.range as Record | undefined; + + if ( + !source || + !range || + typeof source.id !== "string" || + typeof range.offset !== "number" || + typeof range.length !== "number" + ) { + return undefined; + } + + return { + sourceId: source.id, + offset: range.offset, + length: range.length, + }; +} + /** * Find a field by name, searching inside gather arrays. */ diff --git a/packages/bugc-react/src/utils/index.ts b/packages/bugc-react/src/utils/index.ts index 812ee5956..8eabbc46b 100644 --- a/packages/bugc-react/src/utils/index.ts +++ b/packages/bugc-react/src/utils/index.ts @@ -8,8 +8,10 @@ export { hasSourceRange, classifyContext, summarizeContext, + formatCallSignature, type ContextKind, type ContextSummary, + type DeclarationRange, } from "./debugUtils.js"; export { diff --git a/packages/programs-react/src/components/CallInfoPanel.tsx b/packages/programs-react/src/components/CallInfoPanel.tsx index b18e58d78..09da2b3d7 100644 --- a/packages/programs-react/src/components/CallInfoPanel.tsx +++ b/packages/programs-react/src/components/CallInfoPanel.tsx @@ -25,6 +25,9 @@ export interface CallInfoPanelProps { function formatBanner(info: ResolvedCallInfo): string { const name = info.identifier || "(anonymous)"; + const params = info.argumentNames + ? `(${info.argumentNames.join(", ")})` + : "()"; if (info.kind === "invoke") { const prefix = @@ -33,7 +36,7 @@ function formatBanner(info: ResolvedCallInfo): string { : info.callType === "create" ? "Creating" : "Calling"; - return `${prefix} ${name}()`; + return `${prefix} ${name}${params}`; } if (info.kind === "return") { diff --git a/packages/programs-react/src/components/CallStackDisplay.tsx b/packages/programs-react/src/components/CallStackDisplay.tsx index fbc55c994..7f9b087c6 100644 --- a/packages/programs-react/src/components/CallStackDisplay.tsx +++ b/packages/programs-react/src/components/CallStackDisplay.tsx @@ -52,7 +52,9 @@ export function CallStackDisplay({ {frame.identifier || "(anonymous)"} - () + + ({frame.argumentNames ? frame.argumentNames.join(", ") : ""}) + ))} diff --git a/packages/programs-react/src/components/TraceContext.tsx b/packages/programs-react/src/components/TraceContext.tsx index 4a4ef9d4c..d737372ae 100644 --- a/packages/programs-react/src/components/TraceContext.tsx +++ b/packages/programs-react/src/components/TraceContext.tsx @@ -63,6 +63,8 @@ export interface ResolvedCallInfo { identifier?: string; /** Call variant for invoke contexts */ callType?: "internal" | "external" | "create"; + /** Named arguments (from invoke context) */ + argumentNames?: string[]; /** Panic code for revert contexts */ panic?: number; /** Resolved pointer refs */ @@ -309,6 +311,7 @@ export function TraceProvider({ kind: extractedCallInfo.kind, identifier: extractedCallInfo.identifier, callType: extractedCallInfo.callType, + argumentNames: extractedCallInfo.argumentNames, panic: extractedCallInfo.panic, pointerRefs: extractedCallInfo.pointerRefs.map((ref) => ({ label: ref.label, diff --git a/packages/programs-react/src/utils/mockTrace.ts b/packages/programs-react/src/utils/mockTrace.ts index f3234e735..78af909c3 100644 --- a/packages/programs-react/src/utils/mockTrace.ts +++ b/packages/programs-react/src/utils/mockTrace.ts @@ -110,6 +110,8 @@ export interface CallInfo { identifier?: string; /** Call variant for invoke contexts */ callType?: "internal" | "external" | "create"; + /** Named arguments (from invoke context) */ + argumentNames?: string[]; /** Panic code for revert contexts */ panic?: number; /** Named pointer refs to resolve */ @@ -161,10 +163,14 @@ function extractCallInfoFromContext( collectPointerRef(pointerRefs, "input", inv.input); } + // Extract argument names from group entries + const argNames = extractArgNamesFromInvoke(inv); + return { kind: "invoke", identifier: inv.identifier as string | undefined, callType, + argumentNames: argNames, pointerRefs, }; } @@ -217,6 +223,33 @@ function extractCallInfoFromContext( return undefined; } +function extractArgNamesFromInvoke( + inv: Record, +): string[] | undefined { + const args = inv.arguments as Record | undefined; + if (!args) return undefined; + + const pointer = args.pointer as Record | undefined; + if (!pointer) return undefined; + + const group = pointer.group as Array> | undefined; + if (!Array.isArray(group)) return undefined; + + const names: string[] = []; + let hasAny = false; + for (const entry of group) { + const name = entry.name as string | undefined; + if (name) { + names.push(name); + hasAny = true; + } else { + names.push("_"); + } + } + + return hasAny ? names : undefined; +} + function collectPointerRef( refs: CallInfo["pointerRefs"], label: string, @@ -237,6 +270,8 @@ export interface CallFrame { stepIndex: number; /** The call type */ callType?: "internal" | "external" | "create"; + /** Named arguments (from invoke context) */ + argumentNames?: string[]; } /** @@ -276,6 +311,7 @@ export function buildCallStack( identifier: callInfo.identifier, stepIndex: i, callType: callInfo.callType, + argumentNames: extractArgNames(instruction), }); } } else if (callInfo.kind === "return" || callInfo.kind === "revert") { @@ -289,6 +325,63 @@ export function buildCallStack( return stack; } +/** + * Extract argument names from an instruction's invoke + * context, if present. + */ +function extractArgNames( + instruction: Program.Instruction, +): string[] | undefined { + const ctx = instruction.context as Record | undefined; + if (!ctx) return undefined; + + // Find the invoke field (may be nested in gather) + const invoke = findInvokeField(ctx); + if (!invoke) return undefined; + + const args = invoke.arguments as Record | undefined; + if (!args) return undefined; + + const pointer = args.pointer as Record | undefined; + if (!pointer) return undefined; + + const group = pointer.group as Array> | undefined; + if (!Array.isArray(group)) return undefined; + + const names: string[] = []; + let hasAny = false; + for (const entry of group) { + const name = entry.name as string | undefined; + if (name) { + names.push(name); + hasAny = true; + } else { + names.push("_"); + } + } + + return hasAny ? names : undefined; +} + +function findInvokeField( + ctx: Record, +): Record | undefined { + if ("invoke" in ctx) { + return ctx.invoke as Record; + } + if ("gather" in ctx && Array.isArray(ctx.gather)) { + for (const item of ctx.gather) { + if (item && typeof item === "object" && "invoke" in item) { + return (item as Record).invoke as Record< + string, + unknown + >; + } + } + } + return undefined; +} + /** * Build a map of PC to instruction for quick lookup. */ diff --git a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx index 0cae58678..088fa8d3f 100644 --- a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx +++ b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx @@ -105,6 +105,7 @@ function TraceDrawerContent(): JSX.Element { identifier?: string; stepIndex: number; callType?: string; + argumentNames?: string[]; }> = []; for (let i = 0; i <= currentStep && i < trace.length; i++) { @@ -129,6 +130,7 @@ function TraceDrawerContent(): JSX.Element { identifier: info.identifier, stepIndex: i, callType: info.callType, + argumentNames: info.argumentNames, }); } } else if (info.kind === "return" || info.kind === "revert") { @@ -369,7 +371,11 @@ function TraceDrawerContent(): JSX.Element { onClick={() => setCurrentStep(frame.stepIndex)} type="button" > - {frame.identifier || "(anonymous)"} + {frame.identifier || "(anonymous)"}( + {frame.argumentNames + ? frame.argumentNames.join(", ") + : ""} + ) )) @@ -561,6 +567,7 @@ interface CallInfoResult { kind: "invoke" | "return" | "revert"; identifier?: string; callType?: string; + argumentNames?: string[]; } /** @@ -584,6 +591,7 @@ function extractCallInfo(context: unknown): CallInfoResult | undefined { kind: "invoke", identifier: inv.identifier as string | undefined, callType, + argumentNames: extractArgNamesFromInvoke(inv), }; } @@ -626,10 +634,13 @@ function extractCallInfo(context: unknown): CallInfoResult | undefined { */ function formatCallBanner(info: CallInfoResult): string { const name = info.identifier || "(anonymous)"; + const params = info.argumentNames + ? `(${info.argumentNames.join(", ")})` + : "()"; switch (info.kind) { case "invoke": { const prefix = info.callType === "create" ? "Creating" : "Calling"; - return `${prefix} ${name}()`; + return `${prefix} ${name}${params}`; } case "return": return `Returned from ${name}()`; @@ -638,6 +649,33 @@ function formatCallBanner(info: CallInfoResult): string { } } +function extractArgNamesFromInvoke( + inv: Record, +): string[] | undefined { + const args = inv.arguments as Record | undefined; + if (!args) return undefined; + + const pointer = args.pointer as Record | undefined; + if (!pointer) return undefined; + + const group = pointer.group as Array> | undefined; + if (!Array.isArray(group)) return undefined; + + const names: string[] = []; + let hasAny = false; + for (const entry of group) { + const name = entry.name as string | undefined; + if (name) { + names.push(name); + hasAny = true; + } else { + names.push("_"); + } + } + + return hasAny ? names : undefined; +} + /** * Extract variables from an ethdebug format context object. */ From 54bfcc1c0b90c2bd115200c52fb7e830f45b1217 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 2 Apr 2026 04:09:43 -0400 Subject: [PATCH 27/39] Fix typos in type concepts and add missing CSS classes (#199) Fix "disinction" typo and "languages that whose" grammar error in type concepts spec page. Add missing CSS class definitions for .call-stack-empty-text and .call-info-ref-value in both programs-react and web theme. --- packages/programs-react/src/components/CallInfoPanel.css | 4 ++++ packages/programs-react/src/components/CallStackDisplay.css | 4 ++++ packages/web/spec/type/concepts.mdx | 4 ++-- packages/web/src/theme/ProgramExample/CallInfoPanel.css | 4 ++++ packages/web/src/theme/ProgramExample/CallStackDisplay.css | 4 ++++ 5 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/programs-react/src/components/CallInfoPanel.css b/packages/programs-react/src/components/CallInfoPanel.css index 1adfe5dcc..75cd06511 100644 --- a/packages/programs-react/src/components/CallInfoPanel.css +++ b/packages/programs-react/src/components/CallInfoPanel.css @@ -49,6 +49,10 @@ flex-shrink: 0; } +.call-info-ref-value { + display: inline; +} + .call-info-ref-resolved { font-family: monospace; font-size: 0.9em; diff --git a/packages/programs-react/src/components/CallStackDisplay.css b/packages/programs-react/src/components/CallStackDisplay.css index 5d928c292..9143b8d76 100644 --- a/packages/programs-react/src/components/CallStackDisplay.css +++ b/packages/programs-react/src/components/CallStackDisplay.css @@ -41,6 +41,10 @@ font-weight: 500; } +.call-stack-empty-text { + font-style: italic; +} + .call-stack-parens { color: var(--programs-text-muted, #888); } diff --git a/packages/web/spec/type/concepts.mdx b/packages/web/spec/type/concepts.mdx index e42bcbf7f..aa5604d42 100644 --- a/packages/web/spec/type/concepts.mdx +++ b/packages/web/spec/type/concepts.mdx @@ -55,12 +55,12 @@ places additional constraints in addition to what the base schema specifies. ## Elementary vs. complex types Type representations in this schema fall into one of two `class`es: either -`"elementary"` or `"complex"`. Type representations express this disinction in +`"elementary"` or `"complex"`. Type representations express this distinction in two ways (the optional `"class"` field, and the absence or existence of a `"contains"` field). - Elementary types do not compose any other types. For example, `uint256` is an - elementary type. `string` may be an elementary type for languages that whose + elementary type. `string` may be an elementary type for languages whose semantics treat strings differently than simply an array of characters (like Solidity does). diff --git a/packages/web/src/theme/ProgramExample/CallInfoPanel.css b/packages/web/src/theme/ProgramExample/CallInfoPanel.css index 1adfe5dcc..75cd06511 100644 --- a/packages/web/src/theme/ProgramExample/CallInfoPanel.css +++ b/packages/web/src/theme/ProgramExample/CallInfoPanel.css @@ -49,6 +49,10 @@ flex-shrink: 0; } +.call-info-ref-value { + display: inline; +} + .call-info-ref-resolved { font-family: monospace; font-size: 0.9em; diff --git a/packages/web/src/theme/ProgramExample/CallStackDisplay.css b/packages/web/src/theme/ProgramExample/CallStackDisplay.css index 5d928c292..9143b8d76 100644 --- a/packages/web/src/theme/ProgramExample/CallStackDisplay.css +++ b/packages/web/src/theme/ProgramExample/CallStackDisplay.css @@ -41,6 +41,10 @@ font-weight: 500; } +.call-stack-empty-text { + font-style: italic; +} + .call-stack-parens { color: var(--programs-text-muted, #888); } From c7e36e8f6c19d554cb3c81b9a37e87f671ca8e09 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 2 Apr 2026 04:10:14 -0400 Subject: [PATCH 28/39] docs: fix broken anchor in type concepts page Update internal link to match renamed heading "Type specifiers, wrappers, and references". --- packages/web/spec/type/concepts.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/spec/type/concepts.mdx b/packages/web/spec/type/concepts.mdx index aa5604d42..0d3c4105d 100644 --- a/packages/web/spec/type/concepts.mdx +++ b/packages/web/spec/type/concepts.mdx @@ -82,7 +82,7 @@ of three forms: All three forms of composition polymorphically use the `"contains"` field. As described in -[Type wrappers and type references](#type-wrappers-and-type-references) +[Type specifiers, wrappers, and references](#type-specifiers-wrappers-and-references) below, complex types compose other types by way of wrapper objects of the form `{ "type": ... }`, which possibly includes other fields alongside `"type"`. From 1a452becd143cb283feb4153e0045358e1d5fdb8 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 2 Apr 2026 04:28:31 -0400 Subject: [PATCH 29/39] bugc: fix multi-block function return values (#200) Functions with if/else branches returning from both sides produced wrong return values. The branch terminator's loadValue(condition) DUPs the condition onto the stack; JUMPI consumes the dup but the original remains. Successor blocks inherit this stale value, so the return epilogue incorrectly treated it as the return value. Add generateReturnEpilogue that loads the return value, cleans stale stack entries via SWAP+POP, then loads the saved return PC and jumps back. Uses the same imperative state pattern as generateCallTerminator. Also changes generateTerminator return type from Transition to Transition to accommodate the stack-cleaning epilogue. --- packages/bugc/src/evmgen/behavioral.test.ts | 70 +++++++++++++ .../generation/control-flow/terminator.ts | 99 ++++++++++++++++--- 2 files changed, 154 insertions(+), 15 deletions(-) diff --git a/packages/bugc/src/evmgen/behavioral.test.ts b/packages/bugc/src/evmgen/behavioral.test.ts index 7f842ee01..1c67653b4 100644 --- a/packages/bugc/src/evmgen/behavioral.test.ts +++ b/packages/bugc/src/evmgen/behavioral.test.ts @@ -196,6 +196,76 @@ code { expect(await result.getStorage(1n)).toBe(15n); }); + it("should return correct value from if/else branches", async () => { + const source = `name MultiBlockReturn; + +define { + function max(a: uint256, b: uint256) -> uint256 { + if (a > b) { + return a; + } else { + return b; + } + }; +} + +storage { + [0] result: uint256; +} + +create { + result = 0; +} + +code { + result = max(10, 20); +}`; + + const result = await executeProgram(source, { + calldata: "", + }); + + expect(result.callSuccess).toBe(true); + expect(await result.getStorage(0n)).toBe(20n); + }); + + it("should return correct value from both branches", async () => { + const source = `name BothBranches; + +define { + function max(a: uint256, b: uint256) -> uint256 { + if (a > b) { + return a; + } else { + return b; + } + }; +} + +storage { + [0] r1: uint256; + [1] r2: uint256; +} + +create { + r1 = 0; + r2 = 0; +} + +code { + r1 = max(10, 20); + r2 = max(30, 5); +}`; + + const result = await executeProgram(source, { + calldata: "", + }); + + expect(result.callSuccess).toBe(true); + expect(await result.getStorage(0n)).toBe(20n); + expect(await result.getStorage(1n)).toBe(30n); + }); + it("should call a function from another function", async () => { const source = `name FuncFromFunc; diff --git a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts index 310d74c7f..c3b5f25f2 100644 --- a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts +++ b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts @@ -14,8 +14,8 @@ export function generateTerminator( term: Ir.Block.Terminator, isLastBlock: boolean = false, isUserFunction: boolean = false, -): Transition { - const { PUSHn, PUSH2, MSTORE, MLOAD, RETURN, STOP, JUMP, JUMPI } = operations; +): Transition { + const { PUSHn, PUSH2, MSTORE, RETURN, STOP, JUMP, JUMPI } = operations; switch (term.kind) { case "return": { @@ -24,20 +24,12 @@ export function generateTerminator( // on TOS from the previous instruction in the block. This avoids an // unnecessary DUP that would leave an extra value on the stack. if (isUserFunction) { - // Load return PC from the saved slot (not 0x60, - // which may have been overwritten by nested calls). - // Use operationDebug from the IR return terminator - // so the epilogue maps back to the return statement. + // Internal function return epilogue. + // Uses the same imperative pattern as + // generateCallTerminator — returns + // Transition to erase output type. const debug = term.operationDebug; - return pipe() - .peek((state, builder) => { - const pcOffset = state.memory.savedReturnPcOffset ?? 0x60; - return builder - .then(PUSHn(BigInt(pcOffset), { debug }), { as: "offset" }) - .then(MLOAD({ debug }), { as: "counter" }) - .then(JUMP({ debug })); - }) - .done() as unknown as Transition; + return generateReturnEpilogue(term.value, debug); } // Contract return (main function or create) @@ -306,3 +298,80 @@ export function generateCallTerminator( return currentState; }) as Transition; } + +/** + * Generate return epilogue for user-defined functions. + * + * Loads the return value (if any), cleans stale stack + * values from predecessor blocks, then loads the saved + * return PC and jumps back to the caller. + */ +function generateReturnEpilogue( + value: Ir.Value | undefined, + debug: Ir.Block.Debug, +): Transition { + return ((state: State): State => { + let s: State = state as State; + + // Load return value onto the stack if present. + if (value) { + s = loadValue(value, { debug })(s); + } + + // Pop stale values, keeping only the return value + // (if any). Multi-block functions can accumulate + // leftover values (e.g. branch condition results) + // from predecessor blocks. + const keep = value ? 1 : 0; + while (s.stack.length > keep) { + if (keep > 0 && s.stack.length > 1) { + const depth = s.stack.length - 1; + s = { + ...s, + instructions: [ + ...s.instructions, + { + mnemonic: `SWAP${depth}`, + opcode: 0x8f + depth, + debug, + }, + ], + stack: [s.stack[depth], ...s.stack.slice(1, depth), s.stack[0]], + brands: [ + s.brands[depth], + ...s.brands.slice(1, depth), + s.brands[0], + ] as Stack, + }; + } + s = { + ...s, + instructions: [ + ...s.instructions, + { mnemonic: "POP", opcode: 0x50, debug }, + ], + stack: s.stack.slice(1), + brands: s.brands.slice(1) as Stack, + }; + } + + // Load return PC from saved slot and jump back. + const pcOffset = s.memory.savedReturnPcOffset ?? 0x60; + s = { + ...s, + instructions: [ + ...s.instructions, + { + mnemonic: "PUSH2", + opcode: 0x61, + immediates: [(pcOffset >> 8) & 0xff, pcOffset & 0xff], + debug, + }, + { mnemonic: "MLOAD", opcode: 0x51, debug }, + { mnemonic: "JUMP", opcode: 0x56, debug }, + ], + }; + + return s; + }) as Transition; +} From c2f2236dfe5c6ba3a75c36444e5975e9eb55a3ce Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 2 Apr 2026 05:10:36 -0400 Subject: [PATCH 30/39] bugc: add runtime call stack for recursion support (#201) Implement FMP-based call frames so functions can call themselves recursively. Each call allocates a frame (saved FP, saved return PC, locals) from the free memory pointer; returns deallocate by restoring FP and FMP. Also fixes pre-existing SUB/DIV/MOD operand ordering bug where non-commutative operations computed right op left instead of left op right. --- .../bugc/src/evmgen/analysis/memory.test.ts | 6 +- packages/bugc/src/evmgen/analysis/memory.ts | 63 ++-- packages/bugc/src/evmgen/behavioral.test.ts | 57 ++++ packages/bugc/src/evmgen/generation/block.ts | 129 +++++-- .../generation/control-flow/terminator.ts | 76 ++++- .../src/evmgen/generation/function.test.ts | 17 +- .../bugc/src/evmgen/generation/function.ts | 319 +++++++++++------- .../evmgen/generation/instructions/binary.ts | 39 ++- .../bugc/src/evmgen/generation/values/load.ts | 22 +- .../src/evmgen/generation/values/store.ts | 34 +- 10 files changed, 570 insertions(+), 192 deletions(-) diff --git a/packages/bugc/src/evmgen/analysis/memory.test.ts b/packages/bugc/src/evmgen/analysis/memory.test.ts index 8760ed3b8..03a09fdeb 100644 --- a/packages/bugc/src/evmgen/analysis/memory.test.ts +++ b/packages/bugc/src/evmgen/analysis/memory.test.ts @@ -89,12 +89,12 @@ describe("Memory Planning", () => { // Phi destination %3 should be allocated memory expect("%3" in memory.allocations).toBe(true); - // %1 is allocated first at 0x80, then %3 at 0xa0 (160) - expect(memory.allocations["%3"].offset).toBe(0xa0); + // %1 is allocated first at 0xa0, then %3 at 0xc0 + expect(memory.allocations["%3"].offset).toBe(0xc0); // Cross-block value %1 should also be allocated expect("%1" in memory.allocations).toBe(true); - expect(memory.allocations["%1"].offset).toBe(0x80); + expect(memory.allocations["%1"].offset).toBe(0xa0); }); it("should allocate memory for cross-block values", () => { diff --git a/packages/bugc/src/evmgen/analysis/memory.ts b/packages/bugc/src/evmgen/analysis/memory.ts index 9b455c743..7137d1b87 100644 --- a/packages/bugc/src/evmgen/analysis/memory.ts +++ b/packages/bugc/src/evmgen/analysis/memory.ts @@ -30,11 +30,24 @@ export { MemoryError as Error }; * EVM memory layout following Solidity conventions */ export const regions = { - SCRATCH_SPACE_1: 0x00, // 0x00-0x1f: First scratch space slot - SCRATCH_SPACE_2: 0x20, // 0x20-0x3f: Second scratch space slot - FREE_MEMORY_POINTER: 0x40, // 0x40-0x5f: Dynamic memory pointer - ZERO_SLOT: 0x60, // 0x60-0x7f: Zero slot (reserved) - STATIC_MEMORY_START: 0x80, // 0x80+: Static allocations start here + SCRATCH_SPACE_1: 0x00, // 0x00-0x1f: First scratch space + SCRATCH_SPACE_2: 0x20, // 0x20-0x3f: Second scratch space + FREE_MEMORY_POINTER: 0x40, // 0x40-0x5f: Dynamic memory ptr + RETURN_PC_SCRATCH: 0x60, // 0x60-0x7f: Caller→callee return PC + FRAME_POINTER: 0x80, // 0x80-0x9f: Current frame base address + STATIC_MEMORY_START: 0xa0, // 0xa0+: Static allocations +} as const; + +/** + * Call frame header layout (relative to frame base). + * Each user-function call allocates a frame from FMP; + * the first two slots store the saved frame pointer + * and saved return PC. + */ +export const frameHeader = { + SAVED_FP: 0x00, + SAVED_RETURN_PC: 0x20, + LOCALS_START: 0x40, } as const; export interface Allocation { @@ -93,10 +106,10 @@ export namespace Module { result.main = mainMemory.value; // Process user-defined functions. - // Each function's allocations start after the previous - // function's end, so all simultaneously-active frames - // have non-overlapping memory. - let nextFuncOffset = result.main.nextStaticOffset; + // Each function gets its own call frame allocated from + // FMP at runtime. Offsets are relative to the frame + // base, starting after the frame header (saved FP + + // saved return PC). for (const [name, func] of module.functions) { const funcLiveness = liveness.functions[name]; if (!funcLiveness) { @@ -109,13 +122,12 @@ export namespace Module { } const funcMemory = Function.plan(func, funcLiveness, { isUserFunction: true, - startOffset: nextFuncOffset, + startOffset: frameHeader.LOCALS_START, }); if (!funcMemory.success) { return funcMemory; } result.functions[name] = funcMemory.value; - nextFuncOffset = funcMemory.value.nextStaticOffset; } return Result.ok(result); @@ -124,16 +136,22 @@ export namespace Module { export namespace Function { export interface Info { - /** Memory allocation info for each value that needs allocation */ + /** Memory allocation info for each value */ allocations: Record; - /** Next available memory offset after all static allocations */ + /** Next available memory offset after static allocations */ nextStaticOffset: number; /** - * Offset where this function saves its return PC. - * Only set for user-defined functions that need to - * preserve their return address across internal calls. + * Frame-relative offset for saved return PC. + * Only set for user-defined functions. */ savedReturnPcOffset?: number; + /** + * Total frame size in bytes. When set, all allocation + * offsets are relative to the frame base (loaded from + * FRAME_POINTER at 0x80). Frames are allocated from + * FMP on entry and deallocated on return. + */ + frameSize?: number; } /** @@ -212,19 +230,22 @@ export namespace Function { nextStaticOffset = currentSlotOffset; } - // Reserve a slot for the saved return PC in user - // functions. This is needed because nested calls - // overwrite memory[0x60] with their own return PC. + // For user functions, the saved return PC lives at a + // fixed offset in the frame header. frameSize is the + // total frame allocation (header + locals), rounded + // up to a slot boundary. let savedReturnPcOffset: number | undefined; + let frameSize: number | undefined; if (options.isUserFunction) { - savedReturnPcOffset = nextStaticOffset; - nextStaticOffset += SLOT_SIZE; + savedReturnPcOffset = frameHeader.SAVED_RETURN_PC; + frameSize = nextStaticOffset; } return Result.ok({ allocations, nextStaticOffset, savedReturnPcOffset, + frameSize, }); } catch (error) { return Result.err( diff --git a/packages/bugc/src/evmgen/behavioral.test.ts b/packages/bugc/src/evmgen/behavioral.test.ts index 1c67653b4..baf774dbc 100644 --- a/packages/bugc/src/evmgen/behavioral.test.ts +++ b/packages/bugc/src/evmgen/behavioral.test.ts @@ -332,6 +332,63 @@ code { }); }); + describe("recursion", () => { + it("should support recursive function calls", async () => { + const source = `name RecursionTest; + +define { + function succ(n: uint256) -> uint256 { + return n + 1; + }; + function count( + n: uint256, target: uint256 + ) -> uint256 { + if (n < target) { + return count(succ(n), target); + } else { + return n; + } + }; +} + +storage { [0] result: uint256; } +create { result = 0; } +code { result = count(0, 5); }`; + + const result = await executeProgram(source, { + calldata: "", + }); + + expect(result.callSuccess).toBe(true); + expect(await result.getStorage(0n)).toBe(5n); + }); + + it("should support simple self-recursion", async () => { + const source = `name SimpleRecursion; + +define { + function factorial(n: uint256) -> uint256 { + if (n < 2) { + return 1; + } else { + return n * factorial(n - 1); + } + }; +} + +storage { [0] result: uint256; } +create { result = 0; } +code { result = factorial(5); }`; + + const result = await executeProgram(source, { + calldata: "", + }); + + expect(result.callSuccess).toBe(true); + expect(await result.getStorage(0n)).toBe(120n); + }); + }); + describe("loops", () => { it("should execute a for loop", async () => { const source = `name Loop; diff --git a/packages/bugc/src/evmgen/generation/block.ts b/packages/bugc/src/evmgen/generation/block.ts index da19fae46..7c9f71247 100644 --- a/packages/bugc/src/evmgen/generation/block.ts +++ b/packages/bugc/src/evmgen/generation/block.ts @@ -5,6 +5,7 @@ import type * as Ast from "#ast"; import type * as Format from "@ethdebug/format"; import * as Ir from "#ir"; +import type * as Evm from "#evm"; import type { Stack } from "#evm"; import { Error, ErrorCode } from "#evmgen/errors"; @@ -47,8 +48,10 @@ export function generate( }, })); - // Initialize memory for first block - if (isFirstBlock) { + // Initialize memory for first block of main/create. + // User functions allocate frames in the prologue + // instead — re-initializing here would clobber FP/FMP. + if (isFirstBlock && !isUserFunction) { const sourceInfo = func?.sourceId && func?.loc ? { sourceId: func.sourceId, loc: func.loc } @@ -123,7 +126,8 @@ export function generate( result = result.then(annotateTop(destId)).then((s) => { const allocation = s.memory.allocations[destId]; if (!allocation) return s; - // Spill return value to memory: DUP1, PUSH offset, MSTORE + // Spill return value to memory. + // DUP1 keeps the value; compute address; MSTORE. return { ...s, instructions: [ @@ -133,15 +137,11 @@ export function generate( opcode: 0x80, debug: spillDebug, }, - { - mnemonic: "PUSH2" as const, - opcode: 0x61, - immediates: [ - (allocation.offset >> 8) & 0xff, - allocation.offset & 0xff, - ], - debug: spillDebug, - }, + ...computeAddress( + allocation.offset, + s.memory.frameSize !== undefined, + spillDebug, + ), { mnemonic: "MSTORE" as const, opcode: 0x52, @@ -200,7 +200,7 @@ function generatePhi( phi: Ir.Block.Phi, predecessor: string, ): Transition { - const { PUSHn, MSTORE } = operations; + const { PUSHn, ADD, MLOAD, MSTORE } = operations; const source = phi.sources.get(predecessor); if (!source) { @@ -222,8 +222,20 @@ function generatePhi( `Phi destination ${phi.dest} not allocated`, ); } + if (state.memory.frameSize !== undefined) { + return builder + .then(PUSHn(BigInt(Memory.regions.FRAME_POINTER)), { as: "offset" }) + .then(MLOAD(), { as: "b" }) + .then(PUSHn(BigInt(allocation.offset)), { + as: "a", + }) + .then(ADD(), { as: "offset" }) + .then(MSTORE()); + } return builder - .then(PUSHn(BigInt(allocation.offset)), { as: "offset" }) + .then(PUSHn(BigInt(allocation.offset)), { + as: "offset", + }) .then(MSTORE()); }) .done() @@ -261,16 +273,81 @@ function initializeMemory( } as Format.Program.Context, }; - return pipe() - .then(PUSHn(BigInt(nextStaticOffset), { debug }), { - as: "value", - }) - .then( - PUSHn(BigInt(Memory.regions.FREE_MEMORY_POINTER), { - debug, - }), - { as: "offset" }, - ) - .then(MSTORE({ debug })) - .done(); + const { PUSH0 } = operations; + + return ( + pipe() + .then(PUSHn(BigInt(nextStaticOffset), { debug }), { + as: "value", + }) + .then( + PUSHn(BigInt(Memory.regions.FREE_MEMORY_POINTER), { + debug, + }), + { as: "offset" }, + ) + .then(MSTORE({ debug })) + // Initialize frame pointer to 0 (no active frame) + .then(PUSH0({ debug }), { as: "value" }) + .then(PUSHn(BigInt(Memory.regions.FRAME_POINTER), { debug }), { + as: "offset", + }) + .then(MSTORE({ debug })) + .done() + ); +} + +/** + * Emit instructions to compute a memory address. + * + * For frame-based functions, emits PUSH FP; MLOAD; + * PUSH offset; ADD. For absolute mode, emits PUSH2 + * with the offset encoded directly. + */ +function computeAddress( + offset: number, + isFrameBased: boolean, + debug: Evm.Instruction["debug"], +): Evm.Instruction[] { + if (isFrameBased) { + return [ + ...pushImm(Memory.regions.FRAME_POINTER, debug), + { mnemonic: "MLOAD" as const, opcode: 0x51, debug }, + ...pushImm(offset, debug), + { mnemonic: "ADD" as const, opcode: 0x01, debug }, + ]; + } + return [ + { + mnemonic: "PUSH2" as const, + opcode: 0x61, + immediates: [(offset >> 8) & 0xff, offset & 0xff], + debug, + }, + ]; +} + +/** PUSH an integer as the smallest PUSHn. */ +function pushImm( + value: number, + debug: Evm.Instruction["debug"], +): Evm.Instruction[] { + if (value === 0) { + return [{ mnemonic: "PUSH0", opcode: 0x5f, debug }]; + } + const bytes: number[] = []; + let v = value; + while (v > 0) { + bytes.unshift(v & 0xff); + v >>= 8; + } + const n = bytes.length; + return [ + { + mnemonic: `PUSH${n}`, + opcode: 0x5f + n, + immediates: bytes, + debug, + }, + ]; } diff --git a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts index c3b5f25f2..82df52806 100644 --- a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts +++ b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts @@ -1,7 +1,9 @@ import type * as Format from "@ethdebug/format"; import type * as Ir from "#ir"; +import type * as Evm from "#evm"; import type { Stack } from "#evm"; import type { State } from "#evmgen/state"; +import { Memory } from "#evmgen/analysis"; import { type Transition, operations, pipe } from "#evmgen/operations"; @@ -355,19 +357,55 @@ function generateReturnEpilogue( }; } - // Load return PC from saved slot and jump back. - const pcOffset = s.memory.savedReturnPcOffset ?? 0x60; + // Deallocate frame and jump to saved return PC. + // + // fp = mem[FRAME_POINTER] + // return_pc = mem[fp + SAVED_RETURN_PC] + // old_fp = mem[fp + SAVED_FP] (= mem[fp]) + // mem[FRAME_POINTER] = old_fp + // mem[FREE_MEMORY_POINTER] = fp (deallocate) + // JUMP return_pc + const FP = Memory.regions.FRAME_POINTER; + const FMP = Memory.regions.FREE_MEMORY_POINTER; + const pcOff = Memory.frameHeader.SAVED_RETURN_PC; + s = { ...s, instructions: [ ...s.instructions, - { - mnemonic: "PUSH2", - opcode: 0x61, - immediates: [(pcOffset >> 8) & 0xff, pcOffset & 0xff], - debug, - }, + // fp + ...pushImm(FP, debug), + { mnemonic: "MLOAD", opcode: 0x51, debug }, + // Stack: [fp, ...] + + // return_pc = mem[fp + SAVED_RETURN_PC] + { mnemonic: "DUP1", opcode: 0x80, debug }, + ...pushImm(pcOff, debug), + { mnemonic: "ADD", opcode: 0x01, debug }, + { mnemonic: "MLOAD", opcode: 0x51, debug }, + // Stack: [return_pc, fp, ...] + + // old_fp = mem[fp] (SAVED_FP offset is 0) + { mnemonic: "SWAP1", opcode: 0x90, debug }, + // Stack: [fp, return_pc, ...] + { mnemonic: "DUP1", opcode: 0x80, debug }, { mnemonic: "MLOAD", opcode: 0x51, debug }, + // Stack: [old_fp, fp, return_pc, ...] + + // mem[FRAME_POINTER] = old_fp + { mnemonic: "DUP1", opcode: 0x80, debug }, + ...pushImm(FP, debug), + { mnemonic: "MSTORE", opcode: 0x52, debug }, + // Stack: [old_fp, fp, return_pc, ...] + + // mem[FREE_MEMORY_POINTER] = fp (deallocate) + { mnemonic: "POP", opcode: 0x50, debug }, + // Stack: [fp, return_pc, ...] + ...pushImm(FMP, debug), + { mnemonic: "MSTORE", opcode: 0x52, debug }, + // Stack: [return_pc, ...] + + // JUMP { mnemonic: "JUMP", opcode: 0x56, debug }, ], }; @@ -375,3 +413,25 @@ function generateReturnEpilogue( return s; }) as Transition; } + +/** PUSH an integer as the smallest PUSHn. */ +function pushImm(value: number, debug: Ir.Block.Debug): Evm.Instruction[] { + if (value === 0) { + return [{ mnemonic: "PUSH0", opcode: 0x5f, debug }]; + } + const bytes: number[] = []; + let v = value; + while (v > 0) { + bytes.unshift(v & 0xff); + v >>= 8; + } + const n = bytes.length; + return [ + { + mnemonic: `PUSH${n}`, + opcode: 0x5f + n, + immediates: bytes, + debug, + }, + ]; +} diff --git a/packages/bugc/src/evmgen/generation/function.test.ts b/packages/bugc/src/evmgen/generation/function.test.ts index e25339452..be209d8c0 100644 --- a/packages/bugc/src/evmgen/generation/function.test.ts +++ b/packages/bugc/src/evmgen/generation/function.test.ts @@ -46,9 +46,10 @@ describe("Function.generate", () => { const { instructions } = generate(func, memory, layout); - // Should have memory initialization (PUSH1 0x80, PUSH1 0x40, MSTORE) followed by PUSH1 42 - // No JUMPDEST for entry with no predecessors, no STOP since it's the last block - expect(instructions).toHaveLength(4); + // Memory init: FMP (PUSH 0x80, PUSH 0x40, MSTORE) + // then FP=0 (PUSH0, PUSH 0x80, MSTORE) + // then PUSH1 42 + expect(instructions).toHaveLength(7); expect(instructions[0]).toMatchObject({ mnemonic: "PUSH1", immediates: [0x80], @@ -61,6 +62,16 @@ describe("Function.generate", () => { mnemonic: "MSTORE", }); expect(instructions[3]).toMatchObject({ + mnemonic: "PUSH0", + }); + expect(instructions[4]).toMatchObject({ + mnemonic: "PUSH1", + immediates: [0x80], + }); + expect(instructions[5]).toMatchObject({ + mnemonic: "MSTORE", + }); + expect(instructions[6]).toMatchObject({ mnemonic: "PUSH1", immediates: [42], }); diff --git a/packages/bugc/src/evmgen/generation/function.ts b/packages/bugc/src/evmgen/generation/function.ts index fa73325cf..896f3498f 100644 --- a/packages/bugc/src/evmgen/generation/function.ts +++ b/packages/bugc/src/evmgen/generation/function.ts @@ -8,7 +8,7 @@ import type * as Evm from "#evm"; import type { Stack } from "#evm"; import type { State } from "#evmgen/state"; -import type { Layout, Memory } from "#evmgen/analysis"; +import { type Layout, Memory } from "#evmgen/analysis"; import type { Error as EvmgenError } from "#evmgen/errors"; import * as Block from "./block.js"; @@ -77,126 +77,28 @@ function generatePrologue( ], }; - // Store each parameter to memory and pop from stack - // Stack layout on entry: [arg0, arg1, ..., argN] - // Return PC is already in memory at 0x60 (stored by caller) - // Pop and store each arg from argN down to arg0 - - const prologueDebug = - func.sourceId && func.loc - ? { - context: { - gather: [ - { - remark: `prologue: store ${params.length} parameter(s) to memory`, - }, - { - code: { - source: { id: func.sourceId }, - range: func.loc, - }, - }, - ], - } as Format.Program.Context, - } - : { - context: { - remark: `prologue: store ${params.length} parameter(s) to memory`, - } as Format.Program.Context, - }; - - for (let i = params.length - 1; i >= 0; i--) { - const param = params[i]; - const allocation = currentState.memory.allocations[param.tempId]; - - if (!allocation) continue; - - // Push memory offset - const highByte = (allocation.offset >> 8) & 0xff; - const lowByte = allocation.offset & 0xff; - currentState = { - ...currentState, - instructions: [ - ...currentState.instructions, - { - mnemonic: "PUSH2", - opcode: 0x61, - immediates: [highByte, lowByte], - debug: prologueDebug, - }, - ], - }; - - // MSTORE pops arg and offset - currentState = { - ...currentState, - instructions: [ - ...currentState.instructions, - { - mnemonic: "MSTORE", - opcode: 0x52, - debug: prologueDebug, - }, - ], - }; - } - - // Save the return PC from 0x60 to a dedicated slot - // so nested function calls don't clobber it. - const savedPcOffset = currentState.memory.savedReturnPcOffset; - if (savedPcOffset !== undefined) { - const savePcDebug = - func.sourceId && func.loc - ? { - context: { - gather: [ - { - remark: `prologue: save return PC to 0x${savedPcOffset.toString(16)}`, - }, - { - code: { - source: { id: func.sourceId }, - range: func.loc, - }, - }, - ], - } as Format.Program.Context, - } - : { - context: { - remark: `prologue: save return PC to 0x${savedPcOffset.toString(16)}`, - } as Format.Program.Context, - }; - const highByte = (savedPcOffset >> 8) & 0xff; - const lowByte = savedPcOffset & 0xff; - currentState = { - ...currentState, - instructions: [ - ...currentState.instructions, - { - mnemonic: "PUSH1", - opcode: 0x60, - immediates: [0x60], - debug: savePcDebug, - }, - { - mnemonic: "MLOAD", - opcode: 0x51, - debug: savePcDebug, - }, - { - mnemonic: "PUSH2", - opcode: 0x61, - immediates: [highByte, lowByte], - debug: savePcDebug, - }, - { - mnemonic: "MSTORE", - opcode: 0x52, - debug: savePcDebug, - }, - ], - }; + const d = makePrologueDebug(func); + const frameSize = currentState.memory.frameSize; + + if (frameSize !== undefined) { + // Allocate call frame from FMP and save context. + // Stack on entry: [argN, ..., arg1, arg0] + currentState = emitFrameAlloc(currentState, frameSize, d); + // Stack: [new_fp, argN, ..., arg0] + + // Pop new_fp — it's now stored at FRAME_POINTER. + currentState = emit(currentState, d, { mnemonic: "POP", opcode: 0x50 }); + // Stack: [argN, ..., arg0] + + // Store each param to its frame-relative slot. + // PUSH FP; MLOAD; PUSH offset; ADD produces the + // address on top; the arg is second; MSTORE + // consumes both. + for (let i = params.length - 1; i >= 0; i--) { + const alloc = currentState.memory.allocations[params[i].tempId]; + if (!alloc) continue; + currentState = emitFpRelativeStore(currentState, alloc.offset, d); + } } // Return with empty stack @@ -208,6 +110,181 @@ function generatePrologue( }) as Transition; } +// ----- prologue helpers ----- + +const { FRAME_POINTER, FREE_MEMORY_POINTER, RETURN_PC_SCRATCH } = + Memory.regions; +const { SAVED_RETURN_PC } = Memory.frameHeader; + +type Debug = Evm.Instruction["debug"]; +type Inst = Omit; + +/** Append instructions to state, attaching debug. */ +function emit( + state: State, + debug: Debug, + ...instrs: Inst[] +): State { + return { + ...state, + instructions: [ + ...state.instructions, + ...instrs.map((i) => ({ ...i, debug })), + ], + }; +} + +/** PUSH an integer as the smallest PUSHn. */ +function pushImm(value: number, debug: Debug): Evm.Instruction[] { + if (value === 0) { + return [{ mnemonic: "PUSH0", opcode: 0x5f, debug }]; + } + const bytes: number[] = []; + let v = value; + while (v > 0) { + bytes.unshift(v & 0xff); + v >>= 8; + } + const n = bytes.length; + return [ + { + mnemonic: `PUSH${n}`, + opcode: 0x5f + n, + immediates: bytes, + debug, + }, + ]; +} + +/** + * Emit frame allocation sequence. + * + * Pushes new_fp onto the stack, bumps FMP, saves old FP + * and return PC into the new frame, updates FRAME_POINTER. + * + * Stack effect: [...args] → [new_fp, ...args] + */ +function emitFrameAlloc( + state: State, + frameSize: number, + debug: Debug, +): State { + let s = state; + + // new_fp = mem[FMP] + s = emit(s, debug, ...pushImm(FREE_MEMORY_POINTER, debug), { + mnemonic: "MLOAD", + opcode: 0x51, + }); + // Stack: [new_fp, args...] + + // mem[FMP] = new_fp + frameSize + s = emit( + s, + debug, + { mnemonic: "DUP1", opcode: 0x80 }, + ...pushImm(frameSize, debug), + { mnemonic: "ADD", opcode: 0x01 }, + ...pushImm(FREE_MEMORY_POINTER, debug), + { mnemonic: "MSTORE", opcode: 0x52 }, + ); + + // frame[SAVED_FP] = old FP + // old_fp = mem[FRAME_POINTER] + s = emit( + s, + debug, + ...pushImm(FRAME_POINTER, debug), + { mnemonic: "MLOAD", opcode: 0x51 }, + // Stack: [old_fp, new_fp, args...] + { mnemonic: "DUP2", opcode: 0x81 }, + // Stack: [new_fp, old_fp, new_fp, args...] + { mnemonic: "MSTORE", opcode: 0x52 }, + // MSTORE(offset=new_fp, value=old_fp) + // → stores old_fp at new_fp+0 (SAVED_FP=0) + // Stack: [new_fp, args...] + ); + + // mem[FRAME_POINTER] = new_fp + s = emit( + s, + debug, + { mnemonic: "DUP1", opcode: 0x80 }, + ...pushImm(FRAME_POINTER, debug), + { mnemonic: "MSTORE", opcode: 0x52 }, + ); + + // frame[SAVED_RETURN_PC] = mem[RETURN_PC_SCRATCH] + s = emit( + s, + debug, + ...pushImm(RETURN_PC_SCRATCH, debug), + { mnemonic: "MLOAD", opcode: 0x51 }, + // Stack: [return_pc, new_fp, args...] + { mnemonic: "DUP2", opcode: 0x81 }, + // Stack: [new_fp, return_pc, new_fp, args...] + ...pushImm(SAVED_RETURN_PC, debug), + { mnemonic: "ADD", opcode: 0x01 }, + // Stack: [new_fp+0x20, return_pc, new_fp, args...] + { mnemonic: "MSTORE", opcode: 0x52 }, + // MSTORE(offset=new_fp+0x20, value=return_pc) + // Stack: [new_fp, args...] + ); + + return s; +} + +/** + * Emit FP-relative MSTORE. + * + * Computes mem[FRAME_POINTER] + offset and stores the + * current TOS value there. Consumes TOS. + * + * Stack effect: [value, ...] → [...] + */ +function emitFpRelativeStore( + state: State, + offset: number, + debug: Debug, +): State { + // Stack: [value, ...] + // → PUSH FP; MLOAD; PUSH offset; ADD + // Stack: [fp+offset, value, ...] + // → MSTORE (offset=fp+offset, value=value) + // Stack: [...] + return emit( + state, + debug, + ...pushImm(FRAME_POINTER, debug), + { mnemonic: "MLOAD", opcode: 0x51 }, + ...pushImm(offset, debug), + { mnemonic: "ADD", opcode: 0x01 }, + { mnemonic: "MSTORE", opcode: 0x52 }, + ); +} + +function makePrologueDebug(func: Ir.Function): Debug { + return func.sourceId && func.loc + ? { + context: { + gather: [ + { remark: "prologue: allocate call frame" }, + { + code: { + source: { id: func.sourceId }, + range: func.loc, + }, + }, + ], + } as Format.Program.Context, + } + : { + context: { + remark: "prologue: allocate call frame", + } as Format.Program.Context, + }; +} + /** * Generate bytecode for a function */ diff --git a/packages/bugc/src/evmgen/generation/instructions/binary.ts b/packages/bugc/src/evmgen/generation/instructions/binary.ts index 3298d5681..5bbcd0704 100644 --- a/packages/bugc/src/evmgen/generation/instructions/binary.ts +++ b/packages/bugc/src/evmgen/generation/instructions/binary.ts @@ -6,8 +6,22 @@ import { type Transition, operations, pipe, rebrand } from "#evmgen/operations"; import { loadValue, storeValueIfNeeded } from "../values/index.js"; -const { ADD, SUB, MUL, DIV, MOD, EQ, LT, GT, AND, OR, ISZERO, SHL, SHR } = - operations; +const { + ADD, + SUB, + MUL, + DIV, + MOD, + EQ, + LT, + GT, + AND, + OR, + ISZERO, + SHL, + SHR, + SWAP1, +} = operations; /** * Generate code for binary operations @@ -23,10 +37,25 @@ export function generateBinary( ) => State; } = { add: ADD({ debug }), - sub: SUB({ debug }), + // Non-commutative ops: operands load as [right=a, + // left=b] but EVM computes TOS op TOS-1 = right op + // left. SWAP1 puts left on TOS before the operation. + sub: pipe() + .then(SWAP1({ debug })) + .then(rebrand<"b", "a", "a", "b">({ 1: "a", 2: "b" })) + .then(SUB({ debug })) + .done(), mul: MUL({ debug }), - div: DIV({ debug }), - mod: MOD({ debug }), + div: pipe() + .then(SWAP1({ debug })) + .then(rebrand<"b", "a", "a", "b">({ 1: "a", 2: "b" })) + .then(DIV({ debug })) + .done(), + mod: pipe() + .then(SWAP1({ debug })) + .then(rebrand<"b", "a", "a", "b">({ 1: "a", 2: "b" })) + .then(MOD({ debug })) + .done(), shl: pipe() .then(rebrand<"a", "shift", "b", "value">({ 1: "shift", 2: "value" })) .then(SHL({ debug })) diff --git a/packages/bugc/src/evmgen/generation/values/load.ts b/packages/bugc/src/evmgen/generation/values/load.ts index ecfe7d88d..bab4bc209 100644 --- a/packages/bugc/src/evmgen/generation/values/load.ts +++ b/packages/bugc/src/evmgen/generation/values/load.ts @@ -2,17 +2,23 @@ import type * as Ir from "#ir"; import * as Evm from "#evm"; import type { Stack } from "#evm"; import { type Transition, operations, pipe } from "#evmgen/operations"; +import { Memory } from "#evmgen/analysis"; import { valueId, annotateTop } from "./identify.js"; /** - * Load a value onto the stack + * Load a value onto the stack. + * + * When the function uses call frames (memory.frameSize is + * set), allocation offsets are relative to the frame base + * stored at FRAME_POINTER (0x80). The load sequence becomes + * PUSH 0x80 / MLOAD / PUSH offset / ADD / MLOAD. */ export const loadValue = ( value: Ir.Value, options?: Evm.InstructionOptions, ): Transition => { - const { PUSHn, DUPn, MLOAD } = operations; + const { PUSHn, DUPn, ADD, MLOAD } = operations; const id = valueId(value); @@ -35,6 +41,18 @@ export const loadValue = ( // Check if in memory if (id in state.memory.allocations) { const offset = state.memory.allocations[id].offset; + if (state.memory.frameSize !== undefined) { + // FP-relative: load frame base, add offset + return builder + .then(PUSHn(BigInt(Memory.regions.FRAME_POINTER), options), { + as: "offset", + }) + .then(MLOAD(options), { as: "b" }) + .then(PUSHn(BigInt(offset), options), { as: "a" }) + .then(ADD(options), { as: "offset" }) + .then(MLOAD(options)) + .then(annotateTop(id)); + } return builder .then(PUSHn(BigInt(offset), options), { as: "offset" }) .then(MLOAD(options)) diff --git a/packages/bugc/src/evmgen/generation/values/store.ts b/packages/bugc/src/evmgen/generation/values/store.ts index eb813a34c..66751733f 100644 --- a/packages/bugc/src/evmgen/generation/values/store.ts +++ b/packages/bugc/src/evmgen/generation/values/store.ts @@ -1,17 +1,23 @@ import * as Evm from "#evm"; import type { Stack } from "#evm"; import { type Transition, operations, pipe } from "#evmgen/operations"; +import { Memory } from "#evmgen/analysis"; import { annotateTop } from "./identify.js"; /** - * Store a value to memory if it needs to be persisted + * Store a value to memory if it needs to be persisted. + * + * When the function uses call frames (memory.frameSize is + * set), the store address is FP-relative: load frame base + * from FRAME_POINTER, add the allocation offset, then + * MSTORE. */ export const storeValueIfNeeded = ( destId: string, options?: Evm.InstructionOptions, ): Transition => { - const { PUSHn, DUP2, SWAP1, MSTORE } = operations; + const { PUSHn, ADD, DUP2, SWAP1, MLOAD, MSTORE } = operations; return ( pipe() @@ -22,8 +28,30 @@ export const storeValueIfNeeded = ( if (allocation === undefined) { return builder; } + if (state.memory.frameSize !== undefined) { + // FP-relative store: + // stack: [value, ...] + // → PUSH FP_SLOT / MLOAD / PUSH offset / ADD + // → [addr, value, ...] + // → DUP2 / SWAP1 / MSTORE + // → [value, ...] + return builder + .then(PUSHn(BigInt(Memory.regions.FRAME_POINTER), options), { + as: "offset", + }) + .then(MLOAD(options), { as: "b" }) + .then(PUSHn(BigInt(allocation.offset), options), { + as: "a", + }) + .then(ADD(options), { as: "offset" }) + .then(DUP2(options)) + .then(SWAP1(options)) + .then(MSTORE(options)); + } return builder - .then(PUSHn(BigInt(allocation.offset), options), { as: "offset" }) + .then(PUSHn(BigInt(allocation.offset), options), { + as: "offset", + }) .then(DUP2(options)) .then(SWAP1(options)) .then(MSTORE(options)); From 26cec5921cc0428f99ca5db94d60f3c824d090d9 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 2 Apr 2026 05:21:19 -0400 Subject: [PATCH 31/39] docs: add recursive function call tracing example (#202) Add a recursive count/succ example to the tracing docs, showing how nested invoke/return contexts appear when functions call themselves repeatedly. --- .../core-schemas/programs/tracing-examples.ts | 27 +++++++++++++++++++ .../docs/core-schemas/programs/tracing.mdx | 12 +++++++++ 2 files changed, 39 insertions(+) diff --git a/packages/web/docs/core-schemas/programs/tracing-examples.ts b/packages/web/docs/core-schemas/programs/tracing-examples.ts index 63b1a4b6f..12013c33e 100644 --- a/packages/web/docs/core-schemas/programs/tracing-examples.ts +++ b/packages/web/docs/core-schemas/programs/tracing-examples.ts @@ -71,3 +71,30 @@ create { code { result = add(3, 4); }`; + +export const recursiveCount = `name Counter; + +define { + function succ(n: uint256) -> uint256 { + return n + 1; + }; + function count(n: uint256, target: uint256) -> uint256 { + if (n < target) { + return count(succ(n), target); + } else { + return n; + } + }; +} + +storage { + [0] result: uint256; +} + +create { + result = 0; +} + +code { + result = count(0, 5); +}`; diff --git a/packages/web/docs/core-schemas/programs/tracing.mdx b/packages/web/docs/core-schemas/programs/tracing.mdx index 2cb4f0bdd..125ae3c8f 100644 --- a/packages/web/docs/core-schemas/programs/tracing.mdx +++ b/packages/web/docs/core-schemas/programs/tracing.mdx @@ -10,6 +10,7 @@ import { thresholdCheck, multipleStorageSlots, functionCallAndReturn, + recursiveCount, } from "./tracing-examples"; # Tracing execution @@ -95,6 +96,17 @@ trace. Watch for **invoke** contexts on the JUMP into `add` and source={functionCallAndReturn} /> +Recursive calls produce nested invoke/return pairs. In this +example, `count` calls `succ` and then calls itself repeatedly +until `n` reaches `target`. Each recursive call adds a frame +to the call stack: + + + As you step through, three phases are visible: ### Before the call — setting up arguments From 322e55da80d5583bbc1ed74421a5a08b735c492c Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 2 Apr 2026 05:34:04 -0400 Subject: [PATCH 32/39] Fix call stack display for recursive function calls (#203) The call stack dedup logic compared only function name and call type, causing recursive calls (e.g. count -> count) to be collapsed into a single frame. Now also checks whether the previous frame was pushed on the immediately preceding step, which distinguishes the compiler's duplicate invoke contexts (caller JUMP + callee JUMPDEST on consecutive steps) from genuine recursive calls (same name but steps far apart). Fixed in both programs-react buildCallStack and web TraceDrawer's duplicated call stack logic. --- .../programs-react/src/utils/mockTrace.ts | 20 +++++++++++-------- .../src/theme/ProgramExample/TraceDrawer.tsx | 18 ++++++++++------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/packages/programs-react/src/utils/mockTrace.ts b/packages/programs-react/src/utils/mockTrace.ts index 78af909c3..23d8483ed 100644 --- a/packages/programs-react/src/utils/mockTrace.ts +++ b/packages/programs-react/src/utils/mockTrace.ts @@ -298,15 +298,19 @@ export function buildCallStack( } if (callInfo.kind === "invoke") { - // The compiler emits invoke on both the caller JUMP and - // callee entry JUMPDEST. Skip if the top frame already - // matches this call. + // The compiler emits invoke on both the caller JUMP + // and callee entry JUMPDEST for the same call. These + // occur on consecutive trace steps. Only skip if the + // top frame matches AND was pushed on the immediately + // preceding step — otherwise this is a new call (e.g. + // recursion with the same function name). const top = stack[stack.length - 1]; - if ( - !top || - top.identifier !== callInfo.identifier || - top.callType !== callInfo.callType - ) { + const isDuplicate = + top && + top.identifier === callInfo.identifier && + top.callType === callInfo.callType && + top.stepIndex === i - 1; + if (!isDuplicate) { stack.push({ identifier: callInfo.identifier, stepIndex: i, diff --git a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx index 088fa8d3f..99cdea4b8 100644 --- a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx +++ b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx @@ -118,14 +118,18 @@ function TraceDrawerContent(): JSX.Element { if (info.kind === "invoke") { // The compiler emits invoke on both the caller - // JUMP and callee entry JUMPDEST. Skip if the - // top frame already matches this call. + // JUMP and callee entry JUMPDEST for the same + // call. These occur on consecutive trace steps. + // Only skip if the top frame matches AND was + // pushed on the immediately preceding step — + // otherwise this is a new call (e.g. recursion). const top = frames[frames.length - 1]; - if ( - !top || - top.identifier !== info.identifier || - top.callType !== info.callType - ) { + const isDuplicate = + top && + top.identifier === info.identifier && + top.callType === info.callType && + top.stepIndex === i - 1; + if (!isDuplicate) { frames.push({ identifier: info.identifier, stepIndex: i, From 49d63c5118903cd06eab52220a40ff04c7bfcc82 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 2 Apr 2026 07:15:24 -0400 Subject: [PATCH 33/39] Add source-level stepping to trace viewer controls (#204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use outline triangles (◁/▷) for single-step and filled triangles (◀/▶) for source-level stepping, alongside skip-to-start (⏮) and skip-to-end (⏭) controls. --- .../src/components/TraceContext.tsx | 93 ++++++++++++++++++- .../src/components/TraceControls.tsx | 32 +++++-- .../src/theme/ProgramExample/TraceDrawer.tsx | 61 +++++++++++- 3 files changed, 175 insertions(+), 11 deletions(-) diff --git a/packages/programs-react/src/components/TraceContext.tsx b/packages/programs-react/src/components/TraceContext.tsx index d737372ae..c086daf26 100644 --- a/packages/programs-react/src/components/TraceContext.tsx +++ b/packages/programs-react/src/components/TraceContext.tsx @@ -23,6 +23,54 @@ import { } from "#utils/mockTrace"; import { traceStepToMachineState } from "#utils/traceState"; +/** + * Compute a key representing an instruction's source range, + * used to detect when stepping has moved to a new source + * location. Returns empty string for instructions without + * source ranges. + */ +function sourceRangeKey(instruction: Program.Instruction | undefined): string { + if (!instruction?.context) return ""; + + const ctx = instruction.context as Record; + const ranges = collectCodeRanges(ctx); + if (ranges.length === 0) return ""; + + return ranges.map((r) => `${r.offset}:${r.length}`).join(","); +} + +function collectCodeRanges( + ctx: Record, +): Array<{ offset: number; length: number }> { + if ("code" in ctx && typeof ctx.code === "object") { + const code = ctx.code as Record; + if (code.range && typeof code.range === "object") { + const r = code.range as Record; + if (typeof r.offset === "number" && typeof r.length === "number") { + return [{ offset: r.offset, length: r.length }]; + } + } + } + + if ("gather" in ctx && Array.isArray(ctx.gather)) { + return ctx.gather.flatMap((item: unknown) => + item && typeof item === "object" + ? collectCodeRanges(item as Record) + : [], + ); + } + + if ("pick" in ctx && Array.isArray(ctx.pick)) { + return ctx.pick.flatMap((item: unknown) => + item && typeof item === "object" + ? collectCodeRanges(item as Record) + : [], + ); + } + + return []; +} + /** * A variable with its resolved value. */ @@ -98,10 +146,14 @@ export interface TraceState { /** Whether we're at the last step */ isAtEnd: boolean; - /** Move to the next step */ + /** Move to the next trace step */ stepForward(): void; - /** Move to the previous step */ + /** Move to the previous trace step */ stepBackward(): void; + /** Step to the next different source range */ + stepToNextSource(): void; + /** Step to the previous different source range */ + stepToPrevSource(): void; /** Jump to a specific step */ jumpToStep(index: number): void; /** Reset to the first step */ @@ -377,6 +429,41 @@ export function TraceProvider({ setCurrentStepIndex((prev) => Math.max(prev - 1, 0)); }, []); + const stepToNextSource = useCallback(() => { + setCurrentStepIndex((prev) => { + const currentKey = sourceRangeKey(pcToInstruction.get(trace[prev]?.pc)); + for (let i = prev + 1; i < trace.length; i++) { + const instr = pcToInstruction.get(trace[i].pc); + const key = sourceRangeKey(instr); + if (key !== currentKey && key !== "") { + return i; + } + } + return trace.length - 1; + }); + }, [trace, pcToInstruction]); + + const stepToPrevSource = useCallback(() => { + setCurrentStepIndex((prev) => { + const currentKey = sourceRangeKey(pcToInstruction.get(trace[prev]?.pc)); + // First skip past all steps with the same range + let i = prev - 1; + while (i > 0) { + const key = sourceRangeKey(pcToInstruction.get(trace[i].pc)); + if (key !== currentKey && key !== "") break; + i--; + } + // Now find the start of that source range + const targetKey = sourceRangeKey(pcToInstruction.get(trace[i]?.pc)); + while (i > 0) { + const prevKey = sourceRangeKey(pcToInstruction.get(trace[i - 1]?.pc)); + if (prevKey !== targetKey) break; + i--; + } + return Math.max(0, i); + }); + }, [trace, pcToInstruction]); + const jumpToStep = useCallback( (index: number) => { setCurrentStepIndex(Math.max(0, Math.min(index, trace.length - 1))); @@ -406,6 +493,8 @@ export function TraceProvider({ isAtEnd: currentStepIndex >= trace.length - 1, stepForward, stepBackward, + stepToNextSource, + stepToPrevSource, jumpToStep, reset, jumpToEnd, diff --git a/packages/programs-react/src/components/TraceControls.tsx b/packages/programs-react/src/components/TraceControls.tsx index cbda774c7..dc3c8c499 100644 --- a/packages/programs-react/src/components/TraceControls.tsx +++ b/packages/programs-react/src/components/TraceControls.tsx @@ -33,6 +33,8 @@ export function TraceControls({ isAtEnd, stepBackward, stepForward, + stepToPrevSource, + stepToNextSource, reset, jumpToEnd, } = useTraceContext(); @@ -47,25 +49,43 @@ export function TraceControls({ title="Reset to start" type="button" > - ⏮ + ⏮ + + +
diff --git a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx index 99cdea4b8..f14dcf3c4 100644 --- a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx +++ b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx @@ -258,6 +258,45 @@ function TraceDrawerContent(): JSX.Element { setCurrentStep((prev) => Math.max(prev - 1, 0)); }; + const rangeKey = (stepIdx: number): string => { + const step = trace[stepIdx]; + if (!step) return ""; + const instr = pcToInstruction.get(step.pc); + if (!instr?.debug?.context) return ""; + const ranges = extractSourceRange(instr.debug.context); + if (ranges.length === 0) return ""; + return ranges.map((r) => `${r.offset}:${r.length}`).join(","); + }; + + const stepToNextSource = () => { + setCurrentStep((prev) => { + const currentKey = rangeKey(prev); + for (let i = prev + 1; i < trace.length; i++) { + const key = rangeKey(i); + if (key !== currentKey && key !== "") return i; + } + return trace.length - 1; + }); + }; + + const stepToPrevSource = () => { + setCurrentStep((prev) => { + const currentKey = rangeKey(prev); + let i = prev - 1; + while (i > 0) { + const key = rangeKey(i); + if (key !== currentKey && key !== "") break; + i--; + } + const targetKey = rangeKey(i); + while (i > 0) { + if (rangeKey(i - 1) !== targetKey) break; + i--; + } + return Math.max(0, i); + }); + }; + const jumpToStart = () => setCurrentStep(0); const jumpToEnd = () => setCurrentStep(trace.length - 1); @@ -332,21 +371,37 @@ function TraceDrawerContent(): JSX.Element { ⏮ + {currentStep + 1} / {trace.length} + From 67b0c3d398f2f69a7e1a270264eaa237a5379d2e Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 2 Apr 2026 07:21:30 -0400 Subject: [PATCH 34/39] docs: replace recursive example with isEven/isOdd mutual recursion (#205) Replace the succ/count recursive example with an isEven/isOdd mutual recursion example, which better demonstrates alternating invoke/return contexts between two functions. --- .../core-schemas/programs/tracing-examples.ts | 18 ++++++++---------- .../web/docs/core-schemas/programs/tracing.mdx | 14 +++++++------- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/packages/web/docs/core-schemas/programs/tracing-examples.ts b/packages/web/docs/core-schemas/programs/tracing-examples.ts index 12013c33e..08cb5f8df 100644 --- a/packages/web/docs/core-schemas/programs/tracing-examples.ts +++ b/packages/web/docs/core-schemas/programs/tracing-examples.ts @@ -72,18 +72,16 @@ code { result = add(3, 4); }`; -export const recursiveCount = `name Counter; +export const mutualRecursion = `name EvenOdd; define { - function succ(n: uint256) -> uint256 { - return n + 1; + function isEven(n: uint256) -> uint256 { + if (n == 0) { return 1; } + else { return isOdd(n - 1); } }; - function count(n: uint256, target: uint256) -> uint256 { - if (n < target) { - return count(succ(n), target); - } else { - return n; - } + function isOdd(n: uint256) -> uint256 { + if (n == 0) { return 0; } + else { return isEven(n - 1); } }; } @@ -96,5 +94,5 @@ create { } code { - result = count(0, 5); + result = isEven(4); }`; diff --git a/packages/web/docs/core-schemas/programs/tracing.mdx b/packages/web/docs/core-schemas/programs/tracing.mdx index 125ae3c8f..a22c6551d 100644 --- a/packages/web/docs/core-schemas/programs/tracing.mdx +++ b/packages/web/docs/core-schemas/programs/tracing.mdx @@ -10,7 +10,7 @@ import { thresholdCheck, multipleStorageSlots, functionCallAndReturn, - recursiveCount, + mutualRecursion, } from "./tracing-examples"; # Tracing execution @@ -96,15 +96,15 @@ trace. Watch for **invoke** contexts on the JUMP into `add` and source={functionCallAndReturn} /> -Recursive calls produce nested invoke/return pairs. In this -example, `count` calls `succ` and then calls itself repeatedly -until `n` reaches `target`. Each recursive call adds a frame +Mutual recursion produces alternating invoke/return pairs. In +this example, `isEven` and `isOdd` call each other, bouncing +back and forth until `n` reaches zero. Each call adds a frame to the call stack: As you step through, three phases are visible: From 74d75745625fa8c7346d69e56666cccfb5c1a44b Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 2 Apr 2026 07:56:40 -0400 Subject: [PATCH 35/39] bugc: add skipped test for optimizer recursion bug Nested call arguments (e.g. count(succ(n), target)) fail at optimizer level 2+. Add a skipped test to track the issue. --- packages/bugc/src/evmgen/behavioral.test.ts | 33 +++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/bugc/src/evmgen/behavioral.test.ts b/packages/bugc/src/evmgen/behavioral.test.ts index baf774dbc..1dfdfeff5 100644 --- a/packages/bugc/src/evmgen/behavioral.test.ts +++ b/packages/bugc/src/evmgen/behavioral.test.ts @@ -363,6 +363,39 @@ code { result = count(0, 5); }`; expect(await result.getStorage(0n)).toBe(5n); }); + // Known bug: nested call arguments (e.g. count(succ(n), target)) + // fail at optimizer level 2+. Tracked separately. + it.skip("should support recursion at optimization level 2", async () => { + const source = `name RecursionOpt; + +define { + function succ(n: uint256) -> uint256 { + return n + 1; + }; + function count( + n: uint256, target: uint256 + ) -> uint256 { + if (n < target) { + return count(succ(n), target); + } else { + return n; + } + }; +} + +storage { [0] result: uint256; } +create { result = 0; } +code { result = count(0, 5); }`; + + const result = await executeProgram(source, { + calldata: "", + optimizationLevel: 2, + }); + + expect(result.callSuccess).toBe(true); + expect(await result.getStorage(0n)).toBe(5n); + }); + it("should support simple self-recursion", async () => { const source = `name SimpleRecursion; From c29b841ee1fd8b5f60843568bae7bfb51a7596bf Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 2 Apr 2026 08:05:49 -0400 Subject: [PATCH 36/39] Resolve argument values in call stack display (#206) Store argument pointers on call stack frames and resolve them against historical machine state at each frame's invoke step. Display as "add(a: 3, b: 4)" in both CallStackDisplay and TraceDrawer breadcrumbs. When the compiler emits duplicate invoke contexts (caller JUMP + callee JUMPDEST), use the callee entry step for resolution since argument pointers reference stack slots valid at the JUMPDEST, not the JUMP. Fix stack peek indexing in both traceStepToMachineState and traceStepToState: depth 0 should read from the top of the stack (last array element), not the bottom. The evm package stores stacks bottom-first. Values are cached by step index to avoid re-resolving unchanged frames. Small values (<=9999) display as decimal, larger values as hex. --- .../src/components/CallStackDisplay.tsx | 43 ++- .../src/components/TraceContext.tsx | 113 +++++++ .../programs-react/src/components/index.ts | 1 + packages/programs-react/src/index.ts | 1 + .../programs-react/src/utils/mockTrace.ts | 35 ++- .../programs-react/src/utils/traceState.ts | 4 +- .../src/theme/ProgramExample/TraceDrawer.tsx | 277 +++++++++++++++++- 7 files changed, 448 insertions(+), 26 deletions(-) diff --git a/packages/programs-react/src/components/CallStackDisplay.tsx b/packages/programs-react/src/components/CallStackDisplay.tsx index 7f9b087c6..09e2bf7aa 100644 --- a/packages/programs-react/src/components/CallStackDisplay.tsx +++ b/packages/programs-react/src/components/CallStackDisplay.tsx @@ -19,10 +19,49 @@ export interface CallStackDisplayProps { * Shows function names separated by arrows, e.g.: * main() -> transfer() -> _update() */ +function formatArgs( + frame: { identifier?: string; stepIndex: number }, + resolvedCallStack: Array<{ + stepIndex: number; + resolvedArgs?: Array<{ + name: string; + value?: string; + }>; + }>, +): string { + const resolved = resolvedCallStack.find( + (r) => r.stepIndex === frame.stepIndex, + ); + if (!resolved?.resolvedArgs) { + return ""; + } + return resolved.resolvedArgs + .map((arg) => { + if (arg.value === undefined) { + return arg.name; + } + const decimal = formatAsDecimal(arg.value); + return `${arg.name}: ${decimal}`; + }) + .join(", "); +} + +function formatAsDecimal(hex: string): string { + try { + const n = BigInt(hex); + if (n <= 9999n) { + return n.toString(); + } + return hex; + } catch { + return hex; + } +} + export function CallStackDisplay({ className = "", }: CallStackDisplayProps): JSX.Element { - const { callStack, jumpToStep } = useTraceContext(); + const { callStack, resolvedCallStack, jumpToStep } = useTraceContext(); if (callStack.length === 0) { return ( @@ -53,7 +92,7 @@ export function CallStackDisplay({ {frame.identifier || "(anonymous)"} - ({frame.argumentNames ? frame.argumentNames.join(", ") : ""}) + ({formatArgs(frame, resolvedCallStack)}) diff --git a/packages/programs-react/src/components/TraceContext.tsx b/packages/programs-react/src/components/TraceContext.tsx index c086daf26..581b72143 100644 --- a/packages/programs-react/src/components/TraceContext.tsx +++ b/packages/programs-react/src/components/TraceContext.tsx @@ -9,6 +9,7 @@ import React, { useCallback, useEffect, useMemo, + useRef, } from "react"; import type { Pointer, Program } from "@ethdebug/format"; import { dereference, Data } from "@ethdebug/pointers"; @@ -119,6 +120,24 @@ export interface ResolvedCallInfo { pointerRefs: ResolvedPointerRef[]; } +/** + * A call frame with resolved argument values. + */ +export interface ResolvedCallFrame { + /** Function name */ + identifier?: string; + /** The step index where this call was invoked */ + stepIndex: number; + /** The call type */ + callType?: "internal" | "external" | "create"; + /** Argument names paired with resolved values */ + resolvedArgs?: Array<{ + name: string; + value?: string; + error?: string; + }>; +} + /** * State provided by the Trace context. */ @@ -139,6 +158,8 @@ export interface TraceState { currentVariables: ResolvedVariable[]; /** Call stack at current step */ callStack: CallFrame[]; + /** Call stack with resolved argument values */ + resolvedCallStack: ResolvedCallFrame[]; /** Call info for current instruction (if any) */ currentCallInfo: ResolvedCallInfo | undefined; /** Whether we're at the first step */ @@ -339,6 +360,97 @@ export function TraceProvider({ [trace, pcToInstruction, currentStepIndex], ); + // Resolve argument values for call stack frames. + // Cache by stepIndex so we don't re-resolve frames that + // haven't changed when the user steps forward. + const argCacheRef = useRef>( + new Map(), + ); + + const [resolvedCallStack, setResolvedCallStack] = useState< + ResolvedCallFrame[] + >([]); + + useEffect(() => { + if (callStack.length === 0) { + setResolvedCallStack([]); + return; + } + + // Build initial resolved frames using cached values + const initial: ResolvedCallFrame[] = callStack.map((frame) => ({ + identifier: frame.identifier, + stepIndex: frame.stepIndex, + callType: frame.callType, + resolvedArgs: argCacheRef.current.get(frame.stepIndex), + })); + setResolvedCallStack(initial); + + if (!shouldResolve) { + return; + } + + let cancelled = false; + const resolved = [...initial]; + + // Resolve frames that aren't cached yet + const promises = callStack.map(async (frame, index) => { + if (argCacheRef.current.has(frame.stepIndex)) { + return; + } + + const names = frame.argumentNames; + const pointers = frame.argumentPointers; + if (!pointers || pointers.length === 0) { + return; + } + + const step = trace[frame.stepIndex]; + if (!step) { + return; + } + + const args: NonNullable = pointers.map( + (_, i) => ({ + name: names?.[i] ?? `_${i}`, + }), + ); + + const resolvePromises = pointers.map(async (ptr, i) => { + try { + const value = await resolveVariableValue( + ptr as Pointer, + step, + templates, + ); + args[i] = { ...args[i], value }; + } catch (err) { + args[i] = { + ...args[i], + error: err instanceof Error ? err.message : String(err), + }; + } + }); + + await Promise.all(resolvePromises); + + if (!cancelled) { + argCacheRef.current.set(frame.stepIndex, args); + resolved[index] = { + ...resolved[index], + resolvedArgs: args, + }; + setResolvedCallStack([...resolved]); + } + }); + + Promise.all(promises).catch(() => {}); + + return () => { + cancelled = true; + }; + }, [callStack, shouldResolve, trace, templates]); + // Extract call info for current instruction (synchronous) const extractedCallInfo = useMemo((): CallInfo | undefined => { if (!currentInstruction) { @@ -488,6 +600,7 @@ export function TraceProvider({ currentInstruction, currentVariables, callStack, + resolvedCallStack, currentCallInfo, isAtStart: currentStepIndex === 0, isAtEnd: currentStepIndex >= trace.length - 1, diff --git a/packages/programs-react/src/components/index.ts b/packages/programs-react/src/components/index.ts index 222acf051..404294b74 100644 --- a/packages/programs-react/src/components/index.ts +++ b/packages/programs-react/src/components/index.ts @@ -23,6 +23,7 @@ export { type TraceProviderProps, type ResolvedVariable, type ResolvedCallInfo, + type ResolvedCallFrame, type ResolvedPointerRef, } from "./TraceContext.js"; diff --git a/packages/programs-react/src/index.ts b/packages/programs-react/src/index.ts index 2fa17ad36..6253933c2 100644 --- a/packages/programs-react/src/index.ts +++ b/packages/programs-react/src/index.ts @@ -32,6 +32,7 @@ export { type TraceProviderProps, type ResolvedVariable, type ResolvedCallInfo, + type ResolvedCallFrame, type ResolvedPointerRef, type TraceControlsProps, type TraceProgressProps, diff --git a/packages/programs-react/src/utils/mockTrace.ts b/packages/programs-react/src/utils/mockTrace.ts index 23d8483ed..ddbee9dfa 100644 --- a/packages/programs-react/src/utils/mockTrace.ts +++ b/packages/programs-react/src/utils/mockTrace.ts @@ -272,6 +272,8 @@ export interface CallFrame { callType?: "internal" | "external" | "create"; /** Named arguments (from invoke context) */ argumentNames?: string[]; + /** Individual argument pointers for value resolution */ + argumentPointers?: unknown[]; } /** @@ -310,12 +312,21 @@ export function buildCallStack( top.identifier === callInfo.identifier && top.callType === callInfo.callType && top.stepIndex === i - 1; - if (!isDuplicate) { + if (isDuplicate) { + // Use the callee entry step for resolution — + // the argument pointers reference stack slots + // that are valid at the JUMPDEST, not the JUMP + const argResult = extractArgInfo(instruction); + top.stepIndex = i; + top.argumentPointers = argResult?.pointers; + } else { + const argResult = extractArgInfo(instruction); stack.push({ identifier: callInfo.identifier, stepIndex: i, callType: callInfo.callType, - argumentNames: extractArgNames(instruction), + argumentNames: argResult?.names, + argumentPointers: argResult?.pointers, }); } } else if (callInfo.kind === "return" || callInfo.kind === "revert") { @@ -330,16 +341,15 @@ export function buildCallStack( } /** - * Extract argument names from an instruction's invoke - * context, if present. + * Extract argument names and pointers from an + * instruction's invoke context, if present. */ -function extractArgNames( +function extractArgInfo( instruction: Program.Instruction, -): string[] | undefined { +): { names?: string[]; pointers?: unknown[] } | undefined { const ctx = instruction.context as Record | undefined; if (!ctx) return undefined; - // Find the invoke field (may be nested in gather) const invoke = findInvokeField(ctx); if (!invoke) return undefined; @@ -353,18 +363,23 @@ function extractArgNames( if (!Array.isArray(group)) return undefined; const names: string[] = []; - let hasAny = false; + const pointers: unknown[] = []; + let hasAnyName = false; for (const entry of group) { const name = entry.name as string | undefined; if (name) { names.push(name); - hasAny = true; + hasAnyName = true; } else { names.push("_"); } + pointers.push(entry); } - return hasAny ? names : undefined; + return { + names: hasAnyName ? names : undefined, + pointers, + }; } function findInvokeField( diff --git a/packages/programs-react/src/utils/traceState.ts b/packages/programs-react/src/utils/traceState.ts index 8b5643a6d..a62141383 100644 --- a/packages/programs-react/src/utils/traceState.ts +++ b/packages/programs-react/src/utils/traceState.ts @@ -39,8 +39,8 @@ export function traceStepToMachineState(step: TraceStep): Machine.State { return Promise.resolve(BigInt(stackEntries.length)); }, async peek({ depth, slice }) { - const index = Number(depth); - if (index >= stackEntries.length) { + const index = stackEntries.length - 1 - Number(depth); + if (index < 0 || index >= stackEntries.length) { throw new Error( `Stack underflow: depth ${depth} ` + `exceeds stack size ${stackEntries.length}`, diff --git a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx index f14dcf3c4..e4cff9529 100644 --- a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx +++ b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx @@ -8,7 +8,13 @@ * - Step-through trace visualization */ -import React, { useState, useCallback, useEffect, useMemo } from "react"; +import React, { + useState, + useCallback, + useEffect, + useMemo, + useRef, +} from "react"; import BrowserOnly from "@docusaurus/BrowserOnly"; import { compile as bugCompile, Severity, type Evm } from "@ethdebug/bugc"; import { @@ -18,6 +24,7 @@ import { extractSourceRange, } from "@ethdebug/bugc-react"; import { Executor, createTraceCollector, type TraceStep } from "@ethdebug/evm"; +import { dereference, Data, type Machine } from "@ethdebug/pointers"; import { Drawer } from "@theme/Drawer"; import { useTracePlayground } from "./TracePlaygroundContext"; @@ -106,6 +113,7 @@ function TraceDrawerContent(): JSX.Element { stepIndex: number; callType?: string; argumentNames?: string[]; + argumentPointers?: unknown[]; }> = []; for (let i = 0; i <= currentStep && i < trace.length; i++) { @@ -129,12 +137,19 @@ function TraceDrawerContent(): JSX.Element { top.identifier === info.identifier && top.callType === info.callType && top.stepIndex === i - 1; - if (!isDuplicate) { + if (isDuplicate) { + // Use the callee entry step for resolution — + // argument pointers reference stack slots + // valid at the JUMPDEST, not the JUMP + top.stepIndex = i; + top.argumentPointers = info.argumentPointers; + } else { frames.push({ identifier: info.identifier, stepIndex: i, callType: info.callType, argumentNames: info.argumentNames, + argumentPointers: info.argumentPointers, }); } } else if (info.kind === "return" || info.kind === "revert") { @@ -147,6 +162,79 @@ function TraceDrawerContent(): JSX.Element { return frames; }, [trace, currentStep, pcToInstruction]); + // Resolve argument values for call stack frames + const argCacheRef = useRef>(new Map()); + + const [resolvedArgs, setResolvedArgs] = useState>( + new Map(), + ); + + useEffect(() => { + if (callStack.length === 0) { + setResolvedArgs(new Map()); + return; + } + + // Initialize with cached values + const initial = new Map(); + for (const frame of callStack) { + const cached = argCacheRef.current.get(frame.stepIndex); + if (cached) { + initial.set(frame.stepIndex, cached); + } + } + setResolvedArgs(new Map(initial)); + + let cancelled = false; + + const promises = callStack.map(async (frame) => { + if (argCacheRef.current.has(frame.stepIndex)) { + return; + } + + const ptrs = frame.argumentPointers; + const names = frame.argumentNames; + if (!ptrs || ptrs.length === 0) return; + + const step = trace[frame.stepIndex]; + if (!step) return; + + const state = traceStepToState(step, storage); + const args: ResolvedArg[] = ptrs.map((_, i) => ({ + name: names?.[i] ?? `_${i}`, + })); + + const resolvePromises = ptrs.map(async (ptr, i) => { + try { + const value = await resolvePointer(ptr, state); + args[i] = { ...args[i], value }; + } catch (err) { + args[i] = { + ...args[i], + error: err instanceof Error ? err.message : String(err), + }; + } + }); + + await Promise.all(resolvePromises); + + if (!cancelled) { + argCacheRef.current.set(frame.stepIndex, args); + setResolvedArgs((prev) => { + const next = new Map(prev); + next.set(frame.stepIndex, args); + return next; + }); + } + }); + + Promise.all(promises).catch(() => {}); + + return () => { + cancelled = true; + }; + }, [callStack, trace, storage]); + // Compile source and run trace in one shot. // Takes source directly to avoid stale-state issues. const compileAndTrace = useCallback(async (sourceCode: string) => { @@ -431,10 +519,7 @@ function TraceDrawerContent(): JSX.Element { type="button" > {frame.identifier || "(anonymous)"}( - {frame.argumentNames - ? frame.argumentNames.join(", ") - : ""} - ) + {formatFrameArgs(frame, resolvedArgs)}) )) @@ -627,6 +712,7 @@ interface CallInfoResult { identifier?: string; callType?: string; argumentNames?: string[]; + argumentPointers?: unknown[]; } /** @@ -646,11 +732,13 @@ function extractCallInfo(context: unknown): CallInfoResult | undefined { else if ("message" in inv) callType = "external"; else if ("create" in inv) callType = "create"; + const argInfo = extractArgInfoFromInvoke(inv); return { kind: "invoke", identifier: inv.identifier as string | undefined, callType, - argumentNames: extractArgNamesFromInvoke(inv), + argumentNames: argInfo?.names, + argumentPointers: argInfo?.pointers, }; } @@ -708,9 +796,9 @@ function formatCallBanner(info: CallInfoResult): string { } } -function extractArgNamesFromInvoke( +function extractArgInfoFromInvoke( inv: Record, -): string[] | undefined { +): { names?: string[]; pointers?: unknown[] } | undefined { const args = inv.arguments as Record | undefined; if (!args) return undefined; @@ -721,18 +809,23 @@ function extractArgNamesFromInvoke( if (!Array.isArray(group)) return undefined; const names: string[] = []; - let hasAny = false; + const pointers: unknown[] = []; + let hasAnyName = false; for (const entry of group) { const name = entry.name as string | undefined; if (name) { names.push(name); - hasAny = true; + hasAnyName = true; } else { names.push("_"); } + pointers.push(entry); } - return hasAny ? names : undefined; + return { + names: hasAnyName ? names : undefined, + pointers, + }; } /** @@ -808,4 +901,164 @@ function formatType(type: unknown): string { return JSON.stringify(type); } +function formatFrameArgs( + frame: { + stepIndex: number; + argumentNames?: string[]; + }, + resolved: Map, +): string { + const args = resolved.get(frame.stepIndex); + if (!args) { + return frame.argumentNames ? frame.argumentNames.join(", ") : ""; + } + return args + .map((arg) => { + if (arg.value === undefined) return arg.name; + const decimal = formatAsDecimal(arg.value); + return `${arg.name}: ${decimal}`; + }) + .join(", "); +} + +function formatAsDecimal(hex: string): string { + try { + const n = BigInt(hex); + if (n <= 9999n) return n.toString(); + return hex; + } catch { + return hex; + } +} + +/** + * Convert an evm TraceStep + storage into a Machine.State + * for pointer dereferencing. + */ +function traceStepToState( + step: TraceStep, + storage: Record, +): Machine.State { + const stackEntries = step.stack.map((v) => + Data.fromUint(v).padUntilAtLeast(32), + ); + + const memoryData = step.memory ? Data.fromBytes(step.memory) : Data.zero(); + + const storageMap = new Map(); + for (const [slot, value] of Object.entries(storage)) { + const key = Data.fromHex(slot).padUntilAtLeast(32).toHex(); + storageMap.set(key, Data.fromHex(value).padUntilAtLeast(32)); + } + + const stack: Machine.State.Stack = { + get length() { + return Promise.resolve(BigInt(stackEntries.length)); + }, + async peek({ depth, slice }) { + const index = stackEntries.length - 1 - Number(depth); + if (index < 0 || index >= stackEntries.length) { + throw new Error(`Stack underflow: depth ${depth}`); + } + const entry = stackEntries[index]; + if (!slice) return entry; + const { offset, length } = slice; + return Data.fromBytes( + entry.slice(Number(offset), Number(offset + length)), + ); + }, + }; + + const makeBytesReader = (data: Data): Machine.State.Bytes => ({ + get length() { + return Promise.resolve(BigInt(data.length)); + }, + async read({ slice }) { + const { offset, length } = slice; + const start = Number(offset); + const end = start + Number(length); + if (end > data.length) { + const result = new Uint8Array(Number(length)); + const available = Math.max(0, data.length - start); + if (available > 0 && start < data.length) { + result.set(data.slice(start, start + available), 0); + } + return Data.fromBytes(result); + } + return Data.fromBytes(data.slice(start, end)); + }, + }); + + const storageReader: Machine.State.Words = { + async read({ slot, slice }) { + const key = slot.padUntilAtLeast(32).toHex(); + const value = storageMap.get(key) || Data.zero().padUntilAtLeast(32); + if (!slice) return value; + const { offset, length } = slice; + return Data.fromBytes( + value.slice(Number(offset), Number(offset + length)), + ); + }, + }; + + const emptyWords: Machine.State.Words = { + async read({ slice }) { + const value = Data.zero().padUntilAtLeast(32); + if (!slice) return value; + const { offset, length } = slice; + return Data.fromBytes( + value.slice(Number(offset), Number(offset + length)), + ); + }, + }; + + return { + get traceIndex() { + return Promise.resolve(0n); + }, + get programCounter() { + return Promise.resolve(BigInt(step.pc)); + }, + get opcode() { + return Promise.resolve(step.opcode); + }, + stack, + memory: makeBytesReader(memoryData), + storage: storageReader, + calldata: makeBytesReader(Data.zero()), + returndata: makeBytesReader(Data.zero()), + code: makeBytesReader(Data.zero()), + transient: emptyWords, + }; +} + +/** + * Resolve a single pointer against a machine state. + */ +async function resolvePointer( + pointer: unknown, + state: Machine.State, +): Promise { + const cursor = await dereference( + pointer as Parameters[0], + { state, templates: {} }, + ); + const view = await cursor.view(state); + + const values: Data[] = []; + for (const region of view.regions) { + values.push(await view.read(region)); + } + + if (values.length === 0) return "0x"; + if (values.length === 1) return values[0].toHex(); + return values.map((d) => d.toHex()).join(", "); +} + +interface ResolvedArg { + name: string; + value?: string; + error?: string; +} + export default TraceDrawer; From 8e257a5657846ffcb30e1eda0c009b4657a0daa8 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 2 Apr 2026 08:32:12 -0400 Subject: [PATCH 37/39] bugc: fix optimizer for nested call arguments in recursion (#208) Move phi resolution from target blocks to predecessor blocks (standard phi deconstruction). The previous approach resolved phis at the target using the layout-order predecessor, which broke for back-edges where the runtime predecessor differs. Also fixes jump optimization eliminating phi-bearing blocks and rewrites TCO to use a pre_entry trampoline with phis on the original entry block. --- packages/bugc/src/evmgen/behavioral.test.ts | 57 ++++++++++ packages/bugc/src/evmgen/generation/block.ts | 29 ++++- .../src/optimizer/steps/jump-optimization.ts | 1 + .../steps/tail-call-optimization.test.ts | 26 +++-- .../optimizer/steps/tail-call-optimization.ts | 101 ++++++++++-------- 5 files changed, 154 insertions(+), 60 deletions(-) diff --git a/packages/bugc/src/evmgen/behavioral.test.ts b/packages/bugc/src/evmgen/behavioral.test.ts index 1dfdfeff5..ab2f18d39 100644 --- a/packages/bugc/src/evmgen/behavioral.test.ts +++ b/packages/bugc/src/evmgen/behavioral.test.ts @@ -420,6 +420,63 @@ code { result = factorial(5); }`; expect(result.callSuccess).toBe(true); expect(await result.getStorage(0n)).toBe(120n); }); + + it("should support recursion at optimization level 2", async () => { + const source = `name RecursionOpt; + +define { + function succ(n: uint256) -> uint256 { + return n + 1; + }; + function count( + n: uint256, target: uint256 + ) -> uint256 { + if (n < target) { + return count(succ(n), target); + } else { + return n; + } + }; +} + +storage { [0] result: uint256; } +create { result = 0; } +code { result = count(0, 5); }`; + + const result = await executeProgram(source, { + calldata: "", + optimizationLevel: 2, + }); + + expect(result.callSuccess).toBe(true); + expect(await result.getStorage(0n)).toBe(5n); + }); + + it("should support factorial at optimization level 3", async () => { + const source = `name FactorialOpt; + +define { + function factorial(n: uint256) -> uint256 { + if (n < 2) { + return 1; + } else { + return n * factorial(n - 1); + } + }; +} + +storage { [0] result: uint256; } +create { result = 0; } +code { result = factorial(5); }`; + + const result = await executeProgram(source, { + calldata: "", + optimizationLevel: 3, + }); + + expect(result.callSuccess).toBe(true); + expect(await result.getStorage(0n)).toBe(120n); + }); }); describe("loops", () => { diff --git a/packages/bugc/src/evmgen/generation/block.ts b/packages/bugc/src/evmgen/generation/block.ts index 7c9f71247..e23460dfb 100644 --- a/packages/bugc/src/evmgen/generation/block.ts +++ b/packages/bugc/src/evmgen/generation/block.ts @@ -154,18 +154,37 @@ export function generate( } } - // Process phi nodes if we have a predecessor - if (predecessor && block.phis.length > 0) { - result = result.then(generatePhis(block.phis, predecessor)); - } + // Phi resolution happens at predecessors, not at the + // target. Each predecessor stores its phi source values + // into the phi destination memory slots before jumping. + // This is necessary for back-edges (loops, TCO) where + // the runtime predecessor differs from the layout-order + // predecessor. // Process regular instructions for (const inst of block.instructions) { result = result.then(Instruction.generate(inst)); } + // Emit phi copies for successor blocks before the + // terminator. For jump terminators, check if the + // target has phis and store the source values for + // this block. + if (func && block.terminator.kind === "jump") { + const target = func.blocks.get(block.terminator.target); + if (target && target.phis.length > 0) { + const relevant = target.phis.filter((phi) => + phi.sources.has(block.id), + ); + if (relevant.length > 0) { + result = result.then(generatePhis(relevant, block.id)); + } + } + } + // Process terminator - // Handle call terminators specially (they cross function boundaries) + // Handle call terminators specially + // (they cross function boundaries) if (block.terminator.kind === "call") { result = result.then( generateCallTerminator(block.terminator, functions), diff --git a/packages/bugc/src/optimizer/steps/jump-optimization.ts b/packages/bugc/src/optimizer/steps/jump-optimization.ts index ccb4e0586..a8d657d65 100644 --- a/packages/bugc/src/optimizer/steps/jump-optimization.ts +++ b/packages/bugc/src/optimizer/steps/jump-optimization.ts @@ -18,6 +18,7 @@ export class JumpOptimizationStep extends BaseOptimizationStep { for (const [blockId, block] of func.blocks) { if ( block.instructions.length === 0 && + block.phis.length === 0 && block.terminator.kind === "jump" ) { jumpTargets.set(blockId, block.terminator.target); diff --git a/packages/bugc/src/optimizer/steps/tail-call-optimization.test.ts b/packages/bugc/src/optimizer/steps/tail-call-optimization.test.ts index ce888a246..18ac89102 100644 --- a/packages/bugc/src/optimizer/steps/tail-call-optimization.test.ts +++ b/packages/bugc/src/optimizer/steps/tail-call-optimization.test.ts @@ -78,25 +78,35 @@ describe("TailCallOptimizationStep", () => { // Count call terminators after optimization let callsAfterCount = 0; - let hasLoopHeader = false; + let hasPreEntry = false; for (const [blockId, block] of optimizedFunc.blocks) { if (block.terminator.kind === "call") { callsAfterCount++; } - // Look for the loop header block - if (blockId.includes("_loop")) { - hasLoopHeader = true; - // Loop header should have phi nodes for parameters - expect(block.phis.length).toBe(factorialFunc.parameters.length); + // Look for the pre_entry trampoline block + if (blockId.includes("_pre")) { + hasPreEntry = true; } } // Tail-recursive calls should be eliminated expect(callsAfterCount).toBe(0); - // Should have created a loop header - expect(hasLoopHeader).toBe(true); + // Should have created a pre_entry trampoline + expect(hasPreEntry).toBe(true); + + // Original entry block should have phi nodes + // for parameters + const origEntry = optimizedFunc.blocks.get(optimizedFunc.entry); + const origEntryTarget = + origEntry?.terminator.kind === "jump" + ? origEntry.terminator.target + : undefined; + const entryBlock = origEntryTarget + ? optimizedFunc.blocks.get(origEntryTarget) + : undefined; + expect(entryBlock?.phis.length).toBe(factorialFunc.parameters.length); // Should have recorded transformations const transformations = context.getTransformations(); diff --git a/packages/bugc/src/optimizer/steps/tail-call-optimization.ts b/packages/bugc/src/optimizer/steps/tail-call-optimization.ts index 88961ddef..a5273a19a 100644 --- a/packages/bugc/src/optimizer/steps/tail-call-optimization.ts +++ b/packages/bugc/src/optimizer/steps/tail-call-optimization.ts @@ -80,55 +80,59 @@ export class TailCallOptimizationStep extends BaseOptimizationStep { tailCallBlocks.push(blockId); } - // If we found tail calls, create a loop structure + // If we found tail calls, transform into a loop. + // + // Strategy: create a trampoline "pre_entry" block as + // the new function entry. Add phi nodes to the original + // entry block that select between the initial param + // values (from pre_entry) and the tail-call arguments + // (from each tail-call site). Tail-call blocks jump + // directly to the original entry. if (tailCallBlocks.length > 0) { - // Create a new loop header block that will contain phis for parameters - const loopHeaderId = `${func.entry}_loop`; - const originalEntry = func.blocks.get(func.entry); - - if (!originalEntry) { - return; // Should not happen - } + const origEntryId = func.entry; + const origEntry = func.blocks.get(origEntryId); + if (!origEntry) return; + + // Create trampoline that becomes the new func.entry + const preEntryId = `${origEntryId}_pre`; + const preEntry: Ir.Block = { + id: preEntryId, + phis: [], + instructions: [], + terminator: { + kind: "jump", + target: origEntryId, + operationDebug: {}, + }, + predecessors: new Set(), + debug: {}, + }; + func.blocks.set(preEntryId, preEntry); + func.entry = preEntryId; - // Create phi nodes for each parameter + // Build phi nodes on the original entry block. + // Sources: preEntry → original param, each tail + // call block → the call's corresponding argument. const paramPhis: Ir.Block.Phi[] = []; for (let i = 0; i < func.parameters.length; i++) { const param = func.parameters[i]; - const phiSources = new Map(); - - // Initial value from function entry - phiSources.set(func.entry, { + const sources = new Map(); + sources.set(preEntryId, { kind: "temp", id: param.tempId, type: param.type, }); - paramPhis.push({ kind: "phi", - sources: phiSources, - dest: `${param.tempId}_loop`, + sources, + dest: param.tempId, type: param.type, - operationDebug: { context: param.loc ? undefined : undefined }, + operationDebug: {}, }); } - // Create the loop header block - const loopHeader: Ir.Block = { - id: loopHeaderId, - phis: paramPhis, - instructions: [], - terminator: { - kind: "jump", - target: func.entry, - operationDebug: {}, - }, - predecessors: new Set([func.entry, ...tailCallBlocks]), - debug: {}, - }; - - func.blocks.set(loopHeaderId, loopHeader); - - // Transform each tail call + // Transform each tail call: replace call with jump + // to origEntry, add phi sources for arguments. for (const blockId of tailCallBlocks) { const block = func.blocks.get(blockId)!; const callTerm = block.terminator as Ir.Block.Terminator & { @@ -136,21 +140,18 @@ export class TailCallOptimizationStep extends BaseOptimizationStep { }; const contBlock = func.blocks.get(callTerm.continuation)!; - // Update phi sources with arguments from this tail call for (let i = 0; i < func.parameters.length; i++) { if (i < callTerm.arguments.length) { paramPhis[i].sources.set(blockId, callTerm.arguments[i]); } } - // Replace call with jump to loop header block.terminator = { kind: "jump", - target: loopHeaderId, + target: origEntryId, operationDebug: callTerm.operationDebug, }; - // Track the transformation context.trackTransformation({ type: "replace", pass: this.name, @@ -159,30 +160,36 @@ export class TailCallOptimizationStep extends BaseOptimizationStep { ...Ir.Utils.extractContexts(contBlock), ], result: Ir.Utils.extractContexts(block), - reason: `Optimized tail-recursive call to ${funcName} into loop`, + reason: + `Optimized tail-recursive call to ` + `${funcName} into loop`, }); - // Mark continuation block for removal if it has no other - // predecessors - const otherPredecessors = Array.from(contBlock.predecessors).filter( - (pred) => pred !== blockId, + // Remove continuation if no other predecessors + const otherPreds = Array.from(contBlock.predecessors).filter( + (p) => p !== blockId, ); - if (otherPredecessors.length === 0) { + if (otherPreds.length === 0) { blocksToRemove.add(callTerm.continuation); - context.trackTransformation({ type: "delete", pass: this.name, original: Ir.Utils.extractContexts(contBlock), result: [], - reason: `Removed unused continuation block ${callTerm.continuation}`, + reason: + `Removed unused continuation block ` + callTerm.continuation, }); } else { - // Update predecessors contBlock.predecessors.delete(blockId); } } + + // Install phis and update predecessors + origEntry.phis = [...paramPhis, ...origEntry.phis]; + origEntry.predecessors.add(preEntryId); + for (const blockId of tailCallBlocks) { + origEntry.predecessors.add(blockId); + } } // Remove marked blocks From bb90f7f6ce492b85376b1e18e2386aeb96770262 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 2 Apr 2026 08:33:15 -0400 Subject: [PATCH 38/39] format: fix invoke schema pointer semantics and examples (#207) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * format: fix invoke schema pointer semantics and examples Align invoke context examples with the "following execution" semantics defined by instruction.schema.yaml. The internal call example previously referenced pre-JUMP stack state (destination at slot 0, arguments at slots 2-3), but JUMP consumes its destination operand. Fix the example to mark the callee's entry JUMPDEST with the correct post-JUMP stack layout: return label at slot 0, arguments at slots 1-2, and target as a code pointer to the function's entry offset. Also fix the CREATE2 example: salt was incorrectly shown at stack slot 1 instead of slot 3 (the EVM pops value, offset, length, salt in that order). Add clarifying text to the schema description and spec page explaining when internal call contexts use callee JUMPDEST placement vs. external call/create contexts on the instruction itself, and how stack-based pointers reference pre-execution state for instructions that consume their operands. * format: clarify context semantics — facts vs pointer state Resolve the tension between "following execution" semantics and pointer evaluation. The instruction schema's "following execution" describes when a context's semantic facts hold (e.g., "a function was invoked"), not the machine state that pointers reference. Pointers reference the state at the instruction's trace step — the state a debugger observes when it encounters the instruction. Update instruction.schema.yaml to make this two-level model explicit. Simplify the invoke schema description and spec page to present this cleanly rather than restating the contradiction. * chore: fix formatting --- .../spec/program/context/function/invoke.mdx | 28 ++++++- .../context/function/invoke.schema.yaml | 83 ++++++++++++------- schemas/program/instruction.schema.yaml | 5 ++ 3 files changed, 84 insertions(+), 32 deletions(-) diff --git a/packages/web/spec/program/context/function/invoke.mdx b/packages/web/spec/program/context/function/invoke.mdx index d017c2cc0..b249a1a0a 100644 --- a/packages/web/spec/program/context/function/invoke.mdx +++ b/packages/web/spec/program/context/function/invoke.mdx @@ -19,15 +19,37 @@ gas, value, and input data as applicable. See worked examples showing how debuggers use invoke and return contexts to reconstruct call stacks. +## Pointer evaluation and instruction placement + +An instruction's context describes what is known _following_ +that instruction's execution: the fact that a function was +invoked holds from that point forward. Pointers within the +context reference the machine state at the instruction's trace +step — the state a debugger observes when it encounters the +instruction. + +For **internal calls**, this context is typically placed on the +callee's entry JUMPDEST rather than the caller's JUMP. JUMP +consumes its destination operand from the stack; at the entry +JUMPDEST, the remaining stack (return address followed by +arguments) is stable and directly addressable. + +For **external calls** and **contract creations**, this context +marks the CALL/DELEGATECALL/STATICCALL/CREATE/CREATE2 +instruction itself, where the call parameters are visible on +the stack. + ## Internal call -An internal call represents a function call within the same contract -via JUMP/JUMPI. The target points to a code location and arguments -are passed on the stack. +An internal call represents a function call within the same contract. +This context is typically placed on the callee's entry JUMPDEST; the +caller's JUMP has already consumed the destination from the stack, so +pointer slot values reflect the post-JUMP layout. The target points +to a code location and arguments are passed on the stack. Date: Thu, 2 Apr 2026 10:46:28 -0400 Subject: [PATCH 39/39] bugc: update invoke contexts per spec fix (#207) (#209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * bugc: update invoke contexts per spec fix (#207) - Callee JUMPDEST: full invoke with arg pointers and code target (resolved at module-level patching) - Caller JUMP: identity + code target only, no arg pointers - Target pointers changed from stack to code location * fix: use Pointer.Region.isCode type guard in tests * programs-react: propagate arg names from callee JUMPDEST The invoke spec change moved argument pointers from the caller JUMP to the callee entry JUMPDEST. The call stack builder was updating argumentPointers from the duplicate callee step but not argumentNames, causing names to show as _0 instead of the actual parameter name. * web: propagate arg names from callee JUMPDEST in TraceDrawer Same fix as the programs-react mockTrace.ts change — the duplicated call stack builder in TraceDrawer.tsx also needs to update argumentNames from the callee entry step, not just argumentPointers. --- .../bugc/src/evmgen/call-contexts.test.ts | 182 ++++++++++-------- .../generation/control-flow/terminator.ts | 34 +--- .../bugc/src/evmgen/generation/function.ts | 45 ++++- .../programs-react/src/utils/mockTrace.ts | 4 +- .../src/theme/ProgramExample/TraceDrawer.tsx | 4 +- 5 files changed, 155 insertions(+), 114 deletions(-) diff --git a/packages/bugc/src/evmgen/call-contexts.test.ts b/packages/bugc/src/evmgen/call-contexts.test.ts index 244509e91..43f1b76cb 100644 --- a/packages/bugc/src/evmgen/call-contexts.test.ts +++ b/packages/bugc/src/evmgen/call-contexts.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { compile } from "#compiler"; import type * as Format from "@ethdebug/format"; -import { Program } from "@ethdebug/format"; +import { Pointer, Program } from "@ethdebug/format"; const { Context } = Program; const { Invocation } = Context.Invoke; @@ -68,52 +68,42 @@ code { result = add(10, 20); }`; - it("should emit invoke context on caller JUMP", async () => { - const program = await compileProgram(source); + it( + "should emit invoke context on caller JUMP " + + "(identity + code target, no args)", + async () => { + const program = await compileProgram(source); - const invokeJumps = findInstructionsWithContext( - program, - "JUMP", - Context.isInvoke, - ); - - expect(invokeJumps.length).toBeGreaterThanOrEqual(1); + const invokeJumps = findInstructionsWithContext( + program, + "JUMP", + Context.isInvoke, + ); - const { invoke } = invokeJumps[0].context; - expect(Invocation.isInternalCall(invoke)).toBe(true); + expect(invokeJumps.length).toBeGreaterThanOrEqual(1); - const call = invoke as InternalCall; - expect(call.jump).toBe(true); - expect(call.identifier).toBe("add"); + const { invoke } = invokeJumps[0].context; + expect(Invocation.isInternalCall(invoke)).toBe(true); - // Should have declaration source range - expect(invoke.declaration).toBeDefined(); - expect(invoke.declaration!.source).toEqual({ id: "0" }); - expect(invoke.declaration!.range).toBeDefined(); - expect(typeof invoke.declaration!.range!.offset).toBe("number"); - expect(typeof invoke.declaration!.range!.length).toBe("number"); - - // Should have target pointer - expect(call.target.pointer).toBeDefined(); - - // Should have argument pointers - expect(call.arguments).toBeDefined(); - const group = (call.arguments!.pointer as { group: unknown[] }).group; - - expect(group).toHaveLength(2); - // First arg (a) is deepest on stack - expect(group[0]).toEqual({ - name: "a", - location: "stack", - slot: 1, - }); - // Second arg (b) is on top - expect(group[1]).toEqual({ - name: "b", - location: "stack", - slot: 0, - }); - }); + const call = invoke as InternalCall; + expect(call.jump).toBe(true); + expect(call.identifier).toBe("add"); + + // Should have declaration source range + expect(invoke.declaration).toBeDefined(); + expect(invoke.declaration!.source).toEqual({ id: "0" }); + expect(invoke.declaration!.range).toBeDefined(); + expect(typeof invoke.declaration!.range!.offset).toBe("number"); + expect(typeof invoke.declaration!.range!.length).toBe("number"); + + // Target should be a code pointer (not stack) + expect(Pointer.Region.isCode(call.target.pointer)).toBe(true); + + // Caller JUMP should NOT have argument pointers + // (args live on the callee JUMPDEST invoke context) + expect(call.arguments).toBeUndefined(); + }, + ); it("should emit return context on continuation JUMPDEST", async () => { const program = await compileProgram(source); @@ -142,32 +132,51 @@ code { }); }); - it("should emit invoke context on callee entry JUMPDEST", async () => { - const program = await compileProgram(source); + it( + "should emit invoke context on callee entry " + + "JUMPDEST with args and code target", + async () => { + const program = await compileProgram(source); - // The callee entry point, not the continuation - const invokeJumpdests = findInstructionsWithContext( - program, - "JUMPDEST", - Context.isInvoke, - ); + // The callee entry point, not the continuation + const invokeJumpdests = findInstructionsWithContext( + program, + "JUMPDEST", + Context.isInvoke, + ); - expect(invokeJumpdests.length).toBeGreaterThanOrEqual(1); + expect(invokeJumpdests.length).toBeGreaterThanOrEqual(1); + + const { invoke } = invokeJumpdests[0].context; + expect(Invocation.isInternalCall(invoke)).toBe(true); - const { invoke } = invokeJumpdests[0].context; - expect(Invocation.isInternalCall(invoke)).toBe(true); + const call = invoke as InternalCall; + expect(call.jump).toBe(true); + expect(call.identifier).toBe("add"); - const call = invoke as InternalCall; - expect(call.jump).toBe(true); - expect(call.identifier).toBe("add"); + // Target should be a code pointer + expect(Pointer.Region.isCode(call.target.pointer)).toBe(true); - // Should have argument pointers matching - // function parameters - expect(call.arguments).toBeDefined(); - const group = (call.arguments!.pointer as { group: unknown[] }).group; + // Should have argument pointers matching + // function parameters + expect(call.arguments).toBeDefined(); + const group = (call.arguments!.pointer as { group: unknown[] }).group; - expect(group).toHaveLength(2); - }); + expect(group).toHaveLength(2); + // First arg (a) is deepest on stack + expect(group[0]).toEqual({ + name: "a", + location: "stack", + slot: 1, + }); + // Second arg (b) is on top + expect(group[1]).toEqual({ + name: "b", + location: "stack", + slot: 0, + }); + }, + ); it("should emit contexts in correct instruction order", async () => { const program = await compileProgram(source); @@ -331,32 +340,37 @@ code { result = double(7); }`; - it("should emit single-element argument group", async () => { - const program = await compileProgram(singleArgSource); + it( + "should emit single-element argument group " + "on callee JUMPDEST", + async () => { + const program = await compileProgram(singleArgSource); - const invokeJumps = findInstructionsWithContext( - program, - "JUMP", - Context.isInvoke, - ); + // Args are on the callee JUMPDEST, not the + // caller JUMP + const invokeJumpdests = findInstructionsWithContext( + program, + "JUMPDEST", + Context.isInvoke, + ); - expect(invokeJumps.length).toBeGreaterThanOrEqual(1); + expect(invokeJumpdests.length).toBeGreaterThanOrEqual(1); - const { invoke } = invokeJumps[0].context; - expect(Invocation.isInternalCall(invoke)).toBe(true); + const { invoke } = invokeJumpdests[0].context; + expect(Invocation.isInternalCall(invoke)).toBe(true); - const call = invoke as InternalCall; - expect(call.arguments).toBeDefined(); - const group = (call.arguments!.pointer as { group: unknown[] }).group; + const call = invoke as InternalCall; + expect(call.arguments).toBeDefined(); + const group = (call.arguments!.pointer as { group: unknown[] }).group; - // Single arg at stack slot 0 - expect(group).toHaveLength(1); - expect(group[0]).toEqual({ - name: "x", - location: "stack", - slot: 0, - }); - }); + // Single arg at stack slot 0 + expect(group).toHaveLength(1); + expect(group[0]).toEqual({ + name: "x", + location: "stack", + slot: 0, + }); + }, + ); }); describe("return epilogue source maps", () => { diff --git a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts index 82df52806..bc0ba0131 100644 --- a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts +++ b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts @@ -210,19 +210,11 @@ export function generateCallTerminator( } // Push function address and jump. - // The JUMP gets an invoke context: after JUMP executes, - // the function has been entered with args on the stack. + // The JUMP gets a simplified invoke context with + // identity and code target only; the full invoke + // with arg pointers lives on the callee JUMPDEST. const funcAddrPatchIndex = currentState.instructions.length; - // Build argument pointers: after the JUMP, the callee - // sees args on the stack in order (first arg deepest). - const params = targetFunc?.parameters; - const argPointers = args.map((_arg, i) => ({ - ...(params?.[i]?.name ? { name: params[i].name } : {}), - location: "stack" as const, - slot: args.length - 1 - i, - })); - // Build declaration source range if available const declaration = targetFunc?.loc && targetFunc?.sourceId @@ -232,10 +224,6 @@ export function generateCallTerminator( } : undefined; - // Invoke context describes state after JUMP executes: - // the callee has been entered with args on the stack. - // target points to the function address at stack slot 0 - // (consumed by JUMP, but describes the call target). const invoke: Format.Program.Context.Invoke = { invoke: { jump: true as const, @@ -243,20 +231,16 @@ export function generateCallTerminator( ...(declaration ? { declaration } : {}), target: { pointer: { - location: "stack" as const, - slot: 0, + location: "code" as const, + offset: 0, + length: 1, }, }, - ...(argPointers.length > 0 && { - arguments: { - pointer: { - group: argPointers, - }, - }, - }), }, }; - const invokeContext = { context: invoke as Format.Program.Context }; + const invokeContext = { + context: invoke as Format.Program.Context, + }; currentState = { ...currentState, diff --git a/packages/bugc/src/evmgen/generation/function.ts b/packages/bugc/src/evmgen/generation/function.ts index 896f3498f..759c30aec 100644 --- a/packages/bugc/src/evmgen/generation/function.ts +++ b/packages/bugc/src/evmgen/generation/function.ts @@ -2,7 +2,7 @@ * Function-level code generation */ -import type * as Format from "@ethdebug/format"; +import * as Format from "@ethdebug/format"; import * as Ir from "#ir"; import type * as Evm from "#evm"; import type { Stack } from "#evm"; @@ -53,8 +53,9 @@ function generatePrologue( ...(declaration ? { declaration } : {}), target: { pointer: { - location: "stack" as const, - slot: 0, + location: "code" as const, + offset: 0, + length: 1, }, }, ...(argPointers.length > 0 && { @@ -483,8 +484,46 @@ export function patchFunctionCalls( patchedBytecode[bytePos + 1] = lowByte; } + // Patch invoke context code pointers. During codegen, + // invoke targets use placeholder offset 0; resolve them + // to the actual function entry from the registry. + for (const inst of patchedInstructions) { + patchInvokeTarget(inst, functionRegistry); + } + return { bytecode: patchedBytecode, instructions: patchedInstructions, }; } + +/** + * Resolve placeholder code pointer offsets in invoke debug + * contexts. The codegen emits `{ location: "code", offset: 0 }` + * as a placeholder; this replaces offset with the actual + * function entry address from the registry. + */ +function patchInvokeTarget( + inst: Evm.Instruction, + functionRegistry: Record, +): void { + const ctx = inst.debug?.context; + if (!ctx) return; + + if (!Format.Program.Context.isInvoke(ctx)) return; + + const { invoke } = ctx; + if (!Format.Program.Context.Invoke.Invocation.isInternalCall(invoke)) { + return; + } + + if (!invoke.identifier) return; + + const offset = functionRegistry[invoke.identifier]; + if (offset === undefined) return; + + const ptr = invoke.target.pointer; + if (Format.Pointer.Region.isCode(ptr)) { + ptr.offset = `0x${offset.toString(16)}`; + } +} diff --git a/packages/programs-react/src/utils/mockTrace.ts b/packages/programs-react/src/utils/mockTrace.ts index ddbee9dfa..26a912fc7 100644 --- a/packages/programs-react/src/utils/mockTrace.ts +++ b/packages/programs-react/src/utils/mockTrace.ts @@ -315,9 +315,11 @@ export function buildCallStack( if (isDuplicate) { // Use the callee entry step for resolution — // the argument pointers reference stack slots - // that are valid at the JUMPDEST, not the JUMP + // that are valid at the JUMPDEST, not the JUMP. + // Argument names also live on the callee entry. const argResult = extractArgInfo(instruction); top.stepIndex = i; + top.argumentNames = argResult?.names ?? top.argumentNames; top.argumentPointers = argResult?.pointers; } else { const argResult = extractArgInfo(instruction); diff --git a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx index e4cff9529..cd9b37416 100644 --- a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx +++ b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx @@ -140,8 +140,10 @@ function TraceDrawerContent(): JSX.Element { if (isDuplicate) { // Use the callee entry step for resolution — // argument pointers reference stack slots - // valid at the JUMPDEST, not the JUMP + // valid at the JUMPDEST, not the JUMP. + // Argument names also live on the callee entry. top.stepIndex = i; + top.argumentNames = info.argumentNames ?? top.argumentNames; top.argumentPointers = info.argumentPointers; } else { frames.push({