From de25d115a8a64db0566511f57a8c2ac08dd59160 Mon Sep 17 00:00:00 2001 From: Alex Forshtat Date: Sun, 26 Jul 2026 20:02:10 +0200 Subject: [PATCH 01/23] Draft companion ERC: non-ABI encoded fields for ERC-7730 Adds `layout` and `dispatch` keys so ERC-7730 descriptors can describe fields whose bytes are not plain Solidity ABI - packed structs, repeated records, and tag-selected payloads - as seen in Safe MultiSend, Uniswap's Universal Router, ERC-7579 execute, Circle CCTP messages, EAS attestations, and ERC-7683 orders. Includes a `call` construct for nested/wrapped calldata (both byte-embedded and direct signature+positional-args forms) and a top-level dispatch form for calls that only exist to be redirected. Worked examples for all six real cases are full, standalone ERC-7730 descriptor files under assets/erc-non-abi-dispatch/, verified against real mined transactions rather than inlined as snippets. A seventh, made-up TieredExecutor example demonstrates the direct/positional call form, explicitly flagged in Rationale as not grounded in an observed live transaction. Co-Authored-By: Claude Sonnet 5 --- ERCS/erc-draft_non_abi_dispatch.md | 233 ++++++++++++++++++ .../erc-non-abi-dispatch/TieredExecutor.sol | 47 ++++ .../example-cctp-message.json | 56 +++++ .../example-eas-attestation.json | 46 ++++ .../example-erc7579-execute.json | 73 ++++++ .../example-erc7683-order.json | 46 ++++ .../example-legacy-token.json | 32 +++ .../example-reward-vault.json | 32 +++ .../example-safe-multisend.json | 51 ++++ .../example-tiered-executor.json | 55 +++++ .../example-universal-router.json | 51 ++++ 11 files changed, 722 insertions(+) create mode 100644 ERCS/erc-draft_non_abi_dispatch.md create mode 100644 assets/erc-non-abi-dispatch/TieredExecutor.sol create mode 100644 assets/erc-non-abi-dispatch/example-cctp-message.json create mode 100644 assets/erc-non-abi-dispatch/example-eas-attestation.json create mode 100644 assets/erc-non-abi-dispatch/example-erc7579-execute.json create mode 100644 assets/erc-non-abi-dispatch/example-erc7683-order.json create mode 100644 assets/erc-non-abi-dispatch/example-legacy-token.json create mode 100644 assets/erc-non-abi-dispatch/example-reward-vault.json create mode 100644 assets/erc-non-abi-dispatch/example-safe-multisend.json create mode 100644 assets/erc-non-abi-dispatch/example-tiered-executor.json create mode 100644 assets/erc-non-abi-dispatch/example-universal-router.json diff --git a/ERCS/erc-draft_non_abi_dispatch.md b/ERCS/erc-draft_non_abi_dispatch.md new file mode 100644 index 00000000000..738e99dfb83 --- /dev/null +++ b/ERCS/erc-draft_non_abi_dispatch.md @@ -0,0 +1,233 @@ +--- +title: Non-ABI Encoded Fields for ERC-7730 +description: Describes packed, bit-packed, and tag-dispatched byte encodings inside ERC-7730 fields that are not plain Solidity ABI +author: TBD +discussions-to: TBD +status: Draft +type: Standards Track +category: ERC +created: 2026-07-26 +requires: 7730 +--- + +## Abstract + +[ERC-7730](./erc-7730.md) describes how to clear-sign structured data by decoding calldata as a Solidity ABI function call, then formatting the resulting named fields. This works as long as every value in the call is itself ABI-encoded. It breaks down for a common and growing class of contracts that accept one ABI-encoded `bytes` (or `bytes[]`) argument and then interpret its raw content using their own private encoding — packed structs, bit-packed words, or a tag byte that selects one of several possible payload shapes. Gnosis Safe's `MultiSend`, Uniswap's Universal Router, and ERC-7579 modular accounts all do this, and none of it can be described in ERC-7730 today; such fields must be left as opaque, unreadable bytes. + +This ERC adds two new keys to an ERC-7730 [field format specification](./erc-7730.md#field-format-specification) — `layout` and `dispatch` — that let an author describe the internal structure of such a field and, where the field's shape depends on a tag value, which structure applies. Everything else about ERC-7730 (context binding, metadata, top-level selector matching, path syntax, `display.formats`) is unchanged; this ERC only extends what a single field's `path` can resolve into. + +## Motivation + +Look at what a `display.formats` entry can describe today: a Solidity function signature, decoded with the standard ABI rules, giving named parameters that `fields` entries point `path` at. That covers the overwhelming majority of contract calls. It does not cover contracts whose calldata carries a second, private encoding layer inside one of those ABI parameters: + +- **Safe's `MultiSend.multiSend(bytes transactions)`** — `transactions` is not ABI-encoded. It is a tightly packed, back-to-back sequence of `(operation, to, value, dataLength, data)` records, repeated until the buffer runs out. Each record's own `data` is, in turn, a normal call to some other contract. +- **Uniswap's Universal Router `execute(bytes commands, bytes[] inputs)`** — `commands` is one raw opcode byte per sub-action (with a flag bit for "allowed to revert"); `inputs[i]` is separately ABI-encoded, but *which* ABI type it decodes as depends on the opcode at `commands[i]`. +- **ERC-7579 modular accounts, `execute(bytes32 mode, bytes executionCalldata)`** — `mode` packs five sub-fields into one word; `executionCalldata`'s shape (a single packed call, or an ABI-encoded array of calls) depends on one byte of `mode`. + +None of this is exotic or rare. It is how batching, modular accounts, and generic-purpose routers already work across the ecosystem, and account-abstraction adoption is only going to produce more of it. A wallet with no way to describe these fields has no way to clear-sign them beyond showing raw hex — which is exactly the blind, trust-me signing experience ERC-7730 exists to eliminate. + +The goal here is narrow on purpose. This ERC does not attempt to become a general-purpose binary serialization language (no attempt is made to describe Protobuf, Borsh, or arbitrary custom formats in full generality). It describes exactly the small set of shapes observed in real, widely used contracts: fixed-width packed fields, repeated records read until the buffer ends, and tag-selected payload types. Constructs are added because a real case needs them, not because they might be useful someday. + +## Specification + +The keywords "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174. + +This ERC defines two additional keys usable in an ERC-7730 [field format specification](./erc-7730.md#field-format-specification): `layout` and `dispatch`. A field format specification MUST NOT combine `layout` with `format`; the two are alternative ways of turning a field's raw value into something displayable, and `layout` takes over that job entirely for the field it is attached to. `dispatch` MAY be combined with either, since it only decides *which* `layout` or ABI type governs a field — it does not itself produce a displayable value. + +### `layout` + +`layout` describes the internal byte structure of a `bytes` field. Its value is a *layout node*. A layout node is one of the following kinds: + +**Primitive nodes** + +```json +{ "kind": "uint", "bytes": 1, "endian": "be" } +{ "kind": "bytes", "length": 20 } +{ "kind": "address" } +{ "kind": "bool" } +``` + +`uint.bytes` is the width in bytes (1, 2, 4, 8, 16, or 32). `endian` is `"be"` or `"le"`, defaulting to `"be"` — every known EVM-side use case packs data big-endian, matching Solidity's own word layout; `"le"` exists only so this vocabulary does not need to change if a future non-EVM companion reuses it. `address` is sugar for a 20-byte `bytes` node. `bytes.length` MAY be a fixed integer, a `lengthFrom` reference to an earlier sibling field's decoded value (see `struct` below), or omitted entirely on the last field of a `struct`, meaning "consume whatever bytes remain in the enclosing buffer." + +**`struct`** — an ordered, unpadded concatenation of named fields: + +```json +{ + "kind": "struct", + "fields": [ + { "name": "operation", "schema": { "kind": "uint", "bytes": 1 } }, + { "name": "to", "schema": { "kind": "address" } }, + { "name": "value", "schema": { "kind": "uint", "bytes": 32 } }, + { "name": "dataLength", "schema": { "kind": "uint", "bytes": 32 } }, + { "name": "data", "schema": { "kind": "bytes", "lengthFrom": "dataLength" } } + ] +} +``` + +Fields are read strictly in order, byte-for-byte, with no alignment padding and no ABI head/tail indirection. This is the node that describes Safe MultiSend's per-entry record and ERC-7579's `mode` word. + +**`sequence`** — repetition of one element node, read until the enclosing buffer is exhausted: + +```json +{ "kind": "sequence", "element": , "count": "tillEnd" } +``` + +`"tillEnd"` is the only count mode this ERC defines, because it is the only one any known real case needs — Safe MultiSend repeats its record `struct` until `transactions` runs out; Universal Router repeats a `dispatch` (below) once per byte of `commands`. Other termination modes (an explicit element count, a byte-length prefix) are left for a future revision if a real case needs them, rather than specified speculatively now. + +**`call`** — the bytes at this position are themselves calldata to another contract; resolve them by re-running ERC-7730's own [selector matching](./erc-7730.md#selector-matching-contracts), recursively: + +```json +{ "kind": "call", "to": "to", "length": 68 } +``` + +`call` is a byte-consuming node like `bytes`: it accepts the same `length` / `lengthFrom` / implicit-remainder-of-enclosing-buffer rules, so exactly how many bytes it consumes is never ambiguous. `to` is a path (relative to the enclosing `struct`) to the address to recurse into. Having consumed its bytes, a wallet MUST treat the first 4 bytes as a selector and the rest as ABI-encoded arguments, then MUST treat the result exactly as it would a top-level transaction: look up a matching `display.formats` entry (in this file, in an `includes` file, or in the descriptor registry) for that `to`/selector pair, and if none is found, apply the same fallback as an [unknown selector](./erc-7730.md#unknown-selectors). + +This is how Safe MultiSend's inner calls are described. Note that `call` is declared directly as the `schema` of the `data` field itself — there is no separate sibling field for "the nested call"; the fact that a slice of bytes *is* a nested call is a property of that slice's own declared type, not something bolted on next to it. + +`call` need not sit inside a `struct`. A field whose bytes were obtained by ordinary ABI decoding (not by a parent `layout` at all) MAY also declare `layout` directly as a `call` node, meaning "this ABI-decoded `bytes` field is itself calldata to another contract." This is how ERC-7579's batched executions are described: each element of the ABI-decoded `Execution[]` array has an ordinary `bytes callData` member, and a second field entry with `path: "executionCalldata[].callData"` and `layout: {"kind": "call", "to": "executionCalldata[].target"}` recurses into it, addressed at the same array index as its sibling `target` — the same by-index correlation ERC-7730 already uses for [array-valued formatting parameters](./erc-7730.md#field-format-specification). + +**Byte-width invariant.** Every `layout` node either consumes a well-defined, computable number of bytes from the buffer (all of the kinds above), or is explicitly declared non-consuming (only `dispatch`'s path-sourced tag form, below, which reads an already-resolved value instead of parsing bytes). No node kind may be ambiguous about whether, or how much, it advances the cursor. + +### Path addressing + +Paths extend into `layout`-decoded fields the same way they already extend into ABI-decoded struct and array fields: by name for `struct` fields, by index for `sequence` elements. For example, given the `struct` above, `#.transactions[0].to` refers to the `to` field of the first record; `#.transactions[1].data.to` refers into a nested `call`'s own resolved fields, addressed using that resolved function's own parameter names, once matched. + +### `dispatch` + +`dispatch` selects which type or layout governs a field, based on the value of a tag. The tag MAY come from two places: + +1. **An already-resolved sibling path** — the ordinary case, used when the tag is a normal ABI-decoded parameter of the same call, decoded by nothing new at all: + +```json +{ + "path": "data", + "dispatch": { + "tag": { "path": "schema" }, + "cases": { + "0x1234...": { "abiType": "(address recipient,bool isHuman,uint256 score)" } + }, + "default": "reject" + } +} +``` + +This is the shape needed by EAS (`schema` selects how to decode `data`), ERC-7683 (`orderDataType` selects how to decode `orderData`), and ERC-7579 (`mode`'s decoded `callType` sub-field selects how to decode `executionCalldata`). + +2. **Inline, read from the buffer at the current cursor position** — used only inside a `layout` tree, when the tag itself has to be parsed out of raw bytes rather than looked up as an already-decoded value: + +```json +{ "kind": "dispatch", + "tag": { "kind": "uint", "bytes": 1, "mask": "0x3f" }, + "payloadFrom": "inputs", + "cases": { + "0x00": { "abiType": "(address recipient,uint256 amountIn,uint256 amountOutMin,bytes path,bool payerIsUser)" } + }, + "default": "reject" +} +``` + +`mask` is an optional bitmask applied to the tag's raw value before matching against `cases` keys — needed for Universal Router, where the top bit of the command byte is an unrelated "allow revert" flag and only the low 6 bits select the command. `payloadFrom` names a sibling array (`inputs`), read at the same index as the current element of the enclosing `sequence` — a correlated-array lookup already precedented by ERC-7730's existing rule that a formatting parameter array is "read at the same index as the current element being formatted." + +In both forms, a case value is one of: + +* `{ "abiType": "" }` — decode the payload using the ordinary Solidity ABI decoder. +* `{ "layout": }` — recurse into this ERC's own layout language (any node, including `call`). +* `{ "dispatch": {...} }` — nest another dispatch (for multi-level tag structures). + +A wallet MUST treat a tag value with no matching case, and no `default` case supplied, the same way it treats an [unknown selector](./erc-7730.md#unknown-selectors): display a safe fallback and MUST NOT guess at a format. + +### `dispatch` at the top of a structured data format specification + +Every case above dispatches on a tag to reinterpret *one field's* bytes, while the rest of the call keeps its own fixed `intent` and `fields`. Some contracts have no such fixed meaning at all: the entire call exists only to be redirected, and which target function it becomes is the only thing worth describing. A [structured data format specification](./erc-7730.md#structured-data-format-specification) MAY declare a top-level `dispatch` object, with the same `tag`/`cases`/`default` shape as above, instead of `intent`/`fields`. Its `tag` is a path to one of the outer call's own already-decoded parameters (never a raw-byte tag — there is no enclosing buffer to read one from at this level). + +Because there is no bytes value being reinterpreted at this level, only two case-value forms are valid here: a nested `dispatch` (for a tag that further refines an already-matched case), or a `call` object naming a **different** target function outright: + +```json +{ "call": { + "to": "", + "signature": "", + "args": [ { "path": "" } | { "value": "" }, ... ] +} } +``` + +`to` is a path to the target address. `signature` is the target's canonical Solidity signature — a wallet MUST resolve it against `to`'s own `display.formats` entry exactly as it would any other call, but by matching the signature directly rather than by computing and comparing a selector, since no selector was ever computed for this call (see [Rationale](#rationale)). `args` positionally binds values to that signature's declared parameter order — each entry is either `path` (a reference to one of the outer call's own decoded values) or `value` (a literal) — reusing the existing `path`/`value` duality of a [field format specification](./erc-7730.md#field-format-specification). Position and type govern the binding, exactly as ERC-7730 already treats parameter names as non-canonical for selector matching; `args` need not preserve the order values arrived in at the outer call. + +A wallet MUST resolve the matched target's own `intent`, `interpolatedIntent`, and `fields` using the bound `args` values in place of that target's own decoded parameters, and MUST apply the [unknown selector](./erc-7730.md#unknown-selectors) fallback if `to`'s descriptor has no entry matching `signature`. + +## Rationale + +**Why a JSON node tree and not a compact string grammar.** ERC-7730 already describes ABI function signatures and EIP-712 types as strings, so a string grammar (e.g. an extended fragment syntax) might seem consistent. It was rejected here because these payloads are not universally pre-understood the way Solidity ABI is — a string grammar for them would need its own bespoke parser, on top of the ABI parser wallets already carry, and a new string-grammar parser is exactly the class of code that has historically produced hardware-wallet parsing bugs. A JSON node tree reuses the same tree-walking wallets already do for `fields` and `group`. + +**Why the vocabulary is this small.** Every node and mode above exists because one of the real cases surveyed while designing this ERC needs it, not because it seemed generally useful. `sequence` has exactly one termination mode because no known case needs another. `mask` exists only because Universal Router's command byte shares space with a flag bit. Padded/aligned struct variants, bit-level fields narrower than a byte, and non-`tillEnd` sequence termination are all left out deliberately; they can be added later, non-breaking, if a real case turns up. + +**Why `dispatch` has two tag-sourcing forms instead of one.** Most of the surveyed cases (EAS, ERC-7683, ERC-7579) dispatch on a tag that is already sitting in an ordinary ABI-decoded field — no byte parsing is involved in getting the tag at all. Only Universal Router needs the tag pulled out of a raw byte mid-parse. Rather than forcing every case through a byte-oriented mental model, `dispatch` accepts a `path` directly. + +**Why `call` recurses into the whole top-level algorithm instead of its own resolution rule.** Safe MultiSend's inner calls are, from the perspective of the target contract, ordinary top-level calls — they have their own `to` address and their own selector. This works with no dispatch table of any kind because the 4-byte-selector shape isn't an ERC-7730 convention being imposed on the data — it's forced on whoever built the MultiSend batch by the target contract's own compiled dispatcher (Solidity, and most other languages, generate exactly this selector-check-and-jump at the top of every contract's bytecode). Reusing ERC-7730's existing selector-matching and fallback behavior, rather than inventing a parallel mechanism, means a MultiSend entry calling a well-described ERC-20 `approve` gets exactly the same display it would get as a standalone transaction, with no separate code path to keep correct — and it composes for free: if a batch happened to call another MultiSend, or another contract using this very ERC's constructs, recursive resolution picks up that target's own `layout`/`dispatch` declarations with no special-casing. + +**Why `call` is folded into a field's own `schema`/`layout` instead of being a sibling field.** An earlier iteration of this design added a `data` field for the raw bytes and a separate `call` field pointing at it — two names for one byte range, one saying "these are bytes" and the other saying "now recurse into those same bytes." That's redundant, and worse, it leaves the "is this a nested call" fact detached from the value it describes. Declaring `call` directly as a field's `schema` (or its `layout`, for a field that arrived via ordinary ABI decoding rather than a parent `layout`) makes "is a nested call" a property of the field's own declared type — indistinguishable in structure from any other typed field, and impossible to declare in two contradictory ways for the same bytes. + +**Why the top-level `dispatch`/direct-`call` form exists, and why it's the one exception to this ERC's evidence rule.** Every other construct in this ERC exists because a real, cited transaction needed it. This one does not have that grounding — no verified live transaction was found that reinterprets already-decoded parameters into a different function's argument list the way the [TieredExecutor example](../assets/erc-non-abi-dispatch/example-tiered-executor.json) does. It is included because the shape it targets — a small trusted relayer accepting a tag and a handful of generic-looking arguments, then re-dispatching to one of several unrelated target interfaces with a different argument order per target — is a common, plausible, and easily reachable governance/router pattern, structurally close to a `switch` over an enum parameter. Readers should weigh this construct with that in mind: it is motivated by generality, not by an observed case, unlike everything else here. If a real contract using this exact shape turns up, its transaction should replace the made-up one in the Test Cases section. + +**Why positional binding, and why `args` may reorder.** ERC-7730 already treats parameter *names* as non-canonical for the purpose of selector matching — only position and type are. A dispatch case that redirects to a different function has no shared parameter names to align by in the first place (the outer call's `account`/`amount` mean nothing to the target function's own signature), so positional binding by the target's declared order is the only definition that is well-defined at all, and it is the same rule ERC-7730 already applies elsewhere, not a new one. + +## Backwards Compatibility + +This ERC only adds new, optional keys to a field format specification. A descriptor that does not use `layout` or `dispatch` is unaffected, and a wallet implementing only ERC-7730 without this extension can safely ignore fields that use them, applying the existing [unknown field / raw fallback](./erc-7730.md) behavior. + +## Test Cases + +Six of the seven examples below are real, mined transactions, decoded from raw calldata (not an explorer's rendered summary) and cross-checked against at least one independent source. Each is a full, standalone ERC-7730 descriptor file under [`../assets/erc-non-abi-dispatch/`](../assets/erc-non-abi-dispatch/) rather than a snippet, so it can be read with all the surrounding `context`/`metadata`/`display` structure intact. The seventh, `TieredExecutor`, is explicitly a made-up contract — see its own description below and the caveat in [Rationale](#rationale). + +### Safe `MultiSend` + +Ethereum mainnet, tx [`0x414ae5aaff729927d663ccaa027ea2284e47fa546cb73ce1dee481ee36a7138e`](https://etherscan.io/tx/0x414ae5aaff729927d663ccaa027ea2284e47fa546cb73ce1dee481ee36a7138e). A Safe at `0xCa087C9e22bC97059d8fd6e25956835Ec205782B` delegatecalls `MultiSendCallOnly` (`0x40A2aCCbd92BCA938b02010E17A5b8929b49130D`) to batch six CTC ([`0xa3ee21c306a700e682abcdfe9baa6a08f3820419`](https://etherscan.io/address/0xa3ee21c306a700e682abcdfe9baa6a08f3820419)) `transfer` calls to six different recipients in one transaction. The 918-byte `transactions` buffer decodes (record 0 of 6) to `operation=CALL`, `to=0xa3ee21c306a700e682abcdfe9baa6a08f3820419`, `value=0`, `dataLength=68`, and `data` recursing via `call` into a normal `transfer(address,uint256)` sending `40000000000000000000000` (40,000 CTC) to `0x6ba2c52a959f0544e00aea60fe576463fe5fc38d`; the remaining five records follow the same shape, and the buffer is consumed exactly with no slack, confirming the `tillEnd` parse. + +Full descriptor: [`example-safe-multisend.json`](../assets/erc-non-abi-dispatch/example-safe-multisend.json). + +### Uniswap Universal Router + +Ethereum mainnet, tx [`0x3805667353244e8fb763d50b7dd3bdb8f176119b44fdbd0a4ad5629d851ebbba`](https://etherscan.io/tx/0x3805667353244e8fb763d50b7dd3bdb8f176119b44fdbd0a4ad5629d851ebbba), calling `execute(bytes,bytes[],uint256)` on the Universal Router at `0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af`. `commands = 0x000004`: command 0 (`0x00`, `V3_SWAP_EXACT_IN`) sends `amountIn=3425828840000000000000` EURe through the path `EURe → EUR0 → EURC` with `payerIsUser=true`; command 1 (`0x00` again) swaps `amountIn=5138743260000000000000` EURe directly to EURC; command 2 (`0x04`, `SWEEP`) sweeps native ETH with `amountMinimum=0` back to the swapper. All three command bytes had their top (revert-flag) bit unset; token identities and pool fees were confirmed independently via each token's `symbol()`/`decimals()`. + +Full descriptor: [`example-universal-router.json`](../assets/erc-non-abi-dispatch/example-universal-router.json). + +### ERC-7579 `execute` + +Base mainnet, Biconomy Nexus accounts (an ERC-7579 reference implementation), function selector `0xe9ae5c53`. Single-call example: tx [`0x057b1df67f033ad77faba10e39f39dde273c225d62c3b36ef8547b3f51fad5c1`](https://basescan.org/tx/0x057b1df67f033ad77faba10e39f39dde273c225d62c3b36ef8547b3f51fad5c1) — `mode` has `callType=0x00`, and `executionCalldata` decodes to `target=0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` (USDC on Base), `value=0`, `callData` recursing into `transfer(0x3C97112223b1AD104Cf2ac022e450Ef862652b93, 1)`. Batch-call example: tx [`0x26d34bf7aa5adb0642218422264a4034ffda5785be3354168eb478051664613c`](https://basescan.org/tx/0x26d34bf7aa5adb0642218422264a4034ffda5785be3354168eb478051664613c) — `callType=0x01`, decoding to three executions (a native ETH transfer, a DAI `transfer`, and a USDC `transfer`) all to the same recipient, a single-UserOperation "sweep to one address" pattern. Both the mode layout and the callType-driven dispatch were confirmed against Nexus's own `ModeLib.sol`. + +Full descriptor: [`example-erc7579-execute.json`](../assets/erc-non-abi-dispatch/example-erc7579-execute.json). + +### Circle CCTP message + +Base → Ethereum, 50,000 USDC. Burn tx [`0x178632412a0eb4e642bfe30b1f80d0a4799ab400d4d1c76702ba01ba1458b57f`](https://basescan.org/tx/0x178632412a0eb4e642bfe30b1f80d0a4799ab400d4d1c76702ba01ba1458b57f) on Base; mint tx [`0xa5ab46a57e89fe110df3269065c0c07a394f22fe7a769bb916a547c7c1b3e99f`](https://etherscan.io/tx/0xa5ab46a57e89fe110df3269065c0c07a394f22fe7a769bb916a547c7c1b3e99f) on Ethereum. The 248-byte `message` decodes to `sourceDomain=6` (Base), `destinationDomain=0` (Ethereum), `nonce=764152`, `sender`/`recipient` as the two chains' TokenMessenger contracts, `destinationCaller=0x0` (permissionless relay); the nested `messageBody` decodes to `burnToken` = Base USDC, `mintRecipient`/`messageSender` both the same self-relaying address, `amount=50000000000` (50,000 USDC). The 132-byte `messageBody` is consumed exactly. + +Full descriptor: [`example-cctp-message.json`](../assets/erc-non-abi-dispatch/example-cctp-message.json). Note the descriptor's `context.contract` address is a placeholder — see the file's own `$comment`. + +### EAS attestation + +Optimism mainnet, schema `#78` (UID `0xfdcfdad2dbe7489e0ce56b260348b7f14e8365a8a325aef9834818c00d46b31b`), string `string rpgfRound,address referredBy,string referredMethod` — Optimism's RetroPGF badgeholder-referral schema. Attestation [`0x1a7a222934cbab53dd1c8e85d34e5fdd6d17cfd62a18ad871e4bec4705fdaa41`](https://optimism.easscan.org/attestation/view/0x1a7a222934cbab53dd1c8e85d34e5fdd6d17cfd62a18ad871e4bec4705fdaa41), tx `0x820e5b8404f1ec62b47459e538151e54fb598b729dcb461087456bb856abf595`, decodes to `rpgfRound="4"`, `referredBy=0x0000000000000000000000000000000000000342`, `referredMethod="Friend"`. `schema` and `data` are already-decoded ABI sibling fields of `attest()`'s own parameters, so no `layout` node is needed at all here — just `dispatch` sourced from a path. (A simpler, single-field schema also exists at scale — Coinbase's "Verified Account" schema, UID `0xf8b05c79f090979bf4a80270aba232dff11a10d9ca55c4f88de95317970f0de9`, `bool verifiedAccount`, 720,000+ attestations on Base — useful as a minimal case, but the RetroPGF one exercises both static and dynamic ABI types.) + +Full descriptor: [`example-eas-attestation.json`](../assets/erc-non-abi-dispatch/example-eas-attestation.json). Note the descriptor's `context.contract` address is a placeholder — see the file's own `$comment`. + +### ERC-7683 order + +Base mainnet, tx [`0x73b3ca11e3733c78db8365086f64874879fb3a859b21c960ec072c2a95c5da09`](https://basescan.org/tx/0x73b3ca11e3733c78db8365086f64874879fb3a859b21c960ec072c2a95c5da09), calling `open((uint32,bytes32,bytes) order)` on Across's `AcrossOriginSettler` (`0x4afb570AC68BfFc26Bb02FdA3D801728B0f93C9E`) — a self-bridge of 1 USDC from Base to Arbitrum. `orderDataType = 0x9df4b782e7bbc178b3b93bfe8aafb909e84e39484d7f3c59f400f1b4691f85e2`, independently confirmed as `keccak256("AcrossOrderData(address inputToken,uint256 inputAmount,address outputToken,uint256 outputAmount,uint256 destinationChainId,bytes32 recipient,address exclusiveRelayer,uint256 depositNonce,uint32 exclusivityPeriod,bytes message)")`, decoding to `inputToken`/`outputToken` = USDC on Base/Arbitrum, `inputAmount=1000000`, `outputAmount=981521` (the relayer's fee), `destinationChainId=42161`, `recipient` equal to the sender, and empty `exclusiveRelayer`/`depositNonce`/`exclusivityPeriod`/`message`. Note this uses the same typehash-dispatch shape as the EAS example above — different ecosystem, same construct. + +Full descriptor: [`example-erc7683-order.json`](../assets/erc-non-abi-dispatch/example-erc7683-order.json). + +### `TieredExecutor` (made-up example) + +A small, illustrative Solidity contract written for this ERC — [`TieredExecutor.sol`](../assets/erc-non-abi-dispatch/TieredExecutor.sol) — is **not deployed anywhere**; unlike every other example above, no real transaction exists for it. It demonstrates the top-level `dispatch`/direct-`call` form: `executeOperation(address target, Operation op, address account, uint256 amount)` takes an enum tag `op` and two generic-looking arguments, and re-dispatches to one of two unrelated target interfaces — `IRewardVault.grantReward(address,uint256)` for `op=1`, `ILegacyToken.creditAccount(uint256,address)` for `op=2` — each with a different parameter order, resolved from the same `account`/`amount` values by positional binding. + +Full descriptors: [`example-tiered-executor.json`](../assets/erc-non-abi-dispatch/example-tiered-executor.json) (the dispatching contract), [`example-reward-vault.json`](../assets/erc-non-abi-dispatch/example-reward-vault.json) and [`example-legacy-token.json`](../assets/erc-non-abi-dispatch/example-legacy-token.json) (the two target interfaces it recurses into, each an independently-authored descriptor resolved the same way any other nested `call` target would be). + +## Reference Implementation + +TBD + +## Security Considerations + +A `layout`/`dispatch` interpreter is new parsing surface on a hardware wallet, decoding attacker-influenced (calldata is provided by whoever submits the transaction) bytes. Implementations MUST bound recursion depth (`call` and nested `dispatch` can recurse arbitrarily deep in principle), MUST treat any length (`lengthFrom`, or a `sequence`'s implicit `tillEnd` walk) that would read past the end of the underlying buffer as invalid input, and MUST fail closed — applying the [unknown selector](./erc-7730.md#unknown-selectors) fallback — rather than displaying a partially decoded or best-guess value when a `layout` or `dispatch` does not cleanly match the actual bytes. + +## Copyright + +Copyright and related rights waived via [CC0](../LICENSE.md). diff --git a/assets/erc-non-abi-dispatch/TieredExecutor.sol b/assets/erc-non-abi-dispatch/TieredExecutor.sol new file mode 100644 index 00000000000..87aaae9f9ec --- /dev/null +++ b/assets/erc-non-abi-dispatch/TieredExecutor.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: CC0-1.0 +pragma solidity ^0.8.20; + +// Illustrative contract written for the non-ABI-dispatch companion ERC. +// It is NOT deployed anywhere; it exists to demonstrate a real pattern +// (a small trusted relayer that accepts one numeric operation tag and +// re-dispatches the call to one of several unrelated target interfaces, +// each with its own function signature and its own argument order) using +// concrete, compilable Solidity rather than pseudocode. +// +// See example-tiered-executor.json in this same folder for the ERC-7730 +// descriptor that clear-signs calls to `executeOperation`, and +// example-reward-vault.json / example-legacy-token.json for the +// descriptors of the two target interfaces it dispatches into. + +interface IRewardVault { + function grantReward(address to, uint256 amount) external; +} + +interface ILegacyToken { + function creditAccount(uint256 amount, address to) external; +} + +contract TieredExecutor { + enum Operation { + None, // 0 - unused, always rejected + GrantReward, // 1 - IRewardVault.grantReward(address,uint256) + CreditLegacy // 2 - ILegacyToken.creditAccount(uint256,address) + } + + event Executed(Operation indexed op, address indexed target, address indexed account, uint256 amount); + + /// @param target The contract to route the call to. + /// @param op Which trusted operation to perform. + /// @param account The account argument for that operation. + /// @param amount The amount argument for that operation. + function executeOperation(address target, Operation op, address account, uint256 amount) external { + if (op == Operation.GrantReward) { + IRewardVault(target).grantReward(account, amount); + } else if (op == Operation.CreditLegacy) { + ILegacyToken(target).creditAccount(amount, account); + } else { + revert("TieredExecutor: unsupported operation"); + } + emit Executed(op, target, account, amount); + } +} diff --git a/assets/erc-non-abi-dispatch/example-cctp-message.json b/assets/erc-non-abi-dispatch/example-cctp-message.json new file mode 100644 index 00000000000..a6a97e266cf --- /dev/null +++ b/assets/erc-non-abi-dispatch/example-cctp-message.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.schema.json", + + "$comment": "Worked example for the non-ABI-dispatch companion ERC, decoding Circle CCTP's cross-chain message header. Verified against a real Base-to-Ethereum transfer: burn tx 0x178632412a0eb4e642bfe30b1f80d0a4799ab400d4d1c76702ba01ba1458b57f (Base) and mint tx 0xa5ab46a57e89fe110df3269065c0c07a394f22fe7a769bb916a547c7c1b3e99f (Ethereum) - see the ERC's Test Cases section. The MessageTransmitter address was not independently re-verified during this research pass; confirm the real deployment address for your target chain against https://developers.circle.com/cctp/evm-smart-contracts before use.", + + "context": { + "$id": "Circle CCTP MessageTransmitter", + "contract": { + "deployments": [ + { "chainId": 1, "address": "0xYourMessageTransmitterAddress" } + ] + } + }, + + "metadata": { + "owner": "Circle", + "contractName": "MessageTransmitter", + "info": { + "url": "https://developers.circle.com/cctp/v1/message-format" + } + }, + + "display": { + "formats": { + "receiveMessage(bytes message,bytes attestation)": { + "$id": "CCTP Receive Message", + "intent": "Receive cross-chain message", + "fields": [ + { + "path": "message", + "label": "Message", + "layout": { + "kind": "struct", + "fields": [ + { "name": "version", "schema": { "kind": "uint", "bytes": 4 } }, + { "name": "sourceDomain", "schema": { "kind": "uint", "bytes": 4 } }, + { "name": "destinationDomain", "schema": { "kind": "uint", "bytes": 4 } }, + { "name": "nonce", "schema": { "kind": "uint", "bytes": 8 } }, + { "name": "sender", "schema": { "kind": "bytes", "length": 32 } }, + { "name": "recipient", "schema": { "kind": "bytes", "length": 32 } }, + { "name": "destinationCaller", "schema": { "kind": "bytes", "length": 32 } }, + { "name": "messageBody", "schema": { "kind": "struct", "fields": [ + { "name": "version", "schema": { "kind": "uint", "bytes": 4 } }, + { "name": "burnToken", "schema": { "kind": "bytes", "length": 32 } }, + { "name": "mintRecipient", "schema": { "kind": "bytes", "length": 32 } }, + { "name": "amount", "schema": { "kind": "uint", "bytes": 32 } }, + { "name": "messageSender", "schema": { "kind": "bytes", "length": 32 } } + ]}} + ] + } + } + ] + } + } + } +} diff --git a/assets/erc-non-abi-dispatch/example-eas-attestation.json b/assets/erc-non-abi-dispatch/example-eas-attestation.json new file mode 100644 index 00000000000..1798d8cbbf2 --- /dev/null +++ b/assets/erc-non-abi-dispatch/example-eas-attestation.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.schema.json", + + "$comment": "Worked example for the non-ABI-dispatch companion ERC, decoding an EAS attestation via schema-UID dispatch. Verified against a real Optimism mainnet attestation, tx 0x820e5b8404f1ec62b47459e538151e54fb598b729dcb461087456bb856abf595 - see the ERC's Test Cases section. The EAS contract address is a placeholder; confirm the real deployment address for your target chain against https://docs.attest.org before use.", + + "context": { + "$id": "Ethereum Attestation Service", + "contract": { + "deployments": [ + { "chainId": 10, "address": "0xYourEASAddress" } + ] + } + }, + + "metadata": { + "owner": "Ethereum Attestation Service", + "contractName": "EAS", + "info": { + "url": "https://docs.attest.org" + } + }, + + "display": { + "formats": { + "attest((bytes32 schema,(address recipient,uint64 expirationTime,bool revocable,bytes32 refUID,bytes data,uint256 value) data) request)": { + "$id": "EAS Attest", + "intent": "Attest", + "fields": [ + { "path": "request.data.recipient", "label": "Recipient", "format": "addressName" }, + { + "path": "request.data.data", + "label": "Attestation data", + "dispatch": { + "tag": { "path": "request.schema" }, + "cases": { + "0xfdcfdad2dbe7489e0ce56b260348b7f14e8365a8a325aef9834818c00d46b31b": + { "abiType": "(string rpgfRound,address referredBy,string referredMethod)" } + }, + "default": "reject" + } + } + ] + } + } + } +} diff --git a/assets/erc-non-abi-dispatch/example-erc7579-execute.json b/assets/erc-non-abi-dispatch/example-erc7579-execute.json new file mode 100644 index 00000000000..dcff63a8f44 --- /dev/null +++ b/assets/erc-non-abi-dispatch/example-erc7579-execute.json @@ -0,0 +1,73 @@ +{ + "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.schema.json", + + "$comment": "Worked example for the non-ABI-dispatch companion ERC, decoding ERC-7579's mode-driven execute() dispatch. Verified against two real Base mainnet Biconomy Nexus transactions: single-call 0x057b1df67f033ad77faba10e39f39dde273c225d62c3b36ef8547b3f51fad5c1 and batch-call 0x26d34bf7aa5adb0642218422264a4034ffda5785be3354168eb478051664613c - see the ERC's Test Cases section. Only CallType 0x00 (single) and 0x01 (batch) are covered; a production descriptor would also handle 0xfe (staticcall) and 0xff (delegatecall).", + + "context": { + "$id": "ERC-7579 Account Execute", + "contract": { + "deployments": [ + { "chainId": 8453, "address": "0x510a1274373c8120DE634dB372723edcBE899994" }, + { "chainId": 8453, "address": "0xE8cCb14989F0dB3f51A71876195D5867F7fa943d" } + ] + } + }, + + "metadata": { + "owner": "ERC-7579", + "contractName": "Modular Smart Account", + "info": { + "url": "https://eips.ethereum.org/EIPS/eip-7579" + } + }, + + "display": { + "formats": { + "execute(bytes32 mode,bytes executionCalldata)": { + "$id": "ERC-7579 Execute", + "intent": "Execute", + "fields": [ + { + "path": "mode", + "label": "Mode", + "layout": { + "kind": "struct", + "fields": [ + { "name": "callType", "schema": { "kind": "uint", "bytes": 1 } }, + { "name": "execType", "schema": { "kind": "uint", "bytes": 1 } }, + { "name": "unused", "schema": { "kind": "bytes", "length": 4 } }, + { "name": "modeSelector", "schema": { "kind": "bytes", "length": 4 } }, + { "name": "modePayload", "schema": { "kind": "bytes", "length": 22 } } + ] + } + }, + { + "path": "executionCalldata", + "label": "Execution", + "dispatch": { + "tag": { "path": "mode.callType" }, + "cases": { + "0x00": { "layout": { + "kind": "struct", + "fields": [ + { "name": "target", "schema": { "kind": "address" } }, + { "name": "value", "schema": { "kind": "uint", "bytes": 32 } }, + { "name": "callData", "schema": { "kind": "call", "to": "target" } } + ] + }}, + "0x01": { "abiType": "(address target,uint256 value,bytes callData)[]" } + }, + "default": "reject" + } + }, + { + "path": "executionCalldata[].callData", + "label": "Batched call data", + "layout": { "kind": "call", "to": "executionCalldata[].target" }, + "$comment": "Only applicable when mode.callType == 0x01: each ABI-decoded Execution.callData in the batch is itself calldata to its sibling target, resolved the same way as the single-call case above." + } + ] + } + } + } +} diff --git a/assets/erc-non-abi-dispatch/example-erc7683-order.json b/assets/erc-non-abi-dispatch/example-erc7683-order.json new file mode 100644 index 00000000000..daefee95f24 --- /dev/null +++ b/assets/erc-non-abi-dispatch/example-erc7683-order.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.schema.json", + + "$comment": "Worked example for the non-ABI-dispatch companion ERC, decoding an ERC-7683 cross-chain order via orderDataType dispatch. Verified against a real Base mainnet transaction, tx 0x73b3ca11e3733c78db8365086f64874879fb3a859b21c960ec072c2a95c5da09 - see the ERC's Test Cases section.", + + "context": { + "$id": "Across Origin Settler", + "contract": { + "deployments": [ + { "chainId": 8453, "address": "0x4afb570AC68BfFc26Bb02FdA3D801728B0f93C9E" } + ] + } + }, + + "metadata": { + "owner": "Across Protocol", + "contractName": "AcrossOriginSettler", + "info": { + "url": "https://docs.across.to/guides/concepts/erc-7683" + } + }, + + "display": { + "formats": { + "open((uint32 fillDeadline,bytes32 orderDataType,bytes orderData) order)": { + "$id": "ERC-7683 Open Order", + "intent": "Open cross-chain order", + "fields": [ + { "path": "order.fillDeadline", "label": "Fill deadline", "format": "date", "params": { "encoding": "timestamp" } }, + { + "path": "order.orderData", + "label": "Order data", + "dispatch": { + "tag": { "path": "order.orderDataType" }, + "cases": { + "0x9df4b782e7bbc178b3b93bfe8aafb909e84e39484d7f3c59f400f1b4691f85e2": + { "abiType": "(address inputToken,uint256 inputAmount,address outputToken,uint256 outputAmount,uint256 destinationChainId,bytes32 recipient,address exclusiveRelayer,uint256 depositNonce,uint32 exclusivityPeriod,bytes message)" } + }, + "default": "reject" + } + } + ] + } + } + } +} diff --git a/assets/erc-non-abi-dispatch/example-legacy-token.json b/assets/erc-non-abi-dispatch/example-legacy-token.json new file mode 100644 index 00000000000..ae5999f66e7 --- /dev/null +++ b/assets/erc-non-abi-dispatch/example-legacy-token.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.schema.json", + + "$comment": "Illustrative target-interface descriptor. Resolved recursively when TieredExecutor's `op` dispatches to case 2 - see example-tiered-executor.json. Not deployed anywhere; the address below is a placeholder.", + + "context": { + "$id": "Legacy Token", + "contract": { + "deployments": [ + { "chainId": 1, "address": "0xYourLegacyTokenAddress" } + ] + } + }, + + "metadata": { + "owner": "Example", + "contractName": "Legacy Token" + }, + + "display": { + "formats": { + "creditAccount(uint256 amount,address to)": { + "intent": "Credit legacy balance", + "interpolatedIntent": "Credit {to} with {amount}", + "fields": [ + { "path": "amount", "label": "Amount", "format": "amount" }, + { "path": "to", "label": "Recipient", "format": "addressName" } + ] + } + } + } +} diff --git a/assets/erc-non-abi-dispatch/example-reward-vault.json b/assets/erc-non-abi-dispatch/example-reward-vault.json new file mode 100644 index 00000000000..fce98d2eebb --- /dev/null +++ b/assets/erc-non-abi-dispatch/example-reward-vault.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.schema.json", + + "$comment": "Illustrative target-interface descriptor. Resolved recursively when TieredExecutor's `op` dispatches to case 1 - see example-tiered-executor.json. Not deployed anywhere; the address below is a placeholder.", + + "context": { + "$id": "Reward Vault", + "contract": { + "deployments": [ + { "chainId": 1, "address": "0xYourRewardVaultAddress" } + ] + } + }, + + "metadata": { + "owner": "Example", + "contractName": "Reward Vault" + }, + + "display": { + "formats": { + "grantReward(address to,uint256 amount)": { + "intent": "Grant reward", + "interpolatedIntent": "Grant {amount} reward to {to}", + "fields": [ + { "path": "to", "label": "Recipient", "format": "addressName" }, + { "path": "amount", "label": "Amount", "format": "amount" } + ] + } + } + } +} diff --git a/assets/erc-non-abi-dispatch/example-safe-multisend.json b/assets/erc-non-abi-dispatch/example-safe-multisend.json new file mode 100644 index 00000000000..3d36bb365e0 --- /dev/null +++ b/assets/erc-non-abi-dispatch/example-safe-multisend.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.schema.json", + + "$comment": "Worked example for the non-ABI-dispatch companion ERC, decoding Safe's MultiSend packed batching format. Verified against real Ethereum mainnet tx 0x414ae5aaff729927d663ccaa027ea2284e47fa546cb73ce1dee481ee36a7138e - see the ERC's Test Cases section.", + + "context": { + "$id": "Safe MultiSendCallOnly 1.3.0", + "contract": { + "deployments": [ + { "chainId": 1, "address": "0x40A2aCCbd92BCA938b02010E17A5b8929b49130D" } + ] + } + }, + + "metadata": { + "owner": "Safe", + "contractName": "MultiSendCallOnly", + "info": { + "url": "https://github.com/safe-global/safe-smart-account" + } + }, + + "display": { + "formats": { + "multiSend(bytes transactions)": { + "$id": "MultiSend Batch", + "intent": "Execute batch", + "fields": [ + { + "path": "transactions", + "label": "Batched calls", + "layout": { + "kind": "sequence", + "count": "tillEnd", + "element": { + "kind": "struct", + "fields": [ + { "name": "operation", "schema": { "kind": "uint", "bytes": 1 } }, + { "name": "to", "schema": { "kind": "address" } }, + { "name": "value", "schema": { "kind": "uint", "bytes": 32 } }, + { "name": "dataLength", "schema": { "kind": "uint", "bytes": 32 } }, + { "name": "data", "schema": { "kind": "call", "to": "to", "lengthFrom": "dataLength" } } + ] + } + } + } + ] + } + } + } +} diff --git a/assets/erc-non-abi-dispatch/example-tiered-executor.json b/assets/erc-non-abi-dispatch/example-tiered-executor.json new file mode 100644 index 00000000000..37298dd940f --- /dev/null +++ b/assets/erc-non-abi-dispatch/example-tiered-executor.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.schema.json", + + "$comment": "Illustrative example for the non-ABI-dispatch companion ERC. TieredExecutor is a made-up contract written for this ERC (see TieredExecutor.sol in this same folder) and is not deployed anywhere; the address below is a placeholder. It demonstrates the direct/positional form of `call`: `op` is an ordinary already-decoded parameter (not a bytes blob), and the two dispatch cases redirect the whole call to a different target function, each with its own argument order.", + + "context": { + "$id": "TieredExecutor Example", + "contract": { + "deployments": [ + { "chainId": 1, "address": "0xYourTieredExecutorAddress" } + ] + } + }, + + "metadata": { + "owner": "Example", + "contractName": "TieredExecutor", + "enums": { + "operationKind": { + "1": "Grant reward", + "2": "Credit legacy balance" + } + } + }, + + "display": { + "formats": { + "executeOperation(address target,uint8 op,address account,uint256 amount)": { + "$id": "Execute Operation", + "dispatch": { + "tag": { "path": "op" }, + "cases": { + "1": { "call": { + "to": "target", + "signature": "grantReward(address,uint256)", + "args": [ + { "path": "account" }, + { "path": "amount" } + ] + }}, + "2": { "call": { + "to": "target", + "signature": "creditAccount(uint256,address)", + "args": [ + { "path": "amount" }, + { "path": "account" } + ] + }} + }, + "default": "reject" + } + } + } + } +} diff --git a/assets/erc-non-abi-dispatch/example-universal-router.json b/assets/erc-non-abi-dispatch/example-universal-router.json new file mode 100644 index 00000000000..c3f11351464 --- /dev/null +++ b/assets/erc-non-abi-dispatch/example-universal-router.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.schema.json", + + "$comment": "Worked example for the non-ABI-dispatch companion ERC, decoding Uniswap's Universal Router command dispatch. Verified against real Ethereum mainnet tx 0x3805667353244e8fb763d50b7dd3bdb8f176119b44fdbd0a4ad5629d851ebbba - see the ERC's Test Cases section. Only the two command IDs exercised by that transaction (0x00 and 0x04) are listed here; a production descriptor would list every command ID defined in Uniswap/universal-router's Commands.sol.", + + "context": { + "$id": "Uniswap Universal Router", + "contract": { + "deployments": [ + { "chainId": 1, "address": "0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af" } + ] + } + }, + + "metadata": { + "owner": "Uniswap", + "contractName": "Universal Router", + "info": { + "url": "https://docs.uniswap.org/contracts/universal-router/overview" + } + }, + + "display": { + "formats": { + "execute(bytes commands,bytes[] inputs,uint256 deadline)": { + "$id": "Universal Router Execute", + "intent": "Execute swap", + "fields": [ + { "path": "deadline", "label": "Valid until", "format": "date", "params": { "encoding": "timestamp" } }, + { + "path": "inputs", + "label": "Commands", + "layout": { + "kind": "sequence", + "count": "tillEnd", + "element": { + "kind": "dispatch", + "tag": { "path": "commands", "kind": "uint", "bytes": 1, "mask": "0x3f" }, + "cases": { + "0x00": { "abiType": "(address recipient,uint256 amountIn,uint256 amountOutMinimum,bytes path,bool payerIsUser)" }, + "0x04": { "abiType": "(address token,address recipient,uint256 amountMinimum)" } + }, + "default": "reject" + } + } + } + ] + } + } + } +} From 440a6ab0af112d80cbe254a1207dfd5dc363e6ba Mon Sep 17 00:00:00 2001 From: Alex Forshtat Date: Mon, 27 Jul 2026 11:27:36 +0200 Subject: [PATCH 02/23] Add 'bitfield' and 'initCode' formats --- ERCS/erc-draft_non_abi_dispatch.md | 68 ++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/ERCS/erc-draft_non_abi_dispatch.md b/ERCS/erc-draft_non_abi_dispatch.md index 738e99dfb83..e6406fb9805 100644 --- a/ERCS/erc-draft_non_abi_dispatch.md +++ b/ERCS/erc-draft_non_abi_dispatch.md @@ -12,9 +12,9 @@ requires: 7730 ## Abstract -[ERC-7730](./erc-7730.md) describes how to clear-sign structured data by decoding calldata as a Solidity ABI function call, then formatting the resulting named fields. This works as long as every value in the call is itself ABI-encoded. It breaks down for a common and growing class of contracts that accept one ABI-encoded `bytes` (or `bytes[]`) argument and then interpret its raw content using their own private encoding — packed structs, bit-packed words, or a tag byte that selects one of several possible payload shapes. Gnosis Safe's `MultiSend`, Uniswap's Universal Router, and ERC-7579 modular accounts all do this, and none of it can be described in ERC-7730 today; such fields must be left as opaque, unreadable bytes. +[ERC-7730](./erc-7730.md) describes how to clear-sign structured data by decoding calldata as a Solidity ABI function call, then formatting the resulting named fields. This works as long as every value in the call is itself ABI-encoded. It breaks down for a common and growing class of contracts that accept one ABI-encoded `bytes` (or `bytes[]`) argument and then interpret its raw content using their own private encoding — packed structs, bit-packed flags, or a tag that selects one of several possible payload shapes. Gnosis Safe's `MultiSend`, Uniswap's Universal Router and hook addresses, and ERC-7579 modular accounts all do this, and none of it can be described in ERC-7730 today; such fields must be left as opaque, unreadable bytes. It also breaks down entirely for contract-creation calls, which have no function selector at all — a real, common, security-critical transaction type ERC-7730 has no vocabulary for. -This ERC adds two new keys to an ERC-7730 [field format specification](./erc-7730.md#field-format-specification) — `layout` and `dispatch` — that let an author describe the internal structure of such a field and, where the field's shape depends on a tag value, which structure applies. Everything else about ERC-7730 (context binding, metadata, top-level selector matching, path syntax, `display.formats`) is unchanged; this ERC only extends what a single field's `path` can resolve into. +This ERC adds two new keys to an ERC-7730 [field format specification](./erc-7730.md#field-format-specification) — `layout` and `dispatch` — that let an author describe the internal structure of such a field and, where the field's shape depends on a tag value, which structure applies. It also adds a reserved `$fallback` key to `display.formats` for contracts that dispatch with no selector at all. Everything else about ERC-7730 (context binding, metadata, top-level selector matching, path syntax) is unchanged; this ERC only extends what a single field's `path` can resolve into, and how a contract's entry point is matched in the first place. ## Motivation @@ -23,6 +23,8 @@ Look at what a `display.formats` entry can describe today: a Solidity function s - **Safe's `MultiSend.multiSend(bytes transactions)`** — `transactions` is not ABI-encoded. It is a tightly packed, back-to-back sequence of `(operation, to, value, dataLength, data)` records, repeated until the buffer runs out. Each record's own `data` is, in turn, a normal call to some other contract. - **Uniswap's Universal Router `execute(bytes commands, bytes[] inputs)`** — `commands` is one raw opcode byte per sub-action (with a flag bit for "allowed to revert"); `inputs[i]` is separately ABI-encoded, but *which* ABI type it decodes as depends on the opcode at `commands[i]`. - **ERC-7579 modular accounts, `execute(bytes32 mode, bytes executionCalldata)`** — `mode` packs five sub-fields into one word; `executionCalldata`'s shape (a single packed call, or an ABI-encoded array of calls) depends on one byte of `mode`. +- **Uniswap v4 hook addresses** — up to 14 independent permission flags (`beforeSwap`, `afterSwap`, and others) live in specific low-order bits of the 160-bit hook address itself; the same address value is simultaneously "an address" and "a bitmask," with no byte alignment between the two meanings. +- **Contract-creation transactions** — a `CREATE`/`CREATE2` deployment, or a generic deterministic-deployment factory taking raw bytecode as an argument, has no function selector at all. There is nothing for ERC-7730's selector-matching to match against, and no vocabulary for describing constructor arguments appended to, or embedded within, compiler-specific creation bytecode. None of this is exotic or rare. It is how batching, modular accounts, and generic-purpose routers already work across the ecosystem, and account-abstraction adoption is only going to produce more of it. A wallet with no way to describe these fields has no way to clear-sign them beyond showing raw hex — which is exactly the blind, trust-me signing experience ERC-7730 exists to eliminate. @@ -49,6 +51,18 @@ This ERC defines two additional keys usable in an ERC-7730 [field format specifi `uint.bytes` is the width in bytes (1, 2, 4, 8, 16, or 32). `endian` is `"be"` or `"le"`, defaulting to `"be"` — every known EVM-side use case packs data big-endian, matching Solidity's own word layout; `"le"` exists only so this vocabulary does not need to change if a future non-EVM companion reuses it. `address` is sugar for a 20-byte `bytes` node. `bytes.length` MAY be a fixed integer, a `lengthFrom` reference to an earlier sibling field's decoded value (see `struct` below), or omitted entirely on the last field of a `struct`, meaning "consume whatever bytes remain in the enclosing buffer." +**`bitfield`** — a fixed-width value (same width rules as `uint`) whose individual bits or bit ranges each carry independent, named meaning: + +```json +{ "kind": "bitfield", "bytes": 20, "endian": "be", "fields": [ + { "name": "beforeSwap", "bit": 7 }, + { "name": "afterSwap", "bit": 6 }, + { "name": "poolId", "bits": [19, 8] } +]} +``` + +Each entry in `fields` is either `{ "name": ..., "bit": N }` (a single boolean flag at bit `N`, 0-indexed from the least significant bit, decoded as `bool`) or `{ "name": ..., "bits": [hi, lo] }` (an inclusive bit range, decoded as an unsigned integer). Bit positions are independent of, and MAY overlap arbitrarily within, the underlying value's byte boundaries — this is precisely what distinguishes `bitfield` from `struct`, whose fields are always byte-aligned and never overlap. Named sub-fields are addressed exactly like `struct` fields (by name); see [Path addressing](#path-addressing). `bitfield` consumes its declared `bytes` width regardless of how many of those bits are named — undeclared bits are simply not exposed as fields. + **`struct`** — an ordered, unpadded concatenation of named fields: ```json @@ -86,11 +100,41 @@ This is how Safe MultiSend's inner calls are described. Note that `call` is decl `call` need not sit inside a `struct`. A field whose bytes were obtained by ordinary ABI decoding (not by a parent `layout` at all) MAY also declare `layout` directly as a `call` node, meaning "this ABI-decoded `bytes` field is itself calldata to another contract." This is how ERC-7579's batched executions are described: each element of the ABI-decoded `Execution[]` array has an ordinary `bytes callData` member, and a second field entry with `path: "executionCalldata[].callData"` and `layout: {"kind": "call", "to": "executionCalldata[].target"}` recurses into it, addressed at the same array index as its sibling `target` — the same by-index correlation ERC-7730 already uses for [array-valued formatting parameters](./erc-7730.md#field-format-specification). +**`initCode`** — the bytes at this position are a contract-creation payload (raw creation bytecode, optionally followed by, or wrapped around, constructor arguments), matched against a small, explicit set of known, audited templates: + +```json +{ "kind": "initCode", + "length": 1497, + "templates": [ + { + "id": "example-template", + "prefix": { "hash": "0x", "length": 1477 }, + "args": { "abiType": "(address singleton)" } + } + ], + "default": "reject" +} +``` + +`initCode` is byte-consuming like `bytes` (`length` / `lengthFrom` / implicit remainder). Once its bytes are consumed, a wallet MUST attempt each entry in `templates`, **in declaration order**, and use the first that structurally matches: + +1. Compare the first `prefix.length` bytes of the consumed region against `prefix`: either byte-for-byte, if `prefix.literal` (an inline hex string) is given, or by comparing `keccak256` of that exact-length slice against `prefix.hash`. Exactly one of `literal` or `hash`+`length` MUST be present. If the consumed region is shorter than `prefix.length`, or the comparison fails, this template does not match; proceed to the next. +2. If the template also declares a `suffix` (same `literal` or `hash`+`length` shape), compare it the same way against the *last* `suffix.length` bytes of the consumed region. If it does not match, this template does not match; proceed to the next. +3. Otherwise, this template matches. Apply `args` — either `{ "abiType": "" }` (ordinary ABI decoding) or `{ "layout": }` (this ERC's own layout language) — to the bytes strictly between the end of `prefix` and the start of `suffix` (or, if no `suffix` is declared, to everything after `prefix` through the end of the consumed region). + +If no template matches, a wallet MUST apply `default: "reject"` — the same [unknown selector](./erc-7730.md#unknown-selectors) fallback used everywhere else in this ERC: display a safe fallback and MUST NOT guess at a decoding. `"reject"` is the only defined value for `default`. + +`prefix`'s two forms exist for different template sizes: `literal` is legible and auditable at a glance for short, fixed templates (a minimal proxy's handful of bytes); `hash` avoids inlining an entire compiled contract's creation bytecode for large templates, at the cost of the match no longer being visually verifiable from the descriptor text alone. Authors are responsible for computing `hash` values from the exact compiler output (version and settings) they intend to match — a single compiler flag change produces different bytecode and a different hash, silently falling through to `reject` rather than misdecoding. + +### `$fallback` + +Some contracts — notably generic deterministic-deployment proxies (see [Test Cases](#test-cases)) — expose no ABI-selected function at all; every call reaches a single raw fallback. `display.formats` MAY use the reserved key `"$fallback"` for exactly this case: a [structured data format specification](./erc-7730.md#structured-data-format-specification) matched whenever calldata does not correspond to any selector-based entry in the same file, whose `fields`/`layout` describe the entirety of `data` directly, with no selector-stripping step. `$fallback` MUST NOT be combined with selector-keyed entries that could themselves match the same calldata; wallets MUST prefer a matching selector-keyed entry over `$fallback` when both are present and only one is intended to apply. This does not change `display.formats` selector matching for any contract that has a normal ABI — it only gives contracts that genuinely have none a way to be described at all. + **Byte-width invariant.** Every `layout` node either consumes a well-defined, computable number of bytes from the buffer (all of the kinds above), or is explicitly declared non-consuming (only `dispatch`'s path-sourced tag form, below, which reads an already-resolved value instead of parsing bytes). No node kind may be ambiguous about whether, or how much, it advances the cursor. ### Path addressing -Paths extend into `layout`-decoded fields the same way they already extend into ABI-decoded struct and array fields: by name for `struct` fields, by index for `sequence` elements. For example, given the `struct` above, `#.transactions[0].to` refers to the `to` field of the first record; `#.transactions[1].data.to` refers into a nested `call`'s own resolved fields, addressed using that resolved function's own parameter names, once matched. +Paths extend into `layout`-decoded fields the same way they already extend into ABI-decoded struct and array fields: by name for `struct` and `bitfield` fields, by index for `sequence` elements. For example, given the `struct` above, `#.transactions[0].to` refers to the `to` field of the first record; `#.transactions[1].data.to` refers into a nested `call`'s own resolved fields, addressed using that resolved function's own parameter names, once matched. Similarly, once an `initCode` node's `templates` entry has matched, its `args` fields are addressed by name (or by tuple position, for an unnamed `abiType`), exactly as they would be for any other matched call. ### `dispatch` @@ -168,6 +212,12 @@ A wallet MUST resolve the matched target's own `intent`, `interpolatedIntent`, a **Why the top-level `dispatch`/direct-`call` form exists, and why it's the one exception to this ERC's evidence rule.** Every other construct in this ERC exists because a real, cited transaction needed it. This one does not have that grounding — no verified live transaction was found that reinterprets already-decoded parameters into a different function's argument list the way the [TieredExecutor example](../assets/erc-non-abi-dispatch/example-tiered-executor.json) does. It is included because the shape it targets — a small trusted relayer accepting a tag and a handful of generic-looking arguments, then re-dispatching to one of several unrelated target interfaces with a different argument order per target — is a common, plausible, and easily reachable governance/router pattern, structurally close to a `switch` over an enum parameter. Readers should weigh this construct with that in mind: it is motivated by generality, not by an observed case, unlike everything else here. If a real contract using this exact shape turns up, its transaction should replace the made-up one in the Test Cases section. +**Why `bitfield` is a distinct node kind rather than a parameter on `uint`.** `struct` and `sequence` both assume byte-aligned, non-overlapping fields — that assumption is load-bearing throughout the rest of this ERC (it's what makes the byte-width invariant a simple sum of child widths). `bitfield`'s named sub-fields can overlap arbitrarily within a shared width and carry no byte alignment at all, so keeping it a separate, clearly-labeled kind (rather than, say, a `bits` option quietly attached to `uint`) makes it visually obvious, at the point a field is declared, that its sub-fields don't follow the rest of the language's byte-aligned norm. The motivating real case is Uniswap v4: hook contract addresses encode up to 14 independent permission flags in specific low-order bits of the 160-bit address value itself, verified against `Hooks.sol` and Uniswap's own v4 documentation. + +**Why `initCode` is its own node kind rather than a `dispatch` tag-sourcing mode.** An earlier version of this design considered folding creation-bytecode matching into `dispatch` as a fourth tag-sourcing mode (hash of a length-prefix of the buffer). That would have worked for the simplest case, but it doesn't generalize cleanly: some real creation-code templates append constructor arguments after a fixed prefix (a generic factory concatenating a template with a trailing ABI-encoded argument), while others — [EIP-1167](https://eips.ethereum.org/EIPS/eip-1167) minimal proxies, verified against the standard's own bytecode listing — embed their one constructor-equivalent value (the implementation address) *between* a fixed prefix and a fixed suffix, with no ABI encoding at all. Expressing both shapes through a single scalar "tag" and a flat `cases` map would have needed the tag itself to somehow also carry "and here's where the matched region ends", which is exactly the kind of implicit, easy-to-get-wrong behavior the byte-width invariant exists to rule out elsewhere in this ERC. A dedicated node with explicit `prefix`/`suffix`/`args` fields makes the matched region's boundaries an explicit, checkable part of each template entry instead. + +**Why `$fallback` is needed at all.** Every other selector-related mechanism in ERC-7730 and this ERC assumes a 4-byte selector exists to be computed and matched. The generic deterministic-deployment proxy that motivates `initCode` — the same, single contract, deployed at the identical address on nearly every EVM chain, that many real, well-known contracts (including Uniswap's own Permit2) are deployed through — has no selector at all; its calldata is `salt ‖ initCode`, dispatched by a raw fallback. Without `$fallback`, `initCode` would have no contract it could actually be demonstrated on, since every other candidate factory this research pass found either has a normal ABI wrapper around its bytecode argument (already describable with plain `abiType`, no new construct needed) or turned out, on inspection, to build its creation code internally rather than receiving it as literal calldata at all. + **Why positional binding, and why `args` may reorder.** ERC-7730 already treats parameter *names* as non-canonical for the purpose of selector matching — only position and type are. A dispatch case that redirects to a different function has no shared parameter names to align by in the first place (the outer call's `account`/`amount` mean nothing to the target function's own signature), so positional binding by the target's declared order is the only definition that is well-defined at all, and it is the same rule ERC-7730 already applies elsewhere, not a new one. ## Backwards Compatibility @@ -176,7 +226,7 @@ This ERC only adds new, optional keys to a field format specification. A descrip ## Test Cases -Six of the seven examples below are real, mined transactions, decoded from raw calldata (not an explorer's rendered summary) and cross-checked against at least one independent source. Each is a full, standalone ERC-7730 descriptor file under [`../assets/erc-non-abi-dispatch/`](../assets/erc-non-abi-dispatch/) rather than a snippet, so it can be read with all the surrounding `context`/`metadata`/`display` structure intact. The seventh, `TieredExecutor`, is explicitly a made-up contract — see its own description below and the caveat in [Rationale](#rationale). +Six of the eight examples below are real, mined transactions, decoded from raw calldata (not an explorer's rendered summary) and cross-checked against at least one independent source. A seventh demonstrates `initCode`/`$fallback` against real, named, well-known contracts rather than one specific transaction. The eighth, `TieredExecutor`, is explicitly a made-up contract — see its own description below and the caveat in [Rationale](#rationale). Each is a full, standalone ERC-7730 descriptor file under [`../assets/erc-non-abi-dispatch/`](../assets/erc-non-abi-dispatch/) rather than a snippet, so it can be read with all the surrounding `context`/`metadata`/`display` structure intact. ### Safe `MultiSend` @@ -214,6 +264,14 @@ Base mainnet, tx [`0x73b3ca11e3733c78db8365086f64874879fb3a859b21c960ec072c2a95c Full descriptor: [`example-erc7683-order.json`](../assets/erc-non-abi-dispatch/example-erc7683-order.json). +### Deterministic deployment proxy (`initCode` / `$fallback`) + +The generic deterministic-deployment proxy at `0x4e59b44847b379578588920cA78FbF26c0B4956C` ("Nick's method") is deployed at this identical address on nearly every EVM chain and dispatches via a raw fallback — calldata is exactly `salt (32 bytes) ‖ initCode`, fed directly into `CREATE2`; there is no selector at all, which is what motivates `$fallback`. It is used to deploy many well-known contracts deterministically, including Uniswap's own Permit2 (`0x000000000022D473030F116dDEE9F6B43aC78BA3`, the same address on every chain it's deployed to). The descriptor's second template is [EIP-1167](https://eips.ethereum.org/EIPS/eip-1167)'s minimal-proxy creation code, byte-exact and fully verified against the standard's own bytecode listing: a 20-byte prefix (`0x3d602d80600a3d3981f3363d3d373d3d3d363d73`), a 20-byte embedded implementation address, and a 15-byte suffix (`0x5af43d82803e903d91602b57fd5bf3`) — 55 bytes total, with no ABI encoding involved at all, which is why `initCode` needed a `suffix` concept rather than just "prefix, then trailing ABI args." + +Unlike the six examples above, this one demonstrates a mechanism against real, named, well-known contracts rather than one specific mined transaction. The Permit2 template's `prefix.hash`/`prefix.length` are explicitly marked as placeholders in the file — computing them requires Permit2's exact, compiler-version-specific creation bytecode, which was not independently re-derived for this example. + +Full descriptor: [`example-deterministic-deployment-proxy.json`](../assets/erc-non-abi-dispatch/example-deterministic-deployment-proxy.json). + ### `TieredExecutor` (made-up example) A small, illustrative Solidity contract written for this ERC — [`TieredExecutor.sol`](../assets/erc-non-abi-dispatch/TieredExecutor.sol) — is **not deployed anywhere**; unlike every other example above, no real transaction exists for it. It demonstrates the top-level `dispatch`/direct-`call` form: `executeOperation(address target, Operation op, address account, uint256 amount)` takes an enum tag `op` and two generic-looking arguments, and re-dispatches to one of two unrelated target interfaces — `IRewardVault.grantReward(address,uint256)` for `op=1`, `ILegacyToken.creditAccount(uint256,address)` for `op=2` — each with a different parameter order, resolved from the same `account`/`amount` values by positional binding. @@ -228,6 +286,8 @@ TBD A `layout`/`dispatch` interpreter is new parsing surface on a hardware wallet, decoding attacker-influenced (calldata is provided by whoever submits the transaction) bytes. Implementations MUST bound recursion depth (`call` and nested `dispatch` can recurse arbitrarily deep in principle), MUST treat any length (`lengthFrom`, or a `sequence`'s implicit `tillEnd` walk) that would read past the end of the underlying buffer as invalid input, and MUST fail closed — applying the [unknown selector](./erc-7730.md#unknown-selectors) fallback — rather than displaying a partially decoded or best-guess value when a `layout` or `dispatch` does not cleanly match the actual bytes. +`initCode` template matching is exact-bytes (or exact-hash) matching against a fixed template. A wallet MUST NOT treat a partial or fuzzy prefix/suffix match as a match — an attacker who can get even one byte accepted as "close enough" could potentially get unrelated, unaudited bytecode displayed as if it were a known, trusted template. Authors MUST keep `templates` entries pinned to one specific compiler version and settings; the same source recompiled differently produces different bytecode and MUST be treated as an entirely distinct, separately-audited template, never as a "should still basically match" variant of an existing one. + ## Copyright Copyright and related rights waived via [CC0](../LICENSE.md). From afb9d31d8b8fd6a9482fa9b49a7f07812950bfff Mon Sep 17 00:00:00 2001 From: Alex Forshtat Date: Mon, 27 Jul 2026 12:26:21 +0200 Subject: [PATCH 03/23] Add missing assets for bitfield/initCode/$fallback additions Adds the deterministic-deployment-proxy worked example (initCode/$fallback, verified EIP-1167 template plus a placeholder Permit2 template) and the parked stateRef-dispatch feature note, both referenced by the previous commit's spec text but left out of it. --- ...xample-deterministic-deployment-proxy.json | 65 +++++++++++++++++++ .../feature-stateref-based-dispatch.md | 60 +++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 assets/erc-non-abi-dispatch/example-deterministic-deployment-proxy.json create mode 100644 assets/erc-non-abi-dispatch/feature-stateref-based-dispatch.md diff --git a/assets/erc-non-abi-dispatch/example-deterministic-deployment-proxy.json b/assets/erc-non-abi-dispatch/example-deterministic-deployment-proxy.json new file mode 100644 index 00000000000..8767582f888 --- /dev/null +++ b/assets/erc-non-abi-dispatch/example-deterministic-deployment-proxy.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.schema.json", + + "$comment": "Worked example for the non-ABI-dispatch companion ERC, demonstrating `initCode` and `$fallback`. Context: the deterministic deployment proxy at 0x4e59b44847b379578588920cA78FbF26c0B4956C (Arachnid/'Nick's method') is deployed at this identical address on nearly every EVM chain and dispatches via a raw fallback with no ABI selector at all - calldata is exactly `salt (32 bytes) || initCode`, fed directly into CREATE2. It is used to deploy many well-known contracts deterministically, including Uniswap's own Permit2 (0x000000000022D473030F116dDEE9F6B43aC78BA3, the same address on every chain it's deployed to) and EIP-1167 minimal proxy clones. The EIP-1167 template below (prefix/suffix/address-offset) is byte-exact, verified against the standard's own bytecode listing (https://eips.ethereum.org/EIPS/eip-1167). The Permit2 template's `prefix.hash`/`prefix.length` are ILLUSTRATIVE PLACEHOLDERS - a real descriptor must compute them from Permit2's exact, compiler-version-specific creation bytecode, which was not independently re-derived for this example.", + + "context": { + "$id": "Deterministic Deployment Proxy", + "contract": { + "deployments": [ + { "chainId": 1, "address": "0x4e59b44847b379578588920cA78FbF26c0B4956C" }, + { "chainId": 8453, "address": "0x4e59b44847b379578588920cA78FbF26c0B4956C" }, + { "chainId": 42161, "address": "0x4e59b44847b379578588920cA78FbF26c0B4956C" }, + { "chainId": 10, "address": "0x4e59b44847b379578588920cA78FbF26c0B4956C" } + ] + }, + "$comment": "Same address on essentially every EVM chain by construction (see Rationale in the ERC); the chainIds above are a representative sample, not an exhaustive list." + }, + + "metadata": { + "owner": "Arachnid (Nick Johnson)", + "contractName": "Deterministic Deployment Proxy", + "info": { + "url": "https://github.com/Arachnid/deterministic-deployment-proxy" + } + }, + + "display": { + "formats": { + "$fallback": { + "$id": "Deploy via CREATE2", + "intent": "Deploy contract", + "fields": [ + { "path": "salt", "label": "Salt", "format": "bytes32" }, + { + "path": "initCode", + "label": "Contract to deploy", + "layout": { + "kind": "initCode", + "templates": [ + { + "id": "permit2", + "$comment": "NOT A REAL VALUE - placeholder only. A real entry needs `hash` = keccak256 of Permit2's exact, compiler-version-specific creation bytecode (constructor takes no arguments, hence the empty `args` tuple), and `length` = that bytecode's exact byte length. Neither was independently computed for this example; see the file's top-level $comment.", + "prefix": { "hash": "0x", "length": "" }, + "args": { "abiType": "()" } + }, + { + "id": "eip1167-minimal-proxy", + "prefix": { "literal": "0x3d602d80600a3d3981f3363d3d373d3d3d363d73" }, + "suffix": { "literal": "0x5af43d82803e903d91602b57fd5bf3" }, + "args": { "layout": { + "kind": "struct", + "fields": [ + { "name": "implementation", "schema": { "kind": "address" } } + ] + }} + } + ], + "default": "reject" + } + } + ] + } + } + } +} diff --git a/assets/erc-non-abi-dispatch/feature-stateref-based-dispatch.md b/assets/erc-non-abi-dispatch/feature-stateref-based-dispatch.md new file mode 100644 index 00000000000..696e79ab9f9 --- /dev/null +++ b/assets/erc-non-abi-dispatch/feature-stateref-based-dispatch.md @@ -0,0 +1,60 @@ +# Parked feature: dispatch over live chain state + +Status: **not part of the companion ERC yet.** Parked pending [ethereum/ERCs#1738](https://github.com/ethereum/ERCs/pull/1738) ("Intent mutability") landing, since this idea is a direct extension of that PR's mechanism and shouldn't be specified independently of it. + +## The problem this would address + +Safe's `execTransactionFromModule`/Guard model, and similar "arbitrary installed extension" patterns, hand calldata to a contract that is: +- arbitrary and unbounded in general (any address the Safe owners enabled as a module), so +- there is no protocol-fixed schema to write a single descriptor against, the way there is for `MultiSend` (one fixed packed layout) or Universal Router (one bounded, enumerable command set). + +The companion ERC's own Rationale (and the accompanying "10 impossible use-cases" exercise) currently treats this as out of scope: an arbitrary Module is fundamentally unboundable, full stop. That's still correct for the *general* case. But it's more pessimistic than necessary for the common real case: an owner enables one or a small number of *specific, known, audited* modules/guards (a spending-limit module, a specific Zodiac Roles configuration, a specific recovery module), and for exactly those known configurations, a descriptor author could, in principle, describe the resulting behavior precisely. + +## PR #1738's mechanism (as landed/proposed today) + +`context.contract.stateRefs`: an array of storage-slot preconditions — + +```json +{ + "slot": "0x", + "expectedValue": "0x", + "mask": "0x", + "chainId": "", + "address": "", + "description": "human-readable explanation" +} +``` + +A wallet verifies live state matches `expectedValue` (masked, if `mask` is given) before trusting the descriptor's claimed intent. This is a **binary gate**: match → descriptor applies; mismatch → descriptor is stale, fall back to opaque signing. `context.contract.proxy` (typed EIP-1967/1822/2535 verification with `expectedImplementations`) is the same idea specialized to upgradeable-proxy implementation slots. + +Also notable: #1738 has its own normative **omission rule** — anything whose intent depends on unexpressible factors (which includes arbitrary Module/Guard calldata) MUST be omitted from `display.formats` entirely. That's the PR's current, explicit answer to this exact problem: give up, don't describe it. + +## The proposed extension: state as a dispatch tag, not just a gate + +`stateRefs` only ever answers yes/no against one pinned expectation. What Module/Guard calldata actually needs is *selection*: "depending on which known, audited configuration is currently live, here is which interpretation applies" — a multi-way choice, not a single check. That is exactly what this companion ERC's `dispatch` construct already does for calldata-sourced tags; the extension is a **fourth tag-sourcing mode**, reading the tag from live chain state instead of from calldata, a decoded sibling field, or a hashed prefix: + +```json +{ "kind": "dispatch", + "tag": { "kind": "stateRef", "address": "@.to", "slot": "0x" }, + "cases": { + "0x000000000000000000000000": { "call": { "to": "@.to", "signature": "execTransaction(...)", "args": [ /* ... */ ] } } + }, + "default": "reject" +} +``` + +Everything downstream of `tag` resolution is unchanged: the same `cases` map, the same case-value polymorphism (`abiType` / `layout` / `call` / nested `dispatch`), the same fail-closed `default: "reject"` for anything not explicitly enumerated. + +This composes with #1738 rather than duplicating it: `stateRefs`/`proxy` would remain the *static* precondition layer ("this descriptor doesn't even apply unless..."), and a `stateRef`-tagged `dispatch` would be the *dynamic selection* layer on top ("...and depending on which of several known-audited configurations is live, here's which interpretation to use"). + +## What this does and does not solve + +- **Does not** make arbitrary, unaudited Module/Guard calldata describable. That remains, correctly, unboundable — no descriptor language changes that. +- **Does** extend coverage to the case where the live module/guard is one of a small, enumerated, audited set the descriptor author explicitly listed — the same bounded, sparse, fail-closed shape every other `dispatch` table in this ERC already has. +- **Inherits** a real infrastructure requirement #1738 already introduces, not a new one: the wallet needs live chain-read access at signing time (a storage read, or, if generalized further, a `staticcall` return value), which most hardware wallets get via a companion app rather than standalone. + +## Why this is parked, not drafted + +- #1738 is still an open PR under active review (reviewer questions outstanding on diamond binding grain, omission-rule strictness, and whether preconditions should support view-function calls — the last of which is directly relevant to whether a `stateRef` tag should eventually generalize beyond raw storage slots to `staticcall` results too). +- Specifying a dependent extension before the mechanism it extends has stabilized risks having to redo this the moment #1738's own `stateRefs` shape changes during review. +- Revisit once #1738 lands: re-derive the exact `stateRef`/`slot`/`mask` shape from whatever #1738 actually ships (not from this snapshot), and decide whether `dispatch`'s fourth tag-sourcing mode belongs in this companion ERC or in a further, separate companion. From 7bb4e365df255549aa4caa22e5dedad1c514d692 Mon Sep 17 00:00:00 2001 From: Alex Forshtat Date: Thu, 30 Jul 2026 15:12:35 +0200 Subject: [PATCH 04/23] WIP manual example rewrite --- ERCS/erc-0000-custom-bytes-erc7730.md | 32 ++ .../example-all-syntax-human.json5 | 297 ++++++++++++++++++ 2 files changed, 329 insertions(+) create mode 100644 ERCS/erc-0000-custom-bytes-erc7730.md create mode 100644 assets/erc-non-abi-dispatch/example-all-syntax-human.json5 diff --git a/ERCS/erc-0000-custom-bytes-erc7730.md b/ERCS/erc-0000-custom-bytes-erc7730.md new file mode 100644 index 00000000000..16754b6d703 --- /dev/null +++ b/ERCS/erc-0000-custom-bytes-erc7730.md @@ -0,0 +1,32 @@ +--- +title: Custom Encoding Layout for ERC-7730 +description: Format to describes any non-standard byte encodings for ERC-7730 +author: Alex Forshtat (@forshtat) +discussions-to: TBD +status: Draft +type: Standards Track +category: ERC +created: 2026-07-26 +requires: 7730 +--- + +## Abstract +## Motivation +## Specification + + +### `custom` & `layout` + +### `sequence` + +### `select` + +### `interaction` + +## Rationale + +## Security Considerations + +## Copyright + +Copyright and related rights waived via [CC0](../LICENSE.md). \ No newline at end of file diff --git a/assets/erc-non-abi-dispatch/example-all-syntax-human.json5 b/assets/erc-non-abi-dispatch/example-all-syntax-human.json5 new file mode 100644 index 00000000000..af6c9d5da11 --- /dev/null +++ b/assets/erc-non-abi-dispatch/example-all-syntax-human.json5 @@ -0,0 +1,297 @@ +{ + // "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.schema.json", + "$comment": "Worked example for the non-ABI-dispatch companion ERC, decoding Safe's MultiSend packed batching format, Uniswap's Universal Router command dispatch, and ERC-7579's execute mode.", + "context": { + "$id": "ExamplesForAllCases", + "contract": { + "deployments": [] + } + }, + "metadata": {}, + "display": { + "formats": { + "multiSend(bytes transactions)": { + "$id": "MultiSend Batch", + "intent": "Execute batch", + "fields": [ + { + "path": "transactions", + "label": "Batched calls", + "format": "custom", + "layout": { + "type": "sequence", + "element": { + "type": "object", + "fields": [ + { + "name": "operation", + "schema": { + "type": "uint", + "bytes": 1 + } + }, + { + "name": "to", + "schema": { + "type": "address" + } + }, + { + "name": "value", + "schema": { + "type": "uint", + "bytes": 32 + } + }, + { + "name": "dataLength", + "schema": { + "type": "uint", + "bytes": 32 + } + }, + { + "name": "data", + "schema": { + "type": "bytes", + "lengthFrom": "dataLength" + } + } + ], + "interactions": [ + { + "operation": { + "type": "switch", + "expression": "$element.operation", + "cases": { + "0x00": "call", + "0x01": "delegatecall", + } + }, + "target": "$element.to", + "value": "$element.value", + "calldata": "{$element.methodSelector}{$element.data}" + } + ] + }, + } + } + ] + }, + "execute(bytes commands,bytes[] inputs,uint256 deadline)": { + "$id": "Universal Router Execute", + "intent": "Execute swap", + "fields": [ + { + "path": "deadline", + "label": "Valid until", + "format": "date", + "params": { + "encoding": "timestamp" + } + }, + { + "path": "inputs", + "label": "Commands", + "format": "custom", + "layout": { + "type": "sequence", + "element": { + "type": "switch", + "expression": { + "path": "commands", + "type": "uint", + "bytes": 1, + "mask": "0b111111" + }, + "cases": { + "0x00": { + "(address recipient,uint256 amountIn,uint256 amountOutMinimum,bytes path,bool payerIsUser)": { + "intent": "Execute swap with exact input and minimum output", + "fields": [ + { + "path": "recipient", + "label": "Recipient", + "format": "addressName" + }, + { + "path": "amountIn", + "label": "Amount in", + "format": "tokenAmount", + "params": { + "tokenPath": "path.[0:20]" + } + }, + { + "path": "amountOutMinimum", + "label": "Minimum amount out", + "format": "raw", + "$comment": "Output token lives at the tail of a variable-hop V3 path; no established slice syntax picks out 'last 20 bytes' of a variable-length buffer, so this is left as a raw number rather than resolved to a token amount." + }, + { + "path": "path", + "label": "Swap path", + "format": "raw" + }, + { + "path": "payerIsUser", + "label": "Pay from wallet", + "format": "raw" + } + ] + } + }, + "0x04": { + "(address token,address recipient,uint256 amountMinimum)": { + "intent": "Sweep remaining balance", + "fields": [ + { + "path": "token", + "label": "Token", + "format": "addressName" + }, + { + "path": "recipient", + "label": "Recipient", + "format": "addressName" + }, + { + "path": "amountMinimum", + "label": "Minimum amount", + "format": "tokenAmount", + "params": { + "tokenPath": "token" + } + } + ] + } + } + } + } + } + } + ] + }, + "execute(bytes32 mode,bytes executionCalldata)": { + "$id": "ERC-7579 Execute Function", + "intent": "Execute", + "fields": [ + { + "path": "mode", + "label": "Mode", + "format": "custom", + "layout": { + "type": "object", + "fields": [ + { + "name": "callType", + "schema": { + "type": "uint", + "bytes": 1 + } + }, + { + "name": "execType", + "schema": { + "type": "uint", + "bytes": 1 + } + }, + { + "name": "unused", + "schema": { + "type": "bytes", + "length": 4 + } + }, + { + "name": "modeSelector", + "schema": { + "type": "bytes", + "length": 4 + } + }, + { + "name": "modePayload", + "schema": { + "type": "bytes", + "length": 22 + } + } + ] + } + }, + { + "path": "executionCalldata", + "label": "Execution", + "switch": { + "expression": { + "path": "mode.callType" + }, + "cases": { + "0x00": { + "format": "custom", + "layout": { + "type": "object", + "fields": [ + { + "name": "target", + "schema": { + "type": "address" + } + }, + { + "name": "value", + "schema": { + "type": "uint", + "bytes": 32 + } + }, + { + "name": "callData", + "schema": { + "type": "bytes" + } + } + ] + } + }, + "0x01": { + "format": "array", + "element": { + "fields": [ + { "name": "target", "type": "address" }, + { "name": "value", "type": "uint256" }, + { "name": "callData", "type": "bytes" } + ] + }, + } + }, + "default": "reject" + } + }, + { + "path": "executionCalldata.callData", + "label": "Batched call data", + "format": "calldata", + "params": { + "calleePath": "executionCalldata.target", + "amountPath": "executionCalldata.value" + }, + "$comment": "Only applicable when mode.callType == 0x00: executionCalldata was parsed via layout above into a single target/value/callData record; this field resolves callData as an embedded call to target, reusing ERC-7730's own calldata format rather than a companion-ERC-specific construct - callData is already the full, contiguous ABI calldata, no packing involved." + }, + { + "path": "executionCalldata[].callData", + "label": "Batched call data", + "format": "calldata", + "params": { + "calleePath": "executionCalldata[].target", + "amountPath": "executionCalldata[].value" + }, + "$comment": "Only applicable when mode.callType == 0x01: each ABI-decoded Execution.callData in the batch is itself calldata to its sibling target, resolved the same way as the single-call case above." + } + ] + } + } + } +} From c806235073a43fb7e0474bdb1e67a730c0061489 Mon Sep 17 00:00:00 2001 From: Alex Forshtat Date: Thu, 30 Jul 2026 15:33:00 +0200 Subject: [PATCH 05/23] Fix latest format --- ERCS/erc-draft_non_abi_dispatch.md | 127 +++++++++--------- .../example-all-syntax-human.json5 | 51 ++++--- 2 files changed, 100 insertions(+), 78 deletions(-) diff --git a/ERCS/erc-draft_non_abi_dispatch.md b/ERCS/erc-draft_non_abi_dispatch.md index e6406fb9805..c0b6459e916 100644 --- a/ERCS/erc-draft_non_abi_dispatch.md +++ b/ERCS/erc-draft_non_abi_dispatch.md @@ -14,7 +14,7 @@ requires: 7730 [ERC-7730](./erc-7730.md) describes how to clear-sign structured data by decoding calldata as a Solidity ABI function call, then formatting the resulting named fields. This works as long as every value in the call is itself ABI-encoded. It breaks down for a common and growing class of contracts that accept one ABI-encoded `bytes` (or `bytes[]`) argument and then interpret its raw content using their own private encoding — packed structs, bit-packed flags, or a tag that selects one of several possible payload shapes. Gnosis Safe's `MultiSend`, Uniswap's Universal Router and hook addresses, and ERC-7579 modular accounts all do this, and none of it can be described in ERC-7730 today; such fields must be left as opaque, unreadable bytes. It also breaks down entirely for contract-creation calls, which have no function selector at all — a real, common, security-critical transaction type ERC-7730 has no vocabulary for. -This ERC adds two new keys to an ERC-7730 [field format specification](./erc-7730.md#field-format-specification) — `layout` and `dispatch` — that let an author describe the internal structure of such a field and, where the field's shape depends on a tag value, which structure applies. It also adds a reserved `$fallback` key to `display.formats` for contracts that dispatch with no selector at all. Everything else about ERC-7730 (context binding, metadata, top-level selector matching, path syntax) is unchanged; this ERC only extends what a single field's `path` can resolve into, and how a contract's entry point is matched in the first place. +This ERC adds three new keys to an ERC-7730 [field format specification](./erc-7730.md#field-format-specification) — `layout`, `switch`, and `interaction` — that let an author describe the internal structure of such a field, which structure applies where the field's shape depends on a tag value, and how to describe a call synthesized from already-decoded pieces rather than sliced out of contiguous bytes. Where a field's raw bytes already are a complete, contiguous call (selector and ABI-encoded arguments together), this ERC deliberately does not duplicate ERC-7730's own [embedded calldata](./erc-7730.md#embedded-calldata) mechanism (`format: "calldata"`) — it only extends that mechanism with one param, `operation`, for the one thing it cannot already express: a nested call that is a `DELEGATECALL` rather than a plain call. It also adds a reserved `$fallback` key to `display.formats` for contracts that dispatch with no selector at all. Everything else about ERC-7730 (context binding, metadata, top-level selector matching, path syntax) is unchanged; this ERC only extends what a single field's `path` can resolve into, and how a contract's entry point is matched in the first place. ## Motivation @@ -34,46 +34,46 @@ The goal here is narrow on purpose. This ERC does not attempt to become a genera The keywords "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174. -This ERC defines two additional keys usable in an ERC-7730 [field format specification](./erc-7730.md#field-format-specification): `layout` and `dispatch`. A field format specification MUST NOT combine `layout` with `format`; the two are alternative ways of turning a field's raw value into something displayable, and `layout` takes over that job entirely for the field it is attached to. `dispatch` MAY be combined with either, since it only decides *which* `layout` or ABI type governs a field — it does not itself produce a displayable value. +This ERC defines three additional keys usable in an ERC-7730 [field format specification](./erc-7730.md#field-format-specification): `layout`, `switch`, and `interaction`. A field format specification MUST NOT combine `layout` with `format`; the two are alternative ways of turning a field's raw value into something displayable, and `layout` takes over that job entirely for the field it is attached to. `interaction` is likewise mutually exclusive with `format` and `layout` — it describes a call synthesized from already-decoded values rather than any single bytes value being displayed or parsed. `switch` MAY be combined with any of the three, since it only decides *which* `layout`, ABI type, or nested structured format governs a field — it does not itself produce a displayable value. ### `layout` -`layout` describes the internal byte structure of a `bytes` field. Its value is a *layout node*. A layout node is one of the following kinds: +`layout` describes the internal byte structure of a `bytes` field. Its value is a *layout node*. A layout node is one of the following types: **Primitive nodes** ```json -{ "kind": "uint", "bytes": 1, "endian": "be" } -{ "kind": "bytes", "length": 20 } -{ "kind": "address" } -{ "kind": "bool" } +{ "type": "uint", "bytes": 1, "endian": "be" } +{ "type": "bytes", "length": 20 } +{ "type": "address" } +{ "type": "bool" } ``` -`uint.bytes` is the width in bytes (1, 2, 4, 8, 16, or 32). `endian` is `"be"` or `"le"`, defaulting to `"be"` — every known EVM-side use case packs data big-endian, matching Solidity's own word layout; `"le"` exists only so this vocabulary does not need to change if a future non-EVM companion reuses it. `address` is sugar for a 20-byte `bytes` node. `bytes.length` MAY be a fixed integer, a `lengthFrom` reference to an earlier sibling field's decoded value (see `struct` below), or omitted entirely on the last field of a `struct`, meaning "consume whatever bytes remain in the enclosing buffer." +`uint.bytes` is the width in bytes (1, 2, 4, 8, 16, or 32). `endian` is `"be"` or `"le"`, defaulting to `"be"` — every known EVM-side use case packs data big-endian, matching Solidity's own word layout; `"le"` exists only so this vocabulary does not need to change if a future non-EVM companion reuses it. `address` is sugar for a 20-byte `bytes` node. `bytes.length` MAY be a fixed integer, a `lengthFrom` reference to an earlier sibling field's decoded value (see `object` below), or omitted entirely on the last field of an `object`, meaning "consume whatever bytes remain in the enclosing buffer." **`bitfield`** — a fixed-width value (same width rules as `uint`) whose individual bits or bit ranges each carry independent, named meaning: ```json -{ "kind": "bitfield", "bytes": 20, "endian": "be", "fields": [ +{ "type": "bitfield", "bytes": 20, "endian": "be", "fields": [ { "name": "beforeSwap", "bit": 7 }, { "name": "afterSwap", "bit": 6 }, { "name": "poolId", "bits": [19, 8] } ]} ``` -Each entry in `fields` is either `{ "name": ..., "bit": N }` (a single boolean flag at bit `N`, 0-indexed from the least significant bit, decoded as `bool`) or `{ "name": ..., "bits": [hi, lo] }` (an inclusive bit range, decoded as an unsigned integer). Bit positions are independent of, and MAY overlap arbitrarily within, the underlying value's byte boundaries — this is precisely what distinguishes `bitfield` from `struct`, whose fields are always byte-aligned and never overlap. Named sub-fields are addressed exactly like `struct` fields (by name); see [Path addressing](#path-addressing). `bitfield` consumes its declared `bytes` width regardless of how many of those bits are named — undeclared bits are simply not exposed as fields. +Each entry in `fields` is either `{ "name": ..., "bit": N }` (a single boolean flag at bit `N`, 0-indexed from the least significant bit, decoded as `bool`) or `{ "name": ..., "bits": [hi, lo] }` (an inclusive bit range, decoded as an unsigned integer). Bit positions are independent of, and MAY overlap arbitrarily within, the underlying value's byte boundaries — this is precisely what distinguishes `bitfield` from `object`, whose fields are always byte-aligned and never overlap. Named sub-fields are addressed exactly like `object` fields (by name); see [Path addressing](#path-addressing). `bitfield` consumes its declared `bytes` width regardless of how many of those bits are named — undeclared bits are simply not exposed as fields. -**`struct`** — an ordered, unpadded concatenation of named fields: +**`object`** — an ordered, unpadded concatenation of named fields: ```json { - "kind": "struct", + "type": "object", "fields": [ - { "name": "operation", "schema": { "kind": "uint", "bytes": 1 } }, - { "name": "to", "schema": { "kind": "address" } }, - { "name": "value", "schema": { "kind": "uint", "bytes": 32 } }, - { "name": "dataLength", "schema": { "kind": "uint", "bytes": 32 } }, - { "name": "data", "schema": { "kind": "bytes", "lengthFrom": "dataLength" } } + { "name": "operation", "schema": { "type": "uint", "bytes": 1 } }, + { "name": "to", "schema": { "type": "address" } }, + { "name": "value", "schema": { "type": "uint", "bytes": 32 } }, + { "name": "dataLength", "schema": { "type": "uint", "bytes": 32 } }, + { "name": "data", "schema": { "type": "bytes", "lengthFrom": "dataLength" } } ] } ``` @@ -83,27 +83,25 @@ Fields are read strictly in order, byte-for-byte, with no alignment padding and **`sequence`** — repetition of one element node, read until the enclosing buffer is exhausted: ```json -{ "kind": "sequence", "element": , "count": "tillEnd" } +{ "type": "sequence", "element": , "count": "tillEnd" } ``` -`"tillEnd"` is the only count mode this ERC defines, because it is the only one any known real case needs — Safe MultiSend repeats its record `struct` until `transactions` runs out; Universal Router repeats a `dispatch` (below) once per byte of `commands`. Other termination modes (an explicit element count, a byte-length prefix) are left for a future revision if a real case needs them, rather than specified speculatively now. +`"tillEnd"` is the only count mode this ERC defines, because it is the only one any known real case needs — Safe MultiSend repeats its record `object` until `transactions` runs out; Universal Router repeats a `switch` (below) once per byte of `commands`. Other termination modes (an explicit element count, a byte-length prefix) are left for a future revision if a real case needs them, rather than specified speculatively now. -**`call`** — the bytes at this position are themselves calldata to another contract; resolve them by re-running ERC-7730's own [selector matching](./erc-7730.md#selector-matching-contracts), recursively: +**Nested calldata reuses ERC-7730's own mechanism, not a layout node.** A field whose bytes — however they were reached, whether by ordinary ABI decoding or by a parent `layout` — are themselves a complete, contiguous call (a selector followed by ABI-encoded arguments) is described with ERC-7730's own [embedded calldata](./erc-7730.md#embedded-calldata) mechanism, `format: "calldata"`, addressed by an ordinary `path` into the (possibly `layout`-decoded) structure — see [Path addressing](#path-addressing), which already lets a `path` reach into `object` fields and `sequence` elements the same way it reaches into ABI-decoded ones. This is how Safe MultiSend's inner calls are described: the `data` field is parsed as plain `bytes` (its `dataLength` already covers the full selector-plus-arguments blob, unmodified from how Safe's own contract packs it), and a sibling top-level field entry with `path: "transactions[].data"` and `format: "calldata"` resolves it, using `calleePath`/`amountPath` to point back at `transactions[].to`/`transactions[].value`. The same pattern describes ERC-7579's batched executions: each element of the ABI-decoded `Execution[]` array has an ordinary `bytes callData` member, resolved by a field entry with `path: "executionCalldata[].callData"` and `format: "calldata"`, `params: {"calleePath": "executionCalldata[].target"}` — addressed at the same array index as its sibling `target`, the same by-index correlation ERC-7730 already uses for [array-valued formatting parameters](./erc-7730.md#field-format-specification). See [Rationale](#rationale) for why this ERC does not define its own parallel node for this instead. + +This ERC extends `format: "calldata"`'s `params` with one new, optional key: `operation`, since ERC-7730's own definition has no way to express anything but a plain call. Its value is either a literal `"call"` (the default, identical to omitting `operation` entirely) or `"delegatecall"`, or an object choosing between them based on a tag: ```json -{ "kind": "call", "to": "to", "length": 68 } +{ "expression": "", "cases": { "": "call" | "delegatecall" }, "default": "reject" } ``` -`call` is a byte-consuming node like `bytes`: it accepts the same `length` / `lengthFrom` / implicit-remainder-of-enclosing-buffer rules, so exactly how many bytes it consumes is never ambiguous. `to` is a path (relative to the enclosing `struct`) to the address to recurse into. Having consumed its bytes, a wallet MUST treat the first 4 bytes as a selector and the rest as ABI-encoded arguments, then MUST treat the result exactly as it would a top-level transaction: look up a matching `display.formats` entry (in this file, in an `includes` file, or in the descriptor registry) for that `to`/selector pair, and if none is found, apply the same fallback as an [unknown selector](./erc-7730.md#unknown-selectors). - -This is how Safe MultiSend's inner calls are described. Note that `call` is declared directly as the `schema` of the `data` field itself — there is no separate sibling field for "the nested call"; the fact that a slice of bytes *is* a nested call is a property of that slice's own declared type, not something bolted on next to it. - -`call` need not sit inside a `struct`. A field whose bytes were obtained by ordinary ABI decoding (not by a parent `layout` at all) MAY also declare `layout` directly as a `call` node, meaning "this ABI-decoded `bytes` field is itself calldata to another contract." This is how ERC-7579's batched executions are described: each element of the ABI-decoded `Execution[]` array has an ordinary `bytes callData` member, and a second field entry with `path: "executionCalldata[].callData"` and `layout: {"kind": "call", "to": "executionCalldata[].target"}` recurses into it, addressed at the same array index as its sibling `target` — the same by-index correlation ERC-7730 already uses for [array-valued formatting parameters](./erc-7730.md#field-format-specification). +`expression`/`cases`/`default` follow exactly the same rules as `switch` (below): a wallet MUST treat a tag value with no matching case, and no `default` supplied, as an [unknown selector](./erc-7730.md#unknown-selectors). When resolved to `"delegatecall"`, a wallet MUST make clear that the callee executes in the calling contract's own storage and identity (`DELEGATECALL` semantics), and MUST warn as strongly as it would for a raw, undescribed `delegatecall` if `to`'s descriptor cannot be resolved — a delegatecall to an unknown or unaudited target is a full account takeover, not a benign unknown call. Resolution of `to`'s own `display.formats` entry is otherwise unaffected by `operation`; only the execution-context semantics differ, not how the target function is looked up. **`initCode`** — the bytes at this position are a contract-creation payload (raw creation bytecode, optionally followed by, or wrapped around, constructor arguments), matched against a small, explicit set of known, audited templates: ```json -{ "kind": "initCode", +{ "type": "initCode", "length": 1497, "templates": [ { @@ -130,23 +128,23 @@ If no template matches, a wallet MUST apply `default: "reject"` — the same [un Some contracts — notably generic deterministic-deployment proxies (see [Test Cases](#test-cases)) — expose no ABI-selected function at all; every call reaches a single raw fallback. `display.formats` MAY use the reserved key `"$fallback"` for exactly this case: a [structured data format specification](./erc-7730.md#structured-data-format-specification) matched whenever calldata does not correspond to any selector-based entry in the same file, whose `fields`/`layout` describe the entirety of `data` directly, with no selector-stripping step. `$fallback` MUST NOT be combined with selector-keyed entries that could themselves match the same calldata; wallets MUST prefer a matching selector-keyed entry over `$fallback` when both are present and only one is intended to apply. This does not change `display.formats` selector matching for any contract that has a normal ABI — it only gives contracts that genuinely have none a way to be described at all. -**Byte-width invariant.** Every `layout` node either consumes a well-defined, computable number of bytes from the buffer (all of the kinds above), or is explicitly declared non-consuming (only `dispatch`'s path-sourced tag form, below, which reads an already-resolved value instead of parsing bytes). No node kind may be ambiguous about whether, or how much, it advances the cursor. +**Byte-width invariant.** Every `layout` node either consumes a well-defined, computable number of bytes from the buffer (all of the types above), or is explicitly declared non-consuming (only `switch`'s path-sourced expression form, below, which reads an already-resolved value instead of parsing bytes). No node type may be ambiguous about whether, or how much, it advances the cursor. ### Path addressing -Paths extend into `layout`-decoded fields the same way they already extend into ABI-decoded struct and array fields: by name for `struct` and `bitfield` fields, by index for `sequence` elements. For example, given the `struct` above, `#.transactions[0].to` refers to the `to` field of the first record; `#.transactions[1].data.to` refers into a nested `call`'s own resolved fields, addressed using that resolved function's own parameter names, once matched. Similarly, once an `initCode` node's `templates` entry has matched, its `args` fields are addressed by name (or by tuple position, for an unnamed `abiType`), exactly as they would be for any other matched call. +Paths extend into `layout`-decoded fields the same way they already extend into ABI-decoded struct and array fields: by name for `object` and `bitfield` fields, by index for `sequence` elements. For example, given the `object` above, `#.transactions[0].to` refers to the `to` field of the first record. This is also what makes nested calldata resolvable without a dedicated layout node: a sibling top-level field entry can address `#.transactions[].data` directly and apply `format: "calldata"` to it, exactly as it would to any ordinary ABI-decoded `bytes` field. Similarly, once an `initCode` node's `templates` entry has matched, its `args` fields are addressed by name (or by tuple position, for an unnamed `abiType`), exactly as they would be for any other matched call. -### `dispatch` +### `switch` -`dispatch` selects which type or layout governs a field, based on the value of a tag. The tag MAY come from two places: +`switch` selects which type or layout governs a field, based on the value of an expression. The expression MAY come from two places: -1. **An already-resolved sibling path** — the ordinary case, used when the tag is a normal ABI-decoded parameter of the same call, decoded by nothing new at all: +1. **An already-resolved sibling path** — the ordinary case, used when the expression is a normal ABI-decoded parameter of the same call, decoded by nothing new at all: ```json { "path": "data", - "dispatch": { - "tag": { "path": "schema" }, + "switch": { + "expression": { "path": "schema" }, "cases": { "0x1234...": { "abiType": "(address recipient,bool isHuman,uint256 score)" } }, @@ -155,13 +153,13 @@ Paths extend into `layout`-decoded fields the same way they already extend into } ``` -This is the shape needed by EAS (`schema` selects how to decode `data`), ERC-7683 (`orderDataType` selects how to decode `orderData`), and ERC-7579 (`mode`'s decoded `callType` sub-field selects how to decode `executionCalldata`). +This is the shape needed by EAS (`schema` selects how to decode `data`), ERC-7683 (`orderDataType` selects how to decode `orderData`), and ERC-7579 (`mode`'s decoded `callType` sub-field selects how to decode `executionCalldata`). As shorthand, `{"path": ""}` MAY be written as the bare string `""` wherever only a path reference is needed and no inline byte-level parsing (`type`/`bytes`/`mask`) applies — used this way for `operation`'s `expression` above, and for `switch`'s `expression` whenever it is a plain sibling-path reference. -2. **Inline, read from the buffer at the current cursor position** — used only inside a `layout` tree, when the tag itself has to be parsed out of raw bytes rather than looked up as an already-decoded value: +2. **Inline, read from the buffer at the current cursor position** — used only inside a `layout` tree, when the expression itself has to be parsed out of raw bytes rather than looked up as an already-decoded value: ```json -{ "kind": "dispatch", - "tag": { "kind": "uint", "bytes": 1, "mask": "0x3f" }, +{ "type": "switch", + "expression": { "type": "uint", "bytes": 1, "mask": "0x3f" }, "payloadFrom": "inputs", "cases": { "0x00": { "abiType": "(address recipient,uint256 amountIn,uint256 amountOutMin,bytes path,bool payerIsUser)" } @@ -170,24 +168,29 @@ This is the shape needed by EAS (`schema` selects how to decode `data`), ERC-768 } ``` -`mask` is an optional bitmask applied to the tag's raw value before matching against `cases` keys — needed for Universal Router, where the top bit of the command byte is an unrelated "allow revert" flag and only the low 6 bits select the command. `payloadFrom` names a sibling array (`inputs`), read at the same index as the current element of the enclosing `sequence` — a correlated-array lookup already precedented by ERC-7730's existing rule that a formatting parameter array is "read at the same index as the current element being formatted." +`mask` is an optional bitmask applied to the expression's raw value before matching against `cases` keys — needed for Universal Router, where the top bit of the command byte is an unrelated "allow revert" flag and only the low 6 bits select the command. `payloadFrom` names a sibling array (`inputs`), read at the same index as the current element of the enclosing `sequence` — a correlated-array lookup already precedented by ERC-7730's existing rule that a formatting parameter array is "read at the same index as the current element being formatted." In both forms, a case value is one of: * `{ "abiType": "" }` — decode the payload using the ordinary Solidity ABI decoder. -* `{ "layout": }` — recurse into this ERC's own layout language (any node, including `call`). -* `{ "dispatch": {...} }` — nest another dispatch (for multi-level tag structures). +* `{ "layout": }` — recurse into this ERC's own layout language (any node). +* `{ "switch": {...} }` — nest another switch (for multi-level tag structures). +* `{ "": { "intent": ..., "fields": [...] } }` — decode using the tuple signature given as the key, exactly like `abiType`, and immediately apply the given `intent`/`fields` to the result, without a separate top-level `display.formats` entry. This is sugar for `abiType` followed by inline structured display; it exists because a `switch` case very often wants to say both "decode it like this" and "display it like this" together, and forcing every case through a two-step `abiType`-then-somewhere-else-defined-fields indirection added no value in the surveyed cases (Universal Router's command table, most notably). -A wallet MUST treat a tag value with no matching case, and no `default` case supplied, the same way it treats an [unknown selector](./erc-7730.md#unknown-selectors): display a safe fallback and MUST NOT guess at a format. +A wallet MUST treat an expression value with no matching case, and no `default` case supplied, the same way it treats an [unknown selector](./erc-7730.md#unknown-selectors): display a safe fallback and MUST NOT guess at a format. -### `dispatch` at the top of a structured data format specification +### `switch` at the top of a structured data format specification -Every case above dispatches on a tag to reinterpret *one field's* bytes, while the rest of the call keeps its own fixed `intent` and `fields`. Some contracts have no such fixed meaning at all: the entire call exists only to be redirected, and which target function it becomes is the only thing worth describing. A [structured data format specification](./erc-7730.md#structured-data-format-specification) MAY declare a top-level `dispatch` object, with the same `tag`/`cases`/`default` shape as above, instead of `intent`/`fields`. Its `tag` is a path to one of the outer call's own already-decoded parameters (never a raw-byte tag — there is no enclosing buffer to read one from at this level). +Every case above switches on an expression to reinterpret *one field's* bytes, while the rest of the call keeps its own fixed `intent` and `fields`. Some contracts have no such fixed meaning at all: the entire call exists only to be redirected, and which target function it becomes is the only thing worth describing. A [structured data format specification](./erc-7730.md#structured-data-format-specification) MAY declare a top-level `switch` object, with the same `expression`/`cases`/`default` shape as above, instead of `intent`/`fields`. Its `expression` is a path to one of the outer call's own already-decoded parameters (never a raw-byte expression — there is no enclosing buffer to read one from at this level). -Because there is no bytes value being reinterpreted at this level, only two case-value forms are valid here: a nested `dispatch` (for a tag that further refines an already-matched case), or a `call` object naming a **different** target function outright: +Because there is no bytes value being reinterpreted at this level, only two case-value forms are valid here: a nested `switch` (for an expression that further refines an already-matched case), or an `interaction` object naming a **different** target function outright — see below. + +### `interaction` + +`interaction` describes a call synthesized from already-decoded pieces, rather than a bytes value to be displayed directly or resolved via `format: "calldata"`. It is usable wherever a field format specification is (as an alternative to `format`/`layout`), and as a case value of a top-level `switch`: ```json -{ "call": { +{ "interaction": { "to": "", "signature": "", "args": [ { "path": "" } | { "value": "" }, ... ] @@ -204,25 +207,25 @@ A wallet MUST resolve the matched target's own `intent`, `interpolatedIntent`, a **Why the vocabulary is this small.** Every node and mode above exists because one of the real cases surveyed while designing this ERC needs it, not because it seemed generally useful. `sequence` has exactly one termination mode because no known case needs another. `mask` exists only because Universal Router's command byte shares space with a flag bit. Padded/aligned struct variants, bit-level fields narrower than a byte, and non-`tillEnd` sequence termination are all left out deliberately; they can be added later, non-breaking, if a real case turns up. -**Why `dispatch` has two tag-sourcing forms instead of one.** Most of the surveyed cases (EAS, ERC-7683, ERC-7579) dispatch on a tag that is already sitting in an ordinary ABI-decoded field — no byte parsing is involved in getting the tag at all. Only Universal Router needs the tag pulled out of a raw byte mid-parse. Rather than forcing every case through a byte-oriented mental model, `dispatch` accepts a `path` directly. +**Why `switch` has two expression-sourcing forms instead of one.** Most of the surveyed cases (EAS, ERC-7683, ERC-7579) switch on an expression that is already sitting in an ordinary ABI-decoded field — no byte parsing is involved in getting it at all. Only Universal Router needs the expression pulled out of a raw byte mid-parse. Rather than forcing every case through a byte-oriented mental model, `switch` accepts a `path` directly. -**Why `call` recurses into the whole top-level algorithm instead of its own resolution rule.** Safe MultiSend's inner calls are, from the perspective of the target contract, ordinary top-level calls — they have their own `to` address and their own selector. This works with no dispatch table of any kind because the 4-byte-selector shape isn't an ERC-7730 convention being imposed on the data — it's forced on whoever built the MultiSend batch by the target contract's own compiled dispatcher (Solidity, and most other languages, generate exactly this selector-check-and-jump at the top of every contract's bytecode). Reusing ERC-7730's existing selector-matching and fallback behavior, rather than inventing a parallel mechanism, means a MultiSend entry calling a well-described ERC-20 `approve` gets exactly the same display it would get as a standalone transaction, with no separate code path to keep correct — and it composes for free: if a batch happened to call another MultiSend, or another contract using this very ERC's constructs, recursive resolution picks up that target's own `layout`/`dispatch` declarations with no special-casing. +**Why nested calldata reuses ERC-7730's own `calldata` format instead of a dedicated layout node.** An earlier iteration of this design had its own `call` layout node kind: declared directly as a field's `schema`/`layout`, it would recurse into ERC-7730's selector-matching itself. That worked, but it duplicated a mechanism ERC-7730 already has — [`format: "calldata"`](./erc-7730.md#embedded-calldata) already does exactly "this field's bytes are themselves a call to another contract, resolve them recursively," with `calleePath`/`selectorPath`/`amountPath`/`spenderPath` covering everything a `call` node did. Once this ERC's own [Path addressing](#path-addressing) rule lets an ordinary field entry's `path` reach into a `layout`-decoded `object`/`sequence` the same way it reaches into ABI-decoded ones, there is nothing left for a dedicated layout node to do that a sibling `format: "calldata"` field entry doesn't already do — parsing the bytes (plain `bytes`, consuming the right length) and interpreting them as a call become two separate, already-existing steps instead of one new fused one. The only real gap `format: "calldata"` had was `operation`: ERC-7730 has no concept of `DELEGATECALL` because an ordinary transaction field is never anything but a plain call. This ERC closes that one gap by adding `operation` as a new, optional param to the existing format, rather than re-deriving everything else `format: "calldata"` already does. -**Why `call` is folded into a field's own `schema`/`layout` instead of being a sibling field.** An earlier iteration of this design added a `data` field for the raw bytes and a separate `call` field pointing at it — two names for one byte range, one saying "these are bytes" and the other saying "now recurse into those same bytes." That's redundant, and worse, it leaves the "is this a nested call" fact detached from the value it describes. Declaring `call` directly as a field's `schema` (or its `layout`, for a field that arrived via ordinary ABI decoding rather than a parent `layout`) makes "is a nested call" a property of the field's own declared type — indistinguishable in structure from any other typed field, and impossible to declare in two contradictory ways for the same bytes. +**Why `interaction` still exists as its own construct.** Unlike the case above, a call assembled from scattered, already-decoded values — no contiguous calldata bytes anywhere to point `format: "calldata"` at — has no equivalent already in ERC-7730. `interaction`'s `to`/`signature`/`args` shape is the minimum needed to express that: which target, which function (matched by signature text, since no selector was ever computed), and which already-decoded values bind to its arguments, in what order. -**Why the top-level `dispatch`/direct-`call` form exists, and why it's the one exception to this ERC's evidence rule.** Every other construct in this ERC exists because a real, cited transaction needed it. This one does not have that grounding — no verified live transaction was found that reinterprets already-decoded parameters into a different function's argument list the way the [TieredExecutor example](../assets/erc-non-abi-dispatch/example-tiered-executor.json) does. It is included because the shape it targets — a small trusted relayer accepting a tag and a handful of generic-looking arguments, then re-dispatching to one of several unrelated target interfaces with a different argument order per target — is a common, plausible, and easily reachable governance/router pattern, structurally close to a `switch` over an enum parameter. Readers should weigh this construct with that in mind: it is motivated by generality, not by an observed case, unlike everything else here. If a real contract using this exact shape turns up, its transaction should replace the made-up one in the Test Cases section. +**Why the top-level `switch`/`interaction` form exists, and why it's the one exception to this ERC's evidence rule.** Every other construct in this ERC exists because a real, cited transaction needed it. This one does not have that grounding — no verified live transaction was found that reinterprets already-decoded parameters into a different function's argument list the way the [TieredExecutor example](../assets/erc-non-abi-dispatch/example-tiered-executor.json) does. It is included because the shape it targets — a small trusted relayer accepting a tag and a handful of generic-looking arguments, then re-dispatching to one of several unrelated target interfaces with a different argument order per target — is a common, plausible, and easily reachable governance/router pattern, structurally close to a `switch` over an enum parameter. Readers should weigh this construct with that in mind: it is motivated by generality, not by an observed case, unlike everything else here. If a real contract using this exact shape turns up, its transaction should replace the made-up one in the Test Cases section. -**Why `bitfield` is a distinct node kind rather than a parameter on `uint`.** `struct` and `sequence` both assume byte-aligned, non-overlapping fields — that assumption is load-bearing throughout the rest of this ERC (it's what makes the byte-width invariant a simple sum of child widths). `bitfield`'s named sub-fields can overlap arbitrarily within a shared width and carry no byte alignment at all, so keeping it a separate, clearly-labeled kind (rather than, say, a `bits` option quietly attached to `uint`) makes it visually obvious, at the point a field is declared, that its sub-fields don't follow the rest of the language's byte-aligned norm. The motivating real case is Uniswap v4: hook contract addresses encode up to 14 independent permission flags in specific low-order bits of the 160-bit address value itself, verified against `Hooks.sol` and Uniswap's own v4 documentation. +**Why `bitfield` is a distinct node type rather than a parameter on `uint`.** `object` and `sequence` both assume byte-aligned, non-overlapping fields — that assumption is load-bearing throughout the rest of this ERC (it's what makes the byte-width invariant a simple sum of child widths). `bitfield`'s named sub-fields can overlap arbitrarily within a shared width and carry no byte alignment at all, so keeping it a separate, clearly-labeled type (rather than, say, a `bits` option quietly attached to `uint`) makes it visually obvious, at the point a field is declared, that its sub-fields don't follow the rest of the language's byte-aligned norm. The motivating real case is Uniswap v4: hook contract addresses encode up to 14 independent permission flags in specific low-order bits of the 160-bit address value itself, verified against `Hooks.sol` and Uniswap's own v4 documentation. -**Why `initCode` is its own node kind rather than a `dispatch` tag-sourcing mode.** An earlier version of this design considered folding creation-bytecode matching into `dispatch` as a fourth tag-sourcing mode (hash of a length-prefix of the buffer). That would have worked for the simplest case, but it doesn't generalize cleanly: some real creation-code templates append constructor arguments after a fixed prefix (a generic factory concatenating a template with a trailing ABI-encoded argument), while others — [EIP-1167](https://eips.ethereum.org/EIPS/eip-1167) minimal proxies, verified against the standard's own bytecode listing — embed their one constructor-equivalent value (the implementation address) *between* a fixed prefix and a fixed suffix, with no ABI encoding at all. Expressing both shapes through a single scalar "tag" and a flat `cases` map would have needed the tag itself to somehow also carry "and here's where the matched region ends", which is exactly the kind of implicit, easy-to-get-wrong behavior the byte-width invariant exists to rule out elsewhere in this ERC. A dedicated node with explicit `prefix`/`suffix`/`args` fields makes the matched region's boundaries an explicit, checkable part of each template entry instead. +**Why `initCode` is its own node type rather than a `switch` expression-sourcing mode.** An earlier version of this design considered folding creation-bytecode matching into `switch` as a fourth expression-sourcing mode (hash of a length-prefix of the buffer). That would have worked for the simplest case, but it doesn't generalize cleanly: some real creation-code templates append constructor arguments after a fixed prefix (a generic factory concatenating a template with a trailing ABI-encoded argument), while others — [EIP-1167](https://eips.ethereum.org/EIPS/eip-1167) minimal proxies, verified against the standard's own bytecode listing — embed their one constructor-equivalent value (the implementation address) *between* a fixed prefix and a fixed suffix, with no ABI encoding at all. Expressing both shapes through a single scalar expression and a flat `cases` map would have needed the expression itself to somehow also carry "and here's where the matched region ends", which is exactly the kind of implicit, easy-to-get-wrong behavior the byte-width invariant exists to rule out elsewhere in this ERC. A dedicated node with explicit `prefix`/`suffix`/`args` fields makes the matched region's boundaries an explicit, checkable part of each template entry instead. **Why `$fallback` is needed at all.** Every other selector-related mechanism in ERC-7730 and this ERC assumes a 4-byte selector exists to be computed and matched. The generic deterministic-deployment proxy that motivates `initCode` — the same, single contract, deployed at the identical address on nearly every EVM chain, that many real, well-known contracts (including Uniswap's own Permit2) are deployed through — has no selector at all; its calldata is `salt ‖ initCode`, dispatched by a raw fallback. Without `$fallback`, `initCode` would have no contract it could actually be demonstrated on, since every other candidate factory this research pass found either has a normal ABI wrapper around its bytecode argument (already describable with plain `abiType`, no new construct needed) or turned out, on inspection, to build its creation code internally rather than receiving it as literal calldata at all. -**Why positional binding, and why `args` may reorder.** ERC-7730 already treats parameter *names* as non-canonical for the purpose of selector matching — only position and type are. A dispatch case that redirects to a different function has no shared parameter names to align by in the first place (the outer call's `account`/`amount` mean nothing to the target function's own signature), so positional binding by the target's declared order is the only definition that is well-defined at all, and it is the same rule ERC-7730 already applies elsewhere, not a new one. +**Why positional binding, and why `args` may reorder.** ERC-7730 already treats parameter *names* as non-canonical for the purpose of selector matching — only position and type are. An `interaction` that redirects to a different function has no shared parameter names to align by in the first place (the outer call's `account`/`amount` mean nothing to the target function's own signature), so positional binding by the target's declared order is the only definition that is well-defined at all, and it is the same rule ERC-7730 already applies elsewhere, not a new one. ## Backwards Compatibility -This ERC only adds new, optional keys to a field format specification. A descriptor that does not use `layout` or `dispatch` is unaffected, and a wallet implementing only ERC-7730 without this extension can safely ignore fields that use them, applying the existing [unknown field / raw fallback](./erc-7730.md) behavior. +This ERC only adds new, optional keys to a field format specification (`layout`, `switch`, `interaction`), plus one new, optional param (`operation`) to ERC-7730's own `format: "calldata"`. A descriptor that does not use them is unaffected, and a wallet implementing only ERC-7730 without this extension can safely ignore fields that use them, applying the existing [unknown field / raw fallback](./erc-7730.md) behavior. ## Test Cases @@ -230,7 +233,7 @@ Six of the eight examples below are real, mined transactions, decoded from raw c ### Safe `MultiSend` -Ethereum mainnet, tx [`0x414ae5aaff729927d663ccaa027ea2284e47fa546cb73ce1dee481ee36a7138e`](https://etherscan.io/tx/0x414ae5aaff729927d663ccaa027ea2284e47fa546cb73ce1dee481ee36a7138e). A Safe at `0xCa087C9e22bC97059d8fd6e25956835Ec205782B` delegatecalls `MultiSendCallOnly` (`0x40A2aCCbd92BCA938b02010E17A5b8929b49130D`) to batch six CTC ([`0xa3ee21c306a700e682abcdfe9baa6a08f3820419`](https://etherscan.io/address/0xa3ee21c306a700e682abcdfe9baa6a08f3820419)) `transfer` calls to six different recipients in one transaction. The 918-byte `transactions` buffer decodes (record 0 of 6) to `operation=CALL`, `to=0xa3ee21c306a700e682abcdfe9baa6a08f3820419`, `value=0`, `dataLength=68`, and `data` recursing via `call` into a normal `transfer(address,uint256)` sending `40000000000000000000000` (40,000 CTC) to `0x6ba2c52a959f0544e00aea60fe576463fe5fc38d`; the remaining five records follow the same shape, and the buffer is consumed exactly with no slack, confirming the `tillEnd` parse. +Ethereum mainnet, tx [`0x414ae5aaff729927d663ccaa027ea2284e47fa546cb73ce1dee481ee36a7138e`](https://etherscan.io/tx/0x414ae5aaff729927d663ccaa027ea2284e47fa546cb73ce1dee481ee36a7138e). A Safe at `0xCa087C9e22bC97059d8fd6e25956835Ec205782B` delegatecalls `MultiSendCallOnly` (`0x40A2aCCbd92BCA938b02010E17A5b8929b49130D`) to batch six CTC ([`0xa3ee21c306a700e682abcdfe9baa6a08f3820419`](https://etherscan.io/address/0xa3ee21c306a700e682abcdfe9baa6a08f3820419)) `transfer` calls to six different recipients in one transaction. The 918-byte `transactions` buffer decodes (record 0 of 6) to `operation=CALL`, `to=0xa3ee21c306a700e682abcdfe9baa6a08f3820419`, `value=0`, `dataLength=68`, and `data` resolving, via `format: "calldata"`, into a normal `transfer(address,uint256)` sending `40000000000000000000000` (40,000 CTC) to `0x6ba2c52a959f0544e00aea60fe576463fe5fc38d`; the remaining five records follow the same shape, and the buffer is consumed exactly with no slack, confirming the `tillEnd` parse. Full descriptor: [`example-safe-multisend.json`](../assets/erc-non-abi-dispatch/example-safe-multisend.json). @@ -242,7 +245,7 @@ Full descriptor: [`example-universal-router.json`](../assets/erc-non-abi-dispatc ### ERC-7579 `execute` -Base mainnet, Biconomy Nexus accounts (an ERC-7579 reference implementation), function selector `0xe9ae5c53`. Single-call example: tx [`0x057b1df67f033ad77faba10e39f39dde273c225d62c3b36ef8547b3f51fad5c1`](https://basescan.org/tx/0x057b1df67f033ad77faba10e39f39dde273c225d62c3b36ef8547b3f51fad5c1) — `mode` has `callType=0x00`, and `executionCalldata` decodes to `target=0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` (USDC on Base), `value=0`, `callData` recursing into `transfer(0x3C97112223b1AD104Cf2ac022e450Ef862652b93, 1)`. Batch-call example: tx [`0x26d34bf7aa5adb0642218422264a4034ffda5785be3354168eb478051664613c`](https://basescan.org/tx/0x26d34bf7aa5adb0642218422264a4034ffda5785be3354168eb478051664613c) — `callType=0x01`, decoding to three executions (a native ETH transfer, a DAI `transfer`, and a USDC `transfer`) all to the same recipient, a single-UserOperation "sweep to one address" pattern. Both the mode layout and the callType-driven dispatch were confirmed against Nexus's own `ModeLib.sol`. +Base mainnet, Biconomy Nexus accounts (an ERC-7579 reference implementation), function selector `0xe9ae5c53`. Single-call example: tx [`0x057b1df67f033ad77faba10e39f39dde273c225d62c3b36ef8547b3f51fad5c1`](https://basescan.org/tx/0x057b1df67f033ad77faba10e39f39dde273c225d62c3b36ef8547b3f51fad5c1) — `mode` has `callType=0x00`, and `executionCalldata` decodes to `target=0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` (USDC on Base), `value=0`, `callData` recursing into `transfer(0x3C97112223b1AD104Cf2ac022e450Ef862652b93, 1)`. Batch-call example: tx [`0x26d34bf7aa5adb0642218422264a4034ffda5785be3354168eb478051664613c`](https://basescan.org/tx/0x26d34bf7aa5adb0642218422264a4034ffda5785be3354168eb478051664613c) — `callType=0x01`, decoding to three executions (a native ETH transfer, a DAI `transfer`, and a USDC `transfer`) all to the same recipient, a single-UserOperation "sweep to one address" pattern. Both the mode layout and the callType-driven switch were confirmed against Nexus's own `ModeLib.sol`. Full descriptor: [`example-erc7579-execute.json`](../assets/erc-non-abi-dispatch/example-erc7579-execute.json). @@ -254,13 +257,13 @@ Full descriptor: [`example-cctp-message.json`](../assets/erc-non-abi-dispatch/ex ### EAS attestation -Optimism mainnet, schema `#78` (UID `0xfdcfdad2dbe7489e0ce56b260348b7f14e8365a8a325aef9834818c00d46b31b`), string `string rpgfRound,address referredBy,string referredMethod` — Optimism's RetroPGF badgeholder-referral schema. Attestation [`0x1a7a222934cbab53dd1c8e85d34e5fdd6d17cfd62a18ad871e4bec4705fdaa41`](https://optimism.easscan.org/attestation/view/0x1a7a222934cbab53dd1c8e85d34e5fdd6d17cfd62a18ad871e4bec4705fdaa41), tx `0x820e5b8404f1ec62b47459e538151e54fb598b729dcb461087456bb856abf595`, decodes to `rpgfRound="4"`, `referredBy=0x0000000000000000000000000000000000000342`, `referredMethod="Friend"`. `schema` and `data` are already-decoded ABI sibling fields of `attest()`'s own parameters, so no `layout` node is needed at all here — just `dispatch` sourced from a path. (A simpler, single-field schema also exists at scale — Coinbase's "Verified Account" schema, UID `0xf8b05c79f090979bf4a80270aba232dff11a10d9ca55c4f88de95317970f0de9`, `bool verifiedAccount`, 720,000+ attestations on Base — useful as a minimal case, but the RetroPGF one exercises both static and dynamic ABI types.) +Optimism mainnet, schema `#78` (UID `0xfdcfdad2dbe7489e0ce56b260348b7f14e8365a8a325aef9834818c00d46b31b`), string `string rpgfRound,address referredBy,string referredMethod` — Optimism's RetroPGF badgeholder-referral schema. Attestation [`0x1a7a222934cbab53dd1c8e85d34e5fdd6d17cfd62a18ad871e4bec4705fdaa41`](https://optimism.easscan.org/attestation/view/0x1a7a222934cbab53dd1c8e85d34e5fdd6d17cfd62a18ad871e4bec4705fdaa41), tx `0x820e5b8404f1ec62b47459e538151e54fb598b729dcb461087456bb856abf595`, decodes to `rpgfRound="4"`, `referredBy=0x0000000000000000000000000000000000000342`, `referredMethod="Friend"`. `schema` and `data` are already-decoded ABI sibling fields of `attest()`'s own parameters, so no `layout` node is needed at all here — just `switch` sourced from a path. (A simpler, single-field schema also exists at scale — Coinbase's "Verified Account" schema, UID `0xf8b05c79f090979bf4a80270aba232dff11a10d9ca55c4f88de95317970f0de9`, `bool verifiedAccount`, 720,000+ attestations on Base — useful as a minimal case, but the RetroPGF one exercises both static and dynamic ABI types.) Full descriptor: [`example-eas-attestation.json`](../assets/erc-non-abi-dispatch/example-eas-attestation.json). Note the descriptor's `context.contract` address is a placeholder — see the file's own `$comment`. ### ERC-7683 order -Base mainnet, tx [`0x73b3ca11e3733c78db8365086f64874879fb3a859b21c960ec072c2a95c5da09`](https://basescan.org/tx/0x73b3ca11e3733c78db8365086f64874879fb3a859b21c960ec072c2a95c5da09), calling `open((uint32,bytes32,bytes) order)` on Across's `AcrossOriginSettler` (`0x4afb570AC68BfFc26Bb02FdA3D801728B0f93C9E`) — a self-bridge of 1 USDC from Base to Arbitrum. `orderDataType = 0x9df4b782e7bbc178b3b93bfe8aafb909e84e39484d7f3c59f400f1b4691f85e2`, independently confirmed as `keccak256("AcrossOrderData(address inputToken,uint256 inputAmount,address outputToken,uint256 outputAmount,uint256 destinationChainId,bytes32 recipient,address exclusiveRelayer,uint256 depositNonce,uint32 exclusivityPeriod,bytes message)")`, decoding to `inputToken`/`outputToken` = USDC on Base/Arbitrum, `inputAmount=1000000`, `outputAmount=981521` (the relayer's fee), `destinationChainId=42161`, `recipient` equal to the sender, and empty `exclusiveRelayer`/`depositNonce`/`exclusivityPeriod`/`message`. Note this uses the same typehash-dispatch shape as the EAS example above — different ecosystem, same construct. +Base mainnet, tx [`0x73b3ca11e3733c78db8365086f64874879fb3a859b21c960ec072c2a95c5da09`](https://basescan.org/tx/0x73b3ca11e3733c78db8365086f64874879fb3a859b21c960ec072c2a95c5da09), calling `open((uint32,bytes32,bytes) order)` on Across's `AcrossOriginSettler` (`0x4afb570AC68BfFc26Bb02FdA3D801728B0f93C9E`) — a self-bridge of 1 USDC from Base to Arbitrum. `orderDataType = 0x9df4b782e7bbc178b3b93bfe8aafb909e84e39484d7f3c59f400f1b4691f85e2`, independently confirmed as `keccak256("AcrossOrderData(address inputToken,uint256 inputAmount,address outputToken,uint256 outputAmount,uint256 destinationChainId,bytes32 recipient,address exclusiveRelayer,uint256 depositNonce,uint32 exclusivityPeriod,bytes message)")`, decoding to `inputToken`/`outputToken` = USDC on Base/Arbitrum, `inputAmount=1000000`, `outputAmount=981521` (the relayer's fee), `destinationChainId=42161`, `recipient` equal to the sender, and empty `exclusiveRelayer`/`depositNonce`/`exclusivityPeriod`/`message`. Note this uses the same typehash-switch shape as the EAS example above — different ecosystem, same construct. Full descriptor: [`example-erc7683-order.json`](../assets/erc-non-abi-dispatch/example-erc7683-order.json). @@ -274,9 +277,11 @@ Full descriptor: [`example-deterministic-deployment-proxy.json`](../assets/erc-n ### `TieredExecutor` (made-up example) -A small, illustrative Solidity contract written for this ERC — [`TieredExecutor.sol`](../assets/erc-non-abi-dispatch/TieredExecutor.sol) — is **not deployed anywhere**; unlike every other example above, no real transaction exists for it. It demonstrates the top-level `dispatch`/direct-`call` form: `executeOperation(address target, Operation op, address account, uint256 amount)` takes an enum tag `op` and two generic-looking arguments, and re-dispatches to one of two unrelated target interfaces — `IRewardVault.grantReward(address,uint256)` for `op=1`, `ILegacyToken.creditAccount(uint256,address)` for `op=2` — each with a different parameter order, resolved from the same `account`/`amount` values by positional binding. +A small, illustrative Solidity contract written for this ERC — [`TieredExecutor.sol`](../assets/erc-non-abi-dispatch/TieredExecutor.sol) — is **not deployed anywhere**; unlike every other example above, no real transaction exists for it. It demonstrates the top-level `switch`/`interaction` form: `executeOperation(address target, Operation op, address account, uint256 amount)` takes an enum expression `op` and two generic-looking arguments, and re-dispatches to one of two unrelated target interfaces — `IRewardVault.grantReward(address,uint256)` for `op=1`, `ILegacyToken.creditAccount(uint256,address)` for `op=2` — each with a different parameter order, resolved from the same `account`/`amount` values by positional binding. + +Full descriptors: [`example-tiered-executor.json`](../assets/erc-non-abi-dispatch/example-tiered-executor.json) (the dispatching contract), [`example-reward-vault.json`](../assets/erc-non-abi-dispatch/example-reward-vault.json) and [`example-legacy-token.json`](../assets/erc-non-abi-dispatch/example-legacy-token.json) (the two target interfaces it recurses into, each an independently-authored descriptor resolved the same way any other embedded-calldata target would be). -Full descriptors: [`example-tiered-executor.json`](../assets/erc-non-abi-dispatch/example-tiered-executor.json) (the dispatching contract), [`example-reward-vault.json`](../assets/erc-non-abi-dispatch/example-reward-vault.json) and [`example-legacy-token.json`](../assets/erc-non-abi-dispatch/example-legacy-token.json) (the two target interfaces it recurses into, each an independently-authored descriptor resolved the same way any other nested `call` target would be). +> **Naming note:** the linked example files under [`../assets/erc-non-abi-dispatch/`](../assets/erc-non-abi-dispatch/) — all six real-transaction descriptors above, plus `TieredExecutor`'s three files — still use this ERC's pre-rename vocabulary (`kind`, `struct`, `dispatch`, `tag`, and the now-removed `call` layout node) and have not yet been migrated to `type`/`object`/`switch`/`expression`/`format: "calldata"`. [`example-all-syntax-human.json5`](../assets/erc-non-abi-dispatch/example-all-syntax-human.json5) uses the current naming throughout for the three formats it covers (MultiSend, Universal Router, ERC-7579) and is the reference for what the migrated shape looks like. Migrating the other six files is open follow-up work. ## Reference Implementation @@ -284,7 +289,7 @@ TBD ## Security Considerations -A `layout`/`dispatch` interpreter is new parsing surface on a hardware wallet, decoding attacker-influenced (calldata is provided by whoever submits the transaction) bytes. Implementations MUST bound recursion depth (`call` and nested `dispatch` can recurse arbitrarily deep in principle), MUST treat any length (`lengthFrom`, or a `sequence`'s implicit `tillEnd` walk) that would read past the end of the underlying buffer as invalid input, and MUST fail closed — applying the [unknown selector](./erc-7730.md#unknown-selectors) fallback — rather than displaying a partially decoded or best-guess value when a `layout` or `dispatch` does not cleanly match the actual bytes. +A `layout`/`switch`/`interaction` interpreter is new parsing surface on a hardware wallet, decoding attacker-influenced (calldata is provided by whoever submits the transaction) bytes. Implementations MUST bound recursion depth (embedded-calldata resolution, nested `switch`, and `interaction` can all recurse arbitrarily deep in principle), MUST treat any length (`lengthFrom`, or a `sequence`'s implicit `tillEnd` walk) that would read past the end of the underlying buffer as invalid input, and MUST fail closed — applying the [unknown selector](./erc-7730.md#unknown-selectors) fallback — rather than displaying a partially decoded or best-guess value when a `layout` or `switch` does not cleanly match the actual bytes. `operation` resolving to `"delegatecall"` is a particularly high-severity case of this: a wallet MUST fail closed exactly as hard for an unresolvable delegatecall target as it would for one with no descriptor at all, never falling back to treating it as a plain call. `initCode` template matching is exact-bytes (or exact-hash) matching against a fixed template. A wallet MUST NOT treat a partial or fuzzy prefix/suffix match as a match — an attacker who can get even one byte accepted as "close enough" could potentially get unrelated, unaudited bytecode displayed as if it were a known, trusted template. Authors MUST keep `templates` entries pinned to one specific compiler version and settings; the same source recompiled differently produces different bytecode and MUST be treated as an entirely distinct, separately-audited template, never as a "should still basically match" variant of an existing one. diff --git a/assets/erc-non-abi-dispatch/example-all-syntax-human.json5 b/assets/erc-non-abi-dispatch/example-all-syntax-human.json5 index af6c9d5da11..ae95bde38c8 100644 --- a/assets/erc-non-abi-dispatch/example-all-syntax-human.json5 +++ b/assets/erc-non-abi-dispatch/example-all-syntax-human.json5 @@ -70,7 +70,7 @@ }, "target": "$element.to", "value": "$element.value", - "calldata": "{$element.methodSelector}{$element.data}" + "calldata": "$element.data" } ] }, @@ -83,25 +83,25 @@ "intent": "Execute swap", "fields": [ { - "path": "deadline", - "label": "Valid until", - "format": "date", - "params": { - "encoding": "timestamp" - } - }, - { - "path": "inputs", + "path": "commands", "label": "Commands", "format": "custom", "layout": { "type": "sequence", "element": { - "type": "switch", + "type": "uint", + "bytes": 1 + } + } + }, + { + "path": "inputs", + "label": "Command Inputs", + "format": "array", + "element": { + "switch": { "expression": { - "path": "commands", - "type": "uint", - "bytes": 1, + "path": "commands[$index]", "mask": "0b111111" }, "cases": { @@ -169,6 +169,14 @@ } } } + }, + { + "path": "deadline", + "label": "Valid until", + "format": "date", + "params": { + "encoding": "timestamp" + } } ] }, @@ -260,9 +268,18 @@ "format": "array", "element": { "fields": [ - { "name": "target", "type": "address" }, - { "name": "value", "type": "uint256" }, - { "name": "callData", "type": "bytes" } + { + "name": "target", + "type": "address" + }, + { + "name": "value", + "type": "uint256" + }, + { + "name": "callData", + "type": "bytes" + } ] }, } From 80fe8bfc0df8beeee138e3a168698a6f4bf57fb0 Mon Sep 17 00:00:00 2001 From: Alex Forshtat Date: Thu, 30 Jul 2026 15:33:28 +0200 Subject: [PATCH 06/23] add $index --- ERCS/erc-draft_non_abi_dispatch.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ERCS/erc-draft_non_abi_dispatch.md b/ERCS/erc-draft_non_abi_dispatch.md index c0b6459e916..1e1124dca05 100644 --- a/ERCS/erc-draft_non_abi_dispatch.md +++ b/ERCS/erc-draft_non_abi_dispatch.md @@ -88,6 +88,8 @@ Fields are read strictly in order, byte-for-byte, with no alignment padding and `"tillEnd"` is the only count mode this ERC defines, because it is the only one any known real case needs — Safe MultiSend repeats its record `object` until `transactions` runs out; Universal Router repeats a `switch` (below) once per byte of `commands`. Other termination modes (an explicit element count, a byte-length prefix) are left for a future revision if a real case needs them, rather than specified speculatively now. +**`$index`** — inside the `element` of a `sequence`, or of a field iterated via `format: "array"` (a field whose value is already an ABI-decoded array, walked element-by-element rather than by consuming bytes), `$index` is a reserved token equal to the zero-based position of the element currently being processed. It is usable inside a `path` to correlate that element with the same-indexed value of a *different* sibling field — e.g. `commands[$index]`, read from inside an `inputs` element's `switch`, is the byte of the sibling `commands` sequence at the same position as the `inputs` element currently being resolved. This is Universal Router's actual structure: `commands` (raw `bytes`, one command per byte, parsed as a `sequence`) and `inputs` (already-decoded `bytes[]`, walked via `format: "array"`) are two separate fields advanced in lockstep by the same index, not one field nested inside the other — `$index` is what ties them together. + **Nested calldata reuses ERC-7730's own mechanism, not a layout node.** A field whose bytes — however they were reached, whether by ordinary ABI decoding or by a parent `layout` — are themselves a complete, contiguous call (a selector followed by ABI-encoded arguments) is described with ERC-7730's own [embedded calldata](./erc-7730.md#embedded-calldata) mechanism, `format: "calldata"`, addressed by an ordinary `path` into the (possibly `layout`-decoded) structure — see [Path addressing](#path-addressing), which already lets a `path` reach into `object` fields and `sequence` elements the same way it reaches into ABI-decoded ones. This is how Safe MultiSend's inner calls are described: the `data` field is parsed as plain `bytes` (its `dataLength` already covers the full selector-plus-arguments blob, unmodified from how Safe's own contract packs it), and a sibling top-level field entry with `path: "transactions[].data"` and `format: "calldata"` resolves it, using `calleePath`/`amountPath` to point back at `transactions[].to`/`transactions[].value`. The same pattern describes ERC-7579's batched executions: each element of the ABI-decoded `Execution[]` array has an ordinary `bytes callData` member, resolved by a field entry with `path: "executionCalldata[].callData"` and `format: "calldata"`, `params: {"calleePath": "executionCalldata[].target"}` — addressed at the same array index as its sibling `target`, the same by-index correlation ERC-7730 already uses for [array-valued formatting parameters](./erc-7730.md#field-format-specification). See [Rationale](#rationale) for why this ERC does not define its own parallel node for this instead. This ERC extends `format: "calldata"`'s `params` with one new, optional key: `operation`, since ERC-7730's own definition has no way to express anything but a plain call. Its value is either a literal `"call"` (the default, identical to omitting `operation` entirely) or `"delegatecall"`, or an object choosing between them based on a tag: From 6f62a312aa790eea631f9a18c0d733889e8008af Mon Sep 17 00:00:00 2001 From: Alex Forshtat Date: Fri, 31 Jul 2026 00:39:44 +0200 Subject: [PATCH 07/23] WIP: Adding BalancerRelayer as example --- ERCS/erc-draft_non_abi_dispatch.md | 38 ++- .../example-balancer-relayer-library.json | 312 ++++++++++++++++++ .../example-balancer-relayer-multicall.json | 43 +++ 3 files changed, 381 insertions(+), 12 deletions(-) create mode 100644 assets/erc-non-abi-dispatch/example-balancer-relayer-library.json create mode 100644 assets/erc-non-abi-dispatch/example-balancer-relayer-multicall.json diff --git a/ERCS/erc-draft_non_abi_dispatch.md b/ERCS/erc-draft_non_abi_dispatch.md index 1e1124dca05..08559955331 100644 --- a/ERCS/erc-draft_non_abi_dispatch.md +++ b/ERCS/erc-draft_non_abi_dispatch.md @@ -40,6 +40,8 @@ This ERC defines three additional keys usable in an ERC-7730 [field format speci `layout` describes the internal byte structure of a `bytes` field. Its value is a *layout node*. A layout node is one of the following types: +**Anchoring `layout` on an already-decoded scalar.** `layout` is normally attached to a field whose raw `bytes` have not been interpreted yet. It MAY also be attached to a field whose value was already produced by ordinary Solidity ABI decoding, if that value's declared type is a single-word elementary type — `uintN`/`intN`, `bool`, `address`, or a fixed-size `bytesN`. In that case the layout tree operates on the value's canonical 32-byte big-endian ABI-word encoding as its buffer, exactly as if those 32 bytes had been sliced out of a larger one. This is not extended to dynamic types (`bytes`, `string`, arrays, tuples) — no known case needs it, and "canonical encoding" is not a single well-defined byte sequence for them the way it is for a 32-byte word. See [Rationale](#rationale) for the motivating case. + **Primitive nodes** ```json @@ -95,10 +97,10 @@ Fields are read strictly in order, byte-for-byte, with no alignment padding and This ERC extends `format: "calldata"`'s `params` with one new, optional key: `operation`, since ERC-7730's own definition has no way to express anything but a plain call. Its value is either a literal `"call"` (the default, identical to omitting `operation` entirely) or `"delegatecall"`, or an object choosing between them based on a tag: ```json -{ "expression": "", "cases": { "": "call" | "delegatecall" }, "default": "reject" } +{ "expression": "", "cases": { "": "call" | "delegatecall", "$default": "reject" } } ``` -`expression`/`cases`/`default` follow exactly the same rules as `switch` (below): a wallet MUST treat a tag value with no matching case, and no `default` supplied, as an [unknown selector](./erc-7730.md#unknown-selectors). When resolved to `"delegatecall"`, a wallet MUST make clear that the callee executes in the calling contract's own storage and identity (`DELEGATECALL` semantics), and MUST warn as strongly as it would for a raw, undescribed `delegatecall` if `to`'s descriptor cannot be resolved — a delegatecall to an unknown or unaudited target is a full account takeover, not a benign unknown call. Resolution of `to`'s own `display.formats` entry is otherwise unaffected by `operation`; only the execution-context semantics differ, not how the target function is looked up. +`expression`/`cases` (including the reserved `$default` case key) follow exactly the same rules as `switch` (below): a wallet MUST treat a tag value with no matching case, and no `$default` case supplied, as an [unknown selector](./erc-7730.md#unknown-selectors). When resolved to `"delegatecall"`, a wallet MUST make clear that the callee executes in the calling contract's own storage and identity (`DELEGATECALL` semantics), and MUST warn as strongly as it would for a raw, undescribed `delegatecall` if `to`'s descriptor cannot be resolved — a delegatecall to an unknown or unaudited target is a full account takeover, not a benign unknown call. Resolution of `to`'s own `display.formats` entry is otherwise unaffected by `operation`; only the execution-context semantics differ, not how the target function is looked up. **`initCode`** — the bytes at this position are a contract-creation payload (raw creation bytecode, optionally followed by, or wrapped around, constructor arguments), matched against a small, explicit set of known, audited templates: @@ -130,12 +132,14 @@ If no template matches, a wallet MUST apply `default: "reject"` — the same [un Some contracts — notably generic deterministic-deployment proxies (see [Test Cases](#test-cases)) — expose no ABI-selected function at all; every call reaches a single raw fallback. `display.formats` MAY use the reserved key `"$fallback"` for exactly this case: a [structured data format specification](./erc-7730.md#structured-data-format-specification) matched whenever calldata does not correspond to any selector-based entry in the same file, whose `fields`/`layout` describe the entirety of `data` directly, with no selector-stripping step. `$fallback` MUST NOT be combined with selector-keyed entries that could themselves match the same calldata; wallets MUST prefer a matching selector-keyed entry over `$fallback` when both are present and only one is intended to apply. This does not change `display.formats` selector matching for any contract that has a normal ABI — it only gives contracts that genuinely have none a way to be described at all. -**Byte-width invariant.** Every `layout` node either consumes a well-defined, computable number of bytes from the buffer (all of the types above), or is explicitly declared non-consuming (only `switch`'s path-sourced expression form, below, which reads an already-resolved value instead of parsing bytes). No node type may be ambiguous about whether, or how much, it advances the cursor. +**Byte-width invariant.** Every `layout` node either consumes a well-defined, computable number of bytes from the buffer (all of the types above), or is explicitly declared non-consuming (only `switch`'s path-sourced expression form, below, which reads an already-resolved value instead of parsing bytes). No node type may be ambiguous about whether, or how much, it advances the cursor. A `layout` anchored directly on an already-decoded scalar (above) is a degenerate, trivially-satisfying case of this same invariant: there is no enclosing buffer to consume from or leave a remainder in, the buffer *is* the value's fixed 32-byte encoding in full, and the top-level node MUST consume it completely, the same rule applied everywhere else. ### Path addressing Paths extend into `layout`-decoded fields the same way they already extend into ABI-decoded struct and array fields: by name for `object` and `bitfield` fields, by index for `sequence` elements. For example, given the `object` above, `#.transactions[0].to` refers to the `to` field of the first record. This is also what makes nested calldata resolvable without a dedicated layout node: a sibling top-level field entry can address `#.transactions[].data` directly and apply `format: "calldata"` to it, exactly as it would to any ordinary ABI-decoded `bytes` field. Similarly, once an `initCode` node's `templates` entry has matched, its `args` fields are addressed by name (or by tuple position, for an unnamed `abiType`), exactly as they would be for any other matched call. +**`#.` crosses `switch`/`layout` scope boundaries.** A `switch` case that decodes its payload into a tuple (`abiType`, or the tuple-plus-`intent`-plus-`fields` shorthand) introduces a new, local field scope for that case's own `fields`: a relative path there resolves against the just-decoded tuple, not the outer call. `#.`, however, MUST still resolve against the absolute root of the entire structured data — the outer call's own top-level decoded parameters — regardless of how many `switch`/`layout` scopes deep the path is written. This is needed whenever a case's decoded value must be paired with a sibling of the field the `switch` is attached to, not a sibling within the tuple itself — for instance, resolving a `switch`-matched `amountsIn[i]` against a token list that lives one level up, alongside `userData` rather than inside it (see the [`joinPool`/`exitPool` Test Case](#test-cases)). Base ERC-7730's own examples of `#.` never exercise this — every one resolves a flat, top-level sibling — so this ERC states the cross-scope behavior explicitly rather than leaving it to be inferred. + ### `switch` `switch` selects which type or layout governs a field, based on the value of an expression. The expression MAY come from two places: @@ -148,9 +152,9 @@ Paths extend into `layout`-decoded fields the same way they already extend into "switch": { "expression": { "path": "schema" }, "cases": { - "0x1234...": { "abiType": "(address recipient,bool isHuman,uint256 score)" } - }, - "default": "reject" + "0x1234...": { "abiType": "(address recipient,bool isHuman,uint256 score)" }, + "$default": "reject" + } } } ``` @@ -164,9 +168,9 @@ This is the shape needed by EAS (`schema` selects how to decode `data`), ERC-768 "expression": { "type": "uint", "bytes": 1, "mask": "0x3f" }, "payloadFrom": "inputs", "cases": { - "0x00": { "abiType": "(address recipient,uint256 amountIn,uint256 amountOutMin,bytes path,bool payerIsUser)" } - }, - "default": "reject" + "0x00": { "abiType": "(address recipient,uint256 amountIn,uint256 amountOutMin,bytes path,bool payerIsUser)" }, + "$default": "reject" + } } ``` @@ -178,12 +182,14 @@ In both forms, a case value is one of: * `{ "layout": }` — recurse into this ERC's own layout language (any node). * `{ "switch": {...} }` — nest another switch (for multi-level tag structures). * `{ "": { "intent": ..., "fields": [...] } }` — decode using the tuple signature given as the key, exactly like `abiType`, and immediately apply the given `intent`/`fields` to the result, without a separate top-level `display.formats` entry. This is sugar for `abiType` followed by inline structured display; it exists because a `switch` case very often wants to say both "decode it like this" and "display it like this" together, and forcing every case through a two-step `abiType`-then-somewhere-else-defined-fields indirection added no value in the surveyed cases (Universal Router's command table, most notably). +* `{ "format": "" }` — stop: do not decode further, apply an ordinary base-ERC-7730 [field format](./erc-7730.md#field-format-specification) directly to the already-typed value in scope. This is for a case (very often `$default`) where the matched value needs no structural reinterpretation at all — it is already an ordinary value, just display it normally. +* `{ "label": "", "intent": "info" | "warning" }` — stop: do not decode further, display `label` verbatim in place of any decoded value, with the given severity. This is for a case whose matched value has no meaningful decoded content to show at all — see the chained-reference example in [Test Cases](#test-cases), where a matched sentinel value stands for "a value only known once an earlier step in the same batch has executed on-chain," which is not a value a wallet can compute or display, only name. -A wallet MUST treat an expression value with no matching case, and no `default` case supplied, the same way it treats an [unknown selector](./erc-7730.md#unknown-selectors): display a safe fallback and MUST NOT guess at a format. +Every `cases` map MAY include the reserved key `"$default"`, matched when no other case matches; its value is any of the case-value kinds above, or the literal string `"reject"`. A wallet MUST treat an expression value with no matching case, and no `$default` case present, the same way it treats an [unknown selector](./erc-7730.md#unknown-selectors): display a safe fallback and MUST NOT guess at a format. `$default` is an ordinary case, not a structurally different kind of thing — it MAY resolve to a full decode (as in the chained-reference example, where the *non*-sentinel branch is the one that needs `$default`), not only to `"reject"`. ### `switch` at the top of a structured data format specification -Every case above switches on an expression to reinterpret *one field's* bytes, while the rest of the call keeps its own fixed `intent` and `fields`. Some contracts have no such fixed meaning at all: the entire call exists only to be redirected, and which target function it becomes is the only thing worth describing. A [structured data format specification](./erc-7730.md#structured-data-format-specification) MAY declare a top-level `switch` object, with the same `expression`/`cases`/`default` shape as above, instead of `intent`/`fields`. Its `expression` is a path to one of the outer call's own already-decoded parameters (never a raw-byte expression — there is no enclosing buffer to read one from at this level). +Every case above switches on an expression to reinterpret *one field's* bytes, while the rest of the call keeps its own fixed `intent` and `fields`. Some contracts have no such fixed meaning at all: the entire call exists only to be redirected, and which target function it becomes is the only thing worth describing. A [structured data format specification](./erc-7730.md#structured-data-format-specification) MAY declare a top-level `switch` object, with the same `expression`/`cases` shape (including `$default`) as above, instead of `intent`/`fields`. Its `expression` is a path to one of the outer call's own already-decoded parameters (never a raw-byte expression — there is no enclosing buffer to read one from at this level). Because there is no bytes value being reinterpreted at this level, only two case-value forms are valid here: a nested `switch` (for an expression that further refines an already-matched case), or an `interaction` object naming a **different** target function outright — see below. @@ -223,11 +229,19 @@ A wallet MUST resolve the matched target's own `intent`, `interpolatedIntent`, a **Why `$fallback` is needed at all.** Every other selector-related mechanism in ERC-7730 and this ERC assumes a 4-byte selector exists to be computed and matched. The generic deterministic-deployment proxy that motivates `initCode` — the same, single contract, deployed at the identical address on nearly every EVM chain, that many real, well-known contracts (including Uniswap's own Permit2) are deployed through — has no selector at all; its calldata is `salt ‖ initCode`, dispatched by a raw fallback. Without `$fallback`, `initCode` would have no contract it could actually be demonstrated on, since every other candidate factory this research pass found either has a normal ABI wrapper around its bytecode argument (already describable with plain `abiType`, no new construct needed) or turned out, on inspection, to build its creation code internally rather than receiving it as literal calldata at all. +**Why `layout` may anchor on an already-decoded scalar.** Balancer's `BalancerRelayer`/`BatchRelayerLibrary` — the `multicall`-based contract Balancer's own frontend and third-party "zap" integrations use to chain a `joinPool`/`exitPool`/`swap` sequence in one transaction — accepts ordinary ABI-decoded `uint256` amount fields (`maxAmountsIn[i]`, `outputReference`) that are *sometimes* not amounts at all: if the top 12 bits equal `0xba1`, the value is a "chained reference," a pointer to a storage slot the relayer will populate from an *earlier* step's output during execution of this same transaction, not a literal quantity (verified against `BaseRelayerLibraryCommon.sol`'s `_isChainedReference`). Every other tag-dispatch case in this ERC (EAS's `schema`, ERC-7683's `orderDataType`, ERC-7579's `mode`) reads its tag from a field genuinely separate from the value it governs. This one does not — the tag and the value it governs are the same field, examined under a mask. Rather than invent a self-referencing mode of `switch`'s path-sourced form (which would need its own reasoning about read/write ordering and cycles), this ERC reuses the existing, already-masked, inline `switch` node verbatim, and only generalizes *where* a `layout` tree is allowed to start: on the canonical encoding of a value ABI decoding already produced, not only on bytes still waiting to be parsed. This keeps one masking mechanism in the ERC instead of two. + +**Why `$default` moved inside `cases` instead of staying a sibling key.** Originally `default` sat next to `cases`, and every example gave it the value `"reject"` — implying, without saying so, that `default` was structurally special: a fail-closed escape hatch, not really "a case" the way the entries in `cases` are. The chained-reference case above breaks that implication: its `$default` branch is the *common*, expected value (an ordinary amount), and the entry that needs special handling is the sentinel — an inversion of every prior example. Once `default` can legitimately hold a full decode instead of only `"reject"`, it is not structurally different from any other entry in `cases` — it only differs in its matching rule ("nothing else matched" instead of "matched this literal"). Moving it into `cases` under a reserved `$default` key makes that equivalence explicit, and matches the reserved-token convention this ERC already uses for `$index` and `$fallback`, rather than introducing a third way of marking a key as reserved. `initCode`'s `default` is deliberately left as a sibling key, unrenamed: it has no `cases` map to fold into (`templates` is a list, matched structurally, not a value map), and its value is, and remains, restricted to the literal `"reject"` — it was never a case candidate for this treatment in the first place. + +**Why two new case-value kinds, `format` and `label`, and not one.** The chained-reference case needs both ends of the same problem solved: its `$default` branch has nothing unusual to say — the value is exactly what its ABI type already claims, so it needs a way to say "stop, this is fine, just display it normally" without an author re-deriving `intent`/`fields` for a plain amount. Its sentinel branch has the opposite problem: there is no value to compute or format at all — the real quantity is written by an earlier step's execution, after signing, and no construct in this ERC (or in ERC-7730 itself) can display a value that does not yet exist. `format` and `label` are the minimum needed for each half: `format` hands the value to ERC-7730's own existing formatting, unchanged; `label` displays fixed text in place of a value, for exactly the case where "unknown, and unknowable ahead of time" is itself the only honest thing to show. Neither is specific to Balancer — both are general terminal case-value kinds usable anywhere a `switch` case has nothing left to structurally decode, which is precisely the same "narrow but evidence-motivated" bar every other construct in this ERC was held to. + +**Why `#.` crossing `switch`/`layout` scope boundaries is stated explicitly rather than left implied.** Building the `joinPool`/`exitPool` Test Case surfaced a real gap: `switch`-decoded fields like `userData`'s `amountsIn[i]` need to be displayed as token amounts, which requires pairing them with a token list (`request.assets`) that is not inside `userData` at all — it is a sibling of `userData`, one level up in the outer call. Base ERC-7730 defines `#.` as the root of the structured data, which reads as though it should handle this, but every actual use of `#.` in ERC-7730's own spec text and asset examples (checked exhaustively: one inline example plus three asset files) resolves a flat, top-level sibling — none of them cross out of a nested decode the way a `switch` case's local tuple scope requires. Left unstated, two conformant implementations could reasonably disagree on whether `#.` reaches past a `switch`/`layout` scope at all. This ERC resolves that ambiguity in favor of the more useful behavior — `#.` always reaches the true root — rather than requiring every such pairing to be left unresolved. + **Why positional binding, and why `args` may reorder.** ERC-7730 already treats parameter *names* as non-canonical for the purpose of selector matching — only position and type are. An `interaction` that redirects to a different function has no shared parameter names to align by in the first place (the outer call's `account`/`amount` mean nothing to the target function's own signature), so positional binding by the target's declared order is the only definition that is well-defined at all, and it is the same rule ERC-7730 already applies elsewhere, not a new one. ## Backwards Compatibility -This ERC only adds new, optional keys to a field format specification (`layout`, `switch`, `interaction`), plus one new, optional param (`operation`) to ERC-7730's own `format: "calldata"`. A descriptor that does not use them is unaffected, and a wallet implementing only ERC-7730 without this extension can safely ignore fields that use them, applying the existing [unknown field / raw fallback](./erc-7730.md) behavior. +This ERC only adds new, optional keys to a field format specification (`layout`, `switch`, `interaction`), plus one new, optional param (`operation`) to ERC-7730's own `format: "calldata"`, plus two new terminal case-value kinds (`format`, `label`) usable inside any `switch`'s `cases`. A descriptor that does not use them is unaffected, and a wallet implementing only ERC-7730 without this extension can safely ignore fields that use them, applying the existing [unknown field / raw fallback](./erc-7730.md) behavior. `$default` replacing a sibling `default` key is a pre-Draft naming change with no live adopters to migrate — see the [naming note](#test-cases) on this file's own not-yet-migrated example descriptors. ## Test Cases diff --git a/assets/erc-non-abi-dispatch/example-balancer-relayer-library.json b/assets/erc-non-abi-dispatch/example-balancer-relayer-library.json new file mode 100644 index 00000000000..7aa0fb9fe48 --- /dev/null +++ b/assets/erc-non-abi-dispatch/example-balancer-relayer-library.json @@ -0,0 +1,312 @@ +{ + "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.schema.json", + + "$comment": "Worked example for the non-ABI-dispatch companion ERC, decoding BatchRelayerLibrary's setRelayerApproval/joinPool/exitPool - reached only via BalancerRelayer.multicall's per-entry DELEGATECALL, see example-balancer-relayer-multicall.json. Two things are verified against a real, mined transaction; the rest is verified only against source (VaultActions.sol, WeightedPoolUserData.sol, StablePoolUserData.sol, BasePoolUserData.sol at balancer/balancer-v2-monorepo) and flagged inline where that's the case, the same way this ERC's own initCode example marks its Permit2 template as an unverified placeholder. Ethereum mainnet tx 0x9ebb8a7d7c085b3dde80c02f5bc44a1d32749104e2b17d17bf72d7f674ef1b34 (2026-05-22, block 25154060): entry 0 is setRelayerApproval (bundling the one-time authorization into the same transaction as the action it authorizes); entries 1 and 2 are both exitPool - exiting a Weighted pool (kind=0x00, ExitKind=0x01) for, among other tokens, the BPT of a Stable pool nested inside it, then exiting that Stable pool (kind=0x03, ExitKind=0x02) using the just-received BPT amount directly as a chained reference, since that amount cannot be known until the first exitPool actually executes on-chain. joinPool is NOT exercised by this transaction; its entry below is included because it was asked for, built symmetrically to exitPool from source, and is marked as such - it has not been independently confirmed against a separate mined transaction.", + + "context": { + "$id": "Balancer Batch Relayer Library", + "contract": { + "deployments": [ + { "chainId": 1, "address": "0xeA66501dF1A00261E3bB79D1E90444fc6A186B62" } + ] + } + }, + + "metadata": { + "owner": "Balancer", + "contractName": "BatchRelayerLibrary", + "info": { + "url": "https://docs-v2.balancer.fi/concepts/advanced/relayers.html" + } + }, + + "display": { + "formats": { + + "setRelayerApproval(address relayer,bool approved,bytes authorization)": { + "$id": "Balancer Relayer Approval", + "intent": "Allow relayer to act on your behalf", + "fields": [ + { "path": "relayer", "label": "Relayer", "format": "addressName" }, + { "path": "approved", "label": "Approved", "format": "raw" }, + { + "path": "authorization", + "label": "Signed authorization", + "format": "custom", + "layout": { + "type": "object", + "fields": [ + { "name": "deadline", "schema": { "type": "uint", "bytes": 32 } }, + { "name": "v", "schema": { "type": "uint", "bytes": 32 } }, + { "name": "r", "schema": { "type": "bytes", "length": 32 } }, + { "name": "s", "schema": { "type": "bytes", "length": 32 } } + ] + }, + "$comment": "A fixed, non-branching packed record (Vault-specific EIP-712 authorization: deadline, then v/r/s each occupying a full 32-byte word, not tightly packed) - object is enough here, no switch/tag involved. Confirmed against the cited transaction: deadline = type(uint256).max (never expires), v = 28." + }, + { + "path": "authorization.deadline", + "label": "Valid until", + "format": "date", + "params": { "encoding": "timestamp" } + } + ] + }, + + "joinPool(bytes32 poolId,uint8 kind,address sender,address recipient,(address[],uint256[],bytes,bool) request,uint256 value,uint256 outputReference)": { + "$id": "Balancer Relayer Join Pool", + "intent": "Add liquidity", + "$comment": "Not independently confirmed against a mined transaction - built symmetrically to the verified exitPool entry below, from VaultActions.sol/WeightedPoolUserData.sol/StablePoolUserData.sol source. Only kind=0x00 (WEIGHTED), JoinKind.EXACT_TOKENS_IN_FOR_BPT_OUT (0x01) is covered, the one join kind the relayer itself performs chained-reference substitution on (VaultActions._doWeightedJoinChainedReferenceReplacements/_doStableJoinChainedReferenceReplacements: 'All other join kinds are given out ... so we don't do replacements for those') - a production descriptor would cover LEGACY_STABLE/COMPOSABLE_STABLE/COMPOSABLE_STABLE_V2 (kind=0x01-0x03) and the other JoinKind values too.", + "fields": [ + { + "path": "poolId", + "label": "Pool", + "format": "custom", + "layout": { + "type": "object", + "fields": [ + { "name": "poolAddress", "schema": { "type": "address" } }, + { "name": "specialization", "schema": { "type": "uint", "bytes": 2 } }, + { "name": "nonce", "schema": { "type": "uint", "bytes": 10 } } + ] + }, + "$comment": "Pure bit-shift extraction (PoolRegistry._getPoolAddress/_getPoolSpecialization), no chain read - the pool's address is embedded in poolId at registration time and is immutable thereafter." + }, + { "path": "poolId.poolAddress", "label": "Pool", "format": "addressName" }, + { "path": "sender", "label": "From", "format": "addressName" }, + { "path": "recipient", "label": "Recipient", "format": "addressName" }, + { + "path": "request.maxAmountsIn[]", + "label": "Maximum amount in", + "format": "tokenAmount", + "params": { "tokenPath": "request.assets[]" } + }, + { + "path": "request.userData", + "label": "Join details", + "switch": { + "expression": { "path": "kind" }, + "cases": { + "0x00": { + "switch": { + "expression": { "type": "uint", "bytes": 32 }, + "cases": { + "0x01": { + "(uint8 joinKind,uint256[] amountsIn,uint256 minBptAmountOut)": { + "intent": "Add liquidity for exact tokens", + "fields": [ + { + "path": "amountsIn[]", + "label": "Amount in", + "format": "tokenAmount", + "params": { "tokenPath": "#.request.assets[]" }, + "$comment": "amountsIn is decoded from inside userData, a switch-matched local tuple - request.assets is a sibling of userData one level up in the outer call, not reachable by a relative path. #. crossing that scope boundary is this ERC's own clarification (see Path addressing/Rationale), not an established base-ERC-7730 behavior; example-universal-router.json leaves its own analogous case as a raw number instead, written before this clarification existed." + }, + { + "path": "minBptAmountOut", + "label": "Minimum pool tokens out", + "layout": { + "type": "switch", + "expression": { "type": "uint", "bytes": 32, "mask": "0xfff0000000000000000000000000000000000000000000000000000000000000" }, + "cases": { + "0xba10000000000000000000000000000000000000000000000000000000000000": { + "label": "Dynamic value — set by an earlier step in this transaction, not known yet", + "intent": "warning" + }, + "$default": { "format": "tokenAmount", "params": { "tokenPath": "#.poolId.poolAddress" } } + } + }, + "$comment": "The pool's own BPT is the token being minted, so the token to pair against is #.poolId.poolAddress - decoded at the very top of this call, three switch/layout scopes above this field." + } + ] + } + }, + "$default": "reject" + } + } + }, + "$default": "reject" + } + } + }, + { "path": "request.fromInternalBalance", "label": "Pay from Vault internal balance", "format": "raw" }, + { "path": "value", "label": "ETH sent", "format": "amount" }, + { + "path": "outputReference", + "label": "Save result as", + "layout": { + "type": "switch", + "expression": { "type": "uint", "bytes": 32, "mask": "0xfff0000000000000000000000000000000000000000000000000000000000000" }, + "cases": { + "0xba10000000000000000000000000000000000000000000000000000000000000": { + "label": "Stored for use by a later step in this transaction", + "intent": "info" + }, + "$default": { "format": "raw" } + } + }, + "$comment": "Unlike exitPool's outputReferences[].key below, there is no on-chain requirement that this be a chained reference - 0 ('do not store') is a common, valid, non-sentinel value, so $default falls through to a plain display rather than reject." + } + ] + }, + + "exitPool(bytes32 poolId,uint8 kind,address sender,address recipient,(address[],uint256[],bytes,bool) request,(uint256,uint256)[] outputReferences)": { + "$id": "Balancer Relayer Exit Pool", + "intent": "Remove liquidity", + "fields": [ + { + "path": "poolId", + "label": "Pool", + "format": "custom", + "layout": { + "type": "object", + "fields": [ + { "name": "poolAddress", "schema": { "type": "address" } }, + { "name": "specialization", "schema": { "type": "uint", "bytes": 2 } }, + { "name": "nonce", "schema": { "type": "uint", "bytes": 10 } } + ] + } + }, + { "path": "poolId.poolAddress", "label": "Pool", "format": "addressName" }, + { "path": "sender", "label": "From", "format": "addressName" }, + { "path": "recipient", "label": "Recipient", "format": "addressName" }, + { + "path": "request.minAmountsOut[]", + "label": "Minimum amount out", + "format": "tokenAmount", + "params": { "tokenPath": "request.assets[]" } + }, + { + "path": "request.userData", + "label": "Exit details", + "switch": { + "expression": { "type": "uint", "bytes": 32 }, + "cases": { + "0xff": { + "(uint8 exitKind,uint256 bptAmountIn)": { + "intent": "Exit in Recovery Mode", + "fields": [ + { + "path": "bptAmountIn", + "label": "Pool tokens in", + "layout": { + "type": "switch", + "expression": { "type": "uint", "bytes": 32, "mask": "0xfff0000000000000000000000000000000000000000000000000000000000000" }, + "cases": { + "0xba10000000000000000000000000000000000000000000000000000000000000": { + "label": "Dynamic value — set by an earlier step in this transaction, not known yet", + "intent": "warning" + }, + "$default": { "format": "tokenAmount", "params": { "tokenPath": "#.poolId.poolAddress" } } + } + } + } + ] + }, + "$comment": "255 (0xff) is BasePoolUserData.RECOVERY_MODE_EXIT_KIND, deliberately the maximum uint8 value 'to prevent conflicts with future additions to the ExitKind enums' - common to every pool type, checked before kind is even consulted. Not independently mined; verified against BasePoolUserData.sol source." + }, + "$default": { + "switch": { + "expression": { "path": "kind" }, + "cases": { + "0x00": { + "switch": { + "expression": { "type": "uint", "bytes": 32 }, + "cases": { + "0x01": { + "(uint8 exitKind,uint256 bptAmountIn)": { + "intent": "Remove liquidity proportionally", + "fields": [ + { + "path": "bptAmountIn", + "label": "Pool tokens in", + "layout": { + "type": "switch", + "expression": { "type": "uint", "bytes": 32, "mask": "0xfff0000000000000000000000000000000000000000000000000000000000000" }, + "cases": { + "0xba10000000000000000000000000000000000000000000000000000000000000": { + "label": "Dynamic value — set by an earlier step in this transaction, not known yet", + "intent": "warning" + }, + "$default": { "format": "tokenAmount", "params": { "tokenPath": "#.poolId.poolAddress" } } + } + }, + "$comment": "Confirmed against the cited transaction's entry 1 (exiting the Weighted pool): a literal amount, 16,250.97 pool tokens - the $default branch, not the sentinel." + } + ] + } + }, + "$default": "reject" + } + } + }, + "0x03": { + "switch": { + "expression": { "type": "uint", "bytes": 32 }, + "cases": { + "0x02": { + "(uint8 exitKind,uint256 bptAmountIn)": { + "intent": "Remove liquidity proportionally", + "fields": [ + { + "path": "bptAmountIn", + "label": "Pool tokens in", + "layout": { + "type": "switch", + "expression": { "type": "uint", "bytes": 32, "mask": "0xfff0000000000000000000000000000000000000000000000000000000000000" }, + "cases": { + "0xba10000000000000000000000000000000000000000000000000000000000000": { + "label": "Dynamic value — set by an earlier step in this transaction, not known yet", + "intent": "warning" + }, + "$default": { "format": "tokenAmount", "params": { "tokenPath": "#.poolId.poolAddress" } } + } + }, + "$comment": "Confirmed against the cited transaction's entry 2 (exiting the Stable pool): the sentinel branch - the exact chained reference (index 0, key 0xba10...0) that entry 1's outputReferences populated. This is the concrete case the sentinel extension exists for: this number cannot be known until entry 1 has executed on-chain." + } + ] + } + }, + "$default": "reject" + } + } + }, + "0x01": "reject", + "0x02": "reject", + "$default": "reject" + } + } + } + } + }, + "$comment": "Recovery mode is checked first, unconditionally of kind, matching VaultActions._doExitPoolChainedReferenceReplacements' real precedence ('Must check for the recovery mode ExitKind first ... common to all pool types'). kind=0x01 (LEGACY_STABLE) and 0x02 (COMPOSABLE_STABLE v1) are left as reject: not exercised by the cited transaction, and VaultActions.sol itself documents that ExitKind numbering differs even within the Stable family across these versions ('BPT_IN_FOR_EXACT_TOKENS_OUT is 2 in legacy Stable Pools, but 1 in Composable Stable Pools') - a production descriptor would need their own separate tables, not a shared one." + }, + { "path": "request.toInternalBalance", "label": "Receive to Vault internal balance", "format": "raw" }, + { + "path": "outputReferences[].index", + "label": "Token", + "format": "raw", + "$comment": "This indexes into request.assets - but as a value only known once decoded, not a fixed array position, so the established same-index array-pairing rule (params array read at the same index as the element being formatted) doesn't apply here: that rule pairs two arrays at the SAME position, not one array read at a position given by another array's own decoded value. Resolving this to an actual token/symbol is a further gap this worked example does not attempt to close." + }, + { + "path": "outputReferences[].key", + "label": "Save result as", + "layout": { + "type": "switch", + "expression": { "type": "uint", "bytes": 32, "mask": "0xfff0000000000000000000000000000000000000000000000000000000000000" }, + "cases": { + "0xba10000000000000000000000000000000000000000000000000000000000000": { + "label": "Stored for use by a later step in this transaction", + "intent": "info" + }, + "$default": "reject" + } + }, + "$comment": "Unlike joinPool's outputReference, VaultActions.exitPool requires every entry here to be a chained reference (require(_isChainedReference(outputReferences[i].key), \"invalid chained reference\")) - a non-sentinel value is dead on arrival, so $default is reject rather than a plain display, matching this ERC's fail-closed rule for anything the real contract would itself revert on." + } + ] + } + + } + } +} diff --git a/assets/erc-non-abi-dispatch/example-balancer-relayer-multicall.json b/assets/erc-non-abi-dispatch/example-balancer-relayer-multicall.json new file mode 100644 index 00000000000..161f4cdbed0 --- /dev/null +++ b/assets/erc-non-abi-dispatch/example-balancer-relayer-multicall.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.schema.json", + + "$comment": "Worked example for the non-ABI-dispatch companion ERC, decoding Balancer's BalancerRelayer.multicall(bytes[] data) - the outer entry point a human wallet actually signs against. Each data[i] is itself a complete, selector-prefixed call, but it is delegatecalled into a separate, fixed BatchRelayerLibrary contract (0xeA66501dF1A00261E3bB79D1E90444fc6A186B62), not into this relayer address, and that address never appears anywhere in the calldata - it is baked into the relayer's own immutable bytecode. That is why this is a separate descriptor file from example-balancer-relayer-library.json (which describes the library's own setRelayerApproval/joinPool/exitPool), following the same dispatcher/target split this ERC already uses for TieredExecutor. Verified against Ethereum mainnet tx 0x9ebb8a7d7c085b3dde80c02f5bc44a1d32749104e2b17d17bf72d7f674ef1b34 (2026-05-22, block 25154060) - see the ERC's Test Cases section.", + + "context": { + "$id": "Balancer Relayer Multicall", + "contract": { + "deployments": [ + { "chainId": 1, "address": "0x35Cea9e57A393ac66Aaa7E25C391D52C74B5648f" } + ] + } + }, + + "metadata": { + "owner": "Balancer", + "contractName": "BalancerRelayer", + "info": { + "url": "https://docs-v2.balancer.fi/concepts/advanced/relayers.html" + } + }, + + "display": { + "formats": { + "multicall(bytes[] data)": { + "$id": "Balancer Relayer Multicall", + "intent": "Execute batch", + "fields": [ + { + "path": "data[]", + "label": "Batched relayer action", + "format": "calldata", + "params": { + "callee": "0xeA66501dF1A00261E3bB79D1E90444fc6A186B62", + "operation": "delegatecall" + }, + "$comment": "callee is a literal constant, not a path: the BatchRelayerLibrary address is immutable in the relayer's own bytecode and is never itself present in calldata (base ERC-7730's calleePath/callee already allows either form). operation is this companion ERC's addition - every entry executes as a DELEGATECALL, in the relayer's own storage and identity, not a plain call to the library." + } + ] + } + } + } +} From cd29f1612c9408c7afe0a78b1ded91db6dc86c9d Mon Sep 17 00:00:00 2001 From: Alex Forshtat Date: Fri, 31 Jul 2026 01:42:01 +0200 Subject: [PATCH 08/23] Add pointer layout node for offset-indirected buffers (Safe execTransaction) Safe's execTransaction signatures field repurposes a fixed-record field as a byte offset into a shared tail region for EIP-1271 contract signatures - the one case in this ERC's survey needing genuine pointer-style indirection within a custom-packed buffer, which object/ sequence/lengthFrom deliberately can't express. Adds the pointer layout node (bytes width, required containerPath, destination), extends the byte-width invariant and Security Considerations to cover it (including the real recursion case: a Safe signing for another Safe), and adds a worked example verified against a real nested-Safe transaction for the EOA/APPROVED_HASH paths, with the CONTRACT_SIGNATURE/pointer branch marked source-verified-only pending a live example. Co-Authored-By: Claude Sonnet 5 --- ERCS/erc-draft_non_abi_dispatch.md | 35 +++- .../example-safe-exectransaction.json | 151 ++++++++++++++++++ 2 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 assets/erc-non-abi-dispatch/example-safe-exectransaction.json diff --git a/ERCS/erc-draft_non_abi_dispatch.md b/ERCS/erc-draft_non_abi_dispatch.md index 08559955331..5534f2ac039 100644 --- a/ERCS/erc-draft_non_abi_dispatch.md +++ b/ERCS/erc-draft_non_abi_dispatch.md @@ -12,7 +12,7 @@ requires: 7730 ## Abstract -[ERC-7730](./erc-7730.md) describes how to clear-sign structured data by decoding calldata as a Solidity ABI function call, then formatting the resulting named fields. This works as long as every value in the call is itself ABI-encoded. It breaks down for a common and growing class of contracts that accept one ABI-encoded `bytes` (or `bytes[]`) argument and then interpret its raw content using their own private encoding — packed structs, bit-packed flags, or a tag that selects one of several possible payload shapes. Gnosis Safe's `MultiSend`, Uniswap's Universal Router and hook addresses, and ERC-7579 modular accounts all do this, and none of it can be described in ERC-7730 today; such fields must be left as opaque, unreadable bytes. It also breaks down entirely for contract-creation calls, which have no function selector at all — a real, common, security-critical transaction type ERC-7730 has no vocabulary for. +[ERC-7730](./erc-7730.md) describes how to clear-sign structured data by decoding calldata as a Solidity ABI function call, then formatting the resulting named fields. This works as long as every value in the call is itself ABI-encoded. It breaks down for a common and growing class of contracts that accept one ABI-encoded `bytes` (or `bytes[]`) argument and then interpret its raw content using their own private encoding — packed structs, bit-packed flags, a tag that selects one of several possible payload shapes, or an offset pointing to data stored elsewhere in that same buffer. Gnosis Safe's `MultiSend`, Uniswap's Universal Router and hook addresses, and ERC-7579 modular accounts all do this, and none of it can be described in ERC-7730 today; such fields must be left as opaque, unreadable bytes. It also breaks down entirely for contract-creation calls, which have no function selector at all — a real, common, security-critical transaction type ERC-7730 has no vocabulary for. This ERC adds three new keys to an ERC-7730 [field format specification](./erc-7730.md#field-format-specification) — `layout`, `switch`, and `interaction` — that let an author describe the internal structure of such a field, which structure applies where the field's shape depends on a tag value, and how to describe a call synthesized from already-decoded pieces rather than sliced out of contiguous bytes. Where a field's raw bytes already are a complete, contiguous call (selector and ABI-encoded arguments together), this ERC deliberately does not duplicate ERC-7730's own [embedded calldata](./erc-7730.md#embedded-calldata) mechanism (`format: "calldata"`) — it only extends that mechanism with one param, `operation`, for the one thing it cannot already express: a nested call that is a `DELEGATECALL` rather than a plain call. It also adds a reserved `$fallback` key to `display.formats` for contracts that dispatch with no selector at all. Everything else about ERC-7730 (context binding, metadata, top-level selector matching, path syntax) is unchanged; this ERC only extends what a single field's `path` can resolve into, and how a contract's entry point is matched in the first place. @@ -24,6 +24,7 @@ Look at what a `display.formats` entry can describe today: a Solidity function s - **Uniswap's Universal Router `execute(bytes commands, bytes[] inputs)`** — `commands` is one raw opcode byte per sub-action (with a flag bit for "allowed to revert"); `inputs[i]` is separately ABI-encoded, but *which* ABI type it decodes as depends on the opcode at `commands[i]`. - **ERC-7579 modular accounts, `execute(bytes32 mode, bytes executionCalldata)`** — `mode` packs five sub-fields into one word; `executionCalldata`'s shape (a single packed call, or an ABI-encoded array of calls) depends on one byte of `mode`. - **Uniswap v4 hook addresses** — up to 14 independent permission flags (`beforeSwap`, `afterSwap`, and others) live in specific low-order bits of the 160-bit hook address itself; the same address value is simultaneously "an address" and "a bitmask," with no byte alignment between the two meanings. +- **Safe's `execTransaction(...,bytes signatures)`** — `signatures` is a sequence of fixed 65-byte records, but a record signing with a contract (rather than an EOA) repurposes one of its own fields as an offset into a shared tail region appended after every record, where the actual, variable-length signature data lives — the one shape in this ERC's entire survey that needs genuine pointer-style indirection *within* a custom-packed buffer, not just a fixed or tag-selected structure. - **Contract-creation transactions** — a `CREATE`/`CREATE2` deployment, or a generic deterministic-deployment factory taking raw bytecode as an argument, has no function selector at all. There is nothing for ERC-7730's selector-matching to match against, and no vocabulary for describing constructor arguments appended to, or embedded within, compiler-specific creation bytecode. None of this is exotic or rare. It is how batching, modular accounts, and generic-purpose routers already work across the ecosystem, and account-abstraction adoption is only going to produce more of it. A wallet with no way to describe these fields has no way to clear-sign them beyond showing raw hex — which is exactly the blind, trust-me signing experience ERC-7730 exists to eliminate. @@ -40,7 +41,7 @@ This ERC defines three additional keys usable in an ERC-7730 [field format speci `layout` describes the internal byte structure of a `bytes` field. Its value is a *layout node*. A layout node is one of the following types: -**Anchoring `layout` on an already-decoded scalar.** `layout` is normally attached to a field whose raw `bytes` have not been interpreted yet. It MAY also be attached to a field whose value was already produced by ordinary Solidity ABI decoding, if that value's declared type is a single-word elementary type — `uintN`/`intN`, `bool`, `address`, or a fixed-size `bytesN`. In that case the layout tree operates on the value's canonical 32-byte big-endian ABI-word encoding as its buffer, exactly as if those 32 bytes had been sliced out of a larger one. This is not extended to dynamic types (`bytes`, `string`, arrays, tuples) — no known case needs it, and "canonical encoding" is not a single well-defined byte sequence for them the way it is for a 32-byte word. See [Rationale](#rationale) for the motivating case. +**Anchoring `layout` on an already-decoded scalar.** `layout` is normally attached to a field whose raw `bytes` have not been interpreted yet. It MAY also be attached to a field whose value was already produced by ordinary Solidity ABI decoding, or by an enclosing `object`/`bitfield` layout node — [Path addressing](#path-addressing) already treats the two sources the same way — if that value's declared or inferred type is a single-word elementary type: `uintN`/`intN`, `bool`, `address`, or a fixed-size `bytesN`. In that case the layout tree operates on the value's canonical 32-byte big-endian encoding as its buffer, exactly as if those 32 bytes had been sliced out of a larger one. This is not extended to dynamic types (`bytes`, `string`, arrays, tuples) — no known case needs it, and "canonical encoding" is not a single well-defined byte sequence for them the way it is for a 32-byte word. See [Rationale](#rationale) for the motivating case. **Primitive nodes** @@ -90,6 +91,16 @@ Fields are read strictly in order, byte-for-byte, with no alignment padding and `"tillEnd"` is the only count mode this ERC defines, because it is the only one any known real case needs — Safe MultiSend repeats its record `object` until `transactions` runs out; Universal Router repeats a `switch` (below) once per byte of `commands`. Other termination modes (an explicit element count, a byte-length prefix) are left for a future revision if a real case needs them, rather than specified speculatively now. +**`pointer`** — a fixed-width value that is not itself the data of interest, but an offset to where that data actually lives, elsewhere in a named buffer: + +```json +{ "type": "pointer", "bytes": 32, "containerPath": "#.signatures", "destination": } +``` + +`bytes` is the width of the offset value, read at this node's normal sequential position — that read is all that counts toward the enclosing `object`/`sequence`'s byte accounting; nothing else about `pointer` consumes bytes at the cursor's current position. `containerPath` is REQUIRED and MUST resolve to a `bytes`-typed value (a wallet MUST reject a `containerPath` that resolves to anything else, rather than guess); it names the buffer the offset is a position *into* — not necessarily the buffer this `pointer` node itself is being read from, since a fixed-size record containing a pointer is often only one of several records sharing one common tail region. `destination` is any layout node, decoded starting at byte position `containerPath`'s buffer + the offset value just read; its decoded result becomes this field's value. There is no `anchor`-style mode parameter: the offset is always relative to the start of whatever buffer `containerPath` names, because that is the only relationship any known real case has — an implicit, unstated anchor was considered and rejected in favor of `containerPath` precisely because "relative to what" has no safe default once a real case is examined closely enough (see [Rationale](#rationale)). + +This is Safe's `execTransaction` `signatures` field: a `sequence` of fixed 65-byte `(r, s, v)` records, where a record with `v == 0` repurposes `s` — ordinarily half of an ECDSA signature — as a `pointer` into the tail of the same `signatures` buffer, at which a length-prefixed EIP-1271 signature blob lives. See [Test Cases](#test-cases). + **`$index`** — inside the `element` of a `sequence`, or of a field iterated via `format: "array"` (a field whose value is already an ABI-decoded array, walked element-by-element rather than by consuming bytes), `$index` is a reserved token equal to the zero-based position of the element currently being processed. It is usable inside a `path` to correlate that element with the same-indexed value of a *different* sibling field — e.g. `commands[$index]`, read from inside an `inputs` element's `switch`, is the byte of the sibling `commands` sequence at the same position as the `inputs` element currently being resolved. This is Universal Router's actual structure: `commands` (raw `bytes`, one command per byte, parsed as a `sequence`) and `inputs` (already-decoded `bytes[]`, walked via `format: "array"`) are two separate fields advanced in lockstep by the same index, not one field nested inside the other — `$index` is what ties them together. **Nested calldata reuses ERC-7730's own mechanism, not a layout node.** A field whose bytes — however they were reached, whether by ordinary ABI decoding or by a parent `layout` — are themselves a complete, contiguous call (a selector followed by ABI-encoded arguments) is described with ERC-7730's own [embedded calldata](./erc-7730.md#embedded-calldata) mechanism, `format: "calldata"`, addressed by an ordinary `path` into the (possibly `layout`-decoded) structure — see [Path addressing](#path-addressing), which already lets a `path` reach into `object` fields and `sequence` elements the same way it reaches into ABI-decoded ones. This is how Safe MultiSend's inner calls are described: the `data` field is parsed as plain `bytes` (its `dataLength` already covers the full selector-plus-arguments blob, unmodified from how Safe's own contract packs it), and a sibling top-level field entry with `path: "transactions[].data"` and `format: "calldata"` resolves it, using `calleePath`/`amountPath` to point back at `transactions[].to`/`transactions[].value`. The same pattern describes ERC-7579's batched executions: each element of the ABI-decoded `Execution[]` array has an ordinary `bytes callData` member, resolved by a field entry with `path: "executionCalldata[].callData"` and `format: "calldata"`, `params: {"calleePath": "executionCalldata[].target"}` — addressed at the same array index as its sibling `target`, the same by-index correlation ERC-7730 already uses for [array-valued formatting parameters](./erc-7730.md#field-format-specification). See [Rationale](#rationale) for why this ERC does not define its own parallel node for this instead. @@ -132,7 +143,7 @@ If no template matches, a wallet MUST apply `default: "reject"` — the same [un Some contracts — notably generic deterministic-deployment proxies (see [Test Cases](#test-cases)) — expose no ABI-selected function at all; every call reaches a single raw fallback. `display.formats` MAY use the reserved key `"$fallback"` for exactly this case: a [structured data format specification](./erc-7730.md#structured-data-format-specification) matched whenever calldata does not correspond to any selector-based entry in the same file, whose `fields`/`layout` describe the entirety of `data` directly, with no selector-stripping step. `$fallback` MUST NOT be combined with selector-keyed entries that could themselves match the same calldata; wallets MUST prefer a matching selector-keyed entry over `$fallback` when both are present and only one is intended to apply. This does not change `display.formats` selector matching for any contract that has a normal ABI — it only gives contracts that genuinely have none a way to be described at all. -**Byte-width invariant.** Every `layout` node either consumes a well-defined, computable number of bytes from the buffer (all of the types above), or is explicitly declared non-consuming (only `switch`'s path-sourced expression form, below, which reads an already-resolved value instead of parsing bytes). No node type may be ambiguous about whether, or how much, it advances the cursor. A `layout` anchored directly on an already-decoded scalar (above) is a degenerate, trivially-satisfying case of this same invariant: there is no enclosing buffer to consume from or leave a remainder in, the buffer *is* the value's fixed 32-byte encoding in full, and the top-level node MUST consume it completely, the same rule applied everywhere else. +**Byte-width invariant.** Every `layout` node either consumes a well-defined, computable number of bytes from the buffer (all of the types above), or is explicitly declared non-consuming (`switch`'s path-sourced expression form, below, which reads an already-resolved value instead of parsing bytes; and `pointer`'s `destination`, which is decoded at a computed position in a named buffer rather than at the cursor). No node type may be ambiguous about whether, or how much, it advances the *cursor of the buffer it is being read from* — `pointer` does not violate this: its own `bytes` width is a fixed, ordinary consumption at its own position, and `destination`'s decode is a separate, explicitly-declared side read against `containerPath`'s buffer, not a claim about how far the enclosing `object`/`sequence` advances. A `layout` anchored directly on an already-decoded scalar (above) is a degenerate, trivially-satisfying case of this same invariant: there is no enclosing buffer to consume from or leave a remainder in, the buffer *is* the value's fixed 32-byte encoding in full, and the top-level node MUST consume it completely, the same rule applied everywhere else. ### Path addressing @@ -227,6 +238,8 @@ A wallet MUST resolve the matched target's own `intent`, `interpolatedIntent`, a **Why `initCode` is its own node type rather than a `switch` expression-sourcing mode.** An earlier version of this design considered folding creation-bytecode matching into `switch` as a fourth expression-sourcing mode (hash of a length-prefix of the buffer). That would have worked for the simplest case, but it doesn't generalize cleanly: some real creation-code templates append constructor arguments after a fixed prefix (a generic factory concatenating a template with a trailing ABI-encoded argument), while others — [EIP-1167](https://eips.ethereum.org/EIPS/eip-1167) minimal proxies, verified against the standard's own bytecode listing — embed their one constructor-equivalent value (the implementation address) *between* a fixed prefix and a fixed suffix, with no ABI encoding at all. Expressing both shapes through a single scalar expression and a flat `cases` map would have needed the expression itself to somehow also carry "and here's where the matched region ends", which is exactly the kind of implicit, easy-to-get-wrong behavior the byte-width invariant exists to rule out elsewhere in this ERC. A dedicated node with explicit `prefix`/`suffix`/`args` fields makes the matched region's boundaries an explicit, checkable part of each template entry instead. +**Why `pointer` is its own node type, and why it needs `containerPath` rather than an implicit anchor.** Every other node in this ERC's layout language reads strictly forward from the cursor, which is what makes the byte-width invariant a simple, checkable sum. Safe's `execTransaction` breaks that on its own terms, not by choice of this ERC: verified against `Safe.sol`'s `checkNSignatures`/`checkContractSignature`, a signature record with `v == 0` repurposes its own `s` field — ordinarily half an ECDSA signature — as a byte offset into the *same* `signatures` buffer, pointing past all the fixed 65-byte records to a length-prefixed EIP-1271 blob shared by every contract-signer. No combination of `object`, `sequence`, or `bytes.lengthFrom` expresses "this value is a position to seek to, not data to read here" — `lengthFrom` only ever sizes a field from an already-read sibling's value, never repositions the cursor. An earlier version of this design left the offset's anchor implicit — always relative to the root of the enclosing `layout` tree — reasoning that only one anchor is evidenced, the same restraint applied to `sequence`'s `count`. That was reconsidered: `pointer` can appear nested inside a `sequence`'s `element`, several scopes below whatever "the enclosing layout tree" means informally, and Safe's own offset is relative to the *entire* `signatures` array, not to the 65-byte record the pointer happens to sit inside — exactly the kind of local-vs-root ambiguity this ERC's [Path addressing](#path-addressing) `#.` rule already had to resolve explicitly once, for a different construct. `containerPath` removes the ambiguity by construction rather than defining a fallback rule for it: every `pointer` states, visibly, which buffer it reads against, reusing the same path-reference idiom `format: "calldata"`'s `calleePath`/`amountPath`/`spenderPath` already established, rather than inventing an implicit-default rule that would need its own careful, easy-to-get-wrong specification. + **Why `$fallback` is needed at all.** Every other selector-related mechanism in ERC-7730 and this ERC assumes a 4-byte selector exists to be computed and matched. The generic deterministic-deployment proxy that motivates `initCode` — the same, single contract, deployed at the identical address on nearly every EVM chain, that many real, well-known contracts (including Uniswap's own Permit2) are deployed through — has no selector at all; its calldata is `salt ‖ initCode`, dispatched by a raw fallback. Without `$fallback`, `initCode` would have no contract it could actually be demonstrated on, since every other candidate factory this research pass found either has a normal ABI wrapper around its bytecode argument (already describable with plain `abiType`, no new construct needed) or turned out, on inspection, to build its creation code internally rather than receiving it as literal calldata at all. **Why `layout` may anchor on an already-decoded scalar.** Balancer's `BalancerRelayer`/`BatchRelayerLibrary` — the `multicall`-based contract Balancer's own frontend and third-party "zap" integrations use to chain a `joinPool`/`exitPool`/`swap` sequence in one transaction — accepts ordinary ABI-decoded `uint256` amount fields (`maxAmountsIn[i]`, `outputReference`) that are *sometimes* not amounts at all: if the top 12 bits equal `0xba1`, the value is a "chained reference," a pointer to a storage slot the relayer will populate from an *earlier* step's output during execution of this same transaction, not a literal quantity (verified against `BaseRelayerLibraryCommon.sol`'s `_isChainedReference`). Every other tag-dispatch case in this ERC (EAS's `schema`, ERC-7683's `orderDataType`, ERC-7579's `mode`) reads its tag from a field genuinely separate from the value it governs. This one does not — the tag and the value it governs are the same field, examined under a mask. Rather than invent a self-referencing mode of `switch`'s path-sourced form (which would need its own reasoning about read/write ordering and cycles), this ERC reuses the existing, already-masked, inline `switch` node verbatim, and only generalizes *where* a `layout` tree is allowed to start: on the canonical encoding of a value ABI decoding already produced, not only on bytes still waiting to be parsed. This keeps one masking mechanism in the ERC instead of two. @@ -245,7 +258,7 @@ This ERC only adds new, optional keys to a field format specification (`layout`, ## Test Cases -Six of the eight examples below are real, mined transactions, decoded from raw calldata (not an explorer's rendered summary) and cross-checked against at least one independent source. A seventh demonstrates `initCode`/`$fallback` against real, named, well-known contracts rather than one specific transaction. The eighth, `TieredExecutor`, is explicitly a made-up contract — see its own description below and the caveat in [Rationale](#rationale). Each is a full, standalone ERC-7730 descriptor file under [`../assets/erc-non-abi-dispatch/`](../assets/erc-non-abi-dispatch/) rather than a snippet, so it can be read with all the surrounding `context`/`metadata`/`display` structure intact. +Seven of the nine examples below are real, mined transactions, decoded from raw calldata (not an explorer's rendered summary) and cross-checked against at least one independent source. An eighth demonstrates `initCode`/`$fallback` against real, named, well-known contracts rather than one specific transaction. The ninth, `TieredExecutor`, is explicitly a made-up contract — see its own description below and the caveat in [Rationale](#rationale). Each is a full, standalone ERC-7730 descriptor file under [`../assets/erc-non-abi-dispatch/`](../assets/erc-non-abi-dispatch/) rather than a snippet, so it can be read with all the surrounding `context`/`metadata`/`display` structure intact. ### Safe `MultiSend` @@ -253,6 +266,14 @@ Ethereum mainnet, tx [`0x414ae5aaff729927d663ccaa027ea2284e47fa546cb73ce1dee481e Full descriptor: [`example-safe-multisend.json`](../assets/erc-non-abi-dispatch/example-safe-multisend.json). +### Safe `execTransaction` (`pointer`) + +Ethereum mainnet, Safe transaction hash [`0xe6cffb80c9521e152bc97b2bee23140bad634ecb02f9d5dcd57320b1cea95b60`](https://etherscan.io/tx/0xe6cffb80c9521e152bc97b2bee23140bad634ecb02f9d5dcd57320b1cea95b60), executed 2026-03-27. The Safe at `0xa5C629E04E563355c30885B62928fd6E03558548` — itself co-owned by GnosisDAO's own Safe (`0x0DA0C3e52C977Ed3cBc641fF02DD271c3ED55aFe`), confirmed via the Safe Transaction Service's `owners` index, which lists 14 Safes GnosisDAO's Safe is itself an owner of — delegatecalls (`operation=1`) `MultiSend` (`0x9641d764fc13c8B624c04430C7356C1C7C8102e2`) to batch 13 ERC-20 `transfer` calls, the buffer consumed exactly. `signatures` (195 bytes, three 65-byte records, sorted ascending by signer address) decodes to: record 0, `v=1` (`APPROVED_HASH`) from `0x1B0C638616Ed79dB430Edbf549ad9512FF4a8ed1`, `r` holding that same address and `s` unused (zero); records 1 and 2, ordinary `v=27` ECDSA signatures. All three are independently confirmed against the Safe Transaction Service's own per-signer `signatureType` field (`APPROVED_HASH`, `EOA`, `EOA`). + +No `v=0` (`CONTRACT_SIGNATURE`) record — the one that actually exercises `pointer` — was found despite a real search: GnosisDAO's Safe co-signs at least 268 executed transactions across the Safes it owns (sampled directly via the Transaction Service), and every sampled confirmation is `EOA` or `APPROVED_HASH`. This suggests `approveHash` (a separate, simpler pre-validation transaction, surfacing as an ordinary `v=1` record in the child Safe) is how nested-Safe co-signing is actually done in practice, even though `CONTRACT_SIGNATURE` is a real, protocol-supported, currently-reachable path — verified directly against `Safe.sol`'s `checkNSignatures`/`checkContractSignature` (see [Rationale](#rationale)), not against a mined transaction. The descriptor's `pointer`-driven branch is marked accordingly. + +Full descriptor: [`example-safe-exectransaction.json`](../assets/erc-non-abi-dispatch/example-safe-exectransaction.json). + ### Uniswap Universal Router Ethereum mainnet, tx [`0x3805667353244e8fb763d50b7dd3bdb8f176119b44fdbd0a4ad5629d851ebbba`](https://etherscan.io/tx/0x3805667353244e8fb763d50b7dd3bdb8f176119b44fdbd0a4ad5629d851ebbba), calling `execute(bytes,bytes[],uint256)` on the Universal Router at `0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af`. `commands = 0x000004`: command 0 (`0x00`, `V3_SWAP_EXACT_IN`) sends `amountIn=3425828840000000000000` EURe through the path `EURe → EUR0 → EURC` with `payerIsUser=true`; command 1 (`0x00` again) swaps `amountIn=5138743260000000000000` EURe directly to EURC; command 2 (`0x04`, `SWEEP`) sweeps native ETH with `amountMinimum=0` back to the swapper. All three command bytes had their top (revert-flag) bit unset; token identities and pool fees were confirmed independently via each token's `symbol()`/`decimals()`. @@ -297,7 +318,7 @@ A small, illustrative Solidity contract written for this ERC — [`TieredExecuto Full descriptors: [`example-tiered-executor.json`](../assets/erc-non-abi-dispatch/example-tiered-executor.json) (the dispatching contract), [`example-reward-vault.json`](../assets/erc-non-abi-dispatch/example-reward-vault.json) and [`example-legacy-token.json`](../assets/erc-non-abi-dispatch/example-legacy-token.json) (the two target interfaces it recurses into, each an independently-authored descriptor resolved the same way any other embedded-calldata target would be). -> **Naming note:** the linked example files under [`../assets/erc-non-abi-dispatch/`](../assets/erc-non-abi-dispatch/) — all six real-transaction descriptors above, plus `TieredExecutor`'s three files — still use this ERC's pre-rename vocabulary (`kind`, `struct`, `dispatch`, `tag`, and the now-removed `call` layout node) and have not yet been migrated to `type`/`object`/`switch`/`expression`/`format: "calldata"`. [`example-all-syntax-human.json5`](../assets/erc-non-abi-dispatch/example-all-syntax-human.json5) uses the current naming throughout for the three formats it covers (MultiSend, Universal Router, ERC-7579) and is the reference for what the migrated shape looks like. Migrating the other six files is open follow-up work. +> **Naming note:** the linked example files under [`../assets/erc-non-abi-dispatch/`](../assets/erc-non-abi-dispatch/) for the original six real-transaction cases (Safe `MultiSend`, Universal Router, ERC-7579, CCTP, EAS, ERC-7683) plus `TieredExecutor`'s three files still use this ERC's pre-rename vocabulary (`kind`, `struct`, `dispatch`, `tag`, and the now-removed `call` layout node) and have not yet been migrated to `type`/`object`/`switch`/`expression`/`format: "calldata"`. [`example-all-syntax-human.json5`](../assets/erc-non-abi-dispatch/example-all-syntax-human.json5) uses the current naming throughout for the three formats it covers (MultiSend, Universal Router, ERC-7579) and is the reference for what the migrated shape looks like; migrating the other files to match is open follow-up work. `example-safe-exectransaction.json`, and the separate Balancer relayer descriptors referenced from the `layout`-on-scalar and `#.` scope-crossing discussions above, already use the current naming throughout, having been authored after the rename. ## Reference Implementation @@ -305,7 +326,9 @@ TBD ## Security Considerations -A `layout`/`switch`/`interaction` interpreter is new parsing surface on a hardware wallet, decoding attacker-influenced (calldata is provided by whoever submits the transaction) bytes. Implementations MUST bound recursion depth (embedded-calldata resolution, nested `switch`, and `interaction` can all recurse arbitrarily deep in principle), MUST treat any length (`lengthFrom`, or a `sequence`'s implicit `tillEnd` walk) that would read past the end of the underlying buffer as invalid input, and MUST fail closed — applying the [unknown selector](./erc-7730.md#unknown-selectors) fallback — rather than displaying a partially decoded or best-guess value when a `layout` or `switch` does not cleanly match the actual bytes. `operation` resolving to `"delegatecall"` is a particularly high-severity case of this: a wallet MUST fail closed exactly as hard for an unresolvable delegatecall target as it would for one with no descriptor at all, never falling back to treating it as a plain call. +A `layout`/`switch`/`interaction` interpreter is new parsing surface on a hardware wallet, decoding attacker-influenced (calldata is provided by whoever submits the transaction) bytes. Implementations MUST bound recursion depth (embedded-calldata resolution, nested `switch`, `pointer`, and `interaction` can all recurse arbitrarily deep in principle), MUST treat any length (`lengthFrom`, or a `sequence`'s implicit `tillEnd` walk) that would read past the end of the underlying buffer as invalid input, and MUST fail closed — applying the [unknown selector](./erc-7730.md#unknown-selectors) fallback — rather than displaying a partially decoded or best-guess value when a `layout` or `switch` does not cleanly match the actual bytes. `operation` resolving to `"delegatecall"` is a particularly high-severity case of this: a wallet MUST fail closed exactly as hard for an unresolvable delegatecall target as it would for one with no descriptor at all, never falling back to treating it as a plain call. + +`pointer` widens this surface specifically: a wallet MUST treat an offset (plus `destination`'s consumed width) that would read outside `containerPath`'s buffer as invalid input, the same fail-closed rule as any other out-of-bounds read. Recursion here is not merely theoretical: Safe's own `checkContractSignature` calls back into `isValidSignature` on the signing contract, which — if that contract is itself a Safe — recurses into that Safe's own `checkNSignatures` over its own `signatures` buffer, i.e. a `destination` that is itself another `pointer`-bearing `signatures` sequence. A wallet MUST bound this depth and fail closed past it, rather than parsing an attacker-supplied chain of nested signature buffers to unbounded depth. `initCode` template matching is exact-bytes (or exact-hash) matching against a fixed template. A wallet MUST NOT treat a partial or fuzzy prefix/suffix match as a match — an attacker who can get even one byte accepted as "close enough" could potentially get unrelated, unaudited bytecode displayed as if it were a known, trusted template. Authors MUST keep `templates` entries pinned to one specific compiler version and settings; the same source recompiled differently produces different bytecode and MUST be treated as an entirely distinct, separately-audited template, never as a "should still basically match" variant of an existing one. diff --git a/assets/erc-non-abi-dispatch/example-safe-exectransaction.json b/assets/erc-non-abi-dispatch/example-safe-exectransaction.json new file mode 100644 index 00000000000..34d929857d6 --- /dev/null +++ b/assets/erc-non-abi-dispatch/example-safe-exectransaction.json @@ -0,0 +1,151 @@ +{ + "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.schema.json", + + "$comment": "Worked example for the non-ABI-dispatch companion ERC, decoding Safe's execTransaction(...,bytes signatures) - the motivating case for the `pointer` layout node. Verified against Safe.sol's checkNSignatures/checkContractSignature and against a real transaction for the operation/data/EOA/APPROVED_HASH parts: Safe transaction hash 0xe6cffb80c9521e152bc97b2bee23140bad634ecb02f9d5dcd57320b1cea95b60 (Ethereum mainnet, executed 2026-03-27), Safe 0xa5C629E04E563355c30885B62928fd6E03558548 (itself co-owned by GnosisDAO's own Safe, 0x0DA0C3e52C977Ed3cBc641fF02DD271c3ED55aFe - confirmed via the Safe Transaction Service's owners index), delegatecalling MultiSend (0x9641d764fc13c8B624c04430C7356C1C7C8102e2) to batch 13 ERC-20 transfer calls. signatures decodes to three 65-byte records sorted ascending by signer address: v=1 (APPROVED_HASH) from 0x1B0C638616Ed79dB430Edbf549ad9512FF4a8ed1 with s=0, then two ordinary v=27 ECDSA records - all three independently confirmed against the Safe Transaction Service's own per-signer signatureType field. No v=0 (CONTRACT_SIGNATURE) record - the one that actually exercises `pointer` - was found despite sampling 268+ real confirmations from this same nested-Safe structure; that branch is verified against Safe.sol source only, not a mined transaction. See the ERC's Test Cases section.", + + "context": { + "$id": "Safe execTransaction", + "contract": { + "deployments": [ + { "chainId": 1, "address": "0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552" } + ] + } + }, + + "metadata": { + "owner": "Safe", + "contractName": "Safe (formerly Gnosis Safe)", + "info": { + "url": "https://docs.safe.global/advanced/smart-account-signatures" + } + }, + + "display": { + "formats": { + "execTransaction(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,bytes signatures)": { + "$id": "Safe Execute Transaction", + "intent": "Execute Safe transaction", + "fields": [ + { "path": "to", "label": "To", "format": "addressName" }, + { "path": "value", "label": "Value", "format": "amount" }, + { + "path": "operation", + "label": "Call type", + "layout": { + "type": "switch", + "expression": { "type": "uint", "bytes": 1 }, + "cases": { + "0x01": { "label": "Delegatecall — runs in this Safe's own storage and identity", "intent": "warning" }, + "$default": { "label": "Call", "intent": "info" } + } + } + }, + { + "path": "data", + "label": "Call data", + "format": "calldata", + "params": { + "calleePath": "to", + "amountPath": "value", + "operation": { + "expression": { "path": "operation" }, + "cases": { "0x01": "delegatecall", "$default": "call" } + } + }, + "$comment": "Confirmed against the cited transaction: operation=1, to=MultiSend, data resolves recursively into a multiSend(bytes) call batching 13 ERC-20 transfer entries - see example-safe-multisend.json for that construct on its own." + }, + { "path": "safeTxGas", "label": "Gas budget for the call", "format": "raw" }, + { "path": "baseGas", "label": "Fixed refund overhead", "format": "raw" }, + { "path": "gasPrice", "label": "Refund gas price", "format": "raw", "$comment": "0 in the cited transaction - no refund requested, the common case." }, + { "path": "gasToken", "label": "Refund token", "format": "addressName" }, + { "path": "refundReceiver", "label": "Refund recipient", "format": "addressName" }, + { + "path": "signatures", + "label": "Signatures", + "format": "custom", + "layout": { + "type": "sequence", + "element": { + "type": "object", + "fields": [ + { "name": "r", "schema": { "type": "bytes", "length": 32 } }, + { "name": "s", "schema": { "type": "bytes", "length": 32 } }, + { "name": "v", "schema": { "type": "uint", "bytes": 1 } } + ] + } + }, + "$comment": "Confirmed against the cited transaction: 195 bytes, exactly three 65-byte records, sorted ascending by signer address (Safe's own ordering rule)." + }, + { + "path": "signatures[].v", + "label": "Signature type", + "layout": { + "type": "switch", + "expression": { "type": "uint", "bytes": 1 }, + "cases": { + "0x00": { "label": "Contract signature (EIP-1271)", "intent": "info" }, + "0x01": { "label": "Pre-approved hash", "intent": "info" }, + "$default": { "label": "ECDSA signature", "intent": "info" } + } + }, + "$comment": "v is decoded by this same layout tree's own object node, not by ABI decoding - covered by this ERC's 'layout may anchor on an already-decoded scalar' rule extending to layout-produced scalars, not only ABI-produced ones. v=1 and v=27 (falling into $default here) are both confirmed against the cited transaction; v=0 is verified against Safe.sol only." + }, + { + "path": "signatures[].r", + "label": "Signer", + "switch": { + "expression": { "path": "v" }, + "cases": { + "0x00": { "layout": { "type": "object", "fields": [ + { "name": "reserved", "schema": { "type": "bytes", "length": 12 } }, + { "name": "ownerAddress", "schema": { "type": "address" } } + ]}}, + "0x01": { "layout": { "type": "object", "fields": [ + { "name": "reserved", "schema": { "type": "bytes", "length": 12 } }, + { "name": "ownerAddress", "schema": { "type": "address" } } + ]}}, + "$default": "reject" + } + }, + "$comment": "Only v=0 and v=1 repurpose r as a padded owner address (Safe.sol: 'the address of the contract that either approved the hash or is the execTransaction() function caller'); for ordinary ECDSA (v=27/28, or 31/32 for eth_sign) r is a genuine signature component with no meaningful address to show, hence reject rather than a guessed decode. v=1 confirmed against the cited transaction (r decodes to 0x1B0C638616Ed79dB430Edbf549ad9512FF4a8ed1, matching that record's confirmed signer); v=0 verified against source only." + }, + { + "path": "signatures[].r.ownerAddress", + "label": "Signer", + "format": "addressName", + "$comment": "Only resolves for v=0/v=1 records, where the sibling switch above matched - the same conditional-path pattern example-erc7579-execute.json already uses for executionCalldata[].callData under a specific mode.callType." + }, + { + "path": "signatures[].s", + "label": "Contract signature data", + "switch": { + "expression": { "path": "v" }, + "cases": { + "0x00": { "layout": { + "type": "pointer", + "bytes": 32, + "containerPath": "#.signatures", + "destination": { + "type": "object", + "fields": [ + { "name": "len", "schema": { "type": "uint", "bytes": 32 } }, + { "name": "data", "schema": { "type": "bytes", "lengthFrom": "len" } } + ] + } + }}, + "$default": "reject" + } + }, + "$comment": "The one field in this whole descriptor that needs `pointer`: for v=0, s is not a signature component at all - it is a byte offset into #.signatures (the same buffer this whole layout tree is already decoding), pointing past every fixed 65-byte record to a length-prefixed EIP-1271 signature blob shared by every contract-signer (Safe.sol's checkContractSignature, verified precisely: 'contractSignatureLen := mload(add(add(signatures, offset), 0x20))'). Not confirmed against a mined transaction - see this file's own top-level $comment and the ERC's Test Cases entry for why. reject for every other v: s is a genuine ECDSA/unused value there, never a pointer." + }, + { + "path": "signatures[].s.data", + "label": "Contract signature data", + "format": "raw", + "$comment": "Only resolves for v=0 records, past the pointer above. This is itself an EIP-1271 signature, resolved by calling isValidSignature on the owner contract at signatures[].r.ownerAddress - if that owner is itself a Safe, this recurses into another pointer-bearing signatures buffer one level down (see Security Considerations); this descriptor does not attempt that recursive resolution, only exposes the raw bytes." + } + ] + } + } + } +} From 597e002523e8239f5fe64b6ef32a953b4d6bc651 Mon Sep 17 00:00:00 2001 From: Alex Forshtat Date: Fri, 31 Jul 2026 13:55:14 +0200 Subject: [PATCH 09/23] Remove initCode/$fallback/pointer, migrate examples, add full companion schema initCode, $fallback, and pointer are reverted: real, live code paths are not the same as evidenced usage, and none of the three cleared that bar despite genuine search effort (see prior discussion). Their example files (deterministic-deployment-proxy, safe-exectransaction) are removed, and the spec text, Rationale, Security Considerations, and Test Cases are updated accordingly. Balancer's joinPool/exitPool Test Case entry is added, since those files already existed but had no prose write-up. All example descriptors are migrated to the ERC's current vocabulary (type/object/switch/expression/format:"calldata", $default inside cases) and validate cleanly against a new, full companion JSON Schema (erc7730-non-abi-dispatch.schema.json) built as a complete schema rather than a thin extension - composing new properties onto schemas closed with additionalProperties:false, or extending a schema reached only via $ref, does not validate the way a thin allOf+$ref extension would suggest (verified empirically against real tooling, not just by reading the spec). Fixed several real bugs surfaced only by validating against actual tooling: format:"array" was never a real ERC-7730 format (the Universal Router example now uses per-element switch via $index correlation instead); uint/bitfield byte widths were wrongly restricted to powers of two in the prose, contradicting the spec's own bitfield example; the switch case-value "format" branch didn't allow params. All $comment annotations are stripped from the example files. Co-Authored-By: Claude Sonnet 5 --- ERCS/erc-0000-custom-bytes-erc7730.md | 31 +- ERCS/erc-draft_non_abi_dispatch.md | 94 +- .../erc7730-non-abi-dispatch.schema.json | 1944 +++++++++++++++++ .../example-all-syntax-human.json5 | 314 --- .../example-balancer-relayer-library.json | 315 ++- .../example-balancer-relayer-multicall.json | 15 +- .../example-cctp-message.json | 120 +- ...xample-deterministic-deployment-proxy.json | 65 - .../example-eas-attestation.json | 33 +- .../example-erc7579-execute.json | 123 +- .../example-erc7683-order.json | 36 +- .../example-legacy-token.json | 24 +- .../example-reward-vault.json | 24 +- .../example-safe-exectransaction.json | 151 -- .../example-safe-multisend.json | 71 +- .../example-tiered-executor.json | 66 +- .../example-universal-router.json | 102 +- 17 files changed, 2673 insertions(+), 855 deletions(-) create mode 100644 assets/erc-non-abi-dispatch/erc7730-non-abi-dispatch.schema.json delete mode 100644 assets/erc-non-abi-dispatch/example-all-syntax-human.json5 delete mode 100644 assets/erc-non-abi-dispatch/example-deterministic-deployment-proxy.json delete mode 100644 assets/erc-non-abi-dispatch/example-safe-exectransaction.json diff --git a/ERCS/erc-0000-custom-bytes-erc7730.md b/ERCS/erc-0000-custom-bytes-erc7730.md index 16754b6d703..9e50e9357ef 100644 --- a/ERCS/erc-0000-custom-bytes-erc7730.md +++ b/ERCS/erc-0000-custom-bytes-erc7730.md @@ -12,21 +12,48 @@ requires: 7730 ## Abstract ## Motivation + +ERC-7730 provides a rich language for decoding the calldata inputs of smart contracts on Ethereum. + +In practice, there are multiple contracts in active use on Ethereum that implement alternative encoding for their inputs, and it is not possible to deprecate all of them for the purpose of promoting Clear Signing. + +Instead, we can add some features to ERC-7730 that would allow us to cover the most common non-standard encodings of smart contract inputs. + ## Specification -### `custom` & `layout` +### `layout` + +The main mechanism for declaring any parameter whose contents cannot be expressed using Solidity-friendly ABI-encoded data structures. ### `sequence` +The mechanism for declaring an iterative, array-like data structure not represented by an ABI-encoded `array` data layout. + +The elements count for `sequence` parameters is optional, and decoding may continue until the input bytes are exhausted. + +### `object` + +The mechanism for declaring an entry in a `sequence` data structure that is not represented by an ABI-encoded parameter. + ### `select` +The mechanism that allows the decoding to choose the format based on a certain parameter decoded previously. Represents a common pattern of carrying the decoding format flag separately form the data being decoded. + ### `interaction` +The mechanism to declare that some data represents an interaction with an external contract. + +This is an equivalent of `calldata` format from ERC-7730 for contracts that perform their own encoding of the calldata, or execute `delegatecall` and `staticcall` operations. + +### `$index` + +A mechanism for element in a `sequence` to reference their position for indexing into other `sequence` or array-like parameters. + ## Rationale ## Security Considerations ## Copyright -Copyright and related rights waived via [CC0](../LICENSE.md). \ No newline at end of file +Copyright and related rights waived via [CC0](../LICENSE.md). diff --git a/ERCS/erc-draft_non_abi_dispatch.md b/ERCS/erc-draft_non_abi_dispatch.md index 5534f2ac039..209f8077d90 100644 --- a/ERCS/erc-draft_non_abi_dispatch.md +++ b/ERCS/erc-draft_non_abi_dispatch.md @@ -12,9 +12,9 @@ requires: 7730 ## Abstract -[ERC-7730](./erc-7730.md) describes how to clear-sign structured data by decoding calldata as a Solidity ABI function call, then formatting the resulting named fields. This works as long as every value in the call is itself ABI-encoded. It breaks down for a common and growing class of contracts that accept one ABI-encoded `bytes` (or `bytes[]`) argument and then interpret its raw content using their own private encoding — packed structs, bit-packed flags, a tag that selects one of several possible payload shapes, or an offset pointing to data stored elsewhere in that same buffer. Gnosis Safe's `MultiSend`, Uniswap's Universal Router and hook addresses, and ERC-7579 modular accounts all do this, and none of it can be described in ERC-7730 today; such fields must be left as opaque, unreadable bytes. It also breaks down entirely for contract-creation calls, which have no function selector at all — a real, common, security-critical transaction type ERC-7730 has no vocabulary for. +[ERC-7730](./erc-7730.md) describes how to clear-sign structured data by decoding calldata as a Solidity ABI function call, then formatting the resulting named fields. This works as long as every value in the call is itself ABI-encoded. It breaks down for a common and growing class of contracts that accept one ABI-encoded `bytes` (or `bytes[]`) argument and then interpret its raw content using their own private encoding — packed structs, bit-packed flags, or a tag that selects one of several possible payload shapes. Gnosis Safe's `MultiSend`, Uniswap's Universal Router and hook addresses, and ERC-7579 modular accounts all do this, and none of it can be described in ERC-7730 today; such fields must be left as opaque, unreadable bytes. -This ERC adds three new keys to an ERC-7730 [field format specification](./erc-7730.md#field-format-specification) — `layout`, `switch`, and `interaction` — that let an author describe the internal structure of such a field, which structure applies where the field's shape depends on a tag value, and how to describe a call synthesized from already-decoded pieces rather than sliced out of contiguous bytes. Where a field's raw bytes already are a complete, contiguous call (selector and ABI-encoded arguments together), this ERC deliberately does not duplicate ERC-7730's own [embedded calldata](./erc-7730.md#embedded-calldata) mechanism (`format: "calldata"`) — it only extends that mechanism with one param, `operation`, for the one thing it cannot already express: a nested call that is a `DELEGATECALL` rather than a plain call. It also adds a reserved `$fallback` key to `display.formats` for contracts that dispatch with no selector at all. Everything else about ERC-7730 (context binding, metadata, top-level selector matching, path syntax) is unchanged; this ERC only extends what a single field's `path` can resolve into, and how a contract's entry point is matched in the first place. +This ERC adds three new keys to an ERC-7730 [field format specification](./erc-7730.md#field-format-specification) — `layout`, `switch`, and `interaction` — that let an author describe the internal structure of such a field, which structure applies where the field's shape depends on a tag value, and how to describe a call synthesized from already-decoded pieces rather than sliced out of contiguous bytes. Where a field's raw bytes already are a complete, contiguous call (selector and ABI-encoded arguments together), this ERC deliberately does not duplicate ERC-7730's own [embedded calldata](./erc-7730.md#embedded-calldata) mechanism (`format: "calldata"`) — it only extends that mechanism with one param, `operation`, for the one thing it cannot already express: a nested call that is a `DELEGATECALL` rather than a plain call. Everything else about ERC-7730 (context binding, metadata, top-level selector matching, path syntax) is unchanged; this ERC only extends what a single field's `path` can resolve into. ## Motivation @@ -24,8 +24,6 @@ Look at what a `display.formats` entry can describe today: a Solidity function s - **Uniswap's Universal Router `execute(bytes commands, bytes[] inputs)`** — `commands` is one raw opcode byte per sub-action (with a flag bit for "allowed to revert"); `inputs[i]` is separately ABI-encoded, but *which* ABI type it decodes as depends on the opcode at `commands[i]`. - **ERC-7579 modular accounts, `execute(bytes32 mode, bytes executionCalldata)`** — `mode` packs five sub-fields into one word; `executionCalldata`'s shape (a single packed call, or an ABI-encoded array of calls) depends on one byte of `mode`. - **Uniswap v4 hook addresses** — up to 14 independent permission flags (`beforeSwap`, `afterSwap`, and others) live in specific low-order bits of the 160-bit hook address itself; the same address value is simultaneously "an address" and "a bitmask," with no byte alignment between the two meanings. -- **Safe's `execTransaction(...,bytes signatures)`** — `signatures` is a sequence of fixed 65-byte records, but a record signing with a contract (rather than an EOA) repurposes one of its own fields as an offset into a shared tail region appended after every record, where the actual, variable-length signature data lives — the one shape in this ERC's entire survey that needs genuine pointer-style indirection *within* a custom-packed buffer, not just a fixed or tag-selected structure. -- **Contract-creation transactions** — a `CREATE`/`CREATE2` deployment, or a generic deterministic-deployment factory taking raw bytecode as an argument, has no function selector at all. There is nothing for ERC-7730's selector-matching to match against, and no vocabulary for describing constructor arguments appended to, or embedded within, compiler-specific creation bytecode. None of this is exotic or rare. It is how batching, modular accounts, and generic-purpose routers already work across the ecosystem, and account-abstraction adoption is only going to produce more of it. A wallet with no way to describe these fields has no way to clear-sign them beyond showing raw hex — which is exactly the blind, trust-me signing experience ERC-7730 exists to eliminate. @@ -41,7 +39,7 @@ This ERC defines three additional keys usable in an ERC-7730 [field format speci `layout` describes the internal byte structure of a `bytes` field. Its value is a *layout node*. A layout node is one of the following types: -**Anchoring `layout` on an already-decoded scalar.** `layout` is normally attached to a field whose raw `bytes` have not been interpreted yet. It MAY also be attached to a field whose value was already produced by ordinary Solidity ABI decoding, or by an enclosing `object`/`bitfield` layout node — [Path addressing](#path-addressing) already treats the two sources the same way — if that value's declared or inferred type is a single-word elementary type: `uintN`/`intN`, `bool`, `address`, or a fixed-size `bytesN`. In that case the layout tree operates on the value's canonical 32-byte big-endian encoding as its buffer, exactly as if those 32 bytes had been sliced out of a larger one. This is not extended to dynamic types (`bytes`, `string`, arrays, tuples) — no known case needs it, and "canonical encoding" is not a single well-defined byte sequence for them the way it is for a 32-byte word. See [Rationale](#rationale) for the motivating case. +**Anchoring `layout` on an already-decoded scalar.** `layout` is normally attached to a field whose raw `bytes` have not been interpreted yet. It MAY also be attached to a field whose value was already produced by ordinary Solidity ABI decoding, if that value's declared type is a single-word elementary type — `uintN`/`intN`, `bool`, `address`, or a fixed-size `bytesN`. In that case the layout tree operates on the value's canonical 32-byte big-endian ABI-word encoding as its buffer, exactly as if those 32 bytes had been sliced out of a larger one. This is not extended to dynamic types (`bytes`, `string`, arrays, tuples) — no known case needs it, and "canonical encoding" is not a single well-defined byte sequence for them the way it is for a 32-byte word. See [Rationale](#rationale) for the motivating case. **Primitive nodes** @@ -52,7 +50,7 @@ This ERC defines three additional keys usable in an ERC-7730 [field format speci { "type": "bool" } ``` -`uint.bytes` is the width in bytes (1, 2, 4, 8, 16, or 32). `endian` is `"be"` or `"le"`, defaulting to `"be"` — every known EVM-side use case packs data big-endian, matching Solidity's own word layout; `"le"` exists only so this vocabulary does not need to change if a future non-EVM companion reuses it. `address` is sugar for a 20-byte `bytes` node. `bytes.length` MAY be a fixed integer, a `lengthFrom` reference to an earlier sibling field's decoded value (see `object` below), or omitted entirely on the last field of an `object`, meaning "consume whatever bytes remain in the enclosing buffer." +`uint.bytes` is the width in bytes, from 1 to 32. `endian` is `"be"` or `"le"`, defaulting to `"be"` — every known EVM-side use case packs data big-endian, matching Solidity's own word layout; `"le"` exists only so this vocabulary does not need to change if a future non-EVM companion reuses it. `address` is sugar for a 20-byte `bytes` node. `bytes.length` MAY be a fixed integer, a `lengthFrom` reference to an earlier sibling field's decoded value (see `object` below), or omitted entirely on the last field of an `object`, meaning "consume whatever bytes remain in the enclosing buffer." **`bitfield`** — a fixed-width value (same width rules as `uint`) whose individual bits or bit ranges each carry independent, named meaning: @@ -91,16 +89,6 @@ Fields are read strictly in order, byte-for-byte, with no alignment padding and `"tillEnd"` is the only count mode this ERC defines, because it is the only one any known real case needs — Safe MultiSend repeats its record `object` until `transactions` runs out; Universal Router repeats a `switch` (below) once per byte of `commands`. Other termination modes (an explicit element count, a byte-length prefix) are left for a future revision if a real case needs them, rather than specified speculatively now. -**`pointer`** — a fixed-width value that is not itself the data of interest, but an offset to where that data actually lives, elsewhere in a named buffer: - -```json -{ "type": "pointer", "bytes": 32, "containerPath": "#.signatures", "destination": } -``` - -`bytes` is the width of the offset value, read at this node's normal sequential position — that read is all that counts toward the enclosing `object`/`sequence`'s byte accounting; nothing else about `pointer` consumes bytes at the cursor's current position. `containerPath` is REQUIRED and MUST resolve to a `bytes`-typed value (a wallet MUST reject a `containerPath` that resolves to anything else, rather than guess); it names the buffer the offset is a position *into* — not necessarily the buffer this `pointer` node itself is being read from, since a fixed-size record containing a pointer is often only one of several records sharing one common tail region. `destination` is any layout node, decoded starting at byte position `containerPath`'s buffer + the offset value just read; its decoded result becomes this field's value. There is no `anchor`-style mode parameter: the offset is always relative to the start of whatever buffer `containerPath` names, because that is the only relationship any known real case has — an implicit, unstated anchor was considered and rejected in favor of `containerPath` precisely because "relative to what" has no safe default once a real case is examined closely enough (see [Rationale](#rationale)). - -This is Safe's `execTransaction` `signatures` field: a `sequence` of fixed 65-byte `(r, s, v)` records, where a record with `v == 0` repurposes `s` — ordinarily half of an ECDSA signature — as a `pointer` into the tail of the same `signatures` buffer, at which a length-prefixed EIP-1271 signature blob lives. See [Test Cases](#test-cases). - **`$index`** — inside the `element` of a `sequence`, or of a field iterated via `format: "array"` (a field whose value is already an ABI-decoded array, walked element-by-element rather than by consuming bytes), `$index` is a reserved token equal to the zero-based position of the element currently being processed. It is usable inside a `path` to correlate that element with the same-indexed value of a *different* sibling field — e.g. `commands[$index]`, read from inside an `inputs` element's `switch`, is the byte of the sibling `commands` sequence at the same position as the `inputs` element currently being resolved. This is Universal Router's actual structure: `commands` (raw `bytes`, one command per byte, parsed as a `sequence`) and `inputs` (already-decoded `bytes[]`, walked via `format: "array"`) are two separate fields advanced in lockstep by the same index, not one field nested inside the other — `$index` is what ties them together. **Nested calldata reuses ERC-7730's own mechanism, not a layout node.** A field whose bytes — however they were reached, whether by ordinary ABI decoding or by a parent `layout` — are themselves a complete, contiguous call (a selector followed by ABI-encoded arguments) is described with ERC-7730's own [embedded calldata](./erc-7730.md#embedded-calldata) mechanism, `format: "calldata"`, addressed by an ordinary `path` into the (possibly `layout`-decoded) structure — see [Path addressing](#path-addressing), which already lets a `path` reach into `object` fields and `sequence` elements the same way it reaches into ABI-decoded ones. This is how Safe MultiSend's inner calls are described: the `data` field is parsed as plain `bytes` (its `dataLength` already covers the full selector-plus-arguments blob, unmodified from how Safe's own contract packs it), and a sibling top-level field entry with `path: "transactions[].data"` and `format: "calldata"` resolves it, using `calleePath`/`amountPath` to point back at `transactions[].to`/`transactions[].value`. The same pattern describes ERC-7579's batched executions: each element of the ABI-decoded `Execution[]` array has an ordinary `bytes callData` member, resolved by a field entry with `path: "executionCalldata[].callData"` and `format: "calldata"`, `params: {"calleePath": "executionCalldata[].target"}` — addressed at the same array index as its sibling `target`, the same by-index correlation ERC-7730 already uses for [array-valued formatting parameters](./erc-7730.md#field-format-specification). See [Rationale](#rationale) for why this ERC does not define its own parallel node for this instead. @@ -113,41 +101,11 @@ This ERC extends `format: "calldata"`'s `params` with one new, optional key: `op `expression`/`cases` (including the reserved `$default` case key) follow exactly the same rules as `switch` (below): a wallet MUST treat a tag value with no matching case, and no `$default` case supplied, as an [unknown selector](./erc-7730.md#unknown-selectors). When resolved to `"delegatecall"`, a wallet MUST make clear that the callee executes in the calling contract's own storage and identity (`DELEGATECALL` semantics), and MUST warn as strongly as it would for a raw, undescribed `delegatecall` if `to`'s descriptor cannot be resolved — a delegatecall to an unknown or unaudited target is a full account takeover, not a benign unknown call. Resolution of `to`'s own `display.formats` entry is otherwise unaffected by `operation`; only the execution-context semantics differ, not how the target function is looked up. -**`initCode`** — the bytes at this position are a contract-creation payload (raw creation bytecode, optionally followed by, or wrapped around, constructor arguments), matched against a small, explicit set of known, audited templates: - -```json -{ "type": "initCode", - "length": 1497, - "templates": [ - { - "id": "example-template", - "prefix": { "hash": "0x", "length": 1477 }, - "args": { "abiType": "(address singleton)" } - } - ], - "default": "reject" -} -``` - -`initCode` is byte-consuming like `bytes` (`length` / `lengthFrom` / implicit remainder). Once its bytes are consumed, a wallet MUST attempt each entry in `templates`, **in declaration order**, and use the first that structurally matches: - -1. Compare the first `prefix.length` bytes of the consumed region against `prefix`: either byte-for-byte, if `prefix.literal` (an inline hex string) is given, or by comparing `keccak256` of that exact-length slice against `prefix.hash`. Exactly one of `literal` or `hash`+`length` MUST be present. If the consumed region is shorter than `prefix.length`, or the comparison fails, this template does not match; proceed to the next. -2. If the template also declares a `suffix` (same `literal` or `hash`+`length` shape), compare it the same way against the *last* `suffix.length` bytes of the consumed region. If it does not match, this template does not match; proceed to the next. -3. Otherwise, this template matches. Apply `args` — either `{ "abiType": "" }` (ordinary ABI decoding) or `{ "layout": }` (this ERC's own layout language) — to the bytes strictly between the end of `prefix` and the start of `suffix` (or, if no `suffix` is declared, to everything after `prefix` through the end of the consumed region). - -If no template matches, a wallet MUST apply `default: "reject"` — the same [unknown selector](./erc-7730.md#unknown-selectors) fallback used everywhere else in this ERC: display a safe fallback and MUST NOT guess at a decoding. `"reject"` is the only defined value for `default`. - -`prefix`'s two forms exist for different template sizes: `literal` is legible and auditable at a glance for short, fixed templates (a minimal proxy's handful of bytes); `hash` avoids inlining an entire compiled contract's creation bytecode for large templates, at the cost of the match no longer being visually verifiable from the descriptor text alone. Authors are responsible for computing `hash` values from the exact compiler output (version and settings) they intend to match — a single compiler flag change produces different bytecode and a different hash, silently falling through to `reject` rather than misdecoding. - -### `$fallback` - -Some contracts — notably generic deterministic-deployment proxies (see [Test Cases](#test-cases)) — expose no ABI-selected function at all; every call reaches a single raw fallback. `display.formats` MAY use the reserved key `"$fallback"` for exactly this case: a [structured data format specification](./erc-7730.md#structured-data-format-specification) matched whenever calldata does not correspond to any selector-based entry in the same file, whose `fields`/`layout` describe the entirety of `data` directly, with no selector-stripping step. `$fallback` MUST NOT be combined with selector-keyed entries that could themselves match the same calldata; wallets MUST prefer a matching selector-keyed entry over `$fallback` when both are present and only one is intended to apply. This does not change `display.formats` selector matching for any contract that has a normal ABI — it only gives contracts that genuinely have none a way to be described at all. - -**Byte-width invariant.** Every `layout` node either consumes a well-defined, computable number of bytes from the buffer (all of the types above), or is explicitly declared non-consuming (`switch`'s path-sourced expression form, below, which reads an already-resolved value instead of parsing bytes; and `pointer`'s `destination`, which is decoded at a computed position in a named buffer rather than at the cursor). No node type may be ambiguous about whether, or how much, it advances the *cursor of the buffer it is being read from* — `pointer` does not violate this: its own `bytes` width is a fixed, ordinary consumption at its own position, and `destination`'s decode is a separate, explicitly-declared side read against `containerPath`'s buffer, not a claim about how far the enclosing `object`/`sequence` advances. A `layout` anchored directly on an already-decoded scalar (above) is a degenerate, trivially-satisfying case of this same invariant: there is no enclosing buffer to consume from or leave a remainder in, the buffer *is* the value's fixed 32-byte encoding in full, and the top-level node MUST consume it completely, the same rule applied everywhere else. +**Byte-width invariant.** Every `layout` node either consumes a well-defined, computable number of bytes from the buffer (all of the types above), or is explicitly declared non-consuming (only `switch`'s path-sourced expression form, below, which reads an already-resolved value instead of parsing bytes). No node type may be ambiguous about whether, or how much, it advances the cursor. A `layout` anchored directly on an already-decoded scalar (above) is a degenerate, trivially-satisfying case of this same invariant: there is no enclosing buffer to consume from or leave a remainder in, the buffer *is* the value's fixed 32-byte encoding in full, and the top-level node MUST consume it completely, the same rule applied everywhere else. ### Path addressing -Paths extend into `layout`-decoded fields the same way they already extend into ABI-decoded struct and array fields: by name for `object` and `bitfield` fields, by index for `sequence` elements. For example, given the `object` above, `#.transactions[0].to` refers to the `to` field of the first record. This is also what makes nested calldata resolvable without a dedicated layout node: a sibling top-level field entry can address `#.transactions[].data` directly and apply `format: "calldata"` to it, exactly as it would to any ordinary ABI-decoded `bytes` field. Similarly, once an `initCode` node's `templates` entry has matched, its `args` fields are addressed by name (or by tuple position, for an unnamed `abiType`), exactly as they would be for any other matched call. +Paths extend into `layout`-decoded fields the same way they already extend into ABI-decoded struct and array fields: by name for `object` and `bitfield` fields, by index for `sequence` elements. For example, given the `object` above, `#.transactions[0].to` refers to the `to` field of the first record. This is also what makes nested calldata resolvable without a dedicated layout node: a sibling top-level field entry can address `#.transactions[].data` directly and apply `format: "calldata"` to it, exactly as it would to any ordinary ABI-decoded `bytes` field. **`#.` crosses `switch`/`layout` scope boundaries.** A `switch` case that decodes its payload into a tuple (`abiType`, or the tuple-plus-`intent`-plus-`fields` shorthand) introduces a new, local field scope for that case's own `fields`: a relative path there resolves against the just-decoded tuple, not the outer call. `#.`, however, MUST still resolve against the absolute root of the entire structured data — the outer call's own top-level decoded parameters — regardless of how many `switch`/`layout` scopes deep the path is written. This is needed whenever a case's decoded value must be paired with a sibling of the field the `switch` is attached to, not a sibling within the tuple itself — for instance, resolving a `switch`-matched `amountsIn[i]` against a token list that lives one level up, alongside `userData` rather than inside it (see the [`joinPool`/`exitPool` Test Case](#test-cases)). Base ERC-7730's own examples of `#.` never exercise this — every one resolves a flat, top-level sibling — so this ERC states the cross-scope behavior explicitly rather than leaving it to be inferred. @@ -236,15 +194,9 @@ A wallet MUST resolve the matched target's own `intent`, `interpolatedIntent`, a **Why `bitfield` is a distinct node type rather than a parameter on `uint`.** `object` and `sequence` both assume byte-aligned, non-overlapping fields — that assumption is load-bearing throughout the rest of this ERC (it's what makes the byte-width invariant a simple sum of child widths). `bitfield`'s named sub-fields can overlap arbitrarily within a shared width and carry no byte alignment at all, so keeping it a separate, clearly-labeled type (rather than, say, a `bits` option quietly attached to `uint`) makes it visually obvious, at the point a field is declared, that its sub-fields don't follow the rest of the language's byte-aligned norm. The motivating real case is Uniswap v4: hook contract addresses encode up to 14 independent permission flags in specific low-order bits of the 160-bit address value itself, verified against `Hooks.sol` and Uniswap's own v4 documentation. -**Why `initCode` is its own node type rather than a `switch` expression-sourcing mode.** An earlier version of this design considered folding creation-bytecode matching into `switch` as a fourth expression-sourcing mode (hash of a length-prefix of the buffer). That would have worked for the simplest case, but it doesn't generalize cleanly: some real creation-code templates append constructor arguments after a fixed prefix (a generic factory concatenating a template with a trailing ABI-encoded argument), while others — [EIP-1167](https://eips.ethereum.org/EIPS/eip-1167) minimal proxies, verified against the standard's own bytecode listing — embed their one constructor-equivalent value (the implementation address) *between* a fixed prefix and a fixed suffix, with no ABI encoding at all. Expressing both shapes through a single scalar expression and a flat `cases` map would have needed the expression itself to somehow also carry "and here's where the matched region ends", which is exactly the kind of implicit, easy-to-get-wrong behavior the byte-width invariant exists to rule out elsewhere in this ERC. A dedicated node with explicit `prefix`/`suffix`/`args` fields makes the matched region's boundaries an explicit, checkable part of each template entry instead. - -**Why `pointer` is its own node type, and why it needs `containerPath` rather than an implicit anchor.** Every other node in this ERC's layout language reads strictly forward from the cursor, which is what makes the byte-width invariant a simple, checkable sum. Safe's `execTransaction` breaks that on its own terms, not by choice of this ERC: verified against `Safe.sol`'s `checkNSignatures`/`checkContractSignature`, a signature record with `v == 0` repurposes its own `s` field — ordinarily half an ECDSA signature — as a byte offset into the *same* `signatures` buffer, pointing past all the fixed 65-byte records to a length-prefixed EIP-1271 blob shared by every contract-signer. No combination of `object`, `sequence`, or `bytes.lengthFrom` expresses "this value is a position to seek to, not data to read here" — `lengthFrom` only ever sizes a field from an already-read sibling's value, never repositions the cursor. An earlier version of this design left the offset's anchor implicit — always relative to the root of the enclosing `layout` tree — reasoning that only one anchor is evidenced, the same restraint applied to `sequence`'s `count`. That was reconsidered: `pointer` can appear nested inside a `sequence`'s `element`, several scopes below whatever "the enclosing layout tree" means informally, and Safe's own offset is relative to the *entire* `signatures` array, not to the 65-byte record the pointer happens to sit inside — exactly the kind of local-vs-root ambiguity this ERC's [Path addressing](#path-addressing) `#.` rule already had to resolve explicitly once, for a different construct. `containerPath` removes the ambiguity by construction rather than defining a fallback rule for it: every `pointer` states, visibly, which buffer it reads against, reusing the same path-reference idiom `format: "calldata"`'s `calleePath`/`amountPath`/`spenderPath` already established, rather than inventing an implicit-default rule that would need its own careful, easy-to-get-wrong specification. - -**Why `$fallback` is needed at all.** Every other selector-related mechanism in ERC-7730 and this ERC assumes a 4-byte selector exists to be computed and matched. The generic deterministic-deployment proxy that motivates `initCode` — the same, single contract, deployed at the identical address on nearly every EVM chain, that many real, well-known contracts (including Uniswap's own Permit2) are deployed through — has no selector at all; its calldata is `salt ‖ initCode`, dispatched by a raw fallback. Without `$fallback`, `initCode` would have no contract it could actually be demonstrated on, since every other candidate factory this research pass found either has a normal ABI wrapper around its bytecode argument (already describable with plain `abiType`, no new construct needed) or turned out, on inspection, to build its creation code internally rather than receiving it as literal calldata at all. - **Why `layout` may anchor on an already-decoded scalar.** Balancer's `BalancerRelayer`/`BatchRelayerLibrary` — the `multicall`-based contract Balancer's own frontend and third-party "zap" integrations use to chain a `joinPool`/`exitPool`/`swap` sequence in one transaction — accepts ordinary ABI-decoded `uint256` amount fields (`maxAmountsIn[i]`, `outputReference`) that are *sometimes* not amounts at all: if the top 12 bits equal `0xba1`, the value is a "chained reference," a pointer to a storage slot the relayer will populate from an *earlier* step's output during execution of this same transaction, not a literal quantity (verified against `BaseRelayerLibraryCommon.sol`'s `_isChainedReference`). Every other tag-dispatch case in this ERC (EAS's `schema`, ERC-7683's `orderDataType`, ERC-7579's `mode`) reads its tag from a field genuinely separate from the value it governs. This one does not — the tag and the value it governs are the same field, examined under a mask. Rather than invent a self-referencing mode of `switch`'s path-sourced form (which would need its own reasoning about read/write ordering and cycles), this ERC reuses the existing, already-masked, inline `switch` node verbatim, and only generalizes *where* a `layout` tree is allowed to start: on the canonical encoding of a value ABI decoding already produced, not only on bytes still waiting to be parsed. This keeps one masking mechanism in the ERC instead of two. -**Why `$default` moved inside `cases` instead of staying a sibling key.** Originally `default` sat next to `cases`, and every example gave it the value `"reject"` — implying, without saying so, that `default` was structurally special: a fail-closed escape hatch, not really "a case" the way the entries in `cases` are. The chained-reference case above breaks that implication: its `$default` branch is the *common*, expected value (an ordinary amount), and the entry that needs special handling is the sentinel — an inversion of every prior example. Once `default` can legitimately hold a full decode instead of only `"reject"`, it is not structurally different from any other entry in `cases` — it only differs in its matching rule ("nothing else matched" instead of "matched this literal"). Moving it into `cases` under a reserved `$default` key makes that equivalence explicit, and matches the reserved-token convention this ERC already uses for `$index` and `$fallback`, rather than introducing a third way of marking a key as reserved. `initCode`'s `default` is deliberately left as a sibling key, unrenamed: it has no `cases` map to fold into (`templates` is a list, matched structurally, not a value map), and its value is, and remains, restricted to the literal `"reject"` — it was never a case candidate for this treatment in the first place. +**Why `$default` moved inside `cases` instead of staying a sibling key.** Originally `default` sat next to `cases`, and every example gave it the value `"reject"` — implying, without saying so, that `default` was structurally special: a fail-closed escape hatch, not really "a case" the way the entries in `cases` are. The chained-reference case above breaks that implication: its `$default` branch is the *common*, expected value (an ordinary amount), and the entry that needs special handling is the sentinel — an inversion of every prior example. Once `default` can legitimately hold a full decode instead of only `"reject"`, it is not structurally different from any other entry in `cases` — it only differs in its matching rule ("nothing else matched" instead of "matched this literal"). Moving it into `cases` under a reserved `$default` key makes that equivalence explicit, and matches the reserved-token convention this ERC already uses for `$index`, rather than introducing a second way of marking a key as reserved. **Why two new case-value kinds, `format` and `label`, and not one.** The chained-reference case needs both ends of the same problem solved: its `$default` branch has nothing unusual to say — the value is exactly what its ABI type already claims, so it needs a way to say "stop, this is fine, just display it normally" without an author re-deriving `intent`/`fields` for a plain amount. Its sentinel branch has the opposite problem: there is no value to compute or format at all — the real quantity is written by an earlier step's execution, after signing, and no construct in this ERC (or in ERC-7730 itself) can display a value that does not yet exist. `format` and `label` are the minimum needed for each half: `format` hands the value to ERC-7730's own existing formatting, unchanged; `label` displays fixed text in place of a value, for exactly the case where "unknown, and unknowable ahead of time" is itself the only honest thing to show. Neither is specific to Balancer — both are general terminal case-value kinds usable anywhere a `switch` case has nothing left to structurally decode, which is precisely the same "narrow but evidence-motivated" bar every other construct in this ERC was held to. @@ -254,11 +206,11 @@ A wallet MUST resolve the matched target's own `intent`, `interpolatedIntent`, a ## Backwards Compatibility -This ERC only adds new, optional keys to a field format specification (`layout`, `switch`, `interaction`), plus one new, optional param (`operation`) to ERC-7730's own `format: "calldata"`, plus two new terminal case-value kinds (`format`, `label`) usable inside any `switch`'s `cases`. A descriptor that does not use them is unaffected, and a wallet implementing only ERC-7730 without this extension can safely ignore fields that use them, applying the existing [unknown field / raw fallback](./erc-7730.md) behavior. `$default` replacing a sibling `default` key is a pre-Draft naming change with no live adopters to migrate — see the [naming note](#test-cases) on this file's own not-yet-migrated example descriptors. +This ERC only adds new, optional keys to a field format specification (`layout`, `switch`, `interaction`), plus one new, optional param (`operation`) to ERC-7730's own `format: "calldata"`, plus two new terminal case-value kinds (`format`, `label`) usable inside any `switch`'s `cases`. A descriptor that does not use them is unaffected, and a wallet implementing only ERC-7730 without this extension can safely ignore fields that use them, applying the existing [unknown field / raw fallback](./erc-7730.md) behavior. ## Test Cases -Seven of the nine examples below are real, mined transactions, decoded from raw calldata (not an explorer's rendered summary) and cross-checked against at least one independent source. An eighth demonstrates `initCode`/`$fallback` against real, named, well-known contracts rather than one specific transaction. The ninth, `TieredExecutor`, is explicitly a made-up contract — see its own description below and the caveat in [Rationale](#rationale). Each is a full, standalone ERC-7730 descriptor file under [`../assets/erc-non-abi-dispatch/`](../assets/erc-non-abi-dispatch/) rather than a snippet, so it can be read with all the surrounding `context`/`metadata`/`display` structure intact. +Seven of the eight examples below are real, mined transactions, decoded from raw calldata (not an explorer's rendered summary) and cross-checked against at least one independent source. The eighth, `TieredExecutor`, is explicitly a made-up contract — see its own description below and the caveat in [Rationale](#rationale). Each is a full, standalone ERC-7730 descriptor file under [`../assets/erc-non-abi-dispatch/`](../assets/erc-non-abi-dispatch/) rather than a snippet, so it can be read with all the surrounding `context`/`metadata`/`display` structure intact. ### Safe `MultiSend` @@ -266,14 +218,6 @@ Ethereum mainnet, tx [`0x414ae5aaff729927d663ccaa027ea2284e47fa546cb73ce1dee481e Full descriptor: [`example-safe-multisend.json`](../assets/erc-non-abi-dispatch/example-safe-multisend.json). -### Safe `execTransaction` (`pointer`) - -Ethereum mainnet, Safe transaction hash [`0xe6cffb80c9521e152bc97b2bee23140bad634ecb02f9d5dcd57320b1cea95b60`](https://etherscan.io/tx/0xe6cffb80c9521e152bc97b2bee23140bad634ecb02f9d5dcd57320b1cea95b60), executed 2026-03-27. The Safe at `0xa5C629E04E563355c30885B62928fd6E03558548` — itself co-owned by GnosisDAO's own Safe (`0x0DA0C3e52C977Ed3cBc641fF02DD271c3ED55aFe`), confirmed via the Safe Transaction Service's `owners` index, which lists 14 Safes GnosisDAO's Safe is itself an owner of — delegatecalls (`operation=1`) `MultiSend` (`0x9641d764fc13c8B624c04430C7356C1C7C8102e2`) to batch 13 ERC-20 `transfer` calls, the buffer consumed exactly. `signatures` (195 bytes, three 65-byte records, sorted ascending by signer address) decodes to: record 0, `v=1` (`APPROVED_HASH`) from `0x1B0C638616Ed79dB430Edbf549ad9512FF4a8ed1`, `r` holding that same address and `s` unused (zero); records 1 and 2, ordinary `v=27` ECDSA signatures. All three are independently confirmed against the Safe Transaction Service's own per-signer `signatureType` field (`APPROVED_HASH`, `EOA`, `EOA`). - -No `v=0` (`CONTRACT_SIGNATURE`) record — the one that actually exercises `pointer` — was found despite a real search: GnosisDAO's Safe co-signs at least 268 executed transactions across the Safes it owns (sampled directly via the Transaction Service), and every sampled confirmation is `EOA` or `APPROVED_HASH`. This suggests `approveHash` (a separate, simpler pre-validation transaction, surfacing as an ordinary `v=1` record in the child Safe) is how nested-Safe co-signing is actually done in practice, even though `CONTRACT_SIGNATURE` is a real, protocol-supported, currently-reachable path — verified directly against `Safe.sol`'s `checkNSignatures`/`checkContractSignature` (see [Rationale](#rationale)), not against a mined transaction. The descriptor's `pointer`-driven branch is marked accordingly. - -Full descriptor: [`example-safe-exectransaction.json`](../assets/erc-non-abi-dispatch/example-safe-exectransaction.json). - ### Uniswap Universal Router Ethereum mainnet, tx [`0x3805667353244e8fb763d50b7dd3bdb8f176119b44fdbd0a4ad5629d851ebbba`](https://etherscan.io/tx/0x3805667353244e8fb763d50b7dd3bdb8f176119b44fdbd0a4ad5629d851ebbba), calling `execute(bytes,bytes[],uint256)` on the Universal Router at `0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af`. `commands = 0x000004`: command 0 (`0x00`, `V3_SWAP_EXACT_IN`) sends `amountIn=3425828840000000000000` EURe through the path `EURe → EUR0 → EURC` with `payerIsUser=true`; command 1 (`0x00` again) swaps `amountIn=5138743260000000000000` EURe directly to EURC; command 2 (`0x04`, `SWEEP`) sweeps native ETH with `amountMinimum=0` back to the swapper. All three command bytes had their top (revert-flag) bit unset; token identities and pool fees were confirmed independently via each token's `symbol()`/`decimals()`. @@ -290,13 +234,13 @@ Full descriptor: [`example-erc7579-execute.json`](../assets/erc-non-abi-dispatch Base → Ethereum, 50,000 USDC. Burn tx [`0x178632412a0eb4e642bfe30b1f80d0a4799ab400d4d1c76702ba01ba1458b57f`](https://basescan.org/tx/0x178632412a0eb4e642bfe30b1f80d0a4799ab400d4d1c76702ba01ba1458b57f) on Base; mint tx [`0xa5ab46a57e89fe110df3269065c0c07a394f22fe7a769bb916a547c7c1b3e99f`](https://etherscan.io/tx/0xa5ab46a57e89fe110df3269065c0c07a394f22fe7a769bb916a547c7c1b3e99f) on Ethereum. The 248-byte `message` decodes to `sourceDomain=6` (Base), `destinationDomain=0` (Ethereum), `nonce=764152`, `sender`/`recipient` as the two chains' TokenMessenger contracts, `destinationCaller=0x0` (permissionless relay); the nested `messageBody` decodes to `burnToken` = Base USDC, `mintRecipient`/`messageSender` both the same self-relaying address, `amount=50000000000` (50,000 USDC). The 132-byte `messageBody` is consumed exactly. -Full descriptor: [`example-cctp-message.json`](../assets/erc-non-abi-dispatch/example-cctp-message.json). Note the descriptor's `context.contract` address is a placeholder — see the file's own `$comment`. +Full descriptor: [`example-cctp-message.json`](../assets/erc-non-abi-dispatch/example-cctp-message.json). Note the descriptor's `context.contract` address (`0xYourMessageTransmitterAddress`) is a placeholder — confirm the real deployment address for your target chain against [Circle's own docs](https://developers.circle.com/cctp/evm-smart-contracts) before use. ### EAS attestation Optimism mainnet, schema `#78` (UID `0xfdcfdad2dbe7489e0ce56b260348b7f14e8365a8a325aef9834818c00d46b31b`), string `string rpgfRound,address referredBy,string referredMethod` — Optimism's RetroPGF badgeholder-referral schema. Attestation [`0x1a7a222934cbab53dd1c8e85d34e5fdd6d17cfd62a18ad871e4bec4705fdaa41`](https://optimism.easscan.org/attestation/view/0x1a7a222934cbab53dd1c8e85d34e5fdd6d17cfd62a18ad871e4bec4705fdaa41), tx `0x820e5b8404f1ec62b47459e538151e54fb598b729dcb461087456bb856abf595`, decodes to `rpgfRound="4"`, `referredBy=0x0000000000000000000000000000000000000342`, `referredMethod="Friend"`. `schema` and `data` are already-decoded ABI sibling fields of `attest()`'s own parameters, so no `layout` node is needed at all here — just `switch` sourced from a path. (A simpler, single-field schema also exists at scale — Coinbase's "Verified Account" schema, UID `0xf8b05c79f090979bf4a80270aba232dff11a10d9ca55c4f88de95317970f0de9`, `bool verifiedAccount`, 720,000+ attestations on Base — useful as a minimal case, but the RetroPGF one exercises both static and dynamic ABI types.) -Full descriptor: [`example-eas-attestation.json`](../assets/erc-non-abi-dispatch/example-eas-attestation.json). Note the descriptor's `context.contract` address is a placeholder — see the file's own `$comment`. +Full descriptor: [`example-eas-attestation.json`](../assets/erc-non-abi-dispatch/example-eas-attestation.json). Note the descriptor's `context.contract` address (`0xYourEASAddress`) is a placeholder — confirm the real deployment address for your target chain against [attest.org's own docs](https://docs.attest.org) before use. ### ERC-7683 order @@ -304,13 +248,11 @@ Base mainnet, tx [`0x73b3ca11e3733c78db8365086f64874879fb3a859b21c960ec072c2a95c Full descriptor: [`example-erc7683-order.json`](../assets/erc-non-abi-dispatch/example-erc7683-order.json). -### Deterministic deployment proxy (`initCode` / `$fallback`) +### Balancer Relayer `joinPool`/`exitPool` -The generic deterministic-deployment proxy at `0x4e59b44847b379578588920cA78FbF26c0B4956C` ("Nick's method") is deployed at this identical address on nearly every EVM chain and dispatches via a raw fallback — calldata is exactly `salt (32 bytes) ‖ initCode`, fed directly into `CREATE2`; there is no selector at all, which is what motivates `$fallback`. It is used to deploy many well-known contracts deterministically, including Uniswap's own Permit2 (`0x000000000022D473030F116dDEE9F6B43aC78BA3`, the same address on every chain it's deployed to). The descriptor's second template is [EIP-1167](https://eips.ethereum.org/EIPS/eip-1167)'s minimal-proxy creation code, byte-exact and fully verified against the standard's own bytecode listing: a 20-byte prefix (`0x3d602d80600a3d3981f3363d3d373d3d3d363d73`), a 20-byte embedded implementation address, and a 15-byte suffix (`0x5af43d82803e903d91602b57fd5bf3`) — 55 bytes total, with no ABI encoding involved at all, which is why `initCode` needed a `suffix` concept rather than just "prefix, then trailing ABI args." +Ethereum mainnet, Safe transaction hash [`0x9ebb8a7d7c085b3dde80c02f5bc44a1d32749104e2b17d17bf72d7f674ef1b34`](https://etherscan.io/tx/0x9ebb8a7d7c085b3dde80c02f5bc44a1d32749104e2b17d17bf72d7f674ef1b34), executed 2026-05-22. `BalancerRelayer.multicall` (`0x35Cea9e57A393ac66Aaa7E25C391D52C74B5648f`) batches three delegatecalls into `BatchRelayerLibrary` (`0xeA66501dF1A00261E3bB79D1E90444fc6A186B62`): a bundled `setRelayerApproval`, then two `exitPool` calls that unwind a nested LP position two levels deep — exiting a Weighted pool (`kind=0x00`, `ExitKind=0x01`, a literal `16,250.97` pool tokens) for, among other tokens, the BPT of a Stable pool nested inside it, then exiting that Stable pool (`kind=0x03`, `ExitKind=0x02`) using the just-received BPT amount directly as a chained reference (`0xba10...0`) — a value that cannot be known until the first `exitPool` has actually executed on-chain. Both `userData`'s `JoinKind`/`ExitKind` dispatch (sourced from the sibling `kind` parameter, not from `poolId`) and the chained-reference sentinel were confirmed against `VaultActions.sol`/`WeightedPoolUserData.sol`/`StablePoolUserData.sol`/`BasePoolUserData.sol` source, and both non-default branches against the cited transaction's own two `exitPool` calls. -Unlike the six examples above, this one demonstrates a mechanism against real, named, well-known contracts rather than one specific mined transaction. The Permit2 template's `prefix.hash`/`prefix.length` are explicitly marked as placeholders in the file — computing them requires Permit2's exact, compiler-version-specific creation bytecode, which was not independently re-derived for this example. - -Full descriptor: [`example-deterministic-deployment-proxy.json`](../assets/erc-non-abi-dispatch/example-deterministic-deployment-proxy.json). +Full descriptors: [`example-balancer-relayer-multicall.json`](../assets/erc-non-abi-dispatch/example-balancer-relayer-multicall.json) (the outer `multicall` entry point) and [`example-balancer-relayer-library.json`](../assets/erc-non-abi-dispatch/example-balancer-relayer-library.json) (`setRelayerApproval`/`joinPool`/`exitPool`, reached only via the multicall's delegatecalls — `joinPool` itself is not exercised by the cited transaction, unlike `exitPool`). ### `TieredExecutor` (made-up example) @@ -318,7 +260,7 @@ A small, illustrative Solidity contract written for this ERC — [`TieredExecuto Full descriptors: [`example-tiered-executor.json`](../assets/erc-non-abi-dispatch/example-tiered-executor.json) (the dispatching contract), [`example-reward-vault.json`](../assets/erc-non-abi-dispatch/example-reward-vault.json) and [`example-legacy-token.json`](../assets/erc-non-abi-dispatch/example-legacy-token.json) (the two target interfaces it recurses into, each an independently-authored descriptor resolved the same way any other embedded-calldata target would be). -> **Naming note:** the linked example files under [`../assets/erc-non-abi-dispatch/`](../assets/erc-non-abi-dispatch/) for the original six real-transaction cases (Safe `MultiSend`, Universal Router, ERC-7579, CCTP, EAS, ERC-7683) plus `TieredExecutor`'s three files still use this ERC's pre-rename vocabulary (`kind`, `struct`, `dispatch`, `tag`, and the now-removed `call` layout node) and have not yet been migrated to `type`/`object`/`switch`/`expression`/`format: "calldata"`. [`example-all-syntax-human.json5`](../assets/erc-non-abi-dispatch/example-all-syntax-human.json5) uses the current naming throughout for the three formats it covers (MultiSend, Universal Router, ERC-7579) and is the reference for what the migrated shape looks like; migrating the other files to match is open follow-up work. `example-safe-exectransaction.json`, and the separate Balancer relayer descriptors referenced from the `layout`-on-scalar and `#.` scope-crossing discussions above, already use the current naming throughout, having been authored after the rename. +Every example file under [`../assets/erc-non-abi-dispatch/`](../assets/erc-non-abi-dispatch/) uses this ERC's current vocabulary (`type`/`object`/`switch`/`expression`/`format: "calldata"`, `$default` inside `cases`) and validates against [`erc7730-non-abi-dispatch.schema.json`](../assets/erc-non-abi-dispatch/erc7730-non-abi-dispatch.schema.json), the companion JSON Schema extending ERC-7730's own. ## Reference Implementation @@ -326,11 +268,7 @@ TBD ## Security Considerations -A `layout`/`switch`/`interaction` interpreter is new parsing surface on a hardware wallet, decoding attacker-influenced (calldata is provided by whoever submits the transaction) bytes. Implementations MUST bound recursion depth (embedded-calldata resolution, nested `switch`, `pointer`, and `interaction` can all recurse arbitrarily deep in principle), MUST treat any length (`lengthFrom`, or a `sequence`'s implicit `tillEnd` walk) that would read past the end of the underlying buffer as invalid input, and MUST fail closed — applying the [unknown selector](./erc-7730.md#unknown-selectors) fallback — rather than displaying a partially decoded or best-guess value when a `layout` or `switch` does not cleanly match the actual bytes. `operation` resolving to `"delegatecall"` is a particularly high-severity case of this: a wallet MUST fail closed exactly as hard for an unresolvable delegatecall target as it would for one with no descriptor at all, never falling back to treating it as a plain call. - -`pointer` widens this surface specifically: a wallet MUST treat an offset (plus `destination`'s consumed width) that would read outside `containerPath`'s buffer as invalid input, the same fail-closed rule as any other out-of-bounds read. Recursion here is not merely theoretical: Safe's own `checkContractSignature` calls back into `isValidSignature` on the signing contract, which — if that contract is itself a Safe — recurses into that Safe's own `checkNSignatures` over its own `signatures` buffer, i.e. a `destination` that is itself another `pointer`-bearing `signatures` sequence. A wallet MUST bound this depth and fail closed past it, rather than parsing an attacker-supplied chain of nested signature buffers to unbounded depth. - -`initCode` template matching is exact-bytes (or exact-hash) matching against a fixed template. A wallet MUST NOT treat a partial or fuzzy prefix/suffix match as a match — an attacker who can get even one byte accepted as "close enough" could potentially get unrelated, unaudited bytecode displayed as if it were a known, trusted template. Authors MUST keep `templates` entries pinned to one specific compiler version and settings; the same source recompiled differently produces different bytecode and MUST be treated as an entirely distinct, separately-audited template, never as a "should still basically match" variant of an existing one. +A `layout`/`switch`/`interaction` interpreter is new parsing surface on a hardware wallet, decoding attacker-influenced (calldata is provided by whoever submits the transaction) bytes. Implementations MUST bound recursion depth (embedded-calldata resolution, nested `switch`, and `interaction` can all recurse arbitrarily deep in principle), MUST treat any length (`lengthFrom`, or a `sequence`'s implicit `tillEnd` walk) that would read past the end of the underlying buffer as invalid input, and MUST fail closed — applying the [unknown selector](./erc-7730.md#unknown-selectors) fallback — rather than displaying a partially decoded or best-guess value when a `layout` or `switch` does not cleanly match the actual bytes. `operation` resolving to `"delegatecall"` is a particularly high-severity case of this: a wallet MUST fail closed exactly as hard for an unresolvable delegatecall target as it would for one with no descriptor at all, never falling back to treating it as a plain call. ## Copyright diff --git a/assets/erc-non-abi-dispatch/erc7730-non-abi-dispatch.schema.json b/assets/erc-non-abi-dispatch/erc7730-non-abi-dispatch.schema.json new file mode 100644 index 00000000000..c16201e5457 --- /dev/null +++ b/assets/erc-non-abi-dispatch/erc7730-non-abi-dispatch.schema.json @@ -0,0 +1,1944 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "version": "2.0.0", + "type": "object", + "description": "Full schema for ERC-7730 descriptors, including the layout/switch/interaction keys and the operation calldata param defined by the non-ABI-dispatch companion ERC (requires 7730). Based on erc7730-v2.schema.json; see that file for the unmodified base ERC-7730 schema, and the companion ERC's Specification section for the normative prose this schema encodes.", + "properties": { + "$schema": { + "title": "Schema", + "type": "string", + "format": "uri-reference", + "description": "The schema that the document should conform to. This should be the URL of a version of the clear signing JSON schemas available under https://github.com/LedgerHQ/clear-signing-erc7730-registry/tree/master/specs" + }, + "$comment": { + "title": "Schema", + "type": "string", + "description": "An optional comment string that can be used to document the purpose of the file." + }, + "includes": { + "title": "External includes", + "type": "string", + "format": "uri-reference", + "description": "An URL of another ERC 7730 file that should be merged into this one. Includes are merged into this file before analysis. This can be used to manage interfaces definitions without redundancy." + }, + "context": { + "$ref": "#/$context/main" + }, + "metadata": { + "$ref": "#/$metadata/main" + }, + "display": { + "$ref": "#/$display/main" + } + }, + "additionalProperties": false, + "$context": { + "main": { + "title": "Binding Context Section", + "type": "object", + "description": "The binding context is a set of constraints that are used to bind the ERC7730 file to a specific structured data being displayed. Currently, supported contexts include contract-specific constraints or EIP712 message specific constraints.", + "properties": { + "$id": { + "$ref": "#/$definitions/id" + } + }, + "oneOf": [ + { + "$ref": "#/$context/contract" + }, + { + "$ref": "#/$context/EIP712" + } + ], + "unevaluatedProperties": false + }, + "contract": { + "type": "object", + "properties": { + "contract": { + "title": "Contract Binding Context", + "type": "object", + "description": "The contract binding context is a set constraints that are used to bind the ERC7730 file to a specific smart contract.", + "properties": { + "abi": { + "description": "[Deprecated] ABI definition bound to this file. Continue providing it for backward compatibility only; new specs should rely on display formats." + }, + "deployments": { + "$ref": "#/$context/deployments" + }, + "factory": { + "title": "Factory constraint", + "type": "object", + "description": "A factory constraint is used to check whether the target contract is deployed by a specified factory.", + "properties": { + "deployments": { + "$ref": "#/$context/deployments" + }, + "deployEvent": { + "title": "Deploy Event signature", + "type": "string", + "description": "The event signature that is emitted by the factory when deploying a new contract." + } + }, + "required": [ + "deployments", + "deployEvent" + ], + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "required": [ + "contract" + ] + }, + "EIP712": { + "type": "object", + "properties": { + "eip712": { + "title": "EIP 712 Binding", + "type": "object", + "description": "The EIP-712 binding context is a set of constraints that must be verified by the message being signed.", + "properties": { + "schemas": { + "description": "[Deprecated] Schema definition bound to this file. Continue providing it for backward compatibility only; new specs should rely on display formats." + }, + "domain": { + "title": "EIP 712 Domain Binding constraint", + "type": "object", + "description": "Each value of the domain constraint MUST match the corresponding eip 712 message domain value.", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "chainId": { + "type": "integer", + "format": "eip155" + }, + "verifyingContract": { + "type": "string", + "format": "eip55" + } + } + }, + "domainSeparator": { + "title": "Domain Separator constraint", + "type": "string", + "description": "The domain separator value that must be matched by the message. In hex string representation." + }, + "deployments": { + "description": "An array of deployments describing what the chainId and verifyingContract in the domain should match.", + "$ref": "#/$context/deployments" + } + }, + "additionalProperties": false + } + }, + "required": [ + "eip712" + ] + }, + "deployments": { + "title": "Deployments constraint", + "type": "array", + "description": "An array of deployments describing where the contract is deployed. The target contract (Tx to or factory) MUST match one of those deployments.", + "items": { + "properties": { + "chainId": { + "type": "integer", + "format": "eip155" + }, + "address": { + "type": "string", + "format": "eip55" + } + } + } + } + }, + "$metadata": { + "main": { + "title": "Metadata Section", + "type": "object", + "description": "The metadata section contains information about constant values relevant in the scope of the current contract / message (as matched by the `context` section)", + "properties": { + "owner": { + "title": "Owner display name", + "type": "string", + "description": "The display name of the owner or target of the contract / message to be clear signed." + }, + "contractName": { + "title": "Contract Name", + "type": "string", + "description": "The name of the contract targeted by the transaction or message." + }, + "info": { + "$ref": "#/$metadata/info" + }, + "token": { + "$ref": "#/$metadata/token" + }, + "constants": { + "$ref": "#/$metadata/constants" + }, + "enums": { + "$ref": "#/$metadata/enums" + } + } + }, + "info": { + "title": "Main contract's owner detailed information", + "type": "object", + "description": "The owner info section contains detailed information about the owner or target of the contract / message to be clear signed.", + "properties": { + "deploymentDate": { + "title": "Deployment date of the contract / message", + "type": "string", + "format": "date-time", + "description": "The date of deployment of the contract / message." + }, + "url": { + "title": "Owner URL", + "type": "string", + "format": "uri", + "description": "URL with more info on the entity the user interacts with." + } + }, + "required": [ + "url" + ], + "additionalProperties": false + }, + "token": { + "title": "Token Description", + "type": "object", + "description": "A description of an ERC20 token exported by this format, that should be trusted. Not mandatory if the corresponding metadata can be fetched from the contract itself.", + "properties": { + "name": { + "title": "Token Name", + "type": "string" + }, + "ticker": { + "title": "Token Ticker", + "type": "string", + "description": "A short capitalized ticker for the token, that will be displayed in front of corresponding amounts." + }, + "decimals": { + "title": "Token Decimals", + "type": "integer", + "description": "The number of decimals of the token ticker, used to display amounts." + } + }, + "required": [ + "name", + "ticker", + "decimals" + ], + "additionalProperties": false + }, + "constants": { + "title": "Constant values", + "type": "object", + "description": "A set of values that can be used in format parameters. Can be referenced with a path expression like $.metadata.constants.CONSTANT_NAME", + "additionalProperties": { + "type": [ + "string", + "integer", + "number", + "boolean", + "null" + ] + } + }, + "enums": { + "title": "Enums", + "type": "object", + "description": "A set of enums that are used to format fields replacing values with human readable strings.", + "additionalProperties": { + "title": "Enumeration", + "type": "object", + "description": "A set of values that will be used to replace a field value with a human readable string. Enumeration keys are the field values and enumeration values are the displayable strings", + "additionalProperties": { + "type": "string" + } + } + }, + "maps": { + "title": "Maps", + "type": "object", + "description": "A set of maps that are used to manage context dependant constants. Maps can be used in place of constants, and the correpsonding constant is based on the map key resolved value. Each key is a map name that can be used as a reference.", + "additionalProperties": { + "type": "object", + "properties": { + "$keyType": { + "title": "Key Type", + "type": "string", + "description": "An informational representation of the expected key type." + }, + "values": { + "title": "Map Values", + "type": "object", + "description": "A set of values that can be used as constants based on the resolved key. Each key is a possible key value, and each value is the corresponding constant value.", + "additionalProperties": { + "type": [ + "string", + "integer", + "number", + "boolean", + "null" + ] + } + }, + "unresolvedProperties": false + } + } + } + }, + "$display": { + "main": { + "title": "Display Formatting Info Section", + "type": "object", + "description": "The display section contains all the information needed to format the data in a human readable way. It contains the constants and formatters used to display the data contained in the bound structure.", + "properties": { + "definitions": { + "type": "object", + "title": "Common Formatter Definitions", + "description": "A set of definitions that can be used to share formatting information between multiple messages / functions. The definitions can be referenced by the key name in an internal path.", + "additionalProperties": { + "$ref": "#/$format/field" + } + }, + "formats": { + "title": "List of field formats", + "description": "The list includes formatting info for each field of a structure. For contract bindings, entries are keyed by the full function signature with parameter names; for EIP712 bindings, entries are keyed by the string returned by EIP 712 encodeType on the primary type.", + "type": "object", + "propertyNames": { + "pattern": "^\\s*[A-Za-z_][A-Za-z0-9_:]*\\s*\\(.*\\)$" + }, + "additionalProperties": { + "oneOf": [ + { + "title": "A structured data format specification", + "description": "A structured data format specification contains formatting information of fields in a single type of message.", + "type": "object", + "properties": { + "$id": { + "$ref": "#/$definitions/id" + }, + "intent": { + "$ref": "#/$display/intent" + }, + "interpolatedIntent": { + "$ref": "#/$display/interpolatedIntent" + }, + "fields": { + "$ref": "#/$display/fields" + } + }, + "additionalProperties": false, + "required": [ + "fields" + ] + }, + { + "title": "A structured data format specification, redirected via a top-level switch", + "type": "object", + "properties": { + "$id": { + "$ref": "#/$definitions/id" + }, + "switch": { + "$ref": "#/$layout/topLevelSwitch" + } + }, + "required": [ + "switch" + ], + "additionalProperties": false + } + ] + } + } + }, + "required": [ + "formats" + ], + "additionalProperties": false + }, + "intent": { + "oneOf": [ + { + "title": "Simple intent message", + "description": "A description of the intent of the structured data signing, that will be displayed to the user.", + "type": "string" + }, + { + "title": "Complex intent message", + "description": "A description of the intent of the structured data signing, that will be displayed to the user.", + "type": "object", + "additionalProperties": { + "type": "string" + } + } + ] + }, + "interpolatedIntent": { + "title": "Interpolated intent message", + "description": "An optional intent string with embedded field values using {path} interpolation syntax. This provides a dynamic, contextual description by embedding actual transaction/message values directly in the intent string. Wallets should prefer displaying interpolatedIntent when available and fall back to intent if interpolation fails. See the specification for detailed formatting behavior and security considerations.", + "type": "string" + }, + "fields": { + "title": "Field Formats set", + "type": "array", + "description": "An array containing the ordered definitions of fields formats. See the specification for more details.", + "items": { + "oneOf": [ + { + "$ref": "#/$format/field" + }, + { + "$ref": "#/$display/fieldGroup" + }, + { + "$ref": "#/$display/reference" + } + ] + }, + "unevaluatedProperties": false + }, + "fieldGroup": { + "title": "A group of field formats, allowing recursivity in the schema and control over grouping and iteration.", + "description": "A set of field formats used to group whole definitions for structures for instance. This allows nesting definitions of formats, but note that support for deep nesting will be device dependent.", + "type": "object", + "properties": { + "$id": { + "$ref": "#/$definitions/id" + }, + "path": { + "$ref": "#/$format/path" + }, + "label": { + "title": "Group Label", + "description": "The group label of the field group, that will be displayed to the user in front of the formatted field values.", + "type": "string" + }, + "iteration": { + "title": "Group arrays iteration mode", + "description": "Specifies how iteration over arrays in the group should be handled. Sequential mode displays elements grouped by array, ie arr_0[0] ... arr_0[N] arr_1[0] ... arr_1[M]. Bundled mode displays elements of the arrays grouped by index, ie arr_0[0] arr_1[0] arr_0[1] arr_1[1] ... arr_0[N] arr_1[N]. In bundled mode, all arrays MUST be of the same length.", + "type": "string", + "enum": [ + "sequential", + "bundled" + ] + }, + "fields": { + "$ref": "#/$display/fields" + } + }, + "required": [ + "fields" + ], + "additionalProperties": false + }, + "reference": { + "title": "Reference", + "description": "A reference to a shared definition that should be used as the field formatting definition. The value is the key in the display definitions section, as a path expression $.display.definitions.DEFINITION_NAME. It is used to share definitions between multiple messages / functions.", + "properties": { + "path": { + "$ref": "#/$format/path" + }, + "value": { + "$ref": "#/$format/value" + }, + "label": { + "description": "This value overrides the label in the referenced definition if set.", + "type": "string" + }, + "$ref": { + "description": "An internal definition that should be used as the field formatting definition. The value is the key in the display definitions section, as a path expression $.display.definitions.DEFINITION_NAME.", + "type": "string" + }, + "params": { + "description": "Parameters override. These values takes precedence over the ones in the definition itself", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "separator": { + "description": "Separator override for the referenced definition.", + "type": "string" + }, + "visible": { + "description": "Visibility override for the referenced definition.", + "$ref": "#/$format/rules" + }, + "encryption": { + "description": "Encryption override for the referenced definition.", + "$ref": "#/$format/encryptionParameters" + } + }, + "required": [ + "$ref" + ], + "allOf": [ + { + "not": { + "required": [ + "path", + "value" + ] + } + } + ], + "additionalProperties": false + } + }, + "$format": { + "path": { + "title": "Path", + "type": "string", + "description": "A path to the field in the structured data. The path is a JSON path expression that can be used to extract the field value from the structured data." + }, + "value": { + "title": "Value", + "type": [ + "string", + "integer", + "number", + "boolean" + ], + "description": "A literal value on which the format should be applied instead of looking up a field in the structured data." + }, + "field": { + "title": "Field formatter", + "description": "A field formatter contains formatting information of a single field in a message.", + "type": "object", + "properties": { + "$id": { + "$ref": "#/$definitions/id" + }, + "path": { + "$ref": "#/$format/path" + }, + "value": { + "$ref": "#/$format/value" + }, + "visible": { + "$ref": "#/$format/rules" + }, + "label": { + "title": "Field Label", + "description": "The label of the field, that will be displayed to the user in front of the formatted field value.", + "type": "string" + }, + "format": { + "title": "Field Format", + "description": "The format of the field, that will be used to format the field value in a human readable way.", + "type": "string", + "$ref": "#/$format/names" + }, + "separator": { + "title": "Field Separator", + "description": "An optional separator string that will be used to separate multiple values when the field is an array. A separator use the interpolated string format with one specific parameter {index} replaced by the index of the element in the array.", + "type": "string" + }, + "encryption": { + "$ref": "#/$format/encryptionParameters", + "description": "If present, the field value is encrypted. The format specifies how to display the decrypted value." + }, + "layout": { + "$ref": "#/$layout/node" + }, + "switch": { + "$ref": "#/$layout/fieldSwitch" + }, + "interaction": { + "$ref": "#/$layout/interaction" + } + }, + "allOf": [ + { + "not": { + "required": [ + "path", + "value" + ] + } + }, + { + "if": { + "properties": { + "format": { + "const": "addressName" + } + } + }, + "then": { + "properties": { + "params": { + "$ref": "#/$format/addressNameParameters" + } + } + } + }, + { + "if": { + "properties": { + "format": { + "const": "interoperableAddressName" + } + } + }, + "then": { + "properties": { + "params": { + "$ref": "#/$format/interoperableAddressNameParameters" + } + } + } + }, + { + "if": { + "properties": { + "format": { + "const": "calldata" + } + } + }, + "then": { + "properties": { + "params": { + "$ref": "#/$format/calldataParameters" + } + } + } + }, + { + "if": { + "properties": { + "format": { + "const": "tokenAmount" + } + } + }, + "then": { + "properties": { + "params": { + "$ref": "#/$format/tokenAmountParameters" + } + } + } + }, + { + "if": { + "properties": { + "format": { + "const": "tokenTicker" + } + } + }, + "then": { + "properties": { + "params": { + "$ref": "#/$format/tokenTickerParameters" + } + } + } + }, + { + "if": { + "properties": { + "format": { + "const": "nftName" + } + } + }, + "then": { + "properties": { + "params": { + "$ref": "#/$format/nftNameParameters" + } + } + } + }, + { + "if": { + "properties": { + "format": { + "const": "date" + } + } + }, + "then": { + "properties": { + "params": { + "$ref": "#/$format/dateParameters" + } + } + } + }, + { + "if": { + "properties": { + "format": { + "const": "unit" + } + } + }, + "then": { + "properties": { + "params": { + "$ref": "#/$format/unitParameters" + } + } + } + }, + { + "if": { + "properties": { + "format": { + "const": "enum" + } + } + }, + "then": { + "properties": { + "params": { + "$ref": "#/$format/enumParameters" + } + } + } + }, + { + "not": { + "required": [ + "layout", + "format" + ] + }, + "$comment": "layout MUST NOT combine with format (non-abi-dispatch companion ERC, ### layout)." + }, + { + "not": { + "required": [ + "interaction", + "format" + ] + }, + "$comment": "interaction is mutually exclusive with format (non-abi-dispatch companion ERC, ### interaction)." + }, + { + "not": { + "required": [ + "interaction", + "layout" + ] + }, + "$comment": "interaction is mutually exclusive with layout (non-abi-dispatch companion ERC, ### interaction)." + } + ], + "unevaluatedProperties": false + }, + "rules": { + "title": "Display Rule", + "description": "Specifies when a field should be displayed based on its value or context. Defaults to 'always' if not specified.", + "oneOf": [ + { + "type": "string", + "enum": [ + "always", + "never", + "optional" + ], + "description": "Simple display rule: 'always' means always display, 'never' means always skip display, 'optional' means display only if wallet can." + }, + { + "type": "object", + "properties": { + "ifNotIn": { + "type": "array", + "description": "Display the field only if its value is NOT in this list.", + "items": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "minItems": 1 + }, + "mustMatch": { + "type": "array", + "description": "Always skip display, but value MUST match one of these values.", + "items": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "minItems": 1 + } + }, + "additionalProperties": false, + "allOf": [ + { + "not": { + "required": [ + "ifNotIn", + "mustMatch" + ] + } + } + ], + "description": "Conditional display rules. Either 'ifNotIn' or 'mustMatch' must be defined, but not both." + } + ] + }, + "mapReference": { + "title": "Map Reference", + "type": "object", + "properties": { + "map": { + "type": "string", + "description": "The path to the referenced map." + }, + "keyPath": { + "type": "string", + "description": "The path to the key used to resolve a value using the referenced map." + } + } + }, + "names": { + "anyOf": [ + { + "title": "Raw format", + "const": "raw", + "description": "The field should be displayed as the natural representation of the underlying structured data type." + }, + { + "title": "address format", + "const": "addressName", + "description": "The field should be displayed as a trusted name, or as a raw address if no names are found in trusted sources. List of trusted sources can be optionally specified in parameters." + }, + { + "title": "address format", + "const": "tokenTicker", + "description": "The field should be displayed as an ERC 20 token ticker, or as a raw address if no token definition are found." + }, + { + "title": "bytes format", + "const": "calldata", + "description": "The field is itself a calldata embedded in main call. Another ERC 7730 should be used to parse this field. If not available or not supported, the wallet MAY display a hash of the embedded calldata instead." + }, + { + "title": "integer format", + "const": "amount", + "description": "The field should be displayed as an amount in underlying currency, converted using the best magnitude / ticker available." + }, + { + "title": "integer format", + "const": "tokenAmount", + "description": "The field should be displayed as an amount, preceded by the ticker. The magnitude and ticker should be derived from the token or tokenPath parameter corresponding metadata." + }, + { + "title": "integer format", + "const": "nftName", + "description": "The field should be displayed as a single NFT names, or as a raw token Id if a specific name is not found. Collection is specified by the collection or collectionPath parameter." + }, + { + "title": "integer format", + "const": "date", + "description": "The field should be displayed as a date. Suggested RFC3339 representation. Parameter specifies the encoding of the date." + }, + { + "title": "integer format", + "const": "duration", + "description": "The field should be displayed as a duration in HH:MM:ss form. Value is interpreted as a number of seconds." + }, + { + "title": "integer format", + "const": "unit", + "description": "The field should be displayed as a percentage. Magnitude of the percentage encoding is specified as a parameter. Example: a value of 3000 with magnitude 4 is displayed as 0.3%." + }, + { + "title": "integer format", + "const": "enum", + "description": "The field should be displayed as a human readable string by converting the value using the enum referenced in parameters." + }, + { + "title": "integer format", + "const": "chainId", + "description": "The field should be displayed as a Blockchain explicit name, as defined in EIP-155, based on the chain id value." + }, + { + "title": "integer format", + "const": "interoperableAddressName", + "description": "The field should be displayed as a trusted name or as an EIP-7930 Interoperable Address human readable format. List of trusted sources can be optionally specified in parameters." + } + ] + }, + "addressNameParameters": { + "title": "Address Names Formatting Parameters", + "type": "object", + "properties": { + "types": { + "title": "Address Type", + "type": "array", + "description": "The types of address to display. Restrict allowable sources of names and MAY lead to additional checks from wallets.", + "items": { + "type": "string", + "enum": [ + "wallet", + "eoa", + "contract", + "token", + "collection" + ] + } + }, + "sources": { + "title": "Trusted Sources", + "description": "Trusted Sources for names, in order of preferences. Sources values are wallet manufacturer specific, example values are \"local\" or \"ens\". See specification for more details on sources values.", + "type": "array", + "items": { + "type": "string" + } + }, + "senderAddress": { + "title": "Sender Address", + "oneOf": [ + { + "type": "string", + "description": "An address equal to this value is interpreted as the sender referenced by `@.from`." + }, + { + "type": "array", + "description": "An array of addresses, any of which are interpreted as the sender referenced by `@.from`.", + "items": { + "type": "string" + } + } + ] + } + }, + "additionalProperties": false + }, + "calldataParameters": { + "title": "Embedded Calldata Formatting Parameters", + "type": "object", + "properties": { + "callee": { + "title": "Callee Address", + "type": [ + "string", + "object" + ], + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$format/mapReference" + } + ], + "description": "The address of the contract being called by this embedded calldata." + }, + "calleePath": { + "title": "Callee Path", + "type": "string", + "description": "The path to the address of the contract being called by this embedded calldata." + }, + "selector": { + "title": "Called Selector (Optional)", + "type": [ + "string", + "object" + ], + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$format/mapReference" + } + ], + "description": "The selector being called, if not contained in the calldata. Hex string representation." + }, + "selectorPath": { + "title": "Called Selector Path (Optional)", + "type": "string", + "description": "The path to the selector being called, if not contained in the calldata." + }, + "amount": { + "title": "Amount (Optional)", + "type": [ + "integer", + "object" + ], + "anyOf": [ + { + "type": "integer" + }, + { + "$ref": "#/$format/mapReference" + } + ], + "description": "The associated amount in native currency, if the calldata can be associated with a container value." + }, + "amountPath": { + "title": "Amoun Path (Optional)", + "type": "string", + "description": "The path to the associated amount in native currency, if the calldata can be associated with a container value." + }, + "spender": { + "title": "Spender Path (Optional)", + "type": [ + "string", + "object" + ], + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$format/mapReference" + } + ], + "description": "The associated spender, if the calldata can be associated with a container value." + }, + "spenderPath": { + "title": "Spender Path (Optional)", + "type": "string", + "description": "The path to the associated spender, if the calldata can be associated with a container value." + }, + "operation": { + "$ref": "#/$layout/operationParam" + } + }, + "anyOf": [ + { + "required": [ + "callee" + ] + }, + { + "required": [ + "calleePath" + ] + } + ], + "allOf": [ + { + "not": { + "required": [ + "callee", + "calleePath" + ] + } + }, + { + "not": { + "required": [ + "selector", + "selectorPath" + ] + } + }, + { + "not": { + "required": [ + "amount", + "amountPath" + ] + } + }, + { + "not": { + "required": [ + "spender", + "spenderPath" + ] + } + } + ], + "additionalProperties": false + }, + "tokenAmountParameters": { + "title": "Token Amount Formatting Parameters", + "type": "object", + "properties": { + "token": { + "title": "Token", + "type": [ + "string", + "object" + ], + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$format/mapReference" + } + ], + "description": "The token address, or a path to a constant in the ERC 7730 file." + }, + "tokenPath": { + "title": "Token Path", + "type": "string", + "description": "The path to the token address in the structured data." + }, + "nativeCurrencyAddress": { + "title": "Native Currency Address", + "oneOf": [ + { + "type": "string", + "description": "An address equal to this value is interpreted as an amount in native currency rather than a token." + }, + { + "type": "array", + "description": "An array of addresses, any of which are interpreted as an amount in native currency rather than a token.", + "items": { + "type": "string" + } + } + ] + }, + "threshold": { + "title": "Unlimited Threshold", + "type": "string", + "description": "The threshold above which the amount should be displayed using the message parameter rather than the real amount." + }, + "message": { + "title": "Unlimited Message", + "type": "string", + "description": "The message to display when the amount is above the threshold." + }, + "chainId": { + "title": "Chain ID", + "type": [ + "integer", + "object" + ], + "anyOf": [ + { + "type": "integer" + }, + { + "$ref": "#/$format/mapReference" + } + ], + "description": "Optional. The chain on which the token is deployed (constant, or a map reference). When present, the wallet SHOULD resolve token metadata (ticker, decimals) for this chain. Useful for cross-chain swap clear signing where the same token address may refer to different chains." + }, + "chainIdPath": { + "title": "Chain ID Path", + "type": "string", + "description": "Optional. Path to the chain ID in the structured data. When present, the wallet SHOULD resolve token metadata for the chain at this path. Useful for cross-chain swap clear signing." + } + }, + "allOf": [ + { + "not": { + "required": [ + "token", + "tokenPath" + ] + } + }, + { + "not": { + "required": [ + "chainId", + "chainIdPath" + ] + } + } + ], + "additionalProperties": false + }, + "tokenTickerParameters": { + "title": "Token Ticker Formatting Parameters", + "type": "object", + "properties": { + "chainId": { + "title": "Chain ID", + "type": [ + "integer", + "object" + ], + "anyOf": [ + { + "type": "integer" + }, + { + "$ref": "#/$format/mapReference" + } + ], + "description": "Optional. The chain on which the token is deployed (constant, or a map reference). When present, the wallet SHOULD resolve the token ticker for this chain. Useful for cross-chain swap clear signing." + }, + "chainIdPath": { + "title": "Chain ID Path", + "type": "string", + "description": "Optional. Path to the chain ID in the structured data. When present, the wallet SHOULD resolve the token ticker for the chain at this path. Useful for cross-chain swap clear signing." + } + }, + "allOf": [ + { + "not": { + "required": [ + "chainId", + "chainIdPath" + ] + } + } + ], + "additionalProperties": false + }, + "nftNameParameters": { + "title": "NFT Names Formatting Parameters", + "type": "object", + "properties": { + "collection": { + "title": "Collection Address", + "type": [ + "string", + "object" + ], + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$format/mapReference" + } + ], + "description": "The collection address, or a path to a constant in the ERC 7730 file." + }, + "collectionPath": { + "title": "Collection Path", + "type": "string", + "description": "The path to the collection in the structured data." + } + }, + "anyOf": [ + { + "required": [ + "collection" + ] + }, + { + "required": [ + "collectionPath" + ] + } + ], + "not": { + "required": [ + "collection", + "collectionPath" + ] + }, + "additionalProperties": false + }, + "dateParameters": { + "title": "Date Formatting Parameters", + "type": "object", + "properties": { + "encoding": { + "title": "Date Encoding", + "type": "string", + "description": "The encoding of the date.", + "enum": [ + "blockheight", + "timestamp" + ] + } + }, + "required": [ + "encoding" + ], + "additionalProperties": false + }, + "unitParameters": { + "title": "Unit Formatting Parameters", + "type": "object", + "properties": { + "base": { + "title": "Unit base symbol", + "type": "string", + "description": "The base symbol of the unit, displayed after the converted value. It can be an SI unit symbol or acceptable dimensionless symbols like % or bps." + }, + "decimals": { + "title": "Decimals", + "type": "integer", + "description": "The number of decimals of the value, used to convert to a float." + }, + "prefix": { + "title": "Prefix", + "type": "boolean", + "description": "Whether the value should be converted to a prefixed unit, like k, M, G, etc." + } + }, + "required": [ + "base" + ], + "additionalProperties": false + }, + "enumParameters": { + "title": "Enum Formatting Parameters", + "type": "object", + "properties": { + "$ref": { + "title": "Enum reference", + "type": "string", + "description": "The internal path to the enum definition used to convert this value." + } + }, + "required": [ + "$ref" + ], + "additionalProperties": false + }, + "interoperableAddressNameParameters": { + "title": "Interoperable Address Names Formatting Parameters", + "type": "object", + "properties": { + "types": { + "title": "Address Type", + "type": "array", + "description": "The types of address to display. Restrict allowable sources of names and MAY lead to additional checks from wallets.", + "items": { + "type": "string", + "enum": [ + "wallet", + "eoa", + "contract", + "token", + "collection" + ] + } + }, + "sources": { + "title": "Trusted Sources", + "description": "Trusted Sources for names, in order of preferences. Sources values are wallet manufacturer specific, example values are \"local\" or \"ens\". See specification for more details on sources values.", + "type": "array", + "items": { + "type": "string" + } + }, + "senderAddress": { + "title": "Sender Address", + "oneOf": [ + { + "type": "string", + "description": "An address equal to this value is interpreted as the sender referenced by `@.from`." + }, + { + "type": "array", + "description": "An array of addresses, any of which are interpreted as the sender referenced by `@.from`.", + "items": { + "type": "string" + } + } + ] + } + }, + "additionalProperties": false + }, + "encryptionParameters": { + "title": "Encrypted Value Parameters", + "type": "object", + "properties": { + "scheme": { + "type": "string", + "description": "The encryption scheme used to produce the handle." + }, + "plaintextType": { + "type": "string", + "description": "Solidity type of the decrypted value (the handle does not encode this)." + }, + "fallbackLabel": { + "type": "string", + "description": "Optional label to display when decryption is not possible. Defaults to \"[Encrypted]\"." + } + }, + "required": [ + "scheme" + ], + "additionalProperties": false + } + }, + "$definitions": { + "id": { + "title": "ID", + "type": "string", + "description": "An internal identifier that can be used either for clarity specifying what the element is or as a reference in device specific sections." + } + }, + "$id": "https://eips.ethereum.org/assets/eip-7730/erc7730-non-abi-dispatch.schema.json", + "title": "ERC-7730 Non-ABI Dispatch Companion Schema", + "$layout": { + "node": { + "title": "A layout node", + "oneOf": [ + { + "$ref": "#/$layout/uint" + }, + { + "$ref": "#/$layout/bytes" + }, + { + "$ref": "#/$layout/address" + }, + { + "$ref": "#/$layout/bool" + }, + { + "$ref": "#/$layout/bitfield" + }, + { + "$ref": "#/$layout/object" + }, + { + "$ref": "#/$layout/sequence" + }, + { + "$ref": "#/$layout/switchNode" + } + ] + }, + "uint": { + "type": "object", + "properties": { + "type": { + "const": "uint" + }, + "bytes": { + "type": "integer", + "minimum": 1, + "maximum": 32 + }, + "endian": { + "enum": [ + "be", + "le" + ] + }, + "mask": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]+$|^0b[01]+$" + } + }, + "required": [ + "type", + "bytes" + ], + "additionalProperties": false + }, + "bytes": { + "type": "object", + "properties": { + "type": { + "const": "bytes" + }, + "length": { + "type": "integer", + "minimum": 0 + }, + "lengthFrom": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "address": { + "type": "object", + "properties": { + "type": { + "const": "address" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "bool": { + "type": "object", + "properties": { + "type": { + "const": "bool" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "bitfield": { + "type": "object", + "properties": { + "type": { + "const": "bitfield" + }, + "bytes": { + "type": "integer", + "minimum": 1, + "maximum": 32 + }, + "endian": { + "enum": [ + "be", + "le" + ] + }, + "fields": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "bit": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "name", + "bit" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "bits": { + "type": "array", + "items": { + "type": "integer", + "minimum": 0 + }, + "minItems": 2, + "maxItems": 2 + } + }, + "required": [ + "name", + "bits" + ], + "additionalProperties": false + } + ] + } + } + }, + "required": [ + "type", + "bytes", + "fields" + ], + "additionalProperties": false + }, + "object": { + "type": "object", + "properties": { + "type": { + "const": "object" + }, + "fields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "schema": { + "$ref": "#/$layout/node" + } + }, + "required": [ + "name", + "schema" + ], + "additionalProperties": false + } + } + }, + "required": [ + "type", + "fields" + ], + "additionalProperties": false + }, + "sequence": { + "type": "object", + "properties": { + "type": { + "const": "sequence" + }, + "element": { + "$ref": "#/$layout/node" + }, + "count": { + "const": "tillEnd" + } + }, + "required": [ + "type", + "element", + "count" + ], + "additionalProperties": false + }, + "switchNode": { + "title": "switch used as a layout node (inline, reads from the buffer at the cursor)", + "type": "object", + "properties": { + "type": { + "const": "switch" + }, + "expression": { + "$ref": "#/$layout/node" + }, + "payloadFrom": { + "type": "string" + }, + "cases": { + "$ref": "#/$layout/switchCases" + } + }, + "required": [ + "type", + "expression", + "cases" + ], + "additionalProperties": false + }, + "fieldSwitch": { + "title": "switch used as a field-format-specification key (sibling-path-sourced form)", + "type": "object", + "properties": { + "expression": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "mask": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]+$|^0b[01]+$" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + }, + { + "$ref": "#/$layout/node" + } + ] + }, + "cases": { + "$ref": "#/$layout/switchCases" + } + }, + "required": [ + "expression", + "cases" + ], + "additionalProperties": false + }, + "switchCases": { + "title": "A switch cases map, including the reserved $default key", + "type": "object", + "additionalProperties": { + "$ref": "#/$layout/switchCaseValue" + } + }, + "switchCaseValue": { + "oneOf": [ + { + "const": "reject" + }, + { + "type": "object", + "properties": { + "abiType": { + "type": "string" + } + }, + "required": [ + "abiType" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "layout": { + "$ref": "#/$layout/node" + } + }, + "required": [ + "layout" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "switch": { + "$ref": "#/$layout/fieldSwitch" + } + }, + "required": [ + "switch" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "format": { + "$ref": "#/$format/names" + }, + "params": { + "type": "object" + } + }, + "required": [ + "format" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "intent": { + "enum": [ + "info", + "warning" + ] + } + }, + "required": [ + "label" + ], + "additionalProperties": false + }, + { + "title": "Tuple-signature-key shorthand: decode as abiType, then apply intent/fields inline", + "type": "object", + "minProperties": 1, + "maxProperties": 1, + "additionalProperties": { + "type": "object", + "properties": { + "intent": { + "$ref": "#/$display/intent" + }, + "fields": { + "$ref": "#/$display/fields" + } + }, + "required": [ + "fields" + ], + "additionalProperties": false + } + } + ] + }, + "topLevelSwitch": { + "title": "switch at the top of a structured data format specification", + "type": "object", + "properties": { + "expression": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + } + ] + }, + "cases": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "const": "reject" + }, + { + "type": "object", + "properties": { + "switch": { + "$ref": "#/$layout/topLevelSwitch" + } + }, + "required": [ + "switch" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "interaction": { + "$ref": "#/$layout/interaction" + } + }, + "required": [ + "interaction" + ], + "additionalProperties": false + } + ] + } + } + }, + "required": [ + "expression", + "cases" + ], + "additionalProperties": false + }, + "interaction": { + "title": "interaction: a call synthesized from already-decoded pieces", + "type": "object", + "properties": { + "to": { + "type": "string" + }, + "signature": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "value": {} + }, + "required": [ + "value" + ], + "additionalProperties": false + } + ] + } + } + }, + "required": [ + "to", + "signature", + "args" + ], + "additionalProperties": false + }, + "operationParam": { + "title": "operation: call vs delegatecall for format:calldata", + "oneOf": [ + { + "enum": [ + "call", + "delegatecall" + ] + }, + { + "type": "object", + "properties": { + "expression": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + } + ] + }, + "cases": { + "type": "object", + "additionalProperties": { + "enum": [ + "call", + "delegatecall", + "reject" + ] + } + } + }, + "required": [ + "expression", + "cases" + ], + "additionalProperties": false + } + ] + } + }, + "$comment": "This is a full schema, not a thin extension of erc7730-v2.schema.json: composing new properties onto a schema closed with additionalProperties:false (calldataParameters) or reached only via $ref (field, whose own unevaluatedProperties:false does not see sibling allOf properties across a $ref boundary - verified empirically, not just by reading the spec) does not validate the way a thin allOf+$ref extension would suggest. Everything here is spliced directly into the same base schema objects instead. One side effect worth knowing: because field is modified in place rather than duplicated, layout/switch/interaction are also available inside $display/fieldGroup's and $display/reference's own nested fields, not just top-level display.formats.*.fields[], even though no current example exercises that." +} \ No newline at end of file diff --git a/assets/erc-non-abi-dispatch/example-all-syntax-human.json5 b/assets/erc-non-abi-dispatch/example-all-syntax-human.json5 deleted file mode 100644 index ae95bde38c8..00000000000 --- a/assets/erc-non-abi-dispatch/example-all-syntax-human.json5 +++ /dev/null @@ -1,314 +0,0 @@ -{ - // "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.schema.json", - "$comment": "Worked example for the non-ABI-dispatch companion ERC, decoding Safe's MultiSend packed batching format, Uniswap's Universal Router command dispatch, and ERC-7579's execute mode.", - "context": { - "$id": "ExamplesForAllCases", - "contract": { - "deployments": [] - } - }, - "metadata": {}, - "display": { - "formats": { - "multiSend(bytes transactions)": { - "$id": "MultiSend Batch", - "intent": "Execute batch", - "fields": [ - { - "path": "transactions", - "label": "Batched calls", - "format": "custom", - "layout": { - "type": "sequence", - "element": { - "type": "object", - "fields": [ - { - "name": "operation", - "schema": { - "type": "uint", - "bytes": 1 - } - }, - { - "name": "to", - "schema": { - "type": "address" - } - }, - { - "name": "value", - "schema": { - "type": "uint", - "bytes": 32 - } - }, - { - "name": "dataLength", - "schema": { - "type": "uint", - "bytes": 32 - } - }, - { - "name": "data", - "schema": { - "type": "bytes", - "lengthFrom": "dataLength" - } - } - ], - "interactions": [ - { - "operation": { - "type": "switch", - "expression": "$element.operation", - "cases": { - "0x00": "call", - "0x01": "delegatecall", - } - }, - "target": "$element.to", - "value": "$element.value", - "calldata": "$element.data" - } - ] - }, - } - } - ] - }, - "execute(bytes commands,bytes[] inputs,uint256 deadline)": { - "$id": "Universal Router Execute", - "intent": "Execute swap", - "fields": [ - { - "path": "commands", - "label": "Commands", - "format": "custom", - "layout": { - "type": "sequence", - "element": { - "type": "uint", - "bytes": 1 - } - } - }, - { - "path": "inputs", - "label": "Command Inputs", - "format": "array", - "element": { - "switch": { - "expression": { - "path": "commands[$index]", - "mask": "0b111111" - }, - "cases": { - "0x00": { - "(address recipient,uint256 amountIn,uint256 amountOutMinimum,bytes path,bool payerIsUser)": { - "intent": "Execute swap with exact input and minimum output", - "fields": [ - { - "path": "recipient", - "label": "Recipient", - "format": "addressName" - }, - { - "path": "amountIn", - "label": "Amount in", - "format": "tokenAmount", - "params": { - "tokenPath": "path.[0:20]" - } - }, - { - "path": "amountOutMinimum", - "label": "Minimum amount out", - "format": "raw", - "$comment": "Output token lives at the tail of a variable-hop V3 path; no established slice syntax picks out 'last 20 bytes' of a variable-length buffer, so this is left as a raw number rather than resolved to a token amount." - }, - { - "path": "path", - "label": "Swap path", - "format": "raw" - }, - { - "path": "payerIsUser", - "label": "Pay from wallet", - "format": "raw" - } - ] - } - }, - "0x04": { - "(address token,address recipient,uint256 amountMinimum)": { - "intent": "Sweep remaining balance", - "fields": [ - { - "path": "token", - "label": "Token", - "format": "addressName" - }, - { - "path": "recipient", - "label": "Recipient", - "format": "addressName" - }, - { - "path": "amountMinimum", - "label": "Minimum amount", - "format": "tokenAmount", - "params": { - "tokenPath": "token" - } - } - ] - } - } - } - } - } - }, - { - "path": "deadline", - "label": "Valid until", - "format": "date", - "params": { - "encoding": "timestamp" - } - } - ] - }, - "execute(bytes32 mode,bytes executionCalldata)": { - "$id": "ERC-7579 Execute Function", - "intent": "Execute", - "fields": [ - { - "path": "mode", - "label": "Mode", - "format": "custom", - "layout": { - "type": "object", - "fields": [ - { - "name": "callType", - "schema": { - "type": "uint", - "bytes": 1 - } - }, - { - "name": "execType", - "schema": { - "type": "uint", - "bytes": 1 - } - }, - { - "name": "unused", - "schema": { - "type": "bytes", - "length": 4 - } - }, - { - "name": "modeSelector", - "schema": { - "type": "bytes", - "length": 4 - } - }, - { - "name": "modePayload", - "schema": { - "type": "bytes", - "length": 22 - } - } - ] - } - }, - { - "path": "executionCalldata", - "label": "Execution", - "switch": { - "expression": { - "path": "mode.callType" - }, - "cases": { - "0x00": { - "format": "custom", - "layout": { - "type": "object", - "fields": [ - { - "name": "target", - "schema": { - "type": "address" - } - }, - { - "name": "value", - "schema": { - "type": "uint", - "bytes": 32 - } - }, - { - "name": "callData", - "schema": { - "type": "bytes" - } - } - ] - } - }, - "0x01": { - "format": "array", - "element": { - "fields": [ - { - "name": "target", - "type": "address" - }, - { - "name": "value", - "type": "uint256" - }, - { - "name": "callData", - "type": "bytes" - } - ] - }, - } - }, - "default": "reject" - } - }, - { - "path": "executionCalldata.callData", - "label": "Batched call data", - "format": "calldata", - "params": { - "calleePath": "executionCalldata.target", - "amountPath": "executionCalldata.value" - }, - "$comment": "Only applicable when mode.callType == 0x00: executionCalldata was parsed via layout above into a single target/value/callData record; this field resolves callData as an embedded call to target, reusing ERC-7730's own calldata format rather than a companion-ERC-specific construct - callData is already the full, contiguous ABI calldata, no packing involved." - }, - { - "path": "executionCalldata[].callData", - "label": "Batched call data", - "format": "calldata", - "params": { - "calleePath": "executionCalldata[].target", - "amountPath": "executionCalldata[].value" - }, - "$comment": "Only applicable when mode.callType == 0x01: each ABI-decoded Execution.callData in the batch is itself calldata to its sibling target, resolved the same way as the single-call case above." - } - ] - } - } - } -} diff --git a/assets/erc-non-abi-dispatch/example-balancer-relayer-library.json b/assets/erc-non-abi-dispatch/example-balancer-relayer-library.json index 7aa0fb9fe48..761d647326b 100644 --- a/assets/erc-non-abi-dispatch/example-balancer-relayer-library.json +++ b/assets/erc-non-abi-dispatch/example-balancer-relayer-library.json @@ -1,17 +1,16 @@ { - "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.schema.json", - - "$comment": "Worked example for the non-ABI-dispatch companion ERC, decoding BatchRelayerLibrary's setRelayerApproval/joinPool/exitPool - reached only via BalancerRelayer.multicall's per-entry DELEGATECALL, see example-balancer-relayer-multicall.json. Two things are verified against a real, mined transaction; the rest is verified only against source (VaultActions.sol, WeightedPoolUserData.sol, StablePoolUserData.sol, BasePoolUserData.sol at balancer/balancer-v2-monorepo) and flagged inline where that's the case, the same way this ERC's own initCode example marks its Permit2 template as an unverified placeholder. Ethereum mainnet tx 0x9ebb8a7d7c085b3dde80c02f5bc44a1d32749104e2b17d17bf72d7f674ef1b34 (2026-05-22, block 25154060): entry 0 is setRelayerApproval (bundling the one-time authorization into the same transaction as the action it authorizes); entries 1 and 2 are both exitPool - exiting a Weighted pool (kind=0x00, ExitKind=0x01) for, among other tokens, the BPT of a Stable pool nested inside it, then exiting that Stable pool (kind=0x03, ExitKind=0x02) using the just-received BPT amount directly as a chained reference, since that amount cannot be known until the first exitPool actually executes on-chain. joinPool is NOT exercised by this transaction; its entry below is included because it was asked for, built symmetrically to exitPool from source, and is marked as such - it has not been independently confirmed against a separate mined transaction.", - + "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-non-abi-dispatch.schema.json", "context": { "$id": "Balancer Batch Relayer Library", "contract": { "deployments": [ - { "chainId": 1, "address": "0xeA66501dF1A00261E3bB79D1E90444fc6A186B62" } + { + "chainId": 1, + "address": "0xeA66501dF1A00261E3bB79D1E90444fc6A186B62" + } ] } }, - "metadata": { "owner": "Balancer", "contractName": "BatchRelayerLibrary", @@ -19,77 +18,139 @@ "url": "https://docs-v2.balancer.fi/concepts/advanced/relayers.html" } }, - "display": { "formats": { - "setRelayerApproval(address relayer,bool approved,bytes authorization)": { "$id": "Balancer Relayer Approval", "intent": "Allow relayer to act on your behalf", "fields": [ - { "path": "relayer", "label": "Relayer", "format": "addressName" }, - { "path": "approved", "label": "Approved", "format": "raw" }, + { + "path": "relayer", + "label": "Relayer", + "format": "addressName" + }, + { + "path": "approved", + "label": "Approved", + "format": "raw" + }, { "path": "authorization", "label": "Signed authorization", - "format": "custom", "layout": { "type": "object", "fields": [ - { "name": "deadline", "schema": { "type": "uint", "bytes": 32 } }, - { "name": "v", "schema": { "type": "uint", "bytes": 32 } }, - { "name": "r", "schema": { "type": "bytes", "length": 32 } }, - { "name": "s", "schema": { "type": "bytes", "length": 32 } } + { + "name": "deadline", + "schema": { + "type": "uint", + "bytes": 32 + } + }, + { + "name": "v", + "schema": { + "type": "uint", + "bytes": 32 + } + }, + { + "name": "r", + "schema": { + "type": "bytes", + "length": 32 + } + }, + { + "name": "s", + "schema": { + "type": "bytes", + "length": 32 + } + } ] - }, - "$comment": "A fixed, non-branching packed record (Vault-specific EIP-712 authorization: deadline, then v/r/s each occupying a full 32-byte word, not tightly packed) - object is enough here, no switch/tag involved. Confirmed against the cited transaction: deadline = type(uint256).max (never expires), v = 28." + } }, { "path": "authorization.deadline", "label": "Valid until", "format": "date", - "params": { "encoding": "timestamp" } + "params": { + "encoding": "timestamp" + } } ] }, - "joinPool(bytes32 poolId,uint8 kind,address sender,address recipient,(address[],uint256[],bytes,bool) request,uint256 value,uint256 outputReference)": { "$id": "Balancer Relayer Join Pool", "intent": "Add liquidity", - "$comment": "Not independently confirmed against a mined transaction - built symmetrically to the verified exitPool entry below, from VaultActions.sol/WeightedPoolUserData.sol/StablePoolUserData.sol source. Only kind=0x00 (WEIGHTED), JoinKind.EXACT_TOKENS_IN_FOR_BPT_OUT (0x01) is covered, the one join kind the relayer itself performs chained-reference substitution on (VaultActions._doWeightedJoinChainedReferenceReplacements/_doStableJoinChainedReferenceReplacements: 'All other join kinds are given out ... so we don't do replacements for those') - a production descriptor would cover LEGACY_STABLE/COMPOSABLE_STABLE/COMPOSABLE_STABLE_V2 (kind=0x01-0x03) and the other JoinKind values too.", "fields": [ { "path": "poolId", "label": "Pool", - "format": "custom", "layout": { "type": "object", "fields": [ - { "name": "poolAddress", "schema": { "type": "address" } }, - { "name": "specialization", "schema": { "type": "uint", "bytes": 2 } }, - { "name": "nonce", "schema": { "type": "uint", "bytes": 10 } } + { + "name": "poolAddress", + "schema": { + "type": "address" + } + }, + { + "name": "specialization", + "schema": { + "type": "uint", + "bytes": 2 + } + }, + { + "name": "nonce", + "schema": { + "type": "uint", + "bytes": 10 + } + } ] - }, - "$comment": "Pure bit-shift extraction (PoolRegistry._getPoolAddress/_getPoolSpecialization), no chain read - the pool's address is embedded in poolId at registration time and is immutable thereafter." + } + }, + { + "path": "poolId.poolAddress", + "label": "Pool", + "format": "addressName" + }, + { + "path": "sender", + "label": "From", + "format": "addressName" + }, + { + "path": "recipient", + "label": "Recipient", + "format": "addressName" }, - { "path": "poolId.poolAddress", "label": "Pool", "format": "addressName" }, - { "path": "sender", "label": "From", "format": "addressName" }, - { "path": "recipient", "label": "Recipient", "format": "addressName" }, { "path": "request.maxAmountsIn[]", "label": "Maximum amount in", "format": "tokenAmount", - "params": { "tokenPath": "request.assets[]" } + "params": { + "tokenPath": "request.assets[]" + } }, { "path": "request.userData", "label": "Join details", "switch": { - "expression": { "path": "kind" }, + "expression": { + "path": "kind" + }, "cases": { "0x00": { "switch": { - "expression": { "type": "uint", "bytes": 32 }, + "expression": { + "type": "uint", + "bytes": 32 + }, "cases": { "0x01": { "(uint8 joinKind,uint256[] amountsIn,uint256 minBptAmountOut)": { @@ -99,24 +160,33 @@ "path": "amountsIn[]", "label": "Amount in", "format": "tokenAmount", - "params": { "tokenPath": "#.request.assets[]" }, - "$comment": "amountsIn is decoded from inside userData, a switch-matched local tuple - request.assets is a sibling of userData one level up in the outer call, not reachable by a relative path. #. crossing that scope boundary is this ERC's own clarification (see Path addressing/Rationale), not an established base-ERC-7730 behavior; example-universal-router.json leaves its own analogous case as a raw number instead, written before this clarification existed." + "params": { + "tokenPath": "#.request.assets[]" + } }, { "path": "minBptAmountOut", "label": "Minimum pool tokens out", "layout": { "type": "switch", - "expression": { "type": "uint", "bytes": 32, "mask": "0xfff0000000000000000000000000000000000000000000000000000000000000" }, + "expression": { + "type": "uint", + "bytes": 32, + "mask": "0xfff0000000000000000000000000000000000000000000000000000000000000" + }, "cases": { "0xba10000000000000000000000000000000000000000000000000000000000000": { - "label": "Dynamic value — set by an earlier step in this transaction, not known yet", + "label": "Dynamic value \u2014 set by an earlier step in this transaction, not known yet", "intent": "warning" }, - "$default": { "format": "tokenAmount", "params": { "tokenPath": "#.poolId.poolAddress" } } + "$default": { + "format": "tokenAmount", + "params": { + "tokenPath": "#.poolId.poolAddress" + } + } } - }, - "$comment": "The pool's own BPT is the token being minted, so the token to pair against is #.poolId.poolAddress - decoded at the very top of this call, three switch/layout scopes above this field." + } } ] } @@ -129,27 +199,39 @@ } } }, - { "path": "request.fromInternalBalance", "label": "Pay from Vault internal balance", "format": "raw" }, - { "path": "value", "label": "ETH sent", "format": "amount" }, + { + "path": "request.fromInternalBalance", + "label": "Pay from Vault internal balance", + "format": "raw" + }, + { + "path": "value", + "label": "ETH sent", + "format": "amount" + }, { "path": "outputReference", "label": "Save result as", "layout": { "type": "switch", - "expression": { "type": "uint", "bytes": 32, "mask": "0xfff0000000000000000000000000000000000000000000000000000000000000" }, + "expression": { + "type": "uint", + "bytes": 32, + "mask": "0xfff0000000000000000000000000000000000000000000000000000000000000" + }, "cases": { "0xba10000000000000000000000000000000000000000000000000000000000000": { "label": "Stored for use by a later step in this transaction", "intent": "info" }, - "$default": { "format": "raw" } + "$default": { + "format": "raw" + } } - }, - "$comment": "Unlike exitPool's outputReferences[].key below, there is no on-chain requirement that this be a chained reference - 0 ('do not store') is a common, valid, non-sentinel value, so $default falls through to a plain display rather than reject." + } } ] }, - "exitPool(bytes32 poolId,uint8 kind,address sender,address recipient,(address[],uint256[],bytes,bool) request,(uint256,uint256)[] outputReferences)": { "$id": "Balancer Relayer Exit Pool", "intent": "Remove liquidity", @@ -157,30 +239,63 @@ { "path": "poolId", "label": "Pool", - "format": "custom", "layout": { "type": "object", "fields": [ - { "name": "poolAddress", "schema": { "type": "address" } }, - { "name": "specialization", "schema": { "type": "uint", "bytes": 2 } }, - { "name": "nonce", "schema": { "type": "uint", "bytes": 10 } } + { + "name": "poolAddress", + "schema": { + "type": "address" + } + }, + { + "name": "specialization", + "schema": { + "type": "uint", + "bytes": 2 + } + }, + { + "name": "nonce", + "schema": { + "type": "uint", + "bytes": 10 + } + } ] } }, - { "path": "poolId.poolAddress", "label": "Pool", "format": "addressName" }, - { "path": "sender", "label": "From", "format": "addressName" }, - { "path": "recipient", "label": "Recipient", "format": "addressName" }, + { + "path": "poolId.poolAddress", + "label": "Pool", + "format": "addressName" + }, + { + "path": "sender", + "label": "From", + "format": "addressName" + }, + { + "path": "recipient", + "label": "Recipient", + "format": "addressName" + }, { "path": "request.minAmountsOut[]", "label": "Minimum amount out", "format": "tokenAmount", - "params": { "tokenPath": "request.assets[]" } + "params": { + "tokenPath": "request.assets[]" + } }, { "path": "request.userData", "label": "Exit details", "switch": { - "expression": { "type": "uint", "bytes": 32 }, + "expression": { + "type": "uint", + "bytes": 32 + }, "cases": { "0xff": { "(uint8 exitKind,uint256 bptAmountIn)": { @@ -191,27 +306,40 @@ "label": "Pool tokens in", "layout": { "type": "switch", - "expression": { "type": "uint", "bytes": 32, "mask": "0xfff0000000000000000000000000000000000000000000000000000000000000" }, + "expression": { + "type": "uint", + "bytes": 32, + "mask": "0xfff0000000000000000000000000000000000000000000000000000000000000" + }, "cases": { "0xba10000000000000000000000000000000000000000000000000000000000000": { - "label": "Dynamic value — set by an earlier step in this transaction, not known yet", + "label": "Dynamic value \u2014 set by an earlier step in this transaction, not known yet", "intent": "warning" }, - "$default": { "format": "tokenAmount", "params": { "tokenPath": "#.poolId.poolAddress" } } + "$default": { + "format": "tokenAmount", + "params": { + "tokenPath": "#.poolId.poolAddress" + } + } } } } ] - }, - "$comment": "255 (0xff) is BasePoolUserData.RECOVERY_MODE_EXIT_KIND, deliberately the maximum uint8 value 'to prevent conflicts with future additions to the ExitKind enums' - common to every pool type, checked before kind is even consulted. Not independently mined; verified against BasePoolUserData.sol source." + } }, "$default": { "switch": { - "expression": { "path": "kind" }, + "expression": { + "path": "kind" + }, "cases": { "0x00": { "switch": { - "expression": { "type": "uint", "bytes": 32 }, + "expression": { + "type": "uint", + "bytes": 32 + }, "cases": { "0x01": { "(uint8 exitKind,uint256 bptAmountIn)": { @@ -222,16 +350,24 @@ "label": "Pool tokens in", "layout": { "type": "switch", - "expression": { "type": "uint", "bytes": 32, "mask": "0xfff0000000000000000000000000000000000000000000000000000000000000" }, + "expression": { + "type": "uint", + "bytes": 32, + "mask": "0xfff0000000000000000000000000000000000000000000000000000000000000" + }, "cases": { "0xba10000000000000000000000000000000000000000000000000000000000000": { - "label": "Dynamic value — set by an earlier step in this transaction, not known yet", + "label": "Dynamic value \u2014 set by an earlier step in this transaction, not known yet", "intent": "warning" }, - "$default": { "format": "tokenAmount", "params": { "tokenPath": "#.poolId.poolAddress" } } + "$default": { + "format": "tokenAmount", + "params": { + "tokenPath": "#.poolId.poolAddress" + } + } } - }, - "$comment": "Confirmed against the cited transaction's entry 1 (exiting the Weighted pool): a literal amount, 16,250.97 pool tokens - the $default branch, not the sentinel." + } } ] } @@ -242,7 +378,10 @@ }, "0x03": { "switch": { - "expression": { "type": "uint", "bytes": 32 }, + "expression": { + "type": "uint", + "bytes": 32 + }, "cases": { "0x02": { "(uint8 exitKind,uint256 bptAmountIn)": { @@ -253,16 +392,24 @@ "label": "Pool tokens in", "layout": { "type": "switch", - "expression": { "type": "uint", "bytes": 32, "mask": "0xfff0000000000000000000000000000000000000000000000000000000000000" }, + "expression": { + "type": "uint", + "bytes": 32, + "mask": "0xfff0000000000000000000000000000000000000000000000000000000000000" + }, "cases": { "0xba10000000000000000000000000000000000000000000000000000000000000": { - "label": "Dynamic value — set by an earlier step in this transaction, not known yet", + "label": "Dynamic value \u2014 set by an earlier step in this transaction, not known yet", "intent": "warning" }, - "$default": { "format": "tokenAmount", "params": { "tokenPath": "#.poolId.poolAddress" } } + "$default": { + "format": "tokenAmount", + "params": { + "tokenPath": "#.poolId.poolAddress" + } + } } - }, - "$comment": "Confirmed against the cited transaction's entry 2 (exiting the Stable pool): the sentinel branch - the exact chained reference (index 0, key 0xba10...0) that entry 1's outputReferences populated. This is the concrete case the sentinel extension exists for: this number cannot be known until entry 1 has executed on-chain." + } } ] } @@ -278,22 +425,28 @@ } } } - }, - "$comment": "Recovery mode is checked first, unconditionally of kind, matching VaultActions._doExitPoolChainedReferenceReplacements' real precedence ('Must check for the recovery mode ExitKind first ... common to all pool types'). kind=0x01 (LEGACY_STABLE) and 0x02 (COMPOSABLE_STABLE v1) are left as reject: not exercised by the cited transaction, and VaultActions.sol itself documents that ExitKind numbering differs even within the Stable family across these versions ('BPT_IN_FOR_EXACT_TOKENS_OUT is 2 in legacy Stable Pools, but 1 in Composable Stable Pools') - a production descriptor would need their own separate tables, not a shared one." + } + }, + { + "path": "request.toInternalBalance", + "label": "Receive to Vault internal balance", + "format": "raw" }, - { "path": "request.toInternalBalance", "label": "Receive to Vault internal balance", "format": "raw" }, { "path": "outputReferences[].index", "label": "Token", - "format": "raw", - "$comment": "This indexes into request.assets - but as a value only known once decoded, not a fixed array position, so the established same-index array-pairing rule (params array read at the same index as the element being formatted) doesn't apply here: that rule pairs two arrays at the SAME position, not one array read at a position given by another array's own decoded value. Resolving this to an actual token/symbol is a further gap this worked example does not attempt to close." + "format": "raw" }, { "path": "outputReferences[].key", "label": "Save result as", "layout": { "type": "switch", - "expression": { "type": "uint", "bytes": 32, "mask": "0xfff0000000000000000000000000000000000000000000000000000000000000" }, + "expression": { + "type": "uint", + "bytes": 32, + "mask": "0xfff0000000000000000000000000000000000000000000000000000000000000" + }, "cases": { "0xba10000000000000000000000000000000000000000000000000000000000000": { "label": "Stored for use by a later step in this transaction", @@ -301,12 +454,10 @@ }, "$default": "reject" } - }, - "$comment": "Unlike joinPool's outputReference, VaultActions.exitPool requires every entry here to be a chained reference (require(_isChainedReference(outputReferences[i].key), \"invalid chained reference\")) - a non-sentinel value is dead on arrival, so $default is reject rather than a plain display, matching this ERC's fail-closed rule for anything the real contract would itself revert on." + } } ] } - } } } diff --git a/assets/erc-non-abi-dispatch/example-balancer-relayer-multicall.json b/assets/erc-non-abi-dispatch/example-balancer-relayer-multicall.json index 161f4cdbed0..91be1e2361c 100644 --- a/assets/erc-non-abi-dispatch/example-balancer-relayer-multicall.json +++ b/assets/erc-non-abi-dispatch/example-balancer-relayer-multicall.json @@ -1,17 +1,16 @@ { - "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.schema.json", - - "$comment": "Worked example for the non-ABI-dispatch companion ERC, decoding Balancer's BalancerRelayer.multicall(bytes[] data) - the outer entry point a human wallet actually signs against. Each data[i] is itself a complete, selector-prefixed call, but it is delegatecalled into a separate, fixed BatchRelayerLibrary contract (0xeA66501dF1A00261E3bB79D1E90444fc6A186B62), not into this relayer address, and that address never appears anywhere in the calldata - it is baked into the relayer's own immutable bytecode. That is why this is a separate descriptor file from example-balancer-relayer-library.json (which describes the library's own setRelayerApproval/joinPool/exitPool), following the same dispatcher/target split this ERC already uses for TieredExecutor. Verified against Ethereum mainnet tx 0x9ebb8a7d7c085b3dde80c02f5bc44a1d32749104e2b17d17bf72d7f674ef1b34 (2026-05-22, block 25154060) - see the ERC's Test Cases section.", - + "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-non-abi-dispatch.schema.json", "context": { "$id": "Balancer Relayer Multicall", "contract": { "deployments": [ - { "chainId": 1, "address": "0x35Cea9e57A393ac66Aaa7E25C391D52C74B5648f" } + { + "chainId": 1, + "address": "0x35Cea9e57A393ac66Aaa7E25C391D52C74B5648f" + } ] } }, - "metadata": { "owner": "Balancer", "contractName": "BalancerRelayer", @@ -19,7 +18,6 @@ "url": "https://docs-v2.balancer.fi/concepts/advanced/relayers.html" } }, - "display": { "formats": { "multicall(bytes[] data)": { @@ -33,8 +31,7 @@ "params": { "callee": "0xeA66501dF1A00261E3bB79D1E90444fc6A186B62", "operation": "delegatecall" - }, - "$comment": "callee is a literal constant, not a path: the BatchRelayerLibrary address is immutable in the relayer's own bytecode and is never itself present in calldata (base ERC-7730's calleePath/callee already allows either form). operation is this companion ERC's addition - every entry executes as a DELEGATECALL, in the relayer's own storage and identity, not a plain call to the library." + } } ] } diff --git a/assets/erc-non-abi-dispatch/example-cctp-message.json b/assets/erc-non-abi-dispatch/example-cctp-message.json index a6a97e266cf..5b2d00f7016 100644 --- a/assets/erc-non-abi-dispatch/example-cctp-message.json +++ b/assets/erc-non-abi-dispatch/example-cctp-message.json @@ -1,17 +1,16 @@ { - "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.schema.json", - - "$comment": "Worked example for the non-ABI-dispatch companion ERC, decoding Circle CCTP's cross-chain message header. Verified against a real Base-to-Ethereum transfer: burn tx 0x178632412a0eb4e642bfe30b1f80d0a4799ab400d4d1c76702ba01ba1458b57f (Base) and mint tx 0xa5ab46a57e89fe110df3269065c0c07a394f22fe7a769bb916a547c7c1b3e99f (Ethereum) - see the ERC's Test Cases section. The MessageTransmitter address was not independently re-verified during this research pass; confirm the real deployment address for your target chain against https://developers.circle.com/cctp/evm-smart-contracts before use.", - + "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-non-abi-dispatch.schema.json", "context": { "$id": "Circle CCTP MessageTransmitter", "contract": { "deployments": [ - { "chainId": 1, "address": "0xYourMessageTransmitterAddress" } + { + "chainId": 1, + "address": "0xYourMessageTransmitterAddress" + } ] } }, - "metadata": { "owner": "Circle", "contractName": "MessageTransmitter", @@ -19,7 +18,6 @@ "url": "https://developers.circle.com/cctp/v1/message-format" } }, - "display": { "formats": { "receiveMessage(bytes message,bytes attestation)": { @@ -30,22 +28,100 @@ "path": "message", "label": "Message", "layout": { - "kind": "struct", + "type": "object", "fields": [ - { "name": "version", "schema": { "kind": "uint", "bytes": 4 } }, - { "name": "sourceDomain", "schema": { "kind": "uint", "bytes": 4 } }, - { "name": "destinationDomain", "schema": { "kind": "uint", "bytes": 4 } }, - { "name": "nonce", "schema": { "kind": "uint", "bytes": 8 } }, - { "name": "sender", "schema": { "kind": "bytes", "length": 32 } }, - { "name": "recipient", "schema": { "kind": "bytes", "length": 32 } }, - { "name": "destinationCaller", "schema": { "kind": "bytes", "length": 32 } }, - { "name": "messageBody", "schema": { "kind": "struct", "fields": [ - { "name": "version", "schema": { "kind": "uint", "bytes": 4 } }, - { "name": "burnToken", "schema": { "kind": "bytes", "length": 32 } }, - { "name": "mintRecipient", "schema": { "kind": "bytes", "length": 32 } }, - { "name": "amount", "schema": { "kind": "uint", "bytes": 32 } }, - { "name": "messageSender", "schema": { "kind": "bytes", "length": 32 } } - ]}} + { + "name": "version", + "schema": { + "type": "uint", + "bytes": 4 + } + }, + { + "name": "sourceDomain", + "schema": { + "type": "uint", + "bytes": 4 + } + }, + { + "name": "destinationDomain", + "schema": { + "type": "uint", + "bytes": 4 + } + }, + { + "name": "nonce", + "schema": { + "type": "uint", + "bytes": 8 + } + }, + { + "name": "sender", + "schema": { + "type": "bytes", + "length": 32 + } + }, + { + "name": "recipient", + "schema": { + "type": "bytes", + "length": 32 + } + }, + { + "name": "destinationCaller", + "schema": { + "type": "bytes", + "length": 32 + } + }, + { + "name": "messageBody", + "schema": { + "type": "object", + "fields": [ + { + "name": "version", + "schema": { + "type": "uint", + "bytes": 4 + } + }, + { + "name": "burnToken", + "schema": { + "type": "bytes", + "length": 32 + } + }, + { + "name": "mintRecipient", + "schema": { + "type": "bytes", + "length": 32 + } + }, + { + "name": "amount", + "schema": { + "type": "uint", + "bytes": 32 + } + }, + { + "name": "messageSender", + "schema": { + "type": "bytes", + "length": 32 + } + } + ] + } + } ] } } diff --git a/assets/erc-non-abi-dispatch/example-deterministic-deployment-proxy.json b/assets/erc-non-abi-dispatch/example-deterministic-deployment-proxy.json deleted file mode 100644 index 8767582f888..00000000000 --- a/assets/erc-non-abi-dispatch/example-deterministic-deployment-proxy.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.schema.json", - - "$comment": "Worked example for the non-ABI-dispatch companion ERC, demonstrating `initCode` and `$fallback`. Context: the deterministic deployment proxy at 0x4e59b44847b379578588920cA78FbF26c0B4956C (Arachnid/'Nick's method') is deployed at this identical address on nearly every EVM chain and dispatches via a raw fallback with no ABI selector at all - calldata is exactly `salt (32 bytes) || initCode`, fed directly into CREATE2. It is used to deploy many well-known contracts deterministically, including Uniswap's own Permit2 (0x000000000022D473030F116dDEE9F6B43aC78BA3, the same address on every chain it's deployed to) and EIP-1167 minimal proxy clones. The EIP-1167 template below (prefix/suffix/address-offset) is byte-exact, verified against the standard's own bytecode listing (https://eips.ethereum.org/EIPS/eip-1167). The Permit2 template's `prefix.hash`/`prefix.length` are ILLUSTRATIVE PLACEHOLDERS - a real descriptor must compute them from Permit2's exact, compiler-version-specific creation bytecode, which was not independently re-derived for this example.", - - "context": { - "$id": "Deterministic Deployment Proxy", - "contract": { - "deployments": [ - { "chainId": 1, "address": "0x4e59b44847b379578588920cA78FbF26c0B4956C" }, - { "chainId": 8453, "address": "0x4e59b44847b379578588920cA78FbF26c0B4956C" }, - { "chainId": 42161, "address": "0x4e59b44847b379578588920cA78FbF26c0B4956C" }, - { "chainId": 10, "address": "0x4e59b44847b379578588920cA78FbF26c0B4956C" } - ] - }, - "$comment": "Same address on essentially every EVM chain by construction (see Rationale in the ERC); the chainIds above are a representative sample, not an exhaustive list." - }, - - "metadata": { - "owner": "Arachnid (Nick Johnson)", - "contractName": "Deterministic Deployment Proxy", - "info": { - "url": "https://github.com/Arachnid/deterministic-deployment-proxy" - } - }, - - "display": { - "formats": { - "$fallback": { - "$id": "Deploy via CREATE2", - "intent": "Deploy contract", - "fields": [ - { "path": "salt", "label": "Salt", "format": "bytes32" }, - { - "path": "initCode", - "label": "Contract to deploy", - "layout": { - "kind": "initCode", - "templates": [ - { - "id": "permit2", - "$comment": "NOT A REAL VALUE - placeholder only. A real entry needs `hash` = keccak256 of Permit2's exact, compiler-version-specific creation bytecode (constructor takes no arguments, hence the empty `args` tuple), and `length` = that bytecode's exact byte length. Neither was independently computed for this example; see the file's top-level $comment.", - "prefix": { "hash": "0x", "length": "" }, - "args": { "abiType": "()" } - }, - { - "id": "eip1167-minimal-proxy", - "prefix": { "literal": "0x3d602d80600a3d3981f3363d3d373d3d3d363d73" }, - "suffix": { "literal": "0x5af43d82803e903d91602b57fd5bf3" }, - "args": { "layout": { - "kind": "struct", - "fields": [ - { "name": "implementation", "schema": { "kind": "address" } } - ] - }} - } - ], - "default": "reject" - } - } - ] - } - } - } -} diff --git a/assets/erc-non-abi-dispatch/example-eas-attestation.json b/assets/erc-non-abi-dispatch/example-eas-attestation.json index 1798d8cbbf2..a7bded6ebe2 100644 --- a/assets/erc-non-abi-dispatch/example-eas-attestation.json +++ b/assets/erc-non-abi-dispatch/example-eas-attestation.json @@ -1,17 +1,16 @@ { - "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.schema.json", - - "$comment": "Worked example for the non-ABI-dispatch companion ERC, decoding an EAS attestation via schema-UID dispatch. Verified against a real Optimism mainnet attestation, tx 0x820e5b8404f1ec62b47459e538151e54fb598b729dcb461087456bb856abf595 - see the ERC's Test Cases section. The EAS contract address is a placeholder; confirm the real deployment address for your target chain against https://docs.attest.org before use.", - + "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-non-abi-dispatch.schema.json", "context": { "$id": "Ethereum Attestation Service", "contract": { "deployments": [ - { "chainId": 10, "address": "0xYourEASAddress" } + { + "chainId": 10, + "address": "0xYourEASAddress" + } ] } }, - "metadata": { "owner": "Ethereum Attestation Service", "contractName": "EAS", @@ -19,24 +18,30 @@ "url": "https://docs.attest.org" } }, - "display": { "formats": { "attest((bytes32 schema,(address recipient,uint64 expirationTime,bool revocable,bytes32 refUID,bytes data,uint256 value) data) request)": { "$id": "EAS Attest", "intent": "Attest", "fields": [ - { "path": "request.data.recipient", "label": "Recipient", "format": "addressName" }, + { + "path": "request.data.recipient", + "label": "Recipient", + "format": "addressName" + }, { "path": "request.data.data", "label": "Attestation data", - "dispatch": { - "tag": { "path": "request.schema" }, - "cases": { - "0xfdcfdad2dbe7489e0ce56b260348b7f14e8365a8a325aef9834818c00d46b31b": - { "abiType": "(string rpgfRound,address referredBy,string referredMethod)" } + "switch": { + "expression": { + "path": "request.schema" }, - "default": "reject" + "cases": { + "0xfdcfdad2dbe7489e0ce56b260348b7f14e8365a8a325aef9834818c00d46b31b": { + "abiType": "(string rpgfRound,address referredBy,string referredMethod)" + }, + "$default": "reject" + } } } ] diff --git a/assets/erc-non-abi-dispatch/example-erc7579-execute.json b/assets/erc-non-abi-dispatch/example-erc7579-execute.json index dcff63a8f44..7a58770baa0 100644 --- a/assets/erc-non-abi-dispatch/example-erc7579-execute.json +++ b/assets/erc-non-abi-dispatch/example-erc7579-execute.json @@ -1,18 +1,20 @@ { - "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.schema.json", - - "$comment": "Worked example for the non-ABI-dispatch companion ERC, decoding ERC-7579's mode-driven execute() dispatch. Verified against two real Base mainnet Biconomy Nexus transactions: single-call 0x057b1df67f033ad77faba10e39f39dde273c225d62c3b36ef8547b3f51fad5c1 and batch-call 0x26d34bf7aa5adb0642218422264a4034ffda5785be3354168eb478051664613c - see the ERC's Test Cases section. Only CallType 0x00 (single) and 0x01 (batch) are covered; a production descriptor would also handle 0xfe (staticcall) and 0xff (delegatecall).", - + "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-non-abi-dispatch.schema.json", "context": { "$id": "ERC-7579 Account Execute", "contract": { "deployments": [ - { "chainId": 8453, "address": "0x510a1274373c8120DE634dB372723edcBE899994" }, - { "chainId": 8453, "address": "0xE8cCb14989F0dB3f51A71876195D5867F7fa943d" } + { + "chainId": 8453, + "address": "0x510a1274373c8120DE634dB372723edcBE899994" + }, + { + "chainId": 8453, + "address": "0xE8cCb14989F0dB3f51A71876195D5867F7fa943d" + } ] } }, - "metadata": { "owner": "ERC-7579", "contractName": "Modular Smart Account", @@ -20,7 +22,6 @@ "url": "https://eips.ethereum.org/EIPS/eip-7579" } }, - "display": { "formats": { "execute(bytes32 mode,bytes executionCalldata)": { @@ -31,40 +32,104 @@ "path": "mode", "label": "Mode", "layout": { - "kind": "struct", + "type": "object", "fields": [ - { "name": "callType", "schema": { "kind": "uint", "bytes": 1 } }, - { "name": "execType", "schema": { "kind": "uint", "bytes": 1 } }, - { "name": "unused", "schema": { "kind": "bytes", "length": 4 } }, - { "name": "modeSelector", "schema": { "kind": "bytes", "length": 4 } }, - { "name": "modePayload", "schema": { "kind": "bytes", "length": 22 } } + { + "name": "callType", + "schema": { + "type": "uint", + "bytes": 1 + } + }, + { + "name": "execType", + "schema": { + "type": "uint", + "bytes": 1 + } + }, + { + "name": "unused", + "schema": { + "type": "bytes", + "length": 4 + } + }, + { + "name": "modeSelector", + "schema": { + "type": "bytes", + "length": 4 + } + }, + { + "name": "modePayload", + "schema": { + "type": "bytes", + "length": 22 + } + } ] } }, { "path": "executionCalldata", "label": "Execution", - "dispatch": { - "tag": { "path": "mode.callType" }, - "cases": { - "0x00": { "layout": { - "kind": "struct", - "fields": [ - { "name": "target", "schema": { "kind": "address" } }, - { "name": "value", "schema": { "kind": "uint", "bytes": 32 } }, - { "name": "callData", "schema": { "kind": "call", "to": "target" } } - ] - }}, - "0x01": { "abiType": "(address target,uint256 value,bytes callData)[]" } + "switch": { + "expression": { + "path": "mode.callType" }, - "default": "reject" + "cases": { + "0x00": { + "layout": { + "type": "object", + "fields": [ + { + "name": "target", + "schema": { + "type": "address" + } + }, + { + "name": "value", + "schema": { + "type": "uint", + "bytes": 32 + } + }, + { + "name": "callData", + "schema": { + "type": "bytes" + } + } + ] + } + }, + "0x01": { + "abiType": "(address target,uint256 value,bytes callData)[]" + }, + "$default": "reject" + } + } + }, + { + "path": "executionCalldata.callData", + "label": "Batched call data", + "format": "calldata", + "params": { + "calleePath": "executionCalldata.target", + "amountPath": "executionCalldata.value" } }, { "path": "executionCalldata[].callData", "label": "Batched call data", - "layout": { "kind": "call", "to": "executionCalldata[].target" }, - "$comment": "Only applicable when mode.callType == 0x01: each ABI-decoded Execution.callData in the batch is itself calldata to its sibling target, resolved the same way as the single-call case above." + "format": "calldata", + "params": { + "calleePath": "executionCalldata[].target", + "amountPath": "executionCalldata[].value" + } } ] } diff --git a/assets/erc-non-abi-dispatch/example-erc7683-order.json b/assets/erc-non-abi-dispatch/example-erc7683-order.json index daefee95f24..837f2890f6f 100644 --- a/assets/erc-non-abi-dispatch/example-erc7683-order.json +++ b/assets/erc-non-abi-dispatch/example-erc7683-order.json @@ -1,17 +1,16 @@ { - "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.schema.json", - - "$comment": "Worked example for the non-ABI-dispatch companion ERC, decoding an ERC-7683 cross-chain order via orderDataType dispatch. Verified against a real Base mainnet transaction, tx 0x73b3ca11e3733c78db8365086f64874879fb3a859b21c960ec072c2a95c5da09 - see the ERC's Test Cases section.", - + "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-non-abi-dispatch.schema.json", "context": { "$id": "Across Origin Settler", "contract": { "deployments": [ - { "chainId": 8453, "address": "0x4afb570AC68BfFc26Bb02FdA3D801728B0f93C9E" } + { + "chainId": 8453, + "address": "0x4afb570AC68BfFc26Bb02FdA3D801728B0f93C9E" + } ] } }, - "metadata": { "owner": "Across Protocol", "contractName": "AcrossOriginSettler", @@ -19,24 +18,33 @@ "url": "https://docs.across.to/guides/concepts/erc-7683" } }, - "display": { "formats": { "open((uint32 fillDeadline,bytes32 orderDataType,bytes orderData) order)": { "$id": "ERC-7683 Open Order", "intent": "Open cross-chain order", "fields": [ - { "path": "order.fillDeadline", "label": "Fill deadline", "format": "date", "params": { "encoding": "timestamp" } }, + { + "path": "order.fillDeadline", + "label": "Fill deadline", + "format": "date", + "params": { + "encoding": "timestamp" + } + }, { "path": "order.orderData", "label": "Order data", - "dispatch": { - "tag": { "path": "order.orderDataType" }, - "cases": { - "0x9df4b782e7bbc178b3b93bfe8aafb909e84e39484d7f3c59f400f1b4691f85e2": - { "abiType": "(address inputToken,uint256 inputAmount,address outputToken,uint256 outputAmount,uint256 destinationChainId,bytes32 recipient,address exclusiveRelayer,uint256 depositNonce,uint32 exclusivityPeriod,bytes message)" } + "switch": { + "expression": { + "path": "order.orderDataType" }, - "default": "reject" + "cases": { + "0x9df4b782e7bbc178b3b93bfe8aafb909e84e39484d7f3c59f400f1b4691f85e2": { + "abiType": "(address inputToken,uint256 inputAmount,address outputToken,uint256 outputAmount,uint256 destinationChainId,bytes32 recipient,address exclusiveRelayer,uint256 depositNonce,uint32 exclusivityPeriod,bytes message)" + }, + "$default": "reject" + } } } ] diff --git a/assets/erc-non-abi-dispatch/example-legacy-token.json b/assets/erc-non-abi-dispatch/example-legacy-token.json index ae5999f66e7..82d833f399a 100644 --- a/assets/erc-non-abi-dispatch/example-legacy-token.json +++ b/assets/erc-non-abi-dispatch/example-legacy-token.json @@ -1,30 +1,36 @@ { - "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.schema.json", - - "$comment": "Illustrative target-interface descriptor. Resolved recursively when TieredExecutor's `op` dispatches to case 2 - see example-tiered-executor.json. Not deployed anywhere; the address below is a placeholder.", - + "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-non-abi-dispatch.schema.json", "context": { "$id": "Legacy Token", "contract": { "deployments": [ - { "chainId": 1, "address": "0xYourLegacyTokenAddress" } + { + "chainId": 1, + "address": "0xYourLegacyTokenAddress" + } ] } }, - "metadata": { "owner": "Example", "contractName": "Legacy Token" }, - "display": { "formats": { "creditAccount(uint256 amount,address to)": { "intent": "Credit legacy balance", "interpolatedIntent": "Credit {to} with {amount}", "fields": [ - { "path": "amount", "label": "Amount", "format": "amount" }, - { "path": "to", "label": "Recipient", "format": "addressName" } + { + "path": "amount", + "label": "Amount", + "format": "amount" + }, + { + "path": "to", + "label": "Recipient", + "format": "addressName" + } ] } } diff --git a/assets/erc-non-abi-dispatch/example-reward-vault.json b/assets/erc-non-abi-dispatch/example-reward-vault.json index fce98d2eebb..4a771aa24f1 100644 --- a/assets/erc-non-abi-dispatch/example-reward-vault.json +++ b/assets/erc-non-abi-dispatch/example-reward-vault.json @@ -1,30 +1,36 @@ { - "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.schema.json", - - "$comment": "Illustrative target-interface descriptor. Resolved recursively when TieredExecutor's `op` dispatches to case 1 - see example-tiered-executor.json. Not deployed anywhere; the address below is a placeholder.", - + "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-non-abi-dispatch.schema.json", "context": { "$id": "Reward Vault", "contract": { "deployments": [ - { "chainId": 1, "address": "0xYourRewardVaultAddress" } + { + "chainId": 1, + "address": "0xYourRewardVaultAddress" + } ] } }, - "metadata": { "owner": "Example", "contractName": "Reward Vault" }, - "display": { "formats": { "grantReward(address to,uint256 amount)": { "intent": "Grant reward", "interpolatedIntent": "Grant {amount} reward to {to}", "fields": [ - { "path": "to", "label": "Recipient", "format": "addressName" }, - { "path": "amount", "label": "Amount", "format": "amount" } + { + "path": "to", + "label": "Recipient", + "format": "addressName" + }, + { + "path": "amount", + "label": "Amount", + "format": "amount" + } ] } } diff --git a/assets/erc-non-abi-dispatch/example-safe-exectransaction.json b/assets/erc-non-abi-dispatch/example-safe-exectransaction.json deleted file mode 100644 index 34d929857d6..00000000000 --- a/assets/erc-non-abi-dispatch/example-safe-exectransaction.json +++ /dev/null @@ -1,151 +0,0 @@ -{ - "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.schema.json", - - "$comment": "Worked example for the non-ABI-dispatch companion ERC, decoding Safe's execTransaction(...,bytes signatures) - the motivating case for the `pointer` layout node. Verified against Safe.sol's checkNSignatures/checkContractSignature and against a real transaction for the operation/data/EOA/APPROVED_HASH parts: Safe transaction hash 0xe6cffb80c9521e152bc97b2bee23140bad634ecb02f9d5dcd57320b1cea95b60 (Ethereum mainnet, executed 2026-03-27), Safe 0xa5C629E04E563355c30885B62928fd6E03558548 (itself co-owned by GnosisDAO's own Safe, 0x0DA0C3e52C977Ed3cBc641fF02DD271c3ED55aFe - confirmed via the Safe Transaction Service's owners index), delegatecalling MultiSend (0x9641d764fc13c8B624c04430C7356C1C7C8102e2) to batch 13 ERC-20 transfer calls. signatures decodes to three 65-byte records sorted ascending by signer address: v=1 (APPROVED_HASH) from 0x1B0C638616Ed79dB430Edbf549ad9512FF4a8ed1 with s=0, then two ordinary v=27 ECDSA records - all three independently confirmed against the Safe Transaction Service's own per-signer signatureType field. No v=0 (CONTRACT_SIGNATURE) record - the one that actually exercises `pointer` - was found despite sampling 268+ real confirmations from this same nested-Safe structure; that branch is verified against Safe.sol source only, not a mined transaction. See the ERC's Test Cases section.", - - "context": { - "$id": "Safe execTransaction", - "contract": { - "deployments": [ - { "chainId": 1, "address": "0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552" } - ] - } - }, - - "metadata": { - "owner": "Safe", - "contractName": "Safe (formerly Gnosis Safe)", - "info": { - "url": "https://docs.safe.global/advanced/smart-account-signatures" - } - }, - - "display": { - "formats": { - "execTransaction(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,bytes signatures)": { - "$id": "Safe Execute Transaction", - "intent": "Execute Safe transaction", - "fields": [ - { "path": "to", "label": "To", "format": "addressName" }, - { "path": "value", "label": "Value", "format": "amount" }, - { - "path": "operation", - "label": "Call type", - "layout": { - "type": "switch", - "expression": { "type": "uint", "bytes": 1 }, - "cases": { - "0x01": { "label": "Delegatecall — runs in this Safe's own storage and identity", "intent": "warning" }, - "$default": { "label": "Call", "intent": "info" } - } - } - }, - { - "path": "data", - "label": "Call data", - "format": "calldata", - "params": { - "calleePath": "to", - "amountPath": "value", - "operation": { - "expression": { "path": "operation" }, - "cases": { "0x01": "delegatecall", "$default": "call" } - } - }, - "$comment": "Confirmed against the cited transaction: operation=1, to=MultiSend, data resolves recursively into a multiSend(bytes) call batching 13 ERC-20 transfer entries - see example-safe-multisend.json for that construct on its own." - }, - { "path": "safeTxGas", "label": "Gas budget for the call", "format": "raw" }, - { "path": "baseGas", "label": "Fixed refund overhead", "format": "raw" }, - { "path": "gasPrice", "label": "Refund gas price", "format": "raw", "$comment": "0 in the cited transaction - no refund requested, the common case." }, - { "path": "gasToken", "label": "Refund token", "format": "addressName" }, - { "path": "refundReceiver", "label": "Refund recipient", "format": "addressName" }, - { - "path": "signatures", - "label": "Signatures", - "format": "custom", - "layout": { - "type": "sequence", - "element": { - "type": "object", - "fields": [ - { "name": "r", "schema": { "type": "bytes", "length": 32 } }, - { "name": "s", "schema": { "type": "bytes", "length": 32 } }, - { "name": "v", "schema": { "type": "uint", "bytes": 1 } } - ] - } - }, - "$comment": "Confirmed against the cited transaction: 195 bytes, exactly three 65-byte records, sorted ascending by signer address (Safe's own ordering rule)." - }, - { - "path": "signatures[].v", - "label": "Signature type", - "layout": { - "type": "switch", - "expression": { "type": "uint", "bytes": 1 }, - "cases": { - "0x00": { "label": "Contract signature (EIP-1271)", "intent": "info" }, - "0x01": { "label": "Pre-approved hash", "intent": "info" }, - "$default": { "label": "ECDSA signature", "intent": "info" } - } - }, - "$comment": "v is decoded by this same layout tree's own object node, not by ABI decoding - covered by this ERC's 'layout may anchor on an already-decoded scalar' rule extending to layout-produced scalars, not only ABI-produced ones. v=1 and v=27 (falling into $default here) are both confirmed against the cited transaction; v=0 is verified against Safe.sol only." - }, - { - "path": "signatures[].r", - "label": "Signer", - "switch": { - "expression": { "path": "v" }, - "cases": { - "0x00": { "layout": { "type": "object", "fields": [ - { "name": "reserved", "schema": { "type": "bytes", "length": 12 } }, - { "name": "ownerAddress", "schema": { "type": "address" } } - ]}}, - "0x01": { "layout": { "type": "object", "fields": [ - { "name": "reserved", "schema": { "type": "bytes", "length": 12 } }, - { "name": "ownerAddress", "schema": { "type": "address" } } - ]}}, - "$default": "reject" - } - }, - "$comment": "Only v=0 and v=1 repurpose r as a padded owner address (Safe.sol: 'the address of the contract that either approved the hash or is the execTransaction() function caller'); for ordinary ECDSA (v=27/28, or 31/32 for eth_sign) r is a genuine signature component with no meaningful address to show, hence reject rather than a guessed decode. v=1 confirmed against the cited transaction (r decodes to 0x1B0C638616Ed79dB430Edbf549ad9512FF4a8ed1, matching that record's confirmed signer); v=0 verified against source only." - }, - { - "path": "signatures[].r.ownerAddress", - "label": "Signer", - "format": "addressName", - "$comment": "Only resolves for v=0/v=1 records, where the sibling switch above matched - the same conditional-path pattern example-erc7579-execute.json already uses for executionCalldata[].callData under a specific mode.callType." - }, - { - "path": "signatures[].s", - "label": "Contract signature data", - "switch": { - "expression": { "path": "v" }, - "cases": { - "0x00": { "layout": { - "type": "pointer", - "bytes": 32, - "containerPath": "#.signatures", - "destination": { - "type": "object", - "fields": [ - { "name": "len", "schema": { "type": "uint", "bytes": 32 } }, - { "name": "data", "schema": { "type": "bytes", "lengthFrom": "len" } } - ] - } - }}, - "$default": "reject" - } - }, - "$comment": "The one field in this whole descriptor that needs `pointer`: for v=0, s is not a signature component at all - it is a byte offset into #.signatures (the same buffer this whole layout tree is already decoding), pointing past every fixed 65-byte record to a length-prefixed EIP-1271 signature blob shared by every contract-signer (Safe.sol's checkContractSignature, verified precisely: 'contractSignatureLen := mload(add(add(signatures, offset), 0x20))'). Not confirmed against a mined transaction - see this file's own top-level $comment and the ERC's Test Cases entry for why. reject for every other v: s is a genuine ECDSA/unused value there, never a pointer." - }, - { - "path": "signatures[].s.data", - "label": "Contract signature data", - "format": "raw", - "$comment": "Only resolves for v=0 records, past the pointer above. This is itself an EIP-1271 signature, resolved by calling isValidSignature on the owner contract at signatures[].r.ownerAddress - if that owner is itself a Safe, this recurses into another pointer-bearing signatures buffer one level down (see Security Considerations); this descriptor does not attempt that recursive resolution, only exposes the raw bytes." - } - ] - } - } - } -} diff --git a/assets/erc-non-abi-dispatch/example-safe-multisend.json b/assets/erc-non-abi-dispatch/example-safe-multisend.json index 3d36bb365e0..4cbad053c40 100644 --- a/assets/erc-non-abi-dispatch/example-safe-multisend.json +++ b/assets/erc-non-abi-dispatch/example-safe-multisend.json @@ -1,17 +1,16 @@ { - "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.schema.json", - - "$comment": "Worked example for the non-ABI-dispatch companion ERC, decoding Safe's MultiSend packed batching format. Verified against real Ethereum mainnet tx 0x414ae5aaff729927d663ccaa027ea2284e47fa546cb73ce1dee481ee36a7138e - see the ERC's Test Cases section.", - + "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-non-abi-dispatch.schema.json", "context": { "$id": "Safe MultiSendCallOnly 1.3.0", "contract": { "deployments": [ - { "chainId": 1, "address": "0x40A2aCCbd92BCA938b02010E17A5b8929b49130D" } + { + "chainId": 1, + "address": "0x40A2aCCbd92BCA938b02010E17A5b8929b49130D" + } ] } }, - "metadata": { "owner": "Safe", "contractName": "MultiSendCallOnly", @@ -19,7 +18,6 @@ "url": "https://github.com/safe-global/safe-smart-account" } }, - "display": { "formats": { "multiSend(bytes transactions)": { @@ -30,19 +28,64 @@ "path": "transactions", "label": "Batched calls", "layout": { - "kind": "sequence", + "type": "sequence", "count": "tillEnd", "element": { - "kind": "struct", + "type": "object", "fields": [ - { "name": "operation", "schema": { "kind": "uint", "bytes": 1 } }, - { "name": "to", "schema": { "kind": "address" } }, - { "name": "value", "schema": { "kind": "uint", "bytes": 32 } }, - { "name": "dataLength", "schema": { "kind": "uint", "bytes": 32 } }, - { "name": "data", "schema": { "kind": "call", "to": "to", "lengthFrom": "dataLength" } } + { + "name": "operation", + "schema": { + "type": "uint", + "bytes": 1 + } + }, + { + "name": "to", + "schema": { + "type": "address" + } + }, + { + "name": "value", + "schema": { + "type": "uint", + "bytes": 32 + } + }, + { + "name": "dataLength", + "schema": { + "type": "uint", + "bytes": 32 + } + }, + { + "name": "data", + "schema": { + "type": "bytes", + "lengthFrom": "dataLength" + } + } ] } } + }, + { + "path": "transactions[].data", + "label": "Batched call data", + "format": "calldata", + "params": { + "calleePath": "transactions[].to", + "amountPath": "transactions[].value", + "operation": { + "expression": "transactions[].operation", + "cases": { + "0x01": "delegatecall", + "$default": "call" + } + } + } } ] } diff --git a/assets/erc-non-abi-dispatch/example-tiered-executor.json b/assets/erc-non-abi-dispatch/example-tiered-executor.json index 37298dd940f..c0b83f73344 100644 --- a/assets/erc-non-abi-dispatch/example-tiered-executor.json +++ b/assets/erc-non-abi-dispatch/example-tiered-executor.json @@ -1,17 +1,16 @@ { - "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.schema.json", - - "$comment": "Illustrative example for the non-ABI-dispatch companion ERC. TieredExecutor is a made-up contract written for this ERC (see TieredExecutor.sol in this same folder) and is not deployed anywhere; the address below is a placeholder. It demonstrates the direct/positional form of `call`: `op` is an ordinary already-decoded parameter (not a bytes blob), and the two dispatch cases redirect the whole call to a different target function, each with its own argument order.", - + "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-non-abi-dispatch.schema.json", "context": { "$id": "TieredExecutor Example", "contract": { "deployments": [ - { "chainId": 1, "address": "0xYourTieredExecutorAddress" } + { + "chainId": 1, + "address": "0xYourTieredExecutorAddress" + } ] } }, - "metadata": { "owner": "Example", "contractName": "TieredExecutor", @@ -22,32 +21,45 @@ } } }, - "display": { "formats": { "executeOperation(address target,uint8 op,address account,uint256 amount)": { "$id": "Execute Operation", - "dispatch": { - "tag": { "path": "op" }, - "cases": { - "1": { "call": { - "to": "target", - "signature": "grantReward(address,uint256)", - "args": [ - { "path": "account" }, - { "path": "amount" } - ] - }}, - "2": { "call": { - "to": "target", - "signature": "creditAccount(uint256,address)", - "args": [ - { "path": "amount" }, - { "path": "account" } - ] - }} + "switch": { + "expression": { + "path": "op" }, - "default": "reject" + "cases": { + "1": { + "interaction": { + "to": "target", + "signature": "grantReward(address,uint256)", + "args": [ + { + "path": "account" + }, + { + "path": "amount" + } + ] + } + }, + "2": { + "interaction": { + "to": "target", + "signature": "creditAccount(uint256,address)", + "args": [ + { + "path": "amount" + }, + { + "path": "account" + } + ] + } + }, + "$default": "reject" + } } } } diff --git a/assets/erc-non-abi-dispatch/example-universal-router.json b/assets/erc-non-abi-dispatch/example-universal-router.json index c3f11351464..fcf400cd415 100644 --- a/assets/erc-non-abi-dispatch/example-universal-router.json +++ b/assets/erc-non-abi-dispatch/example-universal-router.json @@ -1,17 +1,16 @@ { - "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.schema.json", - - "$comment": "Worked example for the non-ABI-dispatch companion ERC, decoding Uniswap's Universal Router command dispatch. Verified against real Ethereum mainnet tx 0x3805667353244e8fb763d50b7dd3bdb8f176119b44fdbd0a4ad5629d851ebbba - see the ERC's Test Cases section. Only the two command IDs exercised by that transaction (0x00 and 0x04) are listed here; a production descriptor would list every command ID defined in Uniswap/universal-router's Commands.sol.", - + "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-non-abi-dispatch.schema.json", "context": { "$id": "Uniswap Universal Router", "contract": { "deployments": [ - { "chainId": 1, "address": "0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af" } + { + "chainId": 1, + "address": "0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af" + } ] } }, - "metadata": { "owner": "Uniswap", "contractName": "Universal Router", @@ -19,28 +18,99 @@ "url": "https://docs.uniswap.org/contracts/universal-router/overview" } }, - "display": { "formats": { "execute(bytes commands,bytes[] inputs,uint256 deadline)": { "$id": "Universal Router Execute", "intent": "Execute swap", "fields": [ - { "path": "deadline", "label": "Valid until", "format": "date", "params": { "encoding": "timestamp" } }, { - "path": "inputs", + "path": "deadline", + "label": "Valid until", + "format": "date", + "params": { + "encoding": "timestamp" + } + }, + { + "path": "commands", "label": "Commands", "layout": { - "kind": "sequence", + "type": "sequence", "count": "tillEnd", "element": { - "kind": "dispatch", - "tag": { "path": "commands", "kind": "uint", "bytes": 1, "mask": "0x3f" }, - "cases": { - "0x00": { "abiType": "(address recipient,uint256 amountIn,uint256 amountOutMinimum,bytes path,bool payerIsUser)" }, - "0x04": { "abiType": "(address token,address recipient,uint256 amountMinimum)" } + "type": "uint", + "bytes": 1 + } + } + }, + { + "path": "inputs[]", + "label": "Command input", + "switch": { + "expression": { + "path": "commands[$index]", + "mask": "0x3f" + }, + "cases": { + "0x00": { + "(address recipient,uint256 amountIn,uint256 amountOutMinimum,bytes path,bool payerIsUser)": { + "intent": "Execute swap with exact input and minimum output", + "fields": [ + { + "path": "recipient", + "label": "Recipient", + "format": "addressName" + }, + { + "path": "amountIn", + "label": "Amount in", + "format": "raw" + }, + { + "path": "amountOutMinimum", + "label": "Minimum amount out", + "format": "raw" + }, + { + "path": "path", + "label": "Swap path", + "format": "raw" + }, + { + "path": "payerIsUser", + "label": "Pay from wallet", + "format": "raw" + } + ] + } + }, + "0x04": { + "(address token,address recipient,uint256 amountMinimum)": { + "intent": "Sweep remaining balance", + "fields": [ + { + "path": "token", + "label": "Token", + "format": "addressName" + }, + { + "path": "recipient", + "label": "Recipient", + "format": "addressName" + }, + { + "path": "amountMinimum", + "label": "Minimum amount", + "format": "tokenAmount", + "params": { + "tokenPath": "token" + } + } + ] + } }, - "default": "reject" + "$default": "reject" } } } From cd9ee24cd48f4ede07366e1334ddab6218051232 Mon Sep 17 00:00:00 2001 From: Alex Forshtat Date: Fri, 31 Jul 2026 14:13:08 +0200 Subject: [PATCH 10/23] Improve examples --- ERCS/erc-0000-custom-bytes-erc7730.md | 77 +++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/ERCS/erc-0000-custom-bytes-erc7730.md b/ERCS/erc-0000-custom-bytes-erc7730.md index 9e50e9357ef..796c76c4498 100644 --- a/ERCS/erc-0000-custom-bytes-erc7730.md +++ b/ERCS/erc-0000-custom-bytes-erc7730.md @@ -26,30 +26,107 @@ Instead, we can add some features to ERC-7730 that would allow us to cover the m The main mechanism for declaring any parameter whose contents cannot be expressed using Solidity-friendly ABI-encoded data structures. +It can also be provided to values of other formats if these represent some nested data structures. + +```json +{ "sendPacked(bytes data)": { + "fields": [ + { "path": "data", "layout": { "type": "object", "fields": [ + { "name": "to", "schema": { "type": "address" } }, + { "name": "amount", "schema": { "type": "uint", "bytes": 32 } } + ]}} + ] +}} +``` + ### `sequence` The mechanism for declaring an iterative, array-like data structure not represented by an ABI-encoded `array` data layout. The elements count for `sequence` parameters is optional, and decoding may continue until the input bytes are exhausted. +For example, for a byte array with each byte representing a different element: + +```json +{ "runCommands(bytes commands)": { + "fields": [ + { "path": "commands", "layout": { "type": "sequence", "element": { "type": "uint", "bytes": 1 } } } + ] +}} +``` + ### `object` The mechanism for declaring an entry in a `sequence` data structure that is not represented by an ABI-encoded parameter. +It can also be used as a stand-in for any other complex data structure in the formating process. + +```json +{ "batchCalls(bytes transactions)": { + "fields": [ + { "path": "transactions", "layout": { "type": "sequence", "element": { "type": "object", "fields": [ + { "name": "operation", "schema": { "type": "uint", "bytes": 1 } }, + { "name": "to", "schema": { "type": "address" } } + ]}}} + ] +}} +``` + ### `select` The mechanism that allows the decoding to choose the format based on a certain parameter decoded previously. Represents a common pattern of carrying the decoding format flag separately form the data being decoded. +```json +{ "execute(uint8 kind,bytes data)": { + "fields": [ + { "path": "data", "select": { + "expression": { "path": "kind" }, + "cases": { + "0x00": { "abiType": "(address to,uint256 amount)" }, + "0x01": { "abiType": "(address from,address to,uint256 amount,uint256 deadline)" } + } + }} + ] +}} +``` + ### `interaction` The mechanism to declare that some data represents an interaction with an external contract. This is an equivalent of `calldata` format from ERC-7730 for contracts that perform their own encoding of the calldata, or execute `delegatecall` and `staticcall` operations. +```json +{ "executeSendReward(address account,uint256 amount)": { + "fields": [ + { "path": "account", "label": "Account" }, + { "path": "amount", "label": "Amount" }, + { "interaction": { + "to": "target", + "signature": "grantReward(address,uint256)", + "args": [ { "path": "account" }, { "path": "amount" } ] } } + ] +}} +``` + ### `$index` A mechanism for element in a `sequence` to reference their position for indexing into other `sequence` or array-like parameters. +```json +{ "execute(bytes commands,bytes[] inputs)": { + "fields": [ + { "path": "commands", "layout": { "type": "sequence", "element": { "type": "uint", "bytes": 1 } } }, + { "path": "inputs[]", "select": { + "expression": { "path": "commands[$index]" }, + "cases": { + "0x00": { "abiType": "(address to)" } + } + }} + ] +}} +``` + ## Rationale ## Security Considerations From d78fcc2b01f049a8133fcd1c0208bf09d5daef58 Mon Sep 17 00:00:00 2001 From: Alex Forshtat Date: Mon, 3 Aug 2026 14:59:27 +0200 Subject: [PATCH 11/23] Add "test cases" --- ERCS/erc-0000-custom-bytes-erc7730.md | 47 +++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/ERCS/erc-0000-custom-bytes-erc7730.md b/ERCS/erc-0000-custom-bytes-erc7730.md index 796c76c4498..edd98168e6b 100644 --- a/ERCS/erc-0000-custom-bytes-erc7730.md +++ b/ERCS/erc-0000-custom-bytes-erc7730.md @@ -126,6 +126,53 @@ A mechanism for element in a `sequence` to reference their position for indexing ] }} ``` +## Test Cases + +### Safe{Wallet} - `MultiSend` Contract + +The `MultiSend` contract encodes the data in the following format: + +``` +/** +* @notice Sends multiple transactions and reverts all if one fails. +* @param transactions Encoded transactions. Each transaction is encoded as a packed bytes of: +* 1. _operation_ as a {uint8}, 0 for a `CALL` or 1 for a `DELEGATECALL` (=> 1 byte), +* 2. _to_ as an {address} (=> 20 bytes), +* 3. _value_ as a {uint256} (=> 32 bytes), +* 4. _data_ length as a {uint256} (=> 32 bytes), +* 5. _data_ as {bytes}. +function multiSend(bytes memory transactions) public payable; +*/ +``` +#### What makes this encoding unusual +1. The `transactions` count is not provided at all - the code has to iteratively decode the entire data array until it is exhausted. +2. The `data` field has a dynamic size that is specified as a separate sibling parameter. + +Using ERC-0000, this input can easily be described for Clear Singing – see [MultiSend Example](../assets/erc-non-abi-dispatch/example-safe-multisend.json). + +### Uniswap v4 - `UniversalRouter` Contract + +The `UniversalRouter` contract encodes the data in the following format: + +``` +/// @notice Executes encoded commands along with provided inputs. Reverts if deadline has expired. +/// @param commands A set of concatenated commands, each 1 byte in length +/// @param inputs An array of byte strings containing abi encoded inputs for each command +/// @param deadline The deadline by which the transaction must be executed +function execute(bytes calldata commands, bytes[] calldata inputs, uint256 deadline) external payable; +``` + +#### What makes this encoding unusual + +1. `commands` is not an ABI `bytes[]` or `uint8[]` – it is a `bytes` value packed with one `command id` byte, decoded as a `sequence` until the input is exhausted, exactly like the `runCommands` example above. +2. Each command byte also carries a flag in its high bit (`0x80`/`0b10000000`, "allow this command to revert") alongside the actual `command id` in its low 6 bits (`0x3f`/`0b00111111`), so the extracted value must be masked before it can be matched against a `cases` table. +3. `inputs` is a regular ABI `bytes[]`, but the ABI type of `inputs[i]` is only known by decoding `commands[i]` – the format of one array must be resolved by indexing into a **sibling array** at the same position, which is the `$index` mechanism described above. + +Using ERC-0000, this input can be described for Clear Signing – see [Universal Router Example](../assets/erc-non-abi-dispatch/example-universal-router.json). + +### ERC-7579 `execute` + +### Balancer Relayer `joinPool`/`exitPool` ## Rationale From 624bea375c188697b66d0b40049566504551bcca Mon Sep 17 00:00:00 2001 From: Alex Forshtat Date: Mon, 3 Aug 2026 15:54:19 +0200 Subject: [PATCH 12/23] Add ERC-7579 --- ERCS/erc-0000-custom-bytes-erc7730.md | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/ERCS/erc-0000-custom-bytes-erc7730.md b/ERCS/erc-0000-custom-bytes-erc7730.md index edd98168e6b..c16b35112ad 100644 --- a/ERCS/erc-0000-custom-bytes-erc7730.md +++ b/ERCS/erc-0000-custom-bytes-erc7730.md @@ -170,7 +170,31 @@ function execute(bytes calldata commands, bytes[] calldata inputs, uint256 deadl Using ERC-0000, this input can be described for Clear Signing – see [Universal Router Example](../assets/erc-non-abi-dispatch/example-universal-router.json). -### ERC-7579 `execute` +### ERC-7579 `execute` function + +The [ERC-7579](./erc-7579.md) `execute` function, which encodes the data in the following format: + + +| CallType | ExecType | Unused | ModeSelector | ModePayload | +| -------- | -------- | ------- | ------------ | ----------- | +| 1 byte | 1 byte | 4 bytes | 4 bytes | 22 bytes | + +```solidity +function execute(bytes32 mode, bytes calldata executionCalldata) external payable; +``` + +The `mode` value determines how `executionCalldata` itself must be decoded: +- `CALLTYPE = 0x00` (single call): `executionCalldata` is `abi.encodePacked(target, value, callData)`. +- `CALLTYPE = 0x01` (batch call): `executionCalldata` is a regularly ABI-encoded `Execution(address target, uint256 value, bytes callData)[]`. +- `CALLTYPE = 0xff` (delegatecall): `executionCalldata` is `abi.encodePacked(target, callData)`, with **no `value` field**. + +#### What makes this encoding unusual + +1. `mode` is a single `bytes32` packed with five sub-fields of uneven, non-32-byte-aligned widths (1/1/4/4/22 bytes). +2. The format of the second parameter, `executionCalldata`, is dependent on the first byte of the first parameter, `mode` – similar to the pattern used by `UniversalRouter`, but more complicated since the indication is a single byte inside a fixed-size `bytes32` rather than a whole named parameter. +3. The three `CALLTYPE`s are not just different tuples of the same shape – `single` and `delegatecall` use packed encoding, with no `value` field for `delegatecall`, while `batch` uses standard padded ABI encoding of a struct array, so `executionCalldata` decoding changes shape entirely between cases. + +Using ERC-0000, this input can be described for Clear Signing – see [ERC-7579 Execute Example](../assets/erc-non-abi-dispatch/example-erc7579-execute.json). ### Balancer Relayer `joinPool`/`exitPool` From 346631239f31660b3d2da8f4c2c8eda932ef2ce6 Mon Sep 17 00:00:00 2001 From: Alex Forshtat Date: Mon, 3 Aug 2026 17:18:30 +0200 Subject: [PATCH 13/23] Fix all examples to match ERC-0000 --- ERCS/erc-0000-custom-bytes-erc7730.md | 111 +++++++++++++-- ERCS/erc-draft_non_abi_dispatch.md | 19 ++- .../erc7730-non-abi-dispatch.schema.json | 40 +++--- .../example-balancer-relayer-multicall.json | 2 +- .../example-cctp-message.json | 132 ------------------ .../example-eas-attestation.json | 9 +- .../example-erc7579-execute.json | 70 +++++++--- .../example-erc7683-order.json | 13 +- .../example-safe-multisend.json | 29 ++-- .../feature-stateref-based-dispatch.md | 2 +- 10 files changed, 215 insertions(+), 212 deletions(-) delete mode 100644 assets/erc-non-abi-dispatch/example-cctp-message.json diff --git a/ERCS/erc-0000-custom-bytes-erc7730.md b/ERCS/erc-0000-custom-bytes-erc7730.md index c16b35112ad..35ddbafb3f1 100644 --- a/ERCS/erc-0000-custom-bytes-erc7730.md +++ b/ERCS/erc-0000-custom-bytes-erc7730.md @@ -29,14 +29,22 @@ The main mechanism for declaring any parameter whose contents cannot be expresse It can also be provided to values of other formats if these represent some nested data structures. ```json -{ "sendPacked(bytes data)": { - "fields": [ - { "path": "data", "layout": { "type": "object", "fields": [ - { "name": "to", "schema": { "type": "address" } }, - { "name": "amount", "schema": { "type": "uint", "bytes": 32 } } - ]}} - ] -}} +{ + "sendPacked(bytes data)": { + "fields": [ + { + "path": "data", + "layout": { + "type": "object", + "fields": [ + { "name": "to", "schema": { "type": "address" } }, + { "name": "amount", "schema": { "type": "uint", "bytes": 32 } } + ] + } + } + ] + } +} ``` ### `sequence` @@ -72,24 +80,93 @@ It can also be used as a stand-in for any other complex data structure in the fo }} ``` -### `select` +An `object`'s field entries may carry `format`,`params`, `label` and `schema` parameters. +This allows a packed field to declare how it should be displayed using relative paths to that object's own sibling members. + +```json +{ "name": "callData", "schema": { "type": "bytes" }, "label": "Execution", "format": "calldata", + "params": { "calleePath": "target", "amountPath": "value" } } +``` + +### `bitfield` + +A fixed-width value whose individual bits or bit ranges each carry independent, named meaning – unlike `object`, whose fields are always byte-aligned and never overlap. + +```json +{ "type": "bitfield", "bytes": 20, "fields": [ + { "name": "beforeSwap", "bit": 7 }, + { "name": "poolId", "bits": [19, 8] } +]} +``` + +Each entry is either `{name, bit}` (a single flag, decoded as `bool`) or `{name, bits: [hi, lo]}` (an inclusive bit range, decoded as an unsigned integer). + +### `switch` The mechanism that allows the decoding to choose the format based on a certain parameter decoded previously. Represents a common pattern of carrying the decoding format flag separately form the data being decoded. ```json { "execute(uint8 kind,bytes data)": { "fields": [ - { "path": "data", "select": { + { "path": "data", "switch": { "expression": { "path": "kind" }, "cases": { - "0x00": { "abiType": "(address to,uint256 amount)" }, - "0x01": { "abiType": "(address from,address to,uint256 amount,uint256 deadline)" } + "0x00": { "(address to,uint256 amount)": { + "fields": [ + { "path": "to", "label": "To" }, + { "path": "amount", "label": "Amount" } + ] + }}, + "0x01": { "(address from,address to,uint256 amount,uint256 deadline)": { + "fields": [ + { "path": "from", "label": "From" }, + { "path": "to", "label": "To" }, + { "path": "amount", "label": "Amount" }, + { "path": "deadline", "label": "Deadline", "format": "date", "params": { "encoding": "timestamp" } } + ] + }} } }} ] }} ``` +When a `switch` case's tuple resolves to an array (`(...)[]`), its own `fields` can address that array's elements with `.[]` in place of the missing array name, e.g. `.[].callData`. +`#.` inside a case's own `fields` still resolves against the absolute root of the structured data. + +`switch` can also appear as a `layout` node instead of a field-level key: + +```json +{ "exampleCall(uint256 outputReference)": { + "fields": [ + { "path": "outputReference", "label": "Save result as", "layout": { + "type": "switch", + "expression": { "type": "uint", "bytes": 32, "mask": "0xfff0000000000000000000000000000000000000000000000000000000000000" }, + "cases": { + "0xba10000000000000000000000000000000000000000000000000000000000000": { "label": "Set by an earlier step, not known yet", "intent": "info" }, + "$default": { "format": "raw" } + } + }} + ] + } +} +``` + +`mask` is available on any `switch` expression and is applied to the raw value before matching `cases`. +It lets a dispatch tag share space with unrelated bits, as with `UniversalRouter`'s revert-allowed flag in the Test Case below. + +### `operation` + +An optional parameter for the ERC-7730's `format: "calldata"` record. +Allow specifying the actual EVM operation used when executing the call. +Supported values are: + +1. CALL +2. DELEGATECALL +3. CREATE +4. CREATE2 +5. CALLCODE (legacy opcode) + ### `interaction` The mechanism to declare that some data represents an interaction with an external contract. @@ -109,6 +186,8 @@ This is an equivalent of `calldata` format from ERC-7730 for contracts that perf }} ``` +A [structured data format specification](./erc-7730.md) MAY declare a top-level `switch` in place of `intent`/`fields`, redirecting the entire call to a different, unrelated function via `interaction` based on one of its own decoded parameters. + ### `$index` A mechanism for element in a `sequence` to reference their position for indexing into other `sequence` or array-like parameters. @@ -117,10 +196,14 @@ A mechanism for element in a `sequence` to reference their position for indexing { "execute(bytes commands,bytes[] inputs)": { "fields": [ { "path": "commands", "layout": { "type": "sequence", "element": { "type": "uint", "bytes": 1 } } }, - { "path": "inputs[]", "select": { + { "path": "inputs[]", "switch": { "expression": { "path": "commands[$index]" }, "cases": { - "0x00": { "abiType": "(address to)" } + "0x00": { "(address to)": { + "fields": [ + { "path": "to", "label": "To" } + ] + }} } }} ] diff --git a/ERCS/erc-draft_non_abi_dispatch.md b/ERCS/erc-draft_non_abi_dispatch.md index 209f8077d90..7ebcbb3e21e 100644 --- a/ERCS/erc-draft_non_abi_dispatch.md +++ b/ERCS/erc-draft_non_abi_dispatch.md @@ -107,7 +107,7 @@ This ERC extends `format: "calldata"`'s `params` with one new, optional key: `op Paths extend into `layout`-decoded fields the same way they already extend into ABI-decoded struct and array fields: by name for `object` and `bitfield` fields, by index for `sequence` elements. For example, given the `object` above, `#.transactions[0].to` refers to the `to` field of the first record. This is also what makes nested calldata resolvable without a dedicated layout node: a sibling top-level field entry can address `#.transactions[].data` directly and apply `format: "calldata"` to it, exactly as it would to any ordinary ABI-decoded `bytes` field. -**`#.` crosses `switch`/`layout` scope boundaries.** A `switch` case that decodes its payload into a tuple (`abiType`, or the tuple-plus-`intent`-plus-`fields` shorthand) introduces a new, local field scope for that case's own `fields`: a relative path there resolves against the just-decoded tuple, not the outer call. `#.`, however, MUST still resolve against the absolute root of the entire structured data — the outer call's own top-level decoded parameters — regardless of how many `switch`/`layout` scopes deep the path is written. This is needed whenever a case's decoded value must be paired with a sibling of the field the `switch` is attached to, not a sibling within the tuple itself — for instance, resolving a `switch`-matched `amountsIn[i]` against a token list that lives one level up, alongside `userData` rather than inside it (see the [`joinPool`/`exitPool` Test Case](#test-cases)). Base ERC-7730's own examples of `#.` never exercise this — every one resolves a flat, top-level sibling — so this ERC states the cross-scope behavior explicitly rather than leaving it to be inferred. +**`#.` crosses `switch`/`layout` scope boundaries.** A `switch` case that decodes its payload into a tuple via the tuple-signature-key shorthand introduces a new, local field scope for that case's own `fields`: a relative path there resolves against the just-decoded tuple, not the outer call. `#.`, however, MUST still resolve against the absolute root of the entire structured data — the outer call's own top-level decoded parameters — regardless of how many `switch`/`layout` scopes deep the path is written. This is needed whenever a case's decoded value must be paired with a sibling of the field the `switch` is attached to, not a sibling within the tuple itself — for instance, resolving a `switch`-matched `amountsIn[i]` against a token list that lives one level up, alongside `userData` rather than inside it (see the [`joinPool`/`exitPool` Test Case](#test-cases)). Base ERC-7730's own examples of `#.` never exercise this — every one resolves a flat, top-level sibling — so this ERC states the cross-scope behavior explicitly rather than leaving it to be inferred. ### `switch` @@ -121,7 +121,11 @@ Paths extend into `layout`-decoded fields the same way they already extend into "switch": { "expression": { "path": "schema" }, "cases": { - "0x1234...": { "abiType": "(address recipient,bool isHuman,uint256 score)" }, + "0x1234...": { "(address recipient,bool isHuman,uint256 score)": { "fields": [ + { "path": "recipient", "label": "Recipient", "format": "addressName" }, + { "path": "isHuman", "label": "Is human" }, + { "path": "score", "label": "Score" } + ]}}, "$default": "reject" } } @@ -137,7 +141,13 @@ This is the shape needed by EAS (`schema` selects how to decode `data`), ERC-768 "expression": { "type": "uint", "bytes": 1, "mask": "0x3f" }, "payloadFrom": "inputs", "cases": { - "0x00": { "abiType": "(address recipient,uint256 amountIn,uint256 amountOutMin,bytes path,bool payerIsUser)" }, + "0x00": { "(address recipient,uint256 amountIn,uint256 amountOutMin,bytes path,bool payerIsUser)": { "fields": [ + { "path": "recipient", "label": "Recipient", "format": "addressName" }, + { "path": "amountIn", "label": "Amount in" }, + { "path": "amountOutMin", "label": "Minimum amount out" }, + { "path": "path", "label": "Swap path" }, + { "path": "payerIsUser", "label": "Pay from wallet" } + ]}}, "$default": "reject" } } @@ -147,10 +157,9 @@ This is the shape needed by EAS (`schema` selects how to decode `data`), ERC-768 In both forms, a case value is one of: -* `{ "abiType": "" }` — decode the payload using the ordinary Solidity ABI decoder. * `{ "layout": }` — recurse into this ERC's own layout language (any node). * `{ "switch": {...} }` — nest another switch (for multi-level tag structures). -* `{ "": { "intent": ..., "fields": [...] } }` — decode using the tuple signature given as the key, exactly like `abiType`, and immediately apply the given `intent`/`fields` to the result, without a separate top-level `display.formats` entry. This is sugar for `abiType` followed by inline structured display; it exists because a `switch` case very often wants to say both "decode it like this" and "display it like this" together, and forcing every case through a two-step `abiType`-then-somewhere-else-defined-fields indirection added no value in the surveyed cases (Universal Router's command table, most notably). +* `{ "": { "intent": ..., "fields": [...] } }` — decode the payload using the ordinary Solidity ABI decoder for the tuple signature given as the key, and immediately apply the given `intent`/`fields` to the result, without a separate top-level `display.formats` entry. This exists because a `switch` case very often wants to say both "decode it like this" and "display it like this" together (Universal Router's command table, most notably). * `{ "format": "" }` — stop: do not decode further, apply an ordinary base-ERC-7730 [field format](./erc-7730.md#field-format-specification) directly to the already-typed value in scope. This is for a case (very often `$default`) where the matched value needs no structural reinterpretation at all — it is already an ordinary value, just display it normally. * `{ "label": "", "intent": "info" | "warning" }` — stop: do not decode further, display `label` verbatim in place of any decoded value, with the given severity. This is for a case whose matched value has no meaningful decoded content to show at all — see the chained-reference example in [Test Cases](#test-cases), where a matched sentinel value stands for "a value only known once an earlier step in the same batch has executed on-chain," which is not a value a wallet can compute or display, only name. diff --git a/assets/erc-non-abi-dispatch/erc7730-non-abi-dispatch.schema.json b/assets/erc-non-abi-dispatch/erc7730-non-abi-dispatch.schema.json index c16201e5457..67c624fe60e 100644 --- a/assets/erc-non-abi-dispatch/erc7730-non-abi-dispatch.schema.json +++ b/assets/erc-non-abi-dispatch/erc7730-non-abi-dispatch.schema.json @@ -1574,6 +1574,15 @@ }, "schema": { "$ref": "#/$layout/node" + }, + "label": { + "type": "string" + }, + "format": { + "$ref": "#/$format/names" + }, + "params": { + "type": "object" } }, "required": [ @@ -1686,18 +1695,6 @@ { "const": "reject" }, - { - "type": "object", - "properties": { - "abiType": { - "type": "string" - } - }, - "required": [ - "abiType" - ], - "additionalProperties": false - }, { "type": "object", "properties": { @@ -1756,7 +1753,7 @@ "additionalProperties": false }, { - "title": "Tuple-signature-key shorthand: decode as abiType, then apply intent/fields inline", + "title": "Tuple-signature-key shorthand: decode as standard ABI using this Solidity tuple type, then apply intent/fields inline", "type": "object", "minProperties": 1, "maxProperties": 1, @@ -1890,12 +1887,15 @@ "additionalProperties": false }, "operationParam": { - "title": "operation: call vs delegatecall for format:calldata", + "title": "operation: the EVM operation used to execute an embedded call, for format:calldata", "oneOf": [ { "enum": [ - "call", - "delegatecall" + "CALL", + "DELEGATECALL", + "CREATE", + "CREATE2", + "CALLCODE" ] }, { @@ -1924,9 +1924,11 @@ "type": "object", "additionalProperties": { "enum": [ - "call", - "delegatecall", - "reject" + "CALL", + "DELEGATECALL", + "CREATE", + "CREATE2", + "CALLCODE" ] } } diff --git a/assets/erc-non-abi-dispatch/example-balancer-relayer-multicall.json b/assets/erc-non-abi-dispatch/example-balancer-relayer-multicall.json index 91be1e2361c..0a100d4580a 100644 --- a/assets/erc-non-abi-dispatch/example-balancer-relayer-multicall.json +++ b/assets/erc-non-abi-dispatch/example-balancer-relayer-multicall.json @@ -30,7 +30,7 @@ "format": "calldata", "params": { "callee": "0xeA66501dF1A00261E3bB79D1E90444fc6A186B62", - "operation": "delegatecall" + "operation": "DELEGATECALL" } } ] diff --git a/assets/erc-non-abi-dispatch/example-cctp-message.json b/assets/erc-non-abi-dispatch/example-cctp-message.json deleted file mode 100644 index 5b2d00f7016..00000000000 --- a/assets/erc-non-abi-dispatch/example-cctp-message.json +++ /dev/null @@ -1,132 +0,0 @@ -{ - "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-non-abi-dispatch.schema.json", - "context": { - "$id": "Circle CCTP MessageTransmitter", - "contract": { - "deployments": [ - { - "chainId": 1, - "address": "0xYourMessageTransmitterAddress" - } - ] - } - }, - "metadata": { - "owner": "Circle", - "contractName": "MessageTransmitter", - "info": { - "url": "https://developers.circle.com/cctp/v1/message-format" - } - }, - "display": { - "formats": { - "receiveMessage(bytes message,bytes attestation)": { - "$id": "CCTP Receive Message", - "intent": "Receive cross-chain message", - "fields": [ - { - "path": "message", - "label": "Message", - "layout": { - "type": "object", - "fields": [ - { - "name": "version", - "schema": { - "type": "uint", - "bytes": 4 - } - }, - { - "name": "sourceDomain", - "schema": { - "type": "uint", - "bytes": 4 - } - }, - { - "name": "destinationDomain", - "schema": { - "type": "uint", - "bytes": 4 - } - }, - { - "name": "nonce", - "schema": { - "type": "uint", - "bytes": 8 - } - }, - { - "name": "sender", - "schema": { - "type": "bytes", - "length": 32 - } - }, - { - "name": "recipient", - "schema": { - "type": "bytes", - "length": 32 - } - }, - { - "name": "destinationCaller", - "schema": { - "type": "bytes", - "length": 32 - } - }, - { - "name": "messageBody", - "schema": { - "type": "object", - "fields": [ - { - "name": "version", - "schema": { - "type": "uint", - "bytes": 4 - } - }, - { - "name": "burnToken", - "schema": { - "type": "bytes", - "length": 32 - } - }, - { - "name": "mintRecipient", - "schema": { - "type": "bytes", - "length": 32 - } - }, - { - "name": "amount", - "schema": { - "type": "uint", - "bytes": 32 - } - }, - { - "name": "messageSender", - "schema": { - "type": "bytes", - "length": 32 - } - } - ] - } - } - ] - } - } - ] - } - } - } -} diff --git a/assets/erc-non-abi-dispatch/example-eas-attestation.json b/assets/erc-non-abi-dispatch/example-eas-attestation.json index a7bded6ebe2..8dd7f093a04 100644 --- a/assets/erc-non-abi-dispatch/example-eas-attestation.json +++ b/assets/erc-non-abi-dispatch/example-eas-attestation.json @@ -38,7 +38,14 @@ }, "cases": { "0xfdcfdad2dbe7489e0ce56b260348b7f14e8365a8a325aef9834818c00d46b31b": { - "abiType": "(string rpgfRound,address referredBy,string referredMethod)" + "(string rpgfRound,address referredBy,string referredMethod)": { + "intent": "Attest to RetroPGF participation", + "fields": [ + { "path": "rpgfRound", "label": "RPGF Round" }, + { "path": "referredBy", "label": "Referred by", "format": "addressName" }, + { "path": "referredMethod", "label": "Referral method" } + ] + } }, "$default": "reject" } diff --git a/assets/erc-non-abi-dispatch/example-erc7579-execute.json b/assets/erc-non-abi-dispatch/example-erc7579-execute.json index 7a58770baa0..50624e5f014 100644 --- a/assets/erc-non-abi-dispatch/example-erc7579-execute.json +++ b/assets/erc-non-abi-dispatch/example-erc7579-execute.json @@ -16,10 +16,10 @@ } }, "metadata": { - "owner": "ERC-7579", - "contractName": "Modular Smart Account", + "owner": "Biconomy", + "contractName": "Nexus", "info": { - "url": "https://eips.ethereum.org/EIPS/eip-7579" + "url": "https://github.com/bcnmy/nexus" } }, "display": { @@ -101,35 +101,61 @@ "name": "callData", "schema": { "type": "bytes" + }, + "label": "Execution", + "format": "calldata", + "params": { + "calleePath": "target", + "amountPath": "value" } } ] } }, "0x01": { - "abiType": "(address target,uint256 value,bytes callData)[]" + "(address target,uint256 value,bytes callData)[]": { + "intent": "Execute batch", + "fields": [ + { + "path": ".[].callData", + "label": "Batched call data", + "format": "calldata", + "params": { + "calleePath": ".[].target", + "amountPath": ".[].value" + } + } + ] + } + }, + "0xff": { + "layout": { + "type": "object", + "fields": [ + { + "name": "target", + "schema": { + "type": "address" + } + }, + { + "name": "callData", + "schema": { + "type": "bytes" + }, + "label": "Execution", + "format": "calldata", + "params": { + "calleePath": "target", + "operation": "DELEGATECALL" + } + } + ] + } }, "$default": "reject" } } - }, - { - "path": "executionCalldata.callData", - "label": "Batched call data", - "format": "calldata", - "params": { - "calleePath": "executionCalldata.target", - "amountPath": "executionCalldata.value" - } - }, - { - "path": "executionCalldata[].callData", - "label": "Batched call data", - "format": "calldata", - "params": { - "calleePath": "executionCalldata[].target", - "amountPath": "executionCalldata[].value" - } } ] } diff --git a/assets/erc-non-abi-dispatch/example-erc7683-order.json b/assets/erc-non-abi-dispatch/example-erc7683-order.json index 837f2890f6f..f91b6553102 100644 --- a/assets/erc-non-abi-dispatch/example-erc7683-order.json +++ b/assets/erc-non-abi-dispatch/example-erc7683-order.json @@ -41,7 +41,18 @@ }, "cases": { "0x9df4b782e7bbc178b3b93bfe8aafb909e84e39484d7f3c59f400f1b4691f85e2": { - "abiType": "(address inputToken,uint256 inputAmount,address outputToken,uint256 outputAmount,uint256 destinationChainId,bytes32 recipient,address exclusiveRelayer,uint256 depositNonce,uint32 exclusivityPeriod,bytes message)" + "(address inputToken,uint256 inputAmount,address outputToken,uint256 outputAmount,uint256 destinationChainId,bytes32 recipient,address exclusiveRelayer,uint256 depositNonce,uint32 exclusivityPeriod,bytes message)": { + "intent": "Across V3 relay order", + "fields": [ + { "path": "inputToken", "label": "Input token", "format": "addressName" }, + { "path": "inputAmount", "label": "Input amount", "format": "tokenAmount", "params": { "tokenPath": "inputToken" } }, + { "path": "outputToken", "label": "Output token", "format": "addressName" }, + { "path": "outputAmount", "label": "Output amount", "format": "tokenAmount", "params": { "tokenPath": "outputToken" } }, + { "path": "destinationChainId", "label": "Destination chain", "format": "chainId" }, + { "path": "recipient", "label": "Recipient" }, + { "path": "exclusiveRelayer", "label": "Exclusive relayer", "format": "addressName" } + ] + } }, "$default": "reject" } diff --git a/assets/erc-non-abi-dispatch/example-safe-multisend.json b/assets/erc-non-abi-dispatch/example-safe-multisend.json index 4cbad053c40..1d5c239a3a0 100644 --- a/assets/erc-non-abi-dispatch/example-safe-multisend.json +++ b/assets/erc-non-abi-dispatch/example-safe-multisend.json @@ -65,27 +65,24 @@ "schema": { "type": "bytes", "lengthFrom": "dataLength" + }, + "label": "Batched call data", + "format": "calldata", + "params": { + "calleePath": "to", + "amountPath": "value", + "operation": { + "expression": "operation", + "cases": { + "0x01": "DELEGATECALL", + "$default": "CALL" + } + } } } ] } } - }, - { - "path": "transactions[].data", - "label": "Batched call data", - "format": "calldata", - "params": { - "calleePath": "transactions[].to", - "amountPath": "transactions[].value", - "operation": { - "expression": "transactions[].operation", - "cases": { - "0x01": "delegatecall", - "$default": "call" - } - } - } } ] } diff --git a/assets/erc-non-abi-dispatch/feature-stateref-based-dispatch.md b/assets/erc-non-abi-dispatch/feature-stateref-based-dispatch.md index 696e79ab9f9..c6e638934bf 100644 --- a/assets/erc-non-abi-dispatch/feature-stateref-based-dispatch.md +++ b/assets/erc-non-abi-dispatch/feature-stateref-based-dispatch.md @@ -43,7 +43,7 @@ Also notable: #1738 has its own normative **omission rule** — anything whose i } ``` -Everything downstream of `tag` resolution is unchanged: the same `cases` map, the same case-value polymorphism (`abiType` / `layout` / `call` / nested `dispatch`), the same fail-closed `default: "reject"` for anything not explicitly enumerated. +Everything downstream of `tag` resolution is unchanged: the same `cases` map, the same case-value polymorphism (tuple-signature-key / `layout` / `call` / nested `dispatch`), the same fail-closed `default: "reject"` for anything not explicitly enumerated. This composes with #1738 rather than duplicating it: `stateRefs`/`proxy` would remain the *static* precondition layer ("this descriptor doesn't even apply unless..."), and a `stateRef`-tagged `dispatch` would be the *dynamic selection* layer on top ("...and depending on which of several known-audited configurations is live, here's which interpretation to use"). From 2b861c9211fb0e3e82f2947087c3a65b8139617a Mon Sep 17 00:00:00 2001 From: Alex Forshtat Date: Mon, 3 Aug 2026 17:26:59 +0200 Subject: [PATCH 14/23] Elaborate some rules --- ERCS/erc-0000-custom-bytes-erc7730.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ERCS/erc-0000-custom-bytes-erc7730.md b/ERCS/erc-0000-custom-bytes-erc7730.md index 35ddbafb3f1..ea5c5f9c188 100644 --- a/ERCS/erc-0000-custom-bytes-erc7730.md +++ b/ERCS/erc-0000-custom-bytes-erc7730.md @@ -47,6 +47,9 @@ It can also be provided to values of other formats if these represent some neste } ``` +Every `layout` node consumes a well-defined, computable number of bytes from its buffer. +The `switch`'s path-sourced form is an exception as it reads an already-resolved value instead of parsing bytes. + ### `sequence` The mechanism for declaring an iterative, array-like data structure not represented by an ABI-encoded `array` data layout. @@ -155,6 +158,8 @@ When a `switch` case's tuple resolves to an array (`(...)[]`), its own `fields` `mask` is available on any `switch` expression and is applied to the raw value before matching `cases`. It lets a dispatch tag share space with unrelated bits, as with `UniversalRouter`'s revert-allowed flag in the Test Case below. +Inside a `layout` tree, `switch`'s inline form may also use `payloadFrom` in place of `$index`, naming a sibling ABI-decoded array to read at the same index as the enclosing `sequence` element. + ### `operation` An optional parameter for the ERC-7730's `format: "calldata"` record. @@ -186,6 +191,8 @@ This is an equivalent of `calldata` format from ERC-7730 for contracts that perf }} ``` +A wallet MUST resolve the matched target's own `intent`/`interpolatedIntent`/`fields` using the bound `args` values in place of that target's own decoded parameters, applying the same unknown-selector fallback if `to`'s descriptor has no entry matching `signature`. + A [structured data format specification](./erc-7730.md) MAY declare a top-level `switch` in place of `intent`/`fields`, redirecting the entire call to a different, unrelated function via `interaction` based on one of its own decoded parameters. ### `$index` From 03cdc48bf3fa0620f4c40b65701e4a680c2a63fa Mon Sep 17 00:00:00 2001 From: Alex Forshtat Date: Mon, 3 Aug 2026 17:46:13 +0200 Subject: [PATCH 15/23] Explain other examples & drop EAS --- ERCS/erc-0000-custom-bytes-erc7730.md | 59 +++++++++++++++++++ .../example-eas-attestation.json | 58 ------------------ 2 files changed, 59 insertions(+), 58 deletions(-) delete mode 100644 assets/erc-non-abi-dispatch/example-eas-attestation.json diff --git a/ERCS/erc-0000-custom-bytes-erc7730.md b/ERCS/erc-0000-custom-bytes-erc7730.md index ea5c5f9c188..3c9b3fde3a1 100644 --- a/ERCS/erc-0000-custom-bytes-erc7730.md +++ b/ERCS/erc-0000-custom-bytes-erc7730.md @@ -288,6 +288,65 @@ Using ERC-0000, this input can be described for Clear Signing – see [ERC-7579 ### Balancer Relayer `joinPool`/`exitPool` +The `BatchRelayerLibrary` contract reached via `BalancerRelayer.multicall` encodes pool-kind and per-action metadata as sibling ABI parameters, then encodes a *second*, independent tag inside `userData` itself: + +```solidity +enum PoolKind { WEIGHTED, LEGACY_STABLE, COMPOSABLE_STABLE, COMPOSABLE_STABLE_V2 } + +function joinPool(bytes32 poolId, PoolKind kind, address sender, address recipient, + IVault.JoinPoolRequest memory request, uint256 value, uint256 outputReference) external payable; + +function exitPool(bytes32 poolId, PoolKind kind, address sender, address payable recipient, + IVault.ExitPoolRequest memory request, OutputReference[] calldata outputReferences) external payable; +``` + +#### What makes this encoding unusual + +1. `BalancerRelayer.multicall(bytes[] data)` always `DELEGATECALL`s every batched element into one fixed library address – unlike `MultiSend`, there is no per-element operation or target choice, only the embedded call itself needs resolving. +2. `request.userData`'s meaning is tag-dispatched twice: once by `kind` (a sibling parameter of `joinPool`/`exitPool` itself), then again by a `JoinKind`/`ExitKind` enum read from `userData`'s own first 32 bytes – a `switch` nested inside a `switch`. +3. The same already-ABI-decoded `uint256` fields (`maxAmountsIn[i]`, `outputReference`, `bptAmountIn`) can be either a literal amount or a "chained reference" – a placeholder for a value only known once an earlier step in the same batched transaction has actually executed on-chain – distinguished purely by masking the value's own high bits, not by a separate tag field. + +Using ERC-0000, this input can be described for Clear Signing – see [Balancer Relayer Multicall Example](../assets/erc-non-abi-dispatch/example-balancer-relayer-multicall.json) and [Balancer Relayer Library Example](../assets/erc-non-abi-dispatch/example-balancer-relayer-library.json). + +### ERC-7683 `open` + +The `AcrossOriginSettler` contract encodes the data in the following format: + +```solidity +struct OnchainCrossChainOrder { + uint32 fillDeadline; + bytes32 orderDataType; + bytes orderData; +} + +function open(OnchainCrossChainOrder calldata order) external; +``` + +#### What makes this encoding unusual + +1. `orderData`'s ABI type is selected by `orderDataType`, a `bytes32` equal to the `keccak256` hash of the target tuple's own Solidity type string (`keccak256("AcrossOrderData(address inputToken,...)")`) rather than a small, contract-defined enum – an open-ended, hash-keyed dispatch. +2. Both the tag and the payload are already plain sibling ABI parameters of `open` itself, so no `layout` node is needed – only `switch`. + +Using ERC-0000, this input can be described for Clear Signing – see [ERC-7683 Order Example](../assets/erc-non-abi-dispatch/example-erc7683-order.json). + +### `TieredExecutor` (artificial illustrative example) + +The `TieredExecutor` contract encodes the data in the following format: + +```solidity +enum Operation { None, GrantReward, CreditLegacy } + +function executeOperation(address target, Operation op, address account, uint256 amount) external; +``` + +#### What makes this encoding unusual + +1. `executeOperation` has no fixed meaning of its own at all – the entire call exists only to be redirected, and which target function it becomes requires a top-level `switch` instead of an ordinary `intent`/`fields` pair. +2. `account`/`amount` are generic-looking arguments with no inherent semantics – depending on `op`, they are bound to two unrelated target interfaces with a **different parameter order** via `interaction` rather than `format: "calldata"`. There is no contiguous calldata blob to slice out, only already-decoded values that need to be reassembled. +3. This contract is illustrative only, written for this ERC and not deployed anywhere. + +Using ERC-0000, this input can be described for Clear Signing – see [TieredExecutor Example](../assets/erc-non-abi-dispatch/example-tiered-executor.json). + ## Rationale ## Security Considerations diff --git a/assets/erc-non-abi-dispatch/example-eas-attestation.json b/assets/erc-non-abi-dispatch/example-eas-attestation.json deleted file mode 100644 index 8dd7f093a04..00000000000 --- a/assets/erc-non-abi-dispatch/example-eas-attestation.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-non-abi-dispatch.schema.json", - "context": { - "$id": "Ethereum Attestation Service", - "contract": { - "deployments": [ - { - "chainId": 10, - "address": "0xYourEASAddress" - } - ] - } - }, - "metadata": { - "owner": "Ethereum Attestation Service", - "contractName": "EAS", - "info": { - "url": "https://docs.attest.org" - } - }, - "display": { - "formats": { - "attest((bytes32 schema,(address recipient,uint64 expirationTime,bool revocable,bytes32 refUID,bytes data,uint256 value) data) request)": { - "$id": "EAS Attest", - "intent": "Attest", - "fields": [ - { - "path": "request.data.recipient", - "label": "Recipient", - "format": "addressName" - }, - { - "path": "request.data.data", - "label": "Attestation data", - "switch": { - "expression": { - "path": "request.schema" - }, - "cases": { - "0xfdcfdad2dbe7489e0ce56b260348b7f14e8365a8a325aef9834818c00d46b31b": { - "(string rpgfRound,address referredBy,string referredMethod)": { - "intent": "Attest to RetroPGF participation", - "fields": [ - { "path": "rpgfRound", "label": "RPGF Round" }, - { "path": "referredBy", "label": "Referred by", "format": "addressName" }, - { "path": "referredMethod", "label": "Referral method" } - ] - } - }, - "$default": "reject" - } - } - } - ] - } - } - } -} From f78597b8152e2efe280591f118d330e2a73e5eaf Mon Sep 17 00:00:00 2001 From: Alex Forshtat Date: Mon, 3 Aug 2026 17:46:34 +0200 Subject: [PATCH 16/23] Drop old erc draft file --- ERCS/erc-draft_non_abi_dispatch.md | 284 ----------------------------- 1 file changed, 284 deletions(-) delete mode 100644 ERCS/erc-draft_non_abi_dispatch.md diff --git a/ERCS/erc-draft_non_abi_dispatch.md b/ERCS/erc-draft_non_abi_dispatch.md deleted file mode 100644 index 7ebcbb3e21e..00000000000 --- a/ERCS/erc-draft_non_abi_dispatch.md +++ /dev/null @@ -1,284 +0,0 @@ ---- -title: Non-ABI Encoded Fields for ERC-7730 -description: Describes packed, bit-packed, and tag-dispatched byte encodings inside ERC-7730 fields that are not plain Solidity ABI -author: TBD -discussions-to: TBD -status: Draft -type: Standards Track -category: ERC -created: 2026-07-26 -requires: 7730 ---- - -## Abstract - -[ERC-7730](./erc-7730.md) describes how to clear-sign structured data by decoding calldata as a Solidity ABI function call, then formatting the resulting named fields. This works as long as every value in the call is itself ABI-encoded. It breaks down for a common and growing class of contracts that accept one ABI-encoded `bytes` (or `bytes[]`) argument and then interpret its raw content using their own private encoding — packed structs, bit-packed flags, or a tag that selects one of several possible payload shapes. Gnosis Safe's `MultiSend`, Uniswap's Universal Router and hook addresses, and ERC-7579 modular accounts all do this, and none of it can be described in ERC-7730 today; such fields must be left as opaque, unreadable bytes. - -This ERC adds three new keys to an ERC-7730 [field format specification](./erc-7730.md#field-format-specification) — `layout`, `switch`, and `interaction` — that let an author describe the internal structure of such a field, which structure applies where the field's shape depends on a tag value, and how to describe a call synthesized from already-decoded pieces rather than sliced out of contiguous bytes. Where a field's raw bytes already are a complete, contiguous call (selector and ABI-encoded arguments together), this ERC deliberately does not duplicate ERC-7730's own [embedded calldata](./erc-7730.md#embedded-calldata) mechanism (`format: "calldata"`) — it only extends that mechanism with one param, `operation`, for the one thing it cannot already express: a nested call that is a `DELEGATECALL` rather than a plain call. Everything else about ERC-7730 (context binding, metadata, top-level selector matching, path syntax) is unchanged; this ERC only extends what a single field's `path` can resolve into. - -## Motivation - -Look at what a `display.formats` entry can describe today: a Solidity function signature, decoded with the standard ABI rules, giving named parameters that `fields` entries point `path` at. That covers the overwhelming majority of contract calls. It does not cover contracts whose calldata carries a second, private encoding layer inside one of those ABI parameters: - -- **Safe's `MultiSend.multiSend(bytes transactions)`** — `transactions` is not ABI-encoded. It is a tightly packed, back-to-back sequence of `(operation, to, value, dataLength, data)` records, repeated until the buffer runs out. Each record's own `data` is, in turn, a normal call to some other contract. -- **Uniswap's Universal Router `execute(bytes commands, bytes[] inputs)`** — `commands` is one raw opcode byte per sub-action (with a flag bit for "allowed to revert"); `inputs[i]` is separately ABI-encoded, but *which* ABI type it decodes as depends on the opcode at `commands[i]`. -- **ERC-7579 modular accounts, `execute(bytes32 mode, bytes executionCalldata)`** — `mode` packs five sub-fields into one word; `executionCalldata`'s shape (a single packed call, or an ABI-encoded array of calls) depends on one byte of `mode`. -- **Uniswap v4 hook addresses** — up to 14 independent permission flags (`beforeSwap`, `afterSwap`, and others) live in specific low-order bits of the 160-bit hook address itself; the same address value is simultaneously "an address" and "a bitmask," with no byte alignment between the two meanings. - -None of this is exotic or rare. It is how batching, modular accounts, and generic-purpose routers already work across the ecosystem, and account-abstraction adoption is only going to produce more of it. A wallet with no way to describe these fields has no way to clear-sign them beyond showing raw hex — which is exactly the blind, trust-me signing experience ERC-7730 exists to eliminate. - -The goal here is narrow on purpose. This ERC does not attempt to become a general-purpose binary serialization language (no attempt is made to describe Protobuf, Borsh, or arbitrary custom formats in full generality). It describes exactly the small set of shapes observed in real, widely used contracts: fixed-width packed fields, repeated records read until the buffer ends, and tag-selected payload types. Constructs are added because a real case needs them, not because they might be useful someday. - -## Specification - -The keywords "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174. - -This ERC defines three additional keys usable in an ERC-7730 [field format specification](./erc-7730.md#field-format-specification): `layout`, `switch`, and `interaction`. A field format specification MUST NOT combine `layout` with `format`; the two are alternative ways of turning a field's raw value into something displayable, and `layout` takes over that job entirely for the field it is attached to. `interaction` is likewise mutually exclusive with `format` and `layout` — it describes a call synthesized from already-decoded values rather than any single bytes value being displayed or parsed. `switch` MAY be combined with any of the three, since it only decides *which* `layout`, ABI type, or nested structured format governs a field — it does not itself produce a displayable value. - -### `layout` - -`layout` describes the internal byte structure of a `bytes` field. Its value is a *layout node*. A layout node is one of the following types: - -**Anchoring `layout` on an already-decoded scalar.** `layout` is normally attached to a field whose raw `bytes` have not been interpreted yet. It MAY also be attached to a field whose value was already produced by ordinary Solidity ABI decoding, if that value's declared type is a single-word elementary type — `uintN`/`intN`, `bool`, `address`, or a fixed-size `bytesN`. In that case the layout tree operates on the value's canonical 32-byte big-endian ABI-word encoding as its buffer, exactly as if those 32 bytes had been sliced out of a larger one. This is not extended to dynamic types (`bytes`, `string`, arrays, tuples) — no known case needs it, and "canonical encoding" is not a single well-defined byte sequence for them the way it is for a 32-byte word. See [Rationale](#rationale) for the motivating case. - -**Primitive nodes** - -```json -{ "type": "uint", "bytes": 1, "endian": "be" } -{ "type": "bytes", "length": 20 } -{ "type": "address" } -{ "type": "bool" } -``` - -`uint.bytes` is the width in bytes, from 1 to 32. `endian` is `"be"` or `"le"`, defaulting to `"be"` — every known EVM-side use case packs data big-endian, matching Solidity's own word layout; `"le"` exists only so this vocabulary does not need to change if a future non-EVM companion reuses it. `address` is sugar for a 20-byte `bytes` node. `bytes.length` MAY be a fixed integer, a `lengthFrom` reference to an earlier sibling field's decoded value (see `object` below), or omitted entirely on the last field of an `object`, meaning "consume whatever bytes remain in the enclosing buffer." - -**`bitfield`** — a fixed-width value (same width rules as `uint`) whose individual bits or bit ranges each carry independent, named meaning: - -```json -{ "type": "bitfield", "bytes": 20, "endian": "be", "fields": [ - { "name": "beforeSwap", "bit": 7 }, - { "name": "afterSwap", "bit": 6 }, - { "name": "poolId", "bits": [19, 8] } -]} -``` - -Each entry in `fields` is either `{ "name": ..., "bit": N }` (a single boolean flag at bit `N`, 0-indexed from the least significant bit, decoded as `bool`) or `{ "name": ..., "bits": [hi, lo] }` (an inclusive bit range, decoded as an unsigned integer). Bit positions are independent of, and MAY overlap arbitrarily within, the underlying value's byte boundaries — this is precisely what distinguishes `bitfield` from `object`, whose fields are always byte-aligned and never overlap. Named sub-fields are addressed exactly like `object` fields (by name); see [Path addressing](#path-addressing). `bitfield` consumes its declared `bytes` width regardless of how many of those bits are named — undeclared bits are simply not exposed as fields. - -**`object`** — an ordered, unpadded concatenation of named fields: - -```json -{ - "type": "object", - "fields": [ - { "name": "operation", "schema": { "type": "uint", "bytes": 1 } }, - { "name": "to", "schema": { "type": "address" } }, - { "name": "value", "schema": { "type": "uint", "bytes": 32 } }, - { "name": "dataLength", "schema": { "type": "uint", "bytes": 32 } }, - { "name": "data", "schema": { "type": "bytes", "lengthFrom": "dataLength" } } - ] -} -``` - -Fields are read strictly in order, byte-for-byte, with no alignment padding and no ABI head/tail indirection. This is the node that describes Safe MultiSend's per-entry record and ERC-7579's `mode` word. - -**`sequence`** — repetition of one element node, read until the enclosing buffer is exhausted: - -```json -{ "type": "sequence", "element": , "count": "tillEnd" } -``` - -`"tillEnd"` is the only count mode this ERC defines, because it is the only one any known real case needs — Safe MultiSend repeats its record `object` until `transactions` runs out; Universal Router repeats a `switch` (below) once per byte of `commands`. Other termination modes (an explicit element count, a byte-length prefix) are left for a future revision if a real case needs them, rather than specified speculatively now. - -**`$index`** — inside the `element` of a `sequence`, or of a field iterated via `format: "array"` (a field whose value is already an ABI-decoded array, walked element-by-element rather than by consuming bytes), `$index` is a reserved token equal to the zero-based position of the element currently being processed. It is usable inside a `path` to correlate that element with the same-indexed value of a *different* sibling field — e.g. `commands[$index]`, read from inside an `inputs` element's `switch`, is the byte of the sibling `commands` sequence at the same position as the `inputs` element currently being resolved. This is Universal Router's actual structure: `commands` (raw `bytes`, one command per byte, parsed as a `sequence`) and `inputs` (already-decoded `bytes[]`, walked via `format: "array"`) are two separate fields advanced in lockstep by the same index, not one field nested inside the other — `$index` is what ties them together. - -**Nested calldata reuses ERC-7730's own mechanism, not a layout node.** A field whose bytes — however they were reached, whether by ordinary ABI decoding or by a parent `layout` — are themselves a complete, contiguous call (a selector followed by ABI-encoded arguments) is described with ERC-7730's own [embedded calldata](./erc-7730.md#embedded-calldata) mechanism, `format: "calldata"`, addressed by an ordinary `path` into the (possibly `layout`-decoded) structure — see [Path addressing](#path-addressing), which already lets a `path` reach into `object` fields and `sequence` elements the same way it reaches into ABI-decoded ones. This is how Safe MultiSend's inner calls are described: the `data` field is parsed as plain `bytes` (its `dataLength` already covers the full selector-plus-arguments blob, unmodified from how Safe's own contract packs it), and a sibling top-level field entry with `path: "transactions[].data"` and `format: "calldata"` resolves it, using `calleePath`/`amountPath` to point back at `transactions[].to`/`transactions[].value`. The same pattern describes ERC-7579's batched executions: each element of the ABI-decoded `Execution[]` array has an ordinary `bytes callData` member, resolved by a field entry with `path: "executionCalldata[].callData"` and `format: "calldata"`, `params: {"calleePath": "executionCalldata[].target"}` — addressed at the same array index as its sibling `target`, the same by-index correlation ERC-7730 already uses for [array-valued formatting parameters](./erc-7730.md#field-format-specification). See [Rationale](#rationale) for why this ERC does not define its own parallel node for this instead. - -This ERC extends `format: "calldata"`'s `params` with one new, optional key: `operation`, since ERC-7730's own definition has no way to express anything but a plain call. Its value is either a literal `"call"` (the default, identical to omitting `operation` entirely) or `"delegatecall"`, or an object choosing between them based on a tag: - -```json -{ "expression": "", "cases": { "": "call" | "delegatecall", "$default": "reject" } } -``` - -`expression`/`cases` (including the reserved `$default` case key) follow exactly the same rules as `switch` (below): a wallet MUST treat a tag value with no matching case, and no `$default` case supplied, as an [unknown selector](./erc-7730.md#unknown-selectors). When resolved to `"delegatecall"`, a wallet MUST make clear that the callee executes in the calling contract's own storage and identity (`DELEGATECALL` semantics), and MUST warn as strongly as it would for a raw, undescribed `delegatecall` if `to`'s descriptor cannot be resolved — a delegatecall to an unknown or unaudited target is a full account takeover, not a benign unknown call. Resolution of `to`'s own `display.formats` entry is otherwise unaffected by `operation`; only the execution-context semantics differ, not how the target function is looked up. - -**Byte-width invariant.** Every `layout` node either consumes a well-defined, computable number of bytes from the buffer (all of the types above), or is explicitly declared non-consuming (only `switch`'s path-sourced expression form, below, which reads an already-resolved value instead of parsing bytes). No node type may be ambiguous about whether, or how much, it advances the cursor. A `layout` anchored directly on an already-decoded scalar (above) is a degenerate, trivially-satisfying case of this same invariant: there is no enclosing buffer to consume from or leave a remainder in, the buffer *is* the value's fixed 32-byte encoding in full, and the top-level node MUST consume it completely, the same rule applied everywhere else. - -### Path addressing - -Paths extend into `layout`-decoded fields the same way they already extend into ABI-decoded struct and array fields: by name for `object` and `bitfield` fields, by index for `sequence` elements. For example, given the `object` above, `#.transactions[0].to` refers to the `to` field of the first record. This is also what makes nested calldata resolvable without a dedicated layout node: a sibling top-level field entry can address `#.transactions[].data` directly and apply `format: "calldata"` to it, exactly as it would to any ordinary ABI-decoded `bytes` field. - -**`#.` crosses `switch`/`layout` scope boundaries.** A `switch` case that decodes its payload into a tuple via the tuple-signature-key shorthand introduces a new, local field scope for that case's own `fields`: a relative path there resolves against the just-decoded tuple, not the outer call. `#.`, however, MUST still resolve against the absolute root of the entire structured data — the outer call's own top-level decoded parameters — regardless of how many `switch`/`layout` scopes deep the path is written. This is needed whenever a case's decoded value must be paired with a sibling of the field the `switch` is attached to, not a sibling within the tuple itself — for instance, resolving a `switch`-matched `amountsIn[i]` against a token list that lives one level up, alongside `userData` rather than inside it (see the [`joinPool`/`exitPool` Test Case](#test-cases)). Base ERC-7730's own examples of `#.` never exercise this — every one resolves a flat, top-level sibling — so this ERC states the cross-scope behavior explicitly rather than leaving it to be inferred. - -### `switch` - -`switch` selects which type or layout governs a field, based on the value of an expression. The expression MAY come from two places: - -1. **An already-resolved sibling path** — the ordinary case, used when the expression is a normal ABI-decoded parameter of the same call, decoded by nothing new at all: - -```json -{ - "path": "data", - "switch": { - "expression": { "path": "schema" }, - "cases": { - "0x1234...": { "(address recipient,bool isHuman,uint256 score)": { "fields": [ - { "path": "recipient", "label": "Recipient", "format": "addressName" }, - { "path": "isHuman", "label": "Is human" }, - { "path": "score", "label": "Score" } - ]}}, - "$default": "reject" - } - } -} -``` - -This is the shape needed by EAS (`schema` selects how to decode `data`), ERC-7683 (`orderDataType` selects how to decode `orderData`), and ERC-7579 (`mode`'s decoded `callType` sub-field selects how to decode `executionCalldata`). As shorthand, `{"path": ""}` MAY be written as the bare string `""` wherever only a path reference is needed and no inline byte-level parsing (`type`/`bytes`/`mask`) applies — used this way for `operation`'s `expression` above, and for `switch`'s `expression` whenever it is a plain sibling-path reference. - -2. **Inline, read from the buffer at the current cursor position** — used only inside a `layout` tree, when the expression itself has to be parsed out of raw bytes rather than looked up as an already-decoded value: - -```json -{ "type": "switch", - "expression": { "type": "uint", "bytes": 1, "mask": "0x3f" }, - "payloadFrom": "inputs", - "cases": { - "0x00": { "(address recipient,uint256 amountIn,uint256 amountOutMin,bytes path,bool payerIsUser)": { "fields": [ - { "path": "recipient", "label": "Recipient", "format": "addressName" }, - { "path": "amountIn", "label": "Amount in" }, - { "path": "amountOutMin", "label": "Minimum amount out" }, - { "path": "path", "label": "Swap path" }, - { "path": "payerIsUser", "label": "Pay from wallet" } - ]}}, - "$default": "reject" - } -} -``` - -`mask` is an optional bitmask applied to the expression's raw value before matching against `cases` keys — needed for Universal Router, where the top bit of the command byte is an unrelated "allow revert" flag and only the low 6 bits select the command. `payloadFrom` names a sibling array (`inputs`), read at the same index as the current element of the enclosing `sequence` — a correlated-array lookup already precedented by ERC-7730's existing rule that a formatting parameter array is "read at the same index as the current element being formatted." - -In both forms, a case value is one of: - -* `{ "layout": }` — recurse into this ERC's own layout language (any node). -* `{ "switch": {...} }` — nest another switch (for multi-level tag structures). -* `{ "": { "intent": ..., "fields": [...] } }` — decode the payload using the ordinary Solidity ABI decoder for the tuple signature given as the key, and immediately apply the given `intent`/`fields` to the result, without a separate top-level `display.formats` entry. This exists because a `switch` case very often wants to say both "decode it like this" and "display it like this" together (Universal Router's command table, most notably). -* `{ "format": "" }` — stop: do not decode further, apply an ordinary base-ERC-7730 [field format](./erc-7730.md#field-format-specification) directly to the already-typed value in scope. This is for a case (very often `$default`) where the matched value needs no structural reinterpretation at all — it is already an ordinary value, just display it normally. -* `{ "label": "", "intent": "info" | "warning" }` — stop: do not decode further, display `label` verbatim in place of any decoded value, with the given severity. This is for a case whose matched value has no meaningful decoded content to show at all — see the chained-reference example in [Test Cases](#test-cases), where a matched sentinel value stands for "a value only known once an earlier step in the same batch has executed on-chain," which is not a value a wallet can compute or display, only name. - -Every `cases` map MAY include the reserved key `"$default"`, matched when no other case matches; its value is any of the case-value kinds above, or the literal string `"reject"`. A wallet MUST treat an expression value with no matching case, and no `$default` case present, the same way it treats an [unknown selector](./erc-7730.md#unknown-selectors): display a safe fallback and MUST NOT guess at a format. `$default` is an ordinary case, not a structurally different kind of thing — it MAY resolve to a full decode (as in the chained-reference example, where the *non*-sentinel branch is the one that needs `$default`), not only to `"reject"`. - -### `switch` at the top of a structured data format specification - -Every case above switches on an expression to reinterpret *one field's* bytes, while the rest of the call keeps its own fixed `intent` and `fields`. Some contracts have no such fixed meaning at all: the entire call exists only to be redirected, and which target function it becomes is the only thing worth describing. A [structured data format specification](./erc-7730.md#structured-data-format-specification) MAY declare a top-level `switch` object, with the same `expression`/`cases` shape (including `$default`) as above, instead of `intent`/`fields`. Its `expression` is a path to one of the outer call's own already-decoded parameters (never a raw-byte expression — there is no enclosing buffer to read one from at this level). - -Because there is no bytes value being reinterpreted at this level, only two case-value forms are valid here: a nested `switch` (for an expression that further refines an already-matched case), or an `interaction` object naming a **different** target function outright — see below. - -### `interaction` - -`interaction` describes a call synthesized from already-decoded pieces, rather than a bytes value to be displayed directly or resolved via `format: "calldata"`. It is usable wherever a field format specification is (as an alternative to `format`/`layout`), and as a case value of a top-level `switch`: - -```json -{ "interaction": { - "to": "", - "signature": "", - "args": [ { "path": "" } | { "value": "" }, ... ] -} } -``` - -`to` is a path to the target address. `signature` is the target's canonical Solidity signature — a wallet MUST resolve it against `to`'s own `display.formats` entry exactly as it would any other call, but by matching the signature directly rather than by computing and comparing a selector, since no selector was ever computed for this call (see [Rationale](#rationale)). `args` positionally binds values to that signature's declared parameter order — each entry is either `path` (a reference to one of the outer call's own decoded values) or `value` (a literal) — reusing the existing `path`/`value` duality of a [field format specification](./erc-7730.md#field-format-specification). Position and type govern the binding, exactly as ERC-7730 already treats parameter names as non-canonical for selector matching; `args` need not preserve the order values arrived in at the outer call. - -A wallet MUST resolve the matched target's own `intent`, `interpolatedIntent`, and `fields` using the bound `args` values in place of that target's own decoded parameters, and MUST apply the [unknown selector](./erc-7730.md#unknown-selectors) fallback if `to`'s descriptor has no entry matching `signature`. - -## Rationale - -**Why a JSON node tree and not a compact string grammar.** ERC-7730 already describes ABI function signatures and EIP-712 types as strings, so a string grammar (e.g. an extended fragment syntax) might seem consistent. It was rejected here because these payloads are not universally pre-understood the way Solidity ABI is — a string grammar for them would need its own bespoke parser, on top of the ABI parser wallets already carry, and a new string-grammar parser is exactly the class of code that has historically produced hardware-wallet parsing bugs. A JSON node tree reuses the same tree-walking wallets already do for `fields` and `group`. - -**Why the vocabulary is this small.** Every node and mode above exists because one of the real cases surveyed while designing this ERC needs it, not because it seemed generally useful. `sequence` has exactly one termination mode because no known case needs another. `mask` exists only because Universal Router's command byte shares space with a flag bit. Padded/aligned struct variants, bit-level fields narrower than a byte, and non-`tillEnd` sequence termination are all left out deliberately; they can be added later, non-breaking, if a real case turns up. - -**Why `switch` has two expression-sourcing forms instead of one.** Most of the surveyed cases (EAS, ERC-7683, ERC-7579) switch on an expression that is already sitting in an ordinary ABI-decoded field — no byte parsing is involved in getting it at all. Only Universal Router needs the expression pulled out of a raw byte mid-parse. Rather than forcing every case through a byte-oriented mental model, `switch` accepts a `path` directly. - -**Why nested calldata reuses ERC-7730's own `calldata` format instead of a dedicated layout node.** An earlier iteration of this design had its own `call` layout node kind: declared directly as a field's `schema`/`layout`, it would recurse into ERC-7730's selector-matching itself. That worked, but it duplicated a mechanism ERC-7730 already has — [`format: "calldata"`](./erc-7730.md#embedded-calldata) already does exactly "this field's bytes are themselves a call to another contract, resolve them recursively," with `calleePath`/`selectorPath`/`amountPath`/`spenderPath` covering everything a `call` node did. Once this ERC's own [Path addressing](#path-addressing) rule lets an ordinary field entry's `path` reach into a `layout`-decoded `object`/`sequence` the same way it reaches into ABI-decoded ones, there is nothing left for a dedicated layout node to do that a sibling `format: "calldata"` field entry doesn't already do — parsing the bytes (plain `bytes`, consuming the right length) and interpreting them as a call become two separate, already-existing steps instead of one new fused one. The only real gap `format: "calldata"` had was `operation`: ERC-7730 has no concept of `DELEGATECALL` because an ordinary transaction field is never anything but a plain call. This ERC closes that one gap by adding `operation` as a new, optional param to the existing format, rather than re-deriving everything else `format: "calldata"` already does. - -**Why `interaction` still exists as its own construct.** Unlike the case above, a call assembled from scattered, already-decoded values — no contiguous calldata bytes anywhere to point `format: "calldata"` at — has no equivalent already in ERC-7730. `interaction`'s `to`/`signature`/`args` shape is the minimum needed to express that: which target, which function (matched by signature text, since no selector was ever computed), and which already-decoded values bind to its arguments, in what order. - -**Why the top-level `switch`/`interaction` form exists, and why it's the one exception to this ERC's evidence rule.** Every other construct in this ERC exists because a real, cited transaction needed it. This one does not have that grounding — no verified live transaction was found that reinterprets already-decoded parameters into a different function's argument list the way the [TieredExecutor example](../assets/erc-non-abi-dispatch/example-tiered-executor.json) does. It is included because the shape it targets — a small trusted relayer accepting a tag and a handful of generic-looking arguments, then re-dispatching to one of several unrelated target interfaces with a different argument order per target — is a common, plausible, and easily reachable governance/router pattern, structurally close to a `switch` over an enum parameter. Readers should weigh this construct with that in mind: it is motivated by generality, not by an observed case, unlike everything else here. If a real contract using this exact shape turns up, its transaction should replace the made-up one in the Test Cases section. - -**Why `bitfield` is a distinct node type rather than a parameter on `uint`.** `object` and `sequence` both assume byte-aligned, non-overlapping fields — that assumption is load-bearing throughout the rest of this ERC (it's what makes the byte-width invariant a simple sum of child widths). `bitfield`'s named sub-fields can overlap arbitrarily within a shared width and carry no byte alignment at all, so keeping it a separate, clearly-labeled type (rather than, say, a `bits` option quietly attached to `uint`) makes it visually obvious, at the point a field is declared, that its sub-fields don't follow the rest of the language's byte-aligned norm. The motivating real case is Uniswap v4: hook contract addresses encode up to 14 independent permission flags in specific low-order bits of the 160-bit address value itself, verified against `Hooks.sol` and Uniswap's own v4 documentation. - -**Why `layout` may anchor on an already-decoded scalar.** Balancer's `BalancerRelayer`/`BatchRelayerLibrary` — the `multicall`-based contract Balancer's own frontend and third-party "zap" integrations use to chain a `joinPool`/`exitPool`/`swap` sequence in one transaction — accepts ordinary ABI-decoded `uint256` amount fields (`maxAmountsIn[i]`, `outputReference`) that are *sometimes* not amounts at all: if the top 12 bits equal `0xba1`, the value is a "chained reference," a pointer to a storage slot the relayer will populate from an *earlier* step's output during execution of this same transaction, not a literal quantity (verified against `BaseRelayerLibraryCommon.sol`'s `_isChainedReference`). Every other tag-dispatch case in this ERC (EAS's `schema`, ERC-7683's `orderDataType`, ERC-7579's `mode`) reads its tag from a field genuinely separate from the value it governs. This one does not — the tag and the value it governs are the same field, examined under a mask. Rather than invent a self-referencing mode of `switch`'s path-sourced form (which would need its own reasoning about read/write ordering and cycles), this ERC reuses the existing, already-masked, inline `switch` node verbatim, and only generalizes *where* a `layout` tree is allowed to start: on the canonical encoding of a value ABI decoding already produced, not only on bytes still waiting to be parsed. This keeps one masking mechanism in the ERC instead of two. - -**Why `$default` moved inside `cases` instead of staying a sibling key.** Originally `default` sat next to `cases`, and every example gave it the value `"reject"` — implying, without saying so, that `default` was structurally special: a fail-closed escape hatch, not really "a case" the way the entries in `cases` are. The chained-reference case above breaks that implication: its `$default` branch is the *common*, expected value (an ordinary amount), and the entry that needs special handling is the sentinel — an inversion of every prior example. Once `default` can legitimately hold a full decode instead of only `"reject"`, it is not structurally different from any other entry in `cases` — it only differs in its matching rule ("nothing else matched" instead of "matched this literal"). Moving it into `cases` under a reserved `$default` key makes that equivalence explicit, and matches the reserved-token convention this ERC already uses for `$index`, rather than introducing a second way of marking a key as reserved. - -**Why two new case-value kinds, `format` and `label`, and not one.** The chained-reference case needs both ends of the same problem solved: its `$default` branch has nothing unusual to say — the value is exactly what its ABI type already claims, so it needs a way to say "stop, this is fine, just display it normally" without an author re-deriving `intent`/`fields` for a plain amount. Its sentinel branch has the opposite problem: there is no value to compute or format at all — the real quantity is written by an earlier step's execution, after signing, and no construct in this ERC (or in ERC-7730 itself) can display a value that does not yet exist. `format` and `label` are the minimum needed for each half: `format` hands the value to ERC-7730's own existing formatting, unchanged; `label` displays fixed text in place of a value, for exactly the case where "unknown, and unknowable ahead of time" is itself the only honest thing to show. Neither is specific to Balancer — both are general terminal case-value kinds usable anywhere a `switch` case has nothing left to structurally decode, which is precisely the same "narrow but evidence-motivated" bar every other construct in this ERC was held to. - -**Why `#.` crossing `switch`/`layout` scope boundaries is stated explicitly rather than left implied.** Building the `joinPool`/`exitPool` Test Case surfaced a real gap: `switch`-decoded fields like `userData`'s `amountsIn[i]` need to be displayed as token amounts, which requires pairing them with a token list (`request.assets`) that is not inside `userData` at all — it is a sibling of `userData`, one level up in the outer call. Base ERC-7730 defines `#.` as the root of the structured data, which reads as though it should handle this, but every actual use of `#.` in ERC-7730's own spec text and asset examples (checked exhaustively: one inline example plus three asset files) resolves a flat, top-level sibling — none of them cross out of a nested decode the way a `switch` case's local tuple scope requires. Left unstated, two conformant implementations could reasonably disagree on whether `#.` reaches past a `switch`/`layout` scope at all. This ERC resolves that ambiguity in favor of the more useful behavior — `#.` always reaches the true root — rather than requiring every such pairing to be left unresolved. - -**Why positional binding, and why `args` may reorder.** ERC-7730 already treats parameter *names* as non-canonical for the purpose of selector matching — only position and type are. An `interaction` that redirects to a different function has no shared parameter names to align by in the first place (the outer call's `account`/`amount` mean nothing to the target function's own signature), so positional binding by the target's declared order is the only definition that is well-defined at all, and it is the same rule ERC-7730 already applies elsewhere, not a new one. - -## Backwards Compatibility - -This ERC only adds new, optional keys to a field format specification (`layout`, `switch`, `interaction`), plus one new, optional param (`operation`) to ERC-7730's own `format: "calldata"`, plus two new terminal case-value kinds (`format`, `label`) usable inside any `switch`'s `cases`. A descriptor that does not use them is unaffected, and a wallet implementing only ERC-7730 without this extension can safely ignore fields that use them, applying the existing [unknown field / raw fallback](./erc-7730.md) behavior. - -## Test Cases - -Seven of the eight examples below are real, mined transactions, decoded from raw calldata (not an explorer's rendered summary) and cross-checked against at least one independent source. The eighth, `TieredExecutor`, is explicitly a made-up contract — see its own description below and the caveat in [Rationale](#rationale). Each is a full, standalone ERC-7730 descriptor file under [`../assets/erc-non-abi-dispatch/`](../assets/erc-non-abi-dispatch/) rather than a snippet, so it can be read with all the surrounding `context`/`metadata`/`display` structure intact. - -### Safe `MultiSend` - -Ethereum mainnet, tx [`0x414ae5aaff729927d663ccaa027ea2284e47fa546cb73ce1dee481ee36a7138e`](https://etherscan.io/tx/0x414ae5aaff729927d663ccaa027ea2284e47fa546cb73ce1dee481ee36a7138e). A Safe at `0xCa087C9e22bC97059d8fd6e25956835Ec205782B` delegatecalls `MultiSendCallOnly` (`0x40A2aCCbd92BCA938b02010E17A5b8929b49130D`) to batch six CTC ([`0xa3ee21c306a700e682abcdfe9baa6a08f3820419`](https://etherscan.io/address/0xa3ee21c306a700e682abcdfe9baa6a08f3820419)) `transfer` calls to six different recipients in one transaction. The 918-byte `transactions` buffer decodes (record 0 of 6) to `operation=CALL`, `to=0xa3ee21c306a700e682abcdfe9baa6a08f3820419`, `value=0`, `dataLength=68`, and `data` resolving, via `format: "calldata"`, into a normal `transfer(address,uint256)` sending `40000000000000000000000` (40,000 CTC) to `0x6ba2c52a959f0544e00aea60fe576463fe5fc38d`; the remaining five records follow the same shape, and the buffer is consumed exactly with no slack, confirming the `tillEnd` parse. - -Full descriptor: [`example-safe-multisend.json`](../assets/erc-non-abi-dispatch/example-safe-multisend.json). - -### Uniswap Universal Router - -Ethereum mainnet, tx [`0x3805667353244e8fb763d50b7dd3bdb8f176119b44fdbd0a4ad5629d851ebbba`](https://etherscan.io/tx/0x3805667353244e8fb763d50b7dd3bdb8f176119b44fdbd0a4ad5629d851ebbba), calling `execute(bytes,bytes[],uint256)` on the Universal Router at `0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af`. `commands = 0x000004`: command 0 (`0x00`, `V3_SWAP_EXACT_IN`) sends `amountIn=3425828840000000000000` EURe through the path `EURe → EUR0 → EURC` with `payerIsUser=true`; command 1 (`0x00` again) swaps `amountIn=5138743260000000000000` EURe directly to EURC; command 2 (`0x04`, `SWEEP`) sweeps native ETH with `amountMinimum=0` back to the swapper. All three command bytes had their top (revert-flag) bit unset; token identities and pool fees were confirmed independently via each token's `symbol()`/`decimals()`. - -Full descriptor: [`example-universal-router.json`](../assets/erc-non-abi-dispatch/example-universal-router.json). - -### ERC-7579 `execute` - -Base mainnet, Biconomy Nexus accounts (an ERC-7579 reference implementation), function selector `0xe9ae5c53`. Single-call example: tx [`0x057b1df67f033ad77faba10e39f39dde273c225d62c3b36ef8547b3f51fad5c1`](https://basescan.org/tx/0x057b1df67f033ad77faba10e39f39dde273c225d62c3b36ef8547b3f51fad5c1) — `mode` has `callType=0x00`, and `executionCalldata` decodes to `target=0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` (USDC on Base), `value=0`, `callData` recursing into `transfer(0x3C97112223b1AD104Cf2ac022e450Ef862652b93, 1)`. Batch-call example: tx [`0x26d34bf7aa5adb0642218422264a4034ffda5785be3354168eb478051664613c`](https://basescan.org/tx/0x26d34bf7aa5adb0642218422264a4034ffda5785be3354168eb478051664613c) — `callType=0x01`, decoding to three executions (a native ETH transfer, a DAI `transfer`, and a USDC `transfer`) all to the same recipient, a single-UserOperation "sweep to one address" pattern. Both the mode layout and the callType-driven switch were confirmed against Nexus's own `ModeLib.sol`. - -Full descriptor: [`example-erc7579-execute.json`](../assets/erc-non-abi-dispatch/example-erc7579-execute.json). - -### Circle CCTP message - -Base → Ethereum, 50,000 USDC. Burn tx [`0x178632412a0eb4e642bfe30b1f80d0a4799ab400d4d1c76702ba01ba1458b57f`](https://basescan.org/tx/0x178632412a0eb4e642bfe30b1f80d0a4799ab400d4d1c76702ba01ba1458b57f) on Base; mint tx [`0xa5ab46a57e89fe110df3269065c0c07a394f22fe7a769bb916a547c7c1b3e99f`](https://etherscan.io/tx/0xa5ab46a57e89fe110df3269065c0c07a394f22fe7a769bb916a547c7c1b3e99f) on Ethereum. The 248-byte `message` decodes to `sourceDomain=6` (Base), `destinationDomain=0` (Ethereum), `nonce=764152`, `sender`/`recipient` as the two chains' TokenMessenger contracts, `destinationCaller=0x0` (permissionless relay); the nested `messageBody` decodes to `burnToken` = Base USDC, `mintRecipient`/`messageSender` both the same self-relaying address, `amount=50000000000` (50,000 USDC). The 132-byte `messageBody` is consumed exactly. - -Full descriptor: [`example-cctp-message.json`](../assets/erc-non-abi-dispatch/example-cctp-message.json). Note the descriptor's `context.contract` address (`0xYourMessageTransmitterAddress`) is a placeholder — confirm the real deployment address for your target chain against [Circle's own docs](https://developers.circle.com/cctp/evm-smart-contracts) before use. - -### EAS attestation - -Optimism mainnet, schema `#78` (UID `0xfdcfdad2dbe7489e0ce56b260348b7f14e8365a8a325aef9834818c00d46b31b`), string `string rpgfRound,address referredBy,string referredMethod` — Optimism's RetroPGF badgeholder-referral schema. Attestation [`0x1a7a222934cbab53dd1c8e85d34e5fdd6d17cfd62a18ad871e4bec4705fdaa41`](https://optimism.easscan.org/attestation/view/0x1a7a222934cbab53dd1c8e85d34e5fdd6d17cfd62a18ad871e4bec4705fdaa41), tx `0x820e5b8404f1ec62b47459e538151e54fb598b729dcb461087456bb856abf595`, decodes to `rpgfRound="4"`, `referredBy=0x0000000000000000000000000000000000000342`, `referredMethod="Friend"`. `schema` and `data` are already-decoded ABI sibling fields of `attest()`'s own parameters, so no `layout` node is needed at all here — just `switch` sourced from a path. (A simpler, single-field schema also exists at scale — Coinbase's "Verified Account" schema, UID `0xf8b05c79f090979bf4a80270aba232dff11a10d9ca55c4f88de95317970f0de9`, `bool verifiedAccount`, 720,000+ attestations on Base — useful as a minimal case, but the RetroPGF one exercises both static and dynamic ABI types.) - -Full descriptor: [`example-eas-attestation.json`](../assets/erc-non-abi-dispatch/example-eas-attestation.json). Note the descriptor's `context.contract` address (`0xYourEASAddress`) is a placeholder — confirm the real deployment address for your target chain against [attest.org's own docs](https://docs.attest.org) before use. - -### ERC-7683 order - -Base mainnet, tx [`0x73b3ca11e3733c78db8365086f64874879fb3a859b21c960ec072c2a95c5da09`](https://basescan.org/tx/0x73b3ca11e3733c78db8365086f64874879fb3a859b21c960ec072c2a95c5da09), calling `open((uint32,bytes32,bytes) order)` on Across's `AcrossOriginSettler` (`0x4afb570AC68BfFc26Bb02FdA3D801728B0f93C9E`) — a self-bridge of 1 USDC from Base to Arbitrum. `orderDataType = 0x9df4b782e7bbc178b3b93bfe8aafb909e84e39484d7f3c59f400f1b4691f85e2`, independently confirmed as `keccak256("AcrossOrderData(address inputToken,uint256 inputAmount,address outputToken,uint256 outputAmount,uint256 destinationChainId,bytes32 recipient,address exclusiveRelayer,uint256 depositNonce,uint32 exclusivityPeriod,bytes message)")`, decoding to `inputToken`/`outputToken` = USDC on Base/Arbitrum, `inputAmount=1000000`, `outputAmount=981521` (the relayer's fee), `destinationChainId=42161`, `recipient` equal to the sender, and empty `exclusiveRelayer`/`depositNonce`/`exclusivityPeriod`/`message`. Note this uses the same typehash-switch shape as the EAS example above — different ecosystem, same construct. - -Full descriptor: [`example-erc7683-order.json`](../assets/erc-non-abi-dispatch/example-erc7683-order.json). - -### Balancer Relayer `joinPool`/`exitPool` - -Ethereum mainnet, Safe transaction hash [`0x9ebb8a7d7c085b3dde80c02f5bc44a1d32749104e2b17d17bf72d7f674ef1b34`](https://etherscan.io/tx/0x9ebb8a7d7c085b3dde80c02f5bc44a1d32749104e2b17d17bf72d7f674ef1b34), executed 2026-05-22. `BalancerRelayer.multicall` (`0x35Cea9e57A393ac66Aaa7E25C391D52C74B5648f`) batches three delegatecalls into `BatchRelayerLibrary` (`0xeA66501dF1A00261E3bB79D1E90444fc6A186B62`): a bundled `setRelayerApproval`, then two `exitPool` calls that unwind a nested LP position two levels deep — exiting a Weighted pool (`kind=0x00`, `ExitKind=0x01`, a literal `16,250.97` pool tokens) for, among other tokens, the BPT of a Stable pool nested inside it, then exiting that Stable pool (`kind=0x03`, `ExitKind=0x02`) using the just-received BPT amount directly as a chained reference (`0xba10...0`) — a value that cannot be known until the first `exitPool` has actually executed on-chain. Both `userData`'s `JoinKind`/`ExitKind` dispatch (sourced from the sibling `kind` parameter, not from `poolId`) and the chained-reference sentinel were confirmed against `VaultActions.sol`/`WeightedPoolUserData.sol`/`StablePoolUserData.sol`/`BasePoolUserData.sol` source, and both non-default branches against the cited transaction's own two `exitPool` calls. - -Full descriptors: [`example-balancer-relayer-multicall.json`](../assets/erc-non-abi-dispatch/example-balancer-relayer-multicall.json) (the outer `multicall` entry point) and [`example-balancer-relayer-library.json`](../assets/erc-non-abi-dispatch/example-balancer-relayer-library.json) (`setRelayerApproval`/`joinPool`/`exitPool`, reached only via the multicall's delegatecalls — `joinPool` itself is not exercised by the cited transaction, unlike `exitPool`). - -### `TieredExecutor` (made-up example) - -A small, illustrative Solidity contract written for this ERC — [`TieredExecutor.sol`](../assets/erc-non-abi-dispatch/TieredExecutor.sol) — is **not deployed anywhere**; unlike every other example above, no real transaction exists for it. It demonstrates the top-level `switch`/`interaction` form: `executeOperation(address target, Operation op, address account, uint256 amount)` takes an enum expression `op` and two generic-looking arguments, and re-dispatches to one of two unrelated target interfaces — `IRewardVault.grantReward(address,uint256)` for `op=1`, `ILegacyToken.creditAccount(uint256,address)` for `op=2` — each with a different parameter order, resolved from the same `account`/`amount` values by positional binding. - -Full descriptors: [`example-tiered-executor.json`](../assets/erc-non-abi-dispatch/example-tiered-executor.json) (the dispatching contract), [`example-reward-vault.json`](../assets/erc-non-abi-dispatch/example-reward-vault.json) and [`example-legacy-token.json`](../assets/erc-non-abi-dispatch/example-legacy-token.json) (the two target interfaces it recurses into, each an independently-authored descriptor resolved the same way any other embedded-calldata target would be). - -Every example file under [`../assets/erc-non-abi-dispatch/`](../assets/erc-non-abi-dispatch/) uses this ERC's current vocabulary (`type`/`object`/`switch`/`expression`/`format: "calldata"`, `$default` inside `cases`) and validates against [`erc7730-non-abi-dispatch.schema.json`](../assets/erc-non-abi-dispatch/erc7730-non-abi-dispatch.schema.json), the companion JSON Schema extending ERC-7730's own. - -## Reference Implementation - -TBD - -## Security Considerations - -A `layout`/`switch`/`interaction` interpreter is new parsing surface on a hardware wallet, decoding attacker-influenced (calldata is provided by whoever submits the transaction) bytes. Implementations MUST bound recursion depth (embedded-calldata resolution, nested `switch`, and `interaction` can all recurse arbitrarily deep in principle), MUST treat any length (`lengthFrom`, or a `sequence`'s implicit `tillEnd` walk) that would read past the end of the underlying buffer as invalid input, and MUST fail closed — applying the [unknown selector](./erc-7730.md#unknown-selectors) fallback — rather than displaying a partially decoded or best-guess value when a `layout` or `switch` does not cleanly match the actual bytes. `operation` resolving to `"delegatecall"` is a particularly high-severity case of this: a wallet MUST fail closed exactly as hard for an unresolvable delegatecall target as it would for one with no descriptor at all, never falling back to treating it as a plain call. - -## Copyright - -Copyright and related rights waived via [CC0](../LICENSE.md). From 3e6591205fb9a94c36c8eacd13365eb2e95e42d9 Mon Sep 17 00:00:00 2001 From: Alex Forshtat Date: Mon, 3 Aug 2026 17:49:28 +0200 Subject: [PATCH 17/23] Delete assets/erc-non-abi-dispatch/feature-stateref-based-dispatch.md --- .../feature-stateref-based-dispatch.md | 60 ------------------- 1 file changed, 60 deletions(-) delete mode 100644 assets/erc-non-abi-dispatch/feature-stateref-based-dispatch.md diff --git a/assets/erc-non-abi-dispatch/feature-stateref-based-dispatch.md b/assets/erc-non-abi-dispatch/feature-stateref-based-dispatch.md deleted file mode 100644 index c6e638934bf..00000000000 --- a/assets/erc-non-abi-dispatch/feature-stateref-based-dispatch.md +++ /dev/null @@ -1,60 +0,0 @@ -# Parked feature: dispatch over live chain state - -Status: **not part of the companion ERC yet.** Parked pending [ethereum/ERCs#1738](https://github.com/ethereum/ERCs/pull/1738) ("Intent mutability") landing, since this idea is a direct extension of that PR's mechanism and shouldn't be specified independently of it. - -## The problem this would address - -Safe's `execTransactionFromModule`/Guard model, and similar "arbitrary installed extension" patterns, hand calldata to a contract that is: -- arbitrary and unbounded in general (any address the Safe owners enabled as a module), so -- there is no protocol-fixed schema to write a single descriptor against, the way there is for `MultiSend` (one fixed packed layout) or Universal Router (one bounded, enumerable command set). - -The companion ERC's own Rationale (and the accompanying "10 impossible use-cases" exercise) currently treats this as out of scope: an arbitrary Module is fundamentally unboundable, full stop. That's still correct for the *general* case. But it's more pessimistic than necessary for the common real case: an owner enables one or a small number of *specific, known, audited* modules/guards (a spending-limit module, a specific Zodiac Roles configuration, a specific recovery module), and for exactly those known configurations, a descriptor author could, in principle, describe the resulting behavior precisely. - -## PR #1738's mechanism (as landed/proposed today) - -`context.contract.stateRefs`: an array of storage-slot preconditions — - -```json -{ - "slot": "0x", - "expectedValue": "0x", - "mask": "0x", - "chainId": "", - "address": "", - "description": "human-readable explanation" -} -``` - -A wallet verifies live state matches `expectedValue` (masked, if `mask` is given) before trusting the descriptor's claimed intent. This is a **binary gate**: match → descriptor applies; mismatch → descriptor is stale, fall back to opaque signing. `context.contract.proxy` (typed EIP-1967/1822/2535 verification with `expectedImplementations`) is the same idea specialized to upgradeable-proxy implementation slots. - -Also notable: #1738 has its own normative **omission rule** — anything whose intent depends on unexpressible factors (which includes arbitrary Module/Guard calldata) MUST be omitted from `display.formats` entirely. That's the PR's current, explicit answer to this exact problem: give up, don't describe it. - -## The proposed extension: state as a dispatch tag, not just a gate - -`stateRefs` only ever answers yes/no against one pinned expectation. What Module/Guard calldata actually needs is *selection*: "depending on which known, audited configuration is currently live, here is which interpretation applies" — a multi-way choice, not a single check. That is exactly what this companion ERC's `dispatch` construct already does for calldata-sourced tags; the extension is a **fourth tag-sourcing mode**, reading the tag from live chain state instead of from calldata, a decoded sibling field, or a hashed prefix: - -```json -{ "kind": "dispatch", - "tag": { "kind": "stateRef", "address": "@.to", "slot": "0x" }, - "cases": { - "0x000000000000000000000000": { "call": { "to": "@.to", "signature": "execTransaction(...)", "args": [ /* ... */ ] } } - }, - "default": "reject" -} -``` - -Everything downstream of `tag` resolution is unchanged: the same `cases` map, the same case-value polymorphism (tuple-signature-key / `layout` / `call` / nested `dispatch`), the same fail-closed `default: "reject"` for anything not explicitly enumerated. - -This composes with #1738 rather than duplicating it: `stateRefs`/`proxy` would remain the *static* precondition layer ("this descriptor doesn't even apply unless..."), and a `stateRef`-tagged `dispatch` would be the *dynamic selection* layer on top ("...and depending on which of several known-audited configurations is live, here's which interpretation to use"). - -## What this does and does not solve - -- **Does not** make arbitrary, unaudited Module/Guard calldata describable. That remains, correctly, unboundable — no descriptor language changes that. -- **Does** extend coverage to the case where the live module/guard is one of a small, enumerated, audited set the descriptor author explicitly listed — the same bounded, sparse, fail-closed shape every other `dispatch` table in this ERC already has. -- **Inherits** a real infrastructure requirement #1738 already introduces, not a new one: the wallet needs live chain-read access at signing time (a storage read, or, if generalized further, a `staticcall` return value), which most hardware wallets get via a companion app rather than standalone. - -## Why this is parked, not drafted - -- #1738 is still an open PR under active review (reviewer questions outstanding on diamond binding grain, omission-rule strictness, and whether preconditions should support view-function calls — the last of which is directly relevant to whether a `stateRef` tag should eventually generalize beyond raw storage slots to `staticcall` results too). -- Specifying a dependent extension before the mechanism it extends has stabilized risks having to redo this the moment #1738's own `stateRefs` shape changes during review. -- Revisit once #1738 lands: re-derive the exact `stateRef`/`slot`/`mask` shape from whatever #1738 actually ships (not from this snapshot), and decide whether `dispatch`'s fourth tag-sourcing mode belongs in this companion ERC or in a further, separate companion. From c29570391d78f37aaaa5b3cc2aacfcaab232400a Mon Sep 17 00:00:00 2001 From: Alex Forshtat Date: Mon, 3 Aug 2026 23:53:33 +0200 Subject: [PATCH 18/23] Drop 'tillEnd' for sequnece, add 'countFrom' option, add AI suggested examples --- ERCS/erc-0000-custom-bytes-erc7730.md | 72 +++++++++ .../erc7730-non-abi-dispatch.schema.json | 9 +- .../example-compound-bulker.json | 129 +++++++++++++++ .../example-safe-multisend.json | 1 - .../example-uniswap-v4-initialize.json | 85 ++++++++++ .../example-universal-router.json | 1 - .../example-wormhole-token-bridge.json | 147 ++++++++++++++++++ 7 files changed, 439 insertions(+), 5 deletions(-) create mode 100644 assets/erc-non-abi-dispatch/example-compound-bulker.json create mode 100644 assets/erc-non-abi-dispatch/example-uniswap-v4-initialize.json create mode 100644 assets/erc-non-abi-dispatch/example-wormhole-token-bridge.json diff --git a/ERCS/erc-0000-custom-bytes-erc7730.md b/ERCS/erc-0000-custom-bytes-erc7730.md index 3c9b3fde3a1..d708ab2dd1c 100644 --- a/ERCS/erc-0000-custom-bytes-erc7730.md +++ b/ERCS/erc-0000-custom-bytes-erc7730.md @@ -66,6 +66,16 @@ For example, for a byte array with each byte representing a different element: }} ``` +Alternatively, `count` MAY give a literal element count, or `countFrom` may name a sibling field decoded earlier in the same object, sizing the sequence from a previously-decoded value – as with a Wormhole VAA's guardian-signature array, sized by its own `numSignatures` byte, in the Test Case below. + +```json +{ "name": "signatures", "schema": { "type": "sequence", "countFrom": "numSignatures", + "element": { "type": "object", "fields": [ + { "name": "guardianIndex", "schema": { "type": "uint", "bytes": 1 } }, + { "name": "signature", "schema": { "type": "bytes", "length": 65 } } + ]}}} +``` + ### `object` The mechanism for declaring an entry in a `sequence` data structure that is not represented by an ABI-encoded parameter. @@ -329,6 +339,68 @@ function open(OnchainCrossChainOrder calldata order) external; Using ERC-0000, this input can be described for Clear Signing – see [ERC-7683 Order Example](../assets/erc-non-abi-dispatch/example-erc7683-order.json). +### Uniswap v4 - `PoolManager.initialize` (WIP) + +The `PoolManager` contract identifies a pool's hook contract, and which of its callbacks are active, entirely through the low bits of that hook contract's own address: + +```solidity +struct PoolKey { + Currency currency0; + Currency currency1; + uint24 fee; + int24 tickSpacing; + IHooks hooks; +} + +function initialize(PoolKey memory key, uint160 sqrtPriceX96) external returns (int24 tick); +``` + +#### What makes this encoding unusual + +1. `key.hooks` is an ordinary ABI `address` parameter, but its lowest 14 bits are individually meaningful flags (`beforeSwap`, `afterSwap`, `beforeAddLiquidity`, ...) chosen by mining a vanity address at hook-deployment time – the address *is* the bitfield, with no separate flags parameter anywhere in the call. +2. Unlike every other Test Case here, this needs no `sequence` or `switch` at all – just a `bitfield` layout attached directly to an already-ABI-decoded scalar, to tell a signer which of a hook's callbacks it is trusting to run on every swap/mint/burn against this pool. + +Using ERC-0000, this input can be described for Clear Signing – see [Uniswap v4 Initialize Example](../assets/erc-non-abi-dispatch/example-uniswap-v4-initialize.json). + +### Compound III `Bulker.invoke` (WIP) + +The `Bulker` contract batches several Comet actions in one call, but unlike `MultiSend` or `UniversalRouter`, the per-action payload is never itself calldata for a callable function: + +```solidity +bytes32 constant ACTION_SUPPLY_ASSET = "ACTION_SUPPLY_ASSET"; +bytes32 constant ACTION_TRANSFER_ASSET = "ACTION_TRANSFER_ASSET"; +bytes32 constant ACTION_WITHDRAW_ASSET = "ACTION_WITHDRAW_ASSET"; +bytes32 constant ACTION_CLAIM_REWARD = "ACTION_CLAIM_REWARD"; + +function invoke(bytes32[] calldata actions, bytes[] calldata data) external payable; +``` + +#### What makes this encoding unusual (WIP) + +1. Each `data[i]` is a bare `abi.encode` of a tuple – no function selector – that `Bulker` itself `abi.decode`s and forwards, under a **different function name and a different argument list**, to `Comet` (e.g. `ACTION_SUPPLY_ASSET`'s `(comet, to, asset, amount)` becomes a call to `comet.supplyFrom(msg.sender, to, asset, amount)`): exactly the scattered-args-reassembled-as-a-different-call shape `interaction` exists for, on a real, heavily used contract rather than an illustrative one. +2. `msg.sender` – the account that actually calls `Bulker.invoke` – is threaded into that reconstructed call as its first argument despite never appearing anywhere in `data[i]`, which only `interaction`'s `args` referencing the container root (`@.from`) can express. +3. The target contract itself varies by action and is not always the tuple's first field: `ACTION_CLAIM_REWARD` calls `rewards.claim(comet, src, shouldAccrue)`, so `interaction`'s own `to` is bound to `rewards`, not `comet`. + +Using ERC-0000, this input can be described for Clear Signing – see [Compound III Bulker Example](../assets/erc-non-abi-dispatch/example-compound-bulker.json). + +### Wormhole Token Bridge `completeTransfer` (WIP) + +The `TokenBridge` contract accepts a signed Wormhole message (a "VAA") as a single opaque `bytes` argument, entirely packed rather than ABI-encoded: + +```solidity +function completeTransfer(bytes memory encodedVm) public; +``` + +A VAA is `version(1) | guardianSetIndex(4) | numSignatures(1) | signatures[] | timestamp(4) | nonce(4) | emitterChainId(2) | emitterAddress(32) | sequence(8) | consistencyLevel(1) | payload`, where each of the `numSignatures` signature entries is `guardianIndex(1) | r(32) | s(32) | v(1)`, and `payload`'s own first byte selects its shape (`1` = a token transfer: `amount(32) | tokenAddress(32) | tokenChain(2) | to(32) | toChain(2) | fee(32)`). + +#### What makes this encoding unusual + +1. `signatures`'s element count is neither ABI-length-prefixed nor fixed – it is a plain `numSignatures` byte decoded a few bytes earlier in the very same buffer, needing `sequence`'s `countFrom`. +2. `payload` is dispatched by its own leading tag byte, nested inside a `switch` in spirit similar to Balancer's `userData` – but here the entire VAA, both dispatch tags and all data, lives in one opaque `bytes` argument with no ABI structure anywhere around it. +3. `emitterAddress`/`tokenAddress`/`to` are 32-byte, chain-agnostic identifiers, decoded as raw bytes rather than `address` – they are only interpretable as EVM addresses once matched against their accompanying chain-id field. + +Using ERC-0000, this input can be described for Clear Signing – see [Wormhole Token Bridge Example](../assets/erc-non-abi-dispatch/example-wormhole-token-bridge.json). + ### `TieredExecutor` (artificial illustrative example) The `TieredExecutor` contract encodes the data in the following format: diff --git a/assets/erc-non-abi-dispatch/erc7730-non-abi-dispatch.schema.json b/assets/erc-non-abi-dispatch/erc7730-non-abi-dispatch.schema.json index 67c624fe60e..8151c17d8bb 100644 --- a/assets/erc-non-abi-dispatch/erc7730-non-abi-dispatch.schema.json +++ b/assets/erc-non-abi-dispatch/erc7730-non-abi-dispatch.schema.json @@ -1609,13 +1609,16 @@ "$ref": "#/$layout/node" }, "count": { - "const": "tillEnd" + "type": "integer", + "minimum": 0 + }, + "countFrom": { + "type": "string" } }, "required": [ "type", - "element", - "count" + "element" ], "additionalProperties": false }, diff --git a/assets/erc-non-abi-dispatch/example-compound-bulker.json b/assets/erc-non-abi-dispatch/example-compound-bulker.json new file mode 100644 index 00000000000..0ce9dae8485 --- /dev/null +++ b/assets/erc-non-abi-dispatch/example-compound-bulker.json @@ -0,0 +1,129 @@ +{ + "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-non-abi-dispatch.schema.json", + "context": { + "$id": "Compound III Bulker", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0xa397a8C2086C554B531c02E29f3291c9704B00c7" + } + ] + } + }, + "metadata": { + "owner": "Compound", + "contractName": "Bulker", + "info": { + "url": "https://github.com/compound-finance/comet" + } + }, + "display": { + "formats": { + "invoke(bytes32[] actions,bytes[] data)": { + "$id": "Compound III Bulk Actions", + "intent": "Execute batch", + "fields": [ + { + "path": "data[]", + "label": "Batched action", + "switch": { + "expression": { + "path": "actions[$index]" + }, + "cases": { + "0x414354494f4e5f535550504c595f415353455400000000000000000000000000": { + "(address comet,address to,address asset,uint256 amount)": { + "intent": "Supply collateral", + "fields": [ + { "path": "to", "label": "On behalf of", "format": "addressName" }, + { "path": "asset", "label": "Asset", "format": "addressName" }, + { "path": "amount", "label": "Amount", "format": "tokenAmount", "params": { "tokenPath": "asset" } }, + { + "interaction": { + "to": "comet", + "signature": "supplyFrom(address,address,address,uint256)", + "args": [ + { "path": "@.from" }, + { "path": "to" }, + { "path": "asset" }, + { "path": "amount" } + ] + } + } + ] + } + }, + "0x414354494f4e5f5452414e534645525f41535345540000000000000000000000": { + "(address comet,address to,address asset,uint256 amount)": { + "intent": "Transfer within Compound", + "fields": [ + { "path": "to", "label": "Recipient", "format": "addressName" }, + { "path": "asset", "label": "Asset", "format": "addressName" }, + { "path": "amount", "label": "Amount", "format": "tokenAmount", "params": { "tokenPath": "asset" } }, + { + "interaction": { + "to": "comet", + "signature": "transferAssetFrom(address,address,address,uint256)", + "args": [ + { "path": "@.from" }, + { "path": "to" }, + { "path": "asset" }, + { "path": "amount" } + ] + } + } + ] + } + }, + "0x414354494f4e5f57495448445241575f41535345540000000000000000000000": { + "(address comet,address to,address asset,uint256 amount)": { + "intent": "Withdraw or borrow", + "fields": [ + { "path": "to", "label": "Recipient", "format": "addressName" }, + { "path": "asset", "label": "Asset", "format": "addressName" }, + { "path": "amount", "label": "Amount", "format": "tokenAmount", "params": { "tokenPath": "asset" } }, + { + "interaction": { + "to": "comet", + "signature": "withdrawFrom(address,address,address,uint256)", + "args": [ + { "path": "@.from" }, + { "path": "to" }, + { "path": "asset" }, + { "path": "amount" } + ] + } + } + ] + } + }, + "0x414354494f4e5f434c41494d5f52455741524400000000000000000000000000": { + "(address comet,address rewards,address src,bool shouldAccrue)": { + "intent": "Claim rewards", + "fields": [ + { "path": "src", "label": "Claim for", "format": "addressName" }, + { "path": "shouldAccrue", "label": "Accrue first", "format": "raw" }, + { + "interaction": { + "to": "rewards", + "signature": "claim(address,address,bool)", + "args": [ + { "path": "comet" }, + { "path": "src" }, + { "path": "shouldAccrue" } + ] + } + } + ] + } + }, + "$default": "reject" + } + } + } + ] + } + } + } +} diff --git a/assets/erc-non-abi-dispatch/example-safe-multisend.json b/assets/erc-non-abi-dispatch/example-safe-multisend.json index 1d5c239a3a0..462cee3c456 100644 --- a/assets/erc-non-abi-dispatch/example-safe-multisend.json +++ b/assets/erc-non-abi-dispatch/example-safe-multisend.json @@ -29,7 +29,6 @@ "label": "Batched calls", "layout": { "type": "sequence", - "count": "tillEnd", "element": { "type": "object", "fields": [ diff --git a/assets/erc-non-abi-dispatch/example-uniswap-v4-initialize.json b/assets/erc-non-abi-dispatch/example-uniswap-v4-initialize.json new file mode 100644 index 00000000000..abc75ad1783 --- /dev/null +++ b/assets/erc-non-abi-dispatch/example-uniswap-v4-initialize.json @@ -0,0 +1,85 @@ +{ + "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-non-abi-dispatch.schema.json", + "context": { + "$id": "Uniswap v4 PoolManager", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x000000000004444c5dc75cB358380D2e3dE08A90" + } + ] + } + }, + "metadata": { + "owner": "Uniswap Labs", + "contractName": "PoolManager", + "info": { + "url": "https://github.com/Uniswap/v4-core" + } + }, + "display": { + "formats": { + "initialize((address currency0,address currency1,uint24 fee,int24 tickSpacing,address hooks) key,uint160 sqrtPriceX96)": { + "$id": "Initialize Pool", + "intent": "Create pool", + "fields": [ + { + "path": "key.currency0", + "label": "Token 0", + "format": "addressName" + }, + { + "path": "key.currency1", + "label": "Token 1", + "format": "addressName" + }, + { + "path": "key.fee", + "label": "Fee tier", + "format": "raw" + }, + { + "path": "key.tickSpacing", + "label": "Tick spacing", + "format": "raw" + }, + { + "path": "key.hooks", + "label": "Hooks contract", + "format": "addressName" + }, + { + "path": "key.hooks", + "label": "Hook permissions", + "layout": { + "type": "bitfield", + "bytes": 20, + "fields": [ + { "name": "beforeInitialize", "bit": 13 }, + { "name": "afterInitialize", "bit": 12 }, + { "name": "beforeAddLiquidity", "bit": 11 }, + { "name": "afterAddLiquidity", "bit": 10 }, + { "name": "beforeRemoveLiquidity", "bit": 9 }, + { "name": "afterRemoveLiquidity", "bit": 8 }, + { "name": "beforeSwap", "bit": 7 }, + { "name": "afterSwap", "bit": 6 }, + { "name": "beforeDonate", "bit": 5 }, + { "name": "afterDonate", "bit": 4 }, + { "name": "beforeSwapReturnDelta", "bit": 3 }, + { "name": "afterSwapReturnDelta", "bit": 2 }, + { "name": "afterAddLiquidityReturnDelta", "bit": 1 }, + { "name": "afterRemoveLiquidityReturnDelta", "bit": 0 } + ] + } + }, + { + "path": "sqrtPriceX96", + "label": "Initial price (sqrtPriceX96)", + "format": "raw" + } + ] + } + } + } +} diff --git a/assets/erc-non-abi-dispatch/example-universal-router.json b/assets/erc-non-abi-dispatch/example-universal-router.json index fcf400cd415..7bb0bcd0955 100644 --- a/assets/erc-non-abi-dispatch/example-universal-router.json +++ b/assets/erc-non-abi-dispatch/example-universal-router.json @@ -37,7 +37,6 @@ "label": "Commands", "layout": { "type": "sequence", - "count": "tillEnd", "element": { "type": "uint", "bytes": 1 diff --git a/assets/erc-non-abi-dispatch/example-wormhole-token-bridge.json b/assets/erc-non-abi-dispatch/example-wormhole-token-bridge.json new file mode 100644 index 00000000000..cbc1d85e039 --- /dev/null +++ b/assets/erc-non-abi-dispatch/example-wormhole-token-bridge.json @@ -0,0 +1,147 @@ +{ + "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-non-abi-dispatch.schema.json", + "context": { + "$id": "Wormhole Token Bridge", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x3ee18B2214AFF97000D974cf647E7C347E8fa585" + } + ] + } + }, + "metadata": { + "owner": "Wormhole", + "contractName": "TokenBridge", + "info": { + "url": "https://github.com/wormhole-foundation/wormhole" + } + }, + "display": { + "formats": { + "completeTransfer(bytes encodedVm)": { + "$id": "Complete Token Transfer", + "intent": "Complete cross-chain transfer", + "fields": [ + { + "path": "encodedVm", + "label": "Signed message (VAA)", + "layout": { + "type": "object", + "fields": [ + { + "name": "version", + "schema": { "type": "uint", "bytes": 1 } + }, + { + "name": "guardianSetIndex", + "schema": { "type": "uint", "bytes": 4 } + }, + { + "name": "numSignatures", + "schema": { "type": "uint", "bytes": 1 } + }, + { + "name": "signatures", + "schema": { + "type": "sequence", + "countFrom": "numSignatures", + "element": { + "type": "object", + "fields": [ + { "name": "guardianIndex", "schema": { "type": "uint", "bytes": 1 } }, + { "name": "r", "schema": { "type": "bytes", "length": 32 } }, + { "name": "s", "schema": { "type": "bytes", "length": 32 } }, + { "name": "v", "schema": { "type": "uint", "bytes": 1 } } + ] + } + } + }, + { + "name": "timestamp", + "schema": { "type": "uint", "bytes": 4 }, + "label": "Signed at", + "format": "date", + "params": { "encoding": "timestamp" } + }, + { + "name": "nonce", + "schema": { "type": "uint", "bytes": 4 } + }, + { + "name": "emitterChainId", + "schema": { "type": "uint", "bytes": 2 }, + "label": "Origin chain (Wormhole ID)" + }, + { + "name": "emitterAddress", + "schema": { "type": "bytes", "length": 32 }, + "label": "Origin emitter" + }, + { + "name": "sequenceNumber", + "schema": { "type": "uint", "bytes": 8 } + }, + { + "name": "consistencyLevel", + "schema": { "type": "uint", "bytes": 1 } + }, + { + "name": "payload", + "label": "Transfer details", + "schema": { + "type": "switch", + "expression": { "type": "uint", "bytes": 1 }, + "cases": { + "0x01": { + "layout": { + "type": "object", + "fields": [ + { + "name": "amount", + "schema": { "type": "uint", "bytes": 32 }, + "label": "Amount", + "format": "raw" + }, + { + "name": "tokenAddress", + "schema": { "type": "bytes", "length": 32 }, + "label": "Token (origin chain address)" + }, + { + "name": "tokenChain", + "schema": { "type": "uint", "bytes": 2 }, + "label": "Token's origin chain (Wormhole ID)" + }, + { + "name": "to", + "schema": { "type": "bytes", "length": 32 }, + "label": "Recipient (destination chain address)" + }, + { + "name": "toChain", + "schema": { "type": "uint", "bytes": 2 }, + "label": "Destination chain (Wormhole ID)" + }, + { + "name": "fee", + "schema": { "type": "uint", "bytes": 32 }, + "label": "Relayer fee", + "format": "raw" + } + ] + } + }, + "$default": "reject" + } + } + } + ] + } + } + ] + } + } + } +} From 791b87b6156e384c6360de6aedf66d6c7c11dfb4 Mon Sep 17 00:00:00 2001 From: Alex Forshtat Date: Tue, 4 Aug 2026 01:17:15 +0200 Subject: [PATCH 19/23] Fix CI errors: rename to erc-0000, update links, fill empty sections --- ...00-custom-bytes-erc7730.md => erc-0000.md} | 28 ++++++++++++------- .../TieredExecutor.sol | 0 .../erc7730-non-abi-dispatch.schema.json | 0 .../example-balancer-relayer-library.json | 0 .../example-balancer-relayer-multicall.json | 0 .../example-compound-bulker.json | 0 .../example-erc7579-execute.json | 0 .../example-erc7683-order.json | 0 .../example-legacy-token.json | 0 .../example-reward-vault.json | 0 .../example-safe-multisend.json | 0 .../example-tiered-executor.json | 0 .../example-uniswap-v4-initialize.json | 0 .../example-universal-router.json | 0 .../example-wormhole-token-bridge.json | 0 15 files changed, 18 insertions(+), 10 deletions(-) rename ERCS/{erc-0000-custom-bytes-erc7730.md => erc-0000.md} (95%) rename assets/{erc-non-abi-dispatch => erc-0000}/TieredExecutor.sol (100%) rename assets/{erc-non-abi-dispatch => erc-0000}/erc7730-non-abi-dispatch.schema.json (100%) rename assets/{erc-non-abi-dispatch => erc-0000}/example-balancer-relayer-library.json (100%) rename assets/{erc-non-abi-dispatch => erc-0000}/example-balancer-relayer-multicall.json (100%) rename assets/{erc-non-abi-dispatch => erc-0000}/example-compound-bulker.json (100%) rename assets/{erc-non-abi-dispatch => erc-0000}/example-erc7579-execute.json (100%) rename assets/{erc-non-abi-dispatch => erc-0000}/example-erc7683-order.json (100%) rename assets/{erc-non-abi-dispatch => erc-0000}/example-legacy-token.json (100%) rename assets/{erc-non-abi-dispatch => erc-0000}/example-reward-vault.json (100%) rename assets/{erc-non-abi-dispatch => erc-0000}/example-safe-multisend.json (100%) rename assets/{erc-non-abi-dispatch => erc-0000}/example-tiered-executor.json (100%) rename assets/{erc-non-abi-dispatch => erc-0000}/example-uniswap-v4-initialize.json (100%) rename assets/{erc-non-abi-dispatch => erc-0000}/example-universal-router.json (100%) rename assets/{erc-non-abi-dispatch => erc-0000}/example-wormhole-token-bridge.json (100%) diff --git a/ERCS/erc-0000-custom-bytes-erc7730.md b/ERCS/erc-0000.md similarity index 95% rename from ERCS/erc-0000-custom-bytes-erc7730.md rename to ERCS/erc-0000.md index d708ab2dd1c..63b340cc986 100644 --- a/ERCS/erc-0000-custom-bytes-erc7730.md +++ b/ERCS/erc-0000.md @@ -1,8 +1,9 @@ --- +eip: 0000 title: Custom Encoding Layout for ERC-7730 description: Format to describes any non-standard byte encodings for ERC-7730 author: Alex Forshtat (@forshtat) -discussions-to: TBD +discussions-to: https://github.com/ethereum/ERCs/pull/1925 status: Draft type: Standards Track category: ERC @@ -11,6 +12,9 @@ requires: 7730 --- ## Abstract + +TBD. + ## Motivation ERC-7730 provides a rich language for decoding the calldata inputs of smart contracts on Ethereum. @@ -248,7 +252,7 @@ function multiSend(bytes memory transactions) public payable; 1. The `transactions` count is not provided at all - the code has to iteratively decode the entire data array until it is exhausted. 2. The `data` field has a dynamic size that is specified as a separate sibling parameter. -Using ERC-0000, this input can easily be described for Clear Singing – see [MultiSend Example](../assets/erc-non-abi-dispatch/example-safe-multisend.json). +Using ERC-0000, this input can easily be described for Clear Singing – see [MultiSend Example](../assets/erc-0000/example-safe-multisend.json). ### Uniswap v4 - `UniversalRouter` Contract @@ -268,7 +272,7 @@ function execute(bytes calldata commands, bytes[] calldata inputs, uint256 deadl 2. Each command byte also carries a flag in its high bit (`0x80`/`0b10000000`, "allow this command to revert") alongside the actual `command id` in its low 6 bits (`0x3f`/`0b00111111`), so the extracted value must be masked before it can be matched against a `cases` table. 3. `inputs` is a regular ABI `bytes[]`, but the ABI type of `inputs[i]` is only known by decoding `commands[i]` – the format of one array must be resolved by indexing into a **sibling array** at the same position, which is the `$index` mechanism described above. -Using ERC-0000, this input can be described for Clear Signing – see [Universal Router Example](../assets/erc-non-abi-dispatch/example-universal-router.json). +Using ERC-0000, this input can be described for Clear Signing – see [Universal Router Example](../assets/erc-0000/example-universal-router.json). ### ERC-7579 `execute` function @@ -294,7 +298,7 @@ The `mode` value determines how `executionCalldata` itself must be decoded: 2. The format of the second parameter, `executionCalldata`, is dependent on the first byte of the first parameter, `mode` – similar to the pattern used by `UniversalRouter`, but more complicated since the indication is a single byte inside a fixed-size `bytes32` rather than a whole named parameter. 3. The three `CALLTYPE`s are not just different tuples of the same shape – `single` and `delegatecall` use packed encoding, with no `value` field for `delegatecall`, while `batch` uses standard padded ABI encoding of a struct array, so `executionCalldata` decoding changes shape entirely between cases. -Using ERC-0000, this input can be described for Clear Signing – see [ERC-7579 Execute Example](../assets/erc-non-abi-dispatch/example-erc7579-execute.json). +Using ERC-0000, this input can be described for Clear Signing – see [ERC-7579 Execute Example](../assets/erc-0000/example-erc7579-execute.json). ### Balancer Relayer `joinPool`/`exitPool` @@ -316,7 +320,7 @@ function exitPool(bytes32 poolId, PoolKind kind, address sender, address payable 2. `request.userData`'s meaning is tag-dispatched twice: once by `kind` (a sibling parameter of `joinPool`/`exitPool` itself), then again by a `JoinKind`/`ExitKind` enum read from `userData`'s own first 32 bytes – a `switch` nested inside a `switch`. 3. The same already-ABI-decoded `uint256` fields (`maxAmountsIn[i]`, `outputReference`, `bptAmountIn`) can be either a literal amount or a "chained reference" – a placeholder for a value only known once an earlier step in the same batched transaction has actually executed on-chain – distinguished purely by masking the value's own high bits, not by a separate tag field. -Using ERC-0000, this input can be described for Clear Signing – see [Balancer Relayer Multicall Example](../assets/erc-non-abi-dispatch/example-balancer-relayer-multicall.json) and [Balancer Relayer Library Example](../assets/erc-non-abi-dispatch/example-balancer-relayer-library.json). +Using ERC-0000, this input can be described for Clear Signing – see [Balancer Relayer Multicall Example](../assets/erc-0000/example-balancer-relayer-multicall.json) and [Balancer Relayer Library Example](../assets/erc-0000/example-balancer-relayer-library.json). ### ERC-7683 `open` @@ -337,7 +341,7 @@ function open(OnchainCrossChainOrder calldata order) external; 1. `orderData`'s ABI type is selected by `orderDataType`, a `bytes32` equal to the `keccak256` hash of the target tuple's own Solidity type string (`keccak256("AcrossOrderData(address inputToken,...)")`) rather than a small, contract-defined enum – an open-ended, hash-keyed dispatch. 2. Both the tag and the payload are already plain sibling ABI parameters of `open` itself, so no `layout` node is needed – only `switch`. -Using ERC-0000, this input can be described for Clear Signing – see [ERC-7683 Order Example](../assets/erc-non-abi-dispatch/example-erc7683-order.json). +Using ERC-0000, this input can be described for Clear Signing – see [ERC-7683 Order Example](../assets/erc-0000/example-erc7683-order.json). ### Uniswap v4 - `PoolManager.initialize` (WIP) @@ -360,7 +364,7 @@ function initialize(PoolKey memory key, uint160 sqrtPriceX96) external returns ( 1. `key.hooks` is an ordinary ABI `address` parameter, but its lowest 14 bits are individually meaningful flags (`beforeSwap`, `afterSwap`, `beforeAddLiquidity`, ...) chosen by mining a vanity address at hook-deployment time – the address *is* the bitfield, with no separate flags parameter anywhere in the call. 2. Unlike every other Test Case here, this needs no `sequence` or `switch` at all – just a `bitfield` layout attached directly to an already-ABI-decoded scalar, to tell a signer which of a hook's callbacks it is trusting to run on every swap/mint/burn against this pool. -Using ERC-0000, this input can be described for Clear Signing – see [Uniswap v4 Initialize Example](../assets/erc-non-abi-dispatch/example-uniswap-v4-initialize.json). +Using ERC-0000, this input can be described for Clear Signing – see [Uniswap v4 Initialize Example](../assets/erc-0000/example-uniswap-v4-initialize.json). ### Compound III `Bulker.invoke` (WIP) @@ -381,7 +385,7 @@ function invoke(bytes32[] calldata actions, bytes[] calldata data) external paya 2. `msg.sender` – the account that actually calls `Bulker.invoke` – is threaded into that reconstructed call as its first argument despite never appearing anywhere in `data[i]`, which only `interaction`'s `args` referencing the container root (`@.from`) can express. 3. The target contract itself varies by action and is not always the tuple's first field: `ACTION_CLAIM_REWARD` calls `rewards.claim(comet, src, shouldAccrue)`, so `interaction`'s own `to` is bound to `rewards`, not `comet`. -Using ERC-0000, this input can be described for Clear Signing – see [Compound III Bulker Example](../assets/erc-non-abi-dispatch/example-compound-bulker.json). +Using ERC-0000, this input can be described for Clear Signing – see [Compound III Bulker Example](../assets/erc-0000/example-compound-bulker.json). ### Wormhole Token Bridge `completeTransfer` (WIP) @@ -399,7 +403,7 @@ A VAA is `version(1) | guardianSetIndex(4) | numSignatures(1) | signatures[] | t 2. `payload` is dispatched by its own leading tag byte, nested inside a `switch` in spirit similar to Balancer's `userData` – but here the entire VAA, both dispatch tags and all data, lives in one opaque `bytes` argument with no ABI structure anywhere around it. 3. `emitterAddress`/`tokenAddress`/`to` are 32-byte, chain-agnostic identifiers, decoded as raw bytes rather than `address` – they are only interpretable as EVM addresses once matched against their accompanying chain-id field. -Using ERC-0000, this input can be described for Clear Signing – see [Wormhole Token Bridge Example](../assets/erc-non-abi-dispatch/example-wormhole-token-bridge.json). +Using ERC-0000, this input can be described for Clear Signing – see [Wormhole Token Bridge Example](../assets/erc-0000/example-wormhole-token-bridge.json). ### `TieredExecutor` (artificial illustrative example) @@ -417,12 +421,16 @@ function executeOperation(address target, Operation op, address account, uint256 2. `account`/`amount` are generic-looking arguments with no inherent semantics – depending on `op`, they are bound to two unrelated target interfaces with a **different parameter order** via `interaction` rather than `format: "calldata"`. There is no contiguous calldata blob to slice out, only already-decoded values that need to be reassembled. 3. This contract is illustrative only, written for this ERC and not deployed anywhere. -Using ERC-0000, this input can be described for Clear Signing – see [TieredExecutor Example](../assets/erc-non-abi-dispatch/example-tiered-executor.json). +Using ERC-0000, this input can be described for Clear Signing – see [TieredExecutor Example](../assets/erc-0000/example-tiered-executor.json). ## Rationale +TBD. + ## Security Considerations +TBD. + ## Copyright Copyright and related rights waived via [CC0](../LICENSE.md). diff --git a/assets/erc-non-abi-dispatch/TieredExecutor.sol b/assets/erc-0000/TieredExecutor.sol similarity index 100% rename from assets/erc-non-abi-dispatch/TieredExecutor.sol rename to assets/erc-0000/TieredExecutor.sol diff --git a/assets/erc-non-abi-dispatch/erc7730-non-abi-dispatch.schema.json b/assets/erc-0000/erc7730-non-abi-dispatch.schema.json similarity index 100% rename from assets/erc-non-abi-dispatch/erc7730-non-abi-dispatch.schema.json rename to assets/erc-0000/erc7730-non-abi-dispatch.schema.json diff --git a/assets/erc-non-abi-dispatch/example-balancer-relayer-library.json b/assets/erc-0000/example-balancer-relayer-library.json similarity index 100% rename from assets/erc-non-abi-dispatch/example-balancer-relayer-library.json rename to assets/erc-0000/example-balancer-relayer-library.json diff --git a/assets/erc-non-abi-dispatch/example-balancer-relayer-multicall.json b/assets/erc-0000/example-balancer-relayer-multicall.json similarity index 100% rename from assets/erc-non-abi-dispatch/example-balancer-relayer-multicall.json rename to assets/erc-0000/example-balancer-relayer-multicall.json diff --git a/assets/erc-non-abi-dispatch/example-compound-bulker.json b/assets/erc-0000/example-compound-bulker.json similarity index 100% rename from assets/erc-non-abi-dispatch/example-compound-bulker.json rename to assets/erc-0000/example-compound-bulker.json diff --git a/assets/erc-non-abi-dispatch/example-erc7579-execute.json b/assets/erc-0000/example-erc7579-execute.json similarity index 100% rename from assets/erc-non-abi-dispatch/example-erc7579-execute.json rename to assets/erc-0000/example-erc7579-execute.json diff --git a/assets/erc-non-abi-dispatch/example-erc7683-order.json b/assets/erc-0000/example-erc7683-order.json similarity index 100% rename from assets/erc-non-abi-dispatch/example-erc7683-order.json rename to assets/erc-0000/example-erc7683-order.json diff --git a/assets/erc-non-abi-dispatch/example-legacy-token.json b/assets/erc-0000/example-legacy-token.json similarity index 100% rename from assets/erc-non-abi-dispatch/example-legacy-token.json rename to assets/erc-0000/example-legacy-token.json diff --git a/assets/erc-non-abi-dispatch/example-reward-vault.json b/assets/erc-0000/example-reward-vault.json similarity index 100% rename from assets/erc-non-abi-dispatch/example-reward-vault.json rename to assets/erc-0000/example-reward-vault.json diff --git a/assets/erc-non-abi-dispatch/example-safe-multisend.json b/assets/erc-0000/example-safe-multisend.json similarity index 100% rename from assets/erc-non-abi-dispatch/example-safe-multisend.json rename to assets/erc-0000/example-safe-multisend.json diff --git a/assets/erc-non-abi-dispatch/example-tiered-executor.json b/assets/erc-0000/example-tiered-executor.json similarity index 100% rename from assets/erc-non-abi-dispatch/example-tiered-executor.json rename to assets/erc-0000/example-tiered-executor.json diff --git a/assets/erc-non-abi-dispatch/example-uniswap-v4-initialize.json b/assets/erc-0000/example-uniswap-v4-initialize.json similarity index 100% rename from assets/erc-non-abi-dispatch/example-uniswap-v4-initialize.json rename to assets/erc-0000/example-uniswap-v4-initialize.json diff --git a/assets/erc-non-abi-dispatch/example-universal-router.json b/assets/erc-0000/example-universal-router.json similarity index 100% rename from assets/erc-non-abi-dispatch/example-universal-router.json rename to assets/erc-0000/example-universal-router.json diff --git a/assets/erc-non-abi-dispatch/example-wormhole-token-bridge.json b/assets/erc-0000/example-wormhole-token-bridge.json similarity index 100% rename from assets/erc-non-abi-dispatch/example-wormhole-token-bridge.json rename to assets/erc-0000/example-wormhole-token-bridge.json From 1e78f25e9dc6e0c9897c59d4a74a7ad3c95137f2 Mon Sep 17 00:00:00 2001 From: Alex Forshtat Date: Tue, 4 Aug 2026 13:07:33 +0200 Subject: [PATCH 20/23] Elaborate new examples --- ERCS/erc-0000.md | 71 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 60 insertions(+), 11 deletions(-) diff --git a/ERCS/erc-0000.md b/ERCS/erc-0000.md index 63b340cc986..25b41e5f504 100644 --- a/ERCS/erc-0000.md +++ b/ERCS/erc-0000.md @@ -343,7 +343,7 @@ function open(OnchainCrossChainOrder calldata order) external; Using ERC-0000, this input can be described for Clear Signing – see [ERC-7683 Order Example](../assets/erc-0000/example-erc7683-order.json). -### Uniswap v4 - `PoolManager.initialize` (WIP) +### Uniswap v4 - `PoolManager.initialize` The `PoolManager` contract identifies a pool's hook contract, and which of its callbacks are active, entirely through the low bits of that hook contract's own address: @@ -357,18 +357,29 @@ struct PoolKey { } function initialize(PoolKey memory key, uint160 sqrtPriceX96) external returns (int24 tick); + +/// @notice V4 decides whether to invoke specific hooks by inspecting the least significant bits +/// of the address that the hooks contract is deployed to. +/// For example, a hooks contract deployed to address: 0x0000000000000000000000000000000000002400 +/// has the lowest bits '10 0100 0000 0000' which would cause the 'before initialize' and 'after add liquidity' hooks to be used. +library Hooks { + /// @notice Returns whether the flag is configured for the hook + function hasPermission(IHook self, uint160 flag) internal pure returns (bool) { + return uint160(address(self)) & flag != 0; + } +} ``` #### What makes this encoding unusual -1. `key.hooks` is an ordinary ABI `address` parameter, but its lowest 14 bits are individually meaningful flags (`beforeSwap`, `afterSwap`, `beforeAddLiquidity`, ...) chosen by mining a vanity address at hook-deployment time – the address *is* the bitfield, with no separate flags parameter anywhere in the call. +1. `key.hooks` is an ordinary ABI `address` parameter, but its lowest 14 bits are individually meaningful flags (`beforeSwap`, `afterSwap`, `beforeAddLiquidity`, etc.) chosen by **mining a vanity address at hook-deployment time** – the address *is* the bitfield, with no separate flags parameter anywhere in the call. 2. Unlike every other Test Case here, this needs no `sequence` or `switch` at all – just a `bitfield` layout attached directly to an already-ABI-decoded scalar, to tell a signer which of a hook's callbacks it is trusting to run on every swap/mint/burn against this pool. Using ERC-0000, this input can be described for Clear Signing – see [Uniswap v4 Initialize Example](../assets/erc-0000/example-uniswap-v4-initialize.json). ### Compound III `Bulker.invoke` (WIP) -The `Bulker` contract batches several Comet actions in one call, but unlike `MultiSend` or `UniversalRouter`, the per-action payload is never itself calldata for a callable function: +The `Bulker` contract batches several actions in one call, but unlike `MultiSend` or `UniversalRouter`, the per-action payload is never itself calldata for a callable function: ```solidity bytes32 constant ACTION_SUPPLY_ASSET = "ACTION_SUPPLY_ASSET"; @@ -381,27 +392,65 @@ function invoke(bytes32[] calldata actions, bytes[] calldata data) external paya #### What makes this encoding unusual (WIP) -1. Each `data[i]` is a bare `abi.encode` of a tuple – no function selector – that `Bulker` itself `abi.decode`s and forwards, under a **different function name and a different argument list**, to `Comet` (e.g. `ACTION_SUPPLY_ASSET`'s `(comet, to, asset, amount)` becomes a call to `comet.supplyFrom(msg.sender, to, asset, amount)`): exactly the scattered-args-reassembled-as-a-different-call shape `interaction` exists for, on a real, heavily used contract rather than an illustrative one. -2. `msg.sender` – the account that actually calls `Bulker.invoke` – is threaded into that reconstructed call as its first argument despite never appearing anywhere in `data[i]`, which only `interaction`'s `args` referencing the container root (`@.from`) can express. -3. The target contract itself varies by action and is not always the tuple's first field: `ACTION_CLAIM_REWARD` calls `rewards.claim(comet, src, shouldAccrue)`, so `interaction`'s own `to` is bound to `rewards`, not `comet`. +1. Each `data[i]` is a bare `abi.encode` of a tuple – no function selector – that `Bulker` itself `abi.decode`s and forwards, under a **different function name and a different argument list**, to the `Comet` contract. + For example, `ACTION_SUPPLY_ASSET` is encoded as `abi.encode(comet, to, asset, amount)` but eventually becomes a call to `comet.supplyFrom(msg.sender, to, asset, amount)`. + This is a clear example of the "inner call with reassembled arguments" pattern common in routing contracts in DeFi. +2. `msg.sender` – the account that actually calls `Bulker.invoke` – is added into that reassembled call's arguments despite never appearing anywhere in `data[i]`. +3. The target contract itself can vary by either the action or by the tuple's first field. Using ERC-0000, this input can be described for Clear Signing – see [Compound III Bulker Example](../assets/erc-0000/example-compound-bulker.json). ### Wormhole Token Bridge `completeTransfer` (WIP) -The `TokenBridge` contract accepts a signed Wormhole message (a "VAA") as a single opaque `bytes` argument, entirely packed rather than ABI-encoded: +The `TokenBridge` contract accepts a signed Wormhole message called "VAA" as a single opaque packed `bytes` argument: ```solidity function completeTransfer(bytes memory encodedVm) public; ``` -A VAA is `version(1) | guardianSetIndex(4) | numSignatures(1) | signatures[] | timestamp(4) | nonce(4) | emitterChainId(2) | emitterAddress(32) | sequence(8) | consistencyLevel(1) | payload`, where each of the `numSignatures` signature entries is `guardianIndex(1) | r(32) | s(32) | v(1)`, and `payload`'s own first byte selects its shape (`1` = a token transfer: `amount(32) | tokenAddress(32) | tokenChain(2) | to(32) | toChain(2) | fee(32)`). +A VAA encoded as follows: + +``` +version(1) | guardianSetIndex(4) | numSignatures(1) | signatures[] | timestamp(4) | nonce(4) | emitterChainId(2) | emitterAddress(32) | sequence(8) | consistencyLevel(1) | payload +``` + +Where each of the `numSignatures` signature entries is: + +```guardianIndex(1) | r(32) | s(32) | v(1)``` + +And `payload`'s own first byte selects its shape: + +``` +PayloadID uint8 = 1 +Amount uint256 +TokenAddress bytes32 +TokenChain uint16 +To bytes32 +ToChain uint16 +Fee uint256 +--- +PayloadID uint8 = 2 +TokenAddress [32]uint8 +TokenChain uint16 +Decimals uint8 +Symbol [32]uint8 +Name [32]uint8 +--- +PayloadID uint8 = 3 +Amount uint256 +TokenAddress bytes32 +TokenChain uint16 +To bytes32 +ToChain uint16 +FromAddress bytes32 +Payload bytes +``` #### What makes this encoding unusual -1. `signatures`'s element count is neither ABI-length-prefixed nor fixed – it is a plain `numSignatures` byte decoded a few bytes earlier in the very same buffer, needing `sequence`'s `countFrom`. -2. `payload` is dispatched by its own leading tag byte, nested inside a `switch` in spirit similar to Balancer's `userData` – but here the entire VAA, both dispatch tags and all data, lives in one opaque `bytes` argument with no ABI structure anywhere around it. -3. `emitterAddress`/`tokenAddress`/`to` are 32-byte, chain-agnostic identifiers, decoded as raw bytes rather than `address` – they are only interpretable as EVM addresses once matched against their accompanying chain-id field. +1. `signatures`'s element count is neither ABI-length-prefixed, nor fixed, nor is it an iterator – it is a plain `numSignatures` byte decoded a few bytes earlier in the very same buffer. +2. `payload` is dispatched by its own leading tag byte, nested inside a `switch` – the entire "VAA", both dispatch tags and all data, lives in one opaque `bytes` argument with no ABI structure anywhere around it. +3. `emitterAddress`/`tokenAddress`/`to` are 32-byte, chain-agnostic identifiers, decoded as raw bytes rather than `address` – they are only interpretable as EVM addresses once matched against their accompanying `chain id` field. Using ERC-0000, this input can be described for Clear Signing – see [Wormhole Token Bridge Example](../assets/erc-0000/example-wormhole-token-bridge.json). From b00aac6b1f6f70a121c345e74dcbfed66c930b6b Mon Sep 17 00:00:00 2001 From: Alex Forshtat Date: Tue, 4 Aug 2026 13:25:20 +0200 Subject: [PATCH 21/23] Update section headers in erc-0000.md --- ERCS/erc-0000.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ERCS/erc-0000.md b/ERCS/erc-0000.md index 25b41e5f504..426295eecd6 100644 --- a/ERCS/erc-0000.md +++ b/ERCS/erc-0000.md @@ -377,7 +377,7 @@ library Hooks { Using ERC-0000, this input can be described for Clear Signing – see [Uniswap v4 Initialize Example](../assets/erc-0000/example-uniswap-v4-initialize.json). -### Compound III `Bulker.invoke` (WIP) +### Compound III `Bulker.invoke` The `Bulker` contract batches several actions in one call, but unlike `MultiSend` or `UniversalRouter`, the per-action payload is never itself calldata for a callable function: @@ -390,7 +390,7 @@ bytes32 constant ACTION_CLAIM_REWARD = "ACTION_CLAIM_REWARD"; function invoke(bytes32[] calldata actions, bytes[] calldata data) external payable; ``` -#### What makes this encoding unusual (WIP) +#### What makes this encoding unusual 1. Each `data[i]` is a bare `abi.encode` of a tuple – no function selector – that `Bulker` itself `abi.decode`s and forwards, under a **different function name and a different argument list**, to the `Comet` contract. For example, `ACTION_SUPPLY_ASSET` is encoded as `abi.encode(comet, to, asset, amount)` but eventually becomes a call to `comet.supplyFrom(msg.sender, to, asset, amount)`. @@ -400,7 +400,7 @@ function invoke(bytes32[] calldata actions, bytes[] calldata data) external paya Using ERC-0000, this input can be described for Clear Signing – see [Compound III Bulker Example](../assets/erc-0000/example-compound-bulker.json). -### Wormhole Token Bridge `completeTransfer` (WIP) +### Wormhole Token Bridge `completeTransfer` The `TokenBridge` contract accepts a signed Wormhole message called "VAA" as a single opaque packed `bytes` argument: From 7ec37e170430e1f3480d8acd98928ad6d9e17fba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kaan=20Uzdo=C4=9Fan?= Date: Tue, 4 Aug 2026 14:27:51 +0300 Subject: [PATCH 22/23] Rename `layout` to `customEncoding`; expand JSON examples Two mechanical changes, no semantic ones. 1. Rename the `layout` key to `customEncoding` everywhere it refers to the mechanism: the Specification heading and prose, all JSON examples in the ERC, the companion JSON Schema (including the `$layout` definition group and every `#/$layout/...` reference), and the example descriptors under assets/erc-0000/. Two occurrences are deliberately left alone: the `title:` frontmatter, and "an ABI-encoded `array` data layout" under `sequence`, which uses "layout" as an ordinary English word rather than as the key name. 2. Reformat every JSON block in the ERC, replacing the mix of single-line and multi-line styles with one consistent rule: any object or array that contains another object or array is expanded one level per line, while a flat one holding only scalars stays on a single line if it fits in 100 columns. So nesting is always visible, but short leaf entries such as { "path": "to", "label": "To" } are not spread over four lines. Verified: all 47 `$ref`s in the schema still resolve, all 12 example descriptors validate against the renamed schema (the pre-rename `layout` key is rejected by it, so the check is not vacuous), and every JSON block in the ERC round-trips through a parser unchanged apart from whitespace. Co-Authored-By: Claude Opus 4.8 --- ERCS/erc-0000.md | 287 ++++++++++++------ .../erc7730-non-abi-dispatch.schema.json | 72 ++--- .../example-balancer-relayer-library.json | 18 +- assets/erc-0000/example-erc7579-execute.json | 6 +- assets/erc-0000/example-safe-multisend.json | 2 +- .../example-uniswap-v4-initialize.json | 2 +- assets/erc-0000/example-universal-router.json | 2 +- .../example-wormhole-token-bridge.json | 4 +- 8 files changed, 249 insertions(+), 144 deletions(-) diff --git a/ERCS/erc-0000.md b/ERCS/erc-0000.md index 25b41e5f504..1487c75f2a2 100644 --- a/ERCS/erc-0000.md +++ b/ERCS/erc-0000.md @@ -26,7 +26,7 @@ Instead, we can add some features to ERC-7730 that would allow us to cover the m ## Specification -### `layout` +### `customEncoding` The main mechanism for declaring any parameter whose contents cannot be expressed using Solidity-friendly ABI-encoded data structures. @@ -36,22 +36,28 @@ It can also be provided to values of other formats if these represent some neste { "sendPacked(bytes data)": { "fields": [ - { + { "path": "data", - "layout": { + "customEncoding": { "type": "object", "fields": [ - { "name": "to", "schema": { "type": "address" } }, - { "name": "amount", "schema": { "type": "uint", "bytes": 32 } } - ] - } + { + "name": "to", + "schema": { "type": "address" } + }, + { + "name": "amount", + "schema": { "type": "uint", "bytes": 32 } + } + ] } - ] - } + } + ] + } } ``` -Every `layout` node consumes a well-defined, computable number of bytes from its buffer. +Every `customEncoding` node consumes a well-defined, computable number of bytes from its buffer. The `switch`'s path-sourced form is an exception as it reads an already-resolved value instead of parsing bytes. ### `sequence` @@ -63,21 +69,44 @@ The elements count for `sequence` parameters is optional, and decoding may conti For example, for a byte array with each byte representing a different element: ```json -{ "runCommands(bytes commands)": { - "fields": [ - { "path": "commands", "layout": { "type": "sequence", "element": { "type": "uint", "bytes": 1 } } } - ] -}} +{ + "runCommands(bytes commands)": { + "fields": [ + { + "path": "commands", + "customEncoding": { + "type": "sequence", + "element": { "type": "uint", "bytes": 1 } + } + } + ] + } +} ``` Alternatively, `count` MAY give a literal element count, or `countFrom` may name a sibling field decoded earlier in the same object, sizing the sequence from a previously-decoded value – as with a Wormhole VAA's guardian-signature array, sized by its own `numSignatures` byte, in the Test Case below. ```json -{ "name": "signatures", "schema": { "type": "sequence", "countFrom": "numSignatures", - "element": { "type": "object", "fields": [ - { "name": "guardianIndex", "schema": { "type": "uint", "bytes": 1 } }, - { "name": "signature", "schema": { "type": "bytes", "length": 65 } } - ]}}} +{ + "name": "signatures", + "schema": { + "type": "sequence", + "countFrom": "numSignatures", + "element": { + "type": "object", + "fields": [ + { + "name": "guardianIndex", + "schema": { "type": "uint", "bytes": 1 } + }, + { + "name": "signature", + "schema": { "type": "bytes", "length": 65 } + } + ] + } + } +} ``` ### `object` @@ -87,22 +116,44 @@ The mechanism for declaring an entry in a `sequence` data structure that is not It can also be used as a stand-in for any other complex data structure in the formating process. ```json -{ "batchCalls(bytes transactions)": { - "fields": [ - { "path": "transactions", "layout": { "type": "sequence", "element": { "type": "object", "fields": [ - { "name": "operation", "schema": { "type": "uint", "bytes": 1 } }, - { "name": "to", "schema": { "type": "address" } } - ]}}} - ] -}} +{ + "batchCalls(bytes transactions)": { + "fields": [ + { + "path": "transactions", + "customEncoding": { + "type": "sequence", + "element": { + "type": "object", + "fields": [ + { + "name": "operation", + "schema": { "type": "uint", "bytes": 1 } + }, + { + "name": "to", + "schema": { "type": "address" } + } + ] + } + } + } + ] + } +} ``` An `object`'s field entries may carry `format`,`params`, `label` and `schema` parameters. This allows a packed field to declare how it should be displayed using relative paths to that object's own sibling members. ```json -{ "name": "callData", "schema": { "type": "bytes" }, "label": "Execution", "format": "calldata", - "params": { "calleePath": "target", "amountPath": "value" } } +{ + "name": "callData", + "schema": { "type": "bytes" }, + "label": "Execution", + "format": "calldata", + "params": { "calleePath": "target", "amountPath": "value" } +} ``` ### `bitfield` @@ -110,10 +161,17 @@ This allows a packed field to declare how it should be displayed using relative A fixed-width value whose individual bits or bit ranges each carry independent, named meaning – unlike `object`, whose fields are always byte-aligned and never overlap. ```json -{ "type": "bitfield", "bytes": 20, "fields": [ - { "name": "beforeSwap", "bit": 7 }, - { "name": "poolId", "bits": [19, 8] } -]} +{ + "type": "bitfield", + "bytes": 20, + "fields": [ + { "name": "beforeSwap", "bit": 7 }, + { + "name": "poolId", + "bits": [19, 8] + } + ] +} ``` Each entry is either `{name, bit}` (a single flag, decoded as `bool`) or `{name, bits: [hi, lo]}` (an inclusive bit range, decoded as an unsigned integer). @@ -123,48 +181,74 @@ Each entry is either `{name, bit}` (a single flag, decoded as `bool`) or `{name, The mechanism that allows the decoding to choose the format based on a certain parameter decoded previously. Represents a common pattern of carrying the decoding format flag separately form the data being decoded. ```json -{ "execute(uint8 kind,bytes data)": { - "fields": [ - { "path": "data", "switch": { - "expression": { "path": "kind" }, - "cases": { - "0x00": { "(address to,uint256 amount)": { - "fields": [ - { "path": "to", "label": "To" }, - { "path": "amount", "label": "Amount" } - ] - }}, - "0x01": { "(address from,address to,uint256 amount,uint256 deadline)": { - "fields": [ - { "path": "from", "label": "From" }, - { "path": "to", "label": "To" }, - { "path": "amount", "label": "Amount" }, - { "path": "deadline", "label": "Deadline", "format": "date", "params": { "encoding": "timestamp" } } - ] - }} +{ + "execute(uint8 kind,bytes data)": { + "fields": [ + { + "path": "data", + "switch": { + "expression": { "path": "kind" }, + "cases": { + "0x00": { + "(address to,uint256 amount)": { + "fields": [ + { "path": "to", "label": "To" }, + { "path": "amount", "label": "Amount" } + ] + } + }, + "0x01": { + "(address from,address to,uint256 amount,uint256 deadline)": { + "fields": [ + { "path": "from", "label": "From" }, + { "path": "to", "label": "To" }, + { "path": "amount", "label": "Amount" }, + { + "path": "deadline", + "label": "Deadline", + "format": "date", + "params": { "encoding": "timestamp" } + } + ] + } + } + } + } } - }} - ] -}} + ] + } +} ``` When a `switch` case's tuple resolves to an array (`(...)[]`), its own `fields` can address that array's elements with `.[]` in place of the missing array name, e.g. `.[].callData`. `#.` inside a case's own `fields` still resolves against the absolute root of the structured data. -`switch` can also appear as a `layout` node instead of a field-level key: +`switch` can also appear as a `customEncoding` node instead of a field-level key: ```json -{ "exampleCall(uint256 outputReference)": { - "fields": [ - { "path": "outputReference", "label": "Save result as", "layout": { +{ + "exampleCall(uint256 outputReference)": { + "fields": [ + { + "path": "outputReference", + "label": "Save result as", + "customEncoding": { "type": "switch", - "expression": { "type": "uint", "bytes": 32, "mask": "0xfff0000000000000000000000000000000000000000000000000000000000000" }, + "expression": { + "type": "uint", + "bytes": 32, + "mask": "0xfff0000000000000000000000000000000000000000000000000000000000000" + }, "cases": { - "0xba10000000000000000000000000000000000000000000000000000000000000": { "label": "Set by an earlier step, not known yet", "intent": "info" }, + "0xba10000000000000000000000000000000000000000000000000000000000000": { + "label": "Set by an earlier step, not known yet", + "intent": "info" + }, "$default": { "format": "raw" } } - }} - ] + } + } + ] } } ``` @@ -172,7 +256,7 @@ When a `switch` case's tuple resolves to an array (`(...)[]`), its own `fields` `mask` is available on any `switch` expression and is applied to the raw value before matching `cases`. It lets a dispatch tag share space with unrelated bits, as with `UniversalRouter`'s revert-allowed flag in the Test Case below. -Inside a `layout` tree, `switch`'s inline form may also use `payloadFrom` in place of `$index`, naming a sibling ABI-decoded array to read at the same index as the enclosing `sequence` element. +Inside a `customEncoding` tree, `switch`'s inline form may also use `payloadFrom` in place of `$index`, naming a sibling ABI-decoded array to read at the same index as the enclosing `sequence` element. ### `operation` @@ -193,16 +277,24 @@ The mechanism to declare that some data represents an interaction with an extern This is an equivalent of `calldata` format from ERC-7730 for contracts that perform their own encoding of the calldata, or execute `delegatecall` and `staticcall` operations. ```json -{ "executeSendReward(address account,uint256 amount)": { - "fields": [ - { "path": "account", "label": "Account" }, - { "path": "amount", "label": "Amount" }, - { "interaction": { - "to": "target", - "signature": "grantReward(address,uint256)", - "args": [ { "path": "account" }, { "path": "amount" } ] } } - ] -}} +{ + "executeSendReward(address account,uint256 amount)": { + "fields": [ + { "path": "account", "label": "Account" }, + { "path": "amount", "label": "Amount" }, + { + "interaction": { + "to": "target", + "signature": "grantReward(address,uint256)", + "args": [ + { "path": "account" }, + { "path": "amount" } + ] + } + } + ] + } +} ``` A wallet MUST resolve the matched target's own `intent`/`interpolatedIntent`/`fields` using the bound `args` values in place of that target's own decoded parameters, applying the same unknown-selector fallback if `to`'s descriptor has no entry matching `signature`. @@ -214,21 +306,34 @@ A [structured data format specification](./erc-7730.md) MAY declare a top-level A mechanism for element in a `sequence` to reference their position for indexing into other `sequence` or array-like parameters. ```json -{ "execute(bytes commands,bytes[] inputs)": { - "fields": [ - { "path": "commands", "layout": { "type": "sequence", "element": { "type": "uint", "bytes": 1 } } }, - { "path": "inputs[]", "switch": { - "expression": { "path": "commands[$index]" }, - "cases": { - "0x00": { "(address to)": { - "fields": [ - { "path": "to", "label": "To" } - ] - }} +{ + "execute(bytes commands,bytes[] inputs)": { + "fields": [ + { + "path": "commands", + "customEncoding": { + "type": "sequence", + "element": { "type": "uint", "bytes": 1 } + } + }, + { + "path": "inputs[]", + "switch": { + "expression": { "path": "commands[$index]" }, + "cases": { + "0x00": { + "(address to)": { + "fields": [ + { "path": "to", "label": "To" } + ] + } + } + } + } } - }} - ] -}} + ] + } +} ``` ## Test Cases @@ -339,7 +444,7 @@ function open(OnchainCrossChainOrder calldata order) external; #### What makes this encoding unusual 1. `orderData`'s ABI type is selected by `orderDataType`, a `bytes32` equal to the `keccak256` hash of the target tuple's own Solidity type string (`keccak256("AcrossOrderData(address inputToken,...)")`) rather than a small, contract-defined enum – an open-ended, hash-keyed dispatch. -2. Both the tag and the payload are already plain sibling ABI parameters of `open` itself, so no `layout` node is needed – only `switch`. +2. Both the tag and the payload are already plain sibling ABI parameters of `open` itself, so no `customEncoding` node is needed – only `switch`. Using ERC-0000, this input can be described for Clear Signing – see [ERC-7683 Order Example](../assets/erc-0000/example-erc7683-order.json). @@ -373,7 +478,7 @@ library Hooks { #### What makes this encoding unusual 1. `key.hooks` is an ordinary ABI `address` parameter, but its lowest 14 bits are individually meaningful flags (`beforeSwap`, `afterSwap`, `beforeAddLiquidity`, etc.) chosen by **mining a vanity address at hook-deployment time** – the address *is* the bitfield, with no separate flags parameter anywhere in the call. -2. Unlike every other Test Case here, this needs no `sequence` or `switch` at all – just a `bitfield` layout attached directly to an already-ABI-decoded scalar, to tell a signer which of a hook's callbacks it is trusting to run on every swap/mint/burn against this pool. +2. Unlike every other Test Case here, this needs no `sequence` or `switch` at all – just a `bitfield` `customEncoding` node attached directly to an already-ABI-decoded scalar, to tell a signer which of a hook's callbacks it is trusting to run on every swap/mint/burn against this pool. Using ERC-0000, this input can be described for Clear Signing – see [Uniswap v4 Initialize Example](../assets/erc-0000/example-uniswap-v4-initialize.json). diff --git a/assets/erc-0000/erc7730-non-abi-dispatch.schema.json b/assets/erc-0000/erc7730-non-abi-dispatch.schema.json index 8151c17d8bb..5ff95311dac 100644 --- a/assets/erc-0000/erc7730-non-abi-dispatch.schema.json +++ b/assets/erc-0000/erc7730-non-abi-dispatch.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "version": "2.0.0", "type": "object", - "description": "Full schema for ERC-7730 descriptors, including the layout/switch/interaction keys and the operation calldata param defined by the non-ABI-dispatch companion ERC (requires 7730). Based on erc7730-v2.schema.json; see that file for the unmodified base ERC-7730 schema, and the companion ERC's Specification section for the normative prose this schema encodes.", + "description": "Full schema for ERC-7730 descriptors, including the customEncoding/switch/interaction keys and the operation calldata param defined by the non-ABI-dispatch companion ERC (requires 7730). Based on erc7730-v2.schema.json; see that file for the unmodified base ERC-7730 schema, and the companion ERC's Specification section for the normative prose this schema encodes.", "properties": { "$schema": { "title": "Schema", @@ -353,7 +353,7 @@ "$ref": "#/$definitions/id" }, "switch": { - "$ref": "#/$layout/topLevelSwitch" + "$ref": "#/$customEncoding/topLevelSwitch" } }, "required": [ @@ -552,14 +552,14 @@ "$ref": "#/$format/encryptionParameters", "description": "If present, the field value is encrypted. The format specifies how to display the decrypted value." }, - "layout": { - "$ref": "#/$layout/node" + "customEncoding": { + "$ref": "#/$customEncoding/node" }, "switch": { - "$ref": "#/$layout/fieldSwitch" + "$ref": "#/$customEncoding/fieldSwitch" }, "interaction": { - "$ref": "#/$layout/interaction" + "$ref": "#/$customEncoding/interaction" } }, "allOf": [ @@ -718,11 +718,11 @@ { "not": { "required": [ - "layout", + "customEncoding", "format" ] }, - "$comment": "layout MUST NOT combine with format (non-abi-dispatch companion ERC, ### layout)." + "$comment": "customEncoding MUST NOT combine with format (non-abi-dispatch companion ERC, ### customEncoding)." }, { "not": { @@ -737,10 +737,10 @@ "not": { "required": [ "interaction", - "layout" + "customEncoding" ] }, - "$comment": "interaction is mutually exclusive with layout (non-abi-dispatch companion ERC, ### interaction)." + "$comment": "interaction is mutually exclusive with customEncoding (non-abi-dispatch companion ERC, ### interaction)." } ], "unevaluatedProperties": false @@ -1021,7 +1021,7 @@ "description": "The path to the associated spender, if the calldata can be associated with a container value." }, "operation": { - "$ref": "#/$layout/operationParam" + "$ref": "#/$customEncoding/operationParam" } }, "anyOf": [ @@ -1386,33 +1386,33 @@ }, "$id": "https://eips.ethereum.org/assets/eip-7730/erc7730-non-abi-dispatch.schema.json", "title": "ERC-7730 Non-ABI Dispatch Companion Schema", - "$layout": { + "$customEncoding": { "node": { - "title": "A layout node", + "title": "A customEncoding node", "oneOf": [ { - "$ref": "#/$layout/uint" + "$ref": "#/$customEncoding/uint" }, { - "$ref": "#/$layout/bytes" + "$ref": "#/$customEncoding/bytes" }, { - "$ref": "#/$layout/address" + "$ref": "#/$customEncoding/address" }, { - "$ref": "#/$layout/bool" + "$ref": "#/$customEncoding/bool" }, { - "$ref": "#/$layout/bitfield" + "$ref": "#/$customEncoding/bitfield" }, { - "$ref": "#/$layout/object" + "$ref": "#/$customEncoding/object" }, { - "$ref": "#/$layout/sequence" + "$ref": "#/$customEncoding/sequence" }, { - "$ref": "#/$layout/switchNode" + "$ref": "#/$customEncoding/switchNode" } ] }, @@ -1573,7 +1573,7 @@ "type": "string" }, "schema": { - "$ref": "#/$layout/node" + "$ref": "#/$customEncoding/node" }, "label": { "type": "string" @@ -1606,7 +1606,7 @@ "const": "sequence" }, "element": { - "$ref": "#/$layout/node" + "$ref": "#/$customEncoding/node" }, "count": { "type": "integer", @@ -1623,20 +1623,20 @@ "additionalProperties": false }, "switchNode": { - "title": "switch used as a layout node (inline, reads from the buffer at the cursor)", + "title": "switch used as a customEncoding node (inline, reads from the buffer at the cursor)", "type": "object", "properties": { "type": { "const": "switch" }, "expression": { - "$ref": "#/$layout/node" + "$ref": "#/$customEncoding/node" }, "payloadFrom": { "type": "string" }, "cases": { - "$ref": "#/$layout/switchCases" + "$ref": "#/$customEncoding/switchCases" } }, "required": [ @@ -1672,12 +1672,12 @@ "additionalProperties": false }, { - "$ref": "#/$layout/node" + "$ref": "#/$customEncoding/node" } ] }, "cases": { - "$ref": "#/$layout/switchCases" + "$ref": "#/$customEncoding/switchCases" } }, "required": [ @@ -1690,7 +1690,7 @@ "title": "A switch cases map, including the reserved $default key", "type": "object", "additionalProperties": { - "$ref": "#/$layout/switchCaseValue" + "$ref": "#/$customEncoding/switchCaseValue" } }, "switchCaseValue": { @@ -1701,12 +1701,12 @@ { "type": "object", "properties": { - "layout": { - "$ref": "#/$layout/node" + "customEncoding": { + "$ref": "#/$customEncoding/node" } }, "required": [ - "layout" + "customEncoding" ], "additionalProperties": false }, @@ -1714,7 +1714,7 @@ "type": "object", "properties": { "switch": { - "$ref": "#/$layout/fieldSwitch" + "$ref": "#/$customEncoding/fieldSwitch" } }, "required": [ @@ -1812,7 +1812,7 @@ "type": "object", "properties": { "switch": { - "$ref": "#/$layout/topLevelSwitch" + "$ref": "#/$customEncoding/topLevelSwitch" } }, "required": [ @@ -1824,7 +1824,7 @@ "type": "object", "properties": { "interaction": { - "$ref": "#/$layout/interaction" + "$ref": "#/$customEncoding/interaction" } }, "required": [ @@ -1945,5 +1945,5 @@ ] } }, - "$comment": "This is a full schema, not a thin extension of erc7730-v2.schema.json: composing new properties onto a schema closed with additionalProperties:false (calldataParameters) or reached only via $ref (field, whose own unevaluatedProperties:false does not see sibling allOf properties across a $ref boundary - verified empirically, not just by reading the spec) does not validate the way a thin allOf+$ref extension would suggest. Everything here is spliced directly into the same base schema objects instead. One side effect worth knowing: because field is modified in place rather than duplicated, layout/switch/interaction are also available inside $display/fieldGroup's and $display/reference's own nested fields, not just top-level display.formats.*.fields[], even though no current example exercises that." + "$comment": "This is a full schema, not a thin extension of erc7730-v2.schema.json: composing new properties onto a schema closed with additionalProperties:false (calldataParameters) or reached only via $ref (field, whose own unevaluatedProperties:false does not see sibling allOf properties across a $ref boundary - verified empirically, not just by reading the spec) does not validate the way a thin allOf+$ref extension would suggest. Everything here is spliced directly into the same base schema objects instead. One side effect worth knowing: because field is modified in place rather than duplicated, customEncoding/switch/interaction are also available inside $display/fieldGroup's and $display/reference's own nested fields, not just top-level display.formats.*.fields[], even though no current example exercises that." } \ No newline at end of file diff --git a/assets/erc-0000/example-balancer-relayer-library.json b/assets/erc-0000/example-balancer-relayer-library.json index 761d647326b..2112a7d135b 100644 --- a/assets/erc-0000/example-balancer-relayer-library.json +++ b/assets/erc-0000/example-balancer-relayer-library.json @@ -37,7 +37,7 @@ { "path": "authorization", "label": "Signed authorization", - "layout": { + "customEncoding": { "type": "object", "fields": [ { @@ -88,7 +88,7 @@ { "path": "poolId", "label": "Pool", - "layout": { + "customEncoding": { "type": "object", "fields": [ { @@ -167,7 +167,7 @@ { "path": "minBptAmountOut", "label": "Minimum pool tokens out", - "layout": { + "customEncoding": { "type": "switch", "expression": { "type": "uint", @@ -212,7 +212,7 @@ { "path": "outputReference", "label": "Save result as", - "layout": { + "customEncoding": { "type": "switch", "expression": { "type": "uint", @@ -239,7 +239,7 @@ { "path": "poolId", "label": "Pool", - "layout": { + "customEncoding": { "type": "object", "fields": [ { @@ -304,7 +304,7 @@ { "path": "bptAmountIn", "label": "Pool tokens in", - "layout": { + "customEncoding": { "type": "switch", "expression": { "type": "uint", @@ -348,7 +348,7 @@ { "path": "bptAmountIn", "label": "Pool tokens in", - "layout": { + "customEncoding": { "type": "switch", "expression": { "type": "uint", @@ -390,7 +390,7 @@ { "path": "bptAmountIn", "label": "Pool tokens in", - "layout": { + "customEncoding": { "type": "switch", "expression": { "type": "uint", @@ -440,7 +440,7 @@ { "path": "outputReferences[].key", "label": "Save result as", - "layout": { + "customEncoding": { "type": "switch", "expression": { "type": "uint", diff --git a/assets/erc-0000/example-erc7579-execute.json b/assets/erc-0000/example-erc7579-execute.json index 50624e5f014..e3707f3c2f3 100644 --- a/assets/erc-0000/example-erc7579-execute.json +++ b/assets/erc-0000/example-erc7579-execute.json @@ -31,7 +31,7 @@ { "path": "mode", "label": "Mode", - "layout": { + "customEncoding": { "type": "object", "fields": [ { @@ -81,7 +81,7 @@ }, "cases": { "0x00": { - "layout": { + "customEncoding": { "type": "object", "fields": [ { @@ -129,7 +129,7 @@ } }, "0xff": { - "layout": { + "customEncoding": { "type": "object", "fields": [ { diff --git a/assets/erc-0000/example-safe-multisend.json b/assets/erc-0000/example-safe-multisend.json index 462cee3c456..2e50dd1bb31 100644 --- a/assets/erc-0000/example-safe-multisend.json +++ b/assets/erc-0000/example-safe-multisend.json @@ -27,7 +27,7 @@ { "path": "transactions", "label": "Batched calls", - "layout": { + "customEncoding": { "type": "sequence", "element": { "type": "object", diff --git a/assets/erc-0000/example-uniswap-v4-initialize.json b/assets/erc-0000/example-uniswap-v4-initialize.json index abc75ad1783..900e6744b3e 100644 --- a/assets/erc-0000/example-uniswap-v4-initialize.json +++ b/assets/erc-0000/example-uniswap-v4-initialize.json @@ -52,7 +52,7 @@ { "path": "key.hooks", "label": "Hook permissions", - "layout": { + "customEncoding": { "type": "bitfield", "bytes": 20, "fields": [ diff --git a/assets/erc-0000/example-universal-router.json b/assets/erc-0000/example-universal-router.json index 7bb0bcd0955..cf56fca3f11 100644 --- a/assets/erc-0000/example-universal-router.json +++ b/assets/erc-0000/example-universal-router.json @@ -35,7 +35,7 @@ { "path": "commands", "label": "Commands", - "layout": { + "customEncoding": { "type": "sequence", "element": { "type": "uint", diff --git a/assets/erc-0000/example-wormhole-token-bridge.json b/assets/erc-0000/example-wormhole-token-bridge.json index cbc1d85e039..17b197d719c 100644 --- a/assets/erc-0000/example-wormhole-token-bridge.json +++ b/assets/erc-0000/example-wormhole-token-bridge.json @@ -27,7 +27,7 @@ { "path": "encodedVm", "label": "Signed message (VAA)", - "layout": { + "customEncoding": { "type": "object", "fields": [ { @@ -95,7 +95,7 @@ "expression": { "type": "uint", "bytes": 1 }, "cases": { "0x01": { - "layout": { + "customEncoding": { "type": "object", "fields": [ { From e0a58f11334cc5befb559b517fec9e3ce58b6977 Mon Sep 17 00:00:00 2001 From: Alex Forshtat Date: Fri, 7 Aug 2026 13:43:17 +0200 Subject: [PATCH 23/23] Add $fallback keyword and top-level switch selector --- ERCS/erc-0000.md | 49 ++++++++++++++++++- .../erc7730-non-abi-dispatch.schema.json | 31 +++++++++++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/ERCS/erc-0000.md b/ERCS/erc-0000.md index 3ef62e9d73b..49fc55c7ba1 100644 --- a/ERCS/erc-0000.md +++ b/ERCS/erc-0000.md @@ -299,7 +299,54 @@ This is an equivalent of `calldata` format from ERC-7730 for contracts that perf A wallet MUST resolve the matched target's own `intent`/`interpolatedIntent`/`fields` using the bound `args` values in place of that target's own decoded parameters, applying the same unknown-selector fallback if `to`'s descriptor has no entry matching `signature`. -A [structured data format specification](./erc-7730.md) MAY declare a top-level `switch` in place of `intent`/`fields`, redirecting the entire call to a different, unrelated function via `interaction` based on one of its own decoded parameters. +A [structured data format specification](./erc-7730.md) MAY declare a top-level `switch` in place of `intent`/`fields`, redirecting the entire call to a different function via a combination of `$fallback` and `interaction` keywords based on one of its own decoded parameters. + +### `$fallback` + +Contracts may have additional or completely alternative dispatch logic that does not follow the 4-byte selector entry point at all. +Calls to such contracts reach a raw fallback function that inspects the calldata internally. +`display.formats` MAY use the reserved key `"$fallback"` in this case, containing a [structured data format specification](./erc-7730.md#structured-data-format-specification). + +The `"$fallback"` is selected whenever calldata does not correspond to any selector entry in the file. +Wallets MUST prefer a matching selector entry over `$fallback` when a selector and a fallback are both present. + +If the fallback itself dispatches the execution, i.e. based on a leading tag bytes, descriptors may use a top-level `switch` whose `expression` reads that tag straight off `data`. + +Additionally, we define a container-level value `@.data` returning complete, raw calldata bytes of the transaction for use in such scenarios. Descriptors may use the [path slice](./erc-7730.md#path-slices) syntax on `@.data` to extract the top-level switch logic expression. + +```json +{ + "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-non-abi-dispatch.schema.json", + "context": { + "$id": "Fallback Dispatcher Contract" + }, + "display": { + "formats": { + "$fallback": { + "switch": { + "expression": { "path": "@.data.[0:3]" }, + "cases": { + "0x112233": { + "intent": "Do the thing", + "fields": [ + { + "path": "@.data", + "customEncoding": { + "type": "object", + "fields": [ + { "name": "tag", "schema": { "type": "uint", "bytes": 3 } }, + { "name": "amount", "schema": { "type": "uint", "bytes": 32 } } + ]}} + ] + }, + "$default": "reject" + } + } + } + } + } +} +``` ### `$index` diff --git a/assets/erc-0000/erc7730-non-abi-dispatch.schema.json b/assets/erc-0000/erc7730-non-abi-dispatch.schema.json index 5ff95311dac..03e2a1c81d3 100644 --- a/assets/erc-0000/erc7730-non-abi-dispatch.schema.json +++ b/assets/erc-0000/erc7730-non-abi-dispatch.schema.json @@ -318,7 +318,14 @@ "description": "The list includes formatting info for each field of a structure. For contract bindings, entries are keyed by the full function signature with parameter names; for EIP712 bindings, entries are keyed by the string returned by EIP 712 encodeType on the primary type.", "type": "object", "propertyNames": { - "pattern": "^\\s*[A-Za-z_][A-Za-z0-9_:]*\\s*\\(.*\\)$" + "anyOf": [ + { + "pattern": "^\\s*[A-Za-z_][A-Za-z0-9_:]*\\s*\\(.*\\)$" + }, + { + "const": "$fallback" + } + ] }, "additionalProperties": { "oneOf": [ @@ -1798,6 +1805,9 @@ "path" ], "additionalProperties": false + }, + { + "$ref": "#/$customEncoding/node" } ] }, @@ -1831,6 +1841,25 @@ "interaction" ], "additionalProperties": false + }, + { + "title": "A case that directly decodes and displays its own data, same shape as an ordinary format entry", + "type": "object", + "properties": { + "intent": { + "$ref": "#/$display/intent" + }, + "interpolatedIntent": { + "$ref": "#/$display/interpolatedIntent" + }, + "fields": { + "$ref": "#/$display/fields" + } + }, + "required": [ + "fields" + ], + "additionalProperties": false } ] }